mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:20:15 -10:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b9e5c58d4 | ||
|
|
90e336761d | ||
|
|
73be53b6d9 | ||
|
|
2760c91bde | ||
|
|
a5fb380960 | ||
|
|
8dedf5e73b | ||
|
|
75c4ca0be8 | ||
|
|
7856fabbfe | ||
|
|
4a90f124c6 | ||
|
|
bb4e2db47f | ||
|
|
fec391b9c1 | ||
|
|
43af99d3ec | ||
|
|
3e88fa1f39 | ||
|
|
6b89984863 | ||
|
|
a083571537 | ||
|
|
d3cb7c14ca | ||
|
|
0dfdbbc323 | ||
|
|
682fee633d | ||
|
|
dce57ca83a | ||
|
|
b674d0f2e8 | ||
|
|
001381459e | ||
|
|
38ac62f1a4 | ||
|
|
29c54c243a |
No files matched your search
Generated
+32
-3
@@ -1496,7 +1496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3001,7 +3001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"hashbrown 0.17.1",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4504,6 +4504,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8362,7 +8363,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"hashbrown 0.17.1",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10305,6 +10306,12 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.6.1"
|
||||
@@ -10825,6 +10832,28 @@ dependencies = [
|
||||
"wasmtime",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wast"
|
||||
version = "258.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b912cfe5696dfaae736e344fc82082dbd63609abbaeca3926466c0b10d9fecc"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"leb128fmt",
|
||||
"memchr",
|
||||
"unicode-width",
|
||||
"wasm-encoder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wat"
|
||||
version = "1.258.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3069154948f5737a8bcd5c55dcdf2c2b59ca776057732e49821167bde268ec6e"
|
||||
dependencies = [
|
||||
"wast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
|
||||
@@ -76,6 +76,8 @@ pub struct RegistryAgent {
|
||||
pub installed: Option<String>,
|
||||
/// Whether [`Self::version`] is newer than the installed one.
|
||||
pub update: bool,
|
||||
/// An install of it is running (it goes on after the Settings page that started it closes).
|
||||
pub installing: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Type)]
|
||||
@@ -332,6 +334,7 @@ fn visible(
|
||||
distribution,
|
||||
no_checksum,
|
||||
installed,
|
||||
installing: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn plan(root: &Path, model: &BuildModel, module: &Path, cache: &Path) -> Ide
|
||||
kind: "maven",
|
||||
});
|
||||
}
|
||||
if let Some(g) = &model.gradle
|
||||
if let Some(g) = model.gradle_for_module(root, &module)
|
||||
&& let Some(p) = g.projects.iter().find(|p| root.join(&p.dir) == module)
|
||||
{
|
||||
let script = cache.join("resources.init.gradle");
|
||||
@@ -163,11 +163,11 @@ mod tests {
|
||||
assert!(maven.args.contains(&"-Dexpression=project.build.outputDirectory".into()));
|
||||
assert_eq!(maven.env["JAVA_HOME"], "/jdk");
|
||||
model.maven = None;
|
||||
model.gradle = Some(crate::GradleModel {
|
||||
model.gradle = vec![crate::GradleModel {
|
||||
command: "/tools/gradlew".into(),
|
||||
projects: vec![crate::GradleProject { path: ":app".into(), dir: "app".into(), ..Default::default() }],
|
||||
..Default::default()
|
||||
});
|
||||
}];
|
||||
let gradle = plan(&root, &model, Path::new("app"), &cache).unwrap();
|
||||
assert_eq!(gradle.command, "/tools/gradlew");
|
||||
assert_eq!(gradle.args.last().unwrap(), ":app:openideaUpdateResources");
|
||||
|
||||
@@ -147,7 +147,9 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|
||||
let rel = rel_str(root, dir);
|
||||
if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| p.dir == rel)) || has(dir, &["pom.xml"]) {
|
||||
Some(BuildSystem::Maven)
|
||||
} else if has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]) {
|
||||
} else if model.gradle_for_module(root, dir).is_some()
|
||||
|| has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])
|
||||
{
|
||||
Some(BuildSystem::Gradle)
|
||||
} else if has(dir, &["Cargo.toml"]) {
|
||||
Some(BuildSystem::Cargo)
|
||||
@@ -162,16 +164,13 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|
||||
}
|
||||
}
|
||||
|
||||
/// `root/rel`, without a trailing separator for the root itself.
|
||||
/// `root` / `rel`, joined per `/`-separated component: on Windows a single join of
|
||||
/// `a/b` keeps the `/`, and such a path does not compare equal to the same directory
|
||||
/// as other tools (JDT LS) spell it.
|
||||
/// Resolves a workspace-relative or absolute directory with native path separators.
|
||||
fn subdir(root: &Path, rel: &str) -> PathBuf {
|
||||
rel.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
|
||||
root.join(rel).components().collect()
|
||||
}
|
||||
|
||||
fn rel_str(root: &Path, dir: &Path) -> String {
|
||||
dir.strip_prefix(root).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default()
|
||||
dir.strip_prefix(root).unwrap_or(dir).to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
/// Directories from `file`'s folder up to `root` (inclusive).
|
||||
@@ -192,7 +191,14 @@ pub fn plan(
|
||||
let project_name = root.file_name().map_or_else(|| "project".to_owned(), |n| n.to_string_lossy().into_owned());
|
||||
let module_dir = match file {
|
||||
Some(f) => {
|
||||
let dir = ancestors_within(root, f)
|
||||
let scope = model
|
||||
.gradle
|
||||
.iter()
|
||||
.flat_map(|g| std::iter::once(root.join(&g.dir)).chain(g.projects.iter().map(|p| root.join(&p.dir))))
|
||||
.filter(|dir| f.starts_with(dir))
|
||||
.max_by_key(|dir| dir.components().count())
|
||||
.unwrap_or_else(|| root.to_path_buf());
|
||||
let dir = ancestors_within(&scope, f)
|
||||
.find(|d| detect_dir(d, model, root).is_some())
|
||||
.ok_or_else(|| IdeError::not_found(format!("{} is not in a module", f.display())))?;
|
||||
Some(dir.to_path_buf())
|
||||
@@ -202,7 +208,7 @@ pub fn plan(
|
||||
let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf());
|
||||
// A folder of sibling projects has no build file itself; its Maven projects are built one by one.
|
||||
let system = detect_dir(&dir, model, root)
|
||||
.or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
|
||||
.or((!model.gradle.is_empty()).then_some(BuildSystem::Gradle))
|
||||
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
|
||||
.ok_or_else(|| {
|
||||
IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript, CMake) found")
|
||||
@@ -270,14 +276,16 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
|
||||
}
|
||||
|
||||
fn gradle_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult<Vec<BuildStep>> {
|
||||
let fallback;
|
||||
let g = match &model.gradle {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
fallback = crate::gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build found"))?;
|
||||
&fallback
|
||||
}
|
||||
};
|
||||
if module.is_none() && !model.gradle.is_empty() {
|
||||
return Ok(model.gradle.iter().flat_map(|g| gradle_step(root, g, kind, None)).collect());
|
||||
}
|
||||
let g = module
|
||||
.and_then(|m| model.gradle_for_module(root, m))
|
||||
.ok_or_else(|| IdeError::not_found("module does not belong to a linked Gradle build"))?;
|
||||
Ok(gradle_step(root, g, kind, module))
|
||||
}
|
||||
|
||||
fn gradle_step(root: &Path, g: &crate::GradleModel, kind: BuildKind, module: Option<&Path>) -> Vec<BuildStep> {
|
||||
let prefix = module
|
||||
.and_then(|m| {
|
||||
let rel = rel_str(root, m);
|
||||
@@ -296,7 +304,7 @@ fn gradle_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option
|
||||
let mut s = BuildStep::new(&g.command, &["--console=plain"], &subdir(root, &g.dir), Format::Gradle);
|
||||
s.args.extend(tasks);
|
||||
s.env = g.env.clone();
|
||||
Ok(vec![s])
|
||||
vec![s]
|
||||
}
|
||||
|
||||
fn cargo_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
@@ -811,6 +819,77 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn independent_gradle_builds_keep_their_launchers_and_task_paths() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir_all(root.join(dir).join("app/src/main/java")).unwrap();
|
||||
std::fs::write(root.join(dir).join("settings.gradle"), "include 'app'").unwrap();
|
||||
std::fs::write(root.join(dir).join("app/build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
std::fs::write(root.join(dir).join("gradlew"), "").unwrap();
|
||||
}
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut model = crate::load_linked(root, &root.join("cache"), None, &Default::default(), &settings);
|
||||
model.gradle[0].env.insert("JAVA_HOME".into(), "/jdk-a".into());
|
||||
model.gradle[1].env.insert("JAVA_HOME".into(), "/jdk-b".into());
|
||||
let all = super::plan(root, &model, super::BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(all.steps.len(), 2);
|
||||
assert_eq!(all.steps[0].cwd, root.join("a"));
|
||||
assert_eq!(all.steps[1].cwd, root.join("b"));
|
||||
let module = super::plan(root, &model, super::BuildKind::Rebuild, Some(&root.join("b/app")), None).unwrap();
|
||||
assert_eq!(module.steps.len(), 1);
|
||||
assert_eq!(module.steps[0].program, root.join("b/gradlew").to_string_lossy());
|
||||
assert_eq!(module.steps[0].cwd, root.join("b"));
|
||||
assert_eq!(module.steps[0].env["JAVA_HOME"], "/jdk-b");
|
||||
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
|
||||
// A directly linked build owns its module even when another composite reports it too.
|
||||
let direct = model.gradle[1].projects[0].clone();
|
||||
model.gradle[0].projects.push(direct);
|
||||
assert_eq!(model.gradle_for_module(root, &root.join("b")).unwrap().dir, "b");
|
||||
let mut nested = model.gradle[1].projects[1].clone();
|
||||
nested.path = ":b:app".into();
|
||||
model.gradle[0].projects.push(nested);
|
||||
let nested = super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).unwrap();
|
||||
assert_eq!(nested.steps[0].program, root.join("b/gradlew").to_string_lossy());
|
||||
assert_eq!(nested.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
|
||||
model.gradle.clear();
|
||||
assert!(super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_linked_roots_build_the_project_and_external_source_module() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("workspace");
|
||||
let external = base.join("external");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::create_dir_all(external.join("app/src/main/java")).unwrap();
|
||||
std::fs::write(external.join("settings.gradle"), "include 'app'").unwrap();
|
||||
let source = external.join("app/src/main/java/App.java");
|
||||
std::fs::write(&source, "class App {}").unwrap();
|
||||
std::fs::write(external.join("gradlew"), "").unwrap();
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec![external.to_string_lossy().into_owned()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut model = crate::load_linked(&root, &base.join("cache"), None, &Default::default(), &settings);
|
||||
model.gradle[0].env.insert("JAVA_HOME".into(), "/external-jdk".into());
|
||||
let all = super::plan(&root, &model, super::BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(all.steps[0].cwd, external);
|
||||
assert_eq!(all.steps[0].program, external.join("gradlew").to_string_lossy());
|
||||
let module = super::plan(&root, &model, super::BuildKind::Rebuild, Some(&source), None).unwrap();
|
||||
assert_eq!(module.steps[0].cwd, external);
|
||||
assert_eq!(module.steps[0].env["JAVA_HOME"], "/external-jdk");
|
||||
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
|
||||
let other = base.join("unlinked");
|
||||
std::fs::create_dir(&other).unwrap();
|
||||
std::fs::write(other.join("build.gradle"), "").unwrap();
|
||||
assert!(super::plan(&root, &model, super::BuildKind::Build, Some(&other), None).is_err());
|
||||
}
|
||||
use super::*;
|
||||
|
||||
/// Launch outputs use the platform separator throughout, so they compare equal to
|
||||
|
||||
@@ -435,6 +435,15 @@ pub fn external_roots<'a>(root: &Path, dirs: impl IntoIterator<Item = &'a str>)
|
||||
|
||||
pub fn load(root: &Path) -> Option<GradleModel> {
|
||||
let dir = build_dir(root)?;
|
||||
load_build(root, dir.strip_prefix(root).unwrap_or(&dir))
|
||||
}
|
||||
|
||||
/// Loads exactly this build, without searching its child folders.
|
||||
pub fn load_build(root: &Path, dir: &Path) -> Option<GradleModel> {
|
||||
let dir = root.join(dir);
|
||||
if !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| dir.join(name).is_file()) {
|
||||
return None;
|
||||
}
|
||||
let settings = read_first(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default();
|
||||
let root_name =
|
||||
settings.name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
|
||||
@@ -455,6 +464,56 @@ pub fn load(root: &Path) -> Option<GradleModel> {
|
||||
Some(GradleModel { dir: rel(root, &dir), projects, java_version, command: wrapper(&dir), ..GradleModel::default() })
|
||||
}
|
||||
|
||||
/// Validates user-selected build folders and stores a stable, deduplicated identity.
|
||||
pub fn normalize_linked_projects(root: &Path, dirs: &[String]) -> ide_core::IdeResult<Vec<String>> {
|
||||
let root = dunce::canonicalize(root)?;
|
||||
let mut out = Vec::new();
|
||||
for dir in dirs {
|
||||
let path = dunce::canonicalize(root.join(dir))?;
|
||||
if !path.is_dir() || !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| path.join(name).is_file()) {
|
||||
return Err(ide_core::IdeError::invalid(format!("No Gradle build in {}", path.display())));
|
||||
}
|
||||
let dir = rel(&root, &path);
|
||||
if !out.contains(&dir) {
|
||||
out.push(dir);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Linked build identities without reading their scripts (also used by index hot paths).
|
||||
pub(crate) fn linked_dirs(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec<String> {
|
||||
match &settings.linked_projects {
|
||||
None => build_dir(root).map(|dir| rel(root, &dir)).into_iter().collect(),
|
||||
Some(dirs) => {
|
||||
let mut out = Vec::new();
|
||||
for dir in dirs {
|
||||
if let Ok(dirs) = normalize_linked_projects(root, std::slice::from_ref(dir)) {
|
||||
for dir in dirs {
|
||||
if !out.contains(&dir) {
|
||||
out.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_linked(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec<GradleModel> {
|
||||
linked_dirs(root, settings).iter().filter_map(|dir| load_build(root, Path::new(dir))).collect()
|
||||
}
|
||||
|
||||
/// A build's task paths are local to it, so sync caches must be local to its identity too.
|
||||
pub fn cache_dir(cache: &Path, build_dir: &str) -> PathBuf {
|
||||
if build_dir.is_empty() {
|
||||
return cache.to_path_buf();
|
||||
}
|
||||
let hash = build_dir.bytes().fold(0xcbf29ce484222325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100000001b3));
|
||||
cache.join("gradle").join(format!("{hash:016x}"))
|
||||
}
|
||||
|
||||
/// `JavaLanguageVersion.of(21)` / `sourceCompatibility = JavaVersion.VERSION_17` in a build script.
|
||||
pub fn script_java_version(text: &str) -> Option<u32> {
|
||||
let number = |s: &str| -> Option<u32> {
|
||||
@@ -616,6 +675,41 @@ pub(crate) fn has_war_plugin(script: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn explicit_linkage_preserves_default_discovery_and_can_unlink_every_build() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join("settings.gradle"), format!("rootProject.name = '{dir}'")).unwrap();
|
||||
}
|
||||
assert_eq!(super::load_linked(root, &Default::default())[0].dir, "a");
|
||||
let mut settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["b".into(), "./b".into(), "a".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let models = super::load_linked(root, &settings);
|
||||
assert_eq!(models.iter().map(|g| g.dir.as_str()).collect::<Vec<_>>(), ["b", "a"]);
|
||||
settings.linked_projects = Some(vec![]);
|
||||
assert!(super::load_linked(root, &settings).is_empty());
|
||||
assert!(super::normalize_linked_projects(root, &["missing".into()]).is_err());
|
||||
assert!(super::normalize_linked_projects(root, &["".into()]).is_err());
|
||||
assert!(super::load_build(root, root).is_none(), "exact loading never picks a child");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn linked_directories_deduplicate_symlinks_and_keep_external_builds() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("workspace");
|
||||
let external = temp.path().join("external");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::create_dir(&external).unwrap();
|
||||
std::fs::write(external.join("build.gradle"), "").unwrap();
|
||||
std::os::unix::fs::symlink(&external, root.join("alias")).unwrap();
|
||||
let dirs = super::normalize_linked_projects(&root, &["alias".into(), "../external".into()]).unwrap();
|
||||
assert_eq!(dirs, [external.canonicalize().unwrap().to_string_lossy().to_string()]);
|
||||
}
|
||||
#[test]
|
||||
fn script_dependencies_read_string_and_map_notations() {
|
||||
let script = r#"
|
||||
|
||||
@@ -126,6 +126,7 @@ fn project_path(text: &str) -> String {
|
||||
/// `configuration` and returns its tree.
|
||||
pub fn gradle_dependencies(
|
||||
root: &Path,
|
||||
model: &gradle::GradleModel,
|
||||
options: &SyncOptions,
|
||||
project: &str,
|
||||
configuration: &str,
|
||||
@@ -137,7 +138,6 @@ pub fn gradle_dependencies(
|
||||
if !project.starts_with(':') || project.chars().any(char::is_whitespace) {
|
||||
return Err(IdeError::invalid(format!("not a Gradle project path: {project}")));
|
||||
}
|
||||
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
|
||||
let task = if project == ":" { ":dependencies".to_owned() } else { format!("{project}:dependencies") };
|
||||
let mut args: Vec<String> =
|
||||
vec!["--console=plain".into(), "-q".into(), task, "--configuration".into(), configuration.into()];
|
||||
@@ -226,8 +226,12 @@ runtimeClasspath - Runtime classpath of source set 'main'.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let options = SyncOptions::default();
|
||||
let cancel = CancellationToken::new();
|
||||
assert!(gradle_dependencies(tmp.path(), &options, ":", "x --init-script y", &cancel).is_err());
|
||||
assert!(gradle_dependencies(tmp.path(), &options, "app", "runtimeClasspath", &cancel).is_err());
|
||||
assert!(
|
||||
gradle_dependencies(tmp.path(), &Default::default(), &options, ":", "x --init-script y", &cancel).is_err()
|
||||
);
|
||||
assert!(
|
||||
gradle_dependencies(tmp.path(), &Default::default(), &options, "app", "runtimeClasspath", &cancel).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -246,8 +250,15 @@ runtimeClasspath - Runtime classpath of source set 'main'.
|
||||
offline: true,
|
||||
..Default::default()
|
||||
};
|
||||
let tree =
|
||||
gradle_dependencies(root, &options, ":app", "testRuntimeClasspath", &CancellationToken::new()).unwrap();
|
||||
let tree = gradle_dependencies(
|
||||
root,
|
||||
&gradle::load(root).unwrap(),
|
||||
&options,
|
||||
":app",
|
||||
"testRuntimeClasspath",
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tree, vec![dep("org.a", "b", Some("1.0"))]);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("args")).unwrap().trim(),
|
||||
|
||||
+85
-16
@@ -20,7 +20,9 @@ mod xml;
|
||||
pub use gradle::{GradleModel, GradleProject, GradleTask};
|
||||
pub use gradle_deps::{GradleDependency, gradle_dependencies};
|
||||
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission};
|
||||
pub use sync::{SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_maven};
|
||||
pub use sync::{
|
||||
SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_gradle_model, sync_maven,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
@@ -38,13 +40,31 @@ pub enum BuildTool {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildModel {
|
||||
pub maven: Option<MavenModel>,
|
||||
pub gradle: Option<GradleModel>,
|
||||
#[serde(default)]
|
||||
pub gradle: Vec<GradleModel>,
|
||||
}
|
||||
|
||||
impl BuildModel {
|
||||
/// Prefer the deepest linked build owning the module over a composite including it.
|
||||
pub fn gradle_for_module(&self, root: &Path, module: &Path) -> Option<&GradleModel> {
|
||||
let module = root.join(module);
|
||||
self.gradle.iter().filter(|g| g.projects.iter().any(|p| root.join(&p.dir) == module)).min_by_key(|g| {
|
||||
let dir = root.join(&g.dir);
|
||||
std::cmp::Reverse(if module.starts_with(&dir) { dir.components().count() } else { 0 })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Static model of the builds under `root`, merged with the sync results cached in `cache_dir`.
|
||||
/// Parent POMs are looked up in `maven_repo` (the configured local repository), else the default one.
|
||||
pub fn load(root: &Path, cache_dir: &Path, maven_repo: Option<PathBuf>) -> BuildModel {
|
||||
load_linked(root, cache_dir, maven_repo, &ide_core::settings::MavenSettings::default())
|
||||
load_linked(
|
||||
root,
|
||||
cache_dir,
|
||||
maven_repo,
|
||||
&ide_core::settings::MavenSettings::default(),
|
||||
&ide_core::settings::GradleSettings::default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Loads only linked Maven reactors, retaining an empty model so projects can be re-added.
|
||||
@@ -53,31 +73,60 @@ pub fn load_linked(
|
||||
cache_dir: &Path,
|
||||
maven_repo: Option<PathBuf>,
|
||||
settings: &ide_core::settings::MavenSettings,
|
||||
gradle_settings: &ide_core::settings::GradleSettings,
|
||||
) -> BuildModel {
|
||||
let maven = maven::is_maven_project(root).then(|| {
|
||||
let maven = maven::has_projects(root, settings).then(|| {
|
||||
let profiles = sync::maven_profiles(cache_dir);
|
||||
let mut m = maven::load_with(root, maven_repo.or_else(maven::default_local_repo), &profiles);
|
||||
let repo = maven_repo.or_else(maven::default_local_repo);
|
||||
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
|
||||
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
|
||||
sync::apply_maven_cache(root, cache_dir, &mut m);
|
||||
m
|
||||
});
|
||||
let gradle = gradle::load(root).map(|mut g| {
|
||||
sync::apply_gradle_cache(root, cache_dir, &mut g);
|
||||
g
|
||||
});
|
||||
let gradle = gradle::load_linked(root, gradle_settings)
|
||||
.into_iter()
|
||||
.map(|mut g| {
|
||||
sync::apply_gradle_cache(root, &gradle::cache_dir(cache_dir, &g.dir), &mut g);
|
||||
g
|
||||
})
|
||||
.collect();
|
||||
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()))
|
||||
pub fn gradle_external_roots(
|
||||
root: &Path,
|
||||
cache_dir: &Path,
|
||||
settings: &ide_core::settings::GradleSettings,
|
||||
) -> Vec<PathBuf> {
|
||||
let builds = gradle::linked_dirs(root, settings);
|
||||
let dirs: Vec<String> = builds
|
||||
.iter()
|
||||
.flat_map(|dir| {
|
||||
std::iter::once(root.join(dir).to_string_lossy().into_owned()).chain(
|
||||
sync::reported_gradle_projects(&gradle::cache_dir(cache_dir, dir))
|
||||
.iter()
|
||||
.map(|p| p.dir.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
gradle::external_roots(root, dirs.iter().map(String::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))
|
||||
pub fn gradle_source_roots(
|
||||
root: &Path,
|
||||
cache_dir: &Path,
|
||||
settings: &ide_core::settings::GradleSettings,
|
||||
) -> Vec<(PathBuf, gradle::ReportedRoot)> {
|
||||
let reported = gradle::linked_dirs(root, settings)
|
||||
.iter()
|
||||
.flat_map(|dir| sync::reported_gradle_projects(&gradle::cache_dir(cache_dir, dir)).as_ref().clone())
|
||||
.collect::<Vec<_>>();
|
||||
gradle::reported_source_roots(&reported)
|
||||
}
|
||||
|
||||
/// A library the project depends on, as `groupId:artifactId:version`.
|
||||
@@ -121,7 +170,7 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(g) = &model.gradle {
|
||||
for g in &model.gradle {
|
||||
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
|
||||
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
|
||||
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
|
||||
@@ -147,6 +196,26 @@ pub fn affects_build(rel: &Path) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn external_linked_build_is_indexed_before_sync_and_disappears_when_unlinked() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("workspace");
|
||||
let external = temp.path().join("external");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::create_dir(&external).unwrap();
|
||||
std::fs::write(external.join("settings.gradle"), "").unwrap();
|
||||
let mut settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec![external.to_string_lossy().into_owned()]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
gradle_external_roots(&root, &temp.path().join("cache"), &settings),
|
||||
[external.canonicalize().unwrap()]
|
||||
);
|
||||
settings.linked_projects = Some(vec![]);
|
||||
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_build_systems_and_build_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -158,7 +227,7 @@ mod tests {
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
let m = load(&root, &cache, None);
|
||||
assert_eq!(m.maven.unwrap().projects[0].pom, "svc/pom.xml");
|
||||
assert_eq!(m.gradle.unwrap().projects[0].path, ":");
|
||||
assert_eq!(m.gradle[0].projects[0].path, ":");
|
||||
assert!(affects_build(Path::new("a/pom.xml")) && affects_build(Path::new("settings.gradle.kts")));
|
||||
assert!(!affects_build(Path::new("src/Main.java")));
|
||||
}
|
||||
@@ -205,7 +274,7 @@ mod tests {
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let gradle = gradle::load(root);
|
||||
let gradle = gradle::load(root).into_iter().collect();
|
||||
let model = BuildModel { maven: Some(maven), gradle };
|
||||
let names: Vec<String> = dependency_coordinates(root, &model)
|
||||
.into_iter()
|
||||
|
||||
@@ -1065,10 +1065,9 @@ pub fn load(root: &Path) -> MavenModel {
|
||||
load_with_repo(root, default_local_repo())
|
||||
}
|
||||
|
||||
/// [`load`] with the chosen `profiles` (`-P` entries; `!id` deactivates): their modules,
|
||||
/// dependencies and properties are part of the model, those turned off are not.
|
||||
pub fn load_with_profiles(root: &Path, profiles: &[String]) -> MavenModel {
|
||||
load_with(root, default_local_repo(), profiles)
|
||||
/// [`load`] with the POMs `settings` link (Add Maven Projects).
|
||||
pub fn load_linked(root: &Path, settings: &ide_core::settings::MavenSettings) -> MavenModel {
|
||||
load_with_linked(root, default_local_repo(), &[], &settings.linked_projects)
|
||||
}
|
||||
|
||||
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
|
||||
@@ -1090,11 +1089,53 @@ fn profile_choice(chosen: &[String], id: &str) -> Option<bool> {
|
||||
}
|
||||
|
||||
pub(crate) fn load_with(root: &Path, local_repo: Option<PathBuf>, profiles: &[String]) -> MavenModel {
|
||||
load_with_linked(root, local_repo, profiles, &[])
|
||||
}
|
||||
|
||||
/// The POMs of `linked` (project-relative, from Settings) that exist inside `root`.
|
||||
fn linked_poms(root: &Path, linked: &[String]) -> Vec<PathBuf> {
|
||||
let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for pom in linked {
|
||||
let pom = normalized_pom(pom);
|
||||
if pom.is_empty() || pom.starts_with("../") || pom == ".." {
|
||||
continue;
|
||||
}
|
||||
let path = root.join(&pom);
|
||||
let inside = dunce::canonicalize(&path).is_ok_and(|p| p.starts_with(&base));
|
||||
if path.is_file() && inside && !out.contains(&path) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `root` holds a Maven build or the settings link one inside it.
|
||||
pub fn has_projects(root: &Path, settings: &ide_core::settings::MavenSettings) -> bool {
|
||||
is_maven_project(root) || !linked_poms(root, &settings.linked_projects).is_empty()
|
||||
}
|
||||
|
||||
/// The model with the chosen `profiles` (`-P` entries; `!id` deactivates: their modules,
|
||||
/// dependencies and properties are part of the model, those turned off are not) and the
|
||||
/// `linked` POMs (Add Maven Projects): builds that no reactor lists, shown as independent
|
||||
/// top-level projects. A linked POM that turns out to be a module of the root reactor stays that module.
|
||||
pub fn load_with_linked(
|
||||
root: &Path,
|
||||
local_repo: Option<PathBuf>,
|
||||
profiles: &[String],
|
||||
linked: &[String],
|
||||
) -> MavenModel {
|
||||
let mut resolver = Resolver::new(local_repo, profiles);
|
||||
let mut model = MavenModel::default();
|
||||
let mut seen: HashSet<PathBuf> = HashSet::new();
|
||||
// (pom, aggregated)
|
||||
let mut stack: Vec<(PathBuf, bool)> = top_level_poms(root).into_iter().rev().map(|p| (p, false)).collect();
|
||||
let mut tops = top_level_poms(root);
|
||||
for pom in linked_poms(root, linked) {
|
||||
if !tops.contains(&pom) {
|
||||
tops.push(pom);
|
||||
}
|
||||
}
|
||||
// (pom, aggregated); the root reactor and its modules come before linked POMs.
|
||||
let mut stack: Vec<(PathBuf, bool)> = tops.into_iter().rev().map(|p| (p, false)).collect();
|
||||
while let Some((path, aggregated)) = stack.pop() {
|
||||
if model.projects.len() >= MAX_PROJECTS {
|
||||
break;
|
||||
@@ -1216,7 +1257,7 @@ impl UnlinkedProjects {
|
||||
if settings.unlinked_projects.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
Self::from_model(&load(root), settings)
|
||||
Self::from_model(&load_linked(root, settings), settings)
|
||||
}
|
||||
|
||||
pub fn from_model(model: &MavenModel, settings: &ide_core::settings::MavenSettings) -> Self {
|
||||
@@ -1733,6 +1774,49 @@ mod tests {
|
||||
assert!(model.projects.iter().all(|p| p.command == if cfg!(windows) { "mvn.cmd" } else { "mvn" }));
|
||||
}
|
||||
|
||||
/// Add Maven Projects: a POM inside the project that no reactor lists becomes an
|
||||
/// independent top-level project; one the root reactor lists stays its module.
|
||||
#[test]
|
||||
fn linked_poms_outside_the_reactor_are_top_level_projects() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
"<project><groupId>demo</groupId><artifactId>main</artifactId><version>1.0</version><packaging>pom</packaging><modules><module>core</module></modules></project>",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"core/pom.xml",
|
||||
"<project><groupId>demo</groupId><artifactId>core</artifactId><version>1.0</version></project>",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"stub/pom.xml",
|
||||
"<project><groupId>demo</groupId><artifactId>stub</artifactId><version>1.0</version></project>",
|
||||
);
|
||||
let top = |m: &MavenModel| m.projects.iter().map(|p| (p.dir.clone(), p.aggregated)).collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(top(&load_with_linked(root, None, &[], &[])), [("".into(), false), ("core".into(), true)]);
|
||||
let linked: Vec<String> =
|
||||
["stub/pom.xml", "./core/pom.xml", "../outside/pom.xml", "missing/pom.xml"].map(String::from).into();
|
||||
assert_eq!(
|
||||
top(&load_with_linked(root, None, &[], &linked)),
|
||||
[("".into(), false), ("core".into(), true), ("stub".into(), false)]
|
||||
);
|
||||
|
||||
// Deeper than the folder search reaches: only the link makes it a Maven project.
|
||||
let deep = tempfile::tempdir().unwrap();
|
||||
write(deep.path(), "a/b/c/tool/pom.xml", "<project><artifactId>tool</artifactId></project>");
|
||||
let settings = ide_core::settings::MavenSettings {
|
||||
linked_projects: vec!["a/b/c/tool/pom.xml".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!has_projects(deep.path(), &Default::default()));
|
||||
assert!(has_projects(deep.path(), &settings));
|
||||
assert_eq!(top(&load_linked(deep.path(), &settings)), [("a/b/c/tool".into(), false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chosen_profiles_bring_their_modules_and_dependencies() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -283,7 +283,12 @@ pub fn sync_maven(
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
let mut model = maven::load_with_profiles(root, &options.profiles);
|
||||
let mut model = maven::load_with_linked(
|
||||
root,
|
||||
maven::default_local_repo(),
|
||||
&options.profiles,
|
||||
&options.maven_settings.linked_projects,
|
||||
);
|
||||
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
let mut cache = MavenCache {
|
||||
@@ -373,8 +378,25 @@ pub fn sync_gradle(
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
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 model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
|
||||
sync_gradle_model(root, cache_dir, &model, options, cancel, progress)
|
||||
}
|
||||
|
||||
/// Syncs this exact linked build; another build's task paths and cache cannot interfere.
|
||||
pub fn sync_gradle_model(
|
||||
root: &Path,
|
||||
cache_dir: &Path,
|
||||
model: &GradleModel,
|
||||
options: &SyncOptions,
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut model = model.clone();
|
||||
let cache_dir = gradle::cache_dir(cache_dir, &model.dir);
|
||||
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![
|
||||
@@ -389,14 +411,24 @@ pub fn sync_gradle(
|
||||
args.push("--offline".into());
|
||||
}
|
||||
progress(SyncProgress { message: "Loading Gradle tasks".into(), fraction: None });
|
||||
let out = run(
|
||||
let out = match run(
|
||||
options.gradle_command.as_deref().unwrap_or(&model.command),
|
||||
&args,
|
||||
&root.join(&model.dir),
|
||||
&options.env,
|
||||
cancel,
|
||||
|_| {},
|
||||
)?;
|
||||
) {
|
||||
Ok(out) => out,
|
||||
Err(_) if cancel.is_cancelled() => return Err(IdeError::cancelled()),
|
||||
Err(error) if error.kind == ide_core::error::ErrorKind::Unavailable => {
|
||||
Output { success: false, text: error.message }
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
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);
|
||||
@@ -407,13 +439,128 @@ pub fn sync_gradle(
|
||||
tasks: if out.success { gradle::parse_tasks(&out.text) } else { Vec::new() },
|
||||
projects,
|
||||
};
|
||||
write_cache(cache_dir, GRADLE_CACHE, &cache)
|
||||
write_cache(&cache_dir, GRADLE_CACHE, &cache)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unavailable_gradle_launchers_are_cached_without_stopping_other_builds() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join("build.gradle"), "").unwrap();
|
||||
}
|
||||
let wrapper = root.join("b/gradlew");
|
||||
std::fs::write(&wrapper, "#!/bin/sh\nprintf '%s\\n' 'Build tasks' '-----------' 'healthy - ok'\n").unwrap();
|
||||
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut models = gradle::load_linked(root, &settings);
|
||||
models[0].command = root.join("a/missing-gradlew").to_string_lossy().into_owned();
|
||||
let cache = root.join("cache");
|
||||
let run = |model: &GradleModel| {
|
||||
sync_gradle_model(root, &cache, model, &SyncOptions::default(), &CancellationToken::new(), &|_| {})
|
||||
};
|
||||
for model in &models {
|
||||
run(model).unwrap();
|
||||
}
|
||||
let loaded = crate::load_linked(root, &cache, None, &Default::default(), &settings);
|
||||
assert!(loaded.gradle[0].sync_error.as_deref().unwrap().contains("cannot start"));
|
||||
assert!(loaded.gradle[1].sync_error.is_none());
|
||||
assert_eq!(loaded.gradle[1].projects[0].tasks[0].name, "healthy");
|
||||
let bad_wrapper = root.join("a/gradlew");
|
||||
std::fs::write(&bad_wrapper, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
std::fs::set_permissions(&bad_wrapper, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
models[0].command = bad_wrapper.to_string_lossy().into_owned();
|
||||
run(&models[0]).unwrap();
|
||||
let loaded = crate::load_linked(root, &cache, None, &Default::default(), &settings);
|
||||
assert!(loaded.gradle[0].sync_error.as_deref().unwrap().contains("cannot start"));
|
||||
assert!(loaded.gradle[1].sync_error.is_none());
|
||||
let blocked_cache = root.join("blocked-cache");
|
||||
std::fs::write(&blocked_cache, "a file cannot contain sync caches").unwrap();
|
||||
assert!(
|
||||
sync_gradle_model(
|
||||
root,
|
||||
&blocked_cache,
|
||||
&models[0],
|
||||
&SyncOptions::default(),
|
||||
&CancellationToken::new(),
|
||||
&|_| {}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn independent_syncs_do_not_share_tasks_launchers_environments_or_failures() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
let build = root.join(dir);
|
||||
std::fs::create_dir_all(build.join("app")).unwrap();
|
||||
std::fs::write(build.join("settings.gradle"), "include 'app'").unwrap();
|
||||
std::fs::write(build.join("app/build.gradle"), "").unwrap();
|
||||
let report = serde_json::json!({"path": ":app", "dir": build.join("app")});
|
||||
let launcher = build.join("gradlew");
|
||||
std::fs::write(
|
||||
&launcher,
|
||||
format!("#!/bin/sh\npwd > actual-cwd\nprintf '%s' \"$JAVA_HOME\" > actual-env\nprintf '%s\\n' '@openidea-model\t{report}' 'Build tasks' '-----------' 'app:custom{dir} - custom task'\n"),
|
||||
).unwrap();
|
||||
std::fs::set_permissions(launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let cache = root.join("cache");
|
||||
let models = gradle::load_linked(root, &settings);
|
||||
for model in &models {
|
||||
let options = SyncOptions {
|
||||
env: BTreeMap::from([("JAVA_HOME".into(), format!("/jdk-{}", model.dir))]),
|
||||
..Default::default()
|
||||
};
|
||||
sync_gradle_model(root, &cache, model, &options, &CancellationToken::new(), &|_| {}).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join(&model.dir).join("actual-env")).unwrap(),
|
||||
format!("/jdk-{}", model.dir)
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join(&model.dir).join("actual-cwd")).unwrap().trim(),
|
||||
root.join(&model.dir).canonicalize().unwrap().to_string_lossy()
|
||||
);
|
||||
}
|
||||
let load = || crate::load_linked(root, &cache, None, &Default::default(), &settings);
|
||||
let model = load();
|
||||
assert_eq!(model.gradle[0].projects[0].tasks[0].name, "customa");
|
||||
assert_eq!(model.gradle[1].projects[0].tasks[0].name, "customb");
|
||||
assert_ne!(gradle::cache_dir(&cache, "a"), gradle::cache_dir(&cache, "b"));
|
||||
let before = std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap();
|
||||
std::fs::write(root.join("b/gradlew"), "#!/bin/sh\nprintf 'FAILURE: broken b\\n'\nexit 1\n").unwrap();
|
||||
sync_gradle_model(root, &cache, &models[1], &SyncOptions::default(), &CancellationToken::new(), &|_| {})
|
||||
.unwrap();
|
||||
let model = load();
|
||||
assert!(model.gradle[0].sync_error.is_none());
|
||||
assert!(model.gradle[1].sync_error.is_some());
|
||||
assert_eq!(std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap(), before);
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert!(sync_gradle_model(root, &cache, &models[0], &SyncOptions::default(), &cancel, &|_| {}).is_err());
|
||||
assert_eq!(std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap(), before);
|
||||
let unlinked = ide_core::settings::GradleSettings { linked_projects: Some(vec![]), ..Default::default() };
|
||||
assert!(crate::load_linked(root, &cache, None, &Default::default(), &unlinked).gradle.is_empty());
|
||||
assert!(crate::gradle_source_roots(root, &cache, &unlinked).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn gradle_sync_uses_resolved_command_and_environment() {
|
||||
@@ -601,7 +748,7 @@ mod tests {
|
||||
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]);
|
||||
assert_eq!(crate::gradle_external_roots(&root, &cache_dir, &Default::default()), [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();
|
||||
|
||||
@@ -26,12 +26,12 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<WebArtifact> {
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(gradle) = &model.gradle {
|
||||
for gradle in &model.gradle {
|
||||
for p in &gradle.projects {
|
||||
let script = p.build_file.as_ref().and_then(|f| fs::read_to_string(root.join(f)).ok()).unwrap_or_default();
|
||||
if p.tasks.iter().any(|t| t.name == "war") || crate::gradle::has_war_plugin(&script) {
|
||||
out.push(WebArtifact {
|
||||
id: format!("gradle:{}", p.path),
|
||||
id: gradle_artifact_id(gradle, &p.path),
|
||||
name: p.name.clone(),
|
||||
module: p.dir.clone(),
|
||||
tool: "gradle".into(),
|
||||
@@ -42,6 +42,14 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<WebArtifact> {
|
||||
out
|
||||
}
|
||||
|
||||
fn gradle_artifact_id(build: &crate::GradleModel, path: &str) -> String {
|
||||
if build.dir.is_empty() {
|
||||
format!("gradle:{path}")
|
||||
} else {
|
||||
format!("gradle:{}", serde_json::to_string(&(&build.dir, path)).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WebBuild {
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
@@ -150,9 +158,14 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
|
||||
maven_project: Some((probe, output)),
|
||||
});
|
||||
}
|
||||
if let Some(path) = id.strip_prefix("gradle:") {
|
||||
let g = model.gradle.as_ref().ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
|
||||
if !artifacts(root, model).iter().any(|a| a.id == id) {
|
||||
if let Some(legacy_path) = id.strip_prefix("gradle:") {
|
||||
let identity = serde_json::from_str::<(String, String)>(legacy_path).ok();
|
||||
let (g, path) = match &identity {
|
||||
Some((dir, path)) => (model.gradle.iter().find(|g| g.dir == *dir), path.as_str()),
|
||||
None => (model.gradle.first(), legacy_path),
|
||||
};
|
||||
let g = g.ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
|
||||
if !artifacts(root, model).iter().any(|a| a.id == gradle_artifact_id(g, path)) {
|
||||
return Err(IdeError::invalid("Choose a Gradle WAR module"));
|
||||
}
|
||||
let script = cache.join("war.init.gradle");
|
||||
@@ -186,6 +199,28 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn independent_war_modules_have_distinct_ids_and_build_in_their_own_directory() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join("build.gradle"), "plugins { id 'war' }").unwrap();
|
||||
}
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let model = crate::load_linked(root, &root.join("cache"), None, &Default::default(), &settings);
|
||||
let artifacts = super::artifacts(root, &model);
|
||||
assert_eq!(artifacts.len(), 2);
|
||||
assert_ne!(artifacts[0].id, artifacts[1].id);
|
||||
for (artifact, dir) in artifacts.iter().zip(["a", "b"]) {
|
||||
let plan = super::build(root, &model, &artifact.id, &root.join(format!("cache/{dir}"))).unwrap();
|
||||
assert_eq!(plan.cwd, dir);
|
||||
}
|
||||
assert_eq!(super::build(root, &model, "gradle::", &root.join("legacy")).unwrap().cwd, "a");
|
||||
}
|
||||
use super::*;
|
||||
#[test]
|
||||
fn maven_war_modules_use_resolved_artifact_file() {
|
||||
|
||||
@@ -828,6 +828,8 @@ pub struct JavaSettings {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct GradleSettings {
|
||||
/// Linked build directories; absent preserves automatic discovery, empty unlinks all builds.
|
||||
pub linked_projects: Option<Vec<String>>,
|
||||
/// Empty = Project JDK; otherwise a JDK home.
|
||||
pub jvm: String,
|
||||
pub distribution: GradleDistribution,
|
||||
@@ -864,6 +866,8 @@ pub enum GradleDistribution {
|
||||
pub struct MavenSettings {
|
||||
/// Project-relative root POMs removed from the Maven project model.
|
||||
pub unlinked_projects: Vec<String>,
|
||||
/// Project-relative POMs outside the root reactor added as independent top-level projects.
|
||||
pub linked_projects: Vec<String>,
|
||||
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
|
||||
pub home: String,
|
||||
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
|
||||
@@ -1124,6 +1128,16 @@ mod maven_linkage_tests {
|
||||
mod gradle_settings_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn absent_and_explicitly_empty_gradle_links_remain_distinct() {
|
||||
let automatic: GradleSettings = serde_json::from_str("{}").unwrap();
|
||||
let unlinked: GradleSettings = serde_json::from_str(r#"{"linkedProjects":[]}"#).unwrap();
|
||||
assert_eq!(automatic.linked_projects, None);
|
||||
assert_eq!(unlinked.linked_projects, Some(vec![]));
|
||||
let restored: GradleSettings = serde_json::from_str(&serde_json::to_string(&unlinked).unwrap()).unwrap();
|
||||
assert_eq!(restored.linked_projects, Some(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_and_round_trip() {
|
||||
let old: JavaSettings = serde_json::from_str(r#"{"jdk":"/jdk"}"#).unwrap();
|
||||
@@ -1136,6 +1150,7 @@ mod gradle_settings_tests {
|
||||
home: "/gradle".into(),
|
||||
user_home: "/cache/gradle".into(),
|
||||
run_using: GradleRunUsing::Ide,
|
||||
linked_projects: Some(vec!["backend".into(), "../shared".into()]),
|
||||
};
|
||||
let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap();
|
||||
assert_eq!(restored.languages.java.gradle, settings.languages.java.gradle);
|
||||
|
||||
@@ -72,7 +72,13 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"stdio",
|
||||
"Install the Kotlin Debug Adapter from Settings > Language Servers",
|
||||
),
|
||||
"debugpy" => cfg("python3", &["-m", "debugpy.adapter"], "stdio", "pip install debugpy"),
|
||||
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
|
||||
"debugpy" => cfg(
|
||||
if cfg!(windows) { "python" } else { "python3" },
|
||||
&["-m", "debugpy.adapter"],
|
||||
"stdio",
|
||||
"pip install debugpy",
|
||||
),
|
||||
"js-debug" => cfg(
|
||||
"js-debug-adapter",
|
||||
// Without a host it listens on `localhost`, which may resolve to ::1 only.
|
||||
|
||||
@@ -150,6 +150,7 @@ impl ClickhousePool {
|
||||
self.0.client.read().clone().ok_or_else(|| IdeError::new(ErrorKind::Io, "ClickHouse connection is closed"))
|
||||
}
|
||||
|
||||
/// `max_rows`: the rows the server may send (the skipped ones included).
|
||||
fn request(&self, client: &Client, query_id: &str, max_rows: usize, initialized: bool) -> RequestBuilder {
|
||||
let mut request = client
|
||||
.post(self.0.endpoint.clone())
|
||||
@@ -177,6 +178,17 @@ impl ClickhousePool {
|
||||
}
|
||||
|
||||
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
|
||||
self.execute_from(sql, 0, max_rows, cancel).await
|
||||
}
|
||||
|
||||
/// [`execute`](Self::execute), reading past the first `skip` rows without parsing or keeping them.
|
||||
pub async fn execute_from(
|
||||
&self,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
if transaction_statement(sql) {
|
||||
return Err(transaction_error());
|
||||
}
|
||||
@@ -188,7 +200,7 @@ impl ClickhousePool {
|
||||
};
|
||||
let client = self.client()?;
|
||||
let query_id = uuid::Uuid::new_v4().to_string();
|
||||
let request = self.request(&client, &query_id, max_rows, *session).body(sql.to_owned());
|
||||
let request = self.request(&client, &query_id, skip.saturating_add(max_rows), *session).body(sql.to_owned());
|
||||
let started = Instant::now();
|
||||
let fetch = async {
|
||||
let mut response = request.send().await.map_err(http_error)?;
|
||||
@@ -216,7 +228,7 @@ impl ClickhousePool {
|
||||
}
|
||||
(Vec::new(), Vec::new(), false)
|
||||
} else {
|
||||
let mut reader = RowReader::new(max_rows);
|
||||
let mut reader = RowReader::new(skip, max_rows);
|
||||
while let Some(chunk) = response.chunk().await.map_err(http_error)? {
|
||||
reader.push(&chunk)?;
|
||||
}
|
||||
@@ -309,11 +321,13 @@ impl ClickhousePool {
|
||||
|
||||
type DecodedResult = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, bool);
|
||||
|
||||
/// Decodes a streamed [`FORMAT`] body. Keeps the first `max_rows` rows; later rows only set
|
||||
/// `has_more` and are read past unparsed, so a grid limit bounds memory however large the
|
||||
/// block `result_overflow_mode=break` lets the server send. A line that is not a JSON array
|
||||
/// Decodes a streamed [`FORMAT`] body. Reads past the first `skip` rows unparsed (a later
|
||||
/// page), then keeps `max_rows` rows; later rows only set `has_more` and are read past
|
||||
/// unparsed, so a grid limit bounds memory however large the block
|
||||
/// `result_overflow_mode=break` lets the server send. A line that is not a JSON array
|
||||
/// starts the exception ClickHouse writes when a query fails mid-stream.
|
||||
struct RowReader {
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
pending: Vec<u8>,
|
||||
names: Option<Vec<String>>,
|
||||
@@ -324,8 +338,17 @@ struct RowReader {
|
||||
}
|
||||
|
||||
impl RowReader {
|
||||
fn new(max_rows: usize) -> Self {
|
||||
Self { max_rows, pending: Vec::new(), names: None, types: None, rows: Vec::new(), more: false, exception: None }
|
||||
fn new(skip: usize, max_rows: usize) -> Self {
|
||||
Self {
|
||||
skip,
|
||||
max_rows,
|
||||
pending: Vec::new(),
|
||||
names: None,
|
||||
types: None,
|
||||
rows: Vec::new(),
|
||||
more: false,
|
||||
exception: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, chunk: &[u8]) -> IdeResult<()> {
|
||||
@@ -356,9 +379,15 @@ impl RowReader {
|
||||
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
|
||||
return Ok(());
|
||||
}
|
||||
if self.names.is_some() && self.types.is_some() && self.rows.len() >= self.max_rows {
|
||||
self.more = true;
|
||||
return Ok(());
|
||||
if self.names.is_some() && self.types.is_some() {
|
||||
if self.skip > 0 {
|
||||
self.skip -= 1;
|
||||
return Ok(());
|
||||
}
|
||||
if self.rows.len() >= self.max_rows {
|
||||
self.more = true;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let Ok(values) = serde_json::from_slice::<Vec<Box<serde_json::value::RawValue>>>(text) else {
|
||||
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
|
||||
@@ -547,7 +576,11 @@ mod tests {
|
||||
}
|
||||
|
||||
fn decode(body: &[u8], max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
|
||||
let mut reader = RowReader::new(max_rows);
|
||||
decode_from(body, 0, max_rows, chunk)
|
||||
}
|
||||
|
||||
fn decode_from(body: &[u8], skip: usize, max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
|
||||
let mut reader = RowReader::new(skip, max_rows);
|
||||
for part in body.chunks(chunk) {
|
||||
reader.push(part)?;
|
||||
}
|
||||
@@ -586,6 +619,18 @@ mod tests {
|
||||
assert!(decode(b"[\"x\"]\n[\"UInt8\"]\n[1, 2]\n", 1, 64).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_page_skips_rows_without_keeping_them() {
|
||||
let body = b"[\"n\"]\n[\"UInt8\"]\n[1]\n[2]\n[3]\n[4]\n[5]\n";
|
||||
let page = |skip, max| {
|
||||
let (_, rows, more) = decode_from(body, skip, max, 3).unwrap();
|
||||
(rows.into_iter().map(|r| r[0].clone().unwrap()).collect::<Vec<_>>(), more)
|
||||
};
|
||||
assert_eq!(page(2, 2), (vec!["3".to_owned(), "4".to_owned()], true));
|
||||
assert_eq!(page(4, 2), (vec!["5".to_owned()], false));
|
||||
assert_eq!(page(9, 2), (Vec::<String>::new(), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_past_the_limit_are_skipped_but_a_later_exception_still_fails() {
|
||||
let mut body = String::from("[\"n\"]\n[\"UInt64\"]\n");
|
||||
|
||||
+49
-10
@@ -337,10 +337,13 @@ fn result_columns<R: Row>(row: &R) -> Vec<ResultColumn> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows
|
||||
/// with their cells read as `$mode`.
|
||||
/// Streams the results of `$sql` on executor `$exec`, reading past the first `$skip`
|
||||
/// rows without decoding them, then keeping at most `$max` rows with their cells read as `$mode`.
|
||||
macro_rules! fetch {
|
||||
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {{
|
||||
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {
|
||||
fetch!($exec, $sql, 0usize, $max, $mode)
|
||||
};
|
||||
($exec:expr, $sql:expr, $skip:expr, $max:expr, $mode:expr) => {{
|
||||
use futures::StreamExt;
|
||||
let mut stream = sqlx::raw_sql(sqlx::AssertSqlSafe($sql.to_owned())).fetch_many($exec);
|
||||
let mut columns: Vec<ResultColumn> = Vec::new();
|
||||
@@ -349,6 +352,7 @@ macro_rules! fetch {
|
||||
let mut has_more = false;
|
||||
let mut failure = None;
|
||||
let mut first: Option<Instant> = None;
|
||||
let mut to_skip: usize = $skip;
|
||||
while let Some(item) = stream.next().await {
|
||||
first.get_or_insert_with(Instant::now);
|
||||
match item {
|
||||
@@ -357,6 +361,10 @@ macro_rules! fetch {
|
||||
if columns.is_empty() {
|
||||
columns = result_columns(&row);
|
||||
}
|
||||
if to_skip > 0 {
|
||||
to_skip -= 1;
|
||||
continue;
|
||||
}
|
||||
if rows.len() >= $max {
|
||||
has_more = true;
|
||||
break;
|
||||
@@ -386,7 +394,8 @@ async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult<Fetched> {
|
||||
/// Like [`fetch!`] for SQL Server: the first `skip` rows are read past without decoding.
|
||||
async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, skip: usize, max: usize) -> IdeResult<Fetched> {
|
||||
let mut stream = conn.simple_query(sql).await.map_err(mssql_err)?;
|
||||
let first = Some(Instant::now());
|
||||
let columns = stream
|
||||
@@ -399,8 +408,13 @@ async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult
|
||||
.collect();
|
||||
let mut rows = Vec::new();
|
||||
let mut has_more = false;
|
||||
let mut to_skip = skip;
|
||||
while let Some(item) = stream.next().await {
|
||||
if let tiberius::QueryItem::Row(row) = item.map_err(mssql_err)? {
|
||||
if to_skip > 0 {
|
||||
to_skip -= 1;
|
||||
continue;
|
||||
}
|
||||
if rows.len() >= max {
|
||||
has_more = true;
|
||||
break;
|
||||
@@ -734,7 +748,7 @@ impl Pool {
|
||||
let fetched: IdeResult<Fetched> = match self {
|
||||
Pool::SqlServer(p) => {
|
||||
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
|
||||
mssql_fetch(&mut conn, sql, max).await
|
||||
mssql_fetch(&mut conn, sql, 0, max).await
|
||||
}
|
||||
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
|
||||
};
|
||||
@@ -744,11 +758,23 @@ impl Pool {
|
||||
/// Runs one statement and keeps at most `max_rows` rows. Cancelling stops
|
||||
/// the statement on the server (PostgreSQL, MySQL) and drops the connection.
|
||||
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
|
||||
self.execute_from(sql, 0, max_rows, cancel).await
|
||||
}
|
||||
|
||||
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping them
|
||||
/// (a later page of a result: only that page is decoded and returned).
|
||||
pub async fn execute_from(
|
||||
&self,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
if let Pool::Clickhouse(p) = self {
|
||||
return p.execute(sql, max_rows, cancel).await;
|
||||
return p.execute_from(sql, skip, max_rows, cancel).await;
|
||||
}
|
||||
let mut conn = Held::acquire(self).await?;
|
||||
let r = conn.execute(self, sql, max_rows, cancel).await;
|
||||
let r = conn.execute_from(self, sql, skip, max_rows, cancel).await;
|
||||
if cancel.is_cancelled() {
|
||||
conn.discard().await;
|
||||
return Err(IdeError::cancelled());
|
||||
@@ -874,9 +900,22 @@ impl Held {
|
||||
sql: &str,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
self.execute_from(pool, sql, 0, max_rows, cancel).await
|
||||
}
|
||||
|
||||
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping them
|
||||
/// (see [`Pool::execute_from`]).
|
||||
pub async fn execute_from(
|
||||
&mut self,
|
||||
pool: &Pool,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
if let Held::Clickhouse(c) = self {
|
||||
return c.execute(sql, max_rows, cancel).await;
|
||||
return c.execute_from(sql, skip, max_rows, cancel).await;
|
||||
}
|
||||
let started = Instant::now();
|
||||
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
|
||||
@@ -887,7 +926,7 @@ impl Held {
|
||||
None => None,
|
||||
};
|
||||
let r = tokio::select! {
|
||||
r = async { fetch!(&mut **$conn, sql, max_rows, Cells::Display) } => r,
|
||||
r = async { fetch!(&mut **$conn, sql, skip, max_rows, Cells::Display) } => r,
|
||||
() = cancel.cancelled() => Err(IdeError::cancelled()),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
@@ -916,7 +955,7 @@ impl Held {
|
||||
Held::SqlServer(c) => {
|
||||
// Tiberius has no attention/cancel API; stop reading and discard the connection.
|
||||
tokio::select! {
|
||||
r = mssql_fetch(c, sql, max_rows) => r?,
|
||||
r = mssql_fetch(c, sql, skip, max_rows) => r?,
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1009,7 +1009,20 @@ impl DbManager {
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
let r = self.run(id, statement, max_rows, cancel).await;
|
||||
self.execute_from(id, statement, 0, max_rows, cancel).await
|
||||
}
|
||||
|
||||
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping
|
||||
/// them: a later page of a result, where only that page is decoded and returned.
|
||||
pub async fn execute_from(
|
||||
&self,
|
||||
id: &str,
|
||||
statement: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
let r = self.run(id, statement, skip, max_rows, cancel).await;
|
||||
let ran = match &r {
|
||||
Ok(_) => true,
|
||||
Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied),
|
||||
@@ -1044,7 +1057,7 @@ impl DbManager {
|
||||
/// The execution plan of `statement` (explained, not run).
|
||||
pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult<Vec<PlanNode>> {
|
||||
let driver = self.pool(id).await?.driver();
|
||||
let result = self.run(id, &plan::explain_statement(driver, statement), MAX_META_ROWS, cancel).await?;
|
||||
let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?;
|
||||
plan::parse(driver, &result)
|
||||
}
|
||||
|
||||
@@ -1052,13 +1065,14 @@ impl DbManager {
|
||||
&self,
|
||||
id: &str,
|
||||
statement: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
let pool = self.pool(id).await?;
|
||||
let session = self.sessions.lock().get(id).cloned();
|
||||
let Some(session) = session else {
|
||||
return pool.execute(statement, max_rows, cancel).await;
|
||||
return pool.execute_from(statement, skip, max_rows, cancel).await;
|
||||
};
|
||||
if pool.driver() == DbDriver::Clickhouse {
|
||||
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
|
||||
@@ -1070,7 +1084,7 @@ impl DbManager {
|
||||
*held = Some(conn);
|
||||
}
|
||||
let conn = held.as_mut().expect("opened above");
|
||||
let r = conn.execute(&pool, statement, max_rows, cancel).await;
|
||||
let r = conn.execute_from(&pool, statement, skip, max_rows, cancel).await;
|
||||
if cancel.is_cancelled() {
|
||||
// The server rolls the transaction back with the connection.
|
||||
held.take().expect("opened above").discard().await;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Introspection, queries and table edits against a real SQLite file.
|
||||
|
||||
use ide_core::{CancellationToken, IdeResult};
|
||||
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, SortKey};
|
||||
use ide_db::{
|
||||
CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, QueryResult, SortKey,
|
||||
};
|
||||
|
||||
async fn setup() -> (tempfile::TempDir, DbManager) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -123,6 +125,28 @@ async fn queries_with_limits_nulls_and_blobs() -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_later_page_returns_only_its_rows() -> IdeResult<()> {
|
||||
let (_tmp, db) = setup().await;
|
||||
let c = CancellationToken::new();
|
||||
let sql = "SELECT id FROM users ORDER BY id";
|
||||
let ids = |r: &QueryResult| r.rows.iter().map(|row| row[0].clone().unwrap()).collect::<Vec<_>>();
|
||||
|
||||
let second = db.execute_from("s1", sql, 1, 1, &c).await?;
|
||||
assert_eq!(ids(&second), vec!["2"]);
|
||||
assert!(second.has_more);
|
||||
assert_eq!(second.columns.len(), 1);
|
||||
|
||||
let last = db.execute_from("s1", sql, 2, 5, &c).await?;
|
||||
assert_eq!(ids(&last), vec!["3"]);
|
||||
assert!(!last.has_more);
|
||||
|
||||
let past = db.execute_from("s1", sql, 9, 5, &c).await?;
|
||||
assert!(past.rows.is_empty() && !past.has_more);
|
||||
assert_eq!(past.columns.len(), 1, "columns of a page past the end come from the prepared statement");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pages_sorts_filters_and_edits_in_a_transaction() -> IdeResult<()> {
|
||||
let (_tmp, db) = setup().await;
|
||||
|
||||
@@ -8,8 +8,11 @@ use ide_agent::{
|
||||
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
|
||||
NewAgentTask,
|
||||
};
|
||||
use ide_core::{IdeError, IdeResult, IpcPath, RequestId};
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
use ide_tools::InstallEvent;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
/// Streams the project's agent events to this window; returns every task.
|
||||
pub fn subscribe(host: &Host, window: &str, on_event: Emit<AgentEvent>) -> IdeResult<Vec<AgentTask>> {
|
||||
@@ -58,7 +61,39 @@ pub async fn registry_list(host: &Host, _window: &str, request: RequestId) -> Id
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || ide_agent::registry::list(&paths, &cancel)).await.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
let mut registry = result??;
|
||||
let installing = installing();
|
||||
for agent in &mut registry.agents {
|
||||
agent.installing = installing.contains(&agent.id);
|
||||
}
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
/// Registry installs in progress, by agent id. An install outlives the Settings page
|
||||
/// that started it, so a second one of the same agent is refused rather than racing it
|
||||
/// for the same folder.
|
||||
static INSTALLING: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(Mutex::default);
|
||||
|
||||
fn installing() -> std::sync::MutexGuard<'static, HashSet<String>> {
|
||||
INSTALLING.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Marks agent `id` as installing until dropped.
|
||||
struct Installing(String);
|
||||
|
||||
impl Installing {
|
||||
fn begin(id: &str) -> IdeResult<Self> {
|
||||
if !installing().insert(id.to_owned()) {
|
||||
return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{id} is already being installed")));
|
||||
}
|
||||
Ok(Self(id.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Installing {
|
||||
fn drop(&mut self) {
|
||||
installing().remove(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the registry's current version of agent `id` (also to update it), streaming
|
||||
@@ -72,11 +107,43 @@ pub async fn registry_install(
|
||||
on_event: Channel<InstallEvent>,
|
||||
) -> IdeResult<()> {
|
||||
let paths = registry_paths(host);
|
||||
let installing = Installing::begin(&id)?;
|
||||
let cancel = host.begin_request(request);
|
||||
// Also a background task of every project window, so it stays visible and can be
|
||||
// stopped there once the Settings page is closed.
|
||||
let task = TaskId::next();
|
||||
let title = format!("Installing {id}");
|
||||
let sinks: Vec<SharedSink> = host.projects().iter().map(|p| p.sink.clone()).collect();
|
||||
let report = move |message: Option<String>, fraction: Option<f32>, done: bool| {
|
||||
for sink in &sinks {
|
||||
sink.emit(IdeEvent::Task {
|
||||
id: task,
|
||||
title: title.clone(),
|
||||
message: message.clone(),
|
||||
fraction,
|
||||
done,
|
||||
cancellable: !done,
|
||||
});
|
||||
}
|
||||
};
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
ide_agent::registry::install(&paths, &id, &cancel, &|e| {
|
||||
let _installing = installing;
|
||||
let _cancel = ide_core::task::on_cancel(task, {
|
||||
let cancel = cancel.clone();
|
||||
move || cancel.cancel()
|
||||
});
|
||||
report(None, None, false);
|
||||
let result = ide_agent::registry::install(&paths, &id, &cancel, &|e| {
|
||||
match &e {
|
||||
InstallEvent::Line { text } => report(Some(text.clone()), None, false),
|
||||
InstallEvent::Progress { fraction, message } => {
|
||||
report(Some(message.clone()), fraction.map(|f| f as f32), false)
|
||||
}
|
||||
}
|
||||
let _ = on_event.send(e);
|
||||
})
|
||||
});
|
||||
report(None, Some(1.0), true);
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
@@ -263,6 +330,20 @@ fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::P
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_agent_installs_once_at_a_time() {
|
||||
let first = Installing::begin("test-agent").unwrap();
|
||||
assert!(installing().contains("test-agent"));
|
||||
let again = Installing::begin("test-agent").err().unwrap();
|
||||
assert_eq!(again.kind, ErrorKind::AlreadyExists);
|
||||
let other = Installing::begin("test-other-agent").unwrap();
|
||||
drop(first);
|
||||
assert!(!installing().contains("test-agent"));
|
||||
drop(Installing::begin("test-agent").unwrap());
|
||||
drop(other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_roots_are_exact_window_or_workspace_members() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -32,6 +32,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
cache,
|
||||
ide_sdk::apply::maven_local_repository(root, settings),
|
||||
&settings.languages.java.maven,
|
||||
&settings.languages.java.gradle,
|
||||
);
|
||||
let jdk = ide_sdk::apply::project_jdk(root, settings);
|
||||
if let Some(m) = &mut model.maven {
|
||||
@@ -45,7 +46,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
p.options = options.clone();
|
||||
}
|
||||
}
|
||||
if let Some(g) = &mut model.gradle {
|
||||
for g in &mut model.gradle {
|
||||
g.env = ide_sdk::apply::gradle_env(root, settings, java_env(jdk.as_ref(), g.java_version));
|
||||
g.command = ide_sdk::apply::gradle_command(root, &root.join(&g.dir), settings).to_string_lossy().into_owned();
|
||||
}
|
||||
@@ -65,11 +66,12 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
let hint = m.projects.iter().find_map(|p| missing(p.java_version));
|
||||
explain(&mut m.sync_error, hint);
|
||||
}
|
||||
if let Some(g) = &mut model.gradle
|
||||
&& g.sync_error.is_some()
|
||||
{
|
||||
let hint = if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None };
|
||||
explain(&mut g.sync_error, hint);
|
||||
for g in &mut model.gradle {
|
||||
if g.sync_error.is_some() {
|
||||
let hint =
|
||||
if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None };
|
||||
explain(&mut g.sync_error, hint);
|
||||
}
|
||||
}
|
||||
model
|
||||
}
|
||||
@@ -94,9 +96,88 @@ pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
|
||||
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
|
||||
}
|
||||
|
||||
fn linked_gradle_paths(
|
||||
root: &Path,
|
||||
settings: &ide_core::settings::GradleSettings,
|
||||
paths: &[String],
|
||||
link: bool,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let existing = settings
|
||||
.linked_projects
|
||||
.clone()
|
||||
.unwrap_or_else(|| ide_build::gradle::load_linked(root, settings).into_iter().map(|g| g.dir).collect());
|
||||
let mut linked = Vec::new();
|
||||
for path in existing {
|
||||
let normalized = ide_build::gradle::normalize_linked_projects(root, std::slice::from_ref(&path))
|
||||
.unwrap_or_else(|_| vec![path]);
|
||||
for path in normalized {
|
||||
if !linked.contains(&path) {
|
||||
linked.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
if link {
|
||||
let directories: Vec<String> = paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let path = root.join(path);
|
||||
let is_script = path.file_name().and_then(|n| n.to_str()).is_some_and(|name| {
|
||||
ide_build::gradle::SETTINGS_FILES.contains(&name) || ide_build::gradle::BUILD_FILES.contains(&name)
|
||||
});
|
||||
if is_script && path.is_file() {
|
||||
path.parent().unwrap_or(root).to_string_lossy().into_owned()
|
||||
} else {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for path in ide_build::gradle::normalize_linked_projects(root, &directories)? {
|
||||
if !linked.contains(&path) {
|
||||
linked.push(path);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
linked.retain(|path| !paths.contains(path));
|
||||
}
|
||||
Ok(linked)
|
||||
}
|
||||
|
||||
async fn set_gradle_projects(host: &Host, window: &str, paths: Vec<String>, link: bool) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if ide_workspace::workspace::Workspace::load(&project.root)?.is_some() {
|
||||
return Err(IdeError::invalid("Open a workspace member as a project to link Gradle builds"));
|
||||
}
|
||||
let linked =
|
||||
linked_gradle_paths(&project.root, &project.settings.effective().languages.java.gradle, &paths, link)?;
|
||||
let key = "languages.java.gradle.linkedProjects";
|
||||
let settings =
|
||||
project.settings.set(ide_workspace::settings::SettingsScope::Project, key, serde_json::json!(linked))?;
|
||||
project.refresh_index_scope();
|
||||
project.lsp.reconfigure_soon();
|
||||
project.sink.emit(IdeEvent::SettingsChanged { key: key.into() });
|
||||
project.sink.emit(IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
Ok::<BuildModel, IdeError>(load(&project.root, &project.build_cache_dir(), &settings))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)?
|
||||
}
|
||||
|
||||
/// Links independent builds without changing their Gradle scripts.
|
||||
pub async fn gradle_link_projects(host: &Host, window: &str, paths: Vec<String>) -> IdeResult<BuildModel> {
|
||||
set_gradle_projects(host, window, paths, true).await
|
||||
}
|
||||
|
||||
/// Stops importing the selected independent builds; files and saved run configurations stay intact.
|
||||
pub async fn gradle_unlink_projects(host: &Host, window: &str, paths: Vec<String>) -> IdeResult<BuildModel> {
|
||||
set_gradle_projects(host, window, paths, false).await
|
||||
}
|
||||
|
||||
/// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the
|
||||
/// background, reporting progress on `on_progress`; cancellable with `cancel_request(request)`.
|
||||
/// Returns the refreshed model; a failed build is reported in its `syncError`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn build_sync(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -104,6 +185,7 @@ pub async fn build_sync(
|
||||
request: RequestId,
|
||||
offline: bool,
|
||||
profiles: Vec<String>,
|
||||
build_dir: Option<String>,
|
||||
on_progress: Channel<SyncProgress>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
@@ -122,6 +204,8 @@ pub async fn build_sync(
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
}
|
||||
let token = host.begin_project_request(window, request);
|
||||
let completion_token = token.clone();
|
||||
let original_links = settings.languages.java.gradle.linked_projects.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let current = load(&root, &cache, &settings);
|
||||
let env = match tool {
|
||||
@@ -131,7 +215,7 @@ pub async fn build_sync(
|
||||
.and_then(|m| m.projects.iter().find(|p| !p.env.is_empty()))
|
||||
.map(|p| p.env.clone())
|
||||
.unwrap_or_default(),
|
||||
BuildTool::Gradle => current.gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
|
||||
BuildTool::Gradle => BTreeMap::new(),
|
||||
};
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
@@ -140,14 +224,31 @@ pub async fn build_sync(
|
||||
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
|
||||
maven_options: ide_sdk::apply::maven_options(&root, &settings),
|
||||
maven_settings: settings.languages.java.maven.clone(),
|
||||
gradle_command: current.gradle.as_ref().map(|g| g.command.clone()),
|
||||
gradle_command: None,
|
||||
};
|
||||
let progress = |p: SyncProgress| {
|
||||
let _ = on_progress.send(p);
|
||||
};
|
||||
match tool {
|
||||
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
|
||||
BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
|
||||
BuildTool::Gradle => {
|
||||
if let Some(dir) = &build_dir
|
||||
&& !current.gradle.iter().any(|g| &g.dir == dir)
|
||||
{
|
||||
return Err(IdeError::not_found("Gradle project is no longer linked"));
|
||||
}
|
||||
for g in current.gradle.iter().filter(|g| build_dir.as_ref().is_none_or(|dir| dir == &g.dir)) {
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let options =
|
||||
SyncOptions { env: g.env.clone(), gradle_command: Some(g.command.clone()), ..options.clone() };
|
||||
ide_build::sync_gradle_model(&root, &cache, g, &options, &token, &progress)?;
|
||||
}
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<BuildModel, IdeError>(load(&root, &cache, &settings))
|
||||
})
|
||||
@@ -155,6 +256,18 @@ pub async fn build_sync(
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
let model = result??;
|
||||
if completion_token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if tool == BuildTool::Gradle && original_links != project.settings.effective().languages.java.gradle.linked_projects
|
||||
{
|
||||
// A late sync must not re-import a build that was unlinked while it ran.
|
||||
return tokio::task::spawn_blocking(move || {
|
||||
load(&project.root, &project.build_cache_dir(), &project.settings.effective())
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
}
|
||||
if tool == BuildTool::Gradle {
|
||||
// The sync may report project folders outside the project (`includeFlat`): index and list them.
|
||||
project.refresh_index_scope();
|
||||
@@ -247,20 +360,26 @@ pub async fn gradle_dependencies(
|
||||
configuration: String,
|
||||
offline: bool,
|
||||
request: RequestId,
|
||||
build_dir: Option<String>,
|
||||
) -> IdeResult<Vec<ide_build::GradleDependency>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let gradle = load(&root, &cache, &settings).gradle;
|
||||
let model = load(&root, &cache, &settings);
|
||||
let gradle = model
|
||||
.gradle
|
||||
.iter()
|
||||
.find(|g| build_dir.as_ref().is_none_or(|dir| &g.dir == dir))
|
||||
.ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
env: gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
|
||||
gradle_command: gradle.as_ref().map(|g| g.command.clone()),
|
||||
env: gradle.env.clone(),
|
||||
gradle_command: Some(gradle.command.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
ide_build::gradle_dependencies(&root, &options, &gradle_path, &configuration, &token)
|
||||
ide_build::gradle_dependencies(&root, gradle, &options, &gradle_path, &configuration, &token)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
@@ -382,6 +501,11 @@ pub async fn build_run(
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("build_model", |c| async move { build_model(&c.host, &c.window).await });
|
||||
d.add("gradle_link_projects", |c| async move { gradle_link_projects(&c.host, &c.window, c.arg("paths")?).await });
|
||||
d.add(
|
||||
"gradle_unlink_projects",
|
||||
|c| async move { gradle_unlink_projects(&c.host, &c.window, c.arg("paths")?).await },
|
||||
);
|
||||
d.add("build_sync", |c| async move {
|
||||
build_sync(
|
||||
&c.host,
|
||||
@@ -390,6 +514,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
c.arg("request")?,
|
||||
c.arg("offline")?,
|
||||
c.arg("profiles")?,
|
||||
c.arg("buildDir")?,
|
||||
c.sender("onProgress")?,
|
||||
)
|
||||
.await
|
||||
@@ -405,6 +530,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
c.arg("configuration")?,
|
||||
c.arg("offline")?,
|
||||
c.arg("request")?,
|
||||
c.arg("buildDir")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
@@ -432,6 +558,48 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
mod gradle_settings_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn linking_retains_the_implicit_build_and_unlinking_all_stays_empty() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
for name in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(name)).unwrap();
|
||||
std::fs::write(root.join(name).join("settings.gradle"), format!("rootProject.name = '{name}'")).unwrap();
|
||||
}
|
||||
let mut settings = ide_core::settings::GradleSettings::default();
|
||||
let linked = linked_gradle_paths(&root, &settings, &["b/settings.gradle".into()], true).unwrap();
|
||||
assert_eq!(linked, ["a", "b"]);
|
||||
settings.linked_projects = Some(linked);
|
||||
assert_eq!(linked_gradle_paths(&root, &settings, &["b".into()], true).unwrap(), ["a", "b"]);
|
||||
assert!(linked_gradle_paths(&root, &settings, &["missing".into()], true).is_err());
|
||||
settings.linked_projects = Some(vec!["./a".into(), root.join("b").to_string_lossy().into_owned()]);
|
||||
settings.linked_projects = Some(linked_gradle_paths(&root, &settings, &["a".into()], false).unwrap());
|
||||
assert_eq!(settings.linked_projects.as_ref().unwrap(), &["b"]);
|
||||
settings.linked_projects = Some(linked_gradle_paths(&root, &settings, &["b".into()], false).unwrap());
|
||||
assert!(ide_build::gradle::load_linked(&root, &settings).is_empty());
|
||||
assert!(root.join("a/settings.gradle").is_file());
|
||||
assert!(root.join("b/settings.gradle").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_resolve_each_linked_builds_own_wrapper() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.java.gradle.linked_projects = Some(vec!["a".into(), "b".into()]);
|
||||
let executable = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join("build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
std::fs::write(root.join(dir).join(executable), "").unwrap();
|
||||
}
|
||||
let model = load(&root, &root.join("cache"), &settings);
|
||||
assert_eq!(model.gradle.len(), 2);
|
||||
for g in model.gradle {
|
||||
assert_eq!(g.command, root.join(&g.dir).join(executable).to_string_lossy());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -440,13 +608,13 @@ mod gradle_settings_tests {
|
||||
let wrapper = root.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
|
||||
std::fs::write(&wrapper, "").unwrap();
|
||||
let mut settings = Settings::default();
|
||||
let default = load(root, &root.join("cache"), &settings).gradle.unwrap();
|
||||
let default = load(root, &root.join("cache"), &settings).gradle.remove(0);
|
||||
assert_eq!(default.command, wrapper.to_string_lossy());
|
||||
settings.languages.java.gradle.jvm = "jdk21".into();
|
||||
settings.languages.java.gradle.user_home = "user-home".into();
|
||||
settings.languages.java.gradle.distribution = ide_core::settings::GradleDistribution::Local;
|
||||
settings.languages.java.gradle.home = "gradle-install".into();
|
||||
let model = load(root, &root.join("cache"), &settings).gradle.unwrap();
|
||||
let model = load(root, &root.join("cache"), &settings).gradle.remove(0);
|
||||
assert_eq!(
|
||||
model.command,
|
||||
root.join("gradle-install")
|
||||
|
||||
@@ -123,13 +123,14 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
statements(&c.host, &c.window, &id, &text, from, to)
|
||||
});
|
||||
d.add("db_execute", |c| async move {
|
||||
let (request, id, statements, max_rows) = (
|
||||
let (request, id, statements, skip, max_rows) = (
|
||||
c.arg::<RequestId>("request")?,
|
||||
c.arg::<String>("id")?,
|
||||
c.arg::<Vec<SqlStatement>>("statements")?,
|
||||
c.arg::<Option<u32>>("skip")?.unwrap_or(0),
|
||||
c.arg::<u32>("maxRows")?,
|
||||
);
|
||||
execute(&c.host, &c.window, request, &id, statements, max_rows).await
|
||||
execute(&c.host, &c.window, request, &id, statements, skip, max_rows).await
|
||||
});
|
||||
d.add("db_table_page", |c| async move {
|
||||
let (request, id, schema, table) = (
|
||||
@@ -325,20 +326,22 @@ pub fn statements(host: &Host, window: &str, id: &str, text: &str, from: u32, to
|
||||
}
|
||||
|
||||
/// Runs `statements` in order on data source `id`, stopping at the first
|
||||
/// error. Cancel with `cancel_request(request)`.
|
||||
/// error. Each result reads past its first `skip` rows (a later page) and keeps
|
||||
/// `max_rows`. Cancel with `cancel_request(request)`.
|
||||
pub async fn execute(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
id: &str,
|
||||
statements: Vec<SqlStatement>,
|
||||
skip: u32,
|
||||
max_rows: u32,
|
||||
) -> IdeResult<Vec<StatementOutcome>> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let mut out = Vec::new();
|
||||
for statement in statements {
|
||||
match db.execute(id, &statement.text, max_rows.max(1) as usize, &cancel).await {
|
||||
match db.execute_from(id, &statement.text, skip as usize, max_rows.max(1) as usize, &cancel).await {
|
||||
Ok(result) => out.push(StatementOutcome { statement, result: Some(result), error: None }),
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => {
|
||||
host.end_request(request);
|
||||
|
||||
@@ -196,7 +196,9 @@ pub fn set_settings(
|
||||
}
|
||||
let roots_changed = key == "projectStructure"
|
||||
|| key.starts_with("projectStructure.")
|
||||
|| "languages.java.maven.unlinkedProjects".starts_with(key);
|
||||
|| "languages.java.maven.unlinkedProjects".starts_with(key)
|
||||
|| "languages.java.maven.linkedProjects".starts_with(key)
|
||||
|| "languages.java.gradle.linkedProjects".starts_with(key);
|
||||
// Content roots and excludes (files.exclude, ignored names, excluded folders) reach the
|
||||
// index and the watchers without reopening the project (the rescan runs in the background).
|
||||
if roots_changed || key == "files" || key.starts_with("files.") {
|
||||
|
||||
@@ -20,7 +20,7 @@ const MAX_FILE_BYTES: u64 = 4 << 20;
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteSource {
|
||||
pub path: String,
|
||||
pub path: ide_core::IpcPath,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ pub(crate) fn sources_in(root: &Path, excludes: &[String]) -> Vec<RouteSource> {
|
||||
return;
|
||||
}
|
||||
bytes.fetch_add(text.len(), Ordering::Relaxed);
|
||||
found.push(RouteSource { path: path.to_string_lossy().into_owned(), text });
|
||||
found.push(RouteSource { path: path.to_path_buf().into(), text });
|
||||
});
|
||||
let mut files = found.into_inner().unwrap_or_default();
|
||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
@@ -116,7 +116,7 @@ mod tests {
|
||||
std::fs::write(tmp.path().join("src/Notes.txt"), "@GetMapping(\"/a\")").unwrap();
|
||||
let files = sources_in(tmp.path(), &[]);
|
||||
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
|
||||
assert!(Path::new(&files[0].path).ends_with("src/Ctl.java"), "{}", files[0].path);
|
||||
assert!(files[0].path.ends_with("src/Ctl.java"), "{:?}", files[0].path);
|
||||
assert!(files[0].text.contains("@GetMapping"));
|
||||
}
|
||||
|
||||
@@ -129,6 +129,6 @@ mod tests {
|
||||
std::fs::write(tmp.path().join("build/B.java"), "@RequestMapping(\"/b\")").unwrap();
|
||||
let files = sources_in(tmp.path(), &["build/**".to_owned()]);
|
||||
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
|
||||
assert!(Path::new(&files[0].path).ends_with("module/src/A.java"), "{}", files[0].path);
|
||||
assert!(files[0].path.ends_with("module/src/A.java"), "{:?}", files[0].path);
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,26 @@ pub struct LocatedTools {
|
||||
/// Installed interpreters / toolchains of `kind`; project-local ones first.
|
||||
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
|
||||
host.require_trust(window)?;
|
||||
let root = host.project(window).ok().map(|p| p.root.clone());
|
||||
tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err)
|
||||
let project = host.project(window).ok();
|
||||
let root = project.as_ref().map(|p| p.root.clone());
|
||||
// The interpreter the project uses is a choice even where detection doesn't look (a
|
||||
// virtualenv in a subfolder, one picked by path), so it shows by name and version.
|
||||
let current = match (&project, kind) {
|
||||
(Some(p), SdkKind::Python) => ide_sdk::apply::python_interpreter(&p.root, &p.settings.effective()),
|
||||
_ => None,
|
||||
};
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut sdks = ide_sdk::detect(kind, root.as_deref());
|
||||
if let Some(path) = current
|
||||
&& !sdks.iter().any(|s| *s.path == *path)
|
||||
&& let Some(sdk) = ide_sdk::python_interpreter_sdk(&path, root.as_deref())
|
||||
{
|
||||
sdks.push(sdk);
|
||||
}
|
||||
sdks
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// The interpreter the project uses: the configured one, else its virtualenv.
|
||||
|
||||
@@ -492,7 +492,7 @@ pub async fn vcs_add_to_ignore(
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
destination: IgnoreDestination,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
) -> IdeResult<Vec<ide_core::IpcPath>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
@@ -509,7 +509,7 @@ pub async fn vcs_add_to_ignore(
|
||||
watcher.reload_ignore_rules();
|
||||
}
|
||||
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
|
||||
result.map(|files| files.into_iter().map(|p| p.to_string_lossy().into_owned()).collect())
|
||||
result.map(|files| files.into_iter().map(Into::into).collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
+116
-11
@@ -230,6 +230,11 @@ impl Host {
|
||||
if cfg.name == "jdtls" {
|
||||
let (excluded_poms, build_files) = jdtls_maven_import(root, &effective);
|
||||
ide_sdk::apply::configure_maven_import(cfg, root, &excluded_poms, build_files.as_deref());
|
||||
ide_sdk::apply::configure_gradle_import(
|
||||
cfg,
|
||||
effective.languages.java.gradle.linked_projects.as_deref(),
|
||||
build_files.as_deref(),
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -876,8 +881,14 @@ impl Project {
|
||||
|
||||
/// Effective source / test / resource / excluded roots (detected + Project Structure marks).
|
||||
pub fn roots(&self) -> ProjectRoots {
|
||||
let detected = with_gradle_roots(&self.root, &self.build_cache_dir(), &self.detected_roots.read());
|
||||
linked_roots(&self.root, &self.settings.effective(), &detected)
|
||||
let settings = self.settings.effective();
|
||||
let detected = with_gradle_roots(
|
||||
&self.root,
|
||||
&self.build_cache_dir(),
|
||||
&settings.languages.java.gradle,
|
||||
&self.detected_roots.read(),
|
||||
);
|
||||
linked_roots(&self.root, &settings, &detected)
|
||||
}
|
||||
|
||||
/// Where the Maven / Gradle tool windows cache what the build tools reported.
|
||||
@@ -1078,7 +1089,7 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
|
||||
}
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
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)) {
|
||||
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
|
||||
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
|
||||
out.push(p);
|
||||
}
|
||||
@@ -1182,31 +1193,93 @@ pub(crate) fn jdtls_maven_import(
|
||||
settings: &ide_core::settings::Settings,
|
||||
) -> (Vec<PathBuf>, Option<Vec<String>>) {
|
||||
let maven = &settings.languages.java.maven;
|
||||
if maven.unlinked_projects.is_empty() {
|
||||
let gradle = &settings.languages.java.gradle;
|
||||
if maven.unlinked_projects.is_empty() && gradle.linked_projects.is_none() {
|
||||
return (Vec::new(), None);
|
||||
}
|
||||
let model = ide_build::maven::load(root);
|
||||
let model = ide_build::maven::load_linked(root, maven);
|
||||
let unlinked = ide_build::maven::UnlinkedProjects::from_model(&model, maven);
|
||||
let excluded = unlinked.poms().map(PathBuf::from).collect();
|
||||
let build_files = unlinked.import_poms(&model).map(|poms| {
|
||||
let gradle = ide_build::gradle::load(root).and_then(|g| {
|
||||
let poms = unlinked.import_poms(&model).or_else(|| {
|
||||
gradle.linked_projects.as_ref().map(|_| {
|
||||
model
|
||||
.projects
|
||||
.iter()
|
||||
.filter(|p| !p.aggregated && !unlinked.contains(&p.pom))
|
||||
.map(|p| p.pom.clone())
|
||||
.collect()
|
||||
})
|
||||
});
|
||||
let build_files = poms.map(|poms| {
|
||||
let gradle = ide_build::gradle::load_linked(root, gradle).into_iter().filter_map(|g| {
|
||||
let dir = root.join(&g.dir);
|
||||
let names = ide_build::gradle::SETTINGS_FILES.iter().chain(ide_build::gradle::BUILD_FILES);
|
||||
names.map(|n| dir.join(n)).find(|f| f.is_file())
|
||||
});
|
||||
poms.iter().map(|pom| root.join(pom)).chain(gradle).map(|f| ide_lsp::path_to_uri_string(&f)).collect()
|
||||
let eclipse = if settings.languages.java.gradle.linked_projects.is_some() {
|
||||
independent_eclipse_projects(root)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
poms.iter()
|
||||
.map(|pom| root.join(pom))
|
||||
.chain(gradle)
|
||||
.chain(eclipse)
|
||||
.map(|f| ide_lsp::path_to_uri_string(&f))
|
||||
.collect()
|
||||
});
|
||||
(excluded, build_files)
|
||||
}
|
||||
|
||||
/// Explicit Gradle imports must retain Eclipse projects without re-importing generated build metadata.
|
||||
fn independent_eclipse_projects(root: &Path) -> Vec<PathBuf> {
|
||||
let managed = |dir: &Path| {
|
||||
std::iter::once("pom.xml")
|
||||
.chain(ide_build::gradle::SETTINGS_FILES.iter().copied())
|
||||
.chain(ide_build::gradle::BUILD_FILES.iter().copied())
|
||||
.any(|name| dir.join(name).is_file())
|
||||
};
|
||||
if managed(root) {
|
||||
return Vec::new();
|
||||
}
|
||||
let walker = ignore::WalkBuilder::new(root)
|
||||
.standard_filters(false)
|
||||
.filter_entry(move |entry| {
|
||||
if !entry.file_type().is_some_and(|kind| kind.is_dir()) {
|
||||
return false;
|
||||
}
|
||||
if entry.depth() > 0
|
||||
&& (entry.file_name().to_string_lossy().starts_with('.')
|
||||
|| matches!(
|
||||
entry.file_name().to_str(),
|
||||
Some("node_modules" | "target" | "build" | "dist" | "out" | "bin" | "vendor")
|
||||
)
|
||||
|| entry.path().parent().is_some_and(|parent| parent.join(".project").is_file()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
!managed(entry.path())
|
||||
})
|
||||
.build();
|
||||
let mut projects: Vec<_> =
|
||||
walker.flatten().map(|entry| entry.path().join(".project")).filter(|path| path.is_file()).collect();
|
||||
projects.sort();
|
||||
projects
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
fn with_gradle_roots(
|
||||
root: &Path,
|
||||
build_cache: &Path,
|
||||
settings: &ide_core::settings::GradleSettings,
|
||||
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) {
|
||||
for (dir, kind) in ide_build::gradle_source_roots(root, build_cache, settings) {
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
@@ -1376,6 +1449,37 @@ pub fn apply_git_executable(settings: &ide_core::settings::Settings) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod maven_linkage_tests {
|
||||
#[test]
|
||||
fn explicit_gradle_links_preserve_independent_eclipse_projects() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
for dir in
|
||||
["linked", "unlinked", "eclipse", "nested/eclipse", "unlinked/subproject", "maven", "build/generated"]
|
||||
{
|
||||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join(".project"), "<projectDescription/>").unwrap();
|
||||
}
|
||||
for dir in ["linked", "unlinked"] {
|
||||
std::fs::write(root.join(dir).join("settings.gradle"), "").unwrap();
|
||||
}
|
||||
std::fs::write(root.join("maven/pom.xml"), "<project/>").unwrap();
|
||||
let mut settings = ide_core::settings::Settings::default();
|
||||
settings.languages.java.gradle.linked_projects = Some(vec!["linked".into()]);
|
||||
let (_, files) = super::jdtls_maven_import(root, &settings);
|
||||
let files = files.unwrap();
|
||||
let uri = |path: &str| ide_lsp::path_to_uri_string(&root.join(path));
|
||||
assert!(files.contains(&uri("linked/settings.gradle")));
|
||||
assert!(files.contains(&uri("maven/pom.xml")));
|
||||
assert!(files.contains(&uri("eclipse/.project")));
|
||||
assert!(files.contains(&uri("nested/eclipse/.project")));
|
||||
assert!(!files.iter().any(|path| path.contains("unlinked") || path.contains("build/generated")));
|
||||
assert!(!files.contains(&uri("linked/.project")));
|
||||
assert!(!files.contains(&uri("maven/.project")));
|
||||
settings.languages.java.gradle.linked_projects = Some(vec![]);
|
||||
let (_, files) = super::jdtls_maven_import(root, &settings);
|
||||
assert_eq!(files.unwrap(), [uri("maven/pom.xml"), uri("eclipse/.project"), uri("nested/eclipse/.project")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jdtls_imports_the_linked_modules_and_the_gradle_build_once_a_reactor_module_is_unlinked() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1473,7 +1577,8 @@ mod gradle_roots_tests {
|
||||
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()]);
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
let out = with_gradle_roots(&root, &cache, &Default::default(), &[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!(
|
||||
|
||||
@@ -217,6 +217,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "lsp_resend_diagnostics"
|
||||
| "recent_projects"
|
||||
| "remove_recent_project"
|
||||
| "recent_project_groups"
|
||||
| "set_recent_project_groups"
|
||||
| "open_project"
|
||||
| "close_project"
|
||||
| "project_current"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn links_are_validated_persisted_and_unlinked_without_removing_files() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("workspace");
|
||||
for dir in [root.join("a"), root.join("b"), base.join("external")] {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("settings.gradle"), "rootProject.name = 'same-name'").unwrap();
|
||||
}
|
||||
let config = base.join("config");
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: config.clone(),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |window| window,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.open_project("main", &root).unwrap();
|
||||
assert!(ops::build::gradle_link_projects(&host, "main", vec!["b".into()]).await.is_err());
|
||||
host.set_project_trust(&root, true, false).await.unwrap();
|
||||
let model = ops::build::gradle_link_projects(&host, "main", vec!["b/settings.gradle".into()]).await.unwrap();
|
||||
assert_eq!(model.gradle.iter().map(|g| g.dir.as_str()).collect::<Vec<_>>(), ["a", "b"]);
|
||||
let saved =
|
||||
|| ide_workspace::SettingsStore::load(&config, Some(&root)).effective().languages.java.gradle.linked_projects;
|
||||
assert_eq!(saved(), Some(vec!["a".into(), "b".into()]));
|
||||
assert!(ops::build::gradle_link_projects(&host, "main", vec!["missing".into()]).await.is_err());
|
||||
assert_eq!(saved(), Some(vec!["a".into(), "b".into()]));
|
||||
let outside = base.join("external").to_string_lossy().into_owned();
|
||||
let model = ops::build::gradle_link_projects(&host, "main", vec![outside.clone()]).await.unwrap();
|
||||
assert_eq!(model.gradle.len(), 3);
|
||||
let model = ops::build::gradle_unlink_projects(&host, "main", vec!["a".into(), "b".into(), outside]).await.unwrap();
|
||||
assert!(model.gradle.is_empty());
|
||||
assert_eq!(saved(), Some(vec![]));
|
||||
for dir in [root.join("a"), root.join("b"), base.join("external")] {
|
||||
assert!(dir.join("settings.gradle").is_file());
|
||||
}
|
||||
let model = ops::build::gradle_link_projects(&host, "main", vec!["b".into()]).await.unwrap();
|
||||
assert_eq!(model.gradle.len(), 1);
|
||||
assert_eq!(model.gradle[0].dir, "b");
|
||||
host.shutdown().await;
|
||||
}
|
||||
@@ -223,7 +223,8 @@ async fn kotlin_gradle() {
|
||||
.await;
|
||||
let p = open("kotlin-gradle").await;
|
||||
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
let gradle = model.gradle.expect("a Gradle model");
|
||||
assert_eq!(model.gradle.len(), 1, "one independent Gradle build");
|
||||
let gradle = &model.gradle[0];
|
||||
assert_eq!(gradle.projects.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(), ["openidea-fixture-library"]);
|
||||
}
|
||||
|
||||
@@ -231,8 +232,9 @@ async fn kotlin_gradle() {
|
||||
async fn sync(fixture: &str, tool: ide_build::BuildTool) -> ide_build::BuildModel {
|
||||
let p = open(fixture).await;
|
||||
let progress = ops::Channel::new(|_: ide_build::SyncProgress| {});
|
||||
let model =
|
||||
ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], progress).await.unwrap();
|
||||
let model = ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], None, progress)
|
||||
.await
|
||||
.unwrap();
|
||||
// The cached sync is what the next open shows.
|
||||
let reloaded = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
assert_eq!(reloaded, model);
|
||||
@@ -253,7 +255,9 @@ async fn maven_sync() {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs Gradle and a JDK"]
|
||||
async fn gradle_sync() {
|
||||
let gradle = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await.gradle.unwrap();
|
||||
let model = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await;
|
||||
assert_eq!(model.gradle.len(), 1, "one independent Gradle build");
|
||||
let gradle = &model.gradle[0];
|
||||
assert_eq!(gradle.sync_error, None);
|
||||
assert!(gradle.synced_at.is_some());
|
||||
let tasks: Vec<&str> = gradle.projects[0].tasks.iter().map(|t| t.name.as_str()).collect();
|
||||
|
||||
@@ -249,6 +249,7 @@ async fn malicious_project_stays_editable_then_starts_in_place_after_trust() {
|
||||
ide_core::RequestId(472),
|
||||
false,
|
||||
vec![],
|
||||
None,
|
||||
ops::Channel::new(|_| {}),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -22,3 +22,4 @@ wasmtime-wasi = { version = "49.0.4", default-features = false, features = ["p2"
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] }
|
||||
wat = "1.258"
|
||||
@@ -199,8 +199,10 @@ impl PluginEngine {
|
||||
/// Rejects a component this host can't run, with a message that says what
|
||||
/// to do: one importing interfaces outside the plugin API and WASI, or one
|
||||
/// built for a plugin API this host doesn't implement (another major, or a
|
||||
/// newer minor). Returns the API version the component was built for
|
||||
/// (`None`: it uses no plugin interface).
|
||||
/// newer minor), or one that doesn't link: an import the host lacks or
|
||||
/// declares with another type (same API version, different WIT), or a
|
||||
/// missing plugin export. Returns the API version the component was built
|
||||
/// for (`None`: it uses no plugin interface).
|
||||
pub fn check_component(&self, component: &Component) -> Result<Option<semver::Version>, String> {
|
||||
let ty = component.component_type();
|
||||
let imports: Vec<String> = ty.imports(&self.engine).map(|(name, _)| name.to_owned()).collect();
|
||||
@@ -217,10 +219,20 @@ impl PluginEngine {
|
||||
if let Some(v) = &built_for {
|
||||
check_api(v, &host_api())?;
|
||||
}
|
||||
let pre = self.linker.instantiate_pre(component).map_err(|e| link_error(&format!("{e:#}")))?;
|
||||
bindings::PluginIndices::new(&pre).map_err(|e| link_error(&format!("{e:#}")))?;
|
||||
Ok(built_for)
|
||||
}
|
||||
}
|
||||
|
||||
/// Explains a component that doesn't link against this host's plugin API.
|
||||
fn link_error(detail: &str) -> String {
|
||||
format!(
|
||||
"the component doesn't match the plugin API of this IDE ({API_VERSION}): {detail}. \
|
||||
If it was built for a newer OpenIDEA, update OpenIDEA; otherwise rebuild it with the {API_VERSION} SDK"
|
||||
)
|
||||
}
|
||||
|
||||
/// Interface names of the plugin API start with this (`openidea:plugin/[email protected]`).
|
||||
const PLUGIN_PACKAGE_PREFIX: &str = "openidea:plugin/";
|
||||
|
||||
@@ -530,11 +542,7 @@ impl Runtime {
|
||||
arm(&mut store, &setup.limits, setup.limits.timeout).map_err(|e| e.to_string())?;
|
||||
let api_hint = |e: wasmtime::Error| {
|
||||
let msg = describe_trap(&e, &setup.limits);
|
||||
if msg.contains("openidea:plugin/") {
|
||||
format!("{msg} (the component was built for a different plugin API; this IDE runs API {API_VERSION})")
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
if msg.contains("openidea:plugin/") { link_error(&msg) } else { msg }
|
||||
};
|
||||
let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?;
|
||||
let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! `PluginEngine::check_component` (what installing and `openidea-plugin check`
|
||||
//! run) refuses a component the IDE could not start: every import has to link
|
||||
//! against the host, function by function, and the plugin world's exports have
|
||||
//! to be there, not only the interface names and their API version.
|
||||
|
||||
use ide_plugin::PluginEngine;
|
||||
|
||||
fn check(wat: &str) -> Result<Option<semver::Version>, String> {
|
||||
let engine = PluginEngine::new().unwrap();
|
||||
let component = engine.compile_bytes(&wat::parse_str(wat).unwrap()).unwrap();
|
||||
engine.check_component(&component)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_the_host_cannot_link_are_refused() {
|
||||
let cases = [
|
||||
// dengliu.gitlink 0.1.0: functions added to `ui` on an unreleased branch, still at @1.0.0.
|
||||
(
|
||||
"a function the host's interface lacks",
|
||||
r#"(component (import "openidea:plugin/ui@1.0.0" (instance
|
||||
(export "copy-to-clipboard" (func (param "text" string))))))"#,
|
||||
"copy-to-clipboard",
|
||||
),
|
||||
(
|
||||
"a function with another signature",
|
||||
r#"(component (import "openidea:plugin/ui@1.0.0" (instance
|
||||
(export "show-notification" (func (param "title" string))))))"#,
|
||||
"show-notification",
|
||||
),
|
||||
(
|
||||
"an interface the host lacks",
|
||||
r#"(component (import "openidea:plugin/clipboard@1.0.0" (instance
|
||||
(export "copy" (func (param "text" string))))))"#,
|
||||
"openidea:plugin/[email protected]",
|
||||
),
|
||||
("no plugin exports", r#"(component (import "openidea:plugin/[email protected]" (instance)))"#, "lifecycle"),
|
||||
];
|
||||
for (case, wat, names) in cases {
|
||||
let err = check(wat).expect_err(case);
|
||||
assert!(err.contains(names), "{case}: {err}");
|
||||
assert!(err.contains("update OpenIDEA") && err.contains("rebuild"), "{case}: {err}");
|
||||
}
|
||||
}
|
||||
@@ -429,6 +429,36 @@ pub fn configure_maven_import(
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit Gradle links constrain discovery and isolate JDT's persisted import state.
|
||||
pub fn configure_gradle_import(
|
||||
cfg: &mut LanguageServerConfig,
|
||||
linked: Option<&[String]>,
|
||||
build_files: Option<&[String]>,
|
||||
) {
|
||||
let Some(linked) = linked.filter(|_| cfg.name == "jdtls") else { return };
|
||||
merge_init_options(
|
||||
cfg,
|
||||
json!({
|
||||
"settings": {"java": {"import": {"gradle": {"enabled": !linked.is_empty()}}}},
|
||||
"projectConfigurations": build_files.unwrap_or_default()
|
||||
}),
|
||||
);
|
||||
if let Some(settings) = &mut cfg.settings {
|
||||
merge(settings, json!({"java": {"import": {"gradle": {"enabled": !linked.is_empty()}}}}));
|
||||
}
|
||||
if let Some(index) = cfg.args.iter().position(|arg| arg == "-data")
|
||||
&& let Some(workspace) = cfg.args.get_mut(index + 1)
|
||||
{
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
for dir in linked {
|
||||
for byte in dir.bytes().chain([0]) {
|
||||
hash = (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3);
|
||||
}
|
||||
}
|
||||
*workspace = format!("{workspace}-gradle-{hash:016x}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
|
||||
let mut current =
|
||||
cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or_else(|| json!({}));
|
||||
@@ -1218,6 +1248,25 @@ mod java_compiler_tests {
|
||||
mod maven_linkage_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_gradle_links_scope_jdt_import_and_unlink_all_disables_gradle() {
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
cfg.args = vec!["-data".into(), "/cache/jdtls".into()];
|
||||
let files = ["file:///p/a/settings.gradle".to_owned(), "file:///p/maven/pom.xml".to_owned()];
|
||||
configure_gradle_import(&mut cfg, Some(&["a".into()]), Some(&files));
|
||||
let options = serde_json::to_value(&cfg.initialization_options).unwrap();
|
||||
assert_eq!(options["projectConfigurations"], json!(files));
|
||||
assert_eq!(options.pointer("/settings/java/import/gradle/enabled"), Some(&json!(true)));
|
||||
assert!(cfg.args[1].starts_with("/cache/jdtls-gradle-"));
|
||||
let linked_cache = cfg.args[1].clone();
|
||||
cfg.args[1] = "/cache/jdtls".into();
|
||||
configure_gradle_import(&mut cfg, Some(&[]), Some(&files[1..]));
|
||||
let options = serde_json::to_value(&cfg.initialization_options).unwrap();
|
||||
assert_eq!(options["projectConfigurations"], json!(&files[1..]));
|
||||
assert_eq!(options.pointer("/settings/java/import/gradle/enabled"), Some(&json!(false)));
|
||||
assert_ne!(linked_cache, cfg.args[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_excludes_unlinked_modules_and_changes_persistent_workspace() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -184,6 +184,12 @@ fn python(root: Option<&Path>) -> Vec<Sdk> {
|
||||
par_map(&candidates, |(path, source)| python_sdk(path, source, root))
|
||||
}
|
||||
|
||||
/// Interpreter `path` (one the project is set to, wherever it lives) as an SDK, named
|
||||
/// like the detected ones.
|
||||
pub fn python_interpreter_sdk(path: &Path, root: Option<&Path>) -> Option<Sdk> {
|
||||
python_sdk(path, if venv_of(path).is_some() { "venv" } else { "system" }, root)
|
||||
}
|
||||
|
||||
pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Option<Sdk> {
|
||||
let version = query(path, &["--version"], VERSION_TIMEOUT)
|
||||
.and_then(|v| v.strip_prefix("Python ").map(str::to_owned))
|
||||
@@ -193,10 +199,11 @@ pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Opti
|
||||
let label = match (&venv, source) {
|
||||
(Some(home), _) => {
|
||||
let dir = home.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
// `.venv` inside a project reads better as the project name, like PyCharm.
|
||||
// `.venv` inside a project reads better as the folder that holds it (the project,
|
||||
// or a member such as `backend`), like PyCharm.
|
||||
let owner = home.parent().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
|
||||
match (dir.starts_with('.') || dir == "venv" || dir == "env", owner, root) {
|
||||
(true, Some(owner), Some(root)) if home.parent() == Some(root) => format!("Python {short} ({owner})"),
|
||||
(true, Some(owner), Some(root)) if home.starts_with(root) => format!("Python {short} ({owner})"),
|
||||
_ => format!("Python {short} ({dir})"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use discover::{detect, jdk_sdk};
|
||||
pub use discover::{detect, jdk_sdk, python_interpreter_sdk};
|
||||
|
||||
/// What [`detect`] looks for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -76,6 +76,23 @@ fn project_venv_is_the_default_interpreter() {
|
||||
assert_eq!(python_interpreter(tmp.path(), &s).unwrap(), Path::new("/opt/py/bin/python3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interpreter_outside_detection_is_named_like_a_detected_one() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("shop");
|
||||
let member = fake_venv(&root.join("backend"), ".venv");
|
||||
let sdk = crate::python_interpreter_sdk(&member, Some(&root)).unwrap();
|
||||
assert_eq!(
|
||||
(sdk.name.as_str(), sdk.version.as_deref(), sdk.source.as_str()),
|
||||
("Python 3.12 (backend)", Some("3.12.1"), "venv")
|
||||
);
|
||||
let own = fake_venv(&root, ".venv");
|
||||
assert_eq!(crate::python_interpreter_sdk(&own, Some(&root)).unwrap().name, "Python 3.12 (shop)");
|
||||
// Outside the project the folder name says which one it is.
|
||||
let elsewhere = fake_venv(&tmp.path().join("other"), ".venv");
|
||||
assert_eq!(crate::python_interpreter_sdk(&elsewhere, Some(&root)).unwrap().name, "Python 3.12 (.venv)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pyright_gets_python_path_and_venv_env() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -395,6 +395,11 @@ pub fn parse_version(text: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A Windows App Execution Alias (`%LOCALAPPDATA%\Microsoft\WindowsApps\python.exe`).
|
||||
fn is_app_execution_alias(path: &Path) -> bool {
|
||||
path.parent().and_then(Path::file_name).is_some_and(|d| d.eq_ignore_ascii_case("WindowsApps"))
|
||||
}
|
||||
|
||||
/// A tool whose "command" is a plugin jar (e.g. a jdtls bundle) rather than an executable.
|
||||
fn is_bundle(command: &str) -> bool {
|
||||
command.ends_with(".jar")
|
||||
@@ -491,8 +496,32 @@ impl Tools {
|
||||
cmd
|
||||
}
|
||||
|
||||
/// The interpreter that installs and probes Python tools. Windows installs `python.exe`
|
||||
/// and the `py` launcher, not `python3.exe`; its `WindowsApps` entries are App Execution
|
||||
/// Aliases that only open the Store unless the Store's Python is installed, so they count
|
||||
/// only when they run.
|
||||
fn python(&self) -> Option<PathBuf> {
|
||||
self.which_any(&["python3", "python"])
|
||||
if self.platform.os != Os::Windows {
|
||||
return self.which_any(&["python3", "python"]);
|
||||
}
|
||||
["python", "py", "python3"]
|
||||
.into_iter()
|
||||
.flat_map(|command| {
|
||||
std::env::split_paths(&self.system_path).filter_map(move |dir| {
|
||||
if dir.as_os_str().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let search = std::env::join_paths([dir]).ok()?;
|
||||
ide_core::tool_path::find_command(command, &search, &self.root)
|
||||
})
|
||||
})
|
||||
.find(|p| {
|
||||
!is_app_execution_alias(p) || {
|
||||
let mut c = self.python_command(p);
|
||||
c.arg("--version");
|
||||
run_quiet(c, Duration::from_secs(5)).is_some_and(|(ok, _)| ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Executable `cmd` inside `dir`, honouring Windows suffixes.
|
||||
|
||||
@@ -37,6 +37,12 @@ impl Env {
|
||||
fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||
archive::make_executable(&p).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn script(&self, dir: &Path, name: &str, body: &str) {
|
||||
fs::create_dir_all(dir).unwrap();
|
||||
fs::write(dir.join(format!("{name}.cmd")), format!("@echo off\r\n{body}\r\n")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn find<'a>(list: &'a [ToolInfo], name: &str) -> &'a ToolInfo {
|
||||
@@ -700,6 +706,34 @@ fn rust_analyzer_managed_copy_survives_rustup_becoming_available() {
|
||||
assert!(!env.root.join("rust-analyzer").exists());
|
||||
}
|
||||
|
||||
/// Windows has `python.exe` and `py`, not `python3.exe`; the `WindowsApps` aliases only count
|
||||
/// when the Store's Python behind them runs.
|
||||
#[test]
|
||||
fn windows_python_skips_store_aliases_that_do_not_run() {
|
||||
let env = Env::new();
|
||||
let aliases = env.sys.parent().unwrap().join("Microsoft").join("WindowsApps");
|
||||
env.script(&aliases, "python", "exit 9009");
|
||||
env.script(&aliases, "python3", "exit 9009");
|
||||
env.script(&env.sys, "py", "echo Python 3.12.0");
|
||||
let windows = Platform { os: Os::Windows, arch: platform::Arch::X64 };
|
||||
let found = || {
|
||||
let dirs = std::env::join_paths([aliases.clone(), env.sys.clone()]).unwrap();
|
||||
Tools::with_paths(env.root.clone(), dirs, windows).python().map(|python| {
|
||||
let dir = python.parent().unwrap().file_name().unwrap().to_string_lossy().into_owned();
|
||||
format!("{dir}/{}", python.file_stem().unwrap().to_string_lossy())
|
||||
})
|
||||
};
|
||||
assert_eq!(found().as_deref(), Some("sysbin/py"));
|
||||
fs::remove_file(env.sys.join(if cfg!(windows) { "py.cmd" } else { "py" })).unwrap();
|
||||
assert_eq!(found(), None, "only broken aliases are available");
|
||||
env.script(&env.sys, "python3", "echo Python 3.12.0");
|
||||
assert_eq!(found().as_deref(), Some("sysbin/python3"), "python3 is the final fallback");
|
||||
env.script(&env.sys, "python", "echo Python 3.12.0");
|
||||
assert_eq!(found().as_deref(), Some("sysbin/python"), "a broken alias must not hide a later interpreter");
|
||||
env.script(&aliases, "python", "echo Python 3.13.0");
|
||||
assert_eq!(found().as_deref(), Some("WindowsApps/python"), "the Store's Python runs");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn python_probes_run_in_an_empty_private_directory() {
|
||||
|
||||
@@ -404,6 +404,9 @@ fn merge(prev: Option<Change>, next: Change) -> Change {
|
||||
match (prev, next) {
|
||||
(None, n) => n,
|
||||
(Some(Vcs), _) | (_, Vcs) => Vcs,
|
||||
// FSEvents reports a deleted file's history as create, remove, then modify: a
|
||||
// modify can't bring back a path that went away, only a create can.
|
||||
(Some(Transient), Modified) => Transient,
|
||||
(Some(Created | Transient), Modified | Created) => Created,
|
||||
(Some(Created | Transient), Removed) => Transient,
|
||||
(Some(Removed), Created | Modified) => Modified,
|
||||
@@ -630,7 +633,7 @@ impl EventFilter {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use notify::event::{CreateKind, DataChange, RemoveKind};
|
||||
use notify::event::{CreateKind, DataChange, MetadataKind, RemoveKind};
|
||||
|
||||
fn ev(kind: EventKind, paths: Vec<PathBuf>) -> Event {
|
||||
Event { kind, paths, attrs: Default::default() }
|
||||
@@ -788,6 +791,24 @@ mod tests {
|
||||
assert_eq!(batch.ignored, vec![root.join("debug.log")], "ignored files are reported apart");
|
||||
}
|
||||
|
||||
/// What FSEvents delivers when a file is deleted: its coalesced history flags as
|
||||
/// create, remove, then modify events, all for a path that no longer exists.
|
||||
#[test]
|
||||
fn fsevents_deletion_with_history_flags_is_a_removal() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let filter = EventFilter::new(root.clone(), &[]);
|
||||
let gone = root.join("src/Fresh.java");
|
||||
let batch = filter.batch(&[
|
||||
ev(EventKind::Create(CreateKind::File), vec![gone.clone()]),
|
||||
ev(EventKind::Remove(RemoveKind::File), vec![gone.clone()]),
|
||||
ev(EventKind::Modify(ModifyKind::Metadata(MetadataKind::Extended)), vec![gone.clone()]),
|
||||
ev(EventKind::Modify(ModifyKind::Data(DataChange::Content)), vec![gone.clone()]),
|
||||
]);
|
||||
assert_eq!(batch.removed, vec![gone]);
|
||||
assert!(batch.created.is_empty() && batch.modified.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_save_temp_files_are_not_reported() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -25,7 +25,7 @@ pub use editorconfig::{EditorConfig, EditorConfigProps};
|
||||
pub use file_defaults::FileDefaults;
|
||||
pub use file_moves::{FileMove, FileMoves, UndoOutcome};
|
||||
pub use history::{DirectoryHistory, HistoryEntry, HistoryKind, LocalHistory, Retention};
|
||||
pub use recent::{RecentProject, RecentProjects};
|
||||
pub use recent::{RecentProject, RecentProjectGroup, RecentProjects};
|
||||
pub use roots::{ProjectRoots, RootKind, SourceFolder};
|
||||
pub use settings::{SettingsScope, SettingsStore};
|
||||
|
||||
|
||||
@@ -16,10 +16,78 @@ pub struct RecentProject {
|
||||
|
||||
pub const MAX_RECENT_PROJECTS: usize = 30;
|
||||
|
||||
/// Persisted at `<app-config>/recent.json`, newest first.
|
||||
/// A named group of recent projects on the welcome screen (New Project Group…, Move to Group).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecentProjectGroup {
|
||||
pub name: String,
|
||||
/// Its projects, in the order they were added.
|
||||
pub paths: Vec<ide_core::IpcPath>,
|
||||
#[serde(default = "expanded")]
|
||||
pub expanded: bool,
|
||||
}
|
||||
|
||||
fn expanded() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Persisted at `<app-config>/recent.json`, newest first; groups at `<app-config>/recent-groups.json`.
|
||||
pub struct RecentProjects {
|
||||
file: PathBuf,
|
||||
items: Mutex<Vec<RecentProject>>,
|
||||
groups_file: PathBuf,
|
||||
groups: Mutex<Vec<RecentProjectGroup>>,
|
||||
}
|
||||
|
||||
fn read_json<T: serde::de::DeserializeOwned>(file: &Path) -> Option<T> {
|
||||
match std::fs::read(file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||
.map_err(|e| tracing::warn!("{} is invalid, ignoring: {e}", file.display()))
|
||||
.ok(),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot read {}: {e}", file.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_json<T: Serialize + ?Sized>(file: &Path, value: &T) -> IdeResult<()> {
|
||||
if let Some(dir) = file.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(value).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
ide_vfs::write_atomic(file, &json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Each path in one group at most (the first keeps it), group names trimmed and unique.
|
||||
fn normalize(groups: Vec<RecentProjectGroup>) -> Vec<RecentProjectGroup> {
|
||||
let mut paths = std::collections::HashSet::new();
|
||||
let mut names = std::collections::HashSet::new();
|
||||
groups
|
||||
.into_iter()
|
||||
.filter_map(|mut g| {
|
||||
g.name = g.name.trim().to_owned();
|
||||
if g.name.is_empty() || !names.insert(g.name.clone()) {
|
||||
return None;
|
||||
}
|
||||
g.paths.retain(|p| paths.insert(p.clone()));
|
||||
Some(g)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keeps the newest [`MAX_RECENT_PROJECTS`] projects outside groups; grouped ones stay.
|
||||
fn cap(items: &mut Vec<RecentProject>, groups: &[RecentProjectGroup]) {
|
||||
let mut ungrouped = 0;
|
||||
items.retain(|p| {
|
||||
if groups.iter().any(|g| g.paths.contains(&p.path)) {
|
||||
return true;
|
||||
}
|
||||
ungrouped += 1;
|
||||
ungrouped <= MAX_RECENT_PROJECTS
|
||||
});
|
||||
}
|
||||
|
||||
fn now_ms() -> f64 {
|
||||
@@ -29,28 +97,40 @@ fn now_ms() -> f64 {
|
||||
impl RecentProjects {
|
||||
pub fn load(config_dir: &Path) -> Self {
|
||||
let file = config_dir.join("recent.json");
|
||||
let mut items: Vec<RecentProject> = match std::fs::read(&file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("{} is invalid, ignoring: {e}", file.display());
|
||||
Vec::new()
|
||||
}),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot read {}: {e}", file.display());
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let groups_file = config_dir.join("recent-groups.json");
|
||||
let mut items: Vec<RecentProject> = read_json(&file).unwrap_or_default();
|
||||
let mut groups = normalize(read_json(&groups_file).unwrap_or_default());
|
||||
items.sort_by(|a, b| b.opened_at_ms.total_cmp(&a.opened_at_ms));
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
items.retain(|p| seen.insert(p.path.clone()));
|
||||
items.truncate(MAX_RECENT_PROJECTS);
|
||||
Self { file, items: Mutex::new(items) }
|
||||
cap(&mut items, &groups);
|
||||
// A group lists only projects that are still recent.
|
||||
for g in &mut groups {
|
||||
g.paths.retain(|p| items.iter().any(|i| i.path == *p));
|
||||
}
|
||||
Self { file, items: Mutex::new(items), groups_file, groups: Mutex::new(groups) }
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<RecentProject> {
|
||||
self.items.lock().clone()
|
||||
}
|
||||
|
||||
pub fn groups(&self) -> Vec<RecentProjectGroup> {
|
||||
self.groups.lock().clone()
|
||||
}
|
||||
|
||||
/// Replaces the groups (paths that are not recent projects are dropped).
|
||||
pub fn set_groups(&self, groups: Vec<RecentProjectGroup>) -> IdeResult<Vec<RecentProjectGroup>> {
|
||||
let items = self.items.lock();
|
||||
let mut groups = normalize(groups);
|
||||
for g in &mut groups {
|
||||
g.paths.retain(|p| items.iter().any(|i| i.path == *p));
|
||||
}
|
||||
write_json(&self.groups_file, &groups)?;
|
||||
*self.groups.lock() = groups.clone();
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
/// Moves (or adds) `path` to the top of the list.
|
||||
pub fn touch(&self, path: &Path, branch: Option<String>) -> IdeResult<()> {
|
||||
let mut items = self.items.lock();
|
||||
@@ -58,7 +138,7 @@ impl RecentProjects {
|
||||
let name =
|
||||
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string());
|
||||
items.insert(0, RecentProject { path: path.into(), name, opened_at_ms: now_ms(), branch });
|
||||
items.truncate(MAX_RECENT_PROJECTS);
|
||||
cap(&mut items, &self.groups.lock());
|
||||
self.persist(&items)
|
||||
}
|
||||
|
||||
@@ -83,7 +163,15 @@ impl RecentProjects {
|
||||
if items.len() == before {
|
||||
return Ok(());
|
||||
}
|
||||
self.persist(&items)
|
||||
self.persist(&items)?;
|
||||
let mut groups = self.groups.lock();
|
||||
if groups.iter().any(|g| g.paths.iter().any(|p| p == path)) {
|
||||
for g in groups.iter_mut() {
|
||||
g.paths.retain(|p| p != path);
|
||||
}
|
||||
write_json(&self.groups_file, &*groups)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn paths(&self) -> Vec<PathBuf> {
|
||||
@@ -91,12 +179,7 @@ impl RecentProjects {
|
||||
}
|
||||
|
||||
fn persist(&self, items: &[RecentProject]) -> IdeResult<()> {
|
||||
if let Some(dir) = self.file.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(items).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
ide_vfs::write_atomic(&self.file, &json)?;
|
||||
Ok(())
|
||||
write_json(&self.file, items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +214,41 @@ mod tests {
|
||||
assert!(RecentProjects::load(&cfg).list().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_keep_their_projects_and_survive_a_reload() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let recent = RecentProjects::load(tmp.path());
|
||||
for i in 0..3 {
|
||||
recent.touch(&PathBuf::from(format!("/p/proj{i}")), None).unwrap();
|
||||
}
|
||||
let group = |name: &str, paths: &[&str]| RecentProjectGroup {
|
||||
name: name.into(),
|
||||
paths: paths.iter().map(|p| PathBuf::from(p).into()).collect(),
|
||||
expanded: true,
|
||||
};
|
||||
let saved = recent
|
||||
.set_groups(vec![
|
||||
group(" Work ", &["/p/proj0", "/p/gone"]),
|
||||
group("Work", &["/p/proj1"]),
|
||||
group("Oss", &["/p/proj0", "/p/proj2"]),
|
||||
group(" ", &[]),
|
||||
])
|
||||
.unwrap();
|
||||
// Trimmed, unique names; each project in one group; only recent projects.
|
||||
assert_eq!(saved, vec![group("Work", &["/p/proj0"]), group("Oss", &["/p/proj2"])]);
|
||||
assert_eq!(RecentProjects::load(tmp.path()).groups(), saved);
|
||||
|
||||
// A grouped project outlives the cap; removing it from recent projects ungroups it.
|
||||
for i in 3..40 {
|
||||
recent.touch(&PathBuf::from(format!("/p/proj{i}")), None).unwrap();
|
||||
}
|
||||
let paths = recent.paths();
|
||||
assert_eq!(paths.len(), MAX_RECENT_PROJECTS + 2);
|
||||
assert!(paths.contains(&PathBuf::from("/p/proj0")) && paths.contains(&PathBuf::from("/p/proj2")));
|
||||
recent.remove(Path::new("/p/proj2")).unwrap();
|
||||
assert_eq!(RecentProjects::load(tmp.path()).groups(), vec![group("Work", &["/p/proj0"]), group("Oss", &[])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_shows_a_custom_name_until_reset() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -192,7 +192,9 @@ ship no `.icls` at all keep the built-in syntax colours.
|
||||
| `--danger`, `--warning` | `Component.errorFocusColor`, `Component.warningFocusColor` |
|
||||
| `--bg-tooltip`, `--fg-tooltip`, `--border-tooltip` | `ToolTip.background`, `.foreground`, `.borderColor` |
|
||||
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
||||
| `--bg-tab-selected`, `--border-tab-selected` | `EditorTabs.underlinedTabBackground`, `EditorTabs.underlineColor` |
|
||||
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
||||
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
|
||||
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
|
||||
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
||||
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
||||
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `ScrollBar.Mac.thumbColor`, `ScrollBar.Mac.hoverThumbColor` |
|
||||
@@ -209,6 +211,7 @@ The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../../s
|
||||
| `--caret`, `--bg-current-line` | colour `CARET_COLOR`, `CARET_ROW_COLOR` |
|
||||
| `--bg-editor-selection` | colour `SELECTION_BACKGROUND` |
|
||||
| `--fg-line-number`, `--fg-line-number-current` | colour `LINE_NUMBERS_COLOR`, `LINE_NUMBER_ON_CARET_ROW_COLOR` |
|
||||
| `--bg-editor-gutter` | colour `EDITOR_GUTTER_BACKGROUND`, else the editor background (as the new UI paints it; `GUTTER_BACKGROUND` is the classic UI's) |
|
||||
| `--border-indent-guide`, `--border-right-margin`, `--fg-whitespace` | colour `INDENT_GUIDE`, `RIGHT_MARGIN_COLOR`, `WHITESPACES` |
|
||||
| `--vcs-*`, `--gutter-*` | colour `FILESTATUS_*`, `MODIFIED_LINES_COLOR`, `ADDED_LINES_COLOR`, `DELETED_LINES_COLOR` |
|
||||
| `--syn-keyword`, `--syn-string`, `--syn-number` | `DEFAULT_KEYWORD`, `DEFAULT_STRING`, `DEFAULT_NUMBER` |
|
||||
@@ -226,7 +229,9 @@ The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../../s
|
||||
| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` |
|
||||
| `--ansi-*` | `CONSOLE_*_OUTPUT` (`CONSOLE_DARKGRAY_OUTPUT` is bright black, `CONSOLE_GRAY_OUTPUT` white) |
|
||||
|
||||
The full tables are `ICLS_COLORS` and `ICLS_ATTRIBUTES` in the same file.
|
||||
The full tables are `ICLS_COLORS` and `ICLS_ATTRIBUTES` in the same file. An
|
||||
attribute written as `<value />` has no colours of its own and shows as plain
|
||||
text (`TEXT`'s foreground), as in IntelliJ, not with the parent scheme's colour.
|
||||
|
||||
### From VS Code (by hand)
|
||||
|
||||
|
||||
@@ -22,7 +22,15 @@ Paths in an existing manifest may be relative to the workspace directory. New an
|
||||
|
||||
Linked folders appear as roots in Project view and participate in indexing, Find in Files, Git repository discovery and language-server root selection. Detected and saved run configurations have project-qualified names and use their member's working directory and launch settings. Build Project combines the linked projects' build plans; Compile File targets its owning project. Workspace editor/settings state belongs to its own directory. Each member retains its repository and project configuration.
|
||||
|
||||
The specialized Maven/Gradle tool-window model still describes the workspace directory itself; it does not yet aggregate independent member models. Workspace creation and management currently target local folders.
|
||||
The specialized Maven/Gradle tool-window model still describes the workspace directory itself; it does not yet aggregate independent member models. Workspace creation and management currently target local folders. To link independent Gradle builds in one Gradle tree, open their parent folder as an ordinary project instead of creating a Workspace manifest, then use **Link Gradle Projects…** in the Gradle toolbar or Find Action.
|
||||
|
||||
## Linked Gradle builds in a project
|
||||
|
||||
In an ordinary project window, **Link Gradle Projects…** accepts one or more `settings.gradle`, `settings.gradle.kts`, `build.gradle`, or `build.gradle.kts` files. Each linked build has its own root in the Gradle tool window. Its tasks, dependencies, wrapper, working directory and sync cache stay separate, even when another build has the same `:app` project or task names. Included builds remain under the build that includes them.
|
||||
|
||||
Right-click a build root for **Reload Gradle Project** or **Unlink Gradle Project**. Unlinking only removes the IDE connection; it does not delete files or saved run configurations. The Link button remains available after the last build is unlinked. Linked builds may live outside the opened folder.
|
||||
|
||||
Links are saved in project settings as `languages.java.gradle.linkedProjects`. Until links are configured, existing automatic discovery is unchanged. An explicit empty list means that no Gradle builds are linked. Opening the project again restores its links. A folder first opened by an older version may need one fresh sync because independent builds now have separate caches.
|
||||
|
||||
## Window group
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
const xml = readFileSync(
|
||||
process.env.OPENIDEA_ICLS_FIXTURE ?? "src/platform/__fixtures__/colorSchemes/monokai-php.icls",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("imported PHP colours stay separate from Java and preserve regular text on a dark background", async ({
|
||||
workbench: page,
|
||||
}, info) => {
|
||||
await page.evaluate(async (xml) => {
|
||||
const iclsPath = "/src/platform/icls.ts";
|
||||
const projectPath = "/src/platform/project.ts";
|
||||
const backendPath = "/src/editor/core/backend.ts";
|
||||
const contributionsPath = "/src/workbench/contributions.ts";
|
||||
const { parseIcls } = await import(iclsPath);
|
||||
const { useProject } = await import(projectPath);
|
||||
const { docBackend } = await import(backendPath);
|
||||
const { editorService } = await import(contributionsPath);
|
||||
const previous = useProject.getState().settings ?? {};
|
||||
useProject.setState({
|
||||
settings: {
|
||||
...previous,
|
||||
editor: {
|
||||
...previous.editor,
|
||||
colorScheme: "custom-php",
|
||||
customSchemes: { "custom-php": parseIcls(xml).scheme },
|
||||
semanticHighlighting: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
const backend = docBackend();
|
||||
const open = backend.open.bind(backend);
|
||||
backend.open = async (...args: Parameters<typeof open>) => {
|
||||
const result = await open(...args);
|
||||
return args[0].endsWith(".php") ? { ...result, language: "php", uiMode: "PHP" } : result;
|
||||
};
|
||||
backend.files.set(
|
||||
"/dev/acme-api/palette.php",
|
||||
"<?php\n/** Plain PHP documentation */\nclass Palette extends BasePalette {\n var $name = 'Palette';\n function example($value) {\n return $this->name == 10;\n }\n}\n",
|
||||
);
|
||||
await editorService().openFile("/dev/acme-api/palette.php");
|
||||
}, xml);
|
||||
const php = page.locator('.cm-editor[data-language="PHP"]');
|
||||
await expect(php).toBeVisible();
|
||||
await expect(php).toHaveCSS("background-color", "rgb(0, 0, 0)");
|
||||
const token = (text: string) =>
|
||||
php
|
||||
.locator(".cm-content span")
|
||||
.filter({ hasText: text })
|
||||
.filter({ hasNot: php.locator("span span") });
|
||||
await expect(token("$name").first()).toHaveCSS("color", "rgb(72, 216, 238)");
|
||||
await expect(token("'Palette'")).toHaveCSS("color", "rgb(255, 231, 146)");
|
||||
await expect(token("'Palette'")).toHaveCSS("font-weight", "400");
|
||||
await expect(token("class").first()).toHaveCSS("color", "rgb(249, 38, 114)");
|
||||
await expect(token("Plain PHP documentation")).toHaveCSS("font-style", "normal");
|
||||
await expect(token("BasePalette")).toHaveCSS("color", "rgb(174, 129, 255)");
|
||||
await expect(token(";").first()).toHaveCSS("color", "rgb(255, 255, 255)");
|
||||
await expect(token("example")).toHaveCSS("color", "rgb(141, 225, 45)");
|
||||
await expect(token("$name").first()).toHaveCSS("font-weight", "700");
|
||||
await page.evaluate(async () => {
|
||||
const backendPath = "/src/editor/core/backend.ts";
|
||||
const contributionsPath = "/src/workbench/contributions.ts";
|
||||
const { docBackend } = await import(backendPath);
|
||||
const { editorService } = await import(contributionsPath);
|
||||
docBackend().files.set("/dev/acme-api/Palette.java", 'class Palette { String name = "Palette"; }');
|
||||
await editorService().openFile("/dev/acme-api/Palette.java");
|
||||
});
|
||||
const java = page.locator('.cm-editor[data-language="Java"]');
|
||||
await expect(java).toBeVisible();
|
||||
await expect(java.locator(".cm-content span").filter({ hasText: '"Palette"' })).toHaveCSS("font-weight", "700");
|
||||
await page.getByRole("tab", { name: "palette.php", exact: true }).click();
|
||||
await expect(php).toBeVisible();
|
||||
await expect(token("'Palette'")).toHaveCSS("font-weight", "400");
|
||||
await php.screenshot({ path: info.outputPath("php-import.png"), animations: "disabled" });
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/** Linked Gradle builds use isolated native stubs; no real builds or files are touched. */
|
||||
import type { GradleModel, RunConfig } from "../src/platform/ipc";
|
||||
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const build = (dir: string): GradleModel => ({
|
||||
dir,
|
||||
command: `/dev/acme-api/${dir}/gradlew`,
|
||||
env: { JAVA_HOME: `/${dir}/jdk` },
|
||||
javaVersion: 21,
|
||||
syncedAt: 1,
|
||||
stale: false,
|
||||
syncError: null,
|
||||
projects: [
|
||||
{ path: ":", name: dir, dir, buildFile: `${dir}/build.gradle`, tasks: [] },
|
||||
{
|
||||
path: ":app",
|
||||
name: "app",
|
||||
dir: `${dir}/app`,
|
||||
buildFile: `${dir}/app/build.gradle`,
|
||||
tasks: [{ name: "assemble", group: "build", description: "Assembles this project" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
test("links independent Gradle roots, runs identical task paths in their own builds and unlinks without deletion", async ({
|
||||
workbench: page,
|
||||
}, info) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(
|
||||
async ({ first, second }) => {
|
||||
let linked = [first];
|
||||
let runId = 0;
|
||||
const model = () => ({ maven: null, gradle: linked });
|
||||
const h = window.__ipc.handlers;
|
||||
h.build_model = model;
|
||||
h.build_sync = model;
|
||||
h.run_configs = () => [];
|
||||
h.run_apply_settings = (args) => args.configs;
|
||||
h.gradle_link_projects = () => {
|
||||
if (!linked.some((g) => g.dir === second.dir)) linked = [...linked, second];
|
||||
return model();
|
||||
};
|
||||
h.gradle_unlink_projects = (args) => {
|
||||
linked = linked.filter((g) => !(args.paths as string[]).includes(g.dir));
|
||||
return model();
|
||||
};
|
||||
h["plugin:dialog|open"] = () => ["/dev/acme-api/second/settings.gradle"];
|
||||
h.run_start = (args) => {
|
||||
const config = args.config as RunConfig;
|
||||
const id = ++runId;
|
||||
setTimeout(() => window.__ipc.emit({ type: "runExited", run: id, code: 0 }), 50);
|
||||
return { id, configId: config.id, name: config.name, pid: 4242 + id, running: true, pty: false };
|
||||
};
|
||||
const storeModule = "/src/features/build-tools/store.ts";
|
||||
const { loadModel } = await import(/* @vite-ignore */ storeModule);
|
||||
await loadModel();
|
||||
const contributionsModule = "/src/workbench/contributions.ts";
|
||||
const { toolWindowService } = await import(/* @vite-ignore */ contributionsModule);
|
||||
toolWindowService().show("gradle", true);
|
||||
},
|
||||
{ first: build("first"), second: build("second") },
|
||||
);
|
||||
|
||||
const tree = page.getByRole("tree", { name: "Gradle projects" });
|
||||
await expect(tree.getByRole("treeitem", { name: "first", exact: true })).toBeVisible();
|
||||
await expect(tree.getByRole("treeitem", { name: "second", exact: true })).toHaveCount(0);
|
||||
await page.screenshot({ path: info.outputPath("gradle-one-root.png"), animations: "disabled" });
|
||||
|
||||
await page.getByRole("button", { name: "Link Gradle Projects", exact: true }).click();
|
||||
await expect
|
||||
.poll(() => ipcCalls(page, "gradle_link_projects"))
|
||||
.toEqual([{ paths: ["/dev/acme-api/second/settings.gradle"] }]);
|
||||
await expect(tree.getByRole("treeitem", { name: "second", exact: true })).toBeVisible();
|
||||
// Reloading the model reads the persisted linkage again.
|
||||
await page.evaluate(async () => {
|
||||
const storeModule = "/src/features/build-tools/store.ts";
|
||||
const { loadModel } = await import(/* @vite-ignore */ storeModule);
|
||||
await loadModel();
|
||||
const actionModule = "/src/platform/actions.ts";
|
||||
const { executeAction } = await import(/* @vite-ignore */ actionModule);
|
||||
await executeAction("gradle.expandAll");
|
||||
await executeAction("gradle.toggleOffline");
|
||||
});
|
||||
const tasks = tree.getByRole("treeitem", { name: /^assemble\b/ });
|
||||
await expect(tasks).toHaveCount(2);
|
||||
await page.screenshot({ path: info.outputPath("gradle-two-roots.png"), animations: "disabled" });
|
||||
|
||||
await tasks.nth(0).dblclick();
|
||||
await expect.poll(async () => (await ipcCalls(page, "run_start")).length).toBe(1);
|
||||
await tasks.nth(1).dblclick();
|
||||
await expect.poll(async () => (await ipcCalls(page, "run_start")).length).toBe(2);
|
||||
const runs = (await ipcCalls(page, "run_start")).map((args) => args.config as RunConfig);
|
||||
expect(runs[0]!.id).not.toBe(runs[1]!.id);
|
||||
for (const [index, dir] of ["first", "second"].entries()) {
|
||||
expect(runs[index]).toMatchObject({
|
||||
command: `/dev/acme-api/${dir}/gradlew`,
|
||||
cwd: dir,
|
||||
args: [":app:assemble", "--offline"],
|
||||
env: { JAVA_HOME: `/${dir}/jdk` },
|
||||
});
|
||||
}
|
||||
|
||||
for (const dir of ["second", "first"]) {
|
||||
await tree.getByRole("treeitem", { name: dir, exact: true }).click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Unlink Gradle Project", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Unlink Gradle project?" });
|
||||
await expect(dialog).toContainText("Files will remain");
|
||||
await dialog.getByRole("button", { name: "Unlink", exact: true }).click();
|
||||
await expect(tree.getByRole("treeitem", { name: dir, exact: true })).toHaveCount(0);
|
||||
}
|
||||
await expect
|
||||
.poll(() => ipcCalls(page, "gradle_unlink_projects"))
|
||||
.toEqual([{ paths: ["second"] }, { paths: ["first"] }]);
|
||||
await expect(page.getByText("No Gradle projects", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Link Gradle Projects", exact: true })).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(() => window.__ipc.calls.filter((c) => /^fs_(?:delete|remove|trash)/.test(c.cmd))),
|
||||
).toEqual([]);
|
||||
await page.screenshot({ path: info.outputPath("gradle-unlinked.png"), animations: "disabled" });
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, openFromTree, test } from "./fixtures";
|
||||
|
||||
// Headless Chromium hides scrollbars by default, so nothing native sits at the editor's right
|
||||
// edge and the press reaches the overview lane. The tests in `overviewLane.spec.ts` keep a
|
||||
// scrollbar there instead.
|
||||
test("pressing the overview lane scrolls instead of starting a text selection", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "build.gradle.kts");
|
||||
await editor.locator(".cm-content").click();
|
||||
await page.keyboard.press("Meta+a");
|
||||
await page.keyboard.insertText(Array.from({ length: 400 }, (_, i) => `val line${i} = ${i}`).join("\n"));
|
||||
await page.keyboard.press("Escape");
|
||||
const scroller = editor.locator(".cm-scroller");
|
||||
await scroller.evaluate((s) => (s.scrollTop = 0));
|
||||
const box = (await scroller.boundingBox())!;
|
||||
// The lane replaces the native scrollbar: a press on it must stay a scrollbar press.
|
||||
const x = box.x + box.width - 5;
|
||||
const y = box.y + box.height - 40;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x, y - 120, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
const empty = await editor.locator(".cm-content").evaluate((el) => {
|
||||
const view = (el as unknown as { cmTile: { view: { state: { selection: { main: { empty: boolean } } } } } }).cmTile
|
||||
.view;
|
||||
return view.state.selection.main.empty;
|
||||
});
|
||||
expect(empty).toBe(true);
|
||||
});
|
||||
+4
-1
@@ -71,7 +71,8 @@ target through rustup if needed), copies the component to the manifest's
|
||||
`wasm` path and runs `check`. `check` validates the package exactly as
|
||||
_Install Plugin from Disk…_ would — manifest, every contribution, the SVG
|
||||
sanitiser, the component — and that the component imports nothing but the
|
||||
plugin API and WASI. It prints what the user will be asked to grant.
|
||||
plugin API and WASI and links against the IDE's: every function it imports
|
||||
exists with the same signature, and it exports the plugin world. It prints what the user will be asked to grant.
|
||||
|
||||
`link` adds the folder to `links.json` in the IDE's plugins directory
|
||||
(`$OPENIDEA_PLUGINS_DIR`, default `<app-data>/ai.l0ng.openidea/plugins`). Linked
|
||||
@@ -942,6 +943,8 @@ only grows:
|
||||
its `openidea:plugin/*` imports and exports, `PluginEngine::check_component`).
|
||||
Installing and `openidea-plugin check` refuse another major or a newer
|
||||
minor with a message that says to update the IDE or rebuild with the SDK.
|
||||
They also link the component, so one that imports a function this host
|
||||
lacks under the same version (built from unreleased WIT) is refused too.
|
||||
5. **One version number**: the `wit/` package version, `ENGINE_VERSION` and
|
||||
`API_VERSION` agree, and the SDK's `wit/` copy is identical to `wit/`.
|
||||
6. **1.x calls are synchronous and pass documents whole.** Host calls block
|
||||
|
||||
@@ -91,6 +91,8 @@ describe("IntelliJ theme import", () => {
|
||||
"Button": { "default": { "startBackground": "accent" } },
|
||||
"Popup.background": "#181f28",
|
||||
"Label.infoForeground": "#80888F80",
|
||||
"EditorTabs": { "underlineColor": "#405060", "underlinedBorderColor": "#bd93f9" },
|
||||
"CompletionPopup.matchForeground": "#ff79c6",
|
||||
"Tree": { "rowHeight": 24 }
|
||||
}
|
||||
}`;
|
||||
@@ -99,6 +101,7 @@ describe("IntelliJ theme import", () => {
|
||||
<option name="CARET_COLOR" value="ffcc00" />
|
||||
<option name="LINE_NUMBERS_COLOR" value="0" />
|
||||
<option name="UNKNOWN_KEY" value="123456" />
|
||||
<option name="GUTTER_BACKGROUND" value="313335" />
|
||||
</colors>
|
||||
<attributes>
|
||||
<option name="DEFAULT_KEYWORD">
|
||||
@@ -126,6 +129,9 @@ describe("IntelliJ theme import", () => {
|
||||
</value>
|
||||
</option>
|
||||
<option name="DEFAULT_CONSTANT" baseAttributes="DEFAULT_IDENTIFIER" />
|
||||
<option name="DEFAULT_INSTANCE_FIELD">
|
||||
<value />
|
||||
</option>
|
||||
</attributes>
|
||||
</scheme>`;
|
||||
|
||||
@@ -138,6 +144,9 @@ describe("IntelliJ theme import", () => {
|
||||
expect(t.tokens["--bg-selected"]).toBe("#203040");
|
||||
expect(t.tokens["--fg-muted"]).toBe("#80888f80");
|
||||
expect(t.tokens["--fg"]).toBe("#c0c8d0");
|
||||
// Islands borders the selected tab with underlinedBorderColor.
|
||||
expect(t.tokens["--border-tab-selected"]).toBe("#bd93f9");
|
||||
expect(t.tokens["--fg-completion-match"]).toBe("#ff79c6");
|
||||
expect(s).toBeNull();
|
||||
});
|
||||
|
||||
@@ -165,9 +174,12 @@ describe("IntelliJ theme import", () => {
|
||||
"--syn-comment-style": "italic",
|
||||
"--fg-editor": "#a9b7c6",
|
||||
"--bg-editor": "#2b2b2b",
|
||||
// GUTTER_BACKGROUND is the classic UI's: the gutter keeps the editor's background.
|
||||
"--bg-editor-gutter": "#2b2b2b",
|
||||
"--wave-error": "#ff0000",
|
||||
"--editor-error-decoration": "underline wavy #ff0000",
|
||||
// <value />: plain text, not the parent scheme's colour.
|
||||
"--syn-property": "#a9b7c6",
|
||||
});
|
||||
expect(s.tokens["--syn-constant"]).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -78,7 +78,7 @@ block2 = "0.6"
|
||||
# Plugin secrets in the login keychain.
|
||||
security-framework = "3.7"
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSResponder", "NSScreen", "NSView", "NSWindow"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSEvent", "NSResponder", "NSScreen", "NSView", "NSWindow"] }
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "CFRunLoop"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] }
|
||||
# Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions.
|
||||
|
||||
@@ -2,8 +2,8 @@ use crate::state::AppState;
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{EventSink, IdeError, IdeResult, RequestId, TaskId};
|
||||
use ide_language::LanguageInfo;
|
||||
use ide_workspace::RecentProject;
|
||||
use ide_workspace::settings::SettingsScope;
|
||||
use ide_workspace::{RecentProject, RecentProjectGroup};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::PathBuf;
|
||||
@@ -49,6 +49,24 @@ pub fn remove_recent_project(state: State<'_, AppState>, path: PathBuf) -> IdeRe
|
||||
state.recent.remove(&path)
|
||||
}
|
||||
|
||||
/// The welcome screen's project groups.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn recent_project_groups(state: State<'_, AppState>) -> Vec<RecentProjectGroup> {
|
||||
state.recent.groups()
|
||||
}
|
||||
|
||||
/// Replaces the project groups; returns them as saved (names trimmed and unique, each
|
||||
/// project in one group, only recent projects).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn set_recent_project_groups(
|
||||
state: State<'_, AppState>,
|
||||
groups: Vec<RecentProjectGroup>,
|
||||
) -> IdeResult<Vec<RecentProjectGroup>> {
|
||||
state.recent.set_groups(groups)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInfo> {
|
||||
|
||||
@@ -17,11 +17,32 @@ pub async fn build_model(window: Window, state: State<'_, AppState>) -> IdeResul
|
||||
ide_host::ops::build::build_model(&state.host, window.label()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn gradle_link_projects(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
paths: Vec<String>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
ide_host::ops::build::gradle_link_projects(&state.host, window.label(), paths).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn gradle_unlink_projects(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
paths: Vec<String>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
ide_host::ops::build::gradle_unlink_projects(&state.host, window.label(), paths).await
|
||||
}
|
||||
|
||||
/// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the
|
||||
/// background, reporting progress on `on_progress`; cancellable with `cancel_request(request)`.
|
||||
/// Returns the refreshed model; a failed build is reported in its `syncError`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn build_sync(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
@@ -29,6 +50,7 @@ pub async fn build_sync(
|
||||
request: RequestId,
|
||||
offline: bool,
|
||||
profiles: Vec<String>,
|
||||
build_dir: Option<String>,
|
||||
on_progress: Channel<SyncProgress>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
ide_host::ops::build::build_sync(
|
||||
@@ -38,6 +60,7 @@ pub async fn build_sync(
|
||||
request,
|
||||
offline,
|
||||
profiles,
|
||||
build_dir,
|
||||
crate::commands::sender(on_progress),
|
||||
)
|
||||
.await
|
||||
@@ -53,9 +76,18 @@ pub async fn gradle_dependencies(
|
||||
configuration: String,
|
||||
offline: bool,
|
||||
request: RequestId,
|
||||
build_dir: Option<String>,
|
||||
) -> IdeResult<Vec<ide_build::GradleDependency>> {
|
||||
ide_host::ops::build::gradle_dependencies(&state.host, window.label(), gradle_path, configuration, offline, request)
|
||||
.await
|
||||
ide_host::ops::build::gradle_dependencies(
|
||||
&state.host,
|
||||
window.label(),
|
||||
gradle_path,
|
||||
configuration,
|
||||
offline,
|
||||
request,
|
||||
build_dir,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The `.toml` of a version catalog the build imports as `group:artifact:version`, once a
|
||||
|
||||
@@ -250,9 +250,10 @@ pub async fn db_execute(
|
||||
request: RequestId,
|
||||
id: String,
|
||||
statements: Vec<SqlStatement>,
|
||||
skip: u32,
|
||||
max_rows: u32,
|
||||
) -> IdeResult<Vec<StatementOutcome>> {
|
||||
ide_host::ops::db::execute(&state.host, window.label(), request, &id, statements, max_rows).await
|
||||
ide_host::ops::db::execute(&state.host, window.label(), request, &id, statements, skip, max_rows).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -45,6 +45,7 @@ pub mod tools;
|
||||
pub mod vcs;
|
||||
pub mod web;
|
||||
pub mod window;
|
||||
pub mod window_drag;
|
||||
pub mod window_group;
|
||||
pub mod workspace;
|
||||
|
||||
@@ -64,6 +65,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
app::app_info,
|
||||
app::recent_projects,
|
||||
app::remove_recent_project,
|
||||
app::recent_project_groups,
|
||||
app::set_recent_project_groups,
|
||||
app::open_project,
|
||||
app::project_trust_status,
|
||||
app::project_trust_list,
|
||||
@@ -353,6 +356,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
run::run_tomcat_update,
|
||||
run::run_test_reports,
|
||||
build::build_model,
|
||||
build::gradle_link_projects,
|
||||
build::gradle_unlink_projects,
|
||||
build::build_sync,
|
||||
build::gradle_dependencies,
|
||||
build::gradle_published_catalog,
|
||||
@@ -404,6 +409,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
window::window_cycle,
|
||||
window::window_toggle_fullscreen,
|
||||
window::window_set_titlebar_height,
|
||||
window_drag::window_start_drag,
|
||||
window::window_take_launch,
|
||||
window::window_close_context,
|
||||
window::window_open_projects,
|
||||
|
||||
@@ -221,7 +221,7 @@ pub async fn vcs_add_to_ignore(
|
||||
state: State<'_, AppState>,
|
||||
paths: Vec<PathBuf>,
|
||||
destination: IgnoreDestination,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
) -> IdeResult<Vec<ide_core::IpcPath>> {
|
||||
ide_host::ops::vcs::vcs_add_to_ignore(&state.host, window.label(), paths, destination).await
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Native window drag started from the web title bar.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use tauri::WebviewWindow;
|
||||
|
||||
/// Starts dragging `window` while the primary mouse button is still down; returns whether a drag began.
|
||||
///
|
||||
/// macOS WebKit can deliver a tap's `mousedown` after the button is already up. AppKit would then run
|
||||
/// its drag loop with no release left to end it, and the window follows the pointer until the next click.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn window_start_drag(window: WebviewWindow) -> IdeResult<bool> {
|
||||
#[cfg(target_os = "macos")]
|
||||
if objc2_app_kit::NSEvent::pressedMouseButtons() & 1 == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
window.start_dragging().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
Ok(true)
|
||||
}
|
||||
@@ -46,7 +46,9 @@ pub fn new_project_tab_target(app: &AppHandle, launch: &Launch) -> IdeResult<Opt
|
||||
let host = app.state::<crate::state::AppState>();
|
||||
host.project(w.label()).is_ok_and(|p| !p.light) || host.remote_dev.get(w.label()).is_some()
|
||||
});
|
||||
let fullscreen = active.as_ref().is_some_and(|w| w.is_fullscreen().unwrap_or(false));
|
||||
// A project-tab group is fullscreen through simple fullscreen, which the native flag misses.
|
||||
let fullscreen =
|
||||
active.as_ref().is_some_and(|w| w.is_fullscreen().unwrap_or(false) || is_simple_fullscreen(w.label()));
|
||||
let light_edit = launch.project.is_none() && launch.remote.is_none();
|
||||
Ok(should_group_new_project(tabbing_preference(app)?, fullscreen, active.is_some(), light_edit)
|
||||
.then_some(active)
|
||||
|
||||
+36
-6
@@ -10,6 +10,13 @@ export const commands = {
|
||||
appInfo: () => __TAURI_INVOKE<AppInfo>("app_info"),
|
||||
recentProjects: () => __TAURI_INVOKE<RecentProject[]>("recent_projects"),
|
||||
removeRecentProject: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remove_recent_project", { path })),
|
||||
/** The welcome screen's project groups. */
|
||||
recentProjectGroups: () => __TAURI_INVOKE<RecentProjectGroup[]>("recent_project_groups"),
|
||||
/**
|
||||
* Replaces the project groups; returns them as saved (names trimmed and unique, each
|
||||
* project in one group, only recent projects).
|
||||
*/
|
||||
setRecentProjectGroups: (groups: RecentProjectGroup[]) => typedError<RecentProjectGroup[], IdeError>(__TAURI_INVOKE("set_recent_project_groups", { groups })),
|
||||
openProject: (path: string) => typedError<ProjectInfo_Serialize, IdeError>(__TAURI_INVOKE("open_project", { path })),
|
||||
projectTrustStatus: (path: string) => typedError<TrustStatus, IdeError>(__TAURI_INVOKE("project_trust_status", { path })),
|
||||
projectTrustList: () => typedError<TrustDecisions, IdeError>(__TAURI_INVOKE("project_trust_list")),
|
||||
@@ -491,7 +498,7 @@ export const commands = {
|
||||
vcsBranches: (repo: string | null) => typedError<Branch[], IdeError>(__TAURI_INVOKE("vcs_branches", { repo })),
|
||||
vcsStashes: (repo: string | null) => typedError<Stash[], IdeError>(__TAURI_INVOKE("vcs_stashes", { repo })),
|
||||
vcsStage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage", { paths })),
|
||||
vcsAddToIgnore: (paths: string[], destination: IgnoreDestination) => typedError<string[], IdeError>(__TAURI_INVOKE("vcs_add_to_ignore", { paths, destination })),
|
||||
vcsAddToIgnore: (paths: string[], destination: IgnoreDestination) => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("vcs_add_to_ignore", { paths, destination })),
|
||||
vcsUnstage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_unstage", { paths })),
|
||||
vcsStagePatch: (patch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage_patch", { patch })),
|
||||
vcsDiscard: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_discard", { paths })),
|
||||
@@ -664,14 +671,16 @@ export const commands = {
|
||||
runTestReports: (module: string | null, sinceMs: number | null) => typedError<TestCaseReport[], IdeError>(__TAURI_INVOKE("run_test_reports", { module, sinceMs })),
|
||||
/** Maven and Gradle projects of the window's project, from the build files plus the last sync. */
|
||||
buildModel: () => typedError<BuildModel, IdeError>(__TAURI_INVOKE("build_model")),
|
||||
gradleLinkProjects: (paths: string[]) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("gradle_link_projects", { paths })),
|
||||
gradleUnlinkProjects: (paths: string[]) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("gradle_unlink_projects", { paths })),
|
||||
/**
|
||||
* Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the
|
||||
* background, reporting progress on `on_progress`; cancellable with `cancel_request(request)`.
|
||||
* Returns the refreshed model; a failed build is reported in its `syncError`.
|
||||
*/
|
||||
buildSync: (tool: BuildTool, request: RequestId, offline: boolean, profiles: string[], onProgress: Channel<SyncProgress>) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("build_sync", { tool, request, offline, profiles, onProgress })),
|
||||
buildSync: (tool: BuildTool, request: RequestId, offline: boolean, profiles: string[], buildDir: string | null, onProgress: Channel<SyncProgress>) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("build_sync", { tool, request, offline, profiles, buildDir, onProgress })),
|
||||
/** The resolved dependency tree of a Gradle project for one configuration (Dependency Analyzer). */
|
||||
gradleDependencies: (gradlePath: string, configuration: string, offline: boolean, request: RequestId) => typedError<GradleDependency[], IdeError>(__TAURI_INVOKE("gradle_dependencies", { gradlePath, configuration, offline, request })),
|
||||
gradleDependencies: (gradlePath: string, configuration: string, offline: boolean, request: RequestId, buildDir: string | null) => typedError<GradleDependency[], IdeError>(__TAURI_INVOKE("gradle_dependencies", { gradlePath, configuration, offline, request, buildDir })),
|
||||
/**
|
||||
* The `.toml` of a version catalog the build imports as `group:artifact:version`, once a
|
||||
* build has downloaded it.
|
||||
@@ -811,6 +820,13 @@ export const commands = {
|
||||
windowToggleFullscreen: () => typedError<boolean, IdeError>(__TAURI_INVOKE("window_toggle_fullscreen")),
|
||||
/** Height of the web title bar in native logical pixels (CSS height × IDE zoom). */
|
||||
windowSetTitlebarHeight: (height: number | null) => typedError<null, IdeError>(__TAURI_INVOKE("window_set_titlebar_height", { height })),
|
||||
/**
|
||||
* Starts dragging `window` while the primary mouse button is still down; returns whether a drag began.
|
||||
*
|
||||
* macOS WebKit can deliver a tap's `mousedown` after the button is already up. AppKit would then run
|
||||
* its drag loop with no release left to end it, and the window follows the pointer until the next click.
|
||||
*/
|
||||
windowStartDrag: () => typedError<boolean, IdeError>(__TAURI_INVOKE("window_start_drag")),
|
||||
/** What this window was opened for (taken once). */
|
||||
windowTakeLaunch: () => __TAURI_INVOKE<{
|
||||
/** Project folder to open. */
|
||||
@@ -1224,7 +1240,7 @@ export const commands = {
|
||||
dbSetManualCommit: (id: string, manual: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("db_set_manual_commit", { id, manual })),
|
||||
dbFinishTransaction: (id: string, commit: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("db_finish_transaction", { id, commit })),
|
||||
dbStatements: (id: string, text: string, from: number, to: number) => typedError<SqlStatement[], IdeError>(__TAURI_INVOKE("db_statements", { id, text, from, to })),
|
||||
dbExecute: (request: RequestId, id: string, statements: SqlStatement[], maxRows: number) => typedError<DbStatementOutcome[], IdeError>(__TAURI_INVOKE("db_execute", { request, id, statements, maxRows })),
|
||||
dbExecute: (request: RequestId, id: string, statements: SqlStatement[], skip: number, maxRows: number) => typedError<DbStatementOutcome[], IdeError>(__TAURI_INVOKE("db_execute", { request, id, statements, skip, maxRows })),
|
||||
dbTablePage: (request: RequestId, id: string, schema: string, table: string, filter: string | null, sort: DbSortKey[], offset: number, limit: number) => typedError<DbTablePage, IdeError>(__TAURI_INVOKE("db_table_page", { request, id, schema, table, filter, sort, offset, limit })),
|
||||
dbApplyEdits: (id: string, schema: string, table: string, edits: DbRowEdit[]) => typedError<number | null, IdeError>(__TAURI_INVOKE("db_apply_edits", { id, schema, table, edits })),
|
||||
dbConsolePath: (id: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("db_console_path", { id })),
|
||||
@@ -1851,7 +1867,7 @@ export type BuildMessage = {
|
||||
/** The build systems of a project; `None` when the project does not use one. */
|
||||
export type BuildModel = {
|
||||
maven: MavenModel | null,
|
||||
gradle: GradleModel | null,
|
||||
gradle?: GradleModel[],
|
||||
};
|
||||
|
||||
export type BuildResult = {
|
||||
@@ -3917,6 +3933,8 @@ export type GradleRunUsing =
|
||||
|
||||
/** Build Tools › Gradle. */
|
||||
export type GradleSettings = {
|
||||
/** Linked build directories; absent preserves automatic discovery, empty unlinks all builds. */
|
||||
linkedProjects?: string[] | null,
|
||||
/** Empty = Project JDK; otherwise a JDK home. */
|
||||
jvm?: string,
|
||||
distribution?: GradleDistribution,
|
||||
@@ -5120,6 +5138,8 @@ export type MavenProject = {
|
||||
export type MavenSettings = {
|
||||
/** Project-relative root POMs removed from the Maven project model. */
|
||||
unlinkedProjects?: string[],
|
||||
/** Project-relative POMs outside the root reactor added as independent top-level projects. */
|
||||
linkedProjects?: string[],
|
||||
/** Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`. */
|
||||
home?: string,
|
||||
/** User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`. */
|
||||
@@ -5953,6 +5973,14 @@ export type RecentProject = {
|
||||
branch: string | null,
|
||||
};
|
||||
|
||||
/** A named group of recent projects on the welcome screen (New Project Group…, Move to Group). */
|
||||
export type RecentProjectGroup = {
|
||||
name: string,
|
||||
/** Its projects, in the order they were added. */
|
||||
paths: IpcPath[],
|
||||
expanded?: boolean,
|
||||
};
|
||||
|
||||
export type RefactoringProblem = {
|
||||
severity: ProblemSeverity,
|
||||
message: string,
|
||||
@@ -6003,6 +6031,8 @@ export type RegistryAgent = {
|
||||
installed: string | null,
|
||||
/** Whether [`Self::version`] is newer than the installed one. */
|
||||
update: boolean,
|
||||
/** An install of it is running (it goes on after the Settings page that started it closes). */
|
||||
installing: boolean,
|
||||
};
|
||||
|
||||
export type RegistryDistribution =
|
||||
@@ -6186,7 +6216,7 @@ export type Risk =
|
||||
export type RootKind = "source" | "test" | "resources" | "testResources" | "excluded";
|
||||
|
||||
export type RouteSource = {
|
||||
path: string,
|
||||
path: IpcPath,
|
||||
text: string,
|
||||
};
|
||||
|
||||
|
||||
@@ -9,17 +9,19 @@ import { tags as t, type Tag } from "@lezer/highlight";
|
||||
function token(
|
||||
tag: Tag | Tag[],
|
||||
name: string,
|
||||
defaults: { italic?: string; bold?: string; effect?: string } = {},
|
||||
defaults: { italic?: string; bold?: string; effect?: string; color?: string } = {},
|
||||
): TagStyle {
|
||||
const variable = (suffix: string, fallback: string) =>
|
||||
`var(--syn-${name}${suffix}, ${defaults.color ? `var(--syn-${defaults.color}${suffix}, ${fallback})` : fallback})`;
|
||||
return {
|
||||
tag,
|
||||
color: `var(--syn-${name})`,
|
||||
fontStyle: `var(--syn-${name}-italic, ${defaults.italic ?? "inherit"})`,
|
||||
fontWeight: `var(--syn-${name}-bold, ${defaults.bold ?? "inherit"})`,
|
||||
backgroundColor: `var(--syn-${name}-bg, transparent)`,
|
||||
textDecoration: `var(--syn-${name}-effect, ${defaults.effect ?? "none"})`,
|
||||
color: defaults.color ? variable("", "inherit") : `var(--syn-${name})`,
|
||||
fontStyle: variable("-italic", defaults.italic ?? "inherit"),
|
||||
fontWeight: variable("-bold", defaults.bold ?? "inherit"),
|
||||
backgroundColor: variable("-bg", "transparent"),
|
||||
textDecoration: variable("-effect", defaults.effect ?? "none"),
|
||||
// WebKit takes a decoration with a style or colour only through the prefixed property.
|
||||
WebkitTextDecoration: `var(--syn-${name}-effect, ${defaults.effect ?? "none"})`,
|
||||
WebkitTextDecoration: variable("-effect", defaults.effect ?? "none"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,10 +73,12 @@ export const openideaHighlightStyle = HighlightStyle.define([
|
||||
token(t.special(t.brace), "decorator"),
|
||||
token([t.tagName, t.angleBracket], "tag"),
|
||||
token([t.punctuation, t.bracket, t.separator, t.operator, t.derefOperator], "punct"),
|
||||
token(t.bracket, "bracket", { color: "punct" }),
|
||||
token(t.separator, "separator", { color: "punct" }),
|
||||
token(t.invalid, "invalid"),
|
||||
// Markup (Markdown, docs)
|
||||
token(t.heading, "markup-heading", { bold: "var(--fw-code-bold, 700)" }),
|
||||
token(t.processingInstruction, "keyword"),
|
||||
token(t.processingInstruction, "processing-instruction", { color: "keyword" }),
|
||||
{ tag: t.emphasis, fontStyle: "italic" },
|
||||
{ tag: t.strong, fontWeight: "var(--fw-code-bold, 700)" },
|
||||
{ tag: t.strikethrough, textDecoration: "line-through" },
|
||||
|
||||
@@ -65,8 +65,8 @@ class OverviewLane {
|
||||
/** The last pointer position over the editor, to re-check the thumb when it scrolls. */
|
||||
private pointer: { x: number; y: number } | null = null;
|
||||
/**
|
||||
* The scrollbar thumb drawn over the marks: the native one sits under the lane, so a mark
|
||||
* the height of the lane (a new file) would hide it (#1014).
|
||||
* The scrollbar thumb, drawn and dragged by the lane: the native scrollbar is hidden, so a
|
||||
* press on this strip stays here instead of reaching the text layer as a text drag.
|
||||
*/
|
||||
private thumb: HTMLElement;
|
||||
|
||||
@@ -77,6 +77,7 @@ class OverviewLane {
|
||||
this.thumb = document.createElement("div");
|
||||
this.thumb.className = "cm-openidea-overviewThumb";
|
||||
this.dom.addEventListener("mousedown", this.onMouseDown);
|
||||
this.thumb.addEventListener("mousedown", this.onThumbMouseDown);
|
||||
view.dom.addEventListener("mousemove", this.onPointerMove);
|
||||
view.dom.addEventListener("mouseleave", this.onPointerLeave);
|
||||
view.scrollDOM.addEventListener("scroll", this.onScroll, { passive: true });
|
||||
@@ -175,8 +176,8 @@ class OverviewLane {
|
||||
const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight);
|
||||
// The lane starts at the top of the editor, the scrollbar at the top of the scroller.
|
||||
const offset = s.getBoundingClientRect().top - this.dom.getBoundingClientRect().top;
|
||||
// Inset by the thumb's 2px transparent border (global.css).
|
||||
return thumb && { top: thumb.top + offset + 2, height: thumb.bottom - thumb.top - 4 };
|
||||
// The drawn thumb matches the geometry the drag maps onto, so the pointer keeps its grip.
|
||||
return thumb && { top: thumb.top + offset, height: thumb.bottom - thumb.top };
|
||||
},
|
||||
write: (thumb) => {
|
||||
this.thumb.style.display = thumb ? "" : "none";
|
||||
@@ -188,8 +189,8 @@ class OverviewLane {
|
||||
}
|
||||
|
||||
/**
|
||||
* Over the scrollbar thumb the marks let the pointer through, so the thumb can be dragged
|
||||
* even where a mark covers it (a new file is one mark the height of the lane, #752).
|
||||
* Over the thumb the marks let the pointer through, so the thumb can be dragged even where a
|
||||
* mark covers it (a new file is one mark the height of the lane, #752).
|
||||
*/
|
||||
private updateThumbPassThrough() {
|
||||
const p = this.pointer;
|
||||
@@ -220,6 +221,31 @@ class OverviewLane {
|
||||
if (this.pointer) this.updateThumbPassThrough();
|
||||
};
|
||||
|
||||
/** The thumb's own press: drag it, keeping the button state inside the lane. */
|
||||
private onThumbMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const s = this.view.scrollDOM;
|
||||
const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight);
|
||||
if (!thumb) return;
|
||||
const startY = e.clientY;
|
||||
const startTop = s.scrollTop;
|
||||
const travel = s.scrollHeight - s.clientHeight;
|
||||
// The distance the thumb can move: the track minus the thumb itself.
|
||||
const track = Math.max(1, s.clientHeight - (thumb.bottom - thumb.top));
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
s.scrollTop = startTop + ((ev.clientY - startY) * travel) / track;
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
this.updateThumbPassThrough();
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
private onMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
const marks = [...this.dom.querySelectorAll<HTMLElement>(".cm-openidea-overviewMark")].map((el) => {
|
||||
@@ -227,7 +253,17 @@ class OverviewLane {
|
||||
return { top: r.top, bottom: r.bottom, pos: Number(el.dataset.pos) };
|
||||
});
|
||||
const hit = nearestMark(marks, e.clientY);
|
||||
if (!hit) return;
|
||||
if (!hit) {
|
||||
// Above / below the thumb pages the editor, like the native track it replaces.
|
||||
const s = this.view.scrollDOM;
|
||||
const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight);
|
||||
e.preventDefault();
|
||||
if (!thumb) return;
|
||||
const y = e.clientY - s.getBoundingClientRect().top;
|
||||
if (y < thumb.top) s.scrollTop -= s.clientHeight;
|
||||
else if (y > thumb.bottom) s.scrollTop += s.clientHeight;
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const state = this.view.state;
|
||||
const pos = Math.min(hit.pos, state.doc.length);
|
||||
@@ -241,6 +277,7 @@ class OverviewLane {
|
||||
|
||||
destroy() {
|
||||
this.dom.removeEventListener("mousedown", this.onMouseDown);
|
||||
this.thumb.removeEventListener("mousedown", this.onThumbMouseDown);
|
||||
this.view.dom.removeEventListener("mousemove", this.onPointerMove);
|
||||
this.view.dom.removeEventListener("mouseleave", this.onPointerLeave);
|
||||
this.view.scrollDOM.removeEventListener("scroll", this.onScroll);
|
||||
|
||||
@@ -3,6 +3,27 @@ import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight";
|
||||
import { findLanguageDescription, loadSupport } from "./languages";
|
||||
|
||||
describe("language modes", () => {
|
||||
it("distinguishes PHP classes, method declarations and fields for imported schemes", async () => {
|
||||
const support = await loadSupport(findLanguageDescription("PHP", "/tmp/palette.php")!);
|
||||
const source = "<?php class Palette extends BasePalette { var $name; function example() {} }";
|
||||
const found: Array<[string, string]> = [];
|
||||
highlightTree(
|
||||
support!.language.parser.parse(source),
|
||||
tagHighlighter([
|
||||
{ tag: t.className, class: "class" },
|
||||
{ tag: t.function(t.definition(t.variableName)), class: "function" },
|
||||
{ tag: t.definition(t.propertyName), class: "field" },
|
||||
]),
|
||||
(from, to, style) => found.push([source.slice(from, to), style]),
|
||||
);
|
||||
expect(found).toEqual([
|
||||
["Palette", "class"],
|
||||
["BasePalette", "class"],
|
||||
["$name", "field"],
|
||||
["example", "function"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognizes AsciiDoc files and loads their lightweight syntax mode", async () => {
|
||||
for (const path of ["/docs/guide.adoc", "/docs/guide.asciidoc"]) {
|
||||
const description = findLanguageDescription("AsciiDoc", path);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* and indentation fall back to the backend definition when the mode lacks them.
|
||||
*/
|
||||
import { EditorState, Prec, type Extension } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import {
|
||||
LRLanguage,
|
||||
LanguageDescription,
|
||||
@@ -246,6 +247,12 @@ const EXTRA_TAGS: Record<string, Parameters<typeof styleTags>[0]> = {
|
||||
JSX: JS_TAGS,
|
||||
TSX: JS_TAGS,
|
||||
Java: JAVA_TAGS,
|
||||
PHP: {
|
||||
"; ,": t.separator,
|
||||
"ClassDeclaration/Name BaseClause/Name InterfaceClause/Name": t.className,
|
||||
"MethodDeclaration/Name FunctionDefinition/Name": t.function(t.definition(t.variableName)),
|
||||
"PropertyDeclaration/VariableDeclarator/VariableName": t.definition(t.propertyName),
|
||||
},
|
||||
CSS: CSS_TAGS,
|
||||
SCSS: CSS_TAGS,
|
||||
Sass: CSS_TAGS,
|
||||
@@ -255,8 +262,14 @@ const EXTRA_TAGS: Record<string, Parameters<typeof styleTags>[0]> = {
|
||||
|
||||
function patchSupport(desc: LanguageDescription, support: LanguageSupport): LanguageSupport {
|
||||
const extra = EXTRA_TAGS[desc.name];
|
||||
if (!extra || !(support.language instanceof LRLanguage)) return support;
|
||||
const patched = new LanguageSupport(support.language.configure({ props: [styleTags(extra)] }), support.support);
|
||||
const language =
|
||||
extra && support.language instanceof LRLanguage
|
||||
? support.language.configure({ props: [styleTags(extra)] })
|
||||
: support.language;
|
||||
const patched = new LanguageSupport(language, [
|
||||
support.support,
|
||||
EditorView.editorAttributes.of({ "data-language": desc.name }),
|
||||
]);
|
||||
// `loadedSupport` reads `desc.support` synchronously; keep it on the patched instance.
|
||||
(desc as { support: LanguageSupport | undefined }).support = patched;
|
||||
return patched;
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
selectLineStart,
|
||||
} from "@codemirror/commands";
|
||||
import { markdownImages } from "./markdownImages";
|
||||
import { selectionMarks } from "./selectionMarks";
|
||||
import type { EditorExtras } from "@/platform/editorSettings";
|
||||
import { openideaHighlightStyle } from "./highlight";
|
||||
import {
|
||||
@@ -504,6 +505,7 @@ export function caretExtension(prefs: EditorExtras): Extension {
|
||||
// Before drawSelection: its measure request must run before the cursor layer redraws.
|
||||
a.smoothCaretMovement ? caretGlide : [],
|
||||
drawSelection({ cursorBlinkRate: a.caretBlinking ? Math.max(100, Math.min(5000, a.caretBlinkPeriod)) * 2 : 0 }),
|
||||
selectionMarks,
|
||||
classes.length ? EditorView.editorAttributes.of({ class: classes.join(" ") }) : [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState, Prec, type Extension } from "@codemirror/state";
|
||||
import { Decoration, EditorView } from "@codemirror/view";
|
||||
import { selectionMarks } from "./selectionMarks";
|
||||
|
||||
const views: EditorView[] = [];
|
||||
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
|
||||
|
||||
function view(doc: string, selection: EditorSelection, extensions: Extension) {
|
||||
extensions = [EditorState.allowMultipleSelections.of(true), extensions];
|
||||
const parent = document.createElement("div");
|
||||
document.body.appendChild(parent);
|
||||
const v = new EditorView({ state: EditorState.create({ doc, selection, extensions }), parent });
|
||||
views.push(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** A background mark as features add it (lint range, find match, usage, semantic bg…). */
|
||||
const backgroundMark = (cls: string, from: number, to: number, prec: (e: Extension) => Extension = (e) => e) =>
|
||||
prec(EditorView.decorations.of(Decoration.set(Decoration.mark({ class: cls }).range(from, to))));
|
||||
|
||||
const DOC = "return request.app.state.credits";
|
||||
|
||||
describe("selectionMarks", () => {
|
||||
it.each([
|
||||
["default", (e: Extension) => e],
|
||||
["high (Lezer highlighter)", Prec.high],
|
||||
["lowest", Prec.lowest],
|
||||
])("paints selected text inside a %s-precedence background mark", (_, prec) => {
|
||||
// The reporter's case: a light-scheme warning background over `request.app.state.credits`.
|
||||
const v = view(DOC, EditorSelection.single(7, DOC.length), [
|
||||
backgroundMark("cm-lintRange-warning", 7, DOC.length, prec),
|
||||
selectionMarks,
|
||||
]);
|
||||
const selected = [...v.contentDOM.querySelectorAll(".cm-openidea-selected")];
|
||||
expect(selected.map((e) => e.textContent).join("")).toBe(DOC.slice(7));
|
||||
for (const el of selected) expect(el.closest(".cm-lintRange-warning")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("covers every range of a multi-selection, partially overlapping marks too", () => {
|
||||
const v = view(DOC, EditorSelection.create([EditorSelection.range(0, 3), EditorSelection.range(10, 20)], 1), [
|
||||
backgroundMark("cm-openidea-findMatch", 15, 24),
|
||||
selectionMarks,
|
||||
]);
|
||||
const text = [...v.contentDOM.querySelectorAll(".cm-openidea-selected")].map((e) => e.textContent).join("|");
|
||||
expect(text.replace(/\|/g, "")).toBe(DOC.slice(0, 3) + DOC.slice(10, 20));
|
||||
const inMatch = [...v.contentDOM.querySelectorAll(".cm-openidea-findMatch .cm-openidea-selected")];
|
||||
expect(inMatch.map((e) => e.textContent).join("")).toBe(DOC.slice(15, 20));
|
||||
});
|
||||
|
||||
it("adds nothing for an empty selection and clears when the selection collapses", () => {
|
||||
const v = view(DOC, EditorSelection.single(0, 6), [selectionMarks]);
|
||||
expect(v.contentDOM.querySelector(".cm-openidea-selected")).not.toBeNull();
|
||||
v.dispatch({ selection: EditorSelection.cursor(3) });
|
||||
expect(v.contentDOM.querySelector(".cm-openidea-selected")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Prec, RangeSetBuilder, type Extension } from "@codemirror/state";
|
||||
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view";
|
||||
|
||||
/**
|
||||
* `drawSelection` paints the selection in a layer behind the text, so any mark with its own
|
||||
* background (light-scheme warnings, find matches, usages, semantic backgrounds…) hides it.
|
||||
* This repaints selected text in the innermost span: higher-precedence marks nest closer to the
|
||||
* text, and the only other `Prec.highest` marks (LSP semantic tokens) set colour, not background.
|
||||
*/
|
||||
const selectedMark = Decoration.mark({ class: "cm-openidea-selected" });
|
||||
|
||||
function selectedRanges(view: EditorView): DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
const ranges = view.state.selection.ranges.filter((r) => !r.empty);
|
||||
if (!ranges.length) return Decoration.none;
|
||||
for (const { from: vFrom, to: vTo } of view.visibleRanges)
|
||||
for (const r of ranges) {
|
||||
const from = Math.max(r.from, vFrom);
|
||||
const to = Math.min(r.to, vTo);
|
||||
if (from < to) builder.add(from, to, selectedMark);
|
||||
}
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
const plugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = selectedRanges(view);
|
||||
}
|
||||
update(u: ViewUpdate) {
|
||||
if (u.selectionSet || u.docChanged || u.viewportChanged) this.decorations = selectedRanges(u.view);
|
||||
}
|
||||
},
|
||||
{ decorations: (p) => p.decorations },
|
||||
);
|
||||
|
||||
const theme = EditorView.baseTheme({
|
||||
".cm-openidea-selected": {
|
||||
backgroundColor: "var(--editor-selection-inactive, var(--bg-editor-selection-inactive))",
|
||||
},
|
||||
"&.cm-focused .cm-openidea-selected": {
|
||||
backgroundColor: "var(--editor-selection, var(--bg-editor-selection))",
|
||||
},
|
||||
});
|
||||
|
||||
export const selectionMarks: Extension = [Prec.highest(plugin), theme];
|
||||
@@ -267,14 +267,14 @@ export const openideaTheme = EditorView.theme({
|
||||
".cm-panels.cm-panels-top": { borderBottom: "1px solid var(--border)" },
|
||||
".cm-panels.cm-panels-bottom": { borderTop: "1px solid var(--border)" },
|
||||
|
||||
// Error stripe over the scrollbar (cm/lanes.ts)
|
||||
// Error stripe and scrollbar lane (cm/lanes.ts)
|
||||
".cm-openidea-overview": {
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
right: "0",
|
||||
bottom: "0",
|
||||
width: "12px",
|
||||
pointerEvents: "none",
|
||||
cursor: "default",
|
||||
zIndex: "3",
|
||||
},
|
||||
".cm-openidea-overviewMark": {
|
||||
@@ -289,15 +289,17 @@ export const openideaTheme = EditorView.theme({
|
||||
".cm-openidea-overviewMark-thin": { left: "4px", width: "4px", minHeight: "2px" },
|
||||
// The scrollbar thumb wins over the marks under it (cm/lanes.ts, #752).
|
||||
".cm-openidea-overview-onThumb .cm-openidea-overviewMark": { pointerEvents: "none" },
|
||||
// The lane draws the thumb over its marks (#1014); the native one only takes the drag.
|
||||
"&.cm-openidea-withOverview .cm-scroller::-webkit-scrollbar-thumb:vertical": { backgroundColor: "transparent" },
|
||||
// The lane owns the scrollbar and its thumb (cm/lanes.ts): the native one is hidden so a press
|
||||
// on the scrollbar can never reach the text layer and start a text selection.
|
||||
"&.cm-openidea-withOverview .cm-scroller::-webkit-scrollbar:vertical": { width: "0" },
|
||||
".cm-openidea-overviewThumb": {
|
||||
position: "absolute",
|
||||
right: "2px",
|
||||
width: "6px",
|
||||
borderRadius: "3px",
|
||||
background: "var(--scrollbar-thumb)",
|
||||
pointerEvents: "none",
|
||||
pointerEvents: "auto",
|
||||
cursor: "default",
|
||||
},
|
||||
".cm-openidea-overview-onThumb .cm-openidea-overviewThumb": { background: "var(--scrollbar-thumb-hover)" },
|
||||
".cm-openidea-overviewCaret": {
|
||||
@@ -305,6 +307,7 @@ export const openideaTheme = EditorView.theme({
|
||||
left: "2px",
|
||||
right: "2px",
|
||||
height: "2px",
|
||||
pointerEvents: "none",
|
||||
background: "color-mix(in srgb, var(--fg) 30%, transparent)",
|
||||
},
|
||||
|
||||
|
||||
@@ -365,6 +365,14 @@ export const lspTheme = EditorView.theme({
|
||||
".cm-sem-global:not(.cm-sem-function)": { color: "var(--syn-global, inherit)" },
|
||||
".cm-sem-builtin:not(.cm-sem-function)": { color: "var(--syn-builtin, inherit)" },
|
||||
".cm-sem-static": { fontStyle: "var(--syn-static-italic, italic)" },
|
||||
".cm-sem-property.cm-sem-static, .cm-sem-property.cm-sem-static *": {
|
||||
color: "var(--syn-static-property, var(--syn-property))",
|
||||
fontStyle: "var(--syn-static-property-italic, var(--syn-static-italic, italic))",
|
||||
fontWeight: "var(--syn-static-property-bold, var(--syn-property-bold, inherit))",
|
||||
backgroundColor: "var(--syn-static-property-bg, transparent)",
|
||||
textDecoration: "var(--syn-static-property-effect, none)",
|
||||
WebkitTextDecoration: "var(--syn-static-property-effect, none)",
|
||||
},
|
||||
".cm-sem-python-static": { fontStyle: "var(--syn-python-static-italic, normal)" },
|
||||
".cm-sem-mutable": {
|
||||
textDecoration: "underline",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Settings › Tools › Agents: the launch command of built-in agents (Claude Code…), and
|
||||
* agents of the ACP Registry this machine can run, with Install / Update / Remove.
|
||||
* Installs run in the background with progress; leaving the page cancels one.
|
||||
* Installs run in the background with progress, and keep running after the page closes
|
||||
* (as a background task of the project windows); the page then shows them as installing.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { AgentRegistry, RegistryAgent, RequestId } from "@/bindings";
|
||||
import { isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
@@ -28,13 +29,26 @@ import styles from "./AgentRegistry.module.css";
|
||||
|
||||
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
/** How often the page checks on installs it did not start. */
|
||||
const BACKGROUND_POLL_MS = 1500;
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
request: RequestId;
|
||||
fraction: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface PageJob extends Job {
|
||||
request: RequestId;
|
||||
}
|
||||
|
||||
/** An install started before the page opened; its progress shows in the background tasks. */
|
||||
const backgroundJob = (agent: RegistryAgent): Job => ({
|
||||
id: agent.id,
|
||||
fraction: null,
|
||||
message: tx`Installing ${agent.name} in the background…`,
|
||||
});
|
||||
|
||||
export function AgentRegistryPage({
|
||||
onChanged,
|
||||
backend = tauriRegistry,
|
||||
@@ -72,7 +86,7 @@ function RegistryList({ backend, onChanged }: { backend: RegistryBackend; onChan
|
||||
const [registry, setRegistry] = useState<AgentRegistry | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [job, setJob] = useState<Job | null>(null);
|
||||
const [job, setJob] = useState<PageJob | null>(null);
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -89,11 +103,39 @@ function RegistryList({ backend, onChanged }: { backend: RegistryBackend; onChan
|
||||
};
|
||||
}, [backend]);
|
||||
|
||||
// Installs started before the page opened: follow them until they end.
|
||||
const background = registry?.agents.some((a) => a.installing) ?? false;
|
||||
useEffect(() => {
|
||||
if (!background) return;
|
||||
let live = true;
|
||||
let request: RequestId | null = null;
|
||||
const timer = setInterval(() => {
|
||||
if (request != null) return;
|
||||
const r = (request = nextRequestId());
|
||||
backend
|
||||
.list(r)
|
||||
.then((next) => {
|
||||
if (!live) return;
|
||||
setRegistry(next);
|
||||
if (!next.agents.some((a) => a.installing)) onChanged?.();
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (request === r) request = null;
|
||||
});
|
||||
}, BACKGROUND_POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
clearInterval(timer);
|
||||
if (request != null) backend.cancel(request);
|
||||
};
|
||||
}, [backend, background, onChanged]);
|
||||
|
||||
const agents = useMemo(
|
||||
() => sortAgents((registry?.agents ?? []).filter((a) => matchesQuery(a, query))),
|
||||
[registry, query],
|
||||
);
|
||||
const busy = job != null || removing != null;
|
||||
const busy = job != null || removing != null || background;
|
||||
|
||||
const setError = (id: string, error: string | null) =>
|
||||
setErrors((all) => {
|
||||
@@ -101,9 +143,6 @@ function RegistryList({ backend, onChanged }: { backend: RegistryBackend; onChan
|
||||
return error ? { ...rest, [id]: error } : rest;
|
||||
});
|
||||
|
||||
// The install in progress, for leaving the page.
|
||||
const running = useRef<RequestId | null>(null);
|
||||
|
||||
const install = async (agent: RegistryAgent) => {
|
||||
if (
|
||||
agent.noChecksum &&
|
||||
@@ -116,7 +155,6 @@ function RegistryList({ backend, onChanged }: { backend: RegistryBackend; onChan
|
||||
)
|
||||
return;
|
||||
const request = nextRequestId();
|
||||
running.current = request;
|
||||
setError(agent.id, null);
|
||||
setJob({ id: agent.id, request, fraction: null, message: tx`Installing ${agent.name} ${agent.version}…` });
|
||||
backend
|
||||
@@ -134,10 +172,7 @@ function RegistryList({ backend, onChanged }: { backend: RegistryBackend; onChan
|
||||
onChanged?.();
|
||||
})
|
||||
.catch((e: unknown) => !isCancelled(e) && setError(agent.id, errorText(e)))
|
||||
.finally(() => {
|
||||
if (running.current === request) running.current = null;
|
||||
setJob(null);
|
||||
});
|
||||
.finally(() => setJob(null));
|
||||
};
|
||||
|
||||
const remove = (agent: RegistryAgent) => {
|
||||
@@ -153,14 +188,6 @@ function RegistryList({ backend, onChanged }: { backend: RegistryBackend; onChan
|
||||
.finally(() => setRemoving(null));
|
||||
};
|
||||
|
||||
// Leaving the page stops an install.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (running.current != null) backend.cancel(running.current);
|
||||
},
|
||||
[backend],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
<SearchField
|
||||
@@ -202,13 +229,13 @@ function RegistryList({ backend, onChanged }: { backend: RegistryBackend; onChan
|
||||
<AgentRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
job={job?.id === agent.id ? job : null}
|
||||
job={job?.id === agent.id ? job : agent.installing ? backgroundJob(agent) : null}
|
||||
removing={removing === agent.id}
|
||||
disabled={busy}
|
||||
error={errors[agent.id] ?? null}
|
||||
onInstall={() => void install(agent)}
|
||||
onRemove={() => remove(agent)}
|
||||
onCancel={() => job && backend.cancel(job.request)}
|
||||
onCancel={job?.id === agent.id ? () => backend.cancel(job.request) : undefined}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -235,7 +262,8 @@ function AgentRow({
|
||||
error: string | null;
|
||||
onInstall: () => void;
|
||||
onRemove: () => void;
|
||||
onCancel: () => void;
|
||||
/** Absent for an install this page did not start (stop it from the background tasks). */
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const actions = rowActions(agent);
|
||||
const titleId = `agent-${agent.id}`;
|
||||
@@ -274,9 +302,11 @@ function AgentRow({
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
{job ? (
|
||||
<Button size="small" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
onCancel && (
|
||||
<Button size="small" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{actions.install && (
|
||||
|
||||
@@ -17,7 +17,21 @@ import styles from "./Session.module.css";
|
||||
|
||||
const marked = new Marked({ gfm: true, breaks: true });
|
||||
|
||||
/** Rendered messages by text, least recently used first: reopening a conversation renders it again. */
|
||||
const rendered = new Map<string, string>();
|
||||
const RENDERED_MAX = 500;
|
||||
|
||||
export function markdownHtml(text: string): string {
|
||||
let html = rendered.get(text);
|
||||
if (html === undefined) {
|
||||
html = renderHtml(text);
|
||||
if (rendered.size >= RENDERED_MAX) rendered.delete(rendered.keys().next().value!);
|
||||
} else rendered.delete(text);
|
||||
rendered.set(text, html);
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderHtml(text: string): string {
|
||||
const html = marked.parse(text, { async: false }) as string;
|
||||
// Replies can quote repository content. Styles would reach the whole IDE, and the click
|
||||
// handlers below trust `data-copy` / `data-path`, which only `enhance` may set.
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
/** An agent task as an editor tab: the conversation, a composer and the task's changed files. */
|
||||
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
Fragment,
|
||||
startTransition,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import type { AgentChange, AgentEntry, AgentImage, AgentLimit, AgentRetry, AgentTask, AgentUsage } from "@/bindings";
|
||||
@@ -92,14 +101,17 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
useShallow((s) => path.map((sub, i) => findSubagent(s.entries.get(entriesKey(id, path[i - 1] ?? null)), sub))),
|
||||
);
|
||||
useEffect(() => {
|
||||
void loadEntries(id).catch(() => undefined);
|
||||
// Loaded entries are kept current by the task's events: reopening the tab needn't fetch them again.
|
||||
if (!useAgents.getState().entries.has(id)) void loadEntries(id).catch(() => undefined);
|
||||
void refreshChanges(id);
|
||||
}, [id]);
|
||||
useEffect(() => {
|
||||
if (!docked || !changesOpen) return markVisible(id);
|
||||
}, [id, docked, changesOpen]);
|
||||
useEffect(() => {
|
||||
if (view) void loadEntries(id, view).catch(() => undefined);
|
||||
if (view && !useAgents.getState().entries.has(entriesKey(id, view))) {
|
||||
void loadEntries(id, view).catch(() => undefined);
|
||||
}
|
||||
}, [id, view]);
|
||||
const openSubagent = (sub: string) => setPath((p) => [...p, sub]);
|
||||
const [finding, setFinding] = useState(false);
|
||||
@@ -412,6 +424,9 @@ interface StreamProps {
|
||||
onCloseFind: () => void;
|
||||
}
|
||||
|
||||
/** Entries rendered at a time when a conversation opens at its end. */
|
||||
const ENTRY_BATCH = 40;
|
||||
|
||||
function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, onCloseFind }: StreamProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useSelectionBoundary(ref);
|
||||
@@ -419,6 +434,13 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
const positions = useAgents.getState().scrollPositions;
|
||||
const position = useRef(positions.get(key) ?? { top: 0, stick: true });
|
||||
const restored = useRef(false);
|
||||
// Opened at the end, the last entries come first and the earlier ones follow in batches:
|
||||
// reopening a long conversation paints at once. A kept offset needs them all.
|
||||
const [shown, setShown] = useState(() => (positions.get(key)?.stick === false ? Infinity : ENTRY_BATCH));
|
||||
const start = finding || !entries ? 0 : Math.max(0, entries.length - shown);
|
||||
useEffect(() => {
|
||||
if (start > 0) startTransition(() => setShown((n) => n + ENTRY_BATCH));
|
||||
}, [start]);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || entries === null) return;
|
||||
@@ -470,16 +492,18 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
{entries === null ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
entries.map((entry, i) => (
|
||||
<Entry
|
||||
key={i}
|
||||
task={task}
|
||||
entry={entry}
|
||||
turn={turns?.get(i) ?? null}
|
||||
turnCount={turns?.size ?? 0}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
/>
|
||||
))
|
||||
entries
|
||||
.slice(start)
|
||||
.map((entry, i) => (
|
||||
<Entry
|
||||
key={start + i}
|
||||
task={task}
|
||||
entry={entry}
|
||||
turn={turns?.get(start + i) ?? null}
|
||||
turnCount={turns?.size ?? 0}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{subagent && subagentWorking(subagent.status) && (
|
||||
<div className={styles.working}>
|
||||
|
||||
@@ -42,6 +42,7 @@ const agent = (partial: Partial<RegistryAgent> & Pick<RegistryAgent, "id" | "nam
|
||||
noChecksum: false,
|
||||
installed: null,
|
||||
update: false,
|
||||
installing: false,
|
||||
...partial,
|
||||
});
|
||||
|
||||
@@ -67,14 +68,16 @@ const AGENTS: RegistryAgent[] = [AMP, CLINE, FAST];
|
||||
class FakeBackend implements RegistryBackend {
|
||||
registry: AgentRegistry = { agents: AGENTS, warning: null };
|
||||
installs: string[] = [];
|
||||
installRequests: RequestId[] = [];
|
||||
removed: string[] = [];
|
||||
cancelled: RequestId[] = [];
|
||||
finish: () => void = () => {};
|
||||
fail: (e: unknown) => void = () => {};
|
||||
emit: (e: InstallEvent) => void = () => {};
|
||||
list = vi.fn(async () => this.registry);
|
||||
install(id: string, _request: RequestId, onEvent: (e: InstallEvent) => void) {
|
||||
install(id: string, request: RequestId, onEvent: (e: InstallEvent) => void) {
|
||||
this.installs.push(id);
|
||||
this.installRequests.push(request);
|
||||
this.emit = onEvent;
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.finish = resolve;
|
||||
@@ -267,7 +270,7 @@ describe("Agents settings page", () => {
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows why an install failed, and cancels one when the page closes", async () => {
|
||||
it("shows why an install failed, cancels one on Cancel, and leaves it running when the page closes", async () => {
|
||||
const backend = new FakeBackend();
|
||||
const { unmount } = renderRegistry(<AgentRegistryPage backend={backend} />);
|
||||
await waitFor(() => expect(row("Cline")).toBeInTheDocument());
|
||||
@@ -278,10 +281,34 @@ describe("Agents settings page", () => {
|
||||
fireEvent.click(within(row("Amp")).getByRole("button", { name: "Install" }));
|
||||
fireEvent.click(within(row("Amp")).getByRole("button", { name: "Cancel" }));
|
||||
expect(backend.cancelled).toHaveLength(1);
|
||||
// Leaving the page stops an install still running.
|
||||
const install = backend.cancelled[0];
|
||||
const cancelled = backend.cancelled[0];
|
||||
await act(async () => backend.fail(new IpcError({ kind: "cancelled", message: "cancelled" })));
|
||||
|
||||
// An install keeps going after the page closes (it is a background task then).
|
||||
fireEvent.click(within(row("Amp")).getByRole("button", { name: "Install" }));
|
||||
const running = backend.installRequests.at(-1);
|
||||
unmount();
|
||||
expect(backend.cancelled.filter((r) => r === install)).toHaveLength(2);
|
||||
expect(backend.cancelled).not.toContain(running);
|
||||
expect(backend.cancelled.filter((r) => r === cancelled)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("follows an install started before the page opened until it ends", async () => {
|
||||
const backend = new FakeBackend();
|
||||
const onChanged = vi.fn();
|
||||
backend.registry = { agents: [{ ...AMP, installing: true }, CLINE], warning: null };
|
||||
renderRegistry(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
|
||||
await waitFor(() => expect(row("Amp")).toBeInTheDocument());
|
||||
expect(within(row("Amp")).getByText("Installing Amp in the background…")).toBeInTheDocument();
|
||||
// It is stopped from the background tasks, and nothing else starts meanwhile.
|
||||
for (const name of ["Cancel", "Install", "Remove"]) {
|
||||
expect(within(row("Amp")).queryByRole("button", { name })).toBeNull();
|
||||
}
|
||||
expect(within(row("Cline")).getByRole("button", { name: "Update" })).toBeDisabled();
|
||||
|
||||
backend.registry = { agents: [{ ...AMP, installed: AMP.version }, CLINE], warning: null };
|
||||
await waitFor(() => expect(within(row("Amp")).getByText("Installed")).toBeInTheDocument(), { timeout: 4000 });
|
||||
expect(onChanged).toHaveBeenCalledTimes(1);
|
||||
expect(within(row("Cline")).getByRole("button", { name: "Update" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("removes an installed agent", async () => {
|
||||
|
||||
@@ -14,14 +14,16 @@ const backend = vi.hoisted(() => ({
|
||||
entries: new Map<string, AgentEntry[]>(),
|
||||
changes: [] as AgentChange[],
|
||||
answers: [] as Array<[string, number, string | null]>,
|
||||
fetched: [] as string[],
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (original) => ({
|
||||
...(await original<typeof import("@/platform/ipc")>()),
|
||||
commands: {
|
||||
agentEntries: async (id: string, sub: string | null) => ({
|
||||
status: "ok",
|
||||
data: backend.entries.get(sub ? `${id}/${sub}` : id) ?? [],
|
||||
}),
|
||||
agentEntries: async (id: string, sub: string | null) => {
|
||||
const key = sub ? `${id}/${sub}` : id;
|
||||
backend.fetched.push(key);
|
||||
return { status: "ok", data: backend.entries.get(key) ?? [] };
|
||||
},
|
||||
agentChanges: async () => ({ status: "ok", data: backend.changes }),
|
||||
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
|
||||
agentRespond: async (task: string, permission: number, option: string | null) => {
|
||||
@@ -177,6 +179,29 @@ describe("a pending permission request in the conversation", () => {
|
||||
});
|
||||
|
||||
describe("session state across tasks and hosts", () => {
|
||||
it("fetches a conversation once and shows it again from the store when its tab reopens", async () => {
|
||||
const entries = new Map(useAgents.getState().entries);
|
||||
entries.delete("A");
|
||||
useAgents.setState({ entries });
|
||||
backend.fetched = [];
|
||||
const first = render(<SessionEditor input={input("A")} />);
|
||||
expect(await screen.findByText("Answer A")).toBeInTheDocument();
|
||||
first.unmount();
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
expect(screen.getByText("Answer A")).toBeInTheDocument();
|
||||
await act(async () => {});
|
||||
expect(backend.fetched).toEqual(["A"]);
|
||||
});
|
||||
|
||||
it("opens a long conversation at its end and renders the earlier entries after it", async () => {
|
||||
const entries = Array.from({ length: 150 }, (_, i): AgentEntry => ({ type: "agent", text: `Reply ${i}` }));
|
||||
useAgents.setState({ entries: new Map(useAgents.getState().entries).set("A", entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
expect(screen.getByText("Reply 149")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Reply 0")).toBeInTheDocument();
|
||||
expect(stream().children).toHaveLength(150);
|
||||
});
|
||||
|
||||
it("returns to the task conversation when the docked task changes after opening a subagent", async () => {
|
||||
const dispose = registerActions(taskActions);
|
||||
try {
|
||||
|
||||
@@ -12,12 +12,22 @@ function deps(answers: PromptResult[]) {
|
||||
}),
|
||||
saveAll: vi.fn(async () => {}),
|
||||
disableConfirmExit: vi.fn(async () => {}),
|
||||
disableConfirmTerminate: vi.fn(async () => {}),
|
||||
runningProcesses: vi.fn(async () => []),
|
||||
onSaveFailed: vi.fn(),
|
||||
};
|
||||
return { d, asked };
|
||||
}
|
||||
|
||||
const base: CloseInput = { quitting: false, windows: 1, confirmExit: false, dirty: 0, project: "openidea" };
|
||||
const base: CloseInput = {
|
||||
quitting: false,
|
||||
windows: 1,
|
||||
confirmExit: false,
|
||||
confirmTerminate: false,
|
||||
processes: [],
|
||||
dirty: 0,
|
||||
project: "openidea",
|
||||
};
|
||||
|
||||
describe("decideClose", () => {
|
||||
it("closes silently when nothing needs asking", async () => {
|
||||
@@ -50,6 +60,46 @@ describe("decideClose", () => {
|
||||
expect(asked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("asks before terminating a running process", async () => {
|
||||
const { d, asked } = deps([{ button: "terminate", checked: false }]);
|
||||
await expect(decideClose({ ...base, confirmTerminate: true, processes: ["app"] }, d)).resolves.toBe(true);
|
||||
expect(asked).toHaveLength(1);
|
||||
expect(asked[0]?.title).toContain("app");
|
||||
expect(asked[0]?.body).toContain("app");
|
||||
});
|
||||
|
||||
it("names every running process in the prompt", async () => {
|
||||
const { d, asked } = deps([{ button: "terminate", checked: false }]);
|
||||
await expect(decideClose({ ...base, confirmTerminate: true, processes: ["app", "worker"] }, d)).resolves.toBe(true);
|
||||
expect(asked[0]?.body).toContain("app, worker");
|
||||
});
|
||||
|
||||
it("cancelling the terminate prompt keeps the window", async () => {
|
||||
const { d } = deps([{ button: "cancel", checked: false }]);
|
||||
await expect(decideClose({ ...base, confirmTerminate: true, processes: ["app"] }, d)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("asks about processes before discarding unsaved changes", async () => {
|
||||
const { d, asked } = deps([{ button: "cancel", checked: false }]);
|
||||
await expect(decideClose({ ...base, confirmTerminate: true, processes: ["app"], dirty: 1 }, d)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
expect(asked).toHaveLength(1);
|
||||
expect(d.saveAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("'don't ask again' turns process confirmation off", async () => {
|
||||
const { d } = deps([{ button: "terminate", checked: true }]);
|
||||
await decideClose({ ...base, confirmTerminate: true, processes: ["app"] }, d);
|
||||
expect(d.disableConfirmTerminate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not ask about processes when the setting is off", async () => {
|
||||
const { d, asked } = deps([]);
|
||||
await decideClose({ ...base, confirmTerminate: false, processes: ["app"] }, d);
|
||||
expect(asked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("saves all on Save", async () => {
|
||||
const { d } = deps([{ button: "save", checked: false }]);
|
||||
await expect(decideClose({ ...base, dirty: 2 }, d)).resolves.toBe(true);
|
||||
@@ -179,3 +229,12 @@ it("includes pending local edits and restored dirty buffers before backend dirty
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("confirmLeave asks about running processes before closing", async () => {
|
||||
const { confirmLeave } = await import("../closeGuard");
|
||||
const { d, asked } = deps([{ button: "cancel", checked: false }]);
|
||||
d.runningProcesses = vi.fn(async () => ["app"]);
|
||||
await expect(confirmLeave(d, "openidea")).resolves.toBe(false);
|
||||
expect(asked[0]?.title).toContain("app");
|
||||
expect(asked[0]?.body).toContain("app");
|
||||
});
|
||||
@@ -3,14 +3,21 @@
|
||||
*
|
||||
* Closing a window (traffic light, Quit closing every window) runs
|
||||
* `decideClose`: confirm exit when this is the last window and
|
||||
* `general.confirmExit` is on, then Save All / Don't Save / Cancel when
|
||||
* documents have unsaved changes. Quit confirms once, then the backend closes
|
||||
* every window, each of which runs only its unsaved-changes check.
|
||||
* `general.confirmExit` is on, then Terminate / Cancel when Run/Debug processes
|
||||
* still run and `run.confirmTerminate` is on, then Save All / Don't Save / Cancel
|
||||
* when documents have unsaved changes. Quit confirms once, then the backend
|
||||
* closes every window, each of which runs its own checks.
|
||||
*/
|
||||
import { commands, isTauri, onIdeEvent, type Settings } from "@/platform/ipc";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { closeProject, provideLeaveProjectGuard, useProject } from "@/platform/project";
|
||||
import { maybeEditorService, notifications, type EditorCloseSession } from "@/workbench/contributions";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import {
|
||||
maybeEditorService,
|
||||
notifications,
|
||||
runningProcesses as liveProcesses,
|
||||
type EditorCloseSession,
|
||||
} from "@/workbench/contributions";
|
||||
import { ask, type Ask } from "@/workbench/messages";
|
||||
import { AUX_CLOSED, auxiliaryWindow, closeDocumentWindows, type AuxCloseResult } from "@/platform/auxWindow";
|
||||
|
||||
@@ -29,6 +36,9 @@ export interface CloseInput {
|
||||
quitting: boolean;
|
||||
windows: number;
|
||||
confirmExit: boolean;
|
||||
confirmTerminate: boolean;
|
||||
/** Running Run/Debug process names this close would terminate. */
|
||||
processes: readonly string[];
|
||||
dirty: number;
|
||||
project: string | null;
|
||||
}
|
||||
@@ -37,6 +47,9 @@ export interface CloseDeps {
|
||||
ask: Ask;
|
||||
saveAll: () => Promise<void>;
|
||||
disableConfirmExit: () => Promise<void>;
|
||||
disableConfirmTerminate: () => Promise<void>;
|
||||
/** Running Run/Debug process names in this window. */
|
||||
runningProcesses: () => Promise<readonly string[]>;
|
||||
onSaveFailed: (e: unknown) => void;
|
||||
/** Stops custom document edits/execution before reading their final dirty state. */
|
||||
prepareClose?: () => Promise<EditorCloseSession | undefined>;
|
||||
@@ -54,6 +67,24 @@ export async function decideClose(input: CloseInput, deps: CloseDeps): Promise<b
|
||||
if (r.button !== "exit") return false;
|
||||
if (r.checked) await deps.disableConfirmExit().catch(() => {});
|
||||
}
|
||||
if (input.confirmTerminate && input.processes.length > 0) {
|
||||
const names = input.processes;
|
||||
const r = await deps.ask({
|
||||
title:
|
||||
names.length === 1
|
||||
? t("app.terminateProcesses.titleOne", { name: names[0]! })
|
||||
: t("app.terminateProcesses.titleMany", { count: names.length }),
|
||||
body:
|
||||
names.length === 1
|
||||
? t("app.terminateProcesses.bodyOne", { name: names[0]! })
|
||||
: t("app.terminateProcesses.bodyMany", { count: names.length, names: names.join(", ") }),
|
||||
buttons: [{ id: "terminate", label: t("app.terminateProcesses.terminate"), primary: true }],
|
||||
checkbox: t("app.terminateProcesses.dontAsk"),
|
||||
icon: "warning",
|
||||
});
|
||||
if (r.button !== "terminate") return false;
|
||||
if (r.checked) await deps.disableConfirmTerminate().catch(() => {});
|
||||
}
|
||||
let session: EditorCloseSession | undefined;
|
||||
let authorized = false;
|
||||
try {
|
||||
@@ -94,6 +125,11 @@ const defaultDeps: CloseDeps = {
|
||||
const r = await commands.settingsSet("global", "general.confirmExit", "false");
|
||||
if (r.status === "ok") useProject.setState({ settings: r.data });
|
||||
},
|
||||
disableConfirmTerminate: async () => {
|
||||
const r = await commands.settingsSet("global", "run.confirmTerminate", "false");
|
||||
if (r.status === "ok") useProject.setState({ settings: r.data });
|
||||
},
|
||||
runningProcesses: async () => (await liveProcesses()).map((p) => p.name),
|
||||
onSaveFailed: (e) =>
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
@@ -102,6 +138,11 @@ const defaultDeps: CloseDeps = {
|
||||
}),
|
||||
};
|
||||
|
||||
/** Process names this close would terminate; an unavailable feature list never blocks closing. */
|
||||
async function processNames(deps: CloseDeps): Promise<string[]> {
|
||||
return [...new Set(await deps.runningProcesses().catch(() => []))].filter(Boolean);
|
||||
}
|
||||
|
||||
let closing = false;
|
||||
|
||||
async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
|
||||
@@ -120,11 +161,14 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
|
||||
if (await confirmLeave(deps)) await closeProject();
|
||||
return false;
|
||||
}
|
||||
const confirmTerminate = workflowSettings().run.confirmTerminate;
|
||||
const ok = await decideClose(
|
||||
{
|
||||
quitting: ctx.quitting,
|
||||
windows: ctx.windows,
|
||||
confirmExit: confirmExitEnabled(s.settings),
|
||||
confirmTerminate,
|
||||
processes: confirmTerminate ? await processNames(deps) : [],
|
||||
dirty: dirtyCount(),
|
||||
project: s.project?.name ?? null,
|
||||
},
|
||||
@@ -151,15 +195,36 @@ export async function confirmLeave(
|
||||
project = useProject.getState().project?.name ?? null,
|
||||
): Promise<boolean> {
|
||||
if (!(await closeDocumentWindows())) return false;
|
||||
return decideClose({ quitting: false, windows: 2, confirmExit: false, dirty: dirtyCount(), project }, deps);
|
||||
const confirmTerminate = workflowSettings().run.confirmTerminate;
|
||||
return decideClose(
|
||||
{
|
||||
quitting: false,
|
||||
windows: 2,
|
||||
confirmExit: false,
|
||||
confirmTerminate,
|
||||
processes: confirmTerminate ? await processNames(deps) : [],
|
||||
dirty: dirtyCount(),
|
||||
project,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
}
|
||||
|
||||
/** Quit: confirm once (if enabled), then let the backend close every window. */
|
||||
export async function quitApp(deps: CloseDeps = defaultDeps): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
if (confirmExitEnabled(useProject.getState().settings)) {
|
||||
// Each window asks about its own processes as the backend closes it.
|
||||
const ok = await decideClose(
|
||||
{ quitting: false, windows: 1, confirmExit: true, dirty: 0, project: null },
|
||||
{
|
||||
quitting: false,
|
||||
windows: 1,
|
||||
confirmExit: true,
|
||||
confirmTerminate: false,
|
||||
processes: [],
|
||||
dirty: 0,
|
||||
project: null,
|
||||
},
|
||||
{ ...deps, prepareClose: undefined },
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import {
|
||||
gradleChildren,
|
||||
gradleRoots,
|
||||
gradleProjectKey,
|
||||
gradleTaskConfig,
|
||||
isRunnable,
|
||||
mavenChildren,
|
||||
@@ -56,7 +57,7 @@ export function GradleView() {
|
||||
/** The run configuration a runnable node (or several of one project) starts. */
|
||||
export function nodeConfig(
|
||||
nodes: readonly BuildNode[],
|
||||
model: { maven?: MavenModel | null; gradle?: GradleModel | null },
|
||||
model: { maven?: MavenModel | null; gradle?: readonly GradleModel[] },
|
||||
) {
|
||||
const settings = useBuild.getState().settings;
|
||||
const first = nodes[0];
|
||||
@@ -71,13 +72,17 @@ export function nodeConfig(
|
||||
const goals = same.map((n) => (n.kind === "phase" ? n.phase : `${n.plugin.prefix}:${n.goal}`));
|
||||
return mavenGoalConfig(first.project, goals, model.maven, settings.maven);
|
||||
}
|
||||
if (first.kind === "task" && model.gradle) {
|
||||
if (first.kind === "task") {
|
||||
const build = model.gradle?.find((g) => g.dir === first.build.dir);
|
||||
const project = build?.projects.find((p) => p.path === first.project.path);
|
||||
if (!build || !project) return null;
|
||||
const same = nodes.filter(
|
||||
(n): n is Extract<BuildNode, { kind: "task" }> => n.kind === "task" && n.project.path === first.project.path,
|
||||
(n): n is Extract<BuildNode, { kind: "task" }> =>
|
||||
n.kind === "task" && n.build.dir === first.build.dir && n.project.path === first.project.path,
|
||||
);
|
||||
return gradleTaskConfig(
|
||||
model.gradle,
|
||||
first.project,
|
||||
build,
|
||||
project,
|
||||
same.map((n) => n.task.name),
|
||||
settings.gradle,
|
||||
);
|
||||
@@ -89,8 +94,9 @@ export function nodeConfig(
|
||||
function childrenOf(
|
||||
tool: BuildTool,
|
||||
maven: MavenModel | null,
|
||||
gradle: GradleModel | null,
|
||||
gradle: readonly GradleModel[],
|
||||
configs: readonly RunConfig[],
|
||||
root: string,
|
||||
) {
|
||||
const cache = new Map<string, BuildNode[] | undefined>();
|
||||
return (n: BuildNode) => {
|
||||
@@ -101,7 +107,7 @@ function childrenOf(
|
||||
? mavenChildren(n, maven, configs)
|
||||
: undefined
|
||||
: gradle
|
||||
? gradleChildren(n, gradle, configs)
|
||||
? gradleChildren(n, gradle, configs, root)
|
||||
: undefined;
|
||||
cache.set(n.key, kids);
|
||||
return kids;
|
||||
@@ -110,7 +116,8 @@ function childrenOf(
|
||||
|
||||
/** Maven `pom` / Gradle project path of the project a node belongs to. */
|
||||
function nodeProject(n: BuildNode): string | undefined {
|
||||
if ("project" in n) return "pom" in n.project ? n.project.pom : n.project.path;
|
||||
if ("project" in n)
|
||||
return "pom" in n.project ? n.project.pom : "build" in n ? gradleProjectKey(n.build, n.project) : undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -118,9 +125,8 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[]
|
||||
const syncing = useBuild((s) => !!s.syncing[tool]);
|
||||
const settings = useBuild((s) => s.settings);
|
||||
const model = useBuild((s) => s.model);
|
||||
const runnable = selection.filter(isRunnable);
|
||||
const config = model && nodeConfig(selection.filter(isRunnable), model);
|
||||
const runSelected = () => {
|
||||
const config = model && nodeConfig(runnable, model);
|
||||
if (config) runConfig(config, "run");
|
||||
};
|
||||
const maven = tool === "maven";
|
||||
@@ -131,14 +137,14 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[]
|
||||
) : (
|
||||
<IconButton icon="refresh" size="small" iconSize={16} action={`${tool}.reload`} />
|
||||
)}
|
||||
{maven && <IconButton icon="plus" size="small" iconSize={16} action="maven.addProjects" />}
|
||||
<IconButton icon="plus" size="small" iconSize={16} action={maven ? "maven.addProjects" : "gradle.linkProjects"} />
|
||||
<span className={styles.sep} />
|
||||
<IconButton
|
||||
icon="run"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
title={maven ? "Run Maven Build" : "Run Gradle Task"}
|
||||
disabled={!runnable.length}
|
||||
disabled={!config}
|
||||
onClick={runSelected}
|
||||
/>
|
||||
<IconButton
|
||||
@@ -181,7 +187,8 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[]
|
||||
|
||||
function Banner({ tool }: { tool: BuildTool }) {
|
||||
const sync = useBuild((s) => s.syncing[tool]);
|
||||
const info = useBuild((s) => (tool === "maven" ? s.model?.maven : s.model?.gradle));
|
||||
const model = useBuild((s) => s.model);
|
||||
const infos = tool === "maven" ? (model?.maven ? [model.maven] : []) : (model?.gradle ?? []);
|
||||
if (sync)
|
||||
return (
|
||||
<div className={styles.banner} role="status">
|
||||
@@ -190,7 +197,10 @@ function Banner({ tool }: { tool: BuildTool }) {
|
||||
{sync.fraction != null && <span className={styles.bannerPct}>{Math.round(sync.fraction * 100)}%</span>}
|
||||
</div>
|
||||
);
|
||||
if (!info) return null;
|
||||
return infos.map((info) => <BuildBanner key={"dir" in info ? info.dir : "maven"} info={info} tool={tool} />);
|
||||
}
|
||||
|
||||
function BuildBanner({ info, tool }: { info: MavenModel | GradleModel; tool: BuildTool }) {
|
||||
if (info.stale)
|
||||
return (
|
||||
<div className={styles.banner}>
|
||||
@@ -198,7 +208,7 @@ function Banner({ tool }: { tool: BuildTool }) {
|
||||
<span className={styles.bannerText}>
|
||||
<T>Build files changed since the last reload</T>
|
||||
</span>
|
||||
<Button size="small" variant="ghost" onClick={() => void syncTool(tool)}>
|
||||
<Button size="small" variant="ghost" onClick={() => void syncTool(tool, "dir" in info ? info.dir : null)}>
|
||||
{tool === "maven" ? "Load Maven Changes" : "Load Gradle Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -260,15 +270,18 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
|
||||
const root = useProject((s) => s.project?.root ?? "");
|
||||
const [selection, setSelection] = useState<BuildNode[]>([]);
|
||||
const maven = model?.maven ?? null;
|
||||
const gradle = model?.gradle ?? null;
|
||||
const gradle = model?.gradle;
|
||||
|
||||
const roots = useMemo(
|
||||
() => (tool === "maven" ? (maven ? mavenRoots(maven) : []) : gradle ? gradleRoots(gradle) : []),
|
||||
() => (tool === "maven" ? (maven ? mavenRoots(maven) : []) : gradleRoots(gradle ?? [])),
|
||||
[tool, maven, gradle],
|
||||
);
|
||||
const getChildren = useMemo(() => childrenOf(tool, maven, gradle, configs), [tool, maven, gradle, configs]);
|
||||
const getChildren = useMemo(
|
||||
() => childrenOf(tool, maven, gradle ?? [], configs, root),
|
||||
[tool, maven, gradle, configs, root],
|
||||
);
|
||||
|
||||
const hasModel = tool === "maven" ? !!maven : !!gradle;
|
||||
const hasModel = tool === "maven" ? !!maven : !!gradle?.length;
|
||||
useEffect(() => {
|
||||
if (model && hasModel && useBuild.getState().expanded[tool].size === 0)
|
||||
setExpanded(tool, initialExpanded(tool, model));
|
||||
@@ -331,12 +344,22 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
|
||||
items.push({
|
||||
label: tx`Open ${file.split("/").pop()}`,
|
||||
icon: "file-gradle",
|
||||
run: () => void editorService().openFile(`${root}/${file}`),
|
||||
run: () => void editorService().openFile(/^(?:[A-Za-z]:[\\/]|\/)/.test(file) ? file : `${root}/${file}`),
|
||||
});
|
||||
if (node.project.path === ":")
|
||||
items.push({ kind: "action", action: "gradle.unlinkProjects", arg: { buildDir: node.build.dir } });
|
||||
items.push(
|
||||
{ kind: "action", action: "gradle.reload", label: "Reload All Gradle Projects" },
|
||||
{ kind: "action", action: "gradle.executeTask" },
|
||||
{ kind: "action", action: "gradle.analyzeDependencies", arg: { gradlePath: node.project.path } },
|
||||
{ kind: "action", action: "gradle.reload", label: "Reload Gradle Project", arg: { buildDir: node.build.dir } },
|
||||
{
|
||||
kind: "action",
|
||||
action: "gradle.executeTask",
|
||||
arg: { projectKey: gradleProjectKey(node.build, node.project) },
|
||||
},
|
||||
{
|
||||
kind: "action",
|
||||
action: "gradle.analyzeDependencies",
|
||||
arg: { buildDir: node.build.dir, gradlePath: node.project.path },
|
||||
},
|
||||
);
|
||||
}
|
||||
if (node.kind === "dependency") {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
import type { BuildModel, GradleDependency, GradleModel, MavenDependency } from "@/platform/ipc";
|
||||
@@ -72,7 +72,7 @@ const model: BuildModel = {
|
||||
stale: false,
|
||||
syncError: null,
|
||||
},
|
||||
gradle: null,
|
||||
gradle: [],
|
||||
};
|
||||
|
||||
describe("dependency analyzer", () => {
|
||||
@@ -159,7 +159,7 @@ describe("dependency analyzer for Gradle", () => {
|
||||
provideEditorService({ openInput, activePath: () => null } as unknown as EditorService);
|
||||
setContext("projectOpen", true);
|
||||
dispose = activate();
|
||||
act(() => useBuild.setState({ root: "/g", model: { maven: null, gradle } }));
|
||||
act(() => useBuild.setState({ root: "/g", model: { maven: null, gradle: [gradle] } }));
|
||||
});
|
||||
afterEach(() => {
|
||||
dispose();
|
||||
@@ -170,13 +170,13 @@ describe("dependency analyzer for Gradle", () => {
|
||||
it("opens for a project from the action and from its build script's menus", async () => {
|
||||
await executeAction("gradle.analyzeDependencies", { gradlePath: ":app" });
|
||||
expect(openInput).toHaveBeenCalledWith(expect.objectContaining({ kind: ANALYZER_KIND }), { focus: true });
|
||||
expect(useAnalyzer.getState().module).toBe("gradle::app");
|
||||
expect(useAnalyzer.getState().module).toBe('gradle:["",":app"]');
|
||||
const items = menuGroupItems("projectView", { paths: ["/g/app/build.gradle.kts"] });
|
||||
expect(items).toContainEqual(
|
||||
expect.objectContaining({ action: "gradle.analyzeDependencies", arg: { path: "/g/app/build.gradle.kts" } }),
|
||||
);
|
||||
await executeAction("gradle.analyzeDependencies", { path: "/g/build.gradle" });
|
||||
expect(useAnalyzer.getState().module).toBe("gradle::");
|
||||
expect(useAnalyzer.getState().module).toBe('gradle:["",":"]');
|
||||
expect(menuGroupItems("editor", { paths: ["/g/other/build.gradle"] })).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -187,9 +187,9 @@ describe("dependency analyzer for Gradle", () => {
|
||||
]),
|
||||
gradleDep("com.fasterxml.jackson.core:jackson-databind:2.15.3", null),
|
||||
]);
|
||||
act(() => useAnalyzer.setState({ module: "gradle::app" }));
|
||||
act(() => useAnalyzer.setState({ module: 'gradle:["",":app"]' }));
|
||||
render(<DependencyAnalyzer input={{ kind: ANALYZER_KIND, key: ANALYZER_KIND }} />);
|
||||
expect(ipc.gradle).toHaveBeenCalledWith(":app", "runtimeClasspath", false, expect.any(Number));
|
||||
expect(ipc.gradle).toHaveBeenCalledWith(":app", "runtimeClasspath", false, expect.any(Number), "");
|
||||
await screen.findByText("com.fasterxml.jackson.core:jackson-databind");
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Show conflicts only" }));
|
||||
expect(screen.queryByText("org.springframework.boot:spring-boot-starter-json")).toBeNull();
|
||||
@@ -203,9 +203,32 @@ describe("dependency analyzer for Gradle", () => {
|
||||
expect(screen.queryByRole("button", { name: "Jump to Source" })).toBeNull();
|
||||
});
|
||||
|
||||
it("routes identical project paths and report caches by linked build", async () => {
|
||||
const builds = ["one", "two"].map((dir) => ({
|
||||
...gradle,
|
||||
dir,
|
||||
syncedAt: 1,
|
||||
projects: gradle.projects.map((p) => ({ ...p, dir: `${dir}/${p.dir}`, buildFile: `${dir}/${p.buildFile}` })),
|
||||
}));
|
||||
ipc.gradle.mockResolvedValue([]);
|
||||
act(() => {
|
||||
useBuild.setState({ model: { maven: null, gradle: builds } });
|
||||
useAnalyzer.setState({ module: 'gradle:["one",":app"]' });
|
||||
});
|
||||
render(<DependencyAnalyzer input={{ kind: ANALYZER_KIND, key: ANALYZER_KIND }} />);
|
||||
await waitFor(() =>
|
||||
expect(ipc.gradle).toHaveBeenCalledWith(":app", "runtimeClasspath", false, expect.any(Number), "one"),
|
||||
);
|
||||
act(() => useAnalyzer.setState({ module: 'gradle:["two",":app"]' }));
|
||||
await waitFor(() =>
|
||||
expect(ipc.gradle).toHaveBeenLastCalledWith(":app", "runtimeClasspath", false, expect.any(Number), "two"),
|
||||
);
|
||||
expect(ipc.gradle).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows why Gradle could not resolve the configuration", async () => {
|
||||
ipc.gradle.mockRejectedValue(new Error("Could not resolve all files for configuration ':app:runtimeClasspath'."));
|
||||
act(() => useAnalyzer.setState({ module: "gradle::" }));
|
||||
act(() => useAnalyzer.setState({ module: 'gradle:["",":"]' }));
|
||||
render(<DependencyAnalyzer input={{ kind: ANALYZER_KIND, key: ANALYZER_KIND }} />);
|
||||
expect(await screen.findByText("Cannot resolve dependencies")).toBeTruthy();
|
||||
expect(screen.getByText(/Could not resolve all files/)).toBeTruthy();
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
commands,
|
||||
isCancelled,
|
||||
nextRequestId,
|
||||
type GradleProject,
|
||||
type GradleModel,
|
||||
type MavenDependency,
|
||||
type MavenProject,
|
||||
} from "@/platform/ipc";
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
type DependencyTreeNode,
|
||||
type DependencyUsage,
|
||||
} from "./analyzer";
|
||||
import { gradleProjectKey } from "./model";
|
||||
import { syncTool, useAnalyzer, useBuild } from "./store";
|
||||
import styles from "./DependencyAnalyzer.module.css";
|
||||
|
||||
@@ -67,13 +68,13 @@ const GRADLE = "gradle:";
|
||||
const CONFIGURATIONS = ["compileClasspath", "runtimeClasspath", "testCompileClasspath", "testRuntimeClasspath"];
|
||||
|
||||
type GradleTree = { deps: MavenDependency[] | null; error: string | null; loading: boolean };
|
||||
/** Reports already read in this session, by `root|path|configuration`: reopening the tab doesn't run Gradle again. */
|
||||
/** Reports already read in this session, by workspace, build, project and configuration: reopening the tab doesn't run Gradle again. */
|
||||
const gradleTrees = new Map<string, MavenDependency[]>();
|
||||
|
||||
/** The `dependencies` report of `path` for `configuration`, cached; a new `generation` runs Gradle again. */
|
||||
function useGradleTree(path: string | null, configuration: string, generation: number) {
|
||||
function useGradleTree(path: string | null, buildDir: string | null, configuration: string, generation: number) {
|
||||
const root = useBuild((s) => s.root);
|
||||
const key = path && root ? `${root}|${path}|${configuration}` : null;
|
||||
const key = path && root ? JSON.stringify([root, buildDir, path, configuration]) : null;
|
||||
const [state, setState] = useState<GradleTree & { key: string | null }>({
|
||||
key: null,
|
||||
deps: null,
|
||||
@@ -98,7 +99,7 @@ function useGradleTree(path: string | null, configuration: string, generation: n
|
||||
let done = false;
|
||||
setState({ key, deps: cached ?? null, error: null, loading: true });
|
||||
const offline = useBuild.getState().settings.gradle.offline;
|
||||
call(commands.gradleDependencies(path, configuration, offline, request))
|
||||
call(commands.gradleDependencies(path, configuration, offline, request, buildDir))
|
||||
.then((deps) => {
|
||||
const tree = fromGradle(deps);
|
||||
gradleTrees.set(key, tree);
|
||||
@@ -115,7 +116,7 @@ function useGradleTree(path: string | null, configuration: string, generation: n
|
||||
live = false;
|
||||
if (!done) void commands.cancelRequest(request).catch(() => {});
|
||||
};
|
||||
}, [key, path, configuration, generation]);
|
||||
}, [key, path, buildDir, configuration, generation]);
|
||||
const current: GradleTree = state.key === key ? state : { deps: null, error: null, loading: !!key };
|
||||
const cancel = () => {
|
||||
if (running.current != null) void commands.cancelRequest(running.current).catch(() => {});
|
||||
@@ -162,20 +163,22 @@ function versionLabel(d: MavenDependency) {
|
||||
return d.version ?? "";
|
||||
}
|
||||
|
||||
function moduleOptions(maven: readonly MavenProject[], gradle: readonly GradleProject[]): SelectOption[] {
|
||||
function moduleOptions(maven: readonly MavenProject[], gradle: readonly GradleModel[]): SelectOption[] {
|
||||
return [
|
||||
...maven
|
||||
.filter((p) => p.packaging !== "pom" || p.dependencies.length > 0)
|
||||
.map((p) => ({ value: p.pom, label: p.displayName, detail: p.pom, icon: "maven-project" })),
|
||||
// buildSrc's tasks can't be run from the build.
|
||||
...gradle
|
||||
.filter((p) => p.build !== ":buildSrc")
|
||||
.map((p) => ({
|
||||
value: `${GRADLE}${p.path}`,
|
||||
label: p.name,
|
||||
detail: p.path,
|
||||
icon: p.path === ":" ? "gradle-project" : "gradle-module",
|
||||
})),
|
||||
...gradle.flatMap((build) =>
|
||||
build.projects
|
||||
.filter((p) => !p.build?.endsWith(":buildSrc"))
|
||||
.map((p) => ({
|
||||
value: gradleProjectKey(build, p),
|
||||
label: p.name,
|
||||
detail: `${build.dir || "."} ${p.path}`,
|
||||
icon: p.path === ":" ? "gradle-project" : "gradle-module",
|
||||
})),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -332,7 +335,7 @@ function UsagesPane({
|
||||
export function DependencyAnalyzer(_props: { input: EditorInput }) {
|
||||
const tx = useTx();
|
||||
const maven = useBuild((s) => s.model?.maven ?? null);
|
||||
const gradle = useBuild((s) => s.model?.gradle ?? null);
|
||||
const gradle = useBuild((s) => s.model?.gradle);
|
||||
const syncing = useBuild((s) => s.syncing.maven);
|
||||
const chosen = useAnalyzer((s) => s.module);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -343,13 +346,18 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) {
|
||||
const [mode, setMode] = useState<Mode>("list");
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
|
||||
const options = useMemo(() => moduleOptions(maven?.projects ?? [], gradle?.projects ?? []), [maven, gradle]);
|
||||
const options = useMemo(() => moduleOptions(maven?.projects ?? [], gradle ?? []), [maven, gradle]);
|
||||
const value = options.some((o) => o.value === chosen) ? chosen! : (options[0]?.value ?? null);
|
||||
const gradlePath = value?.startsWith(GRADLE) ? value.slice(GRADLE.length) : null;
|
||||
const selectedGradle = value?.startsWith(GRADLE)
|
||||
? (gradle ?? [])
|
||||
.flatMap((build) => build.projects.map((project) => ({ build, project })))
|
||||
.find(({ build, project }) => gradleProjectKey(build, project) === value)
|
||||
: null;
|
||||
const gradlePath = selectedGradle?.project.path ?? null;
|
||||
const pom = value != null && gradlePath == null ? value : null;
|
||||
const module = pom ? (maven?.projects.find((p) => p.pom === pom) ?? null) : null;
|
||||
const gradleProject = gradlePath != null ? (gradle?.projects.find((p) => p.path === gradlePath) ?? null) : null;
|
||||
const report = useGradleTree(gradlePath, configuration, generation);
|
||||
const gradleProject = selectedGradle?.project ?? null;
|
||||
const report = useGradleTree(gradlePath, selectedGradle?.build.dir ?? null, configuration, generation);
|
||||
const deps = useMemo(
|
||||
() => (gradlePath != null ? (report.deps ?? []) : (module?.dependencies ?? [])),
|
||||
[gradlePath, report.deps, module],
|
||||
@@ -428,7 +436,7 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) {
|
||||
showContextMenu({ x: e.clientX, y: e.clientY }, items);
|
||||
};
|
||||
|
||||
if (!maven && !gradle) return <EmptyState title="No Maven or Gradle projects" />;
|
||||
if (!maven && !gradle?.length) return <EmptyState title="No Maven or Gradle projects" />;
|
||||
|
||||
const treeView = (
|
||||
<div className={styles.body}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useMemo, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { BuildTool } from "@/platform/ipc";
|
||||
import { HighlightedText, Input, List, Popup, Select, listNavigate, T } from "@/ui";
|
||||
import { goalCompletions, taskCompletions, type Completion } from "./model";
|
||||
import { goalCompletions, gradleProjectForKey, gradleProjectKey, taskCompletions, type Completion } from "./model";
|
||||
import { executeBuildLine } from "./runAnything";
|
||||
import { readHistory, useBuild } from "./store";
|
||||
import styles from "./BuildTools.module.css";
|
||||
@@ -32,7 +32,13 @@ function ExecutePopup({ tool }: { tool: BuildTool }) {
|
||||
() =>
|
||||
tool === "maven"
|
||||
? (maven?.projects ?? []).map((p) => ({ value: p.pom, label: p.displayName, detail: p.dir || "." }))
|
||||
: (gradle?.projects ?? []).map((p) => ({ value: p.path, label: p.name, detail: p.path })),
|
||||
: (gradle ?? []).flatMap((build) =>
|
||||
build.projects.map((p) => ({
|
||||
value: gradleProjectKey(build, p),
|
||||
label: p.name,
|
||||
detail: `${build.dir || "."} ${p.path}`,
|
||||
})),
|
||||
),
|
||||
[tool, maven, gradle],
|
||||
);
|
||||
const [project, setProject] = useState<string | null>(() =>
|
||||
@@ -44,7 +50,8 @@ function ExecutePopup({ tool }: { tool: BuildTool }) {
|
||||
const p = maven?.projects.find((x) => x.pom === project);
|
||||
return goalCompletions(line, p?.plugins ?? [], history).slice(0, 200);
|
||||
}
|
||||
return gradle ? taskCompletions(line, gradle, history).slice(0, 200) : [];
|
||||
const selected = gradleProjectForKey(gradle ?? [], project);
|
||||
return selected ? taskCompletions(line, selected.build, history).slice(0, 200) : [];
|
||||
}, [tool, maven, gradle, project, line, history]);
|
||||
|
||||
const close = () => useBuild.setState({ executeOpen: null });
|
||||
|
||||
@@ -36,7 +36,7 @@ const mavenModel: BuildModel = {
|
||||
stale: true,
|
||||
syncError: null,
|
||||
},
|
||||
gradle: null,
|
||||
gradle: [],
|
||||
};
|
||||
|
||||
describe("build tools feature", () => {
|
||||
@@ -62,7 +62,7 @@ describe("build tools feature", () => {
|
||||
expect(getContext()["build.maven"]).toBe(true);
|
||||
expect(toolWindows()).not.toContain("gradle");
|
||||
|
||||
act(() => useBuild.setState({ model: { maven: null, gradle: null } }));
|
||||
act(() => useBuild.setState({ model: { maven: null, gradle: [] } }));
|
||||
expect(toolWindows()).not.toContain("maven");
|
||||
expect(getContext()["build.maven"]).toBe(false);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ vi.mock("@tauri-apps/api/core", async (original) => ({
|
||||
|
||||
const maven = (stale: boolean) =>
|
||||
({ syncedAt: 1, stale, syncError: null, profiles: [], projects: [] }) as unknown as MavenModel;
|
||||
const model = (stale: boolean): BuildModel => ({ maven: maven(stale), gradle: null });
|
||||
const model = (stale: boolean): BuildModel => ({ maven: maven(stale), gradle: [] });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -14,16 +14,20 @@ import {
|
||||
registerOverlay,
|
||||
registerRunAnythingProvider,
|
||||
registerToolWindow,
|
||||
toolWindowService,
|
||||
} from "@/workbench/contributions";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { GradleView, MavenView } from "./BuildToolView";
|
||||
import { ExecutePopupHost } from "./ExecutePopup";
|
||||
import { buildToolsRunAnything } from "./runAnything";
|
||||
import { versionCatalogExtension } from "./versionCatalog";
|
||||
import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots } from "./model";
|
||||
import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots, gradleProjectKey } from "./model";
|
||||
import {
|
||||
ANALYZER_KIND,
|
||||
addProjects,
|
||||
linkGradleProjects,
|
||||
unlinkGradleProjects,
|
||||
requestSync,
|
||||
unlinkProjects,
|
||||
cancelSync,
|
||||
loadModel,
|
||||
@@ -33,6 +37,7 @@ import {
|
||||
openAnalyzer,
|
||||
resetBuild,
|
||||
setExpanded,
|
||||
setCurrentProject,
|
||||
syncTool,
|
||||
toggleGradleOffline,
|
||||
toggleMavenOffline,
|
||||
@@ -55,7 +60,7 @@ function expandAll(tool: BuildTool) {
|
||||
tool,
|
||||
expandableKeys(mavenRoots(m), (n) => mavenChildren(n, m, configs)),
|
||||
);
|
||||
} else if (tool === "gradle" && model?.gradle) {
|
||||
} else if (tool === "gradle" && model?.gradle?.length) {
|
||||
const g = model.gradle;
|
||||
setExpanded(
|
||||
tool,
|
||||
@@ -84,24 +89,34 @@ function analyzerPom(arg: unknown): string | null {
|
||||
/** The analyzer module of a Gradle project from an action argument: `{ gradlePath }`, or `{ path }` of its build script. */
|
||||
function analyzerGradle(arg: unknown): string | null {
|
||||
const { model, root } = useBuild.getState();
|
||||
const a = (arg ?? {}) as { gradlePath?: unknown; path?: unknown };
|
||||
if (typeof a.gradlePath === "string") return `gradle:${a.gradlePath}`;
|
||||
const project =
|
||||
typeof a.path === "string" && root
|
||||
? model?.gradle?.projects.find((p) => p.buildFile && `${root}/${p.buildFile}` === a.path)
|
||||
: undefined;
|
||||
return project ? `gradle:${project.path}` : null;
|
||||
const a = (arg ?? {}) as { buildDir?: unknown; gradlePath?: unknown; path?: unknown };
|
||||
for (const build of model?.gradle ?? []) {
|
||||
const project = build.projects.find((p) => {
|
||||
if (typeof a.gradlePath === "string")
|
||||
return p.path === a.gradlePath && (a.buildDir === undefined || a.buildDir === build.dir);
|
||||
const file = p.buildFile;
|
||||
return (
|
||||
typeof a.path === "string" &&
|
||||
root &&
|
||||
file &&
|
||||
(/^(?:[A-Za-z]:[\\/]|\/)/.test(file) ? file : `${root}/${file}`) === a.path
|
||||
);
|
||||
});
|
||||
if (project) return gradleProjectKey(build, project);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const synced = new Set<string>();
|
||||
|
||||
/** Registers / unregisters the tool windows to match the model; first sync of a never-synced build. */
|
||||
function apply(model: BuildModel | null, registered: Partial<Record<BuildTool, () => void>>) {
|
||||
const has: Record<BuildTool, boolean> = { maven: !!model?.maven, gradle: !!model?.gradle };
|
||||
const has: Record<BuildTool, boolean> = { maven: !!model?.maven, gradle: !!model?.gradle?.length };
|
||||
setContext("build.maven", has.maven);
|
||||
setContext("build.gradle", has.gradle);
|
||||
for (const tool of ["maven", "gradle"] as const) {
|
||||
if (has[tool] && !registered[tool]) {
|
||||
const visible = has[tool] || (tool === "gradle" && model !== null && !!registered.gradle);
|
||||
if (visible && !registered[tool]) {
|
||||
registered[tool] =
|
||||
tool === "maven"
|
||||
? registerToolWindow({
|
||||
@@ -120,25 +135,40 @@ function apply(model: BuildModel | null, registered: Partial<Record<BuildTool, (
|
||||
order: 11,
|
||||
component: GradleView,
|
||||
});
|
||||
} else if (!has[tool] && registered[tool]) {
|
||||
} else if (!visible && registered[tool]) {
|
||||
registered[tool]!();
|
||||
delete registered[tool];
|
||||
}
|
||||
}
|
||||
// IDEA imports a build when the project is first opened.
|
||||
// Import each independent build once; linking another build must not be hidden by the first sync.
|
||||
const root = useBuild.getState().root;
|
||||
for (const tool of ["maven", "gradle"] as const) {
|
||||
const info = tool === "maven" ? model?.maven : model?.gradle;
|
||||
const key = `${tool}:${root}`;
|
||||
if (info && (tool !== "maven" || model?.maven?.projects.length) && info.syncedAt == null && !synced.has(key)) {
|
||||
const mavenKey = `maven:${root}`;
|
||||
if (model?.maven?.projects.length && model.maven.syncedAt == null && !synced.has(mavenKey)) {
|
||||
synced.add(mavenKey);
|
||||
requestSync("maven");
|
||||
}
|
||||
for (const build of model?.gradle ?? []) {
|
||||
const key = JSON.stringify([root, build.dir]);
|
||||
if (build.syncedAt == null && !synced.has(key)) {
|
||||
synced.add(key);
|
||||
void syncTool(tool);
|
||||
requestSync("gradle", build.dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function activate(): () => void {
|
||||
const registered: Partial<Record<BuildTool, () => void>> = {};
|
||||
const showGradle = () => {
|
||||
registered.gradle ??= registerToolWindow({
|
||||
id: "gradle",
|
||||
title: "Gradle",
|
||||
icon: "toolwindow-gradle",
|
||||
anchor: "right",
|
||||
order: 11,
|
||||
component: GradleView,
|
||||
});
|
||||
toolWindowService().show("gradle", true);
|
||||
};
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const scheduleLoad = () => {
|
||||
clearTimeout(timer);
|
||||
@@ -187,6 +217,7 @@ export function activate(): () => void {
|
||||
}),
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return;
|
||||
synced.clear();
|
||||
resetBuild();
|
||||
if (s.project) void loadModel();
|
||||
}),
|
||||
@@ -207,6 +238,30 @@ export function activate(): () => void {
|
||||
if (e.key === "languages" || e.key.startsWith("languages.java")) scheduleLoad();
|
||||
}),
|
||||
registerActions([
|
||||
{
|
||||
id: "gradle.linkProjects",
|
||||
title: "Link Gradle Projects",
|
||||
category: "Gradle",
|
||||
icon: "plus",
|
||||
when: "projectOpen && !workspaceOpen",
|
||||
run: async () => {
|
||||
showGradle();
|
||||
await linkGradleProjects();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gradle.unlinkProjects",
|
||||
title: "Unlink Gradle Project",
|
||||
category: "Gradle",
|
||||
hidden: true,
|
||||
when: `${GRADLE_WHEN} && !workspaceOpen`,
|
||||
run: async ({ arg }) => {
|
||||
const dir = (arg as { buildDir?: unknown } | undefined)?.buildDir;
|
||||
const root = useBuild.getState().root;
|
||||
if (await unlinkGradleProjects(typeof dir === "string" ? dir : null))
|
||||
synced.delete(JSON.stringify([root, dir]));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "maven.addProjects",
|
||||
title: "Add Maven Projects",
|
||||
@@ -319,7 +374,10 @@ export function activate(): () => void {
|
||||
icon: "refresh",
|
||||
when: GRADLE_WHEN,
|
||||
keywords: ["gradle", "sync", "refresh"],
|
||||
run: () => syncTool("gradle"),
|
||||
run: ({ arg }) => {
|
||||
const dir = (arg as { buildDir?: unknown } | undefined)?.buildDir;
|
||||
requestSync("gradle", typeof dir === "string" ? dir : null);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gradle.analyzeDependencies",
|
||||
@@ -348,7 +406,11 @@ export function activate(): () => void {
|
||||
icon: "run-anything",
|
||||
when: GRADLE_WHEN,
|
||||
keywords: ["gradle", "task", "run anything"],
|
||||
run: () => openExecute("gradle"),
|
||||
run: ({ arg }) => {
|
||||
const key = (arg as { projectKey?: unknown } | undefined)?.projectKey;
|
||||
if (typeof key === "string") setCurrentProject("gradle", key);
|
||||
openExecute("gradle");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gradle.toggleOffline",
|
||||
@@ -388,6 +450,7 @@ export function activate(): () => void {
|
||||
for (const d of Object.values(registered)) d?.();
|
||||
setContext("build.maven", false);
|
||||
setContext("build.gradle", false);
|
||||
synced.clear();
|
||||
resetBuild();
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { executeAction, registerAction, allActions, isActionEnabled } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
import type { BuildModel, ProjectInfo } from "@/platform/ipc";
|
||||
import type { BuildModel, GradleModel, ProjectInfo, RunConfig } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { MavenView } from "./BuildToolView";
|
||||
import { GradleView, MavenView } from "./BuildToolView";
|
||||
import { activate } from "./index";
|
||||
import { expandableKeys, gradleChildren, gradleRoots, gradleProjectKey } from "./model";
|
||||
import { executeBuildLine } from "./runAnything";
|
||||
import { useBuild } from "./store";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -16,6 +18,10 @@ const mocks = vi.hoisted(() => ({
|
||||
open: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
menu: vi.fn(),
|
||||
link: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
show: vi.fn(),
|
||||
sync: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const real = await original<typeof import("@/platform/ipc")>();
|
||||
@@ -29,9 +35,23 @@ vi.mock("@/platform/ipc", async (original) => {
|
||||
settingsSet: mocks.set,
|
||||
buildModel: mocks.model,
|
||||
runConfigs: mocks.configs,
|
||||
gradleLinkProjects: mocks.link,
|
||||
gradleUnlinkProjects: mocks.unlink,
|
||||
buildSync: mocks.sync,
|
||||
cancelRequest: () => Promise.resolve(),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@tauri-apps/api/core", async (original) => ({
|
||||
...(await original<typeof import("@tauri-apps/api/core")>()),
|
||||
Channel: class {
|
||||
onmessage: unknown = null;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/workbench/contributions", async (original) => ({
|
||||
...(await original<typeof import("@/workbench/contributions")>()),
|
||||
toolWindowService: () => ({ show: mocks.show }),
|
||||
}));
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open }));
|
||||
vi.mock("@/workbench/messages", () => ({ confirm: mocks.confirm }));
|
||||
vi.mock("@/ui", async (original) => ({
|
||||
@@ -40,7 +60,7 @@ vi.mock("@/ui", async (original) => ({
|
||||
}));
|
||||
|
||||
const model: BuildModel = {
|
||||
gradle: null,
|
||||
gradle: [],
|
||||
maven: {
|
||||
syncedAt: 1,
|
||||
stale: false,
|
||||
@@ -81,6 +101,7 @@ beforeEach(async () => {
|
||||
mocks.confirm.mockResolvedValue(true);
|
||||
useProject.setState({ project: { root: "/p" } as ProjectInfo });
|
||||
setContext("projectOpen", true);
|
||||
setContext("workspaceOpen", false);
|
||||
await act(async () => {
|
||||
dispose = activate();
|
||||
});
|
||||
@@ -204,3 +225,173 @@ it.each([null, "/outside/pom.xml", "/p/a/build.xml", "/p/../outside/pom.xml", "/
|
||||
expect(mocks.set).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("links picked POMs that no reactor lists as independent top-level projects", async () => {
|
||||
mocks.get.mockResolvedValue({
|
||||
languages: { java: { maven: { unlinkedProjects: ["b/pom.xml"], linkedProjects: ["tools/pom.xml"] } } },
|
||||
});
|
||||
mocks.open.mockResolvedValue(["/p/stub/pom.xml", "/p/b/pom.xml", "/p/a/pom.xml", "/p/tools/pom.xml"]);
|
||||
await act(async () => {
|
||||
await executeAction("maven.addProjects");
|
||||
});
|
||||
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ multiple: true }));
|
||||
expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", "[]");
|
||||
expect(mocks.set).toHaveBeenCalledWith(
|
||||
"project",
|
||||
"languages.java.maven.linkedProjects",
|
||||
'["tools/pom.xml","stub/pom.xml"]',
|
||||
);
|
||||
});
|
||||
|
||||
it("unlinking a linked independent project removes the link instead of hiding it", async () => {
|
||||
mocks.get.mockResolvedValue({ languages: { java: { maven: { linkedProjects: ["a/pom.xml"] } } } });
|
||||
await act(async () => {
|
||||
await executeAction("maven.unlinkProjects", { pom: "a/pom.xml" });
|
||||
});
|
||||
expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.linkedProjects", "[]");
|
||||
expect(mocks.set).not.toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", expect.anything());
|
||||
});
|
||||
|
||||
const gradleBuild = (dir: string): GradleModel => ({
|
||||
dir,
|
||||
command: `/p/${dir}/gradlew`,
|
||||
env: { JAVA_HOME: `/${dir}/jdk` },
|
||||
javaVersion: 21,
|
||||
syncedAt: 1,
|
||||
stale: false,
|
||||
syncError: null,
|
||||
projects: [
|
||||
{ path: ":", name: dir, dir, buildFile: `${dir}/build.gradle`, tasks: [] },
|
||||
{
|
||||
path: ":app",
|
||||
name: "app",
|
||||
dir: `${dir}/app`,
|
||||
buildFile: `${dir}/app/build.gradle`,
|
||||
tasks: [{ name: "test", group: "verification", description: null }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it("links from the toolbar and keeps Link available after unlinking the final build", async () => {
|
||||
const build = gradleBuild("first");
|
||||
const linked: BuildModel = { maven: null, gradle: [build] };
|
||||
mocks.link.mockResolvedValue(linked);
|
||||
mocks.unlink.mockResolvedValue({ maven: null, gradle: [] });
|
||||
mocks.open.mockResolvedValue(["/p/first/settings.gradle"]);
|
||||
act(() => useBuild.setState({ model: { maven: null, gradle: [] } }));
|
||||
render(<GradleView />);
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Link Gradle Projects" })));
|
||||
expect(mocks.link).toHaveBeenCalledWith(["/p/first/settings.gradle"]);
|
||||
expect(screen.getByText("first")).toBeTruthy();
|
||||
fireEvent.contextMenu(screen.getByText("first"));
|
||||
expect(mocks.menu).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.arrayContaining([expect.objectContaining({ action: "gradle.unlinkProjects", arg: { buildDir: "first" } })]),
|
||||
);
|
||||
await act(async () => executeAction("gradle.unlinkProjects", { buildDir: "first" }));
|
||||
expect(mocks.unlink).toHaveBeenCalledWith(["first"]);
|
||||
expect(mocks.confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining("Files will remain") }),
|
||||
);
|
||||
expect(screen.getByText("No Gradle projects")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Link Gradle Projects" })).toBeTruthy();
|
||||
expect(useBuild.getState().model?.gradle).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves links unchanged when unlink is cancelled or a module is targeted", async () => {
|
||||
act(() => useBuild.setState({ model: { maven: null, gradle: [gradleBuild("first")] } }));
|
||||
mocks.confirm.mockResolvedValue(false);
|
||||
await executeAction("gradle.unlinkProjects", { buildDir: "first" });
|
||||
await executeAction("gradle.unlinkProjects", { buildDir: "first/app" });
|
||||
expect(mocks.unlink).not.toHaveBeenCalled();
|
||||
expect(useBuild.getState().model?.gradle).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("runs duplicate task paths with the selected build's wrapper, cwd and offline setting", async () => {
|
||||
const builds = [gradleBuild("first"), gradleBuild("second")];
|
||||
const model: BuildModel = { maven: null, gradle: builds };
|
||||
const roots = gradleRoots(builds);
|
||||
const expanded = expandableKeys(roots, (n) => gradleChildren(n, builds, [], "/p"));
|
||||
act(() =>
|
||||
useBuild.setState({
|
||||
model,
|
||||
expanded: { maven: new Set(), gradle: expanded },
|
||||
current: { gradle: gradleProjectKey(builds[1]!, builds[1]!.projects[1]!) },
|
||||
settings: { ...useBuild.getState().settings, gradle: { offline: true } },
|
||||
}),
|
||||
);
|
||||
const started: RunConfig[] = [];
|
||||
const off = registerAction({
|
||||
id: "run.runConfig",
|
||||
title: "Run",
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
started.push((arg as { config: RunConfig }).config);
|
||||
},
|
||||
});
|
||||
render(<GradleView />);
|
||||
const tasks = screen.getAllByText("test");
|
||||
expect(tasks).toHaveLength(2);
|
||||
await act(async () => fireEvent.doubleClick(tasks[1]!));
|
||||
expect(executeBuildLine("gradle", "test")).toBe(true);
|
||||
off();
|
||||
expect(started).toHaveLength(2);
|
||||
for (const config of started)
|
||||
expect(config).toMatchObject({
|
||||
command: "/p/second/gradlew",
|
||||
cwd: "second",
|
||||
args: [":app:test", "--offline"],
|
||||
env: { JAVA_HOME: "/second/jdk" },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a late link result after the user changes projects", async () => {
|
||||
let finish!: (model: BuildModel) => void;
|
||||
mocks.open.mockResolvedValue("/p/first/build.gradle");
|
||||
mocks.link.mockImplementation(
|
||||
() =>
|
||||
new Promise<BuildModel>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const pending = executeAction("gradle.linkProjects");
|
||||
await vi.waitFor(() => expect(mocks.link).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
useProject.setState({ project: { root: "/other", trusted: false } as ProjectInfo });
|
||||
finish({ maven: null, gradle: [gradleBuild("first")] });
|
||||
await pending;
|
||||
});
|
||||
expect(useBuild.getState().model).toBeNull();
|
||||
});
|
||||
|
||||
it("routes Reload to the chosen independent build", async () => {
|
||||
const model: BuildModel = { maven: null, gradle: [gradleBuild("first"), gradleBuild("second")] };
|
||||
mocks.sync.mockResolvedValue(model);
|
||||
act(() => useBuild.setState({ model }));
|
||||
await executeAction("gradle.reload", { buildDir: "second" });
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.sync).toHaveBeenCalledWith("gradle", expect.any(Number), false, [], "second", expect.anything()),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not offer Link or Unlink for a native Workspace window", async () => {
|
||||
act(() =>
|
||||
useProject.setState({
|
||||
project: { root: "/p", workspace: { version: 1, name: "workspace", projects: ["/p/first"] } } as ProjectInfo,
|
||||
}),
|
||||
);
|
||||
setContext("workspaceOpen", true);
|
||||
for (const id of ["gradle.linkProjects", "gradle.unlinkProjects"])
|
||||
expect(isActionEnabled(allActions().find((a) => a.id === id)!)).toBe(false);
|
||||
await executeAction("gradle.linkProjects");
|
||||
expect(mocks.open).not.toHaveBeenCalled();
|
||||
expect(mocks.link).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not link or start Gradle imports in an untrusted project", async () => {
|
||||
act(() => useProject.setState({ project: { root: "/p", trusted: false } as ProjectInfo }));
|
||||
await executeAction("gradle.linkProjects");
|
||||
expect(mocks.open).not.toHaveBeenCalled();
|
||||
expect(mocks.link).not.toHaveBeenCalled();
|
||||
expect(mocks.sync).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
goalCompletions,
|
||||
gradleChildren,
|
||||
gradleRoots,
|
||||
gradleRunConfigs,
|
||||
gradleProjectKey,
|
||||
gradleTaskConfig,
|
||||
isRunnable,
|
||||
mavenChildren,
|
||||
@@ -284,10 +286,10 @@ describe("Gradle tree and runs", () => {
|
||||
syncError: null,
|
||||
};
|
||||
const kids = (n: BuildNode) =>
|
||||
gradleChildren(n, gradle, [runConfig({ id: "g", name: "app [run]", kind: "gradle" })]) ?? [];
|
||||
gradleChildren(n, [gradle], [runConfig({ id: "g", name: "app [run]", kind: "gradle" })]) ?? [];
|
||||
|
||||
it("nests subprojects and groups tasks, other last", () => {
|
||||
const roots = gradleRoots(gradle);
|
||||
const roots = gradleRoots([gradle]);
|
||||
expect(labels(roots)).toEqual(["gradle-demo"]);
|
||||
expect(labels(kids(roots[0]!))).toEqual(["Tasks", "Run Configurations", "app", "core"]);
|
||||
const app = kids(roots[0]!)[2]!;
|
||||
@@ -315,6 +317,65 @@ describe("Gradle tree and runs", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps identical project paths and tasks of independent builds distinct", () => {
|
||||
const builds = ["first", "second"].map((dir) => ({
|
||||
...gradle,
|
||||
dir,
|
||||
command: `/p/${dir}/gradlew`,
|
||||
env: { JAVA_HOME: `/${dir}/jdk` },
|
||||
projects: gradle.projects.map((p) => ({ ...p, dir: `${dir}/${p.dir}`.replace(/\/$/, "") })),
|
||||
}));
|
||||
const roots = gradleRoots(builds);
|
||||
expect(new Set(roots.map((n) => n.key)).size).toBe(2);
|
||||
const tasks = roots.map((r) => {
|
||||
const app = gradleChildren(r, builds, [])!.find((n) => n.kind === "gradleProject" && n.project.path === ":app")!;
|
||||
const folder = gradleChildren(app, builds, [])![0]!;
|
||||
const group = gradleChildren(folder, builds, [])!.find(
|
||||
(n) => n.kind === "taskGroup" && n.group === "verification",
|
||||
)!;
|
||||
return gradleChildren(group, builds, [])![0]!;
|
||||
});
|
||||
expect(tasks[0]!.key).not.toBe(tasks[1]!.key);
|
||||
const runs = builds.map((b) => gradleTaskConfig(b, b.projects[1]!, ["test"], { offline: true }));
|
||||
expect(runs[0]!.id).not.toBe(runs[1]!.id);
|
||||
expect(runs[1]).toMatchObject({
|
||||
command: "/p/second/gradlew",
|
||||
cwd: "second",
|
||||
env: { JAVA_HOME: "/second/jdk" },
|
||||
args: [":app:test", "--offline"],
|
||||
});
|
||||
expect(gradleProjectKey(builds[1]!, builds[1]!.projects[1]!)).toBe('gradle:["second",":app"]');
|
||||
});
|
||||
|
||||
it("shows run configurations only under their owning linked build", () => {
|
||||
const nested = {
|
||||
...gradle,
|
||||
dir: "nested",
|
||||
projects: gradle.projects.map((p) => ({ ...p, dir: `nested/${p.dir}` })),
|
||||
};
|
||||
const configs = [
|
||||
runConfig({ id: "root", kind: "gradle", cwd: "app" }),
|
||||
runConfig({ id: "nested", kind: "gradle", cwd: "nested/app" }),
|
||||
runConfig({ id: "absolute", kind: "gradle", cwd: "/p/nested/app" }),
|
||||
runConfig({ id: "other", kind: "maven", cwd: "nested" }),
|
||||
];
|
||||
expect(gradleRunConfigs(gradle, [gradle, nested], configs, "/p").map((c) => c.id)).toEqual(["root"]);
|
||||
expect(gradleRunConfigs(nested, [gradle, nested], configs, "/p").map((c) => c.id)).toEqual(["nested", "absolute"]);
|
||||
});
|
||||
|
||||
it("places a saved run under its directly linked root instead of a longer sibling composite", () => {
|
||||
const composite = {
|
||||
...gradle,
|
||||
dir: "very-long-composite",
|
||||
projects: [{ ...gradle.projects[1]!, path: ":b:app", dir: "b/app", build: ":b" }],
|
||||
};
|
||||
const direct = { ...gradle, dir: "b", projects: [{ ...gradle.projects[1]!, dir: "b/app" }] };
|
||||
const saved = runConfig({ id: "saved", kind: "gradle", cwd: "b/app" });
|
||||
const builds = [composite, direct];
|
||||
expect(gradleRunConfigs(composite, builds, [saved], "/p")).toEqual([]);
|
||||
expect(gradleRunConfigs(direct, builds, [saved], "/p")).toEqual([saved]);
|
||||
});
|
||||
|
||||
it("completes task names and paths", () => {
|
||||
const labelsOf = taskCompletions("ru", gradle).map((c) => c.label);
|
||||
expect(labelsOf).toEqual([":app:run"]);
|
||||
|
||||
@@ -67,10 +67,10 @@ export type BuildNode =
|
||||
| { kind: "dependency"; key: string; dependency: MavenDependency }
|
||||
| { kind: "runConfigs"; key: string; configs: RunConfig[] }
|
||||
| { kind: "runConfig"; key: string; config: RunConfig }
|
||||
| { kind: "gradleProject"; key: string; project: GradleProject }
|
||||
| { kind: "tasks"; key: string; project: GradleProject }
|
||||
| { kind: "taskGroup"; key: string; project: GradleProject; group: string; tasks: GradleTask[] }
|
||||
| { kind: "task"; key: string; project: GradleProject; task: GradleTask };
|
||||
| { kind: "gradleProject"; key: string; build: GradleModel; project: GradleProject }
|
||||
| { kind: "tasks"; key: string; build: GradleModel; project: GradleProject }
|
||||
| { kind: "taskGroup"; key: string; build: GradleModel; project: GradleProject; group: string; tasks: GradleTask[] }
|
||||
| { kind: "task"; key: string; build: GradleModel; project: GradleProject; task: GradleTask };
|
||||
|
||||
/** Options applied to every Maven run (toolbar toggles, Profiles checkboxes). */
|
||||
export interface MavenRunOptions {
|
||||
@@ -133,9 +133,28 @@ export function mavenRunConfigs(project: MavenProject, configs: readonly RunConf
|
||||
return configs.filter((c) => c.kind === "maven" && normalizeDir(c.cwd) === project.dir);
|
||||
}
|
||||
|
||||
/** Run configurations of a Gradle build: every `gradle` config. */
|
||||
export function gradleRunConfigs(configs: readonly RunConfig[]): RunConfig[] {
|
||||
return configs.filter((c) => c.kind === "gradle");
|
||||
/** Run configurations under this build, with nested independent builds owning their own runs. */
|
||||
export function gradleRunConfigs(
|
||||
model: GradleModel,
|
||||
models: readonly GradleModel[],
|
||||
configs: readonly RunConfig[],
|
||||
root: string,
|
||||
): RunConfig[] {
|
||||
const absolute = (path: string) =>
|
||||
/^(?:[A-Za-z]:[\\/]|\/)/.test(path) ? normalizeDir(path) : normalizeDir(`${root}/${path}`);
|
||||
return configs.filter((c) => {
|
||||
if (c.kind !== "gradle") return false;
|
||||
const cwd = absolute(c.cwd ?? "");
|
||||
const contains = (dir: string) => cwd === dir || cwd.startsWith(`${dir}/`);
|
||||
const ownerDepth = (build: GradleModel) => {
|
||||
const dir = absolute(build.dir);
|
||||
return contains(dir) ? dir.split("/").length : 0;
|
||||
};
|
||||
const owners = models
|
||||
.filter((build) => [build.dir, ...build.projects.map((p) => p.dir)].some((dir) => contains(absolute(dir))))
|
||||
.sort((a, b) => ownerDepth(b) - ownerDepth(a));
|
||||
return owners[0]?.dir === model.dir;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDir(cwd: string | null | undefined): string {
|
||||
@@ -230,9 +249,26 @@ export function gradleTaskGroups(project: GradleProject): Array<{ group: string;
|
||||
.map(([group, tasks]) => ({ group, tasks: [...tasks].sort((a, b) => a.name.localeCompare(b.name)) }));
|
||||
}
|
||||
|
||||
export function gradleRoots(model: GradleModel): BuildNode[] {
|
||||
const root = model.projects.find((p) => p.path === ":") ?? model.projects[0];
|
||||
return root ? [{ kind: "gradleProject", key: `gradle:${root.path}`, project: root }] : [];
|
||||
/** Identity of a project within one independently linked build. */
|
||||
export const gradleProjectKey = (build: Pick<GradleModel, "dir">, project: Pick<GradleProject, "path">) =>
|
||||
`gradle:${JSON.stringify([build.dir, project.path])}`;
|
||||
|
||||
export function gradleProjectForKey(models: readonly GradleModel[], key: string | null | undefined) {
|
||||
for (const build of models) {
|
||||
const project = build.projects.find((p) => gradleProjectKey(build, p) === key);
|
||||
if (project) return { build, project };
|
||||
}
|
||||
if (key != null) return null;
|
||||
const build = models[0];
|
||||
const project = build?.projects[0];
|
||||
return build && project ? { build, project } : null;
|
||||
}
|
||||
|
||||
export function gradleRoots(models: readonly GradleModel[]): BuildNode[] {
|
||||
return models.flatMap((build) => {
|
||||
const root = build.projects.find((p) => p.path === ":") ?? build.projects[0];
|
||||
return root ? [{ kind: "gradleProject" as const, key: gradleProjectKey(build, root), build, project: root }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Direct subprojects of `path` (`:a` and `:b` under `:`, `:a:x` under `:a`). */
|
||||
@@ -248,31 +284,39 @@ export function gradleSubprojects(model: GradleModel, path: string): GradleProje
|
||||
|
||||
export function gradleChildren(
|
||||
node: BuildNode,
|
||||
model: GradleModel,
|
||||
models: readonly GradleModel[],
|
||||
configs: readonly RunConfig[],
|
||||
root = "",
|
||||
): BuildNode[] | undefined {
|
||||
switch (node.kind) {
|
||||
case "gradleProject": {
|
||||
const p = node.project;
|
||||
const out: BuildNode[] = [{ kind: "tasks", key: `${node.key}:tasks`, project: p }];
|
||||
const out: BuildNode[] = [{ kind: "tasks", key: `${node.key}:tasks`, build: node.build, project: p }];
|
||||
if (p.path === ":") {
|
||||
const runs = gradleRunConfigs(configs);
|
||||
const runs = gradleRunConfigs(node.build, models, configs, root);
|
||||
if (runs.length) out.push({ kind: "runConfigs", key: `${node.key}:runs`, configs: runs });
|
||||
}
|
||||
for (const sub of gradleSubprojects(model, p.path))
|
||||
out.push({ kind: "gradleProject", key: `gradle:${sub.path}`, project: sub });
|
||||
for (const sub of gradleSubprojects(node.build, p.path))
|
||||
out.push({ kind: "gradleProject", key: gradleProjectKey(node.build, sub), build: node.build, project: sub });
|
||||
return out;
|
||||
}
|
||||
case "tasks":
|
||||
return gradleTaskGroups(node.project).map(({ group, tasks }) => ({
|
||||
kind: "taskGroup",
|
||||
key: `${node.key}:${group}`,
|
||||
build: node.build,
|
||||
project: node.project,
|
||||
group,
|
||||
tasks,
|
||||
}));
|
||||
case "taskGroup":
|
||||
return node.tasks.map((task) => ({ kind: "task", key: `${node.key}:${task.name}`, project: node.project, task }));
|
||||
return node.tasks.map((task) => ({
|
||||
kind: "task",
|
||||
key: `${node.key}:${task.name}`,
|
||||
build: node.build,
|
||||
project: node.project,
|
||||
task,
|
||||
}));
|
||||
case "runConfigs":
|
||||
return node.configs.map((config) => ({ kind: "runConfig", key: `${node.key}:${config.id}`, config }));
|
||||
default:
|
||||
@@ -456,7 +500,7 @@ export function gradleTaskConfig(
|
||||
if (options.offline && !args.includes("--offline")) args.push("--offline");
|
||||
const names = tasks.filter((t) => !t.startsWith("-"));
|
||||
return {
|
||||
id: `gradle:task:${project.path}:${tasks.join(" ")}`,
|
||||
id: `gradle:task:${JSON.stringify([model.dir, project.path])}:${tasks.join(" ")}`,
|
||||
name: `${project.name} [${names.join(",")}]`,
|
||||
kind: "gradle",
|
||||
command: model.command,
|
||||
|
||||
@@ -37,7 +37,7 @@ const maven = {
|
||||
],
|
||||
projects: [],
|
||||
} as unknown as MavenModel;
|
||||
const model: BuildModel = { maven, gradle: null };
|
||||
const model: BuildModel = { maven, gradle: [] };
|
||||
|
||||
/** The `-P` entries each sync was started with. */
|
||||
const syncedProfiles = () => mocks.sync.mock.calls.map((c) => c[3] as string[]);
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { RunAnythingItem, RunAnythingProvider } from "@/workbench/contribut
|
||||
import {
|
||||
goalCompletions,
|
||||
gradleTaskConfig,
|
||||
gradleProjectForKey,
|
||||
mavenGoalConfig,
|
||||
parseGoalLine,
|
||||
parseTaskLine,
|
||||
@@ -25,19 +26,20 @@ const WORD: Record<BuildTool, string> = { maven: "mvn", gradle: "gradle" };
|
||||
export function lineTool(line: string): BuildTool | null {
|
||||
const model = useBuild.getState().model;
|
||||
if (model?.maven && COMMANDS.maven.test(line)) return "maven";
|
||||
if (model?.gradle && COMMANDS.gradle.test(line)) return "gradle";
|
||||
if (model?.gradle?.length && COMMANDS.gradle.test(line)) return "gradle";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The project a line runs in: the given one, else the tool window's current one, else the first. */
|
||||
function projectFor(tool: BuildTool, key?: string | null) {
|
||||
function mavenProjectFor(key?: string | null) {
|
||||
const { model, current } = useBuild.getState();
|
||||
if (tool === "maven") {
|
||||
const projects = model?.maven?.projects ?? [];
|
||||
return projects.find((p) => p.pom === (key ?? current.maven)) ?? projects[0];
|
||||
}
|
||||
const projects = model?.gradle?.projects ?? [];
|
||||
return projects.find((p) => p.path === (key ?? current.gradle)) ?? projects[0];
|
||||
const projects = model?.maven?.projects ?? [];
|
||||
return projects.find((p) => p.pom === (key ?? current.maven)) ?? projects[0];
|
||||
}
|
||||
|
||||
function gradleProjectFor(key?: string | null) {
|
||||
const { model, current } = useBuild.getState();
|
||||
return gradleProjectForKey(model?.gradle ?? [], key ?? current.gradle);
|
||||
}
|
||||
|
||||
/** Runs goals / tasks typed for `tool` in a project; false when there is nothing to run. */
|
||||
@@ -45,28 +47,27 @@ export function executeBuildLine(tool: BuildTool, text: string, projectKey?: str
|
||||
const { model, settings } = useBuild.getState();
|
||||
if (tool === "maven") {
|
||||
const maven = model?.maven;
|
||||
const project = projectFor("maven", projectKey);
|
||||
const project = mavenProjectFor(projectKey);
|
||||
const goals = parseGoalLine(text);
|
||||
if (!maven || !project || !("pom" in project) || !goals.length) return false;
|
||||
runConfig(mavenGoalConfig(project, goals, maven, settings.maven), "run");
|
||||
} else {
|
||||
const gradle = model?.gradle;
|
||||
const project = projectFor("gradle", projectKey);
|
||||
const selected = gradleProjectFor(projectKey);
|
||||
const tasks = parseTaskLine(text);
|
||||
if (!gradle || !project || "pom" in project || !tasks.length) return false;
|
||||
runConfig(gradleTaskConfig(gradle, project, tasks, settings.gradle), "run");
|
||||
if (!selected || !tasks.length) return false;
|
||||
runConfig(gradleTaskConfig(selected.build, selected.project, tasks, settings.gradle), "run");
|
||||
}
|
||||
pushHistory(tool, text.replace(COMMANDS[tool], "").trim());
|
||||
return true;
|
||||
}
|
||||
|
||||
function completions(tool: BuildTool, rest: string) {
|
||||
const model = useBuild.getState().model;
|
||||
if (tool === "maven") {
|
||||
const project = projectFor("maven");
|
||||
const project = mavenProjectFor();
|
||||
return goalCompletions(rest, project && "pom" in project ? project.plugins : [], readHistory("maven"));
|
||||
}
|
||||
return model?.gradle ? taskCompletions(rest, model.gradle, readHistory("gradle")) : [];
|
||||
const selected = gradleProjectFor();
|
||||
return selected ? taskCompletions(rest, selected.build, readHistory("gradle")) : [];
|
||||
}
|
||||
|
||||
export const buildToolsRunAnything: RunAnythingProvider = {
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useBuildModel } from "@/platform/buildModel";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { editorService, notifications } from "@/workbench/contributions";
|
||||
import { cancelTask, removeTask, upsertTask, useTasks } from "@/workbench/tasks";
|
||||
import { profilesList, type GradleRunOptions, type MavenRunOptions } from "./model";
|
||||
import { gradleRoots, profilesList, type GradleRunOptions, type MavenRunOptions } from "./model";
|
||||
|
||||
export interface BuildSettings {
|
||||
maven: MavenRunOptions;
|
||||
@@ -58,7 +58,7 @@ interface BuildState {
|
||||
expanded: Record<BuildTool, Set<string>>;
|
||||
/** Execute Goal / Task popup. */
|
||||
executeOpen: BuildTool | null;
|
||||
/** Project selected in each tree (Maven `pom`, Gradle path): where Execute Goal runs. */
|
||||
/** Project selected in each tree (Maven `pom`, Gradle build/project key): where commands run. */
|
||||
current: Partial<Record<BuildTool, string>>;
|
||||
}
|
||||
|
||||
@@ -198,9 +198,6 @@ export function changedOutsideIde(paths: readonly string[], now = Date.now()): b
|
||||
return external;
|
||||
}
|
||||
|
||||
/** Root whose never-synced Gradle build was imported on open (once per open). */
|
||||
let autoImported: string | null = null;
|
||||
|
||||
/** Re-reads the build model (and the run configurations) of the open project. */
|
||||
export async function loadModel(): Promise<void> {
|
||||
const root = useProject.getState().project?.root ?? null;
|
||||
@@ -230,26 +227,39 @@ 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) });
|
||||
}
|
||||
}
|
||||
|
||||
const UNLINKED_PROJECTS = "languages.java.maven.unlinkedProjects";
|
||||
const LINKED_PROJECTS = "languages.java.maven.linkedProjects";
|
||||
|
||||
async function setLinked(root: string, pom: string, linked: boolean) {
|
||||
/**
|
||||
* Adds `poms` to Maven (back from unlinked, or as independent top-level projects when no
|
||||
* reactor lists them), or removes them (a linked one is dropped, a discovered one unlinked).
|
||||
*/
|
||||
async function setLinked(root: string, poms: string[], linked: boolean) {
|
||||
if (useProject.getState().project?.root !== root) return;
|
||||
const settings = await commands.settingsGet();
|
||||
if (useProject.getState().project?.root !== root) return;
|
||||
const previous = settings.languages.java?.maven?.unlinkedProjects ?? [];
|
||||
const next = linked ? previous.filter((p) => p !== pom) : [...new Set([...previous, pom])];
|
||||
if (JSON.stringify(next) === JSON.stringify(previous)) return;
|
||||
await call(commands.settingsSet("project", UNLINKED_PROJECTS, JSON.stringify(next)));
|
||||
const maven = settings.languages.java?.maven;
|
||||
const unlinkedBefore = maven?.unlinkedProjects ?? [];
|
||||
const linkedBefore = maven?.linkedProjects ?? [];
|
||||
const known = new Set(useBuild.getState().model?.maven?.projects.map((p) => p.pom));
|
||||
let unlinked = unlinkedBefore;
|
||||
let extra = linkedBefore;
|
||||
for (const pom of poms) {
|
||||
if (linked && unlinked.includes(pom)) unlinked = unlinked.filter((p) => p !== pom);
|
||||
else if (linked) {
|
||||
if (!known.has(pom) && !extra.includes(pom)) extra = [...extra, pom];
|
||||
} else if (extra.includes(pom)) extra = extra.filter((p) => p !== pom);
|
||||
else if (!unlinked.includes(pom)) unlinked = [...unlinked, pom];
|
||||
}
|
||||
if (unlinked === unlinkedBefore && extra === linkedBefore) return;
|
||||
if (unlinked !== unlinkedBefore)
|
||||
await call(commands.settingsSet("project", UNLINKED_PROJECTS, JSON.stringify(unlinked)));
|
||||
if (extra !== linkedBefore) await call(commands.settingsSet("project", LINKED_PROJECTS, JSON.stringify(extra)));
|
||||
await loadModel();
|
||||
}
|
||||
|
||||
@@ -268,7 +278,19 @@ export async function unlinkProjects(pom: string | null) {
|
||||
)
|
||||
return;
|
||||
cancelSync("maven");
|
||||
await setLinked(root, pom, false);
|
||||
await setLinked(root, [pom], false);
|
||||
}
|
||||
|
||||
/** Project-relative `pom.xml` paths of `picked` inside `root`; others are skipped. */
|
||||
function projectPoms(root: string, picked: readonly string[]): string[] {
|
||||
const base = root.replace(/\\/g, "/").replace(/\/$/, "");
|
||||
return picked.flatMap((p) => {
|
||||
const path = p.replace(/\\/g, "/");
|
||||
if (!path.startsWith(`${base}/`)) return [];
|
||||
const pom = path.slice(base.length + 1);
|
||||
const parts = pom.split("/");
|
||||
return parts.some((part) => part === ".." || part === ".") || parts.pop() !== "pom.xml" ? [] : [pom];
|
||||
});
|
||||
}
|
||||
|
||||
export async function addProjects() {
|
||||
@@ -278,43 +300,104 @@ export async function addProjects() {
|
||||
const picked = await open({
|
||||
title: tx`Add Maven Projects`,
|
||||
directory: false,
|
||||
multiple: false,
|
||||
multiple: true,
|
||||
defaultPath: root,
|
||||
filters: [{ name: "pom.xml", extensions: ["xml"] }],
|
||||
});
|
||||
if (typeof picked !== "string") return;
|
||||
const base = root.replace(/\\/g, "/").replace(/\/$/, "");
|
||||
const path = picked.replace(/\\/g, "/");
|
||||
if (!path.startsWith(`${base}/`)) return;
|
||||
const pom = path.slice(base.length + 1);
|
||||
if (pom.split("/").some((part) => part === ".." || part === ".") || pom.split("/").pop() !== "pom.xml") return;
|
||||
await setLinked(root, pom, true);
|
||||
const poms = projectPoms(root, typeof picked === "string" ? [picked] : (picked ?? []));
|
||||
if (poms.length) await setLinked(root, poms, true);
|
||||
}
|
||||
|
||||
/** Links independent Gradle builds without changing their files or build scripts. */
|
||||
export async function linkGradleProjects() {
|
||||
const gen = generation;
|
||||
const root = useProject.getState().project?.root;
|
||||
if (
|
||||
!root ||
|
||||
useProject.getState().project?.workspace ||
|
||||
useProject.getState().project?.trusted === false ||
|
||||
!isTauri()
|
||||
)
|
||||
return;
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const picked = await open({
|
||||
title: tx`Link Gradle Projects`,
|
||||
directory: false,
|
||||
multiple: true,
|
||||
defaultPath: root,
|
||||
filters: [{ name: "Gradle build files", extensions: ["gradle", "kts"] }],
|
||||
});
|
||||
const paths = typeof picked === "string" ? [picked] : picked;
|
||||
if (!paths?.length || useProject.getState().project?.root !== root || gen !== generation) return;
|
||||
const model = await call(commands.gradleLinkProjects(paths));
|
||||
if (useProject.getState().project?.root !== root || gen !== generation) return;
|
||||
const configs = await call(commands.runConfigs()).catch(() => useBuild.getState().configs);
|
||||
if (useProject.getState().project?.root === root && gen === generation)
|
||||
useBuild.setState((s) => ({
|
||||
model,
|
||||
configs,
|
||||
expanded: {
|
||||
...s.expanded,
|
||||
gradle: new Set([...s.expanded.gradle, ...initialExpanded("gradle", model)]),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export async function unlinkGradleProjects(buildDir: string | null) {
|
||||
const expectedGeneration = generation;
|
||||
const { root, model } = useBuild.getState();
|
||||
const build = model?.gradle?.find((g) => g.dir === buildDir);
|
||||
if (
|
||||
!root ||
|
||||
useProject.getState().project?.workspace ||
|
||||
!build ||
|
||||
!(await confirm({
|
||||
title: "Unlink Gradle project?",
|
||||
message:
|
||||
"Remove this build from Gradle? Files will remain in the Project view; link its build file again to restore it.",
|
||||
ok: "Unlink",
|
||||
}))
|
||||
)
|
||||
return;
|
||||
if (useProject.getState().project?.root !== root || expectedGeneration !== generation) return;
|
||||
const gen = ++generation;
|
||||
queuedSyncs.delete("gradle");
|
||||
cancelSync("gradle");
|
||||
const next = await call(commands.gradleUnlinkProjects([build.dir]));
|
||||
if (useProject.getState().project?.root !== root || gen !== generation) return;
|
||||
const configs = await call(commands.runConfigs()).catch(() => useBuild.getState().configs);
|
||||
if (useProject.getState().project?.root !== root || gen !== generation) return;
|
||||
useBuild.setState({ model: next, configs, current: { ...useBuild.getState().current, gradle: undefined } });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Tree keys expanded on first show: the top-level projects. */
|
||||
export function initialExpanded(tool: BuildTool, model: BuildModel): Set<string> {
|
||||
if (tool === "maven")
|
||||
return new Set(model.maven?.projects.filter((p) => !p.aggregated).map((p) => `mvn:${p.pom}`) ?? []);
|
||||
return new Set(model.gradle ? ["gradle::"] : []);
|
||||
return new Set(gradleRoots(model.gradle ?? []).map((n) => n.key));
|
||||
}
|
||||
|
||||
const TITLES: Record<BuildTool, string> = { maven: "Syncing Maven projects", gradle: "Syncing Gradle projects" };
|
||||
|
||||
/** Tools to sync again once their running sync ends: its options changed meanwhile. */
|
||||
const queuedSyncs = new Set<BuildTool>();
|
||||
const queuedSyncs = new Map<BuildTool, Set<string | null>>();
|
||||
|
||||
/** Syncs `tool` now, or right after the sync in progress (which used the previous options). */
|
||||
export function requestSync(tool: BuildTool) {
|
||||
if (useBuild.getState().syncing[tool]) queuedSyncs.add(tool);
|
||||
else void syncTool(tool);
|
||||
export function requestSync(tool: BuildTool, buildDir: string | null = null) {
|
||||
if (useBuild.getState().syncing[tool]) {
|
||||
const queued = queuedSyncs.get(tool) ?? new Set<string | null>();
|
||||
queued.add(buildDir);
|
||||
queuedSyncs.set(tool, queued);
|
||||
} else void syncTool(tool, buildDir);
|
||||
}
|
||||
|
||||
/** Reloads all projects of `tool` from the build tool itself (effective POM, dependency tree, task list). */
|
||||
export async function syncTool(tool: BuildTool): Promise<void> {
|
||||
/** Reloads the build tool model; `buildDir` limits a Gradle reload to one linked build. */
|
||||
export async function syncTool(tool: BuildTool, buildDir: string | null = null): Promise<void> {
|
||||
const state = useBuild.getState();
|
||||
const gen = generation;
|
||||
const root = state.root;
|
||||
if (!root || !isTauri() || state.syncing[tool]) return;
|
||||
if (!root || useProject.getState().project?.trusted === false || !isTauri() || state.syncing[tool]) return;
|
||||
const request = nextRequestId();
|
||||
const taskId = `build-sync:${tool}`;
|
||||
const cancel = () => void commands.cancelRequest(request);
|
||||
@@ -331,11 +414,18 @@ export async function syncTool(tool: BuildTool): Promise<void> {
|
||||
const profiles = tool === "maven" && model?.maven ? profilesList(model.maven.profiles, settings.maven.profiles) : [];
|
||||
const offline = tool === "maven" ? settings.maven.offline : settings.gradle.offline;
|
||||
try {
|
||||
const next = await call(commands.buildSync(tool, request, offline, profiles, channel));
|
||||
const next = await call(commands.buildSync(tool, request, offline, profiles, buildDir, channel));
|
||||
if (useBuild.getState().root !== root || gen !== generation) return;
|
||||
const configs = await call(commands.runConfigs()).catch(() => useBuild.getState().configs);
|
||||
if (useBuild.getState().root !== root || gen !== generation) return;
|
||||
useBuild.setState({ model: next, configs });
|
||||
const error = tool === "maven" ? next.maven?.syncError : next.gradle?.syncError;
|
||||
const error =
|
||||
tool === "maven"
|
||||
? next.maven?.syncError
|
||||
: (next.gradle ?? [])
|
||||
.map((g) => g.syncError)
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (error)
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
@@ -355,13 +445,22 @@ export async function syncTool(tool: BuildTool): Promise<void> {
|
||||
key: `build-sync-${tool}`,
|
||||
});
|
||||
} finally {
|
||||
if (useBuild.getState().syncing[tool]?.request === request) setSync(undefined);
|
||||
upsertTask({ id: taskId, title: TITLES[tool], message: null, fraction: 1, done: true });
|
||||
if (queuedSyncs.delete(tool) && gen === generation) void syncTool(tool);
|
||||
if (useBuild.getState().syncing[tool]?.request === request) {
|
||||
setSync(undefined);
|
||||
upsertTask({ id: taskId, title: TITLES[tool], message: null, fraction: 1, done: true });
|
||||
const queued = queuedSyncs.get(tool);
|
||||
const next = queued?.values().next();
|
||||
if (next && !next.done && useBuild.getState().root === root) {
|
||||
queued!.delete(next.value);
|
||||
if (!queued!.size) queuedSyncs.delete(tool);
|
||||
void syncTool(tool, next.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelSync(tool: BuildTool) {
|
||||
queuedSyncs.delete(tool);
|
||||
const s = useBuild.getState().syncing[tool];
|
||||
if (!s) return;
|
||||
const taskId = `build-sync:${tool}`;
|
||||
@@ -419,8 +518,8 @@ export function setCurrentProject(tool: BuildTool, id: string | undefined) {
|
||||
export const ANALYZER_KIND = "maven.dependencyAnalyzer";
|
||||
|
||||
/**
|
||||
* Module the Dependency Analyzer shows: a Maven `pom` (project-relative) or `gradle:<path>`
|
||||
* (`gradle::app`) for a Gradle project; `null` = the first one.
|
||||
* Module the Dependency Analyzer shows: a Maven `pom` or a Gradle build/project key;
|
||||
* `null` = the first one.
|
||||
*/
|
||||
export const useAnalyzer = create<{ module: string | null }>(() => ({ module: null }));
|
||||
|
||||
|
||||
@@ -4,20 +4,24 @@
|
||||
* client-side sorting when the caller does not sort on the server, and the
|
||||
* Record / Value / Aggregates side panel following the selection.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState, type KeyboardEvent, type RefObject } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Button, Checkbox, Icon, IconButton, Input, Popup, Select, Tabs, T, type MenuItem } from "@/ui";
|
||||
import { Button, Checkbox, Icon, IconButton, Input, Popup, SearchField, Select, Tabs, T, type MenuItem } from "@/ui";
|
||||
import { EXPORT_FORMATS, exportData, type ExportData, type ExportFormat } from "./export";
|
||||
import { Grid, type GridColumn, type GridProps, type GridRange } from "./Grid";
|
||||
import type { Cell } from "./model";
|
||||
import {
|
||||
aggregates,
|
||||
cellMatcher,
|
||||
distinctValues,
|
||||
findCells,
|
||||
formatNumber,
|
||||
rowMatches,
|
||||
toggleFilterValue,
|
||||
visibleRows,
|
||||
type LocalFilter,
|
||||
type LocalSort,
|
||||
type SearchOptions,
|
||||
} from "./resultView";
|
||||
import { openDialog } from "./store";
|
||||
import { hasLineBreak } from "./cellPreview";
|
||||
@@ -83,9 +87,23 @@ export interface DataViewProps extends Pick<
|
||||
/** The side panel shown (`null`: hidden). */
|
||||
panel: DataPanel | null;
|
||||
onPanelChange: (panel: DataPanel | null) => void;
|
||||
/** The search bar is shown (the toolbar's search button, ⌘F in the grid). */
|
||||
searchOpen?: boolean;
|
||||
onSearchOpenChange?: (open: boolean) => void;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
const NO_SEARCH: SearchOptions = { caseSensitive: false, wholeWord: false, regex: false };
|
||||
|
||||
interface SearchState {
|
||||
query: string;
|
||||
options: SearchOptions;
|
||||
/** Show only the rows with a match (IDEA's Filter rows). */
|
||||
filterRows: boolean;
|
||||
/** Index of the current match. */
|
||||
current: number;
|
||||
}
|
||||
|
||||
export function DataView(props: DataViewProps) {
|
||||
const { columns, rowCount, value, fixedRows = rowCount, panel, onPanelChange } = props;
|
||||
const columnKey = columns.map((c) => c.name).join("\u0000");
|
||||
@@ -102,15 +120,49 @@ export function DataView(props: DataViewProps) {
|
||||
|
||||
const local = !props.onSort;
|
||||
// Display row → loaded row; `null` keeps the identity (no filter, no local sort).
|
||||
const order = useMemo(
|
||||
const shaped = useMemo(
|
||||
() =>
|
||||
filter.size || (local && localSort)
|
||||
? visibleRows(rowCount, value, filter, local ? localSort : null, fixedRows)
|
||||
: null,
|
||||
[rowCount, value, filter, local, localSort, fixedRows],
|
||||
);
|
||||
|
||||
// Search covers the loaded rows only (the page shown).
|
||||
const { searchOpen = false, onSearchOpenChange } = props;
|
||||
const [search, setSearch] = useState<SearchState>({ query: "", options: NO_SEARCH, filterRows: false, current: 0 });
|
||||
const searchInput = useRef<HTMLInputElement>(null);
|
||||
const matcher = useMemo(
|
||||
() => (searchOpen ? cellMatcher(search.query, search.options) : null),
|
||||
[searchOpen, search.query, search.options],
|
||||
);
|
||||
const test = typeof matcher === "function" ? matcher : null;
|
||||
const order = useMemo(() => {
|
||||
if (!test || !search.filterRows) return shaped;
|
||||
const base = shaped ?? Array.from({ length: rowCount }, (_, i) => i);
|
||||
return base.filter((r) => rowMatches(r, columns.length, value, test));
|
||||
}, [shaped, test, search.filterRows, rowCount, columns.length, value]);
|
||||
const at = (r: number) => (order ? order[r]! : r);
|
||||
const shown = order ? order.length : rowCount;
|
||||
const matches = useMemo(
|
||||
() => (test ? findCells(order ?? Array.from({ length: rowCount }, (_, i) => i), columns.length, value, test) : []),
|
||||
[test, order, rowCount, columns.length, value],
|
||||
);
|
||||
const matchSet = useMemo(() => new Set(matches.map((m) => `${m.row}:${m.col}`)), [matches]);
|
||||
const currentMatch = matches.length ? matches[Math.min(search.current, matches.length - 1)]! : null;
|
||||
const step = (by: number) =>
|
||||
matches.length &&
|
||||
setSearch((s) => ({
|
||||
...s,
|
||||
current: (Math.min(s.current, matches.length - 1) + by + matches.length) % matches.length,
|
||||
}));
|
||||
const openSearch = () => {
|
||||
onSearchOpenChange?.(true);
|
||||
requestAnimationFrame(() => {
|
||||
searchInput.current?.focus();
|
||||
searchInput.current?.select();
|
||||
});
|
||||
};
|
||||
|
||||
const onSort = local
|
||||
? (column: string) => {
|
||||
@@ -129,32 +181,50 @@ export function DataView(props: DataViewProps) {
|
||||
|
||||
return (
|
||||
<div className={styles.dataView}>
|
||||
<div className={clsx(styles.fill, styles.gridHost)}>
|
||||
<Grid
|
||||
aria-label={props["aria-label"]}
|
||||
columns={columns}
|
||||
rowCount={shown}
|
||||
value={(r, c) => value(at(r), c)}
|
||||
rowState={props.rowState && ((r) => props.rowState!(at(r)))}
|
||||
isModified={props.isModified && ((r, c) => props.isModified!(at(r), c))}
|
||||
editable={props.editable}
|
||||
onEdit={props.onEdit && ((r, c, v) => props.onEdit!(at(r), c, v))}
|
||||
sort={sort}
|
||||
onSort={onSort}
|
||||
rowMenu={props.rowMenu && ((rows) => props.rowMenu!(rows.map(at)))}
|
||||
rowNumber={(r) => (props.firstRowNumber ?? 1) + at(r)}
|
||||
onSelectionChange={setRange}
|
||||
onFilter={(col, anchor) => setFilterAt({ col, anchor })}
|
||||
filtered={(col) => filter.has(col)}
|
||||
/>
|
||||
{filter.size > 0 && (
|
||||
<div className={styles.filterPill} role="status">
|
||||
{tx`${shown} of ${rowCount} rows`}
|
||||
<button type="button" className={styles.filterPillClear} onClick={() => setFilter(new Map())}>
|
||||
<T>Clear local filter</T>
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.dataMain}>
|
||||
{searchOpen && (
|
||||
<GridSearchBar
|
||||
inputRef={searchInput}
|
||||
search={search}
|
||||
onChange={(p) => setSearch((s) => ({ ...s, ...p, current: 0 }))}
|
||||
count={matches.length}
|
||||
active={currentMatch ? Math.min(search.current, matches.length - 1) : -1}
|
||||
invalid={matcher === "invalid"}
|
||||
onNext={() => step(1)}
|
||||
onPrevious={() => step(-1)}
|
||||
onClose={() => onSearchOpenChange?.(false)}
|
||||
/>
|
||||
)}
|
||||
<div className={clsx(styles.fill, styles.gridHost)}>
|
||||
<Grid
|
||||
isMatch={test ? (r, c) => matchSet.has(`${r}:${c}`) : undefined}
|
||||
currentMatch={currentMatch}
|
||||
onFind={onSearchOpenChange && openSearch}
|
||||
aria-label={props["aria-label"]}
|
||||
columns={columns}
|
||||
rowCount={shown}
|
||||
value={(r, c) => value(at(r), c)}
|
||||
rowState={props.rowState && ((r) => props.rowState!(at(r)))}
|
||||
isModified={props.isModified && ((r, c) => props.isModified!(at(r), c))}
|
||||
editable={props.editable}
|
||||
onEdit={props.onEdit && ((r, c, v) => props.onEdit!(at(r), c, v))}
|
||||
sort={sort}
|
||||
onSort={onSort}
|
||||
rowMenu={props.rowMenu && ((rows) => props.rowMenu!(rows.map(at)))}
|
||||
rowNumber={(r) => (props.firstRowNumber ?? 1) + at(r)}
|
||||
onSelectionChange={setRange}
|
||||
onFilter={(col, anchor) => setFilterAt({ col, anchor })}
|
||||
filtered={(col) => filter.has(col)}
|
||||
/>
|
||||
{filter.size > 0 && (
|
||||
<div className={styles.filterPill} role="status">
|
||||
{tx`${shown} of ${rowCount} rows`}
|
||||
<button type="button" className={styles.filterPillClear} onClick={() => setFilter(new Map())}>
|
||||
<T>Clear local filter</T>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{filterAt && (
|
||||
<FilterPopup
|
||||
@@ -189,6 +259,109 @@ export function DataView(props: DataViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** IDEA's search bar above the grid: Match Case / Words / Regex, n/m, previous / next, Filter rows. */
|
||||
function GridSearchBar(props: {
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
search: SearchState;
|
||||
onChange: (patch: Partial<Omit<SearchState, "current">>) => void;
|
||||
count: number;
|
||||
/** Zero-based current match; -1 when there is none. */
|
||||
active: number;
|
||||
invalid: boolean;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { search, count, invalid, active, inputRef, onChange, onNext, onPrevious, onClose } = props;
|
||||
const none = !!search.query && !invalid && count === 0;
|
||||
const toggle = (key: keyof SearchOptions) =>
|
||||
onChange({ options: { ...search.options, [key]: !search.options[key] } });
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
} else if (e.key === "Enter" || e.key === "F3") {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) onPrevious();
|
||||
else onNext();
|
||||
} else if (e.altKey && !e.metaKey && !e.ctrlKey) {
|
||||
const key =
|
||||
e.code === "KeyC" ? "caseSensitive" : e.code === "KeyW" ? "wholeWord" : e.code === "KeyX" ? "regex" : null;
|
||||
if (key) {
|
||||
e.preventDefault();
|
||||
toggle(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className={styles.gridSearch} role="search" aria-label={tx("Search in results")} onKeyDown={onKeyDown}>
|
||||
<SearchField
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
className={styles.gridSearchField}
|
||||
value={search.query}
|
||||
onChange={(query) => onChange({ query })}
|
||||
placeholder="Search in results"
|
||||
aria-label="Search in results"
|
||||
invalid={invalid || none}
|
||||
toggles={[
|
||||
{
|
||||
id: "case",
|
||||
icon: "case",
|
||||
title: "Match Case",
|
||||
shortcut: "⌥C",
|
||||
pressed: search.options.caseSensitive,
|
||||
onToggle: () => toggle("caseSensitive"),
|
||||
},
|
||||
{
|
||||
id: "word",
|
||||
icon: "word",
|
||||
title: "Words",
|
||||
shortcut: "⌥W",
|
||||
pressed: search.options.wholeWord,
|
||||
onToggle: () => toggle("wholeWord"),
|
||||
},
|
||||
{
|
||||
id: "regex",
|
||||
icon: "regex",
|
||||
title: "Regex",
|
||||
shortcut: "⌥X",
|
||||
pressed: search.options.regex,
|
||||
onToggle: () => toggle("regex"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<span className={clsx(styles.gridSearchCount, (invalid || none) && styles.gridSearchNone)} aria-live="polite">
|
||||
{invalid ? tx("Bad regex") : !search.query ? "" : count === 0 ? tx("0 results") : `${active + 1}/${count}`}
|
||||
</span>
|
||||
<IconButton
|
||||
icon="arrow-up"
|
||||
title="Previous Occurrence"
|
||||
shortcut="⇧↵"
|
||||
size="small"
|
||||
disabled={count === 0}
|
||||
onClick={onPrevious}
|
||||
/>
|
||||
<IconButton
|
||||
icon="arrow-down"
|
||||
title="Next Occurrence"
|
||||
shortcut="↵"
|
||||
size="small"
|
||||
disabled={count === 0}
|
||||
onClick={onNext}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={search.filterRows}
|
||||
onChange={(filterRows) => onChange({ filterRows })}
|
||||
label={tx("Filter rows")}
|
||||
/>
|
||||
<span className={styles.spacer} />
|
||||
<IconButton icon="close" title="Close" shortcut="⎋" size="small" onClick={onClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterPopup(props: {
|
||||
anchor: HTMLElement;
|
||||
column: string;
|
||||
|
||||
@@ -560,21 +560,6 @@
|
||||
.gridHost {
|
||||
position: relative;
|
||||
}
|
||||
/* IDEA's floating row count at the bottom of a result. */
|
||||
.rowPill {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 12px;
|
||||
transform: translateX(-50%);
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--border-popup, var(--border));
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-popup, var(--bg-elev));
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
box-shadow: var(--shadow-popup, none);
|
||||
pointer-events: none;
|
||||
}
|
||||
.resultSource {
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
@@ -1260,3 +1245,77 @@
|
||||
overflow-wrap: anywhere;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* The grid beside the Record / Value / Aggregates panel, under its search bar. */
|
||||
.dataMain {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* IDEA's search bar above a result grid. */
|
||||
.gridSearch {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--fs-ui);
|
||||
}
|
||||
.gridSearchField {
|
||||
flex: 0 1 360px;
|
||||
min-width: 160px;
|
||||
}
|
||||
.gridSearchCount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-subtle);
|
||||
padding: 0 6px;
|
||||
white-space: nowrap;
|
||||
min-width: 56px;
|
||||
}
|
||||
.gridSearchNone {
|
||||
color: var(--danger);
|
||||
}
|
||||
.cellMatch {
|
||||
background: var(--bg-text-search-result);
|
||||
}
|
||||
.cellMatchCurrent {
|
||||
background: var(--bg-text-search-result-current);
|
||||
}
|
||||
|
||||
/* IDEA's floating pager at the bottom of a result: |< < 1-500 of 501+ > >| and the page size. */
|
||||
.resultPager {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 12px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--border-popup, var(--border));
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-popup, var(--bg-elev));
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
box-shadow: var(--shadow-popup, none);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.resultPagerRange {
|
||||
padding: 0 6px;
|
||||
}
|
||||
.resultPagerCount {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.resultPagerCount:disabled {
|
||||
color: inherit;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { DataView } from "./DataView";
|
||||
import { Grid } from "./Grid";
|
||||
import { CELL_PREVIEW_LIMIT, cellPreview } from "./cellPreview";
|
||||
import { exportData } from "./export";
|
||||
@@ -175,3 +177,60 @@ describe("multi-line database cells", () => {
|
||||
expect(onEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe("result grid search", () => {
|
||||
const columns = [
|
||||
{ name: "city", type: "text" },
|
||||
{ name: "n", type: "int" },
|
||||
];
|
||||
const data = [
|
||||
["Shanghai", "1"],
|
||||
["Beijing", "2"],
|
||||
["Shenzhen", "3"],
|
||||
["Hangzhou", "4"],
|
||||
];
|
||||
const value = (r: number, c: number) => data[r]?.[c] ?? null;
|
||||
|
||||
it("opens the search bar on Ctrl+F and highlights the matches of the page", () => {
|
||||
function Harness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<DataView
|
||||
columns={columns}
|
||||
rowCount={4}
|
||||
value={value}
|
||||
panel={null}
|
||||
onPanelChange={() => {}}
|
||||
searchOpen={open}
|
||||
onSearchOpenChange={setOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
fireEvent.keyDown(screen.getByRole("grid"), { key: "f", ctrlKey: true });
|
||||
const input = screen.getByRole("textbox", { name: "Search in results" });
|
||||
fireEvent.change(input, { target: { value: "sh" } });
|
||||
const marked = () =>
|
||||
screen
|
||||
.getAllByRole("gridcell")
|
||||
.filter((c) => /cellMatch/.test(c.className))
|
||||
.map((c) => c.textContent);
|
||||
expect(marked()).toEqual(["Shanghai", "Shenzhen"]);
|
||||
expect(screen.getByText("1/2")).toBeTruthy();
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByText("2/2")).toBeTruthy();
|
||||
|
||||
// Match Case: "sh" no longer matches "Sh…".
|
||||
fireEvent.click(screen.getByRole("button", { name: /Match Case/ }));
|
||||
expect(marked()).toEqual([]);
|
||||
expect(screen.getByText("0 results")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Match Case/ }));
|
||||
|
||||
// Filter rows keeps only the rows with a match.
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Filter rows" }));
|
||||
expect(screen.getAllByRole("row").length - 1).toBe(2);
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(screen.queryByRole("search")).toBeNull();
|
||||
expect(screen.getAllByRole("row").length - 1).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,12 @@ export interface GridProps {
|
||||
/** The header's filter button (IDEA's local filter); `filtered` marks the columns with one. */
|
||||
onFilter?: (col: number, anchor: HTMLElement) => void;
|
||||
filtered?: (col: number) => boolean;
|
||||
/** Cells matching the search bar's query. */
|
||||
isMatch?: (row: number, col: number) => boolean;
|
||||
/** The search bar's current match: selected and scrolled into view when it changes. */
|
||||
currentMatch?: { row: number; col: number } | null;
|
||||
/** ⌘F / Ctrl+F in the grid (opens the search bar). */
|
||||
onFind?: () => void;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
@@ -154,6 +160,15 @@ export function Grid(props: GridProps) {
|
||||
estimateSize: () => ROW_H,
|
||||
overscan: 12,
|
||||
});
|
||||
const { currentMatch } = props;
|
||||
useEffect(() => {
|
||||
if (!currentMatch) return;
|
||||
const { row, col } = currentMatch;
|
||||
setSel({ r0: row, c0: col, r1: row, c1: col });
|
||||
virtualizer.scrollToIndex(row, { align: "auto" });
|
||||
// Follows the match only, not the virtualizer's identity.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentMatch?.row, currentMatch?.col]);
|
||||
const totalWidth = useMemo(() => NUM_W + widths.reduce((a, b) => a + b, 0), [widths]);
|
||||
const template = `${NUM_W}px ${widths.map((w) => `${w}px`).join(" ")}`;
|
||||
|
||||
@@ -211,6 +226,11 @@ export function Grid(props: GridProps) {
|
||||
copy("tsv", false);
|
||||
return;
|
||||
}
|
||||
if (mod && e.key.toLowerCase() === "f" && props.onFind) {
|
||||
e.preventDefault();
|
||||
props.onFind();
|
||||
return;
|
||||
}
|
||||
if (mod && e.key.toLowerCase() === "a") {
|
||||
e.preventDefault();
|
||||
if (rowCount && columns.length) setSel({ r0: 0, c0: 0, r1: rowCount - 1, c1: columns.length - 1 });
|
||||
@@ -466,6 +486,8 @@ export function Grid(props: GridProps) {
|
||||
className={clsx(
|
||||
styles.cell,
|
||||
numeric[c] && styles.cellNumber,
|
||||
props.isMatch?.(r, c) && styles.cellMatch,
|
||||
currentMatch?.row === r && currentMatch.col === c && styles.cellMatchCurrent,
|
||||
selected(r, c) && styles.cellSelected,
|
||||
props.isModified?.(r, c) && styles.cellModified,
|
||||
)}
|
||||
|
||||
@@ -7,16 +7,22 @@ import { copySelectionOnKeyDown } from "@/ui/selectableText";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { DbPlanNode } from "@/platform/ipc";
|
||||
import { EmptyState, Icon, IconButton, Spinner, Tabs, Tree, showContextMenu, T } from "@/ui";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { EmptyState, Icon, IconButton, Select, Spinner, Tabs, Tree, showContextMenu, T } from "@/ui";
|
||||
import { ChartView } from "./ChartView";
|
||||
import { DataView, ExtractorSelect, PanelToggle, exportMenu, type DataPanel } from "./DataView";
|
||||
import type { ExportData, ExportFormat } from "./export";
|
||||
import { pageWindow } from "./resultView";
|
||||
import { analyze } from "./sql/analyze";
|
||||
import {
|
||||
OUTPUT_TAB,
|
||||
clearOutput,
|
||||
closeResult,
|
||||
countResult,
|
||||
countable,
|
||||
loadResultPage,
|
||||
outcomeSummary,
|
||||
pageable,
|
||||
rerunResult,
|
||||
sourceById,
|
||||
targetLabel,
|
||||
@@ -72,9 +78,88 @@ function queryTable(text: string, data: Pick<ExportData, "driver">): string {
|
||||
return a.refs.find((r) => r.depth === 0 && r.kind === "table")?.name ?? "MY_TABLE";
|
||||
}
|
||||
|
||||
const PAGE_SIZES = [10, 100, 500, 1000];
|
||||
|
||||
/** IDEA's pager at the bottom of a result: |< < 1-500 of 501+ > >| and the page size; paging runs the query again. */
|
||||
function Pager({ tab }: { tab: ResultTab }) {
|
||||
const r = tab.outcome.result!;
|
||||
const page = tab.page ?? 0;
|
||||
const pageSize = tab.pageSize ?? Math.max(r.rows.length, 1);
|
||||
const state = { page, pageSize, rows: r.rows.length, hasMore: r.hasMore, total: tab.total ?? null };
|
||||
const w = pageWindow(state);
|
||||
const canPage = pageable(tab) && !tab.busy;
|
||||
const go = (p: number, size = pageSize) => void loadResultPage(tab.id, p, size);
|
||||
const goLast = async () => {
|
||||
let lastPage = w.lastPage;
|
||||
if (lastPage === null) {
|
||||
const total = await countResult(tab.id);
|
||||
if (total === null) return;
|
||||
lastPage = pageWindow({ ...state, total }).lastPage;
|
||||
}
|
||||
if (lastPage !== null && lastPage !== page) go(lastPage);
|
||||
};
|
||||
const { first, last } = w;
|
||||
const total = w.total === null ? `${w.last + 1}+` : String(w.total);
|
||||
const sizes = PAGE_SIZES.includes(pageSize) ? PAGE_SIZES : [...PAGE_SIZES, pageSize].sort((a, b) => a - b);
|
||||
return (
|
||||
<div
|
||||
className={styles.resultPager}
|
||||
role="toolbar"
|
||||
aria-label={tx("Result pages")}
|
||||
title={outcomeSummary(tab.outcome)}
|
||||
>
|
||||
<IconButton
|
||||
icon="page-first"
|
||||
title="First Page"
|
||||
size="small"
|
||||
disabled={!canPage || page === 0}
|
||||
onClick={() => go(0)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="chevron-left"
|
||||
title="Previous Page"
|
||||
size="small"
|
||||
disabled={!canPage || page === 0}
|
||||
onClick={() => go(page - 1)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.resultPagerRange, styles.resultPagerCount)}
|
||||
disabled={w.total !== null || !canPage || !countable(tab)}
|
||||
title={w.total === null ? tx("Count rows") : undefined}
|
||||
onClick={() => void countResult(tab.id)}
|
||||
>
|
||||
{tx`${first}-${last} of ${total}`}
|
||||
</button>
|
||||
<IconButton
|
||||
icon="chevron-right"
|
||||
title="Next Page"
|
||||
size="small"
|
||||
disabled={!canPage || !r.hasMore}
|
||||
onClick={() => go(page + 1)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="page-last"
|
||||
title="Last Page"
|
||||
size="small"
|
||||
disabled={!canPage || (!r.hasMore && w.total !== null) || (w.total === null && !countable(tab))}
|
||||
onClick={() => void goLast()}
|
||||
/>
|
||||
<Select
|
||||
aria-label="Page size"
|
||||
value={String(pageSize)}
|
||||
options={sizes.map((v) => ({ value: String(v), label: String(v) }))}
|
||||
onChange={(v) => go(0, Number(v))}
|
||||
disabled={!canPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Result({ tab }: { tab: ResultTab }) {
|
||||
const [view, setView] = useState<"table" | "chart">("table");
|
||||
const [panel, setPanel] = useState<DataPanel | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [extractor, setExtractor] = useState<ExportFormat>("csv");
|
||||
const r = tab.outcome.result;
|
||||
const columns = useMemo(() => (r ? r.columns.map((c) => ({ name: c.name, type: c.typeName })) : []), [r]);
|
||||
@@ -94,6 +179,15 @@ function Result({ tab }: { tab: ResultTab }) {
|
||||
<IconButton icon="db-chart" title="Chart" aria-pressed={view === "chart"} onClick={() => setView("chart")} />
|
||||
<span className={styles.toolbarSeparator} />
|
||||
<IconButton icon="refresh" title="Rerun Query" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
|
||||
{view === "table" && (
|
||||
<IconButton
|
||||
icon="search"
|
||||
title="Search"
|
||||
shortcut={shortcutLabel("find.inFile")}
|
||||
aria-pressed={searchOpen}
|
||||
onClick={() => setSearchOpen(!searchOpen)}
|
||||
/>
|
||||
)}
|
||||
{tab.busy && <Spinner size={12} />}
|
||||
<span className={styles.spacer} />
|
||||
<span className={styles.resultSource} title={tab.outcome.statement.text}>
|
||||
@@ -121,12 +215,12 @@ function Result({ tab }: { tab: ResultTab }) {
|
||||
value={value}
|
||||
panel={panel}
|
||||
onPanelChange={setPanel}
|
||||
searchOpen={searchOpen}
|
||||
onSearchOpenChange={setSearchOpen}
|
||||
firstRowNumber={(tab.page ?? 0) * (tab.pageSize ?? 0) + 1}
|
||||
aria-label={tab.title}
|
||||
/>
|
||||
<div className={styles.rowPill} role="status" title={outcomeSummary(tab.outcome)}>
|
||||
{r.rows.length === 1 ? tx`${r.rows.length} row` : tx`${r.rows.length} rows`}
|
||||
{r.hasMore ? ` ${tx("(limit reached)")}` : ""}
|
||||
</div>
|
||||
<Pager tab={tab} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { openDialog, sourceById, withConnection } from "./store";
|
||||
import type { TableRef } from "./tableInput";
|
||||
import styles from "./Database.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
|
||||
const PAGE_SIZES = ["100", "500", "1000", "5000"];
|
||||
|
||||
@@ -46,6 +47,7 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
const [nonce, setNonce] = useState(0);
|
||||
const [edits, setEdits] = useState<PendingEdits>(emptyEdits);
|
||||
const [panel, setPanel] = useState<DataPanel | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [extractor, setExtractor] = useState<ExportFormat>("csv");
|
||||
// The ORDER BY field while it's being edited; it shows the sort otherwise.
|
||||
const [orderDraft, setOrderDraft] = useState<string | null>(null);
|
||||
@@ -254,6 +256,13 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
disabled={!editable}
|
||||
onClick={() => openDialog({ kind: "import", id: ref.id, schema: ref.schema, table: ref.table })}
|
||||
/>
|
||||
<IconButton
|
||||
icon="search"
|
||||
title="Search"
|
||||
shortcut={shortcutLabel("find.inFile")}
|
||||
aria-pressed={searchOpen}
|
||||
onClick={() => setSearchOpen(!searchOpen)}
|
||||
/>
|
||||
<PanelToggle panel={panel} onChange={setPanel} />
|
||||
</div>
|
||||
<div className={styles.filterBar}>
|
||||
@@ -322,6 +331,8 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
value={value}
|
||||
panel={panel}
|
||||
onPanelChange={setPanel}
|
||||
searchOpen={searchOpen}
|
||||
onSearchOpenChange={setSearchOpen}
|
||||
firstRowNumber={offset + 1}
|
||||
rowState={(r) => (r >= loaded ? "inserted" : edits.deletes.has(r) ? "deleted" : null)}
|
||||
isModified={(r, c) => !!edits.updates.get(r)?.has(names[c]!)}
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
import { dropSql } from "./objects";
|
||||
import {
|
||||
aggregates,
|
||||
cellMatcher,
|
||||
findCells,
|
||||
pageFetch,
|
||||
pageWindow,
|
||||
chartSeries,
|
||||
compareCells,
|
||||
distinctValues,
|
||||
@@ -219,3 +223,81 @@ describe("dropping schema objects", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("grid search", () => {
|
||||
const opts = { caseSensitive: false, wholeWord: false, regex: false };
|
||||
|
||||
it("matches cell text with Match Case, Words and Regex", () => {
|
||||
const m = cellMatcher("shang", opts);
|
||||
expect(typeof m).toBe("function");
|
||||
const test = m as (v: string) => boolean;
|
||||
expect(test("Shanghai")).toBe(true);
|
||||
expect(test("Beijing")).toBe(false);
|
||||
expect((cellMatcher("shang", { ...opts, caseSensitive: true }) as (v: string) => boolean)("Shanghai")).toBe(false);
|
||||
const word = cellMatcher("23", { ...opts, wholeWord: true }) as (v: string) => boolean;
|
||||
expect(word("23")).toBe(true);
|
||||
expect(word("123")).toBe(false);
|
||||
expect(word("a 23 b")).toBe(true);
|
||||
const re = cellMatcher(String.raw`^\d\.\d$`, { ...opts, regex: true }) as (v: string) => boolean;
|
||||
expect(re("2.5")).toBe(true);
|
||||
expect(re("10")).toBe(false);
|
||||
// A dot is literal without Regex.
|
||||
expect((cellMatcher("2.5", opts) as (v: string) => boolean)("215")).toBe(false);
|
||||
});
|
||||
|
||||
it("reports no query and a bad regex", () => {
|
||||
expect(cellMatcher("", opts)).toBeNull();
|
||||
expect(cellMatcher("(", { ...opts, regex: true })).toBe("invalid");
|
||||
});
|
||||
|
||||
it("steps over astral characters on empty matches in Words mode", () => {
|
||||
for (const query of ["x*", "|", "^", String.raw`\d*`, String.raw`\b`]) {
|
||||
const m = cellMatcher(query, { ...opts, regex: true, wholeWord: true }) as (v: string) => boolean;
|
||||
expect(() => m("a😀b")).not.toThrow();
|
||||
expect(typeof m("😀")).toBe("boolean");
|
||||
}
|
||||
});
|
||||
|
||||
it("finds the matching cells row by row, skipping NULL", () => {
|
||||
const m = cellMatcher("i", opts) as (v: string) => boolean;
|
||||
expect(findCells([0, 1, 2, 3], 3, value, m)).toEqual([
|
||||
{ row: 0, col: 1 },
|
||||
{ row: 1, col: 1 },
|
||||
{ row: 2, col: 1 },
|
||||
]);
|
||||
// Display rows are positions in `rows`.
|
||||
expect(findCells([3, 1], 3, value, m)).toEqual([{ row: 1, col: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("result pages", () => {
|
||||
it("describes the rows of a page, IDEA style", () => {
|
||||
expect(pageWindow({ page: 0, pageSize: 500, rows: 500, hasMore: true, total: null })).toEqual({
|
||||
first: 1,
|
||||
last: 500,
|
||||
total: null,
|
||||
lastPage: null,
|
||||
});
|
||||
expect(pageWindow({ page: 1, pageSize: 500, rows: 1, hasMore: false, total: null })).toEqual({
|
||||
first: 501,
|
||||
last: 501,
|
||||
total: 501,
|
||||
lastPage: 1,
|
||||
});
|
||||
expect(pageWindow({ page: 0, pageSize: 100, rows: 100, hasMore: true, total: 1234 })).toMatchObject({
|
||||
total: 1234,
|
||||
lastPage: 12,
|
||||
});
|
||||
expect(pageWindow({ page: 0, pageSize: 100, rows: 0, hasMore: false, total: null })).toEqual({
|
||||
first: 0,
|
||||
last: 0,
|
||||
total: 0,
|
||||
lastPage: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("slices the rows of a page from a fetch of every row up to it", () => {
|
||||
expect(pageFetch(0, 500)).toEqual({ maxRows: 500, skip: 0 });
|
||||
expect(pageFetch(2, 100)).toEqual({ maxRows: 100, skip: 200 });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Pure helpers of the result views: IDEA's local filter and client-side sort
|
||||
* of loaded rows, the Aggregates of a selection, and the series of the chart.
|
||||
* of loaded rows, search in the grid, paging of console results, the
|
||||
* Aggregates of a selection, and the series of the chart.
|
||||
*/
|
||||
import type { Cell } from "./model";
|
||||
|
||||
@@ -192,3 +193,114 @@ export function niceTicks(min: number, max: number, count = 5): number[] {
|
||||
if (out.at(-1)! < hi) out.push(Number((out.at(-1)! + step).toPrecision(12)));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** IDEA's search options of the grid's search bar. */
|
||||
export interface SearchOptions {
|
||||
caseSensitive: boolean;
|
||||
wholeWord: boolean;
|
||||
regex: boolean;
|
||||
}
|
||||
|
||||
const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g;
|
||||
const WORD_CHAR = /[\p{L}\p{N}_$]/u;
|
||||
|
||||
/** A test of a cell's text against `query`; `null` without a query, `"invalid"` for a bad regex. */
|
||||
export function cellMatcher(query: string, options: SearchOptions): ((text: string) => boolean) | null | "invalid" {
|
||||
if (!query) return null;
|
||||
const source = options.regex ? query : query.replace(REGEX_SPECIALS, "\\$&");
|
||||
let re: RegExp;
|
||||
try {
|
||||
re = new RegExp(source, options.caseSensitive ? "gu" : "giu");
|
||||
} catch {
|
||||
return "invalid";
|
||||
}
|
||||
// Words: a match with no word character on either side (no lookbehind: older WebKit lacks it).
|
||||
return (text) => {
|
||||
re.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text))) {
|
||||
const start = m.index;
|
||||
const end = start + m[0].length;
|
||||
if (end === start) {
|
||||
// Step a whole code point: with the `u` flag a lastIndex inside a surrogate pair
|
||||
// snaps back to the pair's start, which would match the same empty string forever.
|
||||
re.lastIndex = start + ((text.codePointAt(start) ?? 0) > 0xffff ? 2 : 1);
|
||||
if (!options.wholeWord) return true;
|
||||
continue;
|
||||
}
|
||||
if (!options.wholeWord || (!WORD_CHAR.test(text[start - 1] ?? "") && !WORD_CHAR.test(text[end] ?? "")))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/** The cells matching `test`, row by row; `row` is the position in `rows` (display order). NULL never matches. */
|
||||
export function findCells(
|
||||
rows: readonly number[],
|
||||
colCount: number,
|
||||
value: (row: number, col: number) => Cell,
|
||||
test: (text: string) => boolean,
|
||||
): Array<{ row: number; col: number }> {
|
||||
const out: Array<{ row: number; col: number }> = [];
|
||||
rows.forEach((r, row) => {
|
||||
for (let col = 0; col < colCount; col++) {
|
||||
const v = value(r, col);
|
||||
if (v !== null && test(v)) out.push({ row, col });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether any cell of loaded row `r` matches `test`. */
|
||||
export function rowMatches(
|
||||
r: number,
|
||||
colCount: number,
|
||||
value: (row: number, col: number) => Cell,
|
||||
test: (text: string) => boolean,
|
||||
): boolean {
|
||||
for (let col = 0; col < colCount; col++) {
|
||||
const v = value(r, col);
|
||||
if (v !== null && test(v)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface PageState {
|
||||
/** Zero-based page shown. */
|
||||
page: number;
|
||||
pageSize: number;
|
||||
/** Rows on the page. */
|
||||
rows: number;
|
||||
/** More rows follow the page. */
|
||||
hasMore: boolean;
|
||||
/** Counted rows of the whole query, when known. */
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
export interface PageWindow {
|
||||
/** One-based numbers of the first and last row shown (0 and 0 on an empty page). */
|
||||
first: number;
|
||||
last: number;
|
||||
/** Rows of the whole query: known once counted or on reaching the end. */
|
||||
total: number | null;
|
||||
/** Zero-based last page, when the total is known. */
|
||||
lastPage: number | null;
|
||||
}
|
||||
|
||||
/** What the pager shows ("1-500 of 501+"). */
|
||||
export function pageWindow({ page, pageSize, rows, hasMore, total }: PageState): PageWindow {
|
||||
const offset = page * pageSize;
|
||||
const known = total ?? (hasMore ? null : offset + rows);
|
||||
return {
|
||||
first: rows ? offset + 1 : 0,
|
||||
last: offset + rows,
|
||||
total: known,
|
||||
lastPage: known === null ? null : Math.max(0, Math.ceil(known / pageSize) - 1),
|
||||
};
|
||||
}
|
||||
|
||||
/** A page is read by running the query again: the backend reads past the rows before it and returns only the page. */
|
||||
export function pageFetch(page: number, pageSize: number): { maxRows: number; skip: number } {
|
||||
return { maxRows: pageSize, skip: page * pageSize };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isReadOnlyQuery } from "./readOnly";
|
||||
|
||||
describe("isReadOnlyQuery", () => {
|
||||
it("accepts queries that only read", () => {
|
||||
for (const text of [
|
||||
"SELECT * FROM t",
|
||||
"with x as (select 1) select * from x",
|
||||
"VALUES (1), (2)",
|
||||
"show tables",
|
||||
"(select 1) union (select 2)",
|
||||
])
|
||||
expect(isReadOnlyQuery(text, "postgres"), text).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects statements that change data", () => {
|
||||
for (const text of [
|
||||
"DELETE FROM t RETURNING *",
|
||||
"UPDATE t SET a = 1 RETURNING a",
|
||||
"INSERT INTO t VALUES (1) RETURNING id",
|
||||
"with d as (delete from t returning *) select * from d",
|
||||
"select * into t2 from t",
|
||||
"select * from t for update",
|
||||
"select nextval('s')",
|
||||
])
|
||||
expect(isReadOnlyQuery(text, "postgres"), text).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { DbDriver } from "@/platform/ipc";
|
||||
import { lex } from "./lexer";
|
||||
|
||||
/** Statements that only read. */
|
||||
const READ_VERBS = new Set(["SELECT", "WITH", "VALUES", "TABLE", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "PRAGMA"]);
|
||||
/** Words that make a reading statement write (data-modifying CTEs, SELECT INTO, row locks, sequences). */
|
||||
const WRITES = new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "INTO", "NEXTVAL", "SETVAL", "CALL", "EXEC", "EXECUTE"]);
|
||||
|
||||
/** Whether running `text` again only reads (paging a result re-runs its query). */
|
||||
export function isReadOnlyQuery(text: string, driver: DbDriver): boolean {
|
||||
const tokens = lex(text, driver).tokens;
|
||||
const first = tokens.find((t) => t.text !== "(");
|
||||
if (!first || first.kind !== "word" || !READ_VERBS.has(first.upper)) return false;
|
||||
return !tokens.some((t) => t.kind === "word" && WRITES.has(t.upper));
|
||||
}
|
||||
@@ -39,6 +39,8 @@ import type { ExportData, ExportFormat } from "./export";
|
||||
import type { DbObjectRef } from "./objects";
|
||||
import { formatted } from "./sql/format";
|
||||
import { bindParams, findParams } from "./sql/params";
|
||||
import { isReadOnlyQuery } from "./sql/readOnly";
|
||||
import { pageFetch } from "./resultView";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
export const DB_TOOL_WINDOW = "database";
|
||||
@@ -58,6 +60,11 @@ export interface ResultTab {
|
||||
plan?: DbPlanNode[];
|
||||
/** Being re-run. */
|
||||
busy?: boolean;
|
||||
/** Zero-based page of the result shown in `outcome` (IDEA's paging re-runs the query). */
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
/** Rows of the whole query, once counted. */
|
||||
total?: number | null;
|
||||
}
|
||||
|
||||
/** One line of the consoles' Output log (IDEA's Database Console output). */
|
||||
@@ -643,12 +650,19 @@ function promptOf(id: string): string {
|
||||
return useDatabase.getState().completion[id]?.defaultSchema || sourceById(id)?.name || id;
|
||||
}
|
||||
|
||||
/** Runs `statements` on `id`, logging to Output; errors stop the batch (backend). */
|
||||
async function run(id: string, statements: SqlStatement[], request: RequestId): Promise<DbStatementOutcome[]> {
|
||||
/**
|
||||
* Runs `statements` on `id`, logging to Output; errors stop the batch (backend).
|
||||
* Results keep `maxRows` rows after the first `skip`, which the backend reads past.
|
||||
*/
|
||||
async function run(
|
||||
id: string,
|
||||
statements: SqlStatement[],
|
||||
request: RequestId,
|
||||
maxRows = useDatabase.getState().maxRows,
|
||||
skip = 0,
|
||||
): Promise<DbStatementOutcome[]> {
|
||||
const wasConnected = useDatabase.getState().connected.has(id);
|
||||
const outcomes = await withConnection(id, () =>
|
||||
call(commands.dbExecute(request, id, statements, useDatabase.getState().maxRows)),
|
||||
);
|
||||
const outcomes = await withConnection(id, () => call(commands.dbExecute(request, id, statements, skip, maxRows)));
|
||||
if (!wasConnected) log("info", "Connected");
|
||||
for (const o of outcomes) {
|
||||
log("statement", o.statement.text, `${promptOf(id)}>`);
|
||||
@@ -791,12 +805,13 @@ export async function executeConsole(
|
||||
const request = nextRequestId();
|
||||
useDatabase.setState((s) => ({ running: { ...s.running, [path]: request } }));
|
||||
try {
|
||||
const outcomes = await run(id, statements, request);
|
||||
const pageSize = useDatabase.getState().maxRows;
|
||||
const outcomes = await run(id, statements, request, pageSize);
|
||||
const tabs: ResultTab[] = outcomes
|
||||
.filter((o) => o.result?.columns.length)
|
||||
.map((outcome) => {
|
||||
const n = nextTab++;
|
||||
return { id: n, title: tx`Result ${n}`, path, sourceId: id, outcome };
|
||||
return { id: n, title: tx`Result ${n}`, path, sourceId: id, outcome, page: 0, pageSize };
|
||||
});
|
||||
const failed = outcomes.some((o) => o.error);
|
||||
useDatabase.setState((s) => ({
|
||||
@@ -912,28 +927,90 @@ export async function openObjectDdl(id: string, schema: string, kind: DbObjectKi
|
||||
export async function rerunResult(tabId: number): Promise<void> {
|
||||
const tab = useDatabase.getState().results.find((r) => r.id === tabId);
|
||||
if (!tab || tab.busy) return;
|
||||
const patch = (p: Partial<ResultTab>) =>
|
||||
useDatabase.setState((s) => ({ results: s.results.map((r) => (r.id === tabId ? { ...r, ...p } : r)) }));
|
||||
patch({ busy: true });
|
||||
if (tab.plan) {
|
||||
patchResult(tabId, { busy: true });
|
||||
try {
|
||||
patch({ plan: await explain(tab.sourceId, tab.outcome.statement), busy: false });
|
||||
patchResult(tabId, { plan: await explain(tab.sourceId, tab.outcome.statement), busy: false });
|
||||
} catch (e) {
|
||||
patch({ busy: false });
|
||||
patchResult(tabId, { busy: false });
|
||||
if (!isCancelled(e)) log("error", `[${targetLabel(tab.sourceId)}] ${message(e)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await loadResultPage(tabId, tab.page ?? 0, tab.pageSize ?? useDatabase.getState().maxRows, true);
|
||||
}
|
||||
|
||||
/** Whether result tab `tab` can show other pages: running its query again only reads. */
|
||||
export function pageable(tab: ResultTab): boolean {
|
||||
return !tab.plan && isReadOnlyQuery(tab.outcome.statement.text, sourceById(tab.sourceId)?.driver ?? "sqlite");
|
||||
}
|
||||
|
||||
function patchResult(tabId: number, p: Partial<ResultTab>) {
|
||||
useDatabase.setState((s) => ({ results: s.results.map((r) => (r.id === tabId ? { ...r, ...p } : r)) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows page `page` of `pageSize` rows of result tab `tabId`: its query runs
|
||||
* again and the backend returns only that page (IDEA's result paging).
|
||||
* `rerun` also forgets the counted total.
|
||||
*/
|
||||
export async function loadResultPage(tabId: number, page: number, pageSize: number, rerun = false): Promise<void> {
|
||||
const tab = useDatabase.getState().results.find((r) => r.id === tabId);
|
||||
if (!tab || tab.busy || tab.plan) return;
|
||||
if (page > 0 && !pageable(tab)) return;
|
||||
patchResult(tabId, { busy: true });
|
||||
const { maxRows, skip } = pageFetch(page, pageSize);
|
||||
try {
|
||||
const [outcome] = await run(tab.sourceId, [tab.outcome.statement], nextRequestId());
|
||||
patch(outcome?.result ? { outcome, busy: false } : { busy: false });
|
||||
const [outcome] = await run(tab.sourceId, [tab.outcome.statement], nextRequestId(), maxRows, skip);
|
||||
const r = outcome?.result;
|
||||
if (r) {
|
||||
const sizeChanged = pageSize !== tab.pageSize;
|
||||
patchResult(tabId, {
|
||||
outcome,
|
||||
page,
|
||||
pageSize,
|
||||
busy: false,
|
||||
...(rerun || sizeChanged ? { total: null } : {}),
|
||||
});
|
||||
} else patchResult(tabId, { busy: false });
|
||||
if (outcome?.error) useDatabase.setState({ activeResult: OUTPUT_TAB });
|
||||
} catch (e) {
|
||||
patch({ busy: false });
|
||||
patchResult(tabId, { busy: false });
|
||||
if (!isCancelled(e)) log("error", message(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** `SELECT COUNT(*)` over a query, for queries that can be a subquery. */
|
||||
function countQuery(text: string): string | null {
|
||||
if (!/^\s*(\(|select\b|with\b|values\b|table\b)/i.test(text)) return null;
|
||||
// `)` on its own line: a trailing line comment (`… from t -- note`) would swallow it.
|
||||
return `select count(*) from (\n${text.trim()}\n) q`;
|
||||
}
|
||||
|
||||
/** Whether the rows of result tab `tab` can be counted. */
|
||||
export function countable(tab: ResultTab): boolean {
|
||||
return pageable(tab) && countQuery(tab.outcome.statement.text) !== null;
|
||||
}
|
||||
|
||||
/** Counts the rows of result tab `tabId`'s whole query (the pager's "of 501+"). */
|
||||
export async function countResult(tabId: number): Promise<number | null> {
|
||||
const tab = useDatabase.getState().results.find((r) => r.id === tabId);
|
||||
if (!tab || tab.busy || !countable(tab)) return null;
|
||||
const text = countQuery(tab.outcome.statement.text)!;
|
||||
patchResult(tabId, { busy: true });
|
||||
try {
|
||||
const [outcome] = await run(tab.sourceId, [{ from: 0, to: text.length, text }], nextRequestId(), 1);
|
||||
const total = Number(outcome?.result?.rows[0]?.[0]);
|
||||
const known = outcome?.error || !Number.isFinite(total) ? null : total;
|
||||
patchResult(tabId, { busy: false, ...(known === null ? {} : { total: known }) });
|
||||
return known;
|
||||
} catch (e) {
|
||||
patchResult(tabId, { busy: false });
|
||||
if (!isCancelled(e)) log("error", message(e));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Statements that only read (they leave a manual transaction clean). */
|
||||
const READS = /^\s*(select|show|pragma|explain|describe|desc|values|table)\b/i;
|
||||
|
||||
|
||||
@@ -61,6 +61,23 @@ describe("the Spring endpoint provider", () => {
|
||||
expect(scanSources).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("matches sources by path segments, whatever separator the host used", async () => {
|
||||
scanSources.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [
|
||||
{ ...source(), path: "D:\\work\\demo\\src\\DeepLinkController.java" },
|
||||
{ ...source("other"), path: "D:/work/demo2/DeepLinkController.java" },
|
||||
],
|
||||
});
|
||||
expect(await routeIndex("D:/work/demo")).toMatchObject([
|
||||
{
|
||||
methodName: "searchMenus",
|
||||
path: "D:/work/demo/src/DeepLinkController.java",
|
||||
rel: "src/DeepLinkController.java",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["created", "modified", "removed"] as const)("invalidates on %s mapping sources", async (kind) => {
|
||||
scanSources.mockResolvedValue({ status: "ok", data: [source()] });
|
||||
dispose = registerSpringEndpointProvider();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Spring request-path index, cached per project until a mapping source changes. */
|
||||
import { call, commands, onIdeEvent } from "@/platform/ipc";
|
||||
import { ipcPath, isSameOrInside } from "@/platform/paths";
|
||||
import { registerEndpointProvider, type EndpointEntry } from "@/workbench/contributions";
|
||||
import { springControllers } from "./endpoints";
|
||||
|
||||
@@ -40,8 +41,10 @@ export function routeIndex(root: string | null): Promise<EndpointEntry[]> {
|
||||
async function scan(root: string): Promise<EndpointEntry[]> {
|
||||
const sources = await call(commands.springRouteSources());
|
||||
const routes: EndpointEntry[] = [];
|
||||
const base = ipcPath(root).replace(/\/$/, "");
|
||||
for (const source of sources) {
|
||||
if (!source.path.startsWith(root)) continue;
|
||||
const path = ipcPath(source.path);
|
||||
if (!isSameOrInside(path, base)) continue;
|
||||
for (const controller of springControllers(source.text)) {
|
||||
for (const endpoint of controller.endpoints) {
|
||||
const at = lineCol(source.text, endpoint.offset);
|
||||
@@ -53,8 +56,8 @@ async function scan(root: string): Promise<EndpointEntry[]> {
|
||||
url,
|
||||
controller: controller.name,
|
||||
methodName: endpoint.methodName,
|
||||
path: source.path,
|
||||
rel: source.path.slice(root.length + 1),
|
||||
path,
|
||||
rel: path.slice(base.length + 1),
|
||||
line: at.line,
|
||||
col: at.col,
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { defaultKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { bracketMatching, indentOnInput, syntaxHighlighting } from "@codemirror/language";
|
||||
import { openideaTheme } from "@/editor/core/cm/theme";
|
||||
import { openideaHighlightStyle } from "@/editor/core/cm/highlight";
|
||||
import { selectionMarks } from "@/editor/core/cm/selectionMarks";
|
||||
import { findLanguageDescription, loadSupport } from "@/editor/core/cm/languages";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type { NotebookSession } from "./session";
|
||||
@@ -43,6 +44,7 @@ export function CellEditor({
|
||||
syntaxHighlighting(openideaHighlightStyle),
|
||||
lineNumbers(),
|
||||
drawSelection(),
|
||||
selectionMarks,
|
||||
highlightActiveLine(),
|
||||
highlightSpecialChars(),
|
||||
bracketMatching(),
|
||||
|
||||
@@ -53,7 +53,8 @@ export const UI_KEYS: Record<string, string[]> = {
|
||||
"--bg-checkbox": ["Checkbox.Background.Default"],
|
||||
"--border-checkbox": ["Checkbox.Border.Default"],
|
||||
"--bg-tab-selected": ["EditorTabs.underlinedTabBackground", "EditorTabs.selectedBackground"],
|
||||
"--border-tab-selected": ["EditorTabs.underlineColor"],
|
||||
// Islands paints the selected tab's border with `underlinedBorderColor`; the classic tabs underline with `underlineColor`.
|
||||
"--border-tab-selected": ["EditorTabs.underlinedBorderColor", "EditorTabs.underlineColor"],
|
||||
"--bg-tab-hover": ["EditorTabs.hoverBackground"],
|
||||
"--border-editor-tabs": ["EditorTabs.borderColor"],
|
||||
"--bg-toolbar-hover": ["ActionButton.hoverBackground"],
|
||||
@@ -66,6 +67,7 @@ export const UI_KEYS: Record<string, string[]> = {
|
||||
"--bg-counter": ["Counter.background"],
|
||||
"--fg-counter": ["Counter.foreground"],
|
||||
"--bg-search-match": ["SearchMatch.startBackground", "SearchMatch.endBackground"],
|
||||
"--fg-completion-match": ["CompletionPopup.matchForeground"],
|
||||
"--bg-toggle-on": ["ToggleButton.onBackground"],
|
||||
"--fg-toggle-on": ["ToggleButton.onForeground"],
|
||||
"--bg-toggle-off": ["ToggleButton.offBackground"],
|
||||
@@ -89,7 +91,8 @@ export const ICLS_COLORS: Record<string, string[]> = {
|
||||
SELECTION_BACKGROUND: ["--bg-editor-selection"],
|
||||
LINE_NUMBERS_COLOR: ["--fg-line-number"],
|
||||
LINE_NUMBER_ON_CARET_ROW_COLOR: ["--fg-line-number-current"],
|
||||
GUTTER_BACKGROUND: ["--bg-editor-gutter"],
|
||||
// The new UI paints the gutter with this, else with the editor's background; `GUTTER_BACKGROUND` is the classic UI's.
|
||||
EDITOR_GUTTER_BACKGROUND: ["--bg-editor-gutter"],
|
||||
INDENT_GUIDE: ["--border-indent-guide"],
|
||||
SELECTED_INDENT_GUIDE: ["--border-indent-guide-active"],
|
||||
RIGHT_MARGIN_COLOR: ["--border-right-margin"],
|
||||
@@ -244,19 +247,33 @@ export function convertIcls(xml: string): { name: string; dark: boolean | null;
|
||||
if (key && value !== undefined && ICLS_COLORS[key]) set(ICLS_COLORS[key], iclsColor(value));
|
||||
}
|
||||
|
||||
const attributes = /<attributes>([\s\S]*?)<\/attributes>/.exec(xml)?.[1] ?? "";
|
||||
for (const m of attributes.matchAll(/<option name="([^"]+)"[^>]*>\s*<value>([\s\S]*?)<\/value>\s*<\/option>/g)) {
|
||||
const map = ICLS_ATTRIBUTES[m[1]!];
|
||||
if (!map) continue;
|
||||
const body = /<attributes>([\s\S]*?)<\/attributes>/.exec(xml)?.[1] ?? "";
|
||||
const attributes = new Map<string, Map<string, string>>();
|
||||
const empty = new Set<string>();
|
||||
for (const m of body.matchAll(
|
||||
/<option name="([^"]+)"[^>]*>\s*(?:<value\s*\/>|<value>([\s\S]*?)<\/value>)\s*<\/option>/g,
|
||||
)) {
|
||||
if (m[2] === undefined) empty.add(m[1]!);
|
||||
const parts = new Map<string, string>();
|
||||
for (const o of m[2]!.matchAll(/<option\b[^>]*\/>/g)) {
|
||||
for (const o of (m[2] ?? "").matchAll(/<option\b[^>]*\/>/g)) {
|
||||
const k = attr(o[0], "name");
|
||||
const v = attr(o[0], "value");
|
||||
if (k && v !== undefined) parts.set(k, v);
|
||||
}
|
||||
attributes.set(m[1]!, parts);
|
||||
}
|
||||
const textForeground = iclsColor(attributes.get("TEXT")?.get("FOREGROUND") ?? "");
|
||||
for (const [key, parts] of attributes) {
|
||||
const map = ICLS_ATTRIBUTES[key];
|
||||
if (!map) continue;
|
||||
// `<value />` defines the key without colours, so the parent scheme's don't apply: it shows as plain text.
|
||||
if (empty.has(key)) {
|
||||
for (const [part, token] of map) if (part === "FOREGROUND") set([token], textForeground);
|
||||
continue;
|
||||
}
|
||||
for (const [part, token] of map) set([token], iclsColor(parts.get(part) ?? ""));
|
||||
// FONT_TYPE: 1 bold, 2 italic, 3 bold italic.
|
||||
if (m[1] === "DEFAULT_LINE_COMMENT" && parts.has("FONT_TYPE")) {
|
||||
if (key === "DEFAULT_LINE_COMMENT" && parts.has("FONT_TYPE")) {
|
||||
tokens["--syn-comment-style"] = Number(parts.get("FONT_TYPE")) & 2 ? "italic" : "normal";
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user