diff --git a/Cargo.lock b/Cargo.lock index cf9903a94..3d5f3a357 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "ide-agent" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dunce", "encoding_rs", @@ -4061,7 +4061,7 @@ dependencies = [ [[package]] name = "ide-analysis" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ast-grep-core", "ide-core", @@ -4075,7 +4075,7 @@ dependencies = [ [[package]] name = "ide-build" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dirs 6.0.0", "dunce", @@ -4090,7 +4090,7 @@ dependencies = [ [[package]] name = "ide-clipboard" -version = "0.1.32" +version = "0.1.33" dependencies = [ "objc2 0.6.5", "objc2-app-kit 0.3.2", @@ -4099,7 +4099,7 @@ dependencies = [ [[package]] name = "ide-core" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dunce", "encoding_rs", @@ -4115,7 +4115,7 @@ dependencies = [ [[package]] name = "ide-coverage" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "ide-run", @@ -4129,7 +4129,7 @@ dependencies = [ [[package]] name = "ide-dap" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "ide-jsonrpc", @@ -4147,7 +4147,7 @@ dependencies = [ [[package]] name = "ide-db" -version = "0.1.32" +version = "0.1.33" dependencies = [ "bb8", "dirs 6.0.0", @@ -4169,7 +4169,7 @@ dependencies = [ [[package]] name = "ide-docker" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "libc", @@ -4183,7 +4183,7 @@ dependencies = [ [[package]] name = "ide-fonts" -version = "0.1.32" +version = "0.1.33" dependencies = [ "objc2-core-foundation", "objc2-core-text", @@ -4195,7 +4195,7 @@ dependencies = [ [[package]] name = "ide-github" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "ide-vcs", @@ -4209,7 +4209,7 @@ dependencies = [ [[package]] name = "ide-host" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dirs 6.0.0", "dunce", @@ -4255,7 +4255,7 @@ dependencies = [ [[package]] name = "ide-http" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "parking_lot", @@ -4273,7 +4273,7 @@ dependencies = [ [[package]] name = "ide-index" -version = "0.1.32" +version = "0.1.33" dependencies = [ "blake3", "criterion", @@ -4295,7 +4295,7 @@ dependencies = [ [[package]] name = "ide-jsonrpc" -version = "0.1.32" +version = "0.1.33" dependencies = [ "encoding_rs", "futures", @@ -4311,7 +4311,7 @@ dependencies = [ [[package]] name = "ide-language" -version = "0.1.32" +version = "0.1.33" dependencies = [ "blake3", "criterion", @@ -4366,7 +4366,7 @@ dependencies = [ [[package]] name = "ide-lsp" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dunce", "futures", @@ -4375,6 +4375,7 @@ dependencies = [ "ide-jsonrpc", "ide-language", "ide-text", + "ide-vfs", "ignore", "lsp-types", "parking_lot", @@ -4390,7 +4391,7 @@ dependencies = [ [[package]] name = "ide-notebook" -version = "0.1.32" +version = "0.1.33" dependencies = [ "futures", "ide-core", @@ -4413,7 +4414,7 @@ dependencies = [ [[package]] name = "ide-plugin" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "ide-language", @@ -4431,7 +4432,7 @@ dependencies = [ [[package]] name = "ide-profiler" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "ide-run", @@ -4444,7 +4445,7 @@ dependencies = [ [[package]] name = "ide-remote" -version = "0.1.32" +version = "0.1.33" dependencies = [ "globset", "ide-core", @@ -4461,7 +4462,7 @@ dependencies = [ [[package]] name = "ide-run" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dunce", "encoding_rs", @@ -4484,7 +4485,7 @@ dependencies = [ [[package]] name = "ide-sdk" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dunce", "ide-core", @@ -4503,7 +4504,7 @@ dependencies = [ [[package]] name = "ide-search" -version = "0.1.32" +version = "0.1.33" dependencies = [ "criterion", "globset", @@ -4523,7 +4524,7 @@ dependencies = [ [[package]] name = "ide-spell" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "parking_lot", @@ -4536,7 +4537,7 @@ dependencies = [ [[package]] name = "ide-ssh" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dirs 6.0.0", "flate2", @@ -4555,7 +4556,7 @@ dependencies = [ [[package]] name = "ide-syntax" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "ide-language", @@ -4570,7 +4571,7 @@ dependencies = [ [[package]] name = "ide-terminal" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "libc", @@ -4583,7 +4584,7 @@ dependencies = [ [[package]] name = "ide-text" -version = "0.1.32" +version = "0.1.33" dependencies = [ "criterion", "ide-core", @@ -4595,7 +4596,7 @@ dependencies = [ [[package]] name = "ide-tools" -version = "0.1.32" +version = "0.1.33" dependencies = [ "flate2", "hex", @@ -4616,7 +4617,7 @@ dependencies = [ [[package]] name = "ide-vcs" -version = "0.1.32" +version = "0.1.33" dependencies = [ "dunce", "encoding_rs", @@ -4635,7 +4636,7 @@ dependencies = [ [[package]] name = "ide-vfs" -version = "0.1.32" +version = "0.1.33" dependencies = [ "chardetng", "dunce", @@ -4657,7 +4658,7 @@ dependencies = [ [[package]] name = "ide-web" -version = "0.1.32" +version = "0.1.33" dependencies = [ "ide-core", "open", @@ -4673,7 +4674,7 @@ dependencies = [ [[package]] name = "ide-workspace" -version = "0.1.32" +version = "0.1.33" dependencies = [ "blake3", "dunce", @@ -5808,6 +5809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", + "block2 0.6.2", "dispatch2", "objc2 0.6.5", ] @@ -6143,7 +6145,7 @@ dependencies = [ [[package]] name = "openidea" -version = "0.1.32" +version = "0.1.33" dependencies = [ "blake3", "block2 0.6.2", @@ -6185,6 +6187,7 @@ dependencies = [ "ide-workspace", "objc2 0.6.5", "objc2-app-kit 0.3.2", + "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-web-kit 0.3.2", "parking_lot", diff --git a/Cargo.toml b/Cargo.toml index f8b7ed866..9da1d526f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "src-tauri"] [workspace.package] -version = "0.1.32" +version = "0.1.33" edition = "2024" rust-version = "1.96" license = "Apache-2.0" diff --git a/README.md b/README.md index 774889602..59b4f7c02 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,6 @@  ·  Benchmark  ·  - Build -  ·  Discord

@@ -47,24 +45,6 @@ IDEA analyses Python more deeply, so "ready" doesn't mean the same work on both sides. Both are measured from the outside, the same way: see [`perf/compare`](perf/compare) to run it on your machine and projects. -## Build - -```sh -pnpm install -pnpm tauri dev -``` - -Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/). - -Native notebook execution tests are opt-in and need two distinct Python environments -with `ipykernel` installed: - -```sh -OPENIDEA_TEST_PYTHON=/first/env/bin/python \ -OPENIDEA_TEST_PYTHON_SECOND=/second/env/bin/python \ -cargo test -p ide-notebook --test kernel_runtime -- --ignored --test-threads=1 -``` - ## License [Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md). diff --git a/crates/ide-build/src/compile/fingerprint.rs b/crates/ide-build/src/compile/fingerprint.rs new file mode 100644 index 000000000..8274003d6 --- /dev/null +++ b/crates/ide-build/src/compile/fingerprint.rs @@ -0,0 +1,321 @@ +//! Metadata fingerprints for successful Maven launch builds. +use super::*; +use std::fs; +use std::hash::{Hash, Hasher}; +use std::time::SystemTime; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LaunchFingerprint { + version: u32, + parameters: String, + files: BTreeMap>, +} + +fn scan( + path: &Path, + files: &mut BTreeMap>, + ancestors: &mut HashSet, + cancel: &CancellationToken, +) -> IdeResult<()> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let meta = match fs::metadata(path) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + files.insert(path.to_path_buf(), None); + return Ok(()); + } + Err(e) => return Err(e.into()), + }; + if meta.is_dir() { + let canonical = dunce::canonicalize(path)?; + if !ancestors.insert(canonical.clone()) { + return Err(IdeError::internal("cyclic launch input directory")); + } + // Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions. + files.insert(path.to_path_buf(), None); + for entry in fs::read_dir(path)? { + scan(&entry?.path(), files, ancestors, cancel)?; + } + ancestors.remove(&canonical); + } else if meta.is_file() { + files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?))); + } else { + return Err(IdeError::internal("unsupported launch input file")); + } + Ok(()) +} + +/// `None` means an input cannot be resolved safely: build normally without reuse. +/// All filesystem work belongs on the caller's blocking thread. +pub fn launch_fingerprint( + root: &Path, + model: &BuildModel, + module: &Path, + test: bool, + local_repo: Option, + cancel: &CancellationToken, +) -> IdeResult> { + let Some(m) = &model.maven else { return Ok(None) }; + let projects = maven_launch_projects(root, m, module, test); + if projects.is_empty() { + return Ok(None); + } + let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) }; + let plan = launch_plan(root, model, module, test)?; + let mut environment: BTreeMap = std::env::vars() + .map(|(key, value)| (key.to_ascii_uppercase(), value)) + .filter(|(key, _)| { + key.starts_with("MAVEN_") + || key.starts_with("JAVA_") + || key.starts_with("JDK_") + || matches!( + key.as_str(), + "PATH" | "HOME" | "LANG" | "LC_ALL" | "_JAVA_OPTIONS" | "M2_HOME" | "MAVEN_HOME" + ) + }) + .collect(); + // Explicit step values override the inherited environment just as they do in Command. + for step in &plan.steps { + environment.extend(step.env.iter().map(|(key, value)| (key.to_ascii_uppercase(), value.clone()))); + for dir in step.cwd.ancestors() { + inputs.extend([dir.join(".mvn"), dir.join("mvnw"), dir.join("mvnw.cmd")]); + if dir == root { + break; + } + } + let program = process::program_path(&step.program, &step.env); + inputs.push(program.clone()); + // Maven installed via a symlink still reads conf from its distribution. + if let Ok(program) = dunce::canonicalize(program) + && let Some(home) = program.parent().and_then(Path::parent) + { + for conf in ["conf", "libexec/conf"] { + inputs.extend([home.join(conf).join("settings.xml"), home.join(conf).join("toolchains.xml")]); + } + } + if !environment.contains_key("JAVA_HOME") { + let java = process::program_path(if cfg!(windows) { "java.exe" } else { "java" }, &step.env); + inputs.push(java.clone()); + if let Ok(java) = dunce::canonicalize(java) + && let Some(home) = java.parent().and_then(Path::parent) + { + inputs.push(home.join("release")); + } + } + for (i, arg) in step.args.iter().enumerate() { + if matches!(arg.as_str(), "-s" | "--settings" | "-gs" | "--global-settings" | "-t" | "--toolchains") + && let Some(file) = step.args.get(i + 1) + { + inputs.push(step.cwd.join(file)); + } + for prefix in ["--settings=", "--global-settings=", "--toolchains="] { + if let Some(file) = arg.strip_prefix(prefix) { + inputs.push(step.cwd.join(file)); + } + } + } + } + if let Some(home) = dirs::home_dir() { + inputs.extend([home.join(".m2/settings.xml"), home.join(".m2/toolchains.xml")]); + } + for key in ["MAVEN_HOME", "M2_HOME"] { + if let Some(home) = environment.get(key) { + inputs.extend([Path::new(home).join("conf/settings.xml"), Path::new(home).join("conf/toolchains.xml")]); + } + } + if let Some(home) = environment.get("JAVA_HOME") { + inputs.extend([ + Path::new(home).join("release"), + Path::new(home).join("lib/modules"), + Path::new(home).join("lib/tools.jar"), + Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }), + ]); + } + let steps: Vec<_> = + plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect(); + // Profiles and plugins can read arbitrary environment variables. Keep a signature + // of the inherited environment without persisting unrelated credentials. + let inherited: BTreeMap<_, _> = std::env::vars_os().collect(); + let mut inherited_hash = std::hash::DefaultHasher::new(); + inherited.hash(&mut inherited_hash); + let parameters = + serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects)) + .map_err(|e| IdeError::internal(e.to_string()))?; + let mut files = BTreeMap::new(); + for path in inputs { + scan(&path, &mut files, &mut HashSet::new(), cancel)?; + } + // Store project inputs relative to the project, including each file's relative path. + let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect(); + Ok(Some(LaunchFingerprint { version: 1, parameters, files })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(root: &Path, rel: &str, value: &str) { + let path = root.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, value).unwrap(); + } + + fn fixture(root: &Path) -> BuildModel { + write( + root, + "pom.xml", + r#"demoparent1 + pomlibapp"#, + ); + write( + root, + "lib/pom.xml", + r#"demoparent1 + libcodeconf"#, + ); + write( + root, + "app/pom.xml", + r#"demoparent1 + appdemolib1"#, + ); + write(root, "lib/src/main/kotlin/Channel.kt", "value"); + write(root, "app/src/main/resources/app.properties", "key=value"); + write(root, "lib/conf/custom.properties", "custom=value"); + write(root, "lib/code/Custom.java", "class Custom {}"); + BuildModel { maven: Some(maven::load(root)), ..Default::default() } + } + + fn fingerprint(root: &Path, model: &BuildModel) -> Option { + launch_fingerprint(root, model, &root.join("app"), false, None, &CancellationToken::new()).unwrap() + } + + #[test] + fn unchanged_inputs_match_but_source_resource_pom_add_delete_and_mtime_invalidate() { + for rel in [ + "lib/src/main/kotlin/Channel.kt", + "app/src/main/resources/app.properties", + "lib/conf/custom.properties", + "lib/code/Custom.java", + "app/pom.xml", + "lib/pom.xml", + "pom.xml", + ".mvn/maven.config", + "lib/src/main/groovy/New.groovy", + "app/src/test/scala/Test.scala", + ] { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let before = fingerprint(root, &model); + assert!(before.is_some()); + assert_eq!(before, fingerprint(root, &model), "unchanged {rel}"); + let serialized = serde_json::to_vec(&before).unwrap(); + assert_eq!(before, serde_json::from_slice(&serialized).unwrap()); + let value = if rel.ends_with("pom.xml") { + format!("{}", fs::read_to_string(root.join(rel)).unwrap()) + } else { + "changed input".to_owned() + }; + write(root, rel, &value); + let changed = fingerprint(root, &model); + assert_ne!(before, changed, "changed/added {rel}"); + let file = fs::OpenOptions::new().write(true).open(root.join(rel)).unwrap(); + file.set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42)).unwrap(); + let touched = fingerprint(root, &model); + assert_ne!(changed, touched, "mtime-only {rel}"); + fs::remove_file(root.join(rel)).unwrap(); + assert_ne!(touched, fingerprint(root, &model), "deleted {rel}"); + } + } + + #[test] + fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let mut model = fixture(root); + let before = fingerprint(root, &model); + write(root, "lib/target/classes/Channel.class", "JDT stub"); + write(root, "app/target/classes/Main.class", "JDT stub"); + assert_eq!(before, fingerprint(root, &model)); + let mut previous = before.clone(); + for option in ["-Pdev", "-Pprod", "-DskipTests", "-o"] { + model.maven.as_mut().unwrap().projects[0].options = vec![option.into()]; + let current = fingerprint(root, &model); + assert_ne!(previous, current, "{option}"); + previous = current; + } + model.maven.as_mut().unwrap().projects[0].options.clear(); + model.maven.as_mut().unwrap().projects[0] + .env + .insert("JAVA_HOME".into(), root.join("jdk").to_string_lossy().into_owned()); + let jdk = fingerprint(root, &model); + assert_ne!(before, jdk); + write(root, "jdk/release", "JAVA_VERSION=21"); + assert_ne!(jdk, fingerprint(root, &model)); + model.maven.as_mut().unwrap().projects[0].env.clear(); + let test = launch_fingerprint(root, &model, &root.join("app"), true, None, &CancellationToken::new()) + .unwrap() + .unwrap(); + assert_ne!(before, Some(test)); + write(root, "mvnw", "wrapper changed"); + assert_ne!(before, fingerprint(root, &model)); + } + + #[test] + fn unresolved_roots_and_cancel_never_claim_up_to_date() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + write( + root, + "lib/pom.xml", + "lib${unknown}/src", + ); + assert!( + launch_fingerprint(root, &model, &root.join("app"), false, None, &CancellationToken::new()) + .unwrap() + .is_none() + ); + let model = fixture(root); + let cancel = CancellationToken::new(); + cancel.cancel(); + assert!(launch_fingerprint(root, &model, &root.join("app"), false, None, &cancel).is_err()); + } + #[test] + fn external_parent_and_selected_settings_files_invalidate() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + let repo = dir.path().join("repository"); + write( + &repo, + "demo/parent/1/parent-1.pom", + "demoparent1", + ); + write( + &root, + "pom.xml", + "demoparent1app", + ); + let mut model = crate::load(&root, &root.join("cache"), Some(repo.clone())); + let settings = dir.path().join("custom-settings.xml"); + fs::write(&settings, "").unwrap(); + model.maven.as_mut().unwrap().projects[0].options = vec!["-s".into(), settings.to_string_lossy().into_owned()]; + let get = + || launch_fingerprint(&root, &model, &root, false, Some(repo.clone()), &CancellationToken::new()).unwrap(); + let before = get(); + assert!(before.is_some()); + write( + &repo, + "demo/parent/1/parent-1.pom", + "demoparent1", + ); + let parent = get(); + assert_ne!(before, parent); + fs::write(settings, "dev") + .unwrap(); + assert_ne!(parent, get()); + } +} diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index 857912ada..9cd0ea26f 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -3,8 +3,11 @@ //! background with cancellation and parses the compiler output into messages //! for the Build tool window, the Problems view and the editor. +mod fingerprint; mod parse; +pub use fingerprint::{LaunchFingerprint, launch_fingerprint}; + use crate::process::{self, Stream}; use crate::{BuildModel, maven}; use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity}; @@ -237,12 +240,7 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option< if !m.projects.iter().any(|p| p.dir == rel) { return Err(IdeError::not_found("no linked Maven project found for module")); } - let top = tops - .iter() - .filter(|t| t.dir.is_empty() || rel == t.dir || rel.starts_with(&format!("{}/", t.dir))) - .max_by_key(|t| t.dir.len()) - .copied() - .unwrap_or(tops[0]); + let top = maven_reactor(m, &rel).ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?; let top_dir = subdir(root, &top.dir); if module == top_dir { return Ok(vec![step(top, &[])]); @@ -611,6 +609,90 @@ pub fn has_non_java_sources(module: &Path) -> bool { walk(&module.join("src"), 12) } +fn reactor_projects<'a>(m: &'a maven::MavenModel, top: &'a maven::MavenProject) -> Vec<&'a maven::MavenProject> { + let mut projects = Vec::new(); + let mut pending = vec![top]; + let mut seen = HashSet::new(); + while let Some(p) = pending.pop() { + if !seen.insert(&p.pom) { + continue; + } + projects.push(p); + pending.extend(m.projects.iter().filter(|child| p.modules.contains(&child.pom))); + } + projects +} + +fn maven_reactor<'a>(m: &'a maven::MavenModel, module: &str) -> Option<&'a maven::MavenProject> { + m.projects.iter().filter(|p| !p.aggregated).find(|top| reactor_projects(m, top).iter().any(|p| p.dir == module)) +} + +/// Launch module first, followed by its upstream modules in the same Maven reactor. +/// External artifacts and unrelated linked reactors are never rebuilt for this launch. +fn maven_launch_projects<'a>( + root: &Path, + m: &'a maven::MavenModel, + module: &Path, + test: bool, +) -> Vec<&'a maven::MavenProject> { + let rel = rel_str(root, module); + let Some(top) = maven_reactor(m, &rel) else { return Vec::new() }; + let reactor = reactor_projects(m, top); + let Some(start) = reactor.iter().find(|p| p.dir == rel) else { return Vec::new() }; + let mut pending = vec![*start]; + let mut seen = HashSet::new(); + let mut projects = Vec::new(); + while let Some(p) = pending.pop() { + if !seen.insert(&p.pom) { + continue; + } + projects.push(p); + for dep in &p.dependencies { + if !dep.resolved() || (dep.scope == "test" && (!test || p.pom != start.pom)) { + continue; + } + if let Some(upstream) = reactor.iter().find(|u| { + u.group_id == dep.group_id + && u.artifact_id == dep.artifact_id + && dep.version.as_ref().is_none_or(|v| *v == u.version) + }) { + pending.push(*upstream); + } + } + } + projects +} + +/// Whether a launch requires the external JVM compiler. Maven includes upstream +/// reactor dependencies; adapter selection remains a separate, module-local decision. +pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Path, test: bool) -> bool { + if let Some(m) = &model.maven { + let projects = maven_launch_projects(root, m, module, test); + if !projects.is_empty() { + return projects.iter().any(|p| has_non_java_sources(&subdir(root, &p.dir))); + } + } + has_non_java_sources(module) +} + +fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult { + let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) { + // JDT may have left newer error stubs in Maven's output directories. + BuildKind::Rebuild + } else { + BuildKind::Build + }; + let mut plan = plan(root, model, kind, Some(module))?; + if plan.system == BuildSystem::Maven && !test { + for step in &mut plan.steps { + if let Some(goal) = step.args.last_mut() { + *goal = "compile".into(); + } + } + } + Ok(plan) +} + /// Compiles the module at `module` with its build system before a JVM launch and /// returns the class directories it wrote, to put ahead of the jdtls classpath. pub fn build_for_launch( @@ -620,7 +702,7 @@ pub fn build_for_launch( test: bool, cancel: &CancellationToken, ) -> IdeResult> { - let plan = plan(root, model, BuildKind::Build, Some(module))?; + let plan = launch_plan(root, model, module, test)?; let result = run(root, &plan, cancel, &|_| {})?; if !result.success { let errors: Vec = result @@ -635,6 +717,14 @@ pub fn build_for_launch( .collect(); return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n")))); } + if plan.system == BuildSystem::Maven + && let Some(m) = &model.maven + { + return Ok(maven_launch_projects(root, m, module, test) + .iter() + .flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module)) + .collect()); + } Ok(launch_class_dirs(plan.system, module, test)) } @@ -821,6 +911,142 @@ mod tests { } } + fn reactor_model() -> BuildModel { + let project = |dir: &str, deps: &[&str]| maven::MavenProject { + pom: format!("{dir}/pom.xml"), + dir: dir.into(), + group_id: "probe".into(), + artifact_id: dir.into(), + version: "1".into(), + aggregated: true, + dependencies: deps + .iter() + .map(|d| maven::MavenDependency { + group_id: "probe".into(), + artifact_id: (*d).into(), + version: Some("1".into()), + scope: "compile".into(), + ..Default::default() + }) + .collect(), + ..Default::default() + }; + let mut projects = vec![ + project("app", &["middle"]), + project("middle", &["lib"]), + project("lib", &["app"]), + project("unrelated", &[]), + ]; + projects.insert( + 0, + maven::MavenProject { + pom: "pom.xml".into(), + artifact_id: "reactor".into(), + command: "mvnw".into(), + modules: projects.iter().map(|p| p.pom.clone()).collect(), + options: vec!["-s".into(), "settings.xml".into()], + env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]), + ..Default::default() + }, + ); + BuildModel { maven: Some(maven::MavenModel { projects, ..Default::default() }), ..Default::default() } + } + + #[test] + fn mixed_launch_detection_follows_only_reactor_dependencies() { + for extension in ["kt", "groovy", "scala"] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut model = reactor_model(); + write(root, "app/src/main/java/Application.java", "class Application {}"); + write(root, &format!("unrelated/src/main/java/Other.{extension}"), ""); + assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false)); + write(root, &format!("lib/src/main/java/Channel.{extension}"), ""); + assert!(launch_has_non_java_sources(root, &model, &root.join("app"), false)); + assert_eq!( + maven_launch_projects(root, model.maven.as_ref().unwrap(), &root.join("app"), false).len(), + 3, + "cycles terminate" + ); + let m = model.maven.as_mut().unwrap(); + m.projects[2].dependencies[0].scope = "test".into(); + assert!( + !launch_has_non_java_sources(root, &model, &root.join("app"), true), + "upstream test dependencies are not transitive" + ); + let m = model.maven.as_mut().unwrap(); + m.projects[1].dependencies[0].scope = "test".into(); + m.projects[2].dependencies[0].scope = "compile".into(); + assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false)); + assert!(launch_has_non_java_sources(root, &model, &root.join("app"), true)); + model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("external".into()); + assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), true)); + model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("1".into()); + model.maven.as_mut().unwrap().projects[3].aggregated = false; + model.maven.as_mut().unwrap().projects[0].modules.retain(|p| p != "lib/pom.xml"); + assert!( + !launch_has_non_java_sources(root, &model, &root.join("app"), true), + "another linked reactor is external" + ); + } + } + + #[test] + fn maven_launch_steps_use_reactor_settings_and_launch_scope() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "app/pom.xml", ""); + let model = reactor_model(); + for (test, goal) in [(false, "compile"), (true, "test-compile")] { + let plan = launch_plan(root, &model, &root.join("app"), test).unwrap(); + let step = &plan.steps[0]; + assert_eq!(step.cwd, root); + assert_eq!(step.program, "mvnw"); + assert_eq!(step.env["JAVA_HOME"], "/jdk"); + assert_eq!( + step.args, + ["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal] + ); + } + } + + #[cfg(unix)] + #[test] + fn external_launch_returns_upstream_outputs_and_blocks_failure_or_cancellation() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "app/pom.xml", ""); + write( + root, + "mvnw", + "#!/bin/sh\nmkdir -p app/target/classes middle/target/classes lib/target/classes app/target/test-classes\n", + ); + let wrapper = root.join("mvnw"); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut model = reactor_model(); + model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned(); + let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap(); + assert_eq!( + dirs, + ["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"] + .map(|p| root.join(p)) + ); + write( + root, + "mvnw", + "#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n", + ); + let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err(); + assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message); + let cancel = CancellationToken::new(); + cancel.cancel(); + assert_eq!( + build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind, + ide_core::error::ErrorKind::Cancelled + ); + } + #[test] fn non_java_launch_class_dirs() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-build/src/kotlin_bridge.rs b/crates/ide-build/src/kotlin_bridge.rs new file mode 100644 index 000000000..150170efc --- /dev/null +++ b/crates/ide-build/src/kotlin_bridge.rs @@ -0,0 +1,475 @@ +//! Private Maven builds for JDT's Kotlin binary libraries. Never use launch outputs. + +use crate::BuildModel; +use crate::compile::{self, BuildEvent, BuildKind, BuildMessage}; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Module { + key: String, + project_uri: String, + output: PathBuf, + test_output: Option, + dependencies: Vec, + has_kotlin: bool, + kotlin_goals: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +struct KotlinGoal { + execution: String, + status: String, +} + +#[derive(Clone, Debug, Deserialize)] +struct Dependency { + key: String, + scope: String, + #[serde(rename = "type")] + kind: Option, + classifier: Option, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct Library { + pub path: PathBuf, + pub test: bool, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProjectLibraries { + pub project_uri: String, + pub libraries: Vec, +} + +pub struct Outcome { + pub projects: Vec, + pub messages: Vec, + pub success: bool, +} + +/// Inspect sources, excluding generated/build/VCS trees and never following symlinks. +pub fn has_kotlin(root: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(root) else { return false }; + entries.flatten().any(|entry| { + let Ok(kind) = entry.file_type() else { return false }; + if kind.is_file() { + entry.path().extension().is_some_and(|e| e == "kt") + } else { + kind.is_dir() + && !matches!( + entry.file_name().to_str(), + Some("target" | "build" | "out" | "bin" | "node_modules" | ".git" | ".openidea") + ) + && has_kotlin(&entry.path()) + } + }) +} + +/// Run the project's configured wrapper/settings/JDK and lifecycle, with an output +/// redirector loaded before Maven calculates its lifecycle execution plans. +pub fn build( + root: &Path, + model: &BuildModel, + bundle: &Path, + generation: &Path, + cancel: &CancellationToken, +) -> IdeResult { + std::fs::create_dir_all(generation)?; + let mut plan = compile::plan(root, model, BuildKind::Build, None)?; + if plan.system != compile::BuildSystem::Maven { + return Err(IdeError::invalid("Kotlin bridge requires a Maven build root")); + } + let mut manifests = Vec::new(); + for (index, step) in plan.steps.iter_mut().enumerate() { + step.args.push("--fail-at-end".into()); + let directory = generation.join(format!("reactor-{index}")); + std::fs::create_dir_all(&directory)?; + manifests.push(directory.join("modules.json")); + let mut extensions = vec![bundle.to_path_buf()]; + step.args.retain(|arg| { + if let Some(value) = arg.strip_prefix("-Dmaven.ext.class.path=") { + extensions.extend(std::env::split_paths(value)); + false + } else { + true + } + }); + let extension_path = std::env::join_paths(extensions).map_err(|e| IdeError::invalid(e.to_string()))?; + step.args.push(format!("-Dmaven.ext.class.path={}", extension_path.to_string_lossy())); + step.args.push(format!("-Dopenidea.kotlin.staging={}", directory.join("staging").display())); + } + let mut messages = Vec::new(); + // Independent linked reactors must still run after another reactor's javac fails. + for step in &plan.steps { + let mut reactor = plan.clone(); + reactor.steps = vec![step.clone()]; + let result = compile::run(root, &reactor, cancel, &|event| { + if let BuildEvent::Output { text, .. } = event { + tracing::debug!("kotlin bridge: {}", text.trim_end()); + } + })?; + messages.extend(result.messages); + } + // javac diagnostics belong to JDT, including when a Kotlin target was skipped. + messages.retain(|message| { + message.path.as_ref().is_some_and(|path| PathBuf::from(path.clone()).extension().is_some_and(|e| e == "kt")) + }); + let mut modules = Vec::new(); + for manifest in manifests { + let parsed: Vec = serde_json::from_slice(&std::fs::read(manifest)?) + .map_err(|e| IdeError::internal(format!("Kotlin module manifest: {e}")))?; + modules.extend(parsed); + } + messages.extend(kotlin_failures(&modules)); + if messages.iter().any(|message| message.severity == ide_core::Severity::Error) { + return Ok(Outcome { projects: Vec::new(), messages, success: false }); + } + let pending = generation.join("libraries.pending"); + let published = generation.join("libraries"); + std::fs::create_dir_all(&pending)?; + let mut libraries = BTreeMap::new(); + for (index, module) in modules.iter().enumerate() { + let mut outputs = ModuleLibraries::default(); + for (source, target, name) in [ + (Some(&module.output), &mut outputs.main, format!("main-{index}")), + (module.test_output.as_ref(), &mut outputs.test, format!("test-{index}")), + ] { + let Some(source) = source else { continue }; + if !source.starts_with(generation) { + return Err(IdeError::internal("Kotlin build output escaped staging")); + } + if copy_kotlin(source, &pending.join(&name), cancel)? { + *target = Some(published.join(name)); + } + } + libraries.insert(module.key.clone(), outputs); + } + publish(&pending, &published, cancel)?; + Ok(Outcome { projects: consumers(&modules, &libraries), messages, success: true }) +} + +fn kotlin_failures(modules: &[Module]) -> Vec { + let mut messages = Vec::new(); + for module in modules { + if !module.has_kotlin { + continue; + } + if module.kotlin_goals.is_empty() { + messages.push(kotlin_failure(module, "compile target was not executed")); + } + for goal in &module.kotlin_goals { + if goal.status != "success" { + messages.push(kotlin_failure(module, &format!("{} {}", goal.execution, goal.status))); + } + } + } + messages +} + +fn kotlin_failure(module: &Module, reason: &str) -> BuildMessage { + BuildMessage { + severity: ide_core::Severity::Error, + text: format!("Kotlin compilation for {}: {reason}", module.key), + path: None, + range: None, + code: None, + } +} + +fn publish(pending: &Path, published: &Path, cancel: &CancellationToken) -> IdeResult<()> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + std::fs::rename(pending, published)?; + Ok(()) +} + +#[derive(Default)] +struct ModuleLibraries { + main: Option, + test: Option, +} + +fn consumers(modules: &[Module], libraries: &BTreeMap) -> Vec { + modules + .iter() + .map(|consumer| { + let mut visible = Vec::::new(); + let mut add = |path: &Option, test: bool| { + if let Some(path) = path { + if let Some(previous) = visible.iter_mut().find(|entry| &entry.path == path) { + previous.test &= test; + } else { + visible.push(Library { path: path.clone(), test }); + } + } + }; + if let Some(own) = libraries.get(&consumer.key) { + add(&own.test, true); + add(&own.main, false); + } + // Maven's resolved artifacts already apply exclusions, mediation and optional + // propagation. Preserve their classpath order, after the module's own output. + for dependency in &consumer.dependencies { + let Some(output) = libraries.get(&dependency.key) else { continue }; + let test = match dependency.scope.as_str() { + "compile" | "provided" | "system" | "" => false, + "test" | "runtime" => true, + _ => continue, + }; + match (dependency.kind.as_deref(), dependency.classifier.as_deref()) { + (Some("test-jar"), None | Some("tests")) | (Some("jar"), Some("tests")) => add(&output.test, test), + (None | Some("jar"), None | Some("")) => add(&output.main, test), + _ => {} + } + } + ProjectLibraries { project_uri: consumer.project_uri.clone(), libraries: visible } + }) + .collect() +} + +fn copy_kotlin(source: &Path, destination: &Path, cancel: &CancellationToken) -> IdeResult { + let Ok(entries) = std::fs::read_dir(source) else { return Ok(false) }; + let mut found = false; + for entry in entries { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let entry = entry?; + let kind = entry.file_type()?; + let path = entry.path(); + let output = destination.join(entry.file_name()); + if kind.is_dir() { + found |= copy_kotlin(&path, &output, cancel)?; + } else if kind.is_file() { + let bytes = std::fs::read(&path)?; + let class = path.extension().is_some_and(|e| e == "class"); + let metadata = path.extension().is_some_and(|e| e == "kotlin_module"); + if metadata || (class && kotlin_class(&bytes).unwrap_or(false)) { + std::fs::create_dir_all(destination)?; + std::fs::write(output, bytes)?; + found |= class; + } + } + } + Ok(found) +} + +/// Read the actual class-level SourceFile attribute, rather than searching strings +/// (a Java constant containing "Something.kt" must never become a Kotlin library). +fn kotlin_class(bytes: &[u8]) -> Option { + struct Reader<'a> { + bytes: &'a [u8], + pos: usize, + } + impl<'a> Reader<'a> { + fn take(&mut self, count: usize) -> Option<&'a [u8]> { + let value = self.bytes.get(self.pos..self.pos.checked_add(count)?)?; + self.pos += count; + Some(value) + } + fn u2(&mut self) -> Option { + Some(u16::from_be_bytes(self.take(2)?.try_into().ok()?) as usize) + } + fn u4(&mut self) -> Option { + Some(u32::from_be_bytes(self.take(4)?.try_into().ok()?) as usize) + } + fn attributes(&mut self) -> Option<()> { + for _ in 0..self.u2()? { + self.u2()?; + let n = self.u4()?; + self.take(n)?; + } + Some(()) + } + } + let mut reader = Reader { bytes, pos: 0 }; + if reader.take(4)? != [0xca, 0xfe, 0xba, 0xbe] { + return None; + } + reader.take(4)?; + let count = reader.u2()?; + let mut utf8 = BTreeMap::new(); + let mut index = 1; + while index < count { + match reader.take(1)?[0] { + 1 => { + let n = reader.u2()?; + utf8.insert(index, reader.take(n)?); + } + 3 | 4 | 9..=12 | 17 | 18 => { + reader.take(4)?; + } + 5 | 6 => { + reader.take(8)?; + index += 1; + } + 7 | 8 | 16 | 19 | 20 => { + reader.take(2)?; + } + 15 => { + reader.take(3)?; + } + _ => return None, + } + index += 1; + } + reader.take(6)?; + let interfaces = reader.u2()?; + reader.take(interfaces.checked_mul(2)?)?; + for _ in 0..2 { + for _ in 0..reader.u2()? { + reader.take(6)?; + reader.attributes()?; + } + } + for _ in 0..reader.u2()? { + let name = reader.u2()?; + let length = reader.u4()?; + if utf8.get(&name).copied() == Some(b"SourceFile".as_slice()) && length == 2 { + let source = reader.u2()?; + return Some(utf8.get(&source)?.ends_with(b".kt")); + } + reader.take(length)?; + } + Some(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_kotlin_target_must_succeed_even_when_maven_fails_elsewhere() { + let mut modules: Vec = serde_json::from_str(r#"[ + {"key":"lib","projectUri":"lib","output":"lib","dependencies":[],"hasKotlin":true, + "kotlinGoals":[{"execution":"main:compile","status":"success"},{"execution":"test:test-compile","status":"success"}]}, + {"key":"java","projectUri":"java","output":"java","dependencies":[],"hasKotlin":false,"kotlinGoals":[]} + ]"#).unwrap(); + assert!(kotlin_failures(&modules).is_empty()); + for status in ["failed", "skipped"] { + modules[0].kotlin_goals[1].status = status.into(); + let failures = kotlin_failures(&modules); + assert_eq!(failures.len(), 1); + assert!(failures[0].text.contains(&format!("test:test-compile {status}"))); + } + modules[0].kotlin_goals.clear(); + assert_eq!(kotlin_failures(&modules).len(), 1); + modules[0].has_kotlin = false; + assert!(kotlin_failures(&modules).is_empty()); + assert!( + serde_json::from_str::>( + r#"[{"key":"old","projectUri":"old","output":"old","dependencies":[]}]"# + ) + .is_err() + ); + } + + #[test] + fn respects_mavens_resolved_closure_and_scopes() { + let json = r#"[ + {"key":"zapp","projectUri":"app","output":"app", "hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"mid","scope":"compile","optional":false},{"key":"lib","scope":"compile","optional":false}]}, + {"key":"mid","projectUri":"mid","output":"mid", "hasKotlin":false,"kotlinGoals":[],"dependencies":[ + {"key":"lib","scope":"compile","optional":false}, + {"key":"optional","scope":"compile","optional":true}, + {"key":"test","scope":"test","optional":false}, + {"key":"provided","scope":"provided","optional":false}]}, + {"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]} + ]"#; + let modules: Vec = serde_json::from_str(json).unwrap(); + let libraries = ["zapp", "lib", "optional", "test", "provided"] + .into_iter() + .map(|key| (key.to_owned(), ModuleLibraries { main: Some(PathBuf::from(key)), test: None })) + .collect(); + let result = consumers(&modules, &libraries); + assert_eq!( + result[0].libraries, + vec![Library { path: "zapp".into(), test: false }, Library { path: "lib".into(), test: false },] + ); + assert_eq!(result[1].libraries.len(), 4); + assert!(result[1].libraries.iter().find(|l| l.path == Path::new("test")).unwrap().test); + } + + #[test] + fn test_outputs_are_private_unless_maven_resolves_a_test_jar() { + let modules: Vec = serde_json::from_str(r#"[ + {"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]}, + {"key":"app","projectUri":"app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"compile","type":"jar","classifier":null}]}, + {"key":"test-app","projectUri":"test-app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"test","type":"test-jar","classifier":"tests"}]} + ]"#).unwrap(); + let libraries = + BTreeMap::from([("lib".into(), ModuleLibraries { main: Some("main".into()), test: Some("test".into()) })]); + let result = consumers(&modules, &libraries); + assert_eq!( + result[0].libraries, + vec![Library { path: "test".into(), test: true }, Library { path: "main".into(), test: false }] + ); + assert_eq!(result[1].libraries, vec![Library { path: "main".into(), test: false }]); + assert_eq!(result[2].libraries, vec![Library { path: "test".into(), test: true }]); + } + + #[test] + fn publishes_complete_directories_and_cancellation_keeps_them_private() { + let directory = tempfile::tempdir().unwrap(); + let pending = directory.path().join("pending"); + let final_path = directory.path().join("published"); + std::fs::create_dir(&pending).unwrap(); + std::fs::write(pending.join("Channel.class"), "complete").unwrap(); + let cancelled = CancellationToken::new(); + cancelled.cancel(); + assert!(publish(&pending, &final_path, &cancelled).is_err()); + assert!(!final_path.exists()); + assert!(pending.exists()); + publish(&pending, &final_path, &CancellationToken::new()).unwrap(); + assert!(!pending.exists()); + assert_eq!(std::fs::read(final_path.join("Channel.class")).unwrap(), b"complete"); + } + + #[test] + fn never_runs_another_build_system_with_maven_output_properties() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("Cargo.toml"), "").unwrap(); + let error = build( + directory.path(), + &BuildModel::default(), + Path::new("unused.jar"), + &directory.path().join("private"), + &CancellationToken::new(), + ) + .err() + .unwrap(); + assert_eq!(error.message, "Kotlin bridge requires a Maven build root"); + } + + #[test] + fn does_not_find_deleted_sources_in_build_outputs() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("target/generated")).unwrap(); + std::fs::write(dir.path().join("target/generated/Old.kt"), "").unwrap(); + assert!(!has_kotlin(dir.path())); + std::fs::write(dir.path().join("New.kt"), "").unwrap(); + assert!(has_kotlin(dir.path())); + } + + #[test] + fn parses_sourcefile_and_rejects_truncated_classes() { + let mut class = vec![0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52, 0, 3, 1, 0, 10]; + class.extend_from_slice(b"SourceFile"); + class.extend_from_slice(&[1, 0, 10]); + class.extend_from_slice(b"Channel.kt"); + class.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 2, 0, 2]); + assert_eq!(kotlin_class(&class), Some(true)); + for end in 0..class.len() { + assert!(kotlin_class(&class[..end]).is_none()); + } + let offset = 26; + class[offset..offset + 10].copy_from_slice(b"Other.java"); + assert_eq!(kotlin_class(&class), Some(false)); + } +} diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs index 10dcd9b1c..96358419f 100644 --- a/crates/ide-build/src/lib.rs +++ b/crates/ide-build/src/lib.rs @@ -8,6 +8,7 @@ pub mod application_update; pub mod compile; pub mod gradle; +pub mod kotlin_bridge; pub mod maven; pub mod pom_edit; mod process; diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index 2c0e3f8fb..7f2561003 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -601,6 +601,72 @@ impl Resolver { } } +/// Files and source/resource trees consumed by a launch build. Unresolved custom +/// roots disable reuse rather than silently omitting an input. +pub(crate) fn launch_inputs( + root: &Path, + projects: &[&MavenProject], + local_repo: Option, +) -> Option> { + fn roots(el: &Element, parent: &str, dir: &Path, out: &mut Vec) -> Option<()> { + let input = + matches!(el.name.as_str(), "sourceDirectory" | "testSourceDirectory" | "sourceDir" | "testSourceDir") + || (matches!(parent, "sources" | "sourceDirs" | "testSources" | "testSourceDirs") + && matches!(el.name.as_str(), "source" | "sourceDir" | "testSourceDir")) + || (matches!(parent, "resource" | "testResource" | "source" | "testSource") && el.name == "directory") + || (parent == "filters" && el.name == "filter"); + // Profile activation may depend on the presence of an otherwise unrelated file. + let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing"); + if input || activation { + // Other properties can be overridden by -D, profiles or settings. + // Without Maven's effective roots, conservatively disable reuse for them. + let value = el + .text + .trim() + .replace("${project.basedir}", &dir.to_string_lossy()) + .replace("${basedir}", &dir.to_string_lossy()); + if value.contains("${") { + return None; + } + if !value.is_empty() { + out.push(dir.join(value)); + } + } + for child in &el.children { + roots(child, &el.name, dir, out)?; + } + Some(()) + } + let mut resolver = Resolver::new(local_repo.or_else(default_local_repo)); + let mut out = Vec::new(); + for project in projects { + let dir = root.join(&project.dir); + // Include all conventional language/resource roots, including nonstandard files under src. + out.push(dir.join("src")); + let pom = root.join(&project.pom); + resolver.context(&pom, 0); + let mut path = pom; + let mut seen = HashSet::new(); + loop { + let canonical = dunce::canonicalize(&path).ok()?; + if !seen.insert(canonical) || seen.len() > MAX_PARENT_DEPTH { + return None; + } + out.push(path.clone()); + let text = std::fs::read_to_string(&path).ok()?; + roots(&xml::parse(&text)?, "", &dir, &mut out)?; + let raw = resolver.pom(&path)?; + let Some(parent) = raw.parent else { break }; + path = resolver.parent_path(path.parent()?, &parent)?; + } + } + // Also track imported BOMs read while resolving inheritance. + out.extend(resolver.poms.into_keys()); + out.sort(); + out.dedup(); + Some(out) +} + // ---------------------------------------------------------------- plugins /// `maven-clean-plugin` → `clean`, `spring-boot-maven-plugin` → `spring-boot`. diff --git a/crates/ide-build/src/process.rs b/crates/ide-build/src/process.rs index b598eb791..8177089f0 100644 --- a/crates/ide-build/src/process.rs +++ b/crates/ide-build/src/process.rs @@ -37,7 +37,7 @@ fn kill_tree(child: &mut Child) { /// `program` as spawnable: a bare name (`mvn`, `mvn.cmd`) resolves on the IDE's /// search path — the login shell's `PATH` plus version-manager shims — since the /// app's own `PATH` can be minimal and `Command` would not see `mvn` there. -fn program_path(program: &str, env: &BTreeMap) -> std::path::PathBuf { +pub(crate) fn program_path(program: &str, env: &BTreeMap) -> std::path::PathBuf { let path = std::path::Path::new(program); if !path.is_relative() || path.components().count() > 1 { return path.to_path_buf(); diff --git a/crates/ide-core/src/editor_settings.rs b/crates/ide-core/src/editor_settings.rs index a478558c6..4806bf2cd 100644 --- a/crates/ide-core/src/editor_settings.rs +++ b/crates/ide-core/src/editor_settings.rs @@ -371,6 +371,12 @@ pub struct TextAttributesOverride { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct LanguageCodeStyle { + /// Java formatter: `default` | `intellij` | `custom` (other languages ignore it). + #[serde(skip_serializing_if = "Option::is_none")] + pub formatter: Option, + /// Eclipse formatter XML; relative paths start at the project root. + #[serde(skip_serializing_if = "Option::is_none")] + pub formatter_path: Option, #[serde(skip_serializing_if = "Option::is_none")] pub use_tab: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/ide-core/src/error.rs b/crates/ide-core/src/error.rs index 7df81c9a5..f78cf37f7 100644 --- a/crates/ide-core/src/error.rs +++ b/crates/ide-core/src/error.rs @@ -12,6 +12,15 @@ pub type IdeResult = Result; pub struct IdeError { pub kind: ErrorKind, pub message: String, + /// Structured detail for errors the UI acts on; `null` for plain errors. + #[specta(optional)] + pub data: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ErrorData { + Tool { tool: String, missing: bool }, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -24,6 +33,7 @@ pub enum ErrorKind { /// Document version mismatch; the UI must resync the full text. OutOfSync, Cancelled, + TimedOut, /// A required external tool (language server, git, debugger) is unavailable. Unavailable, Io, @@ -33,7 +43,21 @@ pub enum ErrorKind { impl IdeError { pub fn new(kind: ErrorKind, message: impl Into) -> Self { - Self { kind, message: message.into() } + Self { kind, message: message.into(), data: None } + } + pub fn with_tool(mut self, tool: impl Into) -> Self { + if self.data.is_none() { + self.data = Some(ErrorData::Tool { tool: tool.into(), missing: false }); + } + self + } + pub fn missing_tool(tool: impl Into) -> Self { + let tool = tool.into(); + Self { + kind: ErrorKind::NotFound, + message: format!("{tool} is not installed"), + data: Some(ErrorData::Tool { tool, missing: true }), + } } pub fn not_found(m: impl Into) -> Self { Self::new(ErrorKind::NotFound, m) @@ -65,3 +89,30 @@ impl From for IdeError { Self::new(kind, e.to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_errors_round_trip_without_changing_diagnostics() { + let message = "[error] file.css: Unexpected token\n 1 | }\n"; + let error = IdeError::invalid(message).with_tool("prettier"); + let decoded: IdeError = serde_json::from_str(&serde_json::to_string(&error).unwrap()).unwrap(); + assert_eq!(decoded.message, message); + assert_eq!(decoded.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false })); + let missing = IdeError::missing_tool("prettier").with_tool("prettier"); + assert_eq!(missing.kind, ErrorKind::NotFound); + assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true })); + } + + #[test] + fn legacy_errors_need_no_data() { + let error: IdeError = serde_json::from_str(r#"{"kind":"unavailable","message":"no server"}"#).unwrap(); + assert!(error.data.is_none()); + assert_eq!( + serde_json::to_value(error).unwrap(), + serde_json::json!({"kind":"unavailable","message":"no server","data":null}) + ); + } +} diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index f9390f0da..7f360b1f1 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -276,6 +276,8 @@ pub struct AppearanceSettings { /// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's). pub use_custom_font: bool, pub ui_font_family: String, + /// Font tried when `ui_font_family` lacks a glyph; empty = none. + pub ui_font_fallback: String, pub contrast_scrollbars: bool, /// Adjust colours for red-green vision deficiency. pub red_green_vision: bool, @@ -326,6 +328,7 @@ impl Default for AppearanceSettings { zoom: 100, use_custom_font: false, ui_font_family: "Inter".into(), + ui_font_fallback: String::new(), contrast_scrollbars: false, red_green_vision: false, different_tool_window_background: false, @@ -505,9 +508,50 @@ pub struct LanguagesSettings { pub rust: RustSettings, pub go: GoSettings, pub typescript: TypeScriptSettings, + pub prettier: PrettierSettings, pub java: JavaSettings, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct PrettierSettings { + /// Empty path: project node_modules, managed installation, then PATH. + pub path: String, + pub languages: Vec, + /// Prefer Prettier even when a language server offers formatting. + pub prefer: bool, + pub run_on_save: bool, +} + +impl Default for PrettierSettings { + fn default() -> Self { + Self { + path: String::new(), + languages: [ + "markdown", + "yaml", + "css", + "scss", + "less", + "html", + "graphql", + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "json", + "jsonc", + "vue", + ] + .into_iter() + .map(String::from) + .collect(), + prefer: false, + run_on_save: false, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct PythonSettings { @@ -721,6 +765,20 @@ impl Settings { mod tests { use super::*; + #[test] + fn ui_font_fallback_defaults_empty_and_round_trips() { + let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap(); + assert!(old.appearance.ui_font_fallback.is_empty()); + for family in ["", "Songti SC", "Maple Mono NF CN"] { + let mut settings = Settings::default(); + settings.appearance.ui_font_fallback = family.into(); + let json = serde_json::to_value(&settings).unwrap(); + assert_eq!(json["appearance"]["uiFontFallback"], family); + let restored: Settings = serde_json::from_value(json).unwrap(); + assert_eq!(restored.appearance.ui_font_fallback, family); + } + } + #[test] fn tool_window_badges_default_on_and_round_trip() { let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap(); diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index 6bcd57b65..e1fb0ecae 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -905,6 +905,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc, language: &str, settings: &PrettierSettings, lsp: bool) -> bool { + config.is_some_and(|c| c.tool == "prettier") + && settings.languages.iter().any(|l| l == language) + && (settings.prefer || !lsp) +} + +pub async fn format( + host: &Host, + window: &str, + doc: DocId, + range: Option, + options: FormatOptions, +) -> IdeResult> { + let project = host.project(window)?; + let snap = project.documents.snapshot(doc)?; + let settings = project.settings.effective(); + let prettier = &settings.languages.prettier; + let language = project.languages.get(&snap.language); + let config = language.as_ref().and_then(|l| l.formatter.as_ref()); + let candidate = + config.is_some_and(|c| c.tool == "prettier") && prettier.languages.iter().any(|l| l == snap.language.as_str()); + let lsp = if candidate && !prettier.prefer { project.lsp.can_format(doc).await? } else { false }; + if !external_selected(config, snap.language.as_str(), prettier, lsp) { + if options.on_save && !settings.editor.format_on_save { + return Ok(Vec::new()); + } + return project.lsp.format(doc, range, options).await; + } + if options.on_save && !prettier.run_on_save { + return Ok(Vec::new()); + } + host.require_trust(window)?; + if snap.read_only { + return Err(IdeError::invalid("The file is read-only")); + } + let config = config.unwrap().clone(); + let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path); + let tools = Tools::new(&host.data_dir); + let whole_selection = range.is_some() && !config.range; + let edits: IdeResult> = tokio::task::spawn_blocking(move || { + let text = ide_text::Document::new(&snap.text); + let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end))); + let mut command = tools.formatter_command(&config.tool, &snap.path, custom.as_deref())?; + command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?); + command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets)); + let formatted = formatter::run(command, &snap.text, &Default::default())?; + Ok(text + .diff(&formatted) + .changes + .into_iter() + .map(|c| TextEdit { + range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)), + new_text: c.insert, + }) + .collect()) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))?; + if edits.is_ok() && whole_selection { + project.sink.emit(IdeEvent::Notify { + level: NotifyLevel::Info, + title: "Prettier".into(), + body: Some( + "Prettier does not support selection formatting for this language; formatted the whole file".into(), + ), + }); + } + edits.map_err(|e| e.with_tool("prettier")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn language_enablement_and_preference_preserve_lsp_formatters() { + let languages = ide_language::LanguageRegistry::load(None).unwrap(); + let mut settings = PrettierSettings::default(); + for lang in languages.all() { + for lsp in [false, true] { + let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier"); + assert_eq!( + external_selected(lang.formatter.as_ref(), lang.id.as_str(), &settings, lsp), + configured && !lsp, + "{}", + lang.id + ); + } + } + let ts = languages.get(&ide_core::LanguageId::new("typescript")).unwrap(); + settings.prefer = true; + assert!(external_selected(ts.formatter.as_ref(), "typescript", &settings, true)); + settings.languages.clear(); + assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false)); + assert!(!external_selected(None, "rust", &settings, false)); + } +} diff --git a/crates/ide-host/src/kotlin_bridge.rs b/crates/ide-host/src/kotlin_bridge.rs new file mode 100644 index 000000000..c29be5a29 --- /dev/null +++ b/crates/ide-host/src/kotlin_bridge.rs @@ -0,0 +1,429 @@ +//! Saved Kotlin -> isolated Maven output -> marked JDT libraries. + +use ide_build::kotlin_bridge::{self, Outcome, ProjectLibraries}; +use ide_core::event::NotifyLevel; +use ide_core::{CancellationToken, Diagnostic, IdeEvent, Pos, Range, Severity, SharedSink}; +use ide_lsp::LspManager; +use ide_workspace::SettingsStore; +use ide_workspace::documents::DocumentObserver; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::time::Duration; +use tokio::sync::Notify; + +const DELAY: Duration = Duration::from_millis(750); +const SOURCE: &str = "kotlin-bridge"; +const COMMAND: &str = "openidea.kotlin.replace"; + +#[derive(Default)] +struct Revision { + generation: u64, + cancel: CancellationToken, +} + +impl Revision { + fn change(&mut self) { + self.cancel.cancel(); + self.generation += 1; + self.cancel = CancellationToken::new(); + } + fn current(&self, generation: u64) -> bool { + self.generation == generation && !self.cancel.is_cancelled() + } +} + +#[derive(Clone, Default, Serialize, Deserialize)] +struct MavenOptions { + offline: bool, + profiles: Vec, +} + +pub(crate) struct KotlinBridge { + root: PathBuf, + cache: PathBuf, + bundle: Option, + settings: Arc, + lsp: Arc, + sink: SharedSink, + trusted: Arc, + lifetime: CancellationToken, + revision: Mutex, + changed: Notify, + options: Mutex, + worker: Mutex>>, +} + +impl KotlinBridge { + pub(crate) fn new( + root: PathBuf, + cache: PathBuf, + data: &Path, + settings: Arc, + lsp: Arc, + sink: SharedSink, + trusted: Arc, + ) -> Arc { + let options = std::fs::read(cache.join("kotlin-bridge-options.json")) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default(); + Arc::new(Self { + root, + cache, + bundle: ide_sdk::java::kotlin_bridge_bundle(data), + settings, + lsp, + sink, + trusted, + lifetime: CancellationToken::new(), + revision: Mutex::new(Revision::default()), + changed: Notify::new(), + options: Mutex::new(options), + worker: Mutex::new(None), + }) + } + + pub(crate) fn start(self: &Arc, runtime: &tokio::runtime::Handle) { + let bridge = self.clone(); + *self.worker.lock() = Some(runtime.spawn(async move { bridge.run().await })); + self.schedule(); + } + + pub(crate) fn configure_maven(&self, offline: bool, profiles: Vec) { + let options = MavenOptions { offline, profiles }; + if let Ok(bytes) = serde_json::to_vec(&options) { + let _ = std::fs::create_dir_all(&self.cache); + let _ = std::fs::write(self.cache.join("kotlin-bridge-options.json"), bytes); + } + *self.options.lock() = options; + self.schedule(); + } + + pub(crate) async fn shutdown(&self) { + self.stop(); + let worker = self.worker.lock().take(); + if let Some(worker) = worker { + let _ = worker.await; + } + } + + pub(crate) fn schedule(&self) { + if self.lifetime.is_cancelled() { + return; + } + self.revision.lock().change(); + self.changed.notify_one(); + } + + pub(crate) fn files_changed<'a>(&self, mut paths: impl Iterator) { + let settings = self.settings.effective(); + if paths.any(|path| relevant_change(&self.root, path, &settings)) { + self.schedule(); + } + } + + pub(crate) fn stop(&self) { + self.lifetime.cancel(); + self.revision.lock().cancel.cancel(); + } + + async fn replace(&self, projects: &[ProjectLibraries], cancel: &CancellationToken) -> ide_core::IdeResult<()> { + let snapshot = serde_json::to_value(projects).map_err(|e| ide_core::IdeError::internal(e.to_string()))?; + let response = self.lsp.jdtls_command_cancellable(&self.root, COMMAND, vec![snapshot], cancel).await?; + if response.as_u64().is_none() { + return Err(ide_core::IdeError::unavailable( + "Kotlin bridge command did not acknowledge its library snapshot", + )); + } + Ok(()) + } + + async fn run(self: Arc) { + let mut diagnostics = BTreeSet::new(); + let mut last_failure = String::new(); + let mut initialized = false; + // Also retain directories from unacknowledged replacements: JDT may have applied them. + let mut published = Vec::new(); + loop { + tokio::select! { + _ = self.lifetime.cancelled() => return, + _ = self.changed.notified() => {}, + } + let (generation, cancel) = { + let state = self.revision.lock(); + (state.generation, state.cancel.clone()) + }; + if !self.trusted.load(Ordering::Acquire) { + continue; + } + if !debounce(&cancel, &self.lifetime).await { + continue; + } + let root = self.root.clone(); + let cache = self.cache.clone(); + let settings = self.settings.effective(); + let has_sources = tokio::task::spawn_blocking(move || { + let model = crate::ops::build::load(&root, &cache, &settings); + let enabled = + model.maven.as_ref().is_some_and(|m| !m.projects.is_empty()) && kotlin_bridge::has_kotlin(&root); + (model, enabled) + }) + .await; + let Ok((mut model, enabled)) = has_sources else { continue }; + if !self.revision.lock().current(generation) { + continue; + } + if !enabled { + if self.cache.join("kotlin-bridge").exists() + && let Err(error) = self.replace(&[], &cancel).await + { + if !cancel.is_cancelled() { + tracing::warn!("Kotlin library withdrawal: {error}"); + } + continue; + } + for old in published.drain(..) { + remove_directory(old).await; + } + self.clear_diagnostics(&mut diagnostics); + last_failure.clear(); + continue; + } + if let Some(maven) = &mut model.maven { + let options = self.options.lock(); + for module in &mut maven.projects { + if options.offline { + module.options.push("-o".into()); + } + if !options.profiles.is_empty() { + module.options.push(format!("-P{}", options.profiles.join(","))); + } + } + } + let Some(bundle) = self.bundle.clone() else { + self.failure("Kotlin bridge bundle is unavailable".into(), &mut diagnostics, &mut last_failure); + continue; + }; + // Clear entries persisted by an earlier incarnation only before our first build. + if !initialized { + if let Err(error) = self.replace(&[], &cancel).await { + if !cancel.is_cancelled() { + self.failure(error.message, &mut diagnostics, &mut last_failure); + } + continue; + } + initialized = true; + } + let directory = self.cache.join(format!("kotlin-bridge/{}", uuid::Uuid::new_v4())); + let staging = directory.clone(); + let root = self.root.clone(); + let token = cancel.clone(); + let result = + tokio::task::spawn_blocking(move || kotlin_bridge::build(&root, &model, &bundle, &staging, &token)) + .await; + if !self.revision.lock().current(generation) || self.lifetime.is_cancelled() { + remove_directory(directory).await; + continue; + } + match result { + Ok(Ok(Outcome { success: true, projects, .. })) => { + match self.replace(&projects, &cancel).await { + Ok(()) => { + for old in published.drain(..) { + remove_directory(old).await; + } + published.push(directory); + self.clear_diagnostics(&mut diagnostics); + last_failure.clear(); + } + Err(error) => { + // Keep both snapshots until an acknowledged replacement. + published.push(directory); + if !cancel.is_cancelled() { + self.failure(error.message, &mut diagnostics, &mut last_failure); + } + } + } + } + other => { + let text = match other { + Ok(Ok(outcome)) => outcome + .messages + .iter() + .filter(|m| m.severity == Severity::Error) + .map(|m| m.text.as_str()) + .collect::>() + .join("\n"), + Ok(Err(error)) => error.message, + Err(error) => error.to_string(), + }; + self.failure(text, &mut diagnostics, &mut last_failure); + remove_directory(directory).await; + } + } + } + } + + fn clear_diagnostics(&self, paths: &mut BTreeSet) { + for path in std::mem::take(paths) { + self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: SOURCE.into(), items: Vec::new() }); + } + } + + fn failure(&self, text: String, paths: &mut BTreeSet, previous: &mut String) { + let text = if text.is_empty() { "Kotlin compilation failed".to_owned() } else { text }; + let text = format!("{text}\nJava temporarily uses the last successfully compiled Kotlin API."); + self.clear_diagnostics(paths); + let path = self.root.join("pom.xml"); + paths.insert(path.clone()); + self.sink.emit(IdeEvent::Diagnostics { + path: path.into(), + source: SOURCE.into(), + items: vec![Diagnostic { + range: Range { start: Pos { line: 0, col: 0 }, end: Pos { line: 0, col: 0 } }, + severity: Severity::Error, + message: text.clone(), + source: Some(SOURCE.into()), + server: Some(SOURCE.into()), + code: None, + unnecessary: false, + deprecated: false, + }], + }); + if previous != &text { + self.sink.emit(IdeEvent::Notify { + level: NotifyLevel::Error, + title: "Kotlin editor compilation failed".into(), + body: Some(text.clone()), + }); + *previous = text; + } + } +} + +fn relevant_change(root: &Path, path: &Path, settings: &ide_core::settings::Settings) -> bool { + let input = path.extension().is_some_and(|e| e == "kt") + || path.file_name().is_some_and(|n| n == "pom.xml" || n == "mvnw" || n == "mvnw.cmd") + || path.components().any(|c| c.as_os_str() == ".mvn"); + let Ok(relative) = path.strip_prefix(root) else { + // Content roots outside the project only contribute Kotlin sources. + return path.extension().is_some_and(|e| e == "kt"); + }; + // Build outputs and generated sources (e.g. KSP's `.kt` under target/) never trigger, + // so a build cannot schedule itself. + if relative.components().any(|c| { + matches!( + c.as_os_str().to_str(), + Some("target" | "build" | "out" | "bin" | ".git" | "node_modules" | ".openidea") + ) + }) || ide_vfs::ExcludeMatcher::new(&settings.exclude_globs()).is_excluded(relative) + { + return false; + } + if input { + return true; + } + if path.extension().is_some() { + return false; + } + // Works for deleted directories too; the module POM remains on disk. + path.ancestors().take_while(|ancestor| ancestor.starts_with(root)).any(|ancestor| { + ancestor.file_name().is_some_and(|name| name == "src") + && ancestor.parent().is_some_and(|module| module.join("pom.xml").is_file()) + }) +} + +async fn debounce(cancel: &CancellationToken, lifetime: &CancellationToken) -> bool { + tokio::select! { + _ = lifetime.cancelled() => false, + _ = cancel.cancelled() => false, + _ = tokio::time::sleep(DELAY) => true, + } +} + +async fn remove_directory(path: PathBuf) { + let _ = tokio::task::spawn_blocking(move || std::fs::remove_dir_all(path)).await; +} + +impl DocumentObserver for KotlinBridge { + fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) { + self.files_changed(std::iter::once(&path.to_path_buf())); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_package_directory_does_not_trigger() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("pom.xml"), "").unwrap(); + assert!(!relevant_change(dir.path(), &dir.path().join("target/classes/probe"), &Default::default())); + let generated = dir.path().join("target/generated-sources/ksp/main/kotlin/Gen.kt"); + assert!(!relevant_change(dir.path(), &generated, &Default::default())); + assert!(relevant_change(dir.path(), &dir.path().join("src/main/kotlin/A.kt"), &Default::default())); + } + + #[test] + fn deleted_source_package_triggers_unless_excluded() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("module")).unwrap(); + std::fs::write(dir.path().join("module/pom.xml"), "").unwrap(); + let path = dir.path().join("module/src/main/kotlin/pkg"); + let mut settings = ide_core::settings::Settings::default(); + assert!(relevant_change(dir.path(), &path, &settings)); + settings.project_structure.excluded.push("module/src/main/kotlin".into()); + assert!(!relevant_change(dir.path(), &path, &settings)); + assert!(!relevant_change(dir.path(), &dir.path().join("module/src/build/pkg"), &Default::default())); + } + + #[test] + fn extensionless_root_file_does_not_trigger() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("pom.xml"), "").unwrap(); + assert!(!relevant_change(dir.path(), &dir.path().join("LICENSE"), &Default::default())); + } + + #[tokio::test(start_paused = true)] + async fn burst_saves_cancel_the_pending_debounce() { + let mut state = Revision::default(); + state.change(); + let token = state.cancel.clone(); + let lifetime = CancellationToken::new(); + let pending_lifetime = lifetime.clone(); + let waiting = tokio::spawn(async move { debounce(&token, &pending_lifetime).await }); + tokio::task::yield_now().await; + tokio::time::advance(DELAY - Duration::from_millis(1)).await; + assert!(!waiting.is_finished()); + for _ in 0..20 { + state.change(); + } + assert!(!waiting.await.unwrap()); + let current = state.generation; + assert!(debounce(&state.cancel, &lifetime).await); + assert!(state.current(current)); + lifetime.cancel(); + assert!(!debounce(&state.cancel, &lifetime).await); + } + + #[test] + fn superseded_and_cancelled_builds_cannot_publish() { + let mut state = Revision::default(); + state.change(); + let first = state.generation; + let token = state.cancel.clone(); + state.change(); + assert!(token.is_cancelled()); + assert!(!state.current(first)); + assert!(state.current(state.generation)); + state.cancel.cancel(); + assert!(!state.current(state.generation)); + } +} diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 4b6837bac..7acd168da 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -5,7 +5,9 @@ pub mod data_dirs; pub mod environment; +mod formatter; mod java_runtime; +mod kotlin_bridge; pub mod notebook; pub mod ops; mod project; @@ -165,9 +167,15 @@ impl Host { if !project.light { self.environment.project_closed(); } + if let Some(bridge) = &project.kotlin_bridge { + bridge.stop(); + } project.agents.shutdown(); project.notebooks.stop_admission(); - self.shutdown_notebooks_blocking(vec![project.notebooks.clone()]); + self.shutdown_project_workers_blocking( + vec![project.notebooks.clone()], + project.kotlin_bridge.iter().cloned().collect(), + ); let lsp = project.lsp.clone(); self.runtime.spawn(async move { lsp.shutdown_all().await }); } @@ -175,7 +183,14 @@ impl Host { /// Synchronous project/window destruction must finish owned kernel cleanup, /// not enqueue work which can be lost as the desktop runtime exits. - fn shutdown_notebooks_blocking(&self, notebooks: Vec>) { + fn shutdown_project_workers_blocking( + &self, + notebooks: Vec>, + bridges: Vec>, + ) { + for bridge in &bridges { + bridge.stop(); + } for service in ¬ebooks { service.stop_admission(); } @@ -183,6 +198,9 @@ impl Host { let wait = move || { let worker = std::thread::spawn(move || { runtime.block_on(async move { + for bridge in bridges { + bridge.shutdown().await; + } for service in notebooks { service.shutdown().await; } @@ -203,7 +221,11 @@ impl Host { /// Called before the desktop runtime exits, including update/restart exits. pub fn shutdown_notebooks_on_exit(&self) { - self.shutdown_notebooks_blocking(self.projects().iter().map(|project| project.notebooks.clone()).collect()); + let projects = self.projects(); + self.shutdown_project_workers_blocking( + projects.iter().map(|project| project.notebooks.clone()).collect(), + projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(), + ); } /// Stops what this host started: notebook kernels, language servers, agents, runs and terminals. @@ -214,12 +236,18 @@ impl Host { self.terminals.kill_all(); let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect(); for project in &projects { + if let Some(bridge) = &project.kotlin_bridge { + bridge.stop(); + } project.notebooks.stop_admission(); project.agents.shutdown(); } // Kernel cleanup owns its interrupt/termination/reaping deadlines. An // outer LSP timeout must never abort it before owned children are reaped. for project in &projects { + if let Some(bridge) = &project.kotlin_bridge { + bridge.shutdown().await; + } project.notebooks.shutdown().await; } let stop_language_servers = async { diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 650db06d4..ad3983302 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -97,6 +97,7 @@ pub async fn build_sync( host.require_trust(window)?; let project = host.project(window)?; let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective()); + let bridge_profiles = profiles.clone(); let token = host.begin_project_request(window, request); let result = tokio::task::spawn_blocking(move || { let current = load(&root, &cache, &settings); @@ -137,6 +138,9 @@ pub async fn build_sync( // jdtls re-imports on pom changes but misses artifacts added to ~/.m2 // meanwhile ("missing artifact" stays until Invalidate Caches); asking it // to update the project configurations re-resolves them (#436). + if let Some(bridge) = &project.kotlin_bridge { + bridge.configure_maven(offline, bridge_profiles); + } let lsp = project.lsp.clone(); let uris = maven.projects.iter().map(|p| ide_lsp::path_to_uri_string(&project.root.join(&p.pom))).collect(); tokio::spawn(async move { diff --git a/crates/ide-host/src/ops/fs.rs b/crates/ide-host/src/ops/fs.rs index 10681ca31..fb55f6f0f 100644 --- a/crates/ide-host/src/ops/fs.rs +++ b/crates/ide-host/src/ops/fs.rs @@ -1,8 +1,73 @@ +use super::Channel; use crate::Host; -use ide_core::IdeResult; +use ide_core::{IdeResult, RequestId}; use ide_vfs::FileEntry; use std::path::PathBuf; +pub async fn entry_kind(host: &Host, path: PathBuf) -> IdeResult { + host.blocking(move || { + let meta = std::fs::metadata(path)?; + Ok(if meta.is_dir() { ide_vfs::EntryKind::Dir } else { ide_vfs::EntryKind::File }) + }) + .await +} + +pub async fn compare_directories( + host: &Host, + window: &str, + left: PathBuf, + right: PathBuf, + request: RequestId, + on_progress: Channel, +) -> IdeResult> { + let project = host.project(window)?; + let cancel = host.begin_request(request); + let result = host + .blocking(move || { + let last = std::cell::Cell::new(std::time::Instant::now() - std::time::Duration::from_secs(1)); + ide_vfs::compare_directories(&left, &right, Some(&project.root), &project.exclude_globs(), &cancel, |p| { + // Bound IPC traffic even when hashing multi-gigabyte files. + if last.get().elapsed() >= std::time::Duration::from_millis(80) || p.total == Some(p.compared) { + let _ = on_progress.send(p); + last.set(std::time::Instant::now()); + } + }) + }) + .await; + host.end_request(request); + result +} + +pub async fn copy_merge( + host: &Host, + window: &str, + source_root: PathBuf, + destination_root: PathBuf, + path: PathBuf, + overwrite: bool, +) -> IdeResult<()> { + if path.as_os_str().is_empty() || path.components().any(|c| !matches!(c, std::path::Component::Normal(_))) { + return Err(ide_core::IdeError::invalid("expected a relative comparison entry path")); + } + let project = host.project(window)?; + host.blocking(move || { + let from = source_root.join(&path); + let to = destination_root.join(&path); + if overwrite { + project.history.record_deletion(&to, DELETE_SNAPSHOT_LIMIT); + } + ide_vfs::copy_merge( + &from, + &to, + overwrite, + Some(&project.root), + &project.exclude_globs(), + (&source_root, &destination_root), + ) + }) + .await +} + /// Files of a deleted folder snapshotted in Local History. const DELETE_SNAPSHOT_LIMIT: usize = 2000; diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index e08b04092..d1cc0a616 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -1,11 +1,13 @@ //! Java launch support: classpath / JDK resolution and the java-debug server (jdtls). +pub(crate) mod cache; + use crate::Host; use crate::rpc::Dispatcher; use super::build::load; use ide_build::compile; use ide_core::{CancellationToken, IdeError, IdeResult, RequestId}; -use ide_lsp::jdtls::JavaLaunchInfo; +use ide_lsp::jdtls::{JavaBuildStatus, JavaLaunchInfo}; use std::path::PathBuf; /// Cancellable with `cancel_request(request)`. @@ -39,20 +41,46 @@ async fn resolve_launch( let project = host.project(window)?; let module = project.root.join(module); let settings = project.settings.effective(); - // Let jdtls finish its build before the external compiler writes Kotlin/Scala classes. - let mut info = project.lsp.java_launch(&module, test, build, cancel).await?; + let (model, external, delegated) = { + let project = project.clone(); + let settings = settings.clone(); + let module = module.clone(); + tokio::task::spawn_blocking(move || { + project.java_launch_snapshots.lock().initialize(); + let model = load(&project.root, &project.build_cache_dir(), &settings); + let non_java = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test); + let delegated = build + && model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module)) + && (non_java || cache::has_record(&project.root, &project.build_cache_dir(), &module)); + let external = non_java || delegated; + (model, external, delegated) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))? + }; + let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None }; + let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?; let lsp_ms = started.elapsed().as_millis(); let data_dir = host.local_data_dir.clone(); let cancel = cancel.clone(); tokio::task::spawn_blocking(move || { - let built = if build && compile::has_non_java_sources(&module) { - compile::build_for_launch( + let _external_build = external_build; + let built = if delegated { + let outputs = project.java_launch_snapshots.lock().build( &project.root, - &load(&project.root, &project.build_cache_dir(), &settings), + &model, &module, test, + ide_sdk::apply::maven_local_repository(&project.root, &settings), &cancel, - )? + )?; + let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot); + info.build = Some(JavaBuildStatus::Succeeded); + info.errors.clear(); + info.error_count = 0; + copies + } else if external { + compile::build_for_launch(&project.root, &model, &module, test, &cancel)? } else { Vec::new() }; @@ -103,6 +131,26 @@ async fn resolve_launch( .map_err(|e| IdeError::internal(e.to_string()))? } +fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec { + let mut copies = Vec::new(); + for (index, source) in built.iter().enumerate() { + let dest = snapshot.join(index.to_string()); + let original = source.to_string_lossy(); + info.class_paths.retain(|p| p != original.as_ref()); + let mut modular = false; + for path in &mut info.module_paths { + if path == original.as_ref() { + *path = dest.to_string_lossy().into_owned(); + modular = true; + } + } + if !modular { + copies.push(dest); + } + } + copies +} + /// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand). pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult { host.require_trust(window)?; @@ -132,3 +180,40 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await }); d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn launch_snapshots_survive_jdt_overwrites_and_replace_module_outputs() { + let root = tempfile::tempdir().unwrap(); + let app = root.path().join("app/target/classes"); + let lib = root.path().join("lib/target/classes"); + for dir in [&app, &lib] { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join("Channel.class"), "Maven bytecode").unwrap(); + } + let mut info = JavaLaunchInfo { + class_paths: vec![app.to_string_lossy().into_owned(), "stdlib.jar".into()], + module_paths: vec![lib.to_string_lossy().into_owned()], + ..Default::default() + }; + let snapshot = tempfile::tempdir().unwrap(); + for (i, source) in [&app, &lib].iter().enumerate() { + ide_vfs::copy(source, &snapshot.path().join(i.to_string())).unwrap(); + } + let paths = use_snapshot(&mut info, &[app.clone(), lib.clone()], snapshot.path()); + assert_eq!(paths, [snapshot.path().join("0")]); + assert_eq!(info.class_paths, ["stdlib.jar"]); + assert_eq!(info.module_paths, [snapshot.path().join("1").to_string_lossy().into_owned()]); + std::fs::write(app.join("Channel.class"), "JDT error stub").unwrap(); + std::fs::remove_dir_all(lib).unwrap(); + for index in ["0", "1"] { + assert_eq!( + std::fs::read_to_string(snapshot.path().join(index).join("Channel.class")).unwrap(), + "Maven bytecode" + ); + } + } +} diff --git a/crates/ide-host/src/ops/java/cache.rs b/crates/ide-host/src/ops/java/cache.rs new file mode 100644 index 000000000..422ade156 --- /dev/null +++ b/crates/ide-host/src/ops/java/cache.rs @@ -0,0 +1,371 @@ +//! Immutable Maven outputs retained in the project cache, including across close/reopen. +use ide_build::compile::{self, LaunchFingerprint}; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct Outputs { + fingerprint: Option, + pub snapshot: PathBuf, + pub originals: Vec, +} + +impl Outputs { + fn reusable(&self, fingerprint: &Option, snapshots: &Path) -> bool { + fingerprint.is_some() + && self.fingerprint == *fingerprint + && self.snapshot.parent() == Some(snapshots) + && !self.originals.is_empty() + && (0..self.originals.len()).all(|i| self.snapshot.join(i.to_string()).is_dir()) + } +} + +fn record_path(root: &Path, cache: &Path, module: &Path, test: bool) -> Option { + Some(cache.join("java-launch/records").join(module.strip_prefix(root).ok()?).join(if test { + "test.json" + } else { + "main.json" + })) +} + +pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool { + [false, true].into_iter().any(|test| record_path(root, cache, module, test).is_some_and(|record| record.is_file())) +} + +/// Owned by Project: running JVMs may still load superseded outputs until it drops. +pub(crate) struct Session { + cache: PathBuf, + initialized: bool, + unreferenced: Vec, +} + +impl Session { + pub(crate) fn new(cache: PathBuf) -> Self { + Self { cache, initialized: false, unreferenced: Vec::new() } + } + + pub(super) fn initialize(&mut self) { + if !self.initialized { + self.initialized = true; + if let Err(error) = sweep(&self.cache) { + tracing::warn!(%error, "Cannot clean previous Java launch snapshots"); + } + } + } + + pub(super) fn build( + &mut self, + root: &Path, + model: &ide_build::BuildModel, + module: &Path, + test: bool, + local_repo: Option, + cancel: &CancellationToken, + ) -> IdeResult { + self.initialize(); + let cache = &self.cache; + let record = + record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?; + let snapshots = cache.join("java-launch/snapshots"); + let fingerprint = || match compile::launch_fingerprint(root, model, module, test, local_repo.clone(), cancel) { + Ok(value) => Ok(value), + Err(e) if cancel.is_cancelled() => Err(e), + Err(e) => { + tracing::debug!(error = %e, "Cannot fingerprint Maven launch; rebuilding"); + Ok(None) + } + }; + let before = fingerprint()?; + let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if let Some(outputs) = previous.as_ref() + && outputs.reusable(&before, &snapshots) + { + tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot"); + return Ok(previous.unwrap()); + } + // Retain a non-reusable record even on failure, so removing the final mixed + // source still delegates to Maven to clean its old classes on the next launch. + fs::create_dir_all(record.parent().unwrap())?; + fs::write(&record, b"null")?; + if let Some(previous) = previous { + self.unreferenced.push(previous.snapshot); + } + let originals = compile::build_for_launch(root, model, module, test, cancel)?; + fs::create_dir_all(&snapshots)?; + let snapshot = tempfile::Builder::new().prefix("launch-").tempdir_in(&snapshots)?.keep(); + // Retain every new snapshot even if copying or publishing its record fails. + self.unreferenced.push(snapshot.clone()); + for (i, source) in originals.iter().enumerate() { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + ide_vfs::copy(source, &snapshot.join(i.to_string()))?; + } + let after = fingerprint()?; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let mut outputs = Outputs { fingerprint: None, snapshot, originals }; + if before.is_some() && before == after { + outputs.fingerprint = before; + fs::create_dir_all(record.parent().unwrap())?; + let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?; + serde_json::to_writer(file.as_file_mut(), &outputs).map_err(|e| IdeError::internal(e.to_string()))?; + file.persist(&record).map_err(|e| IdeError::from(e.error))?; + self.unreferenced.retain(|path| path != &outputs.snapshot); + } + Ok(outputs) + } +} + +impl Drop for Session { + fn drop(&mut self) { + // Recheck all records: another module or launch mode may reference a path. + if let Ok(referenced) = referenced_snapshots(&self.cache) { + let snapshots = self.cache.join("java-launch/snapshots"); + for path in &self.unreferenced { + remove_unreferenced(path, &snapshots, &referenced); + } + } + } +} + +fn referenced_snapshots(cache: &Path) -> std::io::Result> { + let mut referenced = HashSet::new(); + let records = cache.join("java-launch/records"); + if !records.try_exists()? { + return Ok(referenced); + } + let mut pending = vec![records]; + while let Some(dir) = pending.pop() { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let kind = entry.file_type()?; + if kind.is_dir() { + pending.push(entry.path()); + } else if kind.is_file() && entry.path().extension().is_some_and(|ext| ext == "json") { + let outputs: Option = serde_json::from_slice(&fs::read(entry.path())?)?; + if let Some(outputs) = outputs + && let Ok(path) = fs::canonicalize(outputs.snapshot) + { + referenced.insert(path); + } + } + } + } + Ok(referenced) +} + +fn remove_unreferenced(path: &Path, snapshots: &Path, referenced: &HashSet) { + // Only real launch-* directories directly inside snapshots, never symlinks. + if path.parent() != Some(snapshots) + || !path.file_name().is_some_and(|name| name.to_string_lossy().starts_with("launch-")) + || !fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_dir()) + { + return; + } + if let (Ok(path), Ok(snapshots)) = (fs::canonicalize(path), fs::canonicalize(snapshots)) + && path.parent() == Some(snapshots.as_path()) + && !referenced.contains(&path) + && let Err(error) = fs::remove_dir_all(&path) + { + tracing::warn!(%error, path = %path.display(), "Cannot remove Java launch snapshot"); + } +} + +fn sweep(cache: &Path) -> std::io::Result<()> { + let referenced = referenced_snapshots(cache)?; + let snapshots = cache.join("java-launch/snapshots"); + if snapshots.try_exists()? { + for entry in fs::read_dir(&snapshots)? { + remove_unreferenced(&entry?.path(), &snapshots, &referenced); + } + } + Ok(()) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + fn fixture(root: &Path) -> ide_build::BuildModel { + fs::write( + root.join("pom.xml"), + r#"demoapp1"#, + ) + .unwrap(); + let script = root.join("mvnw"); + fs::write(&script, "#!/bin/sh\necho build >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap(); + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + fs::create_dir_all(root.join("src/main/kotlin")).unwrap(); + fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap(); + ide_build::load(root, &root.join("cache"), None) + } + + #[test] + fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let cache = root.join("cache"); + let cancel = CancellationToken::new(); + let mut session = Session::new(cache.clone()); + let mut build = || session.build(root, &model, root, false, None, &cancel); + let first = build().unwrap(); + let snapshot = first.snapshot.clone(); + drop(first); + // Dropping an Outputs value leaves the persisted snapshot reusable. + let second = build().unwrap(); + assert_eq!(snapshot, second.snapshot); + assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 1); + fs::write(root.join("target/classes/Main.class"), "JDT stub").unwrap(); + assert_eq!(snapshot, build().unwrap().snapshot); + assert_eq!(fs::read_to_string(snapshot.join("0/Main.class")).unwrap(), "first"); + fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap(); + let updated = build().unwrap(); + assert_ne!(snapshot, updated.snapshot); + assert_eq!(fs::read_to_string(updated.snapshot.join("0/Main.class")).unwrap(), "updated"); + assert!(snapshot.join("0/Main.class").is_file(), "previous JVMs may still load it"); + fs::remove_dir_all(updated.snapshot.join("0")).unwrap(); + let rebuilt = build().unwrap(); + assert_ne!(rebuilt.snapshot, updated.snapshot); + fs::write(root.join("src/main/kotlin/broken"), "broken").unwrap(); + assert!(build().is_err()); + assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null"); + fs::remove_file(root.join("src/main/kotlin/broken")).unwrap(); + assert_ne!(rebuilt.snapshot, build().unwrap().snapshot, "a failed build invalidates success"); + cancel.cancel(); + assert!(build().is_err()); + assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5); + } + #[test] + fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let cache = root.join("cache"); + let cancel = CancellationToken::new(); + let mut session = Session::new(cache.clone()); + let first = session.build(root, &model, root, false, None, &cancel).unwrap(); + fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap(); + let second = session.build(root, &model, root, false, None, &cancel).unwrap(); + assert!(first.snapshot.is_dir()); + assert!(second.snapshot.is_dir()); + // Another mode can keep a superseded snapshot alive. + let test_record = cache.join("java-launch/records/nested/test.json"); + fs::create_dir_all(test_record.parent().unwrap()).unwrap(); + fs::write(&test_record, serde_json::to_vec(&first).unwrap()).unwrap(); + drop(session); + assert!(first.snapshot.is_dir()); + assert!(second.snapshot.is_dir()); + let mut session = Session::new(cache.clone()); + let reused = session.build(root, &model, root, false, None, &cancel).unwrap(); + assert_eq!(reused.snapshot, second.snapshot); + fs::write(root.join("src/main/kotlin/Main.kt"), "third version").unwrap(); + let third = session.build(root, &model, root, false, None, &cancel).unwrap(); + assert!(second.snapshot.is_dir()); + drop(session); + assert!(!second.snapshot.exists()); + assert!(first.snapshot.is_dir()); + assert!(third.snapshot.is_dir()); + assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 3); + } + + #[test] + fn initial_sweep_only_removes_unreferenced_direct_snapshot_directories() { + let dir = tempfile::tempdir().unwrap(); + let cache = dir.path().join("cache"); + let snapshots = cache.join("java-launch/snapshots"); + let retained = snapshots.join("launch-retained"); + let orphan = snapshots.join("launch-orphan"); + let other = snapshots.join("other"); + let nested = other.join("launch-nested"); + let outside = dir.path().join("launch-outside"); + for path in [&retained, &orphan, &nested, &outside] { + fs::create_dir_all(path).unwrap(); + } + fs::write(outside.join("classes"), "keep").unwrap(); + std::os::unix::fs::symlink(&outside, snapshots.join("launch-link")).unwrap(); + fs::write(snapshots.join("launch-file"), "keep").unwrap(); + let record = cache.join("java-launch/records/deep/module/test.json"); + fs::create_dir_all(record.parent().unwrap()).unwrap(); + let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new() }; + fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap(); + let mut session = Session::new(cache); + session.initialize(); + assert!(!orphan.exists()); + assert!(retained.is_dir()); + assert!(nested.is_dir()); + assert!(outside.join("classes").is_file()); + assert!(snapshots.join("launch-link").is_symlink()); + assert!(snapshots.join("launch-file").is_file()); + // Initialization must not sweep snapshots still in use by this session. + fs::create_dir_all(&orphan).unwrap(); + session.initialize(); + assert!(orphan.is_dir()); + session.unreferenced.extend([outside.clone(), snapshots.join("../../launch-outside"), nested]); + drop(session); + assert!(outside.join("classes").is_file()); + assert!(other.join("launch-nested").is_dir()); + } + + #[test] + fn failed_record_publish_retains_snapshot_until_session_drop() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let script = root.join("mvnw"); + let mut text = fs::read_to_string(&script).unwrap(); + text.push_str("rm cache/java-launch/records/main.json\nmkdir cache/java-launch/records/main.json\n"); + fs::write(script, text).unwrap(); + let cache = root.join("cache"); + let mut session = Session::new(cache.clone()); + assert!(session.build(root, &model, root, false, None, &CancellationToken::new()).is_err()); + assert_eq!(session.unreferenced.len(), 1); + let snapshot = session.unreferenced[0].clone(); + assert!(snapshot.join("0/Main.class").is_file()); + drop(session); + assert!(!snapshot.exists()); + } + + #[test] + fn edits_during_build_do_not_publish_a_success_record() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let script = root.join("mvnw"); + let mut text = fs::read_to_string(&script).unwrap(); + text.push_str("echo edited-during-build >> src/main/kotlin/Main.kt\n"); + fs::write(script, text).unwrap(); + let cache = root.join("cache"); + let mut session = Session::new(cache.clone()); + let outputs = session.build(root, &model, root, false, None, &CancellationToken::new()).unwrap(); + assert!(outputs.fingerprint.is_none()); + assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null"); + assert!(outputs.snapshot.join("0/Main.class").is_file()); + drop(session); + assert!(!outputs.snapshot.exists()); + } + #[test] + fn deleting_the_last_mixed_source_keeps_maven_delegation_after_failure() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let cache = root.join("cache"); + let cancel = CancellationToken::new(); + let mut session = Session::new(cache.clone()); + let first = session.build(root, &model, root, false, None, &cancel).unwrap(); + fs::remove_file(root.join("src/main/kotlin/Main.kt")).unwrap(); + assert!(!compile::launch_has_non_java_sources(root, &model, root, false)); + assert!(has_record(root, &cache, root)); + assert!(session.build(root, &model, root, false, None, &cancel).is_err()); + assert!(has_record(root, &cache, root)); + fs::write(root.join("src/main/kotlin/Main.kt"), "restored").unwrap(); + let restored = session.build(root, &model, root, false, None, &cancel).unwrap(); + assert_ne!(first.snapshot, restored.snapshot); + } +} diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index 953f878a7..b57f54efd 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -83,12 +83,18 @@ pub async fn lsp_implementations( result } -/// Contents of a virtual document (library class, decompiled code) that a -/// language server returned a non-`file:` URI for. Cached per project. +/// Contents of a read-only language-server document, including JDT library +/// classes and disk class files. Cached per project. pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult { host.project(window)?.lsp.virtual_document(&uri).await } +/// Opens compiled classes through JDT, with project source taking priority on disk. +pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::PathBuf) -> IdeResult { + let archive = ide_vfs::split_archive_path(&path); + host.project(window)?.lsp.java_class_file(&path, archive).await +} + /// References of the symbol at `pos`. `project_wide` also searches project files /// outside the language server's analysis scope; bulk scans over many symbols /// leave it off. Cancel with `cancel_request(request)`. @@ -208,7 +214,7 @@ pub async fn lsp_format( range: Option, options: FormatOptions, ) -> IdeResult> { - host.project(window)?.lsp.format(doc, range, options).await + crate::formatter::format(host, window, doc, range, options).await } pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult> { @@ -263,7 +269,13 @@ pub fn lsp_resend_diagnostics(host: &Host, window: &str) -> IdeResult<()> { } pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> { - host.project(window)?.lsp.restart(server).await + let project = host.project(window)?; + let java = project.lsp.servers().iter().any(|s| s.id == server && s.name == "jdtls"); + project.lsp.restart(server).await?; + if java && let Some(bridge) = &project.kotlin_bridge { + bridge.schedule(); + } + Ok(()) } /// Applies a (previewed) workspace edit, e.g. after rename confirmation. @@ -367,6 +379,10 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("lsp_implementations", |c| async move { lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await }); + d.add( + "lsp_resolve_class_file", + |c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await }, + ); d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await }); d.add("lsp_references", |c| async move { lsp_references( diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs index ec1d44026..e6632c1d1 100644 --- a/crates/ide-host/src/ops/project.rs +++ b/crates/ide-host/src/ops/project.rs @@ -172,11 +172,16 @@ pub fn set_settings( host.apply_proxy(); } // A new interpreter / toolchain / server executable restarts the language servers it affects. - if ["languages", "tools", "languageServers", "projectStructure"] + let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle."); + let toolchain = ["languages", "tools", "languageServers", "projectStructure"] .iter() - .any(|k| key == *k || key.starts_with(&format!("{k}."))) - { + .any(|k| key == *k || key.starts_with(&format!("{k}."))); + // Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it. + if code_style || toolchain { for project in affected() { + if toolchain && let Some(bridge) = &project.kotlin_bridge { + bridge.schedule(); + } let lsp = project.lsp.clone(); host.runtime.spawn(async move { lsp.reconfigure().await }); } diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs index f2a84d73b..edeae318c 100644 --- a/crates/ide-host/src/ops/tools.rs +++ b/crates/ide-host/src/ops/tools.rs @@ -100,11 +100,11 @@ pub fn jdk_download_vendors(_host: &Host, _window: &str) -> Vec { /// Java versions `vendor` publishes; cancellable with `cancel_request(request)`. pub async fn jdk_download_releases( host: &Host, - window: &str, + _window: &str, vendor: String, request: RequestId, ) -> IdeResult { - let tools = tools(host, window)?; + let tools = Tools::new(&host.data_dir); let cancel = host.begin_request(request); let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err); host.end_request(request); @@ -123,14 +123,14 @@ pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult, request: RequestId, on_event: Channel, ) -> IdeResult { - let tools = tools(host, window)?; + let tools = Tools::new(&host.data_dir); let location = location.filter(|p| !p.as_os_str().is_empty()); let cancel = host.begin_request(request); let result = tokio::task::spawn_blocking(move || { diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 9af77d203..7ac81aac1 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -42,6 +42,8 @@ pub struct Project { pub detected_roots: Arc>>, /// Per-project cache directory (build tool sync results…). pub cache_dir: PathBuf, + /// Superseded launch snapshots retained until this project session ends. + pub(crate) java_launch_snapshots: Mutex, /// Database tool window: data sources and their connections. pub db: Arc, /// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir. @@ -50,6 +52,7 @@ pub struct Project { pub agents: Arc, /// Native notebooks and owned kernels, scoped to this project incarnation. pub notebooks: Arc, + pub(crate) kotlin_bridge: Option>, pub(crate) watcher: Watcher, /// Watchers of the Project Structure content roots outside the project folder. pub(crate) content_watchers: Mutex>, @@ -126,6 +129,8 @@ impl Host { let root = root.clone(); let data_dir = self.local_data_dir.clone(); let config_dir = self.config_dir.clone(); + let sink = sink.clone(); + let formatter_problem = std::sync::Mutex::new(None); lsp.set_config_hook(Box::new(move |cfg, server_root| { let member = ide_workspace::workspace::Workspace::load(&root).ok().flatten().and_then(|w| { w.projects.into_iter().filter(|p| server_root.starts_with(p)).max_by_key(|p| p.components().count()) @@ -139,6 +144,27 @@ impl Host { ide_sdk::apply::configure_server(cfg, root, server_root, &effective); ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks()); if cfg.name == "jdtls" { + let problem = ide_lsp::jdtls::configure_formatter( + cfg, + effective.editor.code_style.languages.get("java"), + root, + &data_dir, + effective.language_servers.get("jdtls"), + ) + .err() + .map(|e| e.to_string()); + let mut previous = formatter_problem.lock().unwrap(); + if problem != *previous { + if let Some(body) = &problem { + sink.emit(IdeEvent::Notify { + level: ide_core::event::NotifyLevel::Warning, + title: "Java formatter configuration unavailable".into(), + body: Some(body.clone()), + }); + } + *previous = problem; + } + drop(previous); ide_lsp::jdtls::configure_source_paths( cfg, ide_workspace::roots::unmanaged_java_source_paths( @@ -150,6 +176,7 @@ impl Host { let bundles: Vec = ide_tools::managed_bundle(&data_dir, "java-debug") .into_iter() .chain(ide_sdk::java::usages_bundle(&data_dir)) + .chain(ide_sdk::java::kotlin_bridge_bundle(&data_dir)) .collect(); let min_java = ide_sdk::java::jdtls_min_java(cfg); let jdks = ide_sdk::java::jdtls_jdks(min_java, &data_dir); @@ -226,11 +253,22 @@ impl Host { trusted: trusted.clone(), })); + let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new( + root.clone(), + self.data_dir.join("cache").join(&project_key).join("build"), + &self.data_dir, + settings.clone(), + lsp.clone(), + sink.clone(), + trusted.clone(), + ); + documents.add_observer(kotlin_bridge.clone()); let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?)); for repo in repos.read().all() { repo.set_execution_allowed(trusted.load(Ordering::Acquire)); } let watcher = { + let kotlin_bridge = kotlin_bridge.clone(); let repos = repos.clone(); let settings = settings.clone(); let index = index.clone(); @@ -251,6 +289,7 @@ impl Host { Box::new(move |batch| { let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); index.update(&changed, &batch.removed); + kotlin_bridge.files_changed(changed.iter().chain(&batch.removed)); lsp.project_files_changed(batch.created.iter().chain(&batch.removed)); // Atomic saves by external tools surface as `created`. documents.on_disk_changed(&changed); @@ -330,7 +369,7 @@ impl Host { let content_watchers = extra_roots .iter() .filter_map(|r| { - let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history)?; + let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history, Some(&kotlin_bridge))?; Some((r.clone(), w)) }) .collect(); @@ -366,10 +405,14 @@ impl Host { syntax, detected_roots, cache_dir: self.local_data_dir.join("cache").join(&project_key), + java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new( + self.local_data_dir.join("cache").join(&project_key).join("build"), + )), db, http_history, agents, notebooks, + kotlin_bridge: Some(kotlin_bridge), watcher, content_watchers: Mutex::new(content_watchers), _scratch_watcher: scratch_watcher, @@ -388,6 +431,9 @@ impl Host { })); } self.insert_project(window, project.clone()); + if let Some(bridge) = &project.kotlin_bridge { + bridge.start(&self.runtime); + } if let Some(cmd) = post_start { self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone()); } @@ -481,10 +527,14 @@ impl Host { syntax, detected_roots: Arc::default(), cache_dir: self.local_data_dir.join("cache").join(&project_key), + java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new( + self.local_data_dir.join("cache").join(&project_key).join("build"), + )), db, http_history, agents, notebooks, + kotlin_bridge: None, watcher, content_watchers: Mutex::new(Vec::new()), _scratch_watcher: None, @@ -778,9 +828,15 @@ impl Project { if watchers.iter().any(|(p, _)| *p == r) { continue; } - if let Some(w) = - content_root_watcher(&r, &excludes, &self.sink, &self.index, &self.documents, &self.history) - { + if let Some(w) = content_root_watcher( + &r, + &excludes, + &self.sink, + &self.index, + &self.documents, + &self.history, + self.kotlin_bridge.as_ref(), + ) { watchers.push((r, w)); } } @@ -912,7 +968,9 @@ fn content_root_watcher( index: &Arc, documents: &Arc, history: &Arc, + kotlin_bridge: Option<&Arc>, ) -> Option { + let kotlin_bridge = kotlin_bridge.cloned(); let (index, documents, history) = (index.clone(), documents.clone(), history.clone()); Watcher::start( dir, @@ -921,6 +979,9 @@ fn content_root_watcher( Box::new(move |batch| { let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); index.update(&changed, &batch.removed); + if let Some(bridge) = &kotlin_bridge { + bridge.files_changed(changed.iter().chain(&batch.removed)); + } documents.on_disk_changed(&changed); record_external_changes(&history, &changed, &batch.removed); }), diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index e34db1e4a..78605b1e0 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -179,6 +179,7 @@ impl Dispatcher { }) .add("cancel_task", |c| async move { Ok(ide_core::task::cancel(c.arg::("id")?)) }) .add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) }) + .add("fs_entry_kind", |c| async move { ops::fs::entry_kind(&c.host, c.arg("path")?).await }) .add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) }) .add("fs_create_file_bytes", |c| async move { ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await @@ -190,6 +191,28 @@ impl Dispatcher { }) .add("fs_delete", |c| async move { ops::fs::delete(&c.host, &c.window, c.arg("path")?).await }) .add("fs_copy", |c| async move { ops::fs::copy(&c.host, c.arg("from")?, c.arg("to")?).await }) + .add("fs_copy_merge", |c| async move { + ops::fs::copy_merge( + &c.host, + &c.window, + c.arg("sourceRoot")?, + c.arg("destinationRoot")?, + c.arg("path")?, + c.arg("overwrite")?, + ) + .await + }) + .add("fs_compare_directories", |c| async move { + ops::fs::compare_directories( + &c.host, + &c.window, + c.arg("left")?, + c.arg("right")?, + c.arg("request")?, + c.sender("onProgress")?, + ) + .await + }) .add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await }) .add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await }) .add("doc_close", |c| async move { ops::doc::close(&c.host, &c.window, c.arg("doc")?) }) diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 2f883b7b9..b829bd636 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -106,6 +106,9 @@ impl crate::Host { if previous == trusted { continue; } + if let Some(bridge) = &project.kotlin_bridge { + bridge.schedule(); + } if !trusted { project.lsp.set_execution_allowed(false); } @@ -175,6 +178,30 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { matches!( cmd, "app_info" + | "app_build_info" + | "app_log_location" + | "app_check_update" + | "app_startup_update_check" + | "app_install_update" + | "caches_schedule_invalidation" + | "clear_caches" + | "clipboard_file_list" + | "clipboard_has_files" + | "clipboard_set_file_list" + | "jdk_download_vendors" + | "jdk_download_releases" + | "jdk_download_location" + | "jdk_download" + | "run_system_env" + | "run_gradle_jvm_args_script" + | "proxy_check_connection" + | "docker_settings" + | "docker_save_settings" + | "docker_set_registry_password" + | "docker_run_env" + | "db_console_path" + | "db_export_file" + | "vcs_write_patch" | "app_metrics" | "app_quit" | "app_cancel_quit" diff --git a/crates/ide-host/tests/properties.rs b/crates/ide-host/tests/properties.rs index d43323984..2d8e5a1aa 100644 --- a/crates/ide-host/tests/properties.rs +++ b/crates/ide-host/tests/properties.rs @@ -105,7 +105,7 @@ async fn properties_search_git_and_document_coordinates_agree() { "main", opened.id, opened.version, - ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: "世界".into() }] }, + ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: r"\u4E16\u754C".into() }] }, ) .unwrap(); let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap(); @@ -130,7 +130,7 @@ async fn properties_search_git_and_document_coordinates_agree() { assert!(project.documents.snapshot(opened.id).unwrap().version > snap.version); assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty()); // Partial commits identify hunks in displayed line coordinates as well. - let partial_text = displayed.replace("target", "世界").replace("logical", "changed"); + let partial_text = displayed.replace("target", r"\u4E16\u754C").replace("logical", "changed"); project.documents.replace_all(opened.id, &partial_text).unwrap(); doc::save(&host, "main", opened.id).await.unwrap(); let hunks = ide_vcs::diff_hunks(displayed, &partial_text); @@ -170,7 +170,7 @@ async fn properties_search_git_and_document_coordinates_agree() { "main", RequestId(520), query, - "世界".into(), + r"\u4E16\u754C".into(), false, vec![ide_search::ReplaceTarget { path: closed_path.clone().into(), @@ -182,5 +182,6 @@ async fn properties_search_git_and_document_coordinates_agree() { .unwrap(); assert_eq!(std::fs::read_to_string(&closed_path).unwrap(), r"value=\u4F60\u597D\uD83D\uDE00\u4E16\u754C"); assert!(project.documents.by_path(&closed_path).is_none()); + assert_eq!(project.text_of(&closed_path).unwrap(), "value=你好😀世界"); host.close_project("main"); } diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index 1b7d63e44..2a101b01e 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -95,6 +95,39 @@ async fn serves_projects_files_documents_search_and_runs() { let entries = invoke(&peer, "fs_read_dir", json!({ "path": project })).await.unwrap(); assert!(entries.as_array().unwrap().iter().any(|e| e["name"] == "src"), "{entries}"); + // Directory comparisons, streamed progress and confirmed copies share the remote filesystem. + let left = project.join("compare-left"); + let right = project.join("compare-right"); + std::fs::create_dir_all(&left).unwrap(); + std::fs::create_dir_all(&right).unwrap(); + std::fs::write(left.join("中文.bin"), [0, 255, 1]).unwrap(); + std::fs::write(right.join("中文.bin"), [0, 254, 1]).unwrap(); + assert_eq!(invoke(&peer, "fs_entry_kind", json!({"path": left})).await.unwrap(), "dir"); + assert_eq!(invoke(&peer, "fs_entry_kind", json!({"path": left.join("中文.bin")})).await.unwrap(), "file"); + let compare_args = + |request| json!({"left": left, "right": right, "request": request, "onProgress": {"$channel": 520}}); + let rows = invoke(&peer, "fs_compare_directories", compare_args(520)).await.unwrap(); + assert_eq!(rows[0]["status"], "different"); + assert!(client.channels.lock().iter().any(|c| c.id == 520 && c.data["compared"] == 1)); + let copy_args = + |overwrite| json!({"sourceRoot": left, "destinationRoot": right, "path": "中文.bin", "overwrite": overwrite}); + let error = invoke(&peer, "fs_copy_merge", copy_args(false)).await.unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::AlreadyExists); + invoke(&peer, "fs_copy_merge", copy_args(true)).await.unwrap(); + let rows = invoke(&peer, "fs_compare_directories", compare_args(521)).await.unwrap(); + assert_eq!(rows[0]["status"], "equal"); + invoke(&peer, "cancel_request", json!({"id": 522})).await.unwrap(); + let error = invoke(&peer, "fs_compare_directories", compare_args(522)).await.unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::Cancelled); + let error = invoke( + &peer, + "fs_copy_merge", + json!({"sourceRoot": left, "destinationRoot": right, "path": "../escape", "overwrite": true}), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput); + // JVM icons use the same read-only protocol as local windows. let java = project.join("src/Foo.java"); let kotlin = project.join("src/Singleton.kt"); diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index 256505e7c..aa41a3158 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -21,6 +21,94 @@ fn host(base: &Path, environment: bool) -> Arc { })) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn application_commands_work_in_restricted_projects_and_without_a_project() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + std::fs::create_dir(&root).unwrap(); + let host = host(&tmp.path().join("app"), false); + assert!(host.project("welcome").is_err()); + assert!(ide_host::ops::project::roots(&host, "welcome").is_err()); + assert!(!host.open_project("main", &root).unwrap().is_trusted()); + for command in [ + "app_info", + "app_build_info", + "app_log_location", + "app_check_update", + "app_startup_update_check", + "app_install_update", + "caches_schedule_invalidation", + "clear_caches", + "clipboard_file_list", + "clipboard_has_files", + "clipboard_set_file_list", + "jdk_download_vendors", + "jdk_download_releases", + "jdk_download_location", + "jdk_download", + "run_system_env", + "run_gradle_jvm_args_script", + "proxy_check_connection", + "docker_settings", + "docker_save_settings", + "docker_set_registry_password", + "docker_run_env", + "db_console_path", + "db_export_file", + "vcs_write_patch", + ] { + assert!(ide_host::trust::restricted_command_allowed(command), "{command}"); + for window in ["main", "welcome"] { + assert!(host.authorize_command(window, command).is_ok(), "{window}: {command}"); + } + } + for command in [ + "run_start", + "build_sync", + "debug_start", + "sdk_detect", + "tools_list", + "tools_install", + "tools_uninstall", + "plugin_install_dir", + "plugin_reload", + "vcs_git_version", + "docker_compose_op", + "db_execute", + "unknown_command", + ] { + assert!(!ide_host::trust::restricted_command_allowed(command), "{command}"); + for window in ["main", "welcome"] { + assert!(host.authorize_command(window, command).is_err(), "{window}: {command}"); + } + } + // These requests validate the vendor before any network access, independently of project trust. + for window in ["main", "welcome"] { + let error = ide_host::ops::tools::jdk_download_releases( + &host, + window, + "unknown-vendor".into(), + ide_core::RequestId(901), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput); + let error = ide_host::ops::tools::jdk_download( + &host, + window, + "unknown-vendor".into(), + 25, + None, + ide_core::RequestId(902), + ide_host::ops::Channel::new(|_| {}), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput); + } + host.shutdown().await; +} + #[test] fn persists_user_decisions_locations_revocation_and_one_time_migration() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index fc935338f..a42587cc8 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -53,6 +53,19 @@ pub struct LanguageConfig { pub language_servers: Vec, #[serde(default)] pub debugger: Option, + /// External formatter used when no language server can format, or selected in settings. + #[serde(default)] + pub formatter: Option, +} + +/// An external formatting tool and its language-specific parser. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct FormatterConfig { + pub tool: String, + pub parser: String, + #[serde(default)] + pub range: bool, } /// A CodeMirror mode for the files of a framework's projects. diff --git a/crates/ide-lsp/Cargo.toml b/crates/ide-lsp/Cargo.toml index 8b011c795..81cd83810 100644 --- a/crates/ide-lsp/Cargo.toml +++ b/crates/ide-lsp/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] ide-core.workspace = true +ide-vfs.workspace = true dunce.workspace = true ide-text.workspace = true ide-language.workspace = true diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 6b47e143c..82a52ef41 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -17,6 +17,9 @@ use std::sync::Arc; use std::time::{Duration, Instant}; const SERVER: &str = "jdtls"; + +mod formatter; +pub use formatter::configure_formatter; /// First start imports the Maven / Gradle model, which can take minutes. const READY_TIMEOUT: Duration = Duration::from_secs(240); /// A workspace build of a large multi-module project can take far longer than @@ -189,6 +192,24 @@ pub struct JavaCompileError { pub message: String, } +/// Holds off JDT writers while an external compiler builds and snapshots launch outputs. +/// Restores current settings even when preparation fails or is cancelled. +pub struct JavaExternalBuild { + server: Arc, + run: Running, + _lock: tokio::sync::OwnedMutexGuard<()>, +} + +impl Drop for JavaExternalBuild { + fn drop(&mut self) { + self.server.java_autobuild_suspended.store(false, std::sync::atomic::Ordering::SeqCst); + let settings = crate::handler::all_settings(&self.server.effective_config()); + if let Err(e) = self.run.peer.notify("workspace/didChangeConfiguration", json!({ "settings": settings })) { + tracing::debug!("jdtls: restoring autobuild: {e}"); + } + } +} + /// Error diagnostics sorted by file and position, capped at `limit`, and their total. pub fn compile_errors( diagnostics: Vec<(PathBuf, lsp_types::Diagnostic)>, @@ -378,7 +399,8 @@ impl LspManager { self.jdtls_command_cancellable(path, command, arguments, &CancellationToken::new()).await } - async fn jdtls_command_cancellable( + /// Cancellable delegate commands for project-owned background services. + pub async fn jdtls_command_cancellable( &self, path: &Path, command: &str, @@ -433,9 +455,28 @@ impl LspManager { .map_err(|e| crate::rpc_err(&server.name, "java/projectConfigurationsUpdate", e)) } + /// Pause autobuild before resolving the launch classpath. That query takes the + /// Java project's workspace scheduling rule, draining the interrupted autobuild. + /// Keep this guard until Maven outputs have been copied out of JDT's reach. + pub async fn java_external_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult { + let (server, run) = self.jdtls(path, cancel).await?; + let lock = cancellable_wait(cancel, server.java_build_lock.clone().lock_owned()).await?; + server.java_autobuild_suspended.store(true, std::sync::atomic::Ordering::SeqCst); + let guard = JavaExternalBuild { server, run, _lock: lock }; + let settings = crate::handler::all_settings(&guard.server.effective_config()); + guard + .run + .peer + .notify("workspace/didChangeConfiguration", json!({ "settings": settings })) + .map_err(|e| crate::rpc_err(SERVER, "workspace/didChangeConfiguration", e))?; + Ok(guard) + } + /// Incremental build of the jdtls workspace (`java/buildWorkspace`), so /// class files are up to date before a launch. pub async fn java_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult { + let (server, _) = self.jdtls(path, cancel).await?; + let _lock = cancellable_wait(cancel, server.java_build_lock.lock()).await?; let mut v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?; // The first build can finish the Maven import before ServiceReady is handled. let (server, run) = self.jdtls(path, cancel).await?; @@ -532,6 +573,59 @@ impl LspManager { collect() } + /// Resolve a disk/archive class to project source or the server's binary document. + /// Archive URIs must come from JDT's Java model: fabricated handles cannot map debug lines. + pub async fn java_class_file(&self, path: &Path, archive: Option<(PathBuf, String)>) -> IdeResult { + let cancel = CancellationToken::new(); + self.jdtls(&self.project_root, &cancel).await?; + if let Some((jar, entry)) = archive { + let entry = entry + .strip_prefix("META-INF/versions/") + .and_then(|s| s.split_once('/').map(|(_, s)| s)) + .unwrap_or(&entry); + let class = entry.strip_suffix(".class").ok_or_else(|| IdeError::not_found("expected a class file"))?; + let outer = class.split('$').next().unwrap_or(class); + let (package, name) = outer.rsplit_once('/').unwrap_or(("", outer)); + let query = outer.replace('/', "."); + let symbols = self.workspace_symbols(&query, cancel).await?; + let package = package.replace('/', "."); + let name = name.to_owned(); + let outer = outer.to_owned(); + return tokio::task::spawn_blocking(move || { + symbols + .into_iter() + .filter_map(|s| s.location.uri) + .find(|uri| crate::virtual_doc::matches_class(uri, &jar, &package, &name)) + .ok_or_else(|| { + IdeError::not_found(format!("jdtls could not resolve {} in {}", outer, jar.display())) + }) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))?; + } + let uri = crate::uri::path_to_uri_string(path); + // classFileContents accepts file URIs, but java-debug's line mapping only accepts JDT handles. + let doc = self.virtual_document(&uri).await?; + let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").split('$').next().unwrap_or(""); + let package = doc + .text + .lines() + .find_map(|l| l.trim().strip_prefix("package ").and_then(|p| p.strip_suffix(';'))) + .unwrap_or(""); + let relative = PathBuf::from(package.replace('.', "/")).join(format!("{name}.java")); + let key = "org.eclipse.jdt.ls.core.sourcePaths"; + let settings = self + .jdtls_project_command(path, "java.project.getSettings", vec![json!(uri), json!([key])], &cancel) + .await?; + let roots: Vec = + settings[key].as_array().into_iter().flatten().filter_map(Value::as_str).map(PathBuf::from).collect(); + let source = + tokio::task::spawn_blocking(move || roots.into_iter().map(|r| r.join(&relative)).find(|p| p.is_file())) + .await + .map_err(|e| IdeError::internal(e.to_string()))?; + Ok(source.map(|s| s.to_string_lossy().into_owned()).unwrap_or(uri)) + } + /// Starts (or reuses) java-debug's DAP server inside jdtls and returns its port. pub async fn java_debug_server(&self, path: &Path) -> IdeResult { let v = self.jdtls_command(path, "vscode.java.startDebugSession", Vec::new()).await.map_err(|e| { @@ -542,7 +636,7 @@ impl LspManager { } })?; // Hot code replace after each (auto-)build; errors only cost the feature. - let settings = json!({ "hotCodeReplace": "auto", "showStaticVariables": false, "showQualifiedNames": false, + let settings = json!({ "debugSupportOnDecompiledSource": "on", "hotCodeReplace": "auto", "showStaticVariables": false, "showQualifiedNames": false, "showLogicalStructure": true, "showToString": true, "maxStringLength": 0, // java-debug NPEs on a missing level (java.util.logging names). "logLevel": "WARNING" }); @@ -574,6 +668,7 @@ mod tests { assert_eq!(settings["java"]["project"]["sourcePaths"], json!(["src/main/java", "tests"])); assert_eq!(settings["java"]["project"]["referencedLibraries"], json!(["lib/**/*.jar"])); assert_eq!(settings["java"]["autobuild"]["enabled"], true); + assert_eq!(settings["java"]["contentProvider"]["preferred"], "fernflowerContentProvider"); assert!(settings.get("bundles").is_none()); assert!(settings.get("settings").is_none(), "didChangeConfiguration takes java settings directly"); } diff --git a/crates/ide-lsp/src/jdtls/formatter.rs b/crates/ide-lsp/src/jdtls/formatter.rs new file mode 100644 index 000000000..f7c19dea6 --- /dev/null +++ b/crates/ide-lsp/src/jdtls/formatter.rs @@ -0,0 +1,224 @@ +use ide_core::IdeResult; +use ide_core::editor_settings::LanguageCodeStyle; +use ide_core::settings::LanguageServerSettings; +use ide_language::LanguageServerConfig; +use serde_json::json; +use std::path::Path; + +const PROFILE: &str = "OpenIDEA IntelliJ IDEA"; +const XML: &str = include_str!("intellij.xml"); + +/// Protocol settings only: switching profiles reaches a running jdtls through +/// didChangeConfiguration. Explicit language-server settings are applied later. +pub fn configure_formatter( + cfg: &mut LanguageServerConfig, + style: Option<&LanguageCodeStyle>, + project_root: &Path, + data_dir: &Path, + overrides: Option<&LanguageServerSettings>, +) -> IdeResult<()> { + if cfg.name != "jdtls" { + return Ok(()); + } + // jdtls retains omitted preferences. Send empty values when returning to + // Default, while preserving any profile from the base server configuration. + let inherited = crate::handler::all_settings(cfg); + let defaults = json!({ + "url": inherited.pointer("/java/format/settings/url").cloned().unwrap_or(json!("")), + "profile": inherited.pointer("/java/format/settings/profile").cloned().unwrap_or(json!("")), + }); + let mut settings = cfg.settings.take().unwrap_or_else(|| json!({})); + settings["java"]["format"]["settings"] = formatter_overrides(defaults, overrides); + cfg.settings = Some(settings); + let Some(style) = style else { return Ok(()) }; + let (path, profile) = match style.formatter.as_deref() { + Some("intellij") => { + let path = data_dir.join("lsp/jdtls/intellij.xml"); + std::fs::create_dir_all(path.parent().unwrap())?; + if std::fs::read_to_string(&path).ok().as_deref() != Some(XML) { + ide_vfs::write_atomic(&path, XML.as_bytes())?; + } + (path, PROFILE) + } + Some("custom") => { + let value = style.formatter_path.as_deref().unwrap_or_default().trim(); + if value.is_empty() { + return Err(ide_core::IdeError::invalid("Choose an Eclipse formatter XML file.")); + } + let path = project_root.join(value); + // Fail without poisoning the server launch; code intelligence remains available. + std::fs::File::open(&path).map_err(|error| { + let path = path.display(); + ide_core::IdeError::invalid(format!("Cannot read Java formatter XML {path}: {error}")) + })?; + if !path.is_file() { + return Err(ide_core::IdeError::invalid(format!("Not a formatter XML file: {}", path.display()))); + } + (path, "") + } + _ => return Ok(()), + }; + let mut settings = cfg.settings.take().unwrap_or_else(|| json!({})); + settings["java"]["format"]["settings"] = formatter_overrides( + json!({ + "url": crate::uri::path_to_uri_string(&path), + "profile": profile, + }), + overrides, + ); + cfg.settings = Some(settings); + Ok(()) +} + +fn formatter_overrides(mut format: serde_json::Value, overrides: Option<&LanguageServerSettings>) -> serde_json::Value { + if let Some(overrides) = overrides { + // all_settings merges protocol settings over initializationOptions. + // Mirror explicit formatter keys so generated protocol values cannot + // accidentally mask an advanced initializationOptions override. + for layer in [ + overrides.initialization_options.as_ref().and_then(|v| v.pointer("/settings/java/format/settings")), + overrides.settings.as_ref().and_then(|v| v.pointer("/settings/java/format/settings")), + overrides.settings.as_ref().and_then(|v| v.pointer("/java/format/settings")), + ] + .into_iter() + .flatten() + { + for key in ["url", "profile"] { + if let Some(value) = layer.get(key) { + format[key] = value.clone(); + } + } + } + } + format +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(name: &str) -> LanguageServerConfig { + serde_json::from_value(json!({"name": name, "command": name})).unwrap() + } + + #[test] + fn profiles_resolve_paths_preserve_settings_and_leave_other_servers_alone() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("project with 空格"); + let data = dir.path().join("data"); + std::fs::create_dir_all(&root).unwrap(); + let custom = root.join("style.xml"); + std::fs::write(&custom, XML).unwrap(); + for name in ["jdtls", "rust-analyzer"] { + for mode in ["default", "intellij", "custom", "unknown"] { + let mut cfg = LanguageServerConfig { + name: name.into(), + settings: Some(json!({"java": {"autobuild": {"enabled": false}}})), + ..config(name) + }; + let initial = cfg.clone(); + let style = LanguageCodeStyle { + formatter: Some(mode.into()), + formatter_path: Some("style.xml".into()), + ..Default::default() + }; + configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap(); + if name != "jdtls" { + assert_eq!(cfg, initial); + continue; + } + let settings = crate::handler::all_settings(&cfg); + if !["intellij", "custom"].contains(&mode) { + assert_eq!(settings["java"]["format"]["settings"], json!({"url": "", "profile": ""})); + continue; + } + let path = if mode == "custom" { custom.clone() } else { data.join("lsp/jdtls/intellij.xml") }; + assert_eq!(settings["java"]["format"]["settings"]["url"], crate::uri::path_to_uri_string(&path)); + assert_eq!( + settings["java"]["format"]["settings"]["profile"], + if mode == "custom" { "" } else { PROFILE } + ); + assert_eq!(settings["java"]["autobuild"]["enabled"], false); + assert_eq!(std::fs::read_to_string(path).unwrap(), XML); + } + } + } + + #[test] + fn unavailable_custom_paths_are_nonfatal_and_do_not_set_launch_error() { + let dir = tempfile::tempdir().unwrap(); + for path in ["", "missing.xml", "."] { + let mut cfg = config("jdtls"); + let style = LanguageCodeStyle { + formatter: Some("custom".into()), + formatter_path: Some(path.into()), + ..Default::default() + }; + assert!(configure_formatter(&mut cfg, Some(&style), dir.path(), dir.path(), None).is_err()); + assert!(cfg.launch_error.is_none()); + assert_eq!(crate::handler::all_settings(&cfg)["java"]["format"]["settings"]["url"], ""); + } + } + + #[test] + fn default_keeps_explicit_server_configuration_and_custom_accepts_absolute_paths() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("custom.xml"); + std::fs::write(&path, XML).unwrap(); + let mut cfg = config("jdtls"); + cfg.initialization_options = Some( + toml::Value::try_from(json!({ + "settings": {"java": {"format": {"settings": {"url": "existing.xml", "profile": "Existing"}}}} + })) + .unwrap(), + ); + configure_formatter(&mut cfg, None, dir.path(), dir.path(), None).unwrap(); + assert_eq!( + crate::handler::all_settings(&cfg)["java"]["format"]["settings"], + json!({"url": "existing.xml", "profile": "Existing"}) + ); + let style = LanguageCodeStyle { + formatter: Some("custom".into()), + formatter_path: Some(path.to_string_lossy().into_owned()), + ..Default::default() + }; + configure_formatter(&mut cfg, Some(&style), &dir.path().join("different-root"), dir.path(), None).unwrap(); + assert_eq!( + crate::handler::all_settings(&cfg)["java"]["format"]["settings"]["url"], + crate::uri::path_to_uri_string(&path) + ); + } + #[test] + fn advanced_formatter_options_win_for_each_profile_and_protocol_layer() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("custom.xml"), XML).unwrap(); + for mode in ["default", "intellij", "custom"] { + for layer in ["initialization", "wrapped", "direct"] { + let mut overrides = LanguageServerSettings { + initialization_options: Some(json!({"settings": {"java": {"format": {"settings": { + "url": "advanced.xml", "profile": "Advanced" + }}}}})), + ..Default::default() + }; + if layer != "initialization" { + let value = json!({"java": {"format": {"settings": {"profile": "Protocol"}}}}); + overrides.settings = Some(if layer == "wrapped" { json!({"settings": value}) } else { value }); + } + let mut cfg = config("jdtls"); + let style = LanguageCodeStyle { + formatter: Some(mode.into()), + formatter_path: Some("custom.xml".into()), + ..Default::default() + }; + configure_formatter(&mut cfg, Some(&style), dir.path(), dir.path(), Some(&overrides)).unwrap(); + assert_eq!( + crate::handler::all_settings(&cfg)["java"]["format"]["settings"], + json!({ + "url": "advanced.xml", + "profile": if layer == "initialization" { "Advanced" } else { "Protocol" }, + }) + ); + } + } + } +} diff --git a/crates/ide-lsp/src/jdtls/intellij.xml b/crates/ide-lsp/src/jdtls/intellij.xml new file mode 100644 index 000000000..18b3cf8de --- /dev/null +++ b/crates/ide-lsp/src/jdtls/intellij.xml @@ -0,0 +1,235 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 6d44afeb9..521451845 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -1886,6 +1886,12 @@ impl LspManager { } } + /// Checks initialized capabilities, including dynamic registrations, before choosing a formatter. + pub async fn can_format(&self, doc: DocId) -> IdeResult { + let snap = self.snapshot(doc)?; + Ok(!self.ready_targets(&snap.servers, |c| c.has("documentFormattingProvider")).await.is_empty()) + } + pub async fn format(&self, doc: DocId, range: Option, options: FormatOptions) -> IdeResult> { let (method, cap, feature) = match range { None => ("textDocument/formatting", "documentFormattingProvider", "formatting"), @@ -2166,6 +2172,7 @@ impl LspManager { return Ok(d.clone()); } let scheme = virtual_doc::scheme(uri).to_owned(); + let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class")); let origin = self.origin_server(uri); let mut servers: Vec> = origin.iter().cloned().collect(); servers.extend(self.servers.lock().iter().filter(|s| Some(s.id) != origin.as_ref().map(|o| o.id)).cloned()); @@ -2174,7 +2181,7 @@ impl LspManager { for t in &targets { let languages = t.server.languages(); let serves_scheme = t.caps().content_schemes().contains(&scheme); - let result: IdeResult> = if serves_scheme { + let result: IdeResult> = if serves_scheme && !class_file { self.call::<_, Option>( t, "workspace/textDocumentContent", @@ -2184,7 +2191,7 @@ impl LspManager { ) .await .map(|v| v.and_then(|v| v.get("text").and_then(Value::as_str).map(str::to_owned))) - } else if scheme == "jdt" && languages.iter().any(|l| l.as_str() == "java") { + } else if class_file && languages.iter().any(|l| l.as_str() == "java") { self.call::<_, Option>(t, "java/classFileContents", json!({ "uri": uri }), None, LONG_TIMEOUT) .await } else { diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 018c81ee0..81c21b021 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -99,6 +99,8 @@ pub struct Server { /// Woken when the last `$/progress` task ends. idle_notify: Notify, transient: Mutex>, + pub java_build_lock: Arc>, + pub java_autobuild_suspended: std::sync::atomic::AtomicBool, pub external_project: tokio::sync::Mutex, pub external_diagnostics: std::sync::atomic::AtomicBool, pub external_refresh_queued: std::sync::atomic::AtomicBool, @@ -126,6 +128,8 @@ impl Server { published_notify: Notify::new(), idle_notify: Notify::new(), transient: Mutex::new(HashMap::new()), + java_build_lock: Arc::new(tokio::sync::Mutex::new(())), + java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false), external_project: tokio::sync::Mutex::new(Default::default()), external_diagnostics: std::sync::atomic::AtomicBool::new(false), external_refresh_queued: std::sync::atomic::AtomicBool::new(false), @@ -134,7 +138,13 @@ impl Server { /// Configuration of the current (or last) instance; the base config before the first launch. pub fn effective_config(&self) -> LanguageServerConfig { - self.effective.lock().clone().unwrap_or_else(|| self.config.clone()) + let mut config = self.effective.lock().clone().unwrap_or_else(|| self.config.clone()); + if self.java_autobuild_suspended.load(std::sync::atomic::Ordering::SeqCst) { + let mut settings = crate::handler::all_settings(&config); + settings["java"]["autobuild"]["enabled"] = serde_json::json!(false); + config.settings = Some(settings); + } + config } pub fn set_effective_config(&self, config: LanguageServerConfig) { @@ -553,6 +563,19 @@ mod tests { assert_eq!(s.record_crash(), Some(Duration::from_millis(500))); } + #[test] + fn external_build_suspends_only_the_effective_autobuild_setting() { + let mut config: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap(); + config.settings = Some(serde_json::json!({"java": {"autobuild": {"enabled": true}, "other": "kept"}})); + let server = Server::new(config.clone(), PathBuf::from("/p"), LanguageId::new("java")); + server.java_autobuild_suspended.store(true, std::sync::atomic::Ordering::SeqCst); + let settings = crate::handler::all_settings(&server.effective_config()); + assert_eq!(settings["java"]["autobuild"]["enabled"], false); + assert_eq!(settings["java"]["other"], "kept"); + server.java_autobuild_suspended.store(false, std::sync::atomic::Ordering::SeqCst); + assert_eq!(server.effective_config(), config); + } + #[tokio::test(flavor = "multi_thread")] async fn a_failed_start_reports_its_status_before_waiters_wake() { // The race behind "jdtls is not ready (Starting)": a request woken by the diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index 45be5d0c3..050d493d1 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -219,6 +219,9 @@ pub struct CodeActionQuery { pub struct FormatOptions { pub tab_size: u32, pub insert_spaces: bool, + #[serde(default)] + #[specta(optional)] + pub on_save: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -335,7 +338,7 @@ pub struct VirtualDocument { pub title: String, /// Origin shown in the tooltip / banner, e.g. the jar path. pub description: Option, - /// Generated by a decompiler / disassembler rather than real sources. + /// Generated Java from a decompiler, rather than real source or a disassembler stub. pub decompiled: bool, } diff --git a/crates/ide-lsp/src/virtual_doc.rs b/crates/ide-lsp/src/virtual_doc.rs index 71394d7a1..110b206fd 100644 --- a/crates/ide-lsp/src/virtual_doc.rs +++ b/crates/ide-lsp/src/virtual_doc.rs @@ -1,21 +1,29 @@ //! Virtual documents: read-only contents a language server provides for a -//! non-`file:` URI (library classes from `jdt://`, `workspace/textDocumentContent`). +//! URI (library classes from `jdt://`, disk classes from `file://`, or +//! `workspace/textDocumentContent`). use percent_encoding::percent_decode_str; -/// Markers decompilers / disassemblers put at the top of generated code. -const GENERATED_MARKERS: [&str; 5] = [ - "generated by the fernflower decompiler", - "failed to get sources", - "decompiled with cfr", - "decompiled with procyon", - "generated by the disassembler", -]; - -/// Whether `text` is decompiled / disassembled code rather than real source. +/// Recognize decompiler headers, without treating disassembler stubs as Java source. pub fn is_decompiled(text: &str) -> bool { - let head: String = text.chars().take(600).collect::().to_ascii_lowercase(); - GENERATED_MARKERS.iter().any(|m| head.contains(m)) + let head: String = text.chars().take(600).collect(); + head.lines() + .take(12) + .take_while(|line| { + let line = line.trim(); + line.is_empty() || line.starts_with("//") || line.starts_with("/*") || line.starts_with('*') + }) + .any(|line| { + let line = line.trim().trim_start_matches('/').trim_start_matches('*').trim().to_ascii_lowercase(); + [ + "source code is unavailable, and was generated by the fernflower decompiler.", + "source code is decompiled from a .class file using fernflower decompiler", + "decompiled with cfr", + "decompiled with procyon", + ] + .iter() + .any(|marker| line.starts_with(marker)) + }) } /// `scheme` of a URI (`jdt` for `jdt://…`). @@ -34,7 +42,16 @@ fn segments(uri: &str) -> Vec { /// Editor tab title: the last path segment; a class file whose real source /// was found is shown as `.java` (IDEA: `String.java` vs `String.class`). pub fn title(uri: &str, decompiled: bool) -> String { - let name = segments(uri).pop().unwrap_or_else(|| uri.to_owned()); + let mut name = segments(uri).pop().unwrap_or_else(|| uri.to_owned()); + // Recent jdtls versions use a .java path even for decompiled class handles. + if decompiled + && scheme(uri) == "jdt" + && name.ends_with(".java") + && uri.split_once('?').is_some_and(|(_, q)| percent_decode_str(q).decode_utf8_lossy().contains(".class")) + { + name.truncate(name.len() - 5); + name.push_str(".class"); + } match name.strip_suffix(".class") { Some(stem) if !decompiled && scheme(uri) == "jdt" => format!("{stem}.java"), _ => name, @@ -68,6 +85,19 @@ pub fn description(uri: &str) -> Option { } } +/// Check both the package and actual archive, so duplicate type names never pick another jar. +pub(crate) fn matches_class(uri: &str, jar: &std::path::Path, package: &str, name: &str) -> bool { + let segs = segments(uri); + scheme(uri) == "jdt" + && (package.is_empty() && segs.len() == 3 || segs.get(2).is_some_and(|s| s == package)) + && segs.last().is_some_and(|s| s == &format!("{name}.class") || s == &format!("{name}.java")) + && description(uri).is_some_and(|p| { + let path = std::path::Path::new(&p); + path == jar + || dunce::canonicalize(path).ok().zip(dunce::canonicalize(jar).ok()).is_some_and(|(a, b)| a == b) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -84,6 +114,30 @@ mod tests { #[test] fn jdt_description() { + assert!(matches_class( + JDT, + std::path::Path::new("/Users/me/.m2/repository/spring-boot-4.1.1.jar"), + "org.springframework.boot", + "SpringApplication" + )); + assert!(!matches_class( + JDT, + std::path::Path::new("/elsewhere/spring-boot-4.1.1.jar"), + "org.springframework.boot", + "SpringApplication" + )); + assert!(!matches_class( + JDT, + std::path::Path::new("/Users/me/.m2/repository/spring-boot-4.1.1.jar"), + "other.package", + "SpringApplication" + )); + assert!(matches_class( + "jdt://contents/a.jar//C.java?=p/%5C/m2%5C/a.jar%3C%28C.class", + std::path::Path::new("/m2/a.jar"), + "", + "C" + )); assert_eq!(description(JDT).as_deref(), Some("/Users/me/.m2/repository/spring-boot-4.1.1.jar")); assert_eq!( description( @@ -98,10 +152,18 @@ mod tests { #[test] fn detects_generated_code() { + assert!(is_decompiled( + "// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage a;" + )); + assert!(!is_decompiled("package a;\n// generated by the fernflower decompiler")); + assert!(!is_decompiled("// Documentation about code generated by the Fernflower decompiler.\npackage a;")); + let recent = "jdt://contents/a.jar/p/C.java?=p/a.jar%3Cp%28C.class"; + assert_eq!(title(recent, true), "C.class"); + assert_eq!(title(recent, false), "C.java"); assert!(is_decompiled( "// Source code is unavailable, and was generated by the Fernflower decompiler.\npackage a;" )); - assert!(is_decompiled( + assert!(!is_decompiled( "// Failed to get sources. Instead, stub sources have been generated by the disassembler." )); assert!(!is_decompiled("/*\n * Copyright 2012-2024 the original author or authors.\n */\npackage a;")); diff --git a/crates/ide-lsp/tests/fixtures/FormatterSample.input.java b/crates/ide-lsp/tests/fixtures/FormatterSample.input.java new file mode 100644 index 000000000..6c7121a84 --- /dev/null +++ b/crates/ide-lsp/tests/fixtures/FormatterSample.input.java @@ -0,0 +1,25 @@ +package demo; +import java.util.List; +import java.util.function.Function; +@Deprecated @SuppressWarnings("unused") public class FormatterSample { +@Deprecated @SuppressWarnings("unused") private String first="first"; +private String second="second"; +/** A description which remains on a very long single line to check whether formatting introduces wrapping when the right margin is exceeded with ordinary words in a JavaDoc comment. */ +@Deprecated @SuppressWarnings("unused") public String combine(@Deprecated @SuppressWarnings("unused") String firstParameterWithAVeryLongName, String secondParameterWithAVeryLongName, String thirdParameterWithAVeryLongName){if(firstParameterWithAVeryLongName==null){return "none";}else{try{return List.of(firstParameterWithAVeryLongName,secondParameterWithAVeryLongName,thirdParameterWithAVeryLongName).stream().map(value->value.trim()).filter(value->!value.isEmpty()).findFirst().orElse("none");}catch(RuntimeException problem){throw problem;}finally{System.out.println("done");}}} +public void multiline(String firstParameter, +String secondParameter, +String thirdParameter){ +combine(firstParameter, +secondParameter, +thirdParameter); +List.of(firstParameter, secondParameter) +.stream() +.map(value->value.trim()) +.forEach(System.out::println); +} +public void retained(String firstParameter, // retain declaration break +String secondParameter){combine(firstParameter, // retain call break +secondParameter, "third");} +public void arrays(){int[] values=new int[]{1,2,3};int[] empty=new int[]{};Function clean=value->{return value.trim();};Runnable run=()->{System.out.println(values.length);};} +// A comment which intentionally exceeds the right margin and should remain unwrapped with the built-in profile even when it contains many ordinary words and spaces to split on. +} diff --git a/crates/ide-lsp/tests/fixtures/FormatterSample.intellij.java b/crates/ide-lsp/tests/fixtures/FormatterSample.intellij.java new file mode 100644 index 000000000..f1211e24c --- /dev/null +++ b/crates/ide-lsp/tests/fixtures/FormatterSample.intellij.java @@ -0,0 +1,65 @@ +package demo; + +import java.util.List; +import java.util.function.Function; + +@Deprecated +@SuppressWarnings("unused") +public class FormatterSample { + @Deprecated + @SuppressWarnings("unused") + private String first = "first"; + private String second = "second"; + + /** A description which remains on a very long single line to check whether formatting introduces wrapping when the right margin is exceeded with ordinary words in a JavaDoc comment. */ + @Deprecated + @SuppressWarnings("unused") + public String combine(@Deprecated @SuppressWarnings("unused") String firstParameterWithAVeryLongName, + String secondParameterWithAVeryLongName, String thirdParameterWithAVeryLongName) { + if (firstParameterWithAVeryLongName == null) { + return "none"; + } else { + try { + return List + .of(firstParameterWithAVeryLongName, secondParameterWithAVeryLongName, + thirdParameterWithAVeryLongName) + .stream().map(value -> value.trim()).filter(value -> !value.isEmpty()).findFirst() + .orElse("none"); + } catch (RuntimeException problem) { + throw problem; + } finally { + System.out.println("done"); + } + } + } + + public void multiline(String firstParameter, + String secondParameter, + String thirdParameter) { + combine(firstParameter, + secondParameter, + thirdParameter); + List.of(firstParameter, secondParameter) + .stream() + .map(value -> value.trim()) + .forEach(System.out::println); + } + + public void retained(String firstParameter, // retain declaration break + String secondParameter) { + combine(firstParameter, // retain call break + secondParameter, "third"); + } + + public void arrays() { + int[] values = new int[]{1, 2, 3}; + int[] empty = new int[]{}; + Function clean = value -> { + return value.trim(); + }; + Runnable run = () -> { + System.out.println(values.length); + }; + } + // A comment which intentionally exceeds the right margin and should remain unwrapped with the built-in profile even when it contains many ordinary words and spaces to split on. +} diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index e909af815..7bfb45ccd 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -252,6 +252,7 @@ fn harness_configured( indent: None, ui_mode: None, framework_ui_modes: Vec::new(), + formatter: None, tree_sitter: None, language_servers: servers .iter() @@ -1016,6 +1017,70 @@ async fn settings_changes_reach_servers_without_restarts() { assert_eq!(s.launches.load(Ordering::SeqCst), 1); } +#[tokio::test(flavor = "multi_thread")] +async fn java_formatter_profiles_reach_initialization_and_live_configuration_without_restart() { + let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| { + cfg.initialization_options = Some( + toml::Value::try_from(json!({ + "settings": {"java": {"autobuild": {"enabled": false}}} + })) + .unwrap(), + ); + }); + let data = tempfile::tempdir().unwrap(); + std::fs::write(h.root.join("custom.xml"), "").unwrap(); + let style = Arc::new(Mutex::new(ide_core::editor_settings::LanguageCodeStyle { + formatter: Some("intellij".into()), + formatter_path: Some("custom.xml".into()), + ..Default::default() + })); + h.mgr.set_config_hook(Box::new({ + let style = style.clone(); + let root = h.root.clone(); + let data = data.path().to_path_buf(); + move |cfg, _| ide_lsp::jdtls::configure_formatter(cfg, Some(&style.lock()), &root, &data, None).unwrap() + })); + h.open("Main.java", "public class Main {}"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let state = h.launcher.state("jdtls"); + let init = state.requested("initialize")[0].clone(); + assert_eq!( + init["initializationOptions"]["settings"]["java"]["format"]["settings"]["profile"], + "OpenIDEA IntelliJ IDEA" + ); + for mode in ["custom", "default", "intellij", "default"] { + style.lock().formatter = Some(mode.into()); + h.mgr.reconfigure().await; + let expected = match mode { + "custom" => uri_of(&h.root.join("custom.xml")), + "intellij" => uri_of(&data.path().join("lsp/jdtls/intellij.xml")), + _ => String::new(), + }; + eventually(|| { + state + .notified("workspace/didChangeConfiguration") + .last() + .is_some_and(|p| p["settings"]["java"]["format"]["settings"]["url"] == expected) + }) + .await; + let result: Value = state + .peer() + .request( + "workspace/configuration", + json!({ + "items": [{"section": "java.format.settings"}, {"section": "java.autobuild.enabled"}] + }), + None, + ) + .await + .unwrap(); + assert_eq!(result[0]["url"], expected); + assert_eq!(result[0]["profile"], if mode == "intellij" { "OpenIDEA IntelliJ IDEA" } else { "" }); + assert_eq!(result[1], false); + assert_eq!(state.launches.load(Ordering::SeqCst), 1); + } +} + #[tokio::test(flavor = "multi_thread")] async fn java_source_paths_reach_initialization_live_configuration_and_builds() { let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| { @@ -1248,12 +1313,17 @@ async fn navigation_rename_format_and_decorations() { assert!(matches!(&edit.files[0], FileEdit::Edit { edits, .. } if edits.len() == 2)); assert_eq!(s.requested("textDocument/rename")[0]["newName"], "bar"); + assert!(h.mgr.can_format(doc).await.unwrap()); s.respond("textDocument/formatting", json!([{ "range": r(0,10,0,11), "newText": "\n}" }])); - let edits = h.mgr.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true }).await.unwrap(); + let edits = + h.mgr.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true, on_save: false }).await.unwrap(); assert_eq!(edits[0].range, rng(0, 10, 0, 11)); assert_eq!(s.requested("textDocument/formatting")[0]["options"], json!({ "tabSize": 2, "insertSpaces": true })); assert!( - h.mgr.format(doc, Some(rng(0, 0, 1, 0)), FormatOptions { tab_size: 2, insert_spaces: true }).await.is_err() + h.mgr + .format(doc, Some(rng(0, 0, 1, 0)), FormatOptions { tab_size: 2, insert_spaces: true, on_save: false }) + .await + .is_err() ); s.respond("textDocument/semanticTokens/full", json!({ "data": [0, 3, 3, 0, 1, 1, 0, 3, 0, 0] })); @@ -1617,6 +1687,61 @@ async fn jdt_class_file_contents() { assert_eq!(vd.title, "Lib.class"); assert_eq!(vd.language_id.as_str(), "java"); assert_eq!(vd.description.as_deref(), Some("/m2/lib-1.0.jar")); + let source_uri = jdt.replace("Lib.class", "WithSources.class"); + s.respond( + "java/classFileContents", + json!("/* Copyright library author */\npackage org.example;\npublic class WithSources {}"), + ); + let source = h.mgr.virtual_document(&source_uri).await.unwrap(); + assert!(!source.decompiled); + assert_eq!(source.title, "WithSources.java"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn class_file_opening_routes_sources_and_binary_documents() { + let mut caps = incremental_caps(); + caps["workspaceSymbolProvider"] = json!(true); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + h.open("App.java", "class App {}\n"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let s = h.launcher.state("jdtls"); + let jdt = "jdt://contents/lib.jar/p/Outer.java?=demo/%5C/m2%5C/lib.jar%3Cp%28Outer.class"; + s.respond("workspace/symbol", json!([ + {"name":"Outer", "kind":5, "location":{"uri":jdt,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}}}} + ])); + let archive = Some((PathBuf::from("/m2/lib.jar"), "p/Outer$1.class".into())); + assert_eq!(h.mgr.java_class_file(Path::new("/m2/lib.jar!/p/Outer$1.class"), archive).await.unwrap(), jdt); + assert!( + h.mgr + .java_class_file( + Path::new("/other/lib.jar!/p/Outer.class"), + Some(("/other/lib.jar".into(), "p/Outer.class".into())) + ) + .await + .is_err() + ); + + let sources = h.root.join("src/main/java"); + std::fs::create_dir_all(sources.join("p")).unwrap(); + let source = sources.join("p/Outer.java"); + std::fs::write(&source, "package p; class Outer {} ").unwrap(); + s.respond("java/classFileContents", json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Outer {}")); + s.respond("workspace/executeCommand", json!({"org.eclipse.jdt.ls.core.sourcePaths":[sources]})); + for class in ["Outer", "Outer$Nested", "Outer$1"] { + let path = h.root.join(format!("target/classes/p/{class}.class")); + let resolved = h.mgr.java_class_file(&path, None).await.unwrap(); + // Compare as paths: the manager joins the relative source path with std::path, so + // separators can differ from the literal "p/Outer.java" used to build `source` on Windows. + assert_eq!(Path::new(&resolved), source.as_path()); + } + let path = h.root.join("build/classes/java/main/p/Missing.class"); + let uri = uri_of(&path); + assert_eq!(h.mgr.java_class_file(&path, None).await.unwrap(), uri); + let vd = h.mgr.virtual_document(&uri).await.unwrap(); + assert!(vd.decompiled); + assert_eq!(vd.language_id.as_str(), "java"); + assert_eq!(vd.title, "Missing.class"); + assert!(s.requested("java/classFileContents").iter().any(|p| p["uri"] == uri)); } #[tokio::test(flavor = "multi_thread")] @@ -2682,3 +2807,32 @@ async fn interactive_capability_is_scoped_to_gopls() { assert!(form.is_none()); assert!(s.requested("command/resolve").is_empty()); } + +#[tokio::test(flavor = "multi_thread")] +async fn formatting_provider_selection_tracks_dynamic_registration() { + let h = harness(&[("mock", json!({ "textDocumentSync": 1, "documentRangeFormattingProvider": true }))]); + let (doc, _) = h.open("a.mk", "hello\n"); + h.wait_status("mock", ServerStatus::Ready).await; + assert!(!h.mgr.can_format(doc).await.unwrap(), "a range provider alone cannot format the whole document"); + let peer = h.launcher.state("mock").peer(); + peer.request::<_, Value>( + "client/registerCapability", + json!({"registrations": [ + {"id": "formatter", "method": "textDocument/formatting"} + ]}), + None, + ) + .await + .unwrap(); + assert!(h.mgr.can_format(doc).await.unwrap()); + peer.request::<_, Value>( + "client/unregisterCapability", + json!({"unregisterations": [ + {"id": "formatter", "method": "textDocument/formatting"} + ]}), + None, + ) + .await + .unwrap(); + assert!(!h.mgr.can_format(doc).await.unwrap()); +} diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 13ab9667f..f25f77745 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -55,12 +55,60 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig { indent: None, ui_mode: None, framework_ui_modes: Vec::new(), + formatter: None, tree_sitter: None, language_servers: vec![server], debugger: None, } } +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires jdtls on PATH and a compatible JDK"] +async fn intellij_java_formatter_preserves_manual_breaks_and_wraps_long_lines() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + let source = include_str!("fixtures/FormatterSample.input.java"); + let expected = include_str!("fixtures/FormatterSample.intellij.java"); + let path = root.join("src/demo/FormatterSample.java"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, source).unwrap(); + let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap(); + let server = &mut java.language_servers[0]; + if let Ok(bin) = std::env::var("OPENIDEA_TEST_JDTLS") { + server.command = bin; + } + server.args.extend(["-data".into(), root.join("jdtls-workspace").to_string_lossy().into_owned()]); + server.settings = Some(serde_json::json!({"java": {"autobuild": {"enabled": false}}})); + let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![java])); + mgr.set_config_hook(Box::new({ + let root = root.clone(); + move |cfg, _| { + ide_lsp::jdtls::configure_source_paths(cfg, Some(vec!["src".into()])); + let style = ide_core::editor_settings::LanguageCodeStyle { + formatter: Some("intellij".into()), + ..Default::default() + }; + ide_lsp::jdtls::configure_formatter(cfg, Some(&style), &root, &root.join("data"), None).unwrap(); + } + })); + let doc = DocId::next(); + mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source); + retry("jdtls ready", Duration::from_secs(120), || { + let status = last_status(&events, "jdtls"); + if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status { + panic!("jdtls failed: {msg:?}"); + } + async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) } + }) + .await; + let edits = + mgr.format(doc, None, ide_lsp::types::FormatOptions { tab_size: 4, insert_spaces: true, on_save: false }).await; + tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); + let mut document = ide_text::Document::new(source); + document.apply_edits(&edits.unwrap()).unwrap(); + assert_eq!(document.text(), expected); +} + #[tokio::test(flavor = "multi_thread")] async fn missing_binary_is_reported_with_install_hint() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index bf86f5815..9d104583a 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -948,6 +948,49 @@ mod tests { } } +/// The same jar supplies the JDT command and the private Maven output redirector. +pub fn kotlin_bridge_bundle(data_dir: &Path) -> Option { + let extension = data_dir.join("kotlin-bridge-1.0.0.jar"); + let bytes = include_bytes!("../../../plugins/kotlin-bridge/openidea-kotlin-bridge.jar"); + if std::fs::read(&extension).is_ok_and(|old| old == bytes) { + return Some(extension); + } + let temporary = data_dir.join(format!( + "kotlin-bridge-{}-{}.tmp", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos() + )); + let result = std::fs::create_dir_all(data_dir) + .and_then(|_| std::fs::write(&temporary, bytes)) + .and_then(|_| std::fs::rename(&temporary, &extension)); + if let Err(error) = result { + let _ = std::fs::remove_file(temporary); + tracing::warn!("Kotlin bridge extension unavailable: {error}"); + return std::fs::read(&extension).is_ok_and(|old| old == bytes).then_some(extension); + } + Some(extension) +} + +#[cfg(test)] +mod kotlin_bridge_bundle_tests { + #[test] + fn embeds_both_runtime_targets_and_repairs_a_damaged_installation() { + let directory = tempfile::tempdir().unwrap(); + let path = super::kotlin_bridge_bundle(directory.path()).unwrap(); + let expected = std::fs::read(&path).unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&expected)).unwrap(); + for (class, major) in [("KotlinLibraries", 65), ("IsolatedOutput", 52)] { + use std::io::Read; + let mut bytes = Vec::new(); + archive.by_name(&format!("ai/openidea/kotlin/{class}.class")).unwrap().read_to_end(&mut bytes).unwrap(); + assert_eq!(u16::from_be_bytes([bytes[6], bytes[7]]), major); + } + std::fs::write(&path, "incomplete").unwrap(); + assert_eq!(super::kotlin_bridge_bundle(directory.path()), Some(path.clone())); + assert_eq!(std::fs::read(path).unwrap(), expected); + } +} + /// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure. pub fn usages_bundle(data_dir: &Path) -> Option { let extension = data_dir.join("java-usages-1.1.1.jar"); diff --git a/crates/ide-sdk/src/libraries.rs b/crates/ide-sdk/src/libraries.rs index 4c50b2f55..bdc450b7e 100644 --- a/crates/ide-sdk/src/libraries.rs +++ b/crates/ide-sdk/src/libraries.rs @@ -8,7 +8,7 @@ //! result is cached until the manifests (or the relevant settings) change. //! //! Archives are listed as browsable roots (`!`, see `ide_vfs`): the -//! JDK's `src.zip` and the Maven / Gradle dependency jars (sources jars preferred). +//! JDK's `src.zip` and the Maven / Gradle dependency jars (source attachments resolved by JDT). use crate::exe; use crate::exec::{query, query_in, which}; @@ -248,7 +248,7 @@ fn gradle_user_home() -> Option { } /// One `Maven: g:a:v` / `Gradle: g:a:v` group per dependency whose jar is in a -/// local repository, its root being the jar (the `-sources.jar` when downloaded). +/// local repository, its root being the binary jar so Java classes open through JDT's source provider. pub(crate) fn dependency_libraries( deps: &[BuildDependency], maven_repo: Option<&Path>, @@ -286,7 +286,7 @@ pub(crate) fn maven_jar(repo: &Path, d: &BuildDependency) -> Option { return None; } let dir = repo.join(d.group.replace('.', "/")).join(&d.artifact).join(&d.version); - ["-sources.jar", ".jar"] + [".jar", "-sources.jar"] .iter() .map(|suffix| dir.join(format!("{}-{}{suffix}", d.artifact, d.version))) .find(|p| p.is_file()) @@ -300,7 +300,7 @@ pub(crate) fn gradle_jar(home: &Path, d: &BuildDependency) -> Option { let dir = home.join("caches/modules-2/files-2.1").join(&d.group).join(&d.artifact).join(&d.version); let mut hashes: Vec = std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).collect(); hashes.sort(); - ["-sources.jar", ".jar"].iter().find_map(|suffix| { + [".jar", "-sources.jar"].iter().find_map(|suffix| { let file = format!("{}-{}{suffix}", d.artifact, d.version); hashes.iter().map(|h| h.join(&file)).find(|p| p.is_file()) }) diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 8ce5a774b..37adca8be 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -540,12 +540,12 @@ fn build_dependencies_resolve_to_local_jars() { std::fs::write(&p, "").unwrap(); p }; - touch(m2.join("com/google/guava/guava/33.0/guava-33.0.jar")); - let guava_src = touch(m2.join("com/google/guava/guava/33.0/guava-33.0-sources.jar")); + let guava = touch(m2.join("com/google/guava/guava/33.0/guava-33.0.jar")); + touch(m2.join("com/google/guava/guava/33.0/guava-33.0-sources.jar")); let junit = touch(m2.join("junit/junit/4.13/junit-4.13.jar")); let files = gradle.join("caches/modules-2/files-2.1/org.slf4j/slf4j-api/2.0.9"); - touch(files.join("a1b2/slf4j-api-2.0.9.jar")); - let slf4j_src = touch(files.join("c3d4/slf4j-api-2.0.9-sources.jar")); + let slf4j = touch(files.join("a1b2/slf4j-api-2.0.9.jar")); + touch(files.join("c3d4/slf4j-api-2.0.9-sources.jar")); let dep = |gradle: bool, g: &str, a: &str, v: &str| BuildDependency { gradle, group: g.into(), @@ -566,14 +566,14 @@ fn build_dependencies_resolve_to_local_jars() { assert_eq!( shown, [ - ("Maven: com.google.guava:guava:33.0", "maven", "guava-33.0-sources.jar"), + ("Maven: com.google.guava:guava:33.0", "maven", "guava-33.0.jar"), ("Maven: junit:junit:4.13", "maven", "junit-4.13.jar"), - ("Gradle: org.slf4j:slf4j-api:2.0.9", "gradle", "slf4j-api-2.0.9-sources.jar"), + ("Gradle: org.slf4j:slf4j-api:2.0.9", "gradle", "slf4j-api-2.0.9.jar"), ] ); - assert_eq!(groups[0].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&guava_src))); + assert_eq!(groups[0].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&guava))); assert_eq!(groups[1].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&junit))); - assert_eq!(groups[2].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&slf4j_src))); + assert_eq!(groups[2].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&slf4j))); // A Gradle build also finds jars installed in the Maven local repository (`mavenLocal()`). let groups = dependency_libraries(&[dep(true, "junit", "junit", "4.13")], Some(&m2), Some(&gradle)); assert_eq!(groups[0].name, "Gradle: junit:junit:4.13"); diff --git a/crates/ide-tools/src/formatter.rs b/crates/ide-tools/src/formatter.rs new file mode 100644 index 000000000..9b599f0bb --- /dev/null +++ b/crates/ide-tools/src/formatter.rs @@ -0,0 +1,174 @@ +//! External formatting: resolve a tool, pass unsaved text on stdin, return text without writing files. + +use crate::Tools; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use ide_language::FormatterConfig; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +/// Walk from the file's directory upward, including workspace parents (monorepos). +pub fn find_up(file: &Path, relative: &Path) -> Option { + file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file()) +} + +fn package_bin(package: &Path) -> IdeResult { + let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?) + .map_err(|e| IdeError::invalid(format!("Prettier: {e}")))?; + let bin = json["bin"] + .as_str() + .or_else(|| json["bin"]["prettier"].as_str()) + .ok_or_else(|| IdeError::invalid("Prettier: package has no CLI entry point"))?; + let path = package.join(bin); + if !path.is_file() { + return Err(IdeError::unavailable(format!("Prettier: CLI does not exist: {}", path.display()))); + } + Ok(path) +} + +impl Tools { + /// Explicit executable/script, then the nearest project package, then managed tools and PATH. + pub fn formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult { + self.resolve_formatter_command(tool, file, custom).map_err(|e| e.with_tool(tool)) + } + + fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult { + if tool != "prettier" { + return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}"))); + } + let (path, node) = if let Some(custom) = custom.filter(|p| p.is_dir()) { + (package_bin(custom)?, true) + } else if let Some(custom) = custom { + (custom.to_path_buf(), matches!(custom.extension().and_then(|s| s.to_str()), Some("js" | "cjs" | "mjs"))) + } else if let Some(manifest) = find_up(file, Path::new("node_modules/prettier/package.json")) { + (package_bin(manifest.parent().unwrap())?, true) + } else if self.root.join("node_modules/prettier/package.json").is_file() { + (package_bin(&self.root.join("node_modules/prettier"))?, true) + } else { + (self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false) + }; + if node { + let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?; + let mut cmd = self.command(node); + cmd.arg(path); + Ok(cmd) + } else { + Ok(self.command(path)) + } + } +} + +/// Project configuration overrides these defaults; Prettier resolves config and plugins itself. +pub fn arguments( + config: &FormatterConfig, + file: &Path, + tab_size: u32, + insert_spaces: bool, + range: Option<(u32, u32)>, +) -> Vec { + let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") { + "mdx" + } else { + &config.parser + }; + let mut args: Vec = [ + "--parser", + parser, + "--config-precedence", + "file-override", + "--tab-width", + &tab_size.max(1).to_string(), + "--use-tabs", + if insert_spaces { "false" } else { "true" }, + ] + .into_iter() + .map(Into::into) + .collect(); + args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]); + if config.range + && let Some((start, end)) = range + { + args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]); + } + // CLI defaults only find ignores in cwd; explicitly pass the nearest ancestor's files. + for name in [".prettierignore", ".gitignore"] { + if let Some(path) = find_up(file, Path::new(name)) { + args.extend(["--ignore-path".into(), path.into_os_string()]); + } + } + args +} + +pub fn run(mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult { + crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool("prettier")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_formatter_is_structured_but_invalid_custom_path_is_not_an_install_prompt() { + use ide_core::error::{ErrorData, ErrorKind}; + let dir = tempfile::tempdir().unwrap(); + let tools = Tools::with_paths(dir.path().join("tools"), OsString::new(), crate::platform::Platform::current()); + let file = dir.path().join("file.md"); + let missing = tools.formatter_command("prettier", &file, None).unwrap_err(); + assert_eq!(missing.kind, ErrorKind::NotFound); + assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true })); + let custom = tools.formatter_command("prettier", &file, Some(&dir.path().join("absent-prettier"))).unwrap(); + let failure = run(custom, "text", &Default::default()).unwrap_err(); + assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false })); + } + + #[cfg(unix)] + #[test] + fn stderr_is_preserved_verbatim_even_when_longer_than_four_thousand_characters() { + let mut command = Command::new("sh"); + command.args(["-c", "i=0; while [ $i -lt 1000 ]; do printf 'error\\n' >&2; i=$((i+1)); done; exit 1"]); + let failure = run(command, "", &Default::default()).unwrap_err(); + assert_eq!(failure.message, "error\n".repeat(1000)); + assert_eq!(failure.data, Some(ide_core::error::ErrorData::Tool { tool: "prettier".into(), missing: false })); + } + + #[test] + fn discovers_nearest_package_and_ignore_above_nested_file() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("packages/web/src")).unwrap(); + std::fs::write(root.join(".prettierignore"), "ignored.md").unwrap(); + std::fs::create_dir_all(root.join("node_modules/prettier")).unwrap(); + std::fs::write(root.join("node_modules/prettier/package.json"), "{}").unwrap(); + let file = root.join("packages/web/src/readme.md"); + assert_eq!(find_up(&file, Path::new(".prettierignore")), Some(root.join(".prettierignore"))); + std::fs::write(root.join("packages/web/.prettierignore"), "src/ignored.md").unwrap(); + assert_eq!(find_up(&file, Path::new(".prettierignore")), Some(root.join("packages/web/.prettierignore"))); + assert_eq!( + find_up(&file, Path::new("node_modules/prettier/package.json")), + Some(root.join("node_modules/prettier/package.json")) + ); + } + + #[test] + fn arguments_use_stdin_config_precedence_and_only_supported_ranges() { + for (parser, supports_range) in [ + ("markdown", false), + ("css", false), + ("yaml", false), + ("typescript", true), + ("graphql", true), + ("vue", true), + ] { + let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range }; + let args = arguments(&config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9))); + let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect(); + assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"])); + assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"])); + assert!(args.windows(2).any(|a| a == ["--tab-width", "4"])); + assert!(args.windows(2).any(|a| a == ["--use-tabs", "true"])); + assert_eq!(args.contains(&"--range-start".into()), supports_range); + assert!(!args.contains(&"--write".into())); + } + } +} diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 51272bf6c..1822367fe 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -11,6 +11,7 @@ pub mod archive; mod bundled; +pub mod formatter; pub mod jdk; pub mod platform; mod process; @@ -323,6 +324,9 @@ fn usages(langs: &[LanguageConfig]) -> BTreeMap { } }; for l in langs { + if let Some(f) = &l.formatter { + add(&f.tool, &f.tool, ToolKind::Formatter, None, &l.name); + } for s in &l.language_servers { add(&s.name, &s.command, ToolKind::LanguageServer, s.install_hint.as_ref(), &l.name); } diff --git a/crates/ide-tools/src/process.rs b/crates/ide-tools/src/process.rs index cd3e15788..11bc37b45 100644 --- a/crates/ide-tools/src/process.rs +++ b/crates/ide-tools/src/process.rs @@ -348,6 +348,89 @@ pub fn progress_event(bytes: u64, size: Option) -> InstallEvent { } } +/// Runs a formatter with separate pipes so diagnostics can never become document text. +pub(crate) fn run_input( + cmd: &mut Command, + input: &str, + timeout: Duration, + cancel: &CancellationToken, +) -> IdeResult { + use std::io::Write; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let mut child = own_group(cmd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?; + let mut stdin = child.stdin.take().unwrap(); + let input = input.to_owned(); + // All pipes drain concurrently, including stdin: large documents cannot deadlock or bypass the timeout. + enum Pipe { + Written(std::io::Result<()>), + Read(bool, std::io::Result>), + } + let (tx, rx) = mpsc::channel(); + let writer = tx.clone(); + std::thread::spawn(move || { + let _ = writer.send(Pipe::Written(stdin.write_all(input.as_bytes()))); + }); + for (is_stderr, mut pipe) in [ + (false, Box::new(child.stdout.take().unwrap()) as Box), + (true, Box::new(child.stderr.take().unwrap()) as Box), + ] { + let tx = tx.clone(); + std::thread::spawn(move || { + let mut bytes = Vec::new(); + let _ = tx.send(Pipe::Read(is_stderr, pipe.read_to_end(&mut bytes).map(|_| bytes))); + }); + } + drop(tx); + let started = Instant::now(); + let (mut status, mut output, mut errors, mut written) = (None, None, None, None); + let result = loop { + if cancel.is_cancelled() { + break Err(IdeError::cancelled()); + } + if started.elapsed() >= timeout { + break Err(IdeError::new(ErrorKind::TimedOut, "Formatting timed out")); + } + if status.is_none() { + match child.try_wait() { + Ok(s) => status = s, + Err(e) => break Err(e.into()), + } + } + if status.is_some() && output.is_some() && errors.is_some() && written.is_some() { + break Ok(()); + } + match rx.recv_timeout(Duration::from_millis(10)) { + Ok(Pipe::Written(result)) => written = Some(result), + Ok(Pipe::Read(true, result)) => errors = Some(result), + Ok(Pipe::Read(false, result)) => output = Some(result), + Err(mpsc::RecvTimeoutError::Disconnected) if output.is_some() && errors.is_some() && written.is_some() => { + std::thread::sleep(Duration::from_millis(10)); + } + Err(mpsc::RecvTimeoutError::Disconnected) => break Err(IdeError::internal("formatter pipe reader failed")), + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + }; + if result.is_err() { + kill(&mut child); + } + result?; + let output = output.unwrap()?; + let errors = errors.unwrap()?; + let status = status.unwrap(); + if !status.success() { + let diagnostics: String = String::from_utf8_lossy(&errors).into_owned(); + return Err(IdeError::invalid(if diagnostics.trim().is_empty() { + format!("Formatter exited with {status}") + } else { + diagnostics + })); + } + written.unwrap()?; + String::from_utf8(output).map_err(|e| IdeError::invalid(e.to_string())) +} + #[cfg(test)] mod tests { use super::*; @@ -464,3 +547,37 @@ mod tests { assert_eq!(run_quiet(cmd, Duration::from_secs(5)), Some((true, "v1.2.3\n".into()))); } } + +#[cfg(all(test, unix))] +mod input_tests { + use super::*; + + #[test] + fn separate_diagnostics_and_never_return_failed_output() { + let mut command = Command::new("sh"); + command.args(["-c", "cat; printf warning >&2"]); + assert_eq!(run_input(&mut command, "a😀\n", Duration::from_secs(2), &Default::default()).unwrap(), "a😀\n"); + let mut command = Command::new("sh"); + command.args(["-c", "printf broken; printf 'invalid syntax' >&2; exit 1"]); + let e = run_input(&mut command, "", Duration::from_secs(2), &Default::default()).unwrap_err(); + assert_eq!(e.message, "invalid syntax"); + } + + #[test] + fn timeout_kills_formatter_and_cancellation_prevents_start() { + let mut command = Command::new("sh"); + command.args(["-c", "sleep 10 &"]); + let start = Instant::now(); + assert_eq!( + run_input(&mut command, "", Duration::from_millis(50), &Default::default()).unwrap_err().kind, + ErrorKind::TimedOut + ); + assert!(start.elapsed() < Duration::from_secs(2)); + let cancel = CancellationToken::new(); + cancel.cancel(); + assert_eq!( + run_input(&mut Command::new("does-not-exist"), "", Duration::from_secs(2), &cancel).unwrap_err().kind, + ErrorKind::Cancelled + ); + } +} diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index d23f6db3b..5c05cbe75 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -12,6 +12,7 @@ pub enum ToolKind { Debugger, /// Loaded into another tool's process, e.g. a jdtls agent. Plugin, + Formatter, } /// Where release archives come from. @@ -105,7 +106,7 @@ impl ToolSpec { use Arch::{Arm64, X64}; use Os::{Linux, Mac, Windows}; -use ToolKind::{Debugger, LanguageServer, Plugin}; +use ToolKind::{Debugger, Formatter, LanguageServer, Plugin}; const V: Option<(&str, &[&str])> = None; const JAVA: &[(&str, &str)] = &[("java", "Java is required (https://adoptium.net)")]; @@ -122,6 +123,17 @@ const fn ver(cmd: &'static str) -> Option<(&'static str, &'static [&'static str] pub const TYPESCRIPT: &str = "typescript@6"; pub static TOOLS: &[ToolSpec] = &[ + ToolSpec { + name: "prettier", + display: "Prettier", + kind: Formatter, + commands: &["prettier"], + method: Method::Npm { packages: &["prettier"] }, + fallback: None, + version: ver("prettier"), + needs: NODE, + homepage: "https://prettier.io", + }, ToolSpec { name: "rust-analyzer", display: "rust-analyzer", @@ -943,6 +955,11 @@ mod tests { known.insert((d.adapter.clone(), d.command.clone())); } } + for l in &langs { + if let Some(f) = &l.formatter { + known.insert((f.tool.clone(), f.tool.clone())); + } + } for t in TOOLS.iter().filter(|t| t.kind != Plugin) { assert!( known.contains(&(t.name.to_string(), t.commands[0].to_string())), diff --git a/crates/ide-tools/tests/prettier.rs b/crates/ide-tools/tests/prettier.rs new file mode 100644 index 000000000..3d622caac --- /dev/null +++ b/crates/ide-tools/tests/prettier.rs @@ -0,0 +1,91 @@ +//! `cargo test -p ide-tools --test prettier -- --ignored --nocapture` +//! Installs into a temporary managed-tools directory; no repository dependency is added. +use ide_core::CancellationToken; +use ide_language::FormatterConfig; +use ide_tools::{Tools, formatter}; +use std::path::Path; + +fn format( + tools: &Tools, + path: &Path, + parser: &str, + text: &str, + range: Option<(u32, u32)>, +) -> ide_core::IdeResult { + let config = FormatterConfig { + tool: "prettier".into(), + parser: parser.into(), + range: matches!(parser, "typescript" | "json" | "graphql"), + }; + let mut cmd = tools.formatter_command("prettier", path, None)?; + cmd.current_dir(path.parent().unwrap()); + cmd.args(formatter::arguments(&config, path, 4, true, range)); + formatter::run(cmd, text, &CancellationToken::new()) +} + +#[test] +#[ignore = "requires Node.js/npm and network; installs real Prettier into a temporary managed directory"] +fn real_prettier_config_ignore_languages_ranges_and_errors() { + if std::env::var_os("OPENIDEA_TEST_OFFLINE").is_some() { + eprintln!("SKIP real Prettier: OPENIDEA_TEST_OFFLINE is set; npm network access is disabled"); + return; + } + let temp = tempfile::tempdir().unwrap(); + let tools = Tools::new(&temp.path().join("app-data")); + let info = tools.info("prettier", &[]).unwrap(); + if !info.can_install { + eprintln!("SKIP real Prettier: {}", info.unavailable_reason.unwrap_or_default()); + return; + } + tools.install("prettier", &[], &Default::default(), &|_| {}).expect( + "Prettier managed install failed; offline environments may set OPENIDEA_TEST_OFFLINE=1 (reported as SKIP)", + ); + let project = temp.path().join("project with spaces"); + std::fs::create_dir_all(project.join("docs/nested")).unwrap(); + let md = project.join("docs/nested/readme.md"); + let input = "# Title\ntext\n\n| a | b |\n|---|---|\n|1|2|\n\n- a\n - b\n"; + let output = format(&tools, &md, "markdown", input, None).unwrap(); + assert!(output.starts_with("# Title\n\ntext\n"), "{output}"); + assert!(output.contains("| 1 | 2 |"), "{output}"); + assert!(!md.exists(), "formatting must never write the file"); + std::fs::write(project.join(".prettierrc"), r#"{"proseWrap":"always","printWidth":24,"tabWidth":2}"#).unwrap(); + let prose = + "This is a long paragraph with several words that should wrap according to the project configuration.\n"; + assert!(format(&tools, &md, "markdown", prose, None).unwrap().lines().count() > 2); + std::fs::write(project.join(".prettierignore"), "docs/nested/readme.md\n").unwrap(); + assert_eq!(format(&tools, &md, "markdown", input, None).unwrap(), input); + std::fs::remove_file(project.join(".prettierignore")).unwrap(); + std::fs::remove_file(project.join(".prettierrc")).unwrap(); + assert_eq!( + format(&tools, &project.join("a.css"), "css", "a{color:red}", None).unwrap(), + "a {\n color: red;\n}\n" + ); + std::fs::write(project.join("package.json"), r#"{"prettier":{"tabWidth":3,"proseWrap":"never"}}"#).unwrap(); + assert_eq!( + format(&tools, &project.join("a.css"), "css", "a{color:red}", None).unwrap(), + "a {\n color: red;\n}\n" + ); + assert_eq!(format(&tools, &project.join("a.yaml"), "yaml", "a: [1,2]\n", None).unwrap(), "a: [1, 2]\n"); + let js = "const a={x:1};\nconst b={y:2};\n"; + let formatted = format(&tools, &project.join("a.ts"), "typescript", js, Some((0, 14))).unwrap(); + assert!(formatted.starts_with("const a = { x: 1 };"), "{formatted}"); + assert!(formatted.contains("const b={y:2};"), "{formatted}"); + assert!(format(&tools, &project.join("bad.css"), "css", "a {", None).is_err()); + // Nearest project version must win over the managed install, including npm's object-style bin. + let package = project.join("node_modules/prettier"); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write(package.join("package.json"), r#"{"bin":{"prettier":"cli.cjs"}}"#).unwrap(); + std::fs::write( + package.join("cli.cjs"), + "process.stdin.resume(); process.stdin.on('end', () => process.stdout.write('project version\\n')); ", + ) + .unwrap(); + assert_eq!(format(&tools, &md, "markdown", input, None).unwrap(), "project version\n"); + std::fs::write(package.join("cli.cjs"), "setInterval(() => {}, 1000)").unwrap(); + let start = std::time::Instant::now(); + assert!(format(&tools, &md, "markdown", input, None).unwrap_err().message.contains("timed out")); + assert!(start.elapsed() < std::time::Duration::from_secs(20)); + eprintln!( + "PASS: real managed Prettier, config/ignore discovery, editor defaults, YAML/CSS/Markdown, ranges, errors, project precedence, timeout; no disk writes" + ); +} diff --git a/crates/ide-vcs/src/text_encoding.rs b/crates/ide-vcs/src/text_encoding.rs index 038256a95..768504b6d 100644 --- a/crates/ide-vcs/src/text_encoding.rs +++ b/crates/ide-vcs/src/text_encoding.rs @@ -3,7 +3,7 @@ use encoding_rs::{Encoding, UTF_8, UTF_16BE, UTF_16LE}; use ide_core::{IdeError, IdeResult}; use std::borrow::Cow; -/// File charset plus an optional reversible ASCII representation (for properties). +/// File charset plus an optional ASCII representation (for properties). /// The host supplies conversion functions so Git and documents share one codec. #[derive(Clone, Copy)] pub struct TextEncoding { diff --git a/crates/ide-vfs/src/compare.rs b/crates/ide-vfs/src/compare.rs new file mode 100644 index 000000000..8f29b1008 --- /dev/null +++ b/crates/ide-vfs/src/compare.rs @@ -0,0 +1,505 @@ +//! Disk comparisons use the same excludes and layered ignore files as the project tree. +use crate::{EntryKind, ExcludeMatcher, rules::GitignoreStack}; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::BTreeMap; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum DirectoryCompareStatus { + LeftOnly, + RightOnly, + Different, + Equal, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryCompareEntry { + pub path: String, + pub status: DirectoryCompareStatus, + pub left_kind: Option, + pub right_kind: Option, + #[specta(optional)] + pub left_link: Option, + #[specta(optional)] + pub right_link: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryCompareProgress { + pub scanned: u32, + pub compared: u32, + pub total: Option, + pub bytes_read: f64, + pub path: String, +} + +struct Entry { + kind: EntryKind, + size: u64, + link: Option, +} + +fn check(cancel: &CancellationToken) -> IdeResult<()> { + if cancel.is_cancelled() { Err(IdeError::cancelled()) } else { Ok(()) } +} + +struct Rules { + base: PathBuf, + excludes: ExcludeMatcher, + ignore: GitignoreStack, +} + +impl Rules { + fn new(root: &Path, project: Option<&Path>, excludes: &[String]) -> Self { + Self { + base: project.filter(|p| root.starts_with(p)).unwrap_or(root).to_path_buf(), + excludes: ExcludeMatcher::new(excludes), + ignore: GitignoreStack::new(root), + } + } + + fn excluded(&self, path: &Path, dir: bool) -> bool { + path.file_name().is_some_and(|n| n == ".git") + || self.excludes.is_excluded(path.strip_prefix(&self.base).unwrap_or(path)) + || self.ignore.is_ignored(path, dir) + } +} + +fn scan( + root: &Path, + rules: &Rules, + cancel: &CancellationToken, + progress: &mut DirectoryCompareProgress, + emit: &impl Fn(DirectoryCompareProgress), +) -> IdeResult> { + if !fs::metadata(root)?.is_dir() { + return Err(IdeError::invalid(format!("{} is not a directory", root.display()))); + } + let mut result = BTreeMap::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(dir) = pending.pop() { + check(cancel)?; + for item in fs::read_dir(dir)? { + check(cancel)?; + let path = item?.path(); + let meta = fs::symlink_metadata(&path)?; + if rules.excluded(&path, meta.is_dir()) { + continue; + } + let kind = if meta.file_type().is_symlink() { + EntryKind::Symlink + } else if meta.is_dir() { + pending.push(path.clone()); + EntryKind::Dir + } else if meta.is_file() { + EntryKind::File + } else { + continue; + }; + let rel = path.strip_prefix(root).map_err(|e| IdeError::internal(e.to_string()))?; + let rel = ide_core::ipc_path::to_ipc(rel); + let link = if kind == EntryKind::Symlink { Some(fs::read_link(&path)?) } else { None }; + result.insert(rel.clone(), Entry { kind, size: meta.len(), link }); + progress.scanned += 1; + progress.path = rel; + emit(progress.clone()); + } + } + Ok(result) +} + +fn hash( + path: &Path, + cancel: &CancellationToken, + progress: &mut DirectoryCompareProgress, + emit: &impl Fn(DirectoryCompareProgress), +) -> IdeResult { + let mut file = fs::File::open(path)?; + let mut hash = gix::hash::hasher(gix::hash::Kind::Sha256); + let mut buffer = [0; 64 * 1024]; + loop { + check(cancel)?; + let len = file.read(&mut buffer)?; + if len == 0 { + break; + } + hash.update(&buffer[..len]); + progress.bytes_read += len as f64; + emit(progress.clone()); + } + hash.try_finalize().map_err(|e| IdeError::internal(e.to_string())) +} + +/// Size checks avoid reading unequal files. Equal-sized files are hashed in bounded, +/// cancellable chunks; symlinks compare their link text and are never traversed. +pub fn compare_directories( + left: &Path, + right: &Path, + project: Option<&Path>, + excludes: &[String], + cancel: &CancellationToken, + emit: impl Fn(DirectoryCompareProgress), +) -> IdeResult> { + check(cancel)?; + let mut progress = DirectoryCompareProgress::default(); + emit(progress.clone()); + let l = scan(left, &Rules::new(left, project, excludes), cancel, &mut progress, &emit)?; + let r = scan(right, &Rules::new(right, project, excludes), cancel, &mut progress, &emit)?; + let mut paths: Vec<_> = l.keys().chain(r.keys()).cloned().collect(); + paths.sort(); + paths.dedup(); + progress.total = Some(paths.len() as u32); + let mut rows = BTreeMap::new(); + for path in paths { + check(cancel)?; + progress.path.clone_from(&path); + let status = match (l.get(&path), r.get(&path)) { + (Some(_), None) => DirectoryCompareStatus::LeftOnly, + (None, Some(_)) => DirectoryCompareStatus::RightOnly, + (Some(a), Some(b)) => { + let equal = a.kind == b.kind + && match a.kind { + EntryKind::Dir => true, + EntryKind::Symlink => a.link == b.link, + EntryKind::File => { + a.size == b.size + && hash(&left.join(&path), cancel, &mut progress, &emit)? + == hash(&right.join(&path), cancel, &mut progress, &emit)? + } + }; + if equal { DirectoryCompareStatus::Equal } else { DirectoryCompareStatus::Different } + } + _ => unreachable!(), + }; + rows.insert( + path.clone(), + DirectoryCompareEntry { + path: path.clone(), + status, + left_kind: l.get(&path).map(|e| e.kind), + right_kind: r.get(&path).map(|e| e.kind), + left_link: l.get(&path).and_then(|e| e.link.as_ref()).map(|p| p.to_string_lossy().into_owned()), + right_link: r.get(&path).and_then(|e| e.link.as_ref()).map(|p| p.to_string_lossy().into_owned()), + }, + ); + progress.compared += 1; + emit(progress.clone()); + } + let changed: Vec<_> = + rows.values().filter(|r| r.status != DirectoryCompareStatus::Equal).map(|r| r.path.clone()).collect(); + for path in changed { + for parent in Path::new(&path).ancestors().skip(1) { + if let Some(row) = rows.get_mut(&ide_core::ipc_path::to_ipc(parent)) + && row.status == DirectoryCompareStatus::Equal + { + row.status = DirectoryCompareStatus::Different; + } + } + } + check(cancel)?; + Ok(rows.into_values().collect()) +} + +/// Merges a subtree without following links inside the comparison roots. Existing files +/// require explicit overwrite; directory merges preserve destination-only entries. +pub fn copy_merge( + from: &Path, + to: &Path, + overwrite: bool, + project: Option<&Path>, + excludes: &[String], + roots: (&Path, &Path), +) -> IdeResult<()> { + fn resolve(path: &Path) -> IdeResult { + if path.exists() { + return Ok(dunce::canonicalize(path)?); + } + let parent = path.parent().ok_or_else(|| IdeError::invalid("copy requires an absolute path"))?; + Ok(resolve(parent)?.join(path.file_name().ok_or_else(|| IdeError::invalid("missing file name"))?)) + } + if !from.is_absolute() || !to.is_absolute() { + return Err(IdeError::invalid("copy requires absolute paths")); + } + if !from.starts_with(roots.0) || !to.starts_with(roots.1) { + return Err(IdeError::invalid("copy paths must belong to the comparison roots")); + } + let source = resolve(from)?; + let target = resolve(to)?; + if source.starts_with(&target) || target.starts_with(&source) { + return Err(IdeError::invalid("cannot copy overlapping paths")); + } + for parent in to.ancestors().skip(1) { + // The user explicitly selected this root; aliases above it (e.g. /tmp + // on macOS) are valid. Links inside the copied subtree are not followed. + if to == roots.1 || parent == roots.1 { + break; + } + if fs::symlink_metadata(parent).is_ok_and(|m| m.file_type().is_symlink()) { + return Err(IdeError::invalid("cannot copy through a destination symlink")); + } + } + let source_base = project.filter(|p| from.starts_with(p)).or(Some(roots.0)); + let target_base = project.filter(|p| to.starts_with(p)).or(Some(roots.1)); + let rules = Rules::new(from, source_base, excludes); + let target_rules = Rules::new(to, target_base, excludes); + let mut pending = vec![(from.to_path_buf(), to.to_path_buf())]; + while let Some((src, dst)) = pending.pop() { + let meta = fs::symlink_metadata(&src)?; + if rules.excluded(&src, meta.is_dir()) || target_rules.excluded(&dst, meta.is_dir()) { + continue; + } + let existing = fs::symlink_metadata(&dst).ok(); + let merge = meta.is_dir() && existing.as_ref().is_some_and(|m| m.is_dir()); + if existing.is_some() && !merge && !overwrite { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, dst.display().to_string()).into()); + } + if meta.is_dir() { + if existing.is_some() && !merge { + crate::delete(&dst)?; + } + fs::create_dir_all(&dst)?; + for entry in fs::read_dir(&src)? { + let entry = entry?; + pending.push((entry.path(), dst.join(entry.file_name()))); + } + } else { + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent)?; + } + // Stage the complete file before touching its destination. + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let temp = dst.with_file_name(format!(".openidea-copy-{}-{id}", std::process::id())); + let mut staged = false; + let result: IdeResult<()> = (|| { + #[cfg(windows)] + if meta.file_type().is_symlink() { + use std::os::windows::fs::{FileTypeExt, symlink_dir, symlink_file}; + let target = fs::read_link(&src)?; + if meta.file_type().is_symlink_dir() { + symlink_dir(target, &temp)?; + } else { + symlink_file(target, &temp)?; + } + } else { + crate::copy(&src, &temp)?; + } + #[cfg(not(windows))] + crate::copy(&src, &temp)?; + staged = true; + // Recheck after staging: another writer may have created the target. + if fs::symlink_metadata(&dst).is_ok() { + if !overwrite { + return Err( + std::io::Error::new(std::io::ErrorKind::AlreadyExists, dst.display().to_string()).into() + ); + } + crate::delete(&dst)?; + } + fs::rename(&temp, &dst)?; + Ok(()) + })(); + if let Err(error) = &result + && (staged || error.kind != ide_core::error::ErrorKind::AlreadyExists) + { + let _ = crate::delete_permanently(&temp); + } + result?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ide_core::error::ErrorKind; + + fn put(root: &Path, path: &str, content: &[u8]) { + let path = root.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + + #[test] + fn reports_add_delete_modify_equal_binary_unicode_empty_and_type_conflicts() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "子目录/仅左.txt", b"left"); + put(&right, "only-right", b"right"); + for (path, a, b) in [ + ("same", b"same".as_slice(), b"same".as_slice()), + ("binary", &[0, 255, 10], &[0, 254, 10]), + ("size", b"a", b"bb"), + ("conflict", b"file", b""), + ] { + put(&left, path, a); + if path == "conflict" { + put(&right, "conflict/child", b"child"); + } else { + put(&right, path, b); + } + } + fs::create_dir(left.join("empty")).unwrap(); + fs::create_dir(right.join("empty")).unwrap(); + let rows = compare_directories(&left, &right, None, &[], &CancellationToken::new(), |_| {}).unwrap(); + let status = |path| rows.iter().find(|r| r.path == path).unwrap().status; + assert_eq!(status("same"), DirectoryCompareStatus::Equal); + assert_eq!(status("empty"), DirectoryCompareStatus::Equal); + for path in ["binary", "size", "conflict"] { + assert_eq!(status(path), DirectoryCompareStatus::Different); + } + assert_eq!(status("子目录/仅左.txt"), DirectoryCompareStatus::LeftOnly); + assert_eq!(status("only-right"), DirectoryCompareStatus::RightOnly); + assert_eq!(status("conflict/child"), DirectoryCompareStatus::RightOnly); + } + + #[test] + fn applies_project_anchored_excludes_and_layered_ignores() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + for root in [&left, &right] { + put(root, "nested/node_modules/x", b"ignored"); + put(root, "target/x", b"ignored"); + put(root, ".git/config", b"ignored"); + put(root, "generated/x", b"ignored"); + put(root, ".gitignore", b"*.log\n"); + put(root, "nested/debug.log", b"ignored"); + put(root, "kept", b"kept"); + } + let excludes = ["**/node_modules", "**/target", "/left/generated", "/right/generated"].map(String::from); + let rows = + compare_directories(&left, &right, Some(tmp.path()), &excludes, &CancellationToken::new(), |_| {}).unwrap(); + assert!( + rows.iter() + .all(|r| !["node_modules", "target", ".git/", "generated", ".log"].iter().any(|p| r.path.contains(p))) + ); + assert!(rows.iter().any(|r| r.path == "kept")); + } + + #[test] + fn cancellation_stops_before_scan_during_scan_and_during_hashing() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "big", &vec![1; 1024 * 1024]); + put(&right, "big", &vec![1; 1024 * 1024]); + for phase in 0..3 { + let cancel = CancellationToken::new(); + if phase == 0 { + cancel.cancel(); + } + let result = compare_directories(&left, &right, None, &[], &cancel, |p| { + if phase == 1 && p.scanned > 0 || phase == 2 && p.bytes_read > 0.0 { + cancel.cancel(); + } + }); + assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled); + } + } + + #[test] + fn size_mismatches_are_not_hashed_and_directory_status_tracks_children() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "nested/file", b"a"); + put(&right, "nested/file", b"ab"); + let bytes = std::cell::Cell::new(0.0); + let rows = + compare_directories(&left, &right, None, &[], &CancellationToken::new(), |p| bytes.set(p.bytes_read)) + .unwrap(); + assert_eq!(bytes.get(), 0.0); + assert!(rows.iter().all(|r| r.status == DirectoryCompareStatus::Different)); + } + + #[test] + fn merges_copies_with_explicit_overwrite_and_keeps_destination_only_files() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "file", b"new"); + put(&left, "target/ignored", b"ignored"); + put(&right, "file", b"old"); + put(&right, "keep", b"keep"); + assert_eq!( + copy_merge(&left, &right, false, None, &["target".into()], (&left, &right)).unwrap_err().kind, + ErrorKind::AlreadyExists + ); + assert_eq!(fs::read(right.join("file")).unwrap(), b"old"); + copy_merge(&left, &right, true, None, &["target".into()], (&left, &right)).unwrap(); + assert_eq!(fs::read(right.join("file")).unwrap(), b"new"); + assert_eq!(fs::read(right.join("keep")).unwrap(), b"keep"); + assert!(!right.join("target").exists()); + for (from, to) in [(&left, left.clone()), (&left, left.join("child")), (&right, tmp.path().to_path_buf())] { + assert_eq!( + copy_merge(from, &to, true, None, &[], (tmp.path(), tmp.path())).unwrap_err().kind, + ErrorKind::InvalidInput + ); + } + } + + #[test] + fn subtree_copy_keeps_excludes_anchored_to_each_comparison_root() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "sub/build/keep", b"included below sub"); + put(&left, "sub/protected", b"do not overwrite"); + put(&right, "sub/protected", b"keep destination"); + put(&right, ".gitignore", b"sub/protected\n"); + copy_merge(&left.join("sub"), &right.join("sub"), true, None, &["/build".into()], (&left, &right)).unwrap(); + assert_eq!(fs::read(right.join("sub/build/keep")).unwrap(), b"included below sub"); + assert_eq!(fs::read(right.join("sub/protected")).unwrap(), b"keep destination"); + } + + #[test] + fn confirmed_copy_replaces_file_directory_type_conflicts() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "file", b"file"); + put(&right, "file/child", b"old directory"); + put(&left, "tree/child", b"new directory"); + put(&right, "tree", b"old file"); + for path in ["file", "tree"] { + copy_merge(&left.join(path), &right.join(path), true, None, &[], (&left, &right)).unwrap(); + } + assert_eq!(fs::read(right.join("file")).unwrap(), b"file"); + assert_eq!(fs::read(right.join("tree/child")).unwrap(), b"new directory"); + } + + #[cfg(unix)] + #[test] + fn symlinks_compare_link_text_and_are_not_followed_when_copying() { + use std::os::unix::fs::symlink; + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + fs::create_dir(&left).unwrap(); + fs::create_dir(&right).unwrap(); + symlink(".", left.join("loop")).unwrap(); + symlink("missing", right.join("loop")).unwrap(); + let rows = compare_directories(&left, &right, None, &[], &CancellationToken::new(), |_| {}).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].status, DirectoryCompareStatus::Different); + copy_merge(&left.join("loop"), &right.join("copied"), false, None, &[], (&left, &right)).unwrap(); + assert_eq!(fs::read_link(right.join("copied")).unwrap(), PathBuf::from(".")); + symlink(&right, tmp.path().join("alias")).unwrap(); + assert_eq!( + copy_merge(&left.join("loop"), &tmp.path().join("alias/new"), true, None, &[], (tmp.path(), tmp.path())) + .unwrap_err() + .kind, + ErrorKind::InvalidInput + ); + } +} diff --git a/crates/ide-vfs/src/encoding.rs b/crates/ide-vfs/src/encoding.rs index 05504a1b2..92e0d999b 100644 --- a/crates/ide-vfs/src/encoding.rs +++ b/crates/ide-vfs/src/encoding.rs @@ -62,20 +62,19 @@ pub fn decode_properties_escapes(text: &str) -> String { } /// Serializes displayed properties text as ASCII, using uppercase UTF-16 units. +/// Existing ASCII Unicode escapes retain their spelling and meaning on disk. pub fn encode_properties_ascii(text: &str) -> String { use std::fmt::Write; let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(c) = rest.chars().next() { - let current = rest; rest = &rest[c.len_utf8()..]; if c == '\\' { if rest.starts_with('\\') { out.push_str("\\\\"); rest = &rest[1..]; - } else if unicode_character(current).is_some() || rest.chars().next().is_some_and(|next| !next.is_ascii()) { - // Keep a literal backslash from swallowing a generated escape, - // or becoming a Unicode escape on the next load. + } else if rest.chars().next().is_some_and(|next| !next.is_ascii()) { + // Keep a literal backslash from swallowing a generated escape. out.push_str("\\u005C"); } else { out.push(c); @@ -387,13 +386,32 @@ mod tests { for text in ["plain=ASCII\r\n", r"escaped=\\u0041", r"bad=\u12xz", r"bad=\uD800"] { assert_eq!(encode_properties_ascii(text), text); } - for text in ["你好😀", "\0", "\\中", "\\\\中", "\\\\\\😀", r"\u0041", r"\\u0041", r"\uD83D\uDE00"] { + for text in ["你好😀", "\0", "\\中", "\\\\中", "\\\\\\😀", r"\\u0041"] { let ascii = encode_properties_ascii(text); assert!(ascii.is_ascii()); assert_eq!(decode_properties_escapes(&ascii), text, "{ascii}"); } } + #[test] + fn properties_ascii_escapes_are_not_escaped_again() { + for text in [ + r"test.key2=\u4F60\u597D", + r"test.key3=\u4f60\u597d", + r"ascii=\u0041\u005C\u000A", + r"emoji=\uD83D\uDE00", + r"slashes=\\u4F60 \\\u4F60 \\\\u4F60", + r"bad=\u123 \u12xz \uu0041 \uD800 \uDC00", + ] { + assert_eq!(encode_properties_ascii(text), text, "{text}"); + } + let mixed = "raw=\\u4f60\\u597d; native=你好😀"; + let expected = r"raw=\u4f60\u597d; native=\u4F60\u597D\uD83D\uDE00"; + assert_eq!(encode_properties_ascii(mixed), expected); + assert_eq!(encode_properties_ascii(expected), expected); + assert_eq!(decode_properties_escapes(expected), "raw=你好; native=你好😀"); + } + #[test] fn properties_line_terminator_escapes_survive_an_edit() { let source = r"key=one\u000Atwo\u000Dthree"; diff --git a/crates/ide-vfs/src/lib.rs b/crates/ide-vfs/src/lib.rs index 55c28b71b..6ac24e21b 100644 --- a/crates/ide-vfs/src/lib.rs +++ b/crates/ide-vfs/src/lib.rs @@ -2,6 +2,7 @@ mod archive; mod bytes; +mod compare; mod encoding; mod ops; mod rules; @@ -20,6 +21,9 @@ pub use archive::{ read_archive_entry, split_archive_path, }; pub use bytes::{ByteRange, FileProbe, MAX_RANGE_LEN, base64, image_mime, probe, read_data_url, read_range}; +pub use compare::{ + DirectoryCompareEntry, DirectoryCompareProgress, DirectoryCompareStatus, compare_directories, copy_merge, +}; use encoding::SNIFF_LEN; pub use encoding::{ LARGE_FILE_THRESHOLD, MAX_OPEN_SIZE, READ_ONLY_THRESHOLD, decode_properties_escapes, encode, diff --git a/crates/ide-workspace/tests/documents.rs b/crates/ide-workspace/tests/documents.rs index 028617eb8..02370eed5 100644 --- a/crates/ide-workspace/tests/documents.rs +++ b/crates/ide-workspace/tests/documents.rs @@ -641,6 +641,57 @@ fn properties_conversion_uses_displayed_utf16_and_survives_reload() { assert!(!f.docs.is_dirty(doc.id).unwrap()); } +#[test] +fn properties_pasted_escapes_survive_save_and_decode_on_reload() { + let pasted = "test.key1=Hello\ntest.key2=\\u4F60\\u597D\ntest.key3=\\u4f60\\u597d\n"; + let displayed = "test.key1=Hello\ntest.key2=你好\ntest.key3=你好\n"; + for encoding in ["utf-8", "iso-8859-1", "utf-16le"] { + for eol in ["\n", "\r\n"] { + let f = fixture(); + let path = f.root.join("test_zh_CN.properties"); + let source = pasted.replace('\n', eol); + fs::write(&path, ide_vfs::encode(&source, encoding, encoding == "utf-16le").unwrap()).unwrap(); + let mut defaults = f.docs.file_defaults(); + defaults.native_to_ascii = true; + defaults.properties_fallback = encoding.into(); + f.docs.set_file_defaults(defaults.clone()); + let doc = f.docs.open(&path).unwrap(); + assert_eq!(doc.text, displayed); + + f.docs.replace_all(doc.id, pasted).unwrap(); + f.docs.save(doc.id).unwrap(); + assert_eq!(fs::read(&path).unwrap(), source.as_bytes(), "{encoding} {eol:?}"); + assert_eq!(f.docs.snapshot(doc.id).unwrap().text, pasted); + assert!(!f.docs.is_dirty(doc.id).unwrap()); + f.docs.on_disk_changed(std::slice::from_ref(&path)); + assert_eq!(f.docs.snapshot(doc.id).unwrap().text, pasted, "own saves do not reload"); + + f.docs.close(doc.id); + let reopened = f.docs.open(&path).unwrap(); + assert_eq!(reopened.text, displayed); + // An edit followed by its inverse preserves the original escape casing. + f.docs.replace_all(reopened.id, pasted).unwrap(); + f.docs.replace_all(reopened.id, displayed).unwrap(); + f.docs.save(reopened.id).unwrap(); + assert_eq!(fs::read(&path).unwrap(), source.as_bytes()); + + let external = format!("{source}test.key4=\\uD83D\\uDE00{eol}"); + fs::write(&path, &external).unwrap(); + f.docs.on_disk_changed(std::slice::from_ref(&path)); + assert_eq!(f.docs.snapshot(reopened.id).unwrap().text, format!("{displayed}test.key4=😀\n")); + defaults.native_to_ascii = false; + f.docs.set_file_defaults(defaults.clone()); + f.docs.reload_properties(); + assert_eq!(f.docs.snapshot(reopened.id).unwrap().text, external.replace("\r\n", "\n")); + defaults.native_to_ascii = true; + f.docs.set_file_defaults(defaults); + f.docs.reload_properties(); + assert_eq!(f.docs.snapshot(reopened.id).unwrap().text, format!("{displayed}test.key4=😀\n")); + assert_eq!(fs::read_to_string(&path).unwrap(), external); + } + } +} + #[test] fn properties_toggle_reloads_only_clean_properties_and_pins_dirty_codec() { let f = fixture(); diff --git a/languages/css.toml b/languages/css.toml index 81379d5b8..4f8ba1e11 100644 --- a/languages/css.toml +++ b/languages/css.toml @@ -1,5 +1,6 @@ id = "css" name = "CSS" +formatter = { tool = "prettier", parser = "css", range = false } extensions = ["css", "pcss", "postcss"] block-comment = ["/*", "*/"] indent = { tab-size = 2, insert-spaces = true } diff --git a/languages/graphql.toml b/languages/graphql.toml index d3a7a9263..a26bb6acd 100644 --- a/languages/graphql.toml +++ b/languages/graphql.toml @@ -1,5 +1,6 @@ id = "graphql" name = "GraphQL" +formatter = { tool = "prettier", parser = "graphql", range = true } extensions = ["graphql", "graphqls", "gql"] line-comment = "#" indent = { tab-size = 2, insert-spaces = true } diff --git a/languages/html.toml b/languages/html.toml index 9da5321dd..908771a2d 100644 --- a/languages/html.toml +++ b/languages/html.toml @@ -1,5 +1,6 @@ id = "html" name = "HTML" +formatter = { tool = "prettier", parser = "html", range = false } extensions = ["html", "htm", "xhtml", "shtml", "html.tmpl"] first-line = "^\\s*"] diff --git a/languages/java.toml b/languages/java.toml index 7e2e87c7f..7e7837b08 100644 --- a/languages/java.toml +++ b/languages/java.toml @@ -32,7 +32,7 @@ resolveAdditionalTextEditsSupport = true [language-servers.initialization-options.settings.java] autobuild = { enabled = true } -contentProvider = { preferred = "fernflower" } +contentProvider = { preferred = "fernflowerContentProvider" } maven = { downloadSources = true, updateSnapshots = false } eclipse = { downloadSources = true } references = { includeDecompiledSources = true } diff --git a/languages/javascript.toml b/languages/javascript.toml index edbd83d89..b5676537e 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -1,5 +1,6 @@ id = "javascript" name = "JavaScript" +formatter = { tool = "prettier", parser = "babel", range = true } extensions = ["js", "mjs", "cjs"] first-line = "^#!.*\\b(node|nodejs|deno|bun)\\b" line-comment = "//" diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index f2864cdf3..3c5a17760 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -1,5 +1,6 @@ id = "javascriptreact" name = "JavaScript JSX" +formatter = { tool = "prettier", parser = "babel", range = true } extensions = ["jsx"] line-comment = "//" block-comment = ["/*", "*/"] diff --git a/languages/json.toml b/languages/json.toml index bad607263..f7010dffa 100644 --- a/languages/json.toml +++ b/languages/json.toml @@ -1,5 +1,6 @@ id = "json" name = "JSON" +formatter = { tool = "prettier", parser = "json", range = true } extensions = ["json", "geojson", "topojson", "webmanifest", "har", "avsc", "jsonl", "ndjson", "json.map"] filenames = [".prettierrc", ".watchmanconfig", "composer.lock", ".arcconfig"] indent = { tab-size = 2, insert-spaces = true } diff --git a/languages/jsonc.toml b/languages/jsonc.toml index 272f5c956..d90e5a655 100644 --- a/languages/jsonc.toml +++ b/languages/jsonc.toml @@ -1,5 +1,6 @@ id = "jsonc" name = "JSON with Comments" +formatter = { tool = "prettier", parser = "jsonc", range = true } extensions = ["jsonc", "code-workspace", "code-snippets"] filenames = ["tsconfig.json", "jsconfig.json", "tsconfig.*.json", "jsconfig.*.json", ".eslintrc", ".eslintrc.json", ".jshintrc", ".babelrc", ".swcrc", "devcontainer.json", ".devcontainer.json", "launch.json", "tasks.json", "settings.json", "extensions.json", "keybindings.json", "tslint.json", "api-extractor.json", "typedoc.json", "deno.json", "biome.json", "turbo.json", "language-configuration.json"] line-comment = "//" diff --git a/languages/less.toml b/languages/less.toml index 227590f9f..cfc834ee8 100644 --- a/languages/less.toml +++ b/languages/less.toml @@ -1,5 +1,6 @@ id = "less" name = "Less" +formatter = { tool = "prettier", parser = "less", range = false } extensions = ["less"] line-comment = "//" block-comment = ["/*", "*/"] diff --git a/languages/markdown.toml b/languages/markdown.toml index 9fec38354..675ac34f3 100644 --- a/languages/markdown.toml +++ b/languages/markdown.toml @@ -1,5 +1,6 @@ id = "markdown" name = "Markdown" +formatter = { tool = "prettier", parser = "markdown", range = false } extensions = ["md", "markdown", "mdown", "mkd", "mkdn", "mdwn", "mdx", "ronn", "workbook"] block-comment = [""] indent = { tab-size = 2, insert-spaces = true } diff --git a/languages/scss.toml b/languages/scss.toml index 1fd1d0d5a..dcdaadd4c 100644 --- a/languages/scss.toml +++ b/languages/scss.toml @@ -1,5 +1,6 @@ id = "scss" name = "SCSS" +formatter = { tool = "prettier", parser = "scss", range = false } extensions = ["scss"] line-comment = "//" block-comment = ["/*", "*/"] diff --git a/languages/typescript.toml b/languages/typescript.toml index e5f231bc5..41817c5c3 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -1,5 +1,6 @@ id = "typescript" name = "TypeScript" +formatter = { tool = "prettier", parser = "typescript", range = true } extensions = ["ts", "mts", "cts"] line-comment = "//" block-comment = ["/*", "*/"] diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index 0a5069c7a..ad238c6c9 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -1,5 +1,6 @@ id = "typescriptreact" name = "TypeScript JSX" +formatter = { tool = "prettier", parser = "typescript", range = true } extensions = ["tsx"] line-comment = "//" block-comment = ["/*", "*/"] diff --git a/languages/vue.toml b/languages/vue.toml index a7cdade09..e260bce72 100644 --- a/languages/vue.toml +++ b/languages/vue.toml @@ -1,5 +1,6 @@ id = "vue" name = "Vue" +formatter = { tool = "prettier", parser = "vue", range = true } extensions = ["vue"] block-comment = [""] indent = { tab-size = 2, insert-spaces = true } diff --git a/languages/yaml.toml b/languages/yaml.toml index 76d550671..17e64e1b2 100644 --- a/languages/yaml.toml +++ b/languages/yaml.toml @@ -1,5 +1,6 @@ id = "yaml" name = "YAML" +formatter = { tool = "prettier", parser = "yaml", range = false } extensions = ["yaml", "yml"] filenames = [".clang-format", ".clang-tidy", ".clangd", ".gemrc", "pnpm-lock.yaml", "CITATION.cff"] first-line = "^%YAML" diff --git a/package.json b/package.json index 9cf94c037..9fb5ec265 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "openidea", "private": true, "description": "An open-source IDE inspired by IntelliJ IDEA", - "version": "0.1.32", + "version": "0.1.33", "license": "Apache-2.0", "type": "module", "scripts": { diff --git a/plugins/kotlin-bridge/MANIFEST.MF b/plugins/kotlin-bridge/MANIFEST.MF new file mode 100644 index 000000000..f0d3eebaa --- /dev/null +++ b/plugins/kotlin-bridge/MANIFEST.MF @@ -0,0 +1,8 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Name: OpenIDEA Kotlin binary bridge +Bundle-SymbolicName: ai.openidea.kotlin;singleton:=true +Bundle-Version: 1.0.0 +Bundle-RequiredExecutionEnvironment: JavaSE-21 +Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core, + org.eclipse.core.runtime,org.eclipse.core.resources,com.google.gson diff --git a/plugins/kotlin-bridge/README.md b/plugins/kotlin-bridge/README.md new file mode 100644 index 000000000..a1eb4bf21 --- /dev/null +++ b/plugins/kotlin-bridge/README.md @@ -0,0 +1,73 @@ +# Kotlin binary bridge + +App-owned source, licensed under this repository's Apache-2.0 license. The jar +contains no third-party implementation classes. JDT LS and Maven supply their +respective APIs at runtime. + +`KotlinLibraries` is a JDT LS delegate command. `openidea.kotlin.replace` takes +one complete array of `{projectUri, libraries: [{path, test}]}` objects and returns +the number of changed projects. Paths name fully published, immutable directories. +An empty array withdraws all owned libraries. Only library entries carrying +`ai.openidea.kotlin=true` are removed; all other `IClasspathEntry` objects are +retained, including containers, access rules, attributes and project references. +A Java model listener restores the desired snapshot after Maven reimport. + +`IsolatedOutput` is a Java 8 Maven lifecycle participant, activated only in the +private editor build via `-Dmaven.ext.class.path=` and +`-Dopenidea.kotlin.staging=`. It redirects reactor output +before lifecycle planning and records Maven's resolved dependency closure after +each session, including failed builds. An execution listener records each module's +Kotlin compile/test-compile targets as successful, failed or skipped. The host uses +the project's Maven lifecycle/configuration, +then publishes only classes whose actual `SourceFile` attribute ends in `.kt`, +plus Kotlin module metadata. Java output is not bridged. Main/test Kotlin APIs and +same-reactor consumers are covered; Precise Kotlin +source navigation, unsaved analysis and refactoring are outside this bridge. + +The host debounces saved/source/configuration changes, cancels superseded process +groups, and retains the last successful snapshot while rebuilding. Maven runs +with `--fail-at-end`. A new snapshot is attached only when all Kotlin targets in +modules containing Kotlin sources succeed, even if javac fails. Java diagnostics +belong to JDT. Failed or skipped Kotlin targets retain the previous snapshot and +report errors under `kotlin-bridge` Problems and a notification explaining the +older API. Old directories are deleted only after JDT acknowledges replacement. +With no Kotlin sources left, all managed libraries and bridge diagnostics are +withdrawn, even when the POM still declares the Kotlin plugin. Extensionless +watcher paths trigger only below a Maven module's `src/`, outside generated and +user-excluded directories. Run/Debug preparation is independent of this editor +build. + +## Build + +Built against JDT LS 1.63.0 and Maven 3.9.11, using their installed API jars. +`build-dependencies.sha256` pins all build inputs. The build refuses different +inputs; `--record-dependencies` is an explicit maintainer action when updating +those dependencies. Compilation uses the JDT batch compiler with Java 21 target +for the JDT command and Java 8 target for the Maven participant. Use Java 25 to +run the build compiler. Archive order and timestamps are deterministic. + +```sh +python3 plugins/kotlin-bridge/build.py /path/to/jdtls /path/to/maven +shasum -a 256 plugins/kotlin-bridge/openidea-kotlin-bridge.jar +``` + +Committed jar SHA-256: +`5c6407a85bd3cfcde0ee68b2f223dd3d1ca0e5165ff76a2249191617496b1fcd`. + +The SDK embeds this jar with `include_bytes!`, writes it atomically into the app +data directory, and includes it in JDT LS initialization bundles. + +## Integration regression + +```sh +# `java` on PATH must satisfy JDT LS; JAVA_HOME selects Maven's project JDK. +JAVA_HOME=/path/to/project/jdk python3 plugins/kotlin-bridge/tests/verify.py /path/to/jdtls +``` + +Requires Maven on PATH and access to Kotlin 2.0.10 and the fixture's Maven +plugins (or their existing local cache). The test creates its own projects, +workspace, bundle copy and JDT process. It checks baseline errors, single/reactor +resolution, SMS/member completion, binary definition locations, preservation of +unowned entries, reimport, API changes, publication despite javac failure, +failed/skipped Kotlin target retention, deletion withdrawal and repair. Evidence +and protocol transcripts are written to a printed temporary directory. Only the spawned JDT process is terminated. diff --git a/plugins/kotlin-bridge/build-dependencies.sha256 b/plugins/kotlin-bridge/build-dependencies.sha256 new file mode 100644 index 000000000..23592d907 --- /dev/null +++ b/plugins/kotlin-bridge/build-dependencies.sha256 @@ -0,0 +1,161 @@ +beebede8db065fe1b72909ecc66bb49acda618901635d86c25fce14a5915e37e jdt/ch.qos.logback.classic_1.6.3.jar +a6967a28c8b086dee75a694d2f6fb8830c71153539866d7d8af065c9b6df91e0 jdt/ch.qos.logback.core_1.6.3.jar +2cbd119bf1961c28788310963dc80ba65f58cdeec1dd139c8bdb1240faa2c36f jdt/com.google.gson_2.14.0.jar +cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb jdt/com.google.guava.failureaccess_1.0.3.jar +796d8e28ac64e83a47c4c5935a8fecc4682650a04bbdead738ef0f5a3a0e6c46 jdt/com.google.guava_33.7.1.jre.jar +3b3864f5b449e9c3c24b16861524b622b086563f44e0cd8384c8efc5a6052f82 jdt/com.sun.jna.platform_5.19.1.jar +467b2990d4f406e7d41f7a989f89df257767ca11d566f844560e2ab8d09f6433 jdt/com.sun.jna_5.19.1.v20260612-1000.jar +85fb03fc054cdf4efca8efd9b6712bbb418e1ab98241c4539c8585bbc23e1b8a jdt/jakarta.annotation-api_1.3.5.jar +a852b0971c589491e24334bd77e92760c048d2c102fc4c68e582e1cd53130f84 jdt/jakarta.inject.jakarta.inject-api_1.0.5.jar +2506fb25a748d4a5e5ec549d8bb063889d0f213e9cfbd6fd6fcbdc93b4f35bbb jdt/jakarta.servlet-api_6.2.0.M2.jar +cbf2aa09c14441d2ddea7096f60b738b3aee7f08fbf98f5f91e2ca48b08fff4a jdt/org.apache.ant_1.10.18.v20260906-1000.jar +8f7f8605d68e15bf32db61ec94eac6fdafc51b1bdbe1e0e0802b57d23f387792 jdt/org.apache.commons.cli_1.11.0.jar +78a5d732fbd715e2d10bd7150d2f8030bae57267f8aacc5c88f642cb6c2e5d3f jdt/org.apache.commons.commons-codec_1.22.1.jar +69e5c9fa35da7a51a5fd2099dfe56a2d8d32cf233e2f6d770e796146440263f4 jdt/org.apache.commons.lang3_3.20.0.jar +c5bd887c8fd5ec4cd563249e7552bb1a1a1ca57e05a1f1ef8425a7b7b7bd2fbe jdt/org.apache.felix.scr_2.2.18.jar +c375b6d5b71efe179e5763a6d83fcf10e303c0bf82031310a46769727ac90da0 jdt/org.commonmark.ext-gfm-tables_0.30.1.jar +9b0d20064975a17e876fcb4b599c6b3667f46a7739ed61e2f06c96f44b50629f jdt/org.commonmark_0.30.1.jar +a6a16f8f2b6977a10f951ab4ff46b155e4e20dd8c2fcd2cb835b74d90f30b456 jdt/org.eclipse.ant.core_3.7.1100.v20260831-0701.jar +61c479b6544db7eeebb5ba796051c092032d03a5136295baabd72a233baef29c jdt/org.eclipse.buildship.compat_3.1.13.v20260923-0232-s.jar +213baab808816e0661acd61a5382a8f120923288212defbd189b4ee6540ecce1 jdt/org.eclipse.buildship.core_3.1.13.v20260923-0232-s.jar +e75b07863128a076989d15e815d63a49cab772a338f1f0444d007bef3fa94601 jdt/org.eclipse.compare.core_3.8.900.v20260731-0809.jar +01425989976dccd56d348b207e11239b3d8bf572f7b7ef12dbd29eb39c40205e jdt/org.eclipse.core.commands_3.13.100.v20260902-1129.jar +a63b488fb9d5382fbb3b8bae0951f1da5fd3a20211aa6814c7e8c1e21d2ffc78 jdt/org.eclipse.core.contenttype_3.9.1000.v20260921-1824.jar +06a18236ae181659e1e5499d912d68de527720686f1f51dab632d3e298e32b78 jdt/org.eclipse.core.expressions_3.9.700.v20260710-0925.jar +422a33041ea01954f48ff3969e2c6d96e64831b3e372202a4d2a914282e3ffd9 jdt/org.eclipse.core.filebuffers_3.9.0.v20261010-1449.jar +d0f5e3fb7cd17bb8daebc684311198138e380e089292477f12d2dfbb4c8a30a8 jdt/org.eclipse.core.filesystem_1.12.0.v20260927-0940.jar +941b3c5feeba4ec0a949a8a94ff10283b2505813fa736c9e6345f146ae1b7e8a jdt/org.eclipse.core.jobs_3.15.900.v20260604-0622.jar +6e7a8db7d87a44bb35924bb345ddf0d9372413e3fee2edd842b50ed630ec254f jdt/org.eclipse.core.net_1.5.900.v20260507-0401.jar +e22d47e2e68713a74bfacd134dc01b1aa904a1b429769241483e058de166acea jdt/org.eclipse.core.resources_3.25.0.v20261010-1447.jar +0947f29f5c5b7d234e3b735de2d00d33ae6e3ee428a61bb818f9c0fe2b809735 jdt/org.eclipse.core.runtime_3.35.100.v20260910-1515.jar +553f09a59f2662fa4a3bbc94e84bd83221c1c6fb532d81b57010162fb47460a6 jdt/org.eclipse.core.variables_3.6.900.v20260602-0702.jar +a4e87f5e883e6c864783dc8148e9f915c3e7a9e3e0e26acf6b82210d1134ed79 jdt/org.eclipse.debug.core_3.24.100.v20261009-1915.jar +fd353398835107608f554779a6e91891725efb94e0f57559d0ab3cea4b6156c7 jdt/org.eclipse.equinox.app_1.7.600.v20251211-1038.jar +28749ec64dac9d0e20e00becc3d5f9daccb5637455c9c546ab07be324011ef84 jdt/org.eclipse.equinox.common_3.21.100.v20260829-1451.jar +9beeb1098a3da89a470c547aa840dea0d240d144138a5ba7b9ceedb84ed301ab jdt/org.eclipse.equinox.frameworkadmin.equinox_1.3.500.v20260928-0506.jar +f2b956930b8383d708f1fe9df92938bffbe2e129082d4fd0b3d5f59c20a5c9f0 jdt/org.eclipse.equinox.frameworkadmin_2.3.600.v20260306-0554.jar +8e889f92718fb8c612dd358265da2c94738bbc08b46473b81cad36f1177a8235 jdt/org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.1700.v20260913-1737.jar +347674eb3d3d4000c184573618241141297fb9586c8f65ebdbacace59b8c81a2 jdt/org.eclipse.equinox.launcher.cocoa.macosx.x86_64_1.2.1700.v20260913-1737.jar +57e8a4b46906751e214dfa8fab1e02462c40ad4e51f2226d9ef70af5f0941ccb jdt/org.eclipse.equinox.launcher.gtk.linux.aarch64_1.2.1700.v20260913-1737.jar +d0e7418232627c53288e566728d24d1578650514152992b87f82a5d982429606 jdt/org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.1700.v20260913-1737.jar +aa8c31a082b25b87f75008548f6544379174c98091934fb8399e3f917a0445f1 jdt/org.eclipse.equinox.launcher.win32.win32.x86_64_1.3.200.v20260913-1737.jar +9b3b1da60813ac5fa09dd5c198f1884be7076d0a5daec54faa3f351a787e34b7 jdt/org.eclipse.equinox.launcher_1.8.0.v20260804-1928.jar +fd04e81a28ab32b6bb073286eb293c44b760176fcf1e35ef15614d62df820f86 jdt/org.eclipse.equinox.preferences_3.12.100.v20251111-0704.jar +9b149d3367d33fc1f9a19d3f87c425ecf9fc235ee748b3cf200ec387169b042b jdt/org.eclipse.equinox.registry_3.12.700.v20260830-1519.jar +60388d163f70329c39a9cf4e32389ec4fba8dc80ae5518d97cd0be2ebb971510 jdt/org.eclipse.equinox.security.linux_1.2.0.v20260914-1851.jar +a8408d4bcdc70e019d2d89ed8b1b0413eb74ea73e74fa3d064b7401643539797 jdt/org.eclipse.equinox.security.macosx_1.102.500.v20250521-0414.jar +cf9668c1d1e481059366abc2719468aa52e1276475cf9c32d4d10f57b34d7182 jdt/org.eclipse.equinox.security.win32_1.3.0.v20240419-2334.jar +91b74719f9c1a130193407808fe5daab3b92431efbf30b8f886f1ff5e97f468f jdt/org.eclipse.equinox.security_1.4.700.v20250622-1644.jar +1a24a3c0fb01ad71f97432d5c2b364d9868b5a5ebd99a1308aacb1a2c03c47da jdt/org.eclipse.equinox.simpleconfigurator.manipulator_2.3.700.v20260306-0556.jar +d250938d3715529a9822fcdf46ad2f5de39720558d7195b5c1a8713af00bcd13 jdt/org.eclipse.equinox.simpleconfigurator_1.5.700.v20251111-1031.jar +db9a4e856bb4728a2719df544b0f2ccef52b54ebf4e643a417fdb471f85703c1 jdt/org.eclipse.equinox.spi_1.0.0.v20261009-1945.jar +7615ee7abdd14695957a52864c57869018dc10ddaac80d3cd24b6a97e05d0d9f jdt/org.eclipse.jdt.apt.core_3.8.1000.v20261012-1009.jar +ea365808514a22c2e5a1a3fe0b9c07b88a371094d5d5716d31906a8818d56b2a jdt/org.eclipse.jdt.apt.pluggable.core_1.4.800.v20260930-1106.jar +f667bb3666d9875a2309fa2447a19e6cdd960281473bd7125a22c5a435246d21 jdt/org.eclipse.jdt.core.compiler.batch_3.46.200.v20261008-1115.jar +a6d0f8fabd64c248c37c463789a1a13f801252b68b0a4f4808c207677b5cae17 jdt/org.eclipse.jdt.core.javac_1.0.0.z20260430-1959.jar +8ea98fcbecbd89f311849e448f4c4274a2aba5e15cfcdcad29951330c40e0ee8 jdt/org.eclipse.jdt.core.manipulation_1.26.0.v20261009-1526.jar +70b4bb168aab096ee7f33da7b72f470f4172fe3e18e80289dd89cf344937ed22 jdt/org.eclipse.jdt.core_3.48.0.v20261007-1246.jar +12113df750bb22f7ef0dd3f044f7c334feecd55ddb56b9340529899c55f5893b jdt/org.eclipse.jdt.debug_3.27.0.v20261012-1250.jar +f140fad97dc5c452af0cb0f87ed3c6c83ff518f1dda49a43b8b9bec07f17d0fd jdt/org.eclipse.jdt.junit.core_3.16.0.v20261008-2002.jar +4ea3fe4a12a0e1e8b10b7ce3f577a0ff4f5115c6a5e40364c53b8b329a8505b0 jdt/org.eclipse.jdt.junit.runtime_3.8.200.v20260911-1643.jar +8b928263817f5dd0a9210a8842682903f779dc83253fea501591e150e5303095 jdt/org.eclipse.jdt.launching_3.25.0.v20261010-0421.jar +6e5e5e39d82e9ac04925e8b3f432ef6fe81284441e99952d2f720779c9e2831b jdt/org.eclipse.jdt.ls.core_1.63.0.202610191322.jar +666cc544f8a63e41d2dc8e6f3626071214027e4f698dea5bf36137f85477bba4 jdt/org.eclipse.jdt.ls.filesystem_1.63.0.202610191322.jar +792f7df214d4ab5e4915ac1805922d44de61ebb880f1fc4881ecd4ecfdca6aca jdt/org.eclipse.jdt.ls.logback.appender_1.63.0.202610191322.jar +9647feb0524bf763c878e12ab878a684102b81cccb3f77feecbec709d54f9bbb jdt/org.eclipse.lsp4j.jsonrpc_1.0.0.v20260209-1721.jar +ccd78893facc6bfcc359d56cba05d3d5b85eb41e4c40d4b4215ca45db5f416d9 jdt/org.eclipse.lsp4j_1.0.0.v20260209-1721.jar +a5d6bdc3e6f5017f19375697c7a5929fbc83f5734f35d5ed4540c7b2c843d4bb jdt/org.eclipse.ltk.core.refactoring_3.17.0.v20261010-1449.jar +69d1b41401686fd99337e1ddd8aeec309255ae464393ca19a8025c85bfae6601 jdt/org.eclipse.m2e.apt.core_2.3.200.20260815-0704.jar +34872f7712dffca0b39804708c0c1b58f2f2576e27cb7176b92a1ea437d384e6 jdt/org.eclipse.m2e.core_2.8.0.20260815-0704.jar +8c3fa36fe9f62ace897b0196df8e6bba6a50610c186f6689ada5f386a8b223b1 jdt/org.eclipse.m2e.jdt_2.5.300.20260813-1359.jar +5c5b419a101210127f21fa7d37bd2605b90f96efe9d3e91ea31099fb243dec8e jdt/org.eclipse.m2e.maven.runtime_3.9.1601.20260722-2149.jar +53376fa2d7bfd2d5166806e74af9576ee3f8f1c1ac916e2cb4d2bb0c3e72c02f jdt/org.eclipse.m2e.workspace.cli_0.4.0.jar +20e09b8f88845480b6ad0b89ac001679b6713592cd0ee4bd78cdb326172eee62 jdt/org.eclipse.osgi.compatibility.state_1.3.100.v20260909-1123.jar +fe66fd0b071bebc7e1a122be571ed8ecde373d2966fa6b1e161a899b3c7a054d jdt/org.eclipse.osgi_3.25.0.v20261008-2211.jar +2714e0c7548a0c23ab99d4e84b68d3e67419bbd6b295efda15d881e7cb173a1c jdt/org.eclipse.search.core_3.17.0.v20261010-1449.jar +0ca3b552c867619285b8ae36c7defdd6c0f013e692643d433fd111796da55f19 jdt/org.eclipse.text_3.14.900.v20261012-1310.jar +c6c2b8593cd39c279e74e7055cbd146d39dc6f16011961b4928b92728d32d00d jdt/org.eclipse.xtext.xbase.lib_2.44.0.v20260824-1228.jar +ea7a53f8b7966039c6f7d54f462cbbaaad38a5e313a4e7d9125fa39640988725 jdt/org.gradle.toolingapi_9.7.1.v20260923-0232-s.jar +5d66b6a4a680755cb6ed7cb104fa7835ef644667586ff0737adeb977c39ecdbc jdt/org.hamcrest_3.0.0.jar +e9d8c856856680427f096d156f02a414289985c930998fcb519ce5be39b42003 jdt/org.jsoup_1.23.2.jar +3efac657139b0b8024748e937e3e1dba71fbdf69fb526c60f504a3fb5490381b jdt/org.junit_4.13.2.v20240929-1000.jar +ed825d10ab1399c8c0cb669e688cf0c8c82629b4c8399b58352b68e92ca10fcb jdt/org.objectweb.asm_9.10.1.jar +0653852deab5c6f6e9c84e3aa8f72e72b9ce7dfaf7a6431f3d80f6f9d7a433ad jdt/org.osgi.service.component_1.5.1.202212101352.jar +43c7c870710e363405d422da653cce0d798a4537f76e4930f79bceadd3a55345 jdt/org.osgi.service.prefs_1.1.2.202109301733.jar +208819c7c71690c15a6bb8b187474e7f9d0147946b680182a62b9f222ae014ec jdt/org.osgi.util.function_1.2.0.202109301733.jar +7053c57e7d7d88fec6b90979a3af125e1d2bb847268a328a2f1ed65ad0a4c185 jdt/org.osgi.util.promise_1.3.0.202212101352.jar +7e1446499b359675d8aabe6af2de86b85e72ae8a2a932c0b40d0e5ba57097439 jdt/slf4j.api_2.0.20.jar +3970dab20b4deb8380ed9656392f289f3f3c2ee882d8c3942f1b5b486284620d jdt/wrapped.com.jetbrains.intellij.java.java-decompiler-engine_253.29346.240.jar +65d87386b7e926c0b6fb330a8d2eb6a987f5949634f7574731c0a3c2329d5629 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-emoji_0.64.8.jar +7a350f43f858da8f4a230552607ced119aeaf97484ca11ce4cd792e1e41e5bf7 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-gfm-strikethrough_0.64.8.jar +7a424dae7df0ee86200a08e163d9d1d8f63cd7a1a0c980b84ccbec029571fb4e jdt/wrapped.com.vladsch.flexmark.flexmark-ext-ins_0.64.8.jar +75a2e7168145f799a039832708aa0bc277a954c4a367f58711ccd6b9b7e7d04e jdt/wrapped.com.vladsch.flexmark.flexmark-ext-superscript_0.64.8.jar +dbe6355ea9e04731d534d3d4151870f83684c7a44c87d541a2c53ef5ee2c4fa4 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-tables_0.64.8.jar +91ae2f77e832d20607fad5b7f407d438ca3f16ac032b4f14956e4e7eb3a60931 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-wikilink_0.64.8.jar +0b650a4b47d29ffe0a08802c5d58e2270f733ce5443420c0e8904cfa1605964f jdt/wrapped.com.vladsch.flexmark.flexmark-html2md-converter_0.64.8.jar +74bab2d767f48f930471c9a72a0c3b89e9073549ad3754e6b7f83f09585463bc jdt/wrapped.com.vladsch.flexmark.flexmark-jira-converter_0.64.8.jar +1c7d205afeef618760a5fa907d579835df625ee258cbebd49813f14c2254a535 jdt/wrapped.com.vladsch.flexmark.flexmark-util-ast_0.64.8.jar +9179310212700753a4ecdc7aa057a04591b9cfdb089787a248e8269c34740310 jdt/wrapped.com.vladsch.flexmark.flexmark-util-builder_0.64.8.jar +796b9acec9af0fa290725a14be8a3fc1d6fe7a9cb518a13f67124b5172a3485b jdt/wrapped.com.vladsch.flexmark.flexmark-util-collection_0.64.8.jar +20458c20bc72808e1718ff8f1652272bbb32f62ddaecb2e9a0ddc84d77fd7283 jdt/wrapped.com.vladsch.flexmark.flexmark-util-data_0.64.8.jar +b59fbc922e3604442ff6aae1c6b83220245ba4c3f89d38870bc28c982b69d01d jdt/wrapped.com.vladsch.flexmark.flexmark-util-dependency_0.64.8.jar +b6073ee177aa2476d64787f9ec813fdb881943d402c195df0631ba672bfb1311 jdt/wrapped.com.vladsch.flexmark.flexmark-util-format_0.64.8.jar +c892e6cdbba715c55462decbe1c83cec432548cf2877cfd0715975c44dfab55c jdt/wrapped.com.vladsch.flexmark.flexmark-util-html_0.64.8.jar +1726c33ee95f77cfadc58c8ad27949ed72653f4045fbc399aff62827b44c7fcd jdt/wrapped.com.vladsch.flexmark.flexmark-util-misc_0.64.8.jar +cd0f9212530aa73f67de94d080018b2c3a3a55d41050d3abb4be8afb69989be0 jdt/wrapped.com.vladsch.flexmark.flexmark-util-options_0.64.8.jar +340167af15da00e3ae5fb89d6b44751abfd648951bb6d2e05c48287d9f9e70c9 jdt/wrapped.com.vladsch.flexmark.flexmark-util-sequence_0.64.8.jar +a8fad4a006411d020624c7539e6145e97920067844acddc10b019a178a680948 jdt/wrapped.com.vladsch.flexmark.flexmark-util-visitor_0.64.8.jar +792c13bc5536704fc0bfba9120e5bac712843a7ae6da6104b1392b382feaf63d jdt/wrapped.com.vladsch.flexmark.flexmark-util_0.64.8.jar +a2ec63056bcfe8c4babc62f13f5f49b502fccccf7c011b5d4ba7dd93f4d09393 jdt/wrapped.com.vladsch.flexmark.flexmark_0.64.8.jar +482b6aade7b0320e28db530a22f1382b3d30addf26a5ffd55a347f5c32f6dfc2 jdt/wrapped.org.jetbrains.annotations_24.0.1.jar +0addec670fedcd3f113c5c8091d783280d23f75e3acb841b61a9cdb079376a08 maven/aopalliance-1.0.jar +876eab6a83daecad5ca67eb9fcabb063c97b5aeb8cf1fca7a989ecde17522051 maven/asm-9.8.jar +d3d530d0f28fd0fbbffe2b0b338f70e8cb96f1605579e2e3abd4db29cac24e69 maven/commons-cli-1.9.0.jar +ba005f304cef92a3dede24a38ad5ac9b8afccf0d8f75839d6c1338634cf7f6e4 maven/commons-codec-1.18.0.jar +6661d5335090a5fc61dd869d2095bc6c1e2156e3aa47a6e4ababdf64c99a7889 maven/error_prone_annotations-2.38.0.jar +cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb maven/failureaccess-1.0.3.jar +94855942d4992f112946d3de1c334e709237b8126d8130bf07807c018a4a2120 maven/gson-2.13.1.jar +f3d7f57f67fd622f4d468dfdd692b3a5e3909246c28017ac3263405f0fe617ed maven/guava-33.4.8-jre.jar +142ad4475e19524d2fe3ac995b3f7cbc962fc726f2edb9dbdccc61feab9b2bf9 maven/guice-5.1.0-classes.jar +c8bc7e1c51a6d4ce72f40d2ebbabf1c4b68bfe76e732104b04381b493478e9d6 maven/httpclient-4.5.14.jar +6c9b3dd142a09dc468e23ad39aad6f75a0f2b85125104469f026e52a474e464f maven/httpcore-4.4.16.jar +0b7b8b003a90ea491579b62f5118828e45112914c65589b00faa49d6ec785839 maven/jansi-2.4.2.jar +e04ba5195bcd555dc95650f7cc614d151e4bcd52d29a10b8aa2197f3ab89ab9b maven/javax.annotation-api-1.3.2.jar +91c77044a50c481636c32d916fd89c9118a72195390452c81065080f957de7ff maven/javax.inject-1.jar +ab57ca8fd223772c17365d121f59e94ecbf0ae59d08c03a3cb5b81071c019195 maven/jcl-over-slf4j-1.7.36.jar +1fad6e6be7557781e4d33729d49ae1cdc8fdda6fe477bb0cc68ce351eafdfbab maven/jspecify-1.0.0.jar +0bfd1c121f907642344311d81e4dfd93eba6c24dc712cc0aeae273eb22833022 maven/maven-artifact-3.9.11.jar +5b1de1560ec19c4c69a655f986455888f3a0bcc3c160cac1f1edb87de3825f77 maven/maven-builder-support-3.9.11.jar +b0e2865fc808fd9fb3bee95bf37c0609eeb20c59bf1e6b958b37b365ee11f52e maven/maven-compat-3.9.11.jar +93d3c0523dda6c86c6620d9e692e7df39d0e2e4e4cd860061a8c0d274c2ad185 maven/maven-core-3.9.11.jar +7e15e05035331d0a7e0fe56d7354b2b30e15c0a8c60863cbf28df25190e197db maven/maven-embedder-3.9.11.jar +163aa596f976264384efeb50b4cb8a806086fd64a9b755ad1f69195fed3998ad maven/maven-model-3.9.11.jar +c4c72accdcd848332c1a37995610a6a369d49fdaabc43e85f0022a5b77de3ba7 maven/maven-model-builder-3.9.11.jar +8e985a56412564cf687040efed8bcd3a63eac2202a824bb01e1d5ece2b87458f maven/maven-plugin-api-3.9.11.jar +f8655f5f0060e0032944f2a23688d186e140d2184749bcfa1927f83cd6a8b1a2 maven/maven-repository-metadata-3.9.11.jar +c0ce14849298720dcf3b09fe9e263b3c576d2d6cd43c61f8aa9d6117e04e5e47 maven/maven-resolver-api-1.9.24.jar +18b7d557c41379db8b157ba95263c676f52c293f5416b67bd1db083e99b30383 maven/maven-resolver-connector-basic-1.9.24.jar +3cd88edda00c46242cdbe57aefb47703519d176d9e8a500333de15faf41b0e1d maven/maven-resolver-impl-1.9.24.jar +a222b0a5ca4c145e8e0f45207a23bcce18bdbe1f1c980e4e9fb332591ee8c91a maven/maven-resolver-named-locks-1.9.24.jar +b5d6e52ae1ecf1cb62ede2234234ba3093149524066488e84415187ce478c582 maven/maven-resolver-provider-3.9.11.jar +e3153906e93333c106ee18babb72ba7984ccb15e9116ea08f88c71b8d9387e01 maven/maven-resolver-spi-1.9.24.jar +b09268d41e1dfcae15a829d45a512ca29d62b727421d3bd6383ac6252e5e960f maven/maven-resolver-transport-file-1.9.24.jar +a0de339a8dd3473d9abaa1cf974507f38783e26af31d36a90c1e59cc8714ded0 maven/maven-resolver-transport-http-1.9.24.jar +78ed57fa2123a2d84fad7a0b15ca73b89df47c10294d02d6f8121c4d0e5050cb maven/maven-resolver-transport-wagon-1.9.24.jar +aeb7e776a8c47b1c317a2581f299e2525b044cb8abde31164e970bbbefab12b1 maven/maven-resolver-util-1.9.24.jar +026718d47d19e360d978857bc63d3dc065884fc2c801563d8a2a77b5ab74ccf8 maven/maven-settings-3.9.11.jar +33c96e4ca5a15b7c93180f6b2f8deb15f511dfc15dda0d2b671c968b22108972 maven/maven-settings-builder-3.9.11.jar +b613357e1bad4dfc1dead801691c9460f9585fe7c6b466bc25186212d7d18487 maven/maven-shared-utils-3.4.2.jar +76d9fa894fa61d1492877ca599cb5ab28ca4573d5f389d6c12a8233b995335f9 maven/maven-slf4j-provider-3.9.11.jar +1cbd7a965a5e2a9ea823bab311962a4e5aa5c240705bdbad5a52b40ffdfa1004 maven/org.eclipse.sisu.inject-0.9.0.M4.jar +b90579bc652eac7331436e0a25533fce14130b9c6e015f2dd3a3d4bb07e942b7 maven/org.eclipse.sisu.plexus-0.9.0.M4.jar +9a7f1b5c5a9effd61eadfd8731452a2f76a8e79111fac391ef75ea801bea203a maven/plexus-cipher-2.0.jar +50edb93c73786e62822b4fe1336e22880fdf147191373cf5c911370e16748fcf maven/plexus-component-annotations-2.2.0.jar +ab2a8715570438a2e4164d85ad3e8d489eabc38ea5093c2eb8ab7f58403535b5 maven/plexus-interpolation-1.28.jar +873139960c4c780176dda580b003a2c4bf82188bdce5bb99234e224ef7acfceb maven/plexus-sec-dispatcher-2.0.jar +27ef130e32c236090e408fb5498d94cb9ea26d14070fb1c8985d607b62d098d1 maven/plexus-utils-3.6.0.jar +d3ef575e3e4979678dc01bf1dcce51021493b4d11fb7f1be8ad982877c16a1c0 maven/slf4j-api-1.7.36.jar +afc9216fa97b78dad227b4a8d4d67b9897bf113a57f80598d62993841113e103 maven/wagon-file-3.5.3.jar +d2b6e48c9fcbe579e1858c622d14464011ff265fa6e28e794004a6882154a509 maven/wagon-http-3.5.3.jar +8e7da766f55164fde8779aaaa125832506c2848cab4876b5305138873e28037f maven/wagon-http-shared-3.5.3.jar +5e72000338945ed3e96f8e4f578d1d0672e1af7e19c0e9014197ae5b31af3ef4 maven/wagon-provider-api-3.5.3.jar diff --git a/plugins/kotlin-bridge/build.py b/plugins/kotlin-bridge/build.py new file mode 100644 index 000000000..538b749c3 --- /dev/null +++ b/plugins/kotlin-bridge/build.py @@ -0,0 +1,32 @@ +"""Build with JDT LS 1.63.0 + Maven 3.9.11. Checksums pin every compiler dependency.""" +import hashlib +import os +import pathlib +import subprocess +import sys +import tempfile +import zipfile + +root = pathlib.Path(__file__).resolve().parent +jdt, maven = map(lambda p: pathlib.Path(p).resolve(), sys.argv[1:3]) +jars = sorted((jdt / 'plugins').glob('*.jar')) + sorted((maven / 'lib').glob('*.jar')) +actual = ''.join(f'{hashlib.sha256(p.read_bytes()).hexdigest()} {"jdt/" if p.parent.name == "plugins" else "maven/"}{p.name}\n' for p in jars) +if '--record-dependencies' in sys.argv: + (root / 'build-dependencies.sha256').write_text(actual) +if (root / 'build-dependencies.sha256').read_text() != actual: + sys.exit('Build dependencies differ from build-dependencies.sha256') +with tempfile.TemporaryDirectory() as output: + for source, target, classpath in [('src', '-21', jars[:len(list((jdt / 'plugins').glob('*.jar')))]), ('maven', '-8', jars)]: + subprocess.run(['java', '-cp', str(jdt / 'plugins/*'), 'org.eclipse.jdt.internal.compiler.batch.Main', target, '-proc:none', '-classpath', os.pathsep.join(map(str, classpath)), '-d', output, *map(str, sorted((root/source).rglob('*.java')))], check=True) + files = {'META-INF/MANIFEST.MF': root/'MANIFEST.MF', 'plugin.xml': root/'plugin.xml', 'META-INF/plexus/components.xml': root/'components.xml'} + files.update({p.relative_to(output).as_posix(): p for p in pathlib.Path(output).rglob('*.class')}) + archive = pathlib.Path(output)/'bundle.jar' + with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED) as jar: + for name, path in sorted(files.items()): + data = path.read_bytes() + if name == 'META-INF/MANIFEST.MF': + data = data.rstrip(b'\r\n') + b'\n\n' + jar.writestr(zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0)), data) + staged = root/'openidea-kotlin-bridge.jar.pending' + staged.write_bytes(archive.read_bytes()) + staged.replace(root/'openidea-kotlin-bridge.jar') diff --git a/plugins/kotlin-bridge/components.xml b/plugins/kotlin-bridge/components.xml new file mode 100644 index 000000000..156cd7d36 --- /dev/null +++ b/plugins/kotlin-bridge/components.xml @@ -0,0 +1,5 @@ + + org.apache.maven.AbstractMavenLifecycleParticipant + openidea-kotlin-output + ai.openidea.kotlin.IsolatedOutput + diff --git a/plugins/kotlin-bridge/maven/ai/openidea/kotlin/IsolatedOutput.java b/plugins/kotlin-bridge/maven/ai/openidea/kotlin/IsolatedOutput.java new file mode 100644 index 000000000..8da379947 --- /dev/null +++ b/plugins/kotlin-bridge/maven/ai/openidea/kotlin/IsolatedOutput.java @@ -0,0 +1,177 @@ +package ai.openidea.kotlin; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import org.apache.maven.AbstractMavenLifecycleParticipant; +import org.apache.maven.artifact.Artifact; +import org.apache.maven.MavenExecutionException; +import org.apache.maven.execution.*; +import java.lang.reflect.*; +import org.apache.maven.model.*; +import org.apache.maven.project.MavenProject; +import org.codehaus.plexus.util.xml.Xpp3Dom; + +/** Loaded only by the private bridge build, before lifecycle plans are calculated. */ +public final class IsolatedOutput extends AbstractMavenLifecycleParticipant { + private final Map outputs = new LinkedHashMap<>(); + private File staging; + private final Map> kotlin = new LinkedHashMap<>(); + private final Set sources = new HashSet<>(); + + @Override public void afterProjectsRead(MavenSession session) throws MavenExecutionException { + String destination = session.getUserProperties().getProperty("openidea.kotlin.staging"); + if (destination == null) throw new MavenExecutionException("Missing Kotlin staging directory", (File) null); + staging = new File(destination); + int index = 0; + for (MavenProject project : session.getProjects()) { + File build = new File(staging, Integer.toString(index++)); + File main = new File(build, "classes"); + File test = new File(build, "test-classes"); + project.getBuild().setDirectory(build.getAbsolutePath()); + project.getBuild().setOutputDirectory(main.getAbsolutePath()); + project.getBuild().setTestOutputDirectory(test.getAbsolutePath()); + for (Plugin plugin : project.getBuildPlugins()) { + if ("kotlin-maven-plugin".equals(plugin.getArtifactId())) { + plugin.setConfiguration(redirect(plugin.getConfiguration(), build, main, test)); + for (PluginExecution execution : plugin.getExecutions()) + execution.setConfiguration(redirect(execution.getConfiguration(), build, main, test)); + } else if ("maven-compiler-plugin".equals(plugin.getArtifactId())) { + plugin.setConfiguration(compiler(plugin.getConfiguration())); + for (PluginExecution execution : plugin.getExecutions()) + execution.setConfiguration(compiler(execution.getConfiguration())); + } + } + outputs.put(project, main); + Map goals = new LinkedHashMap<>(); + for (Plugin plugin : project.getBuildPlugins()) { + if (!isKotlin(plugin)) continue; + for (PluginExecution execution : plugin.getExecutions()) { + if ("none".equals(execution.getPhase())) continue; + for (String goal : execution.getGoals()) + if ("compile".equals(goal) || "test-compile".equals(goal)) + goals.put(execution.getId() + ":" + goal, "skipped"); + } + } + kotlin.put(project, goals); + if (hasKotlin(project.getBasedir().toPath(), project.getBasedir().toPath(), session.getProjects())) + sources.add(project); + } + final ExecutionListener previous = session.getRequest().getExecutionListener(); + // Preserve Maven's logger and any listener installed by another extension. + session.getRequest().setExecutionListener((ExecutionListener) Proxy.newProxyInstance( + ExecutionListener.class.getClassLoader(), new Class[] { ExecutionListener.class }, + (proxy, method, args) -> { + if (args != null && args.length == 1 && args[0] instanceof ExecutionEvent) { + ExecutionEvent event = (ExecutionEvent) args[0]; + if (Arrays.asList("mojoStarted", "mojoSucceeded", "mojoFailed", "mojoSkipped").contains(method.getName()) + && event.getMojoExecution() != null && isKotlin(event.getMojoExecution().getPlugin())) { + String goal = event.getMojoExecution().getGoal(); + if ("compile".equals(goal) || "test-compile".equals(goal)) { + String status = method.getName().equals("mojoSucceeded") ? "success" : + method.getName().equals("mojoFailed") ? "failed" : "skipped"; + Map goals = kotlin.get(event.getProject()); + if (goals != null) goals.put(event.getMojoExecution().getExecutionId() + ":" + goal, status); + } + } + } + if (previous == null) return null; + try { return method.invoke(previous, args); } + catch (InvocationTargetException e) { throw e.getCause(); } + })); + } + + private static boolean isKotlin(Plugin plugin) { + return "org.jetbrains.kotlin".equals(plugin.getGroupId()) && "kotlin-maven-plugin".equals(plugin.getArtifactId()); + } + private static boolean hasKotlin(Path root, Path directory, List projects) throws MavenExecutionException { + if (!directory.equals(root)) { + String name = directory.getFileName().toString(); + if (Arrays.asList("target", "build", "out", "bin", ".git", "node_modules", ".openidea").contains(name)) return false; + for (MavenProject project : projects) if (project.getBasedir().toPath().equals(directory)) return false; + } + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) return false; + try (DirectoryStream entries = Files.newDirectoryStream(directory)) { + for (Path path : entries) { + if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && path.toString().endsWith(".kt")) return true; + if (hasKotlin(root, path, projects)) return true; + } + return false; + } catch (IOException e) { throw new MavenExecutionException("Cannot inspect Kotlin sources", e); } + } + + @Override public void afterSessionEnd(MavenSession session) throws MavenExecutionException { + if (staging == null) return; + List modules = new ArrayList<>(); + for (Map.Entry entry : outputs.entrySet()) { + MavenProject project = entry.getKey(); + File main = entry.getValue(); + List dependencies = new ArrayList<>(); + // Maven has applied dependency management, mediation, scopes, optional + // propagation and exclusions. Do not reconstruct that graph from POMs. + for (Artifact dependency : project.getArtifacts()) { + dependencies.add("{\"key\":" + quote(dependency.getGroupId() + ":" + dependency.getArtifactId() + ":" + dependency.getVersion()) + + ",\"scope\":" + quote(dependency.getScope() == null ? "compile" : dependency.getScope()) + + ",\"optional\":" + dependency.isOptional() + ",\"type\":" + quote(dependency.getType()) + + ",\"classifier\":" + quote(dependency.getClassifier()) + "}"); + } + List goals = new ArrayList<>(); + for (Map.Entry goal : kotlin.get(project).entrySet()) + goals.add("{\"execution\":" + quote(goal.getKey()) + ",\"status\":" + quote(goal.getValue()) + "}"); + modules.add("{\"key\":" + quote(project.getGroupId() + ":" + project.getArtifactId() + ":" + project.getVersion()) + + ",\"projectUri\":" + quote(project.getBasedir().toURI().toString()) + + ",\"output\":" + quote(main.getAbsolutePath()) + + ",\"testOutput\":" + quote(project.getBuild().getTestOutputDirectory()) + + ",\"hasKotlin\":" + sources.contains(project) + + ",\"kotlinGoals\":[" + String.join(",", goals) + "]" + + ",\"dependencies\":[" + String.join(",", dependencies) + "]}"); + } + try { + Files.createDirectories(staging.toPath()); + Files.write(new File(staging.getParentFile(), "modules.json").toPath(), + ("[" + String.join(",", modules) + "]").getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new MavenExecutionException("Cannot write Kotlin module manifest", e); + } + } + + private static Xpp3Dom config(Object value) { + return value instanceof Xpp3Dom ? new Xpp3Dom((Xpp3Dom) value) : new Xpp3Dom("configuration"); + } + private static void set(Xpp3Dom config, String name, File path) { + Xpp3Dom child = config.getChild(name); + if (child == null) { child = new Xpp3Dom(name); config.addChild(child); } + child.setValue(path.getAbsolutePath()); + } + private static Xpp3Dom redirect(Object value, File build, File main, File test) { + Xpp3Dom config = config(value); + set(config, "output", main); + set(config, "testOutput", test); + set(config, "incrementalCachesRoot", new File(build, "kotlin-ic")); + Xpp3Dom incremental = config.getChild("myIncremental"); + if (incremental == null) { incremental = new Xpp3Dom("myIncremental"); config.addChild(incremental); } + incremental.setValue("false"); + return config; + } + private static Xpp3Dom compiler(Object value) { + Xpp3Dom config = config(value); + // compile and testCompile share the parameter name outputDirectory but + // have different defaults. Let each mojo read the redirected build model. + for (int i = config.getChildCount() - 1; i >= 0; i--) { + String name = config.getChild(i).getName(); + if ("outputDirectory".equals(name) || "testOutputDirectory".equals(name)) config.removeChild(i); + } + return config; + } + private static String quote(String text) { + if (text == null) return "null"; + StringBuilder out = new StringBuilder("\""); + for (char c : text.toCharArray()) { + if (c == '\\' || c == '"') out.append('\\').append(c); + else if (c < 32) out.append(String.format("\\u%04x", (int) c)); + else out.append(c); + } + return out.append('"').toString(); + } +} diff --git a/plugins/kotlin-bridge/openidea-kotlin-bridge.jar b/plugins/kotlin-bridge/openidea-kotlin-bridge.jar new file mode 100644 index 000000000..8250780f3 Binary files /dev/null and b/plugins/kotlin-bridge/openidea-kotlin-bridge.jar differ diff --git a/plugins/kotlin-bridge/plugin.xml b/plugins/kotlin-bridge/plugin.xml new file mode 100644 index 000000000..a0ac23ef7 --- /dev/null +++ b/plugins/kotlin-bridge/plugin.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/plugins/kotlin-bridge/src/ai/openidea/kotlin/KotlinLibraries.java b/plugins/kotlin-bridge/src/ai/openidea/kotlin/KotlinLibraries.java new file mode 100644 index 000000000..8f92e7efd --- /dev/null +++ b/plugins/kotlin-bridge/src/ai/openidea/kotlin/KotlinLibraries.java @@ -0,0 +1,94 @@ +package ai.openidea.kotlin; + +import com.google.gson.Gson; +import java.io.File; +import java.net.URI; +import java.util.*; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.*; +import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.jdt.core.*; +import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler; + +/** Owns only marked binary entries; Maven and user entries retain their identities. */ +public final class KotlinLibraries implements IDelegateCommandHandler { + private static final String OWNER = "ai.openidea.kotlin"; + private static final Object LOCK = new Object(); + private static List desired = List.of(); + private static boolean listening; + private static final Job reconcileJob = new Job("Kotlin binary libraries") { + @Override protected IStatus run(IProgressMonitor monitor) { + synchronized (LOCK) { + try { + reconcile(monitor); + return Status.OK_STATUS; + } catch (CoreException e) { + return e.getStatus(); + } + } + } + }; + private static class Library { String path; boolean test; } + private static class ProjectLibraries { String projectUri; List libraries; } + + @Override public Object executeCommand(String command, List arguments, IProgressMonitor monitor) throws Exception { + if (!"openidea.kotlin.replace".equals(command) || arguments.size() != 1) + throw new IllegalArgumentException("Expected one Kotlin library snapshot"); + Gson gson = new Gson(); + ProjectLibraries[] snapshot = gson.fromJson(gson.toJson(arguments.get(0)), ProjectLibraries[].class); + for (ProjectLibraries project : snapshot) { + new File(URI.create(project.projectUri)); + for (Library library : project.libraries) { + if (!new File(library.path).isAbsolute() || !new File(library.path).isDirectory()) + throw new IllegalArgumentException("Kotlin library must be a published directory: " + library.path); + } + } + synchronized (LOCK) { + if (monitor.isCanceled()) throw new OperationCanceledException(); + desired = List.of(snapshot); + if (!listening) { + JavaCore.addElementChangedListener(event -> reconcileJob.schedule(200), ElementChangedEvent.POST_CHANGE); + listening = true; + } + return reconcile(monitor); + } + } + + private static File canonical(URI uri) { + File file = new File(uri); + try { return file.getCanonicalFile(); } + catch (java.io.IOException e) { return file.getAbsoluteFile(); } + } + + private static int reconcile(IProgressMonitor monitor) throws CoreException { + int changed = 0; + for (IJavaProject project : JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects()) { + if (!project.getProject().isAccessible()) continue; + List entries = new ArrayList<>(); + IClasspathEntry[] original = project.getRawClasspath(); + for (IClasspathEntry entry : original) { + boolean owned = entry.getEntryKind() == IClasspathEntry.CPE_LIBRARY && + Arrays.stream(entry.getExtraAttributes()).anyMatch(a -> OWNER.equals(a.getName()) && "true".equals(a.getValue())); + if (!owned) entries.add(entry); + } + URI location = project.getProject().getLocationURI(); + for (ProjectLibraries item : desired) { + if (location == null || !canonical(location).equals(canonical(URI.create(item.projectUri)))) continue; + for (Library library : item.libraries) { + List attributes = new ArrayList<>(); + attributes.add(JavaCore.newClasspathAttribute(OWNER, "true")); + if (library.test) attributes.add(JavaCore.newClasspathAttribute(IClasspathAttribute.TEST, "true")); + entries.add(JavaCore.newLibraryEntry(Path.fromOSString(library.path), null, null, + new IAccessRule[0], attributes.toArray(IClasspathAttribute[]::new), false)); + } + } + IClasspathEntry[] updated = entries.toArray(IClasspathEntry[]::new); + if (!Arrays.equals(original, updated)) { + if (monitor.isCanceled()) throw new OperationCanceledException(); + project.setRawClasspath(updated, monitor); + changed++; + } + } + return changed; + } +} diff --git a/plugins/kotlin-bridge/tests/verify.py b/plugins/kotlin-bridge/tests/verify.py new file mode 100644 index 000000000..3d6c89862 --- /dev/null +++ b/plugins/kotlin-bridge/tests/verify.py @@ -0,0 +1,275 @@ +"""Real Maven + JDT LS regression: python3 verify.py JDT_HOME (Java 25; Maven uses JAVA_HOME).""" +import json +import os +import pathlib +import platform +import queue +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import xml.etree.ElementTree as ET + +home = pathlib.Path(sys.argv[1]).resolve() +bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-kotlin-bridge.jar' +work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-kotlin-bridge-', dir=os.environ.get('TMPDIR'))).resolve() +copied_bundle = work/'openidea-kotlin-bridge.jar' +shutil.copyfile(bundle, copied_bundle) +bundle = copied_bundle +projects = work / 'projects' +single = projects / 'single' +multi = projects / 'multi' +lib = multi / 'channel-lib' +app = multi / 'app' +for directory in [single, lib, app]: + (directory / 'src/main/java/probe').mkdir(parents=True) +plugin = '''org.jetbrains.kotlinkotlin-maven-plugin2.0.10kotlin-compileprocess-sourcescompile1.8src/main/javaorg.apache.maven.pluginsmaven-compiler-plugin3.13.0''' +plugin = plugin.replace('', 'kotlin-testprocess-test-sourcestest-compile1.8src/test/java') +stdlib = 'org.jetbrains.kotlinkotlin-stdlib2.0.10' +properties = '88' +prefix = '4.0.0' +(single / 'pom.xml').write_text(prefix + 'probesingle1' + properties + stdlib + plugin + '') +(multi / 'pom.xml').write_text(prefix + 'probereactor1pom' + properties + 'channel-libapp') +parent = 'probereactor1' +(lib / 'pom.xml').write_text(prefix + parent + 'channel-lib' + stdlib + plugin + '') +(app / 'pom.xml').write_text(prefix + parent + 'appprobechannel-lib1') +source = 'package probe;\npublic class Caller {\n public Channel channel() {\n return Channel.SMS;\n }\n}\n' +for directory in [single, app]: + (directory / 'src/main/java/probe/Caller.java').write_text(source) +for directory in [single, lib]: + (directory / 'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n') + +(single/'src/test/java/probe').mkdir(parents=True) +test_source = source.replace('Caller', 'TestCaller').replace('Channel', 'TestChannel') +(single/'src/test/java/probe/TestCaller.java').write_text(test_source) +(single/'src/test/java/probe/TestChannel.kt').write_text('package probe\nenum class TestChannel { SMS }\n') + +config = work / 'config' +config.mkdir() +base = {'Darwin': 'config_mac', 'Windows': 'config_win'}.get(platform.system(), 'config_linux') +names = [base + '_arm', base] if platform.machine().lower() in ('arm64', 'aarch64') else [base] +shutil.copyfile(next(home / n / 'config.ini' for n in names if (home/n/'config.ini').exists()), config/'config.ini') +proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home/'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work/'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=(work/'stderr.log').open('w')) +messages = queue.Queue() +lock = threading.Lock() +transcript = [] +seq = 0 +def send(message): + data = json.dumps({'jsonrpc': '2.0', **message}).encode() + with lock: + proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data) + proc.stdin.flush() +def read(): + while True: + headers = {} + while True: + line = proc.stdout.readline() + if not line: return + if line == b'\r\n': break + key, value = line.decode().split(':', 1) + headers[key.lower()] = value.strip() + message = json.loads(proc.stdout.read(int(headers['content-length']))) + if 'method' in message and 'id' in message: + send({'id':message['id'], 'result': [] if message['method']=='workspace/configuration' else None}) + else: + messages.put(message) +threading.Thread(target=read, daemon=True).start() +def request(method, params): + global seq + seq += 1 + send({'id':seq, 'method':method, 'params':params}) + deadline = time.monotonic() + 240 + while time.monotonic() < deadline: + message = messages.get(timeout=max(1, deadline-time.monotonic())) + transcript.append(message) + if message.get('id') == seq: + if 'error' in message: raise RuntimeError(message['error']) + return message.get('result') + raise TimeoutError(method) +def command(snapshot): + response = request('workspace/executeCommand', {'command':'openidea.kotlin.replace', 'arguments':[snapshot]}) + assert isinstance(response, (int, float)), response + return response +def build(directory, revision, expected=True): + staging = work / revision + result = subprocess.run(['mvn', '-B', '-fae', '-Dstyle.color=never', f'-Dmaven.ext.class.path={bundle}', f'-Dopenidea.kotlin.staging={staging}/staging', 'test-compile'], cwd=directory, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + (work/f'{revision}.log').write_bytes(result.stdout) + modules = json.loads((staging/'modules.json').read_text()) + eligible = all(m['kotlinGoals'] and all(g['status'] == 'success' for g in m['kotlinGoals']) for m in modules if m['hasKotlin']) + assert eligible == expected, (modules, result.stdout.decode()) + evidence[revision + '-build'] = {'exitCode':result.returncode, 'modules':modules, 'eligible':eligible} + return modules +def library(module, revision, test=False, name="Channel"): + directory = work/'published'/revision + directory.mkdir(parents=True) + output = module['testOutput' if test else 'output'] + for file in pathlib.Path(output).rglob('TestChannel*.class' if test else name+'*.class'): + destination = directory/file.relative_to(output) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(file, destination) + assert list(directory.rglob('*.class')), module + return {'path':str(directory), 'test':test} +def state(directory, label, expected, test=False): + file = directory/('src/test/java/probe/TestCaller.java' if test else 'src/main/java/probe/Caller.java') + deadline = time.monotonic() + 30 + while True: + time.sleep(0.25) + request('workspace/symbol', {'query':'Caller'}) + reports = [m['params'] for m in transcript if m.get('method')=='textDocument/publishDiagnostics' and m['params']['uri']==file.as_uri()] + errors = [d for d in reports[-1]['diagnostics'] if d.get('severity')==1] if reports else None + if errors is not None and ((errors == []) == expected): break + if time.monotonic() > deadline: raise AssertionError((label, errors)) + completion = request('textDocument/completion', {'textDocument':{'uri':file.as_uri()}, 'position':{'line':3,'character':27 if test else 23}}) + items = completion.get('items', []) if isinstance(completion, dict) else completion or [] + labels = [item['label'] for item in items] + definition = request('textDocument/definition', {'textDocument':{'uri':file.as_uri()}, 'position':{'line':2,'character':12}}) + result = {'errors':errors, 'completion':labels, 'definition':definition} + evidence[label] = result + assert errors is not None, (label, "no published diagnostics") + assert (errors == []) == expected, (label, errors) + assert any(label.startswith('SMS') for label in labels) == expected, (label, labels) + assert bool(definition) == expected, (label, definition) + if expected: + location = definition[0] + assert location.get('uri', location.get('targetUri', '')).startswith('jdt:'), definition + return result + +evidence = {'work':str(work)} +try: + init = request('initialize', {'processId':None, 'rootUri':projects.as_uri(), 'workspaceFolders':[{'uri':projects.as_uri(), 'name':'projects'}], 'capabilities':{'textDocument':{'completion':{'completionItem':{'snippetSupport':True}}, 'definition':{'linkSupport':True}}}, 'initializationOptions':{'bundles':[str(bundle)], 'extendedClientCapabilities':{'classFileContentsSupport':True}, 'settings':{'java':{'import':{'gradle':{'enabled':False}, 'maven':{'enabled':True}}, 'configuration':{'updateBuildConfiguration':'automatic'}}}}}) + send({'method':'initialized', 'params':{}}) + request('java/buildWorkspace', True) + for directory, test in [(single,False), (app,False), (single,True)]: + file = directory/('src/test/java/probe/TestCaller.java' if test else 'src/main/java/probe/Caller.java') + send({'method':'textDocument/didOpen','params':{'textDocument':{'uri':file.as_uri(),'languageId':'java','version':1,'text':test_source if test else source}}}) + # Allow reconcile jobs to publish their first marker batch. + time.sleep(2) + state(single, 'single-before', False) + state(app, 'multi-before', False) + state(single, 'test-before', False, True) + def raw(directory): + entries = ET.parse(directory/'.classpath').getroot() + return [ET.tostring(e).decode().strip() for e in entries if not any(a.get('name')=='ai.openidea.kotlin' for a in e.findall('./attributes/attribute'))] + before = {str(d):raw(d) for d in [single, lib, app]} + sm = build(single, 'single-build')[0] + assert not (pathlib.Path(sm['output'])/'probe/TestCaller.class').exists() + assert (pathlib.Path(sm['testOutput'])/'probe/TestCaller.class').is_file() + mm = build(multi, 'multi-build') + lm = next(m for m in mm if 'channel-lib' in m['key']) + sl, ml = library(sm, 'single'), library(lm, 'multi') + snapshot = [{'projectUri':single.as_uri(),'libraries':[sl, library(sm, 'single-test', True)]}, {'projectUri':lib.as_uri(),'libraries':[ml]}, {'projectUri':app.as_uri(),'libraries':[ml]}] + command(snapshot) + time.sleep(2) + state(single, 'single-after', True) + state(app, 'multi-after', True) + state(single, 'test-after', True, True) + assert all(raw(d) == before[str(d)] for d in [single,lib,app]), 'changed unowned entries' + # Simulate m2e removing the extra entries during a configuration update. + send({'method':'java/projectConfigurationsUpdate','params':{'identifiers':[{'uri':(d/'pom.xml').as_uri()} for d in [single,lib,app]]}}) + time.sleep(3) + state(single, 'single-reimport', True) + state(app, 'multi-reimport', True) + assert all(raw(d) == before[str(d)] for d in [single,lib,app]), 'reimport changed unowned entries' + # A saved API change must replace the immutable binary directory. + (single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS, EMAIL }\n') + state(single, 'single-during-rebuild', True) + changed = build(single, 'single-changed')[0] + snapshot[0]['libraries'] = [library(changed, 'single-changed'), library(changed, 'single-test-changed', True)] + command(snapshot) + time.sleep(2) + changed_state = state(single, 'single-new-enum-member', True) + assert any(label.startswith('EMAIL') for label in changed_state['completion']) + # Failed Kotlin compilation keeps both main and test APIs attached. + (single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { ??? }\n') + broken = build(single, 'single-broken', expected=False)[0] + assert any(g['status'] == 'failed' for g in broken['kotlinGoals']) + state(single, 'single-broken-retains-api', True) + state(single, 'single-broken-retains-test-api', True, True) + # A javac failure skips test Kotlin targets, so this whole snapshot is retained. + (single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Delivery { SMS }\n') + skipped = build(single, 'single-skipped-test', expected=False)[0] + assert any(g['execution'].endswith(':test-compile') and g['status'] == 'skipped' for g in skipped['kotlinGoals']) + assert list(pathlib.Path(skipped['output']).rglob('Delivery.class')) + state(single, 'single-skipped-test-retains-api', True) + # The reactor fixture has no test Kotlin goal. Publish despite downstream javac failure. + kt = lib/'src/main/java/probe/Channel.kt' + kt.write_text('package probe\nenum class Delivery { SMS }\n') + renamed = build(multi, 'multi-renamed') + renamed_lib = next(m for m in renamed if 'channel-lib' in m['key']) + new_library = library(renamed_lib, 'multi-renamed', name='Delivery') + assert not list(pathlib.Path(renamed_lib['output']).rglob('Channel.class')) + snapshot[1]['libraries'] = snapshot[2]['libraries'] = [new_library] + command(snapshot) + state(app, 'multi-renamed', False) + kt.unlink() + snapshot[1]['libraries'] = snapshot[2]['libraries'] = [] + command(snapshot) + state(app, 'multi-deleted', False) + kt.write_text('package probe\nenum class Channel { SMS }\n') + restored = next(m for m in build(multi, 'multi-restored') if 'channel-lib' in m['key']) + restored_library = library(restored, 'multi-restored') + snapshot[1]['libraries'] = snapshot[2]['libraries'] = [restored_library] + command(snapshot) + state(app, 'multi-restored', True) + (single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n') + repaired = build(single, 'single-repaired')[0] + snapshot[0]['libraries'] = [library(repaired, 'single-repaired'), library(repaired, 'single-test-repaired', True)] + command(snapshot) + time.sleep(2) + state(single, 'single-repaired', True) + # No old API after explicit invalidation, and recovery is repeatable. + command([]) + time.sleep(2) + state(single, 'single-withdrawn', False) + state(app, 'multi-withdrawn', False) + state(single, 'test-withdrawn', False, True) + command(snapshot) + time.sleep(2) + state(single, 'single-recovered', True) + state(app, 'multi-recovered', True) + # Main sources must not gain visibility of test-only Kotlin libraries. + leak = single/'src/main/java/probe/MainLeak.java' + leak_source = test_source.replace('TestCaller', 'MainLeak') + leak.write_text(leak_source) + send({'method':'textDocument/didOpen','params':{'textDocument':{'uri':leak.as_uri(),'languageId':'java','version':1,'text':leak_source}}}) + deadline = time.monotonic() + 30 + while True: + time.sleep(0.25) + request('workspace/symbol', {'query':'MainLeak'}) + reports = [m['params'] for m in transcript if m.get('method')=='textDocument/publishDiagnostics' and m['params']['uri']==leak.as_uri()] + if reports: break + assert time.monotonic() < deadline, 'no diagnostics for main/test isolation' + evidence['main-test-isolation'] = reports[-1]['diagnostics'] + assert any(d.get('severity') == 1 for d in reports[-1]['diagnostics']), reports[-1] + # Compiler and JDT outputs must not share Kotlin classes. + assert not list(single.glob('target/classes/**/Channel.class')) + assert not list(lib.glob('target/classes/**/Channel.class')) + # -fae runs independent Kotlin modules and records upstream-skipped targets. + fail_reactor = work/'fail-at-end' + fail_reactor.mkdir() + (fail_reactor/'pom.xml').write_text(prefix + 'probefailure-reactor1pom' + properties + 'broken-javaindependentdownstream') + failure_parent = 'probefailure-reactor1' + for name in ['broken-java', 'independent', 'downstream']: + module = fail_reactor/name + (module/'src/main/java/probe').mkdir(parents=True) + dependency = 'probebroken-java1org.jetbrains.kotlinkotlin-stdlib2.0.10' if name == 'downstream' else stdlib + (module/'pom.xml').write_text(prefix + failure_parent + '' + name + '' + dependency + plugin + '') + if name == 'broken-java': + (module/'src/main/java/probe/Broken.java').write_text('package probe; public class Broken { Missing value; }') + else: + (module/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n') + statuses = build(fail_reactor, 'fail-at-end-statuses', expected=False) + independent = next(m for m in statuses if ':independent:' in m['key']) + downstream = next(m for m in statuses if ':downstream:' in m['key']) + assert all(g['status'] == 'success' for g in independent['kotlinGoals']), independent + assert all(g['status'] == 'skipped' for g in downstream['kotlinGoals']), downstream + assert list(pathlib.Path(independent['output']).rglob('Channel.class')) + print(json.dumps(evidence, indent=2)) +finally: + (work/'evidence.json').write_text(json.dumps(evidence, indent=2)) + (work/'transcript.json').write_text(json.dumps(transcript, indent=2)) + proc.terminate() + proc.wait(timeout=20) + print('Evidence:', work, file=sys.stderr) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 131f729a5..5ebf05c5e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -74,6 +74,7 @@ ide-github = { path = "../crates/ide-github" } block2 = "0.6" objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSResponder", "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. objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] } diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index 5ff8d0f10..efa29a9b5 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -5,6 +5,38 @@ use ide_vfs::FileEntry; use std::path::PathBuf; use tauri::{State, Window}; +#[tauri::command] +#[specta::specta] +pub async fn fs_entry_kind(state: State<'_, AppState>, path: PathBuf) -> IdeResult { + fs::entry_kind(&state.host, path).await +} + +#[tauri::command] +#[specta::specta] +pub async fn fs_compare_directories( + window: Window, + state: State<'_, AppState>, + left: PathBuf, + right: PathBuf, + request: ide_core::RequestId, + on_progress: tauri::ipc::Channel, +) -> IdeResult> { + fs::compare_directories(&state.host, window.label(), left, right, request, super::sender(on_progress)).await +} + +#[tauri::command] +#[specta::specta] +pub async fn fs_copy_merge( + window: Window, + state: State<'_, AppState>, + source_root: PathBuf, + destination_root: PathBuf, + path: PathBuf, + overwrite: bool, +) -> IdeResult<()> { + fs::copy_merge(&state.host, window.label(), source_root, destination_root, path, overwrite).await +} + #[tauri::command] #[specta::specta] pub fn fs_read_dir(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult> { diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index c311cb8eb..765d7e755 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -95,8 +95,8 @@ pub async fn lsp_implementations( ide_host::ops::lsp::lsp_implementations(&state.host, window.label(), request, doc, positions).await } -/// Contents of a virtual document (library class, decompiled code) that a -/// language server returned a non-`file:` URI for. Cached per project. +/// Contents of a read-only language-server document, including JDT library +/// classes and disk class files. Cached per project. #[tauri::command] #[specta::specta] pub async fn lsp_virtual_document( @@ -107,6 +107,16 @@ pub async fn lsp_virtual_document( ide_host::ops::lsp::lsp_virtual_document(&state.host, window.label(), uri).await } +#[tauri::command] +#[specta::specta] +pub async fn lsp_resolve_class_file( + window: Window, + state: State<'_, AppState>, + path: std::path::PathBuf, +) -> IdeResult { + ide_host::ops::lsp::lsp_resolve_class_file(&state.host, window.label(), path).await +} + /// References of the symbol at `pos`. `project_wide` also searches project files /// outside the language server's analysis scope; bulk scans over many symbols /// leave it off. Cancel with `cancel_request(request)`. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 5111d421b..c71ce8405 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -126,6 +126,7 @@ pub fn builder() -> tauri_specta::Builder { notebook::notebook_interrupt, notebook::notebook_restart, fs::fs_read_dir, + fs::fs_entry_kind, bytes::fs_probe, bytes::fs_read_range, bytes::fs_read_image, @@ -136,6 +137,8 @@ pub fn builder() -> tauri_specta::Builder { fs::fs_set_writable, fs::fs_delete, fs::fs_copy, + fs::fs_copy_merge, + fs::fs_compare_directories, fs::fs_read_text, clipboard::clipboard_file_list, clipboard::clipboard_has_files, @@ -201,6 +204,7 @@ pub fn builder() -> tauri_specta::Builder { lsp::lsp_goto, lsp::lsp_implementations, lsp::lsp_virtual_document, + lsp::lsp_resolve_class_file, lsp::lsp_references, lsp::lsp_document_highlights, lsp::lsp_workspace_symbols, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 439fed64b..bee40a882 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,8 @@ mod commands; #[cfg(debug_assertions)] mod dev; mod events; +#[cfg(any(target_os = "linux", test))] +mod linux_window_state; mod logging; mod menu; mod remote_dev; @@ -68,16 +70,18 @@ pub fn run() { // Single instance first: a second launch focuses this one and opens its path argument. // Debug builds have a separate identifier per worktree. let app = app.plugin(tauri_plugin_single_instance::init(windows::on_second_instance)); + let app = app.plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_dialog::init()); + #[cfg(target_os = "linux")] + let app = app.plugin(linux_window_state::plugin()); + #[cfg(not(target_os = "linux"))] + let app = app.plugin( + tauri_plugin_window_state::Builder::default() + .with_state_flags(window_state_flags()) + // One remembered geometry shared by every project's settings window. + .map_label(|l| if windows::is_settings_window(l) { "settings" } else { l }) + .build(), + ); let app = app - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin( - tauri_plugin_window_state::Builder::default() - .with_state_flags(window_state_flags()) - // One remembered geometry shared by every project's settings window. - .map_label(|l| if windows::is_settings_window(l) { "settings" } else { l }) - .build(), - ) // Inert until an endpoint is configured (see `commands::window::app_check_update`). .plugin(tauri_plugin_updater::Builder::new().build()); // E2E automation bridge (Tauri MCP), debug builds only, localhost only. @@ -152,6 +156,8 @@ pub fn run() { Ok(()) }) .on_window_event(|window, event| match event { + #[cfg(target_os = "linux")] + tauri::WindowEvent::CloseRequested { .. } => linux_window_state::save_before_close(window.app_handle()), tauri::WindowEvent::Focused(true) => { application_focus::changed(window.app_handle(), true); if let Some(w) = window.try_state::() { @@ -204,6 +210,7 @@ pub fn run() { }); } +#[cfg(not(target_os = "linux"))] fn window_state_flags() -> tauri_plugin_window_state::StateFlags { use tauri_plugin_window_state::StateFlags; StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED | StateFlags::FULLSCREEN diff --git a/src-tauri/src/linux_window_state.rs b/src-tauri/src/linux_window_state.rs new file mode 100644 index 000000000..3d9816dce --- /dev/null +++ b/src-tauri/src/linux_window_state.rs @@ -0,0 +1,483 @@ +//! Restore GTK geometry after mapping, using the scale of the window's actual output. + +mod geometry; +#[cfg(target_os = "linux")] +use geometry::*; +use tauri::utils::config::WindowConfig; + +fn build_hidden(config: &mut WindowConfig) -> bool { + std::mem::replace(&mut config.visible, false) +} + +#[cfg(target_os = "linux")] +mod runtime { + use super::*; + use parking_lot::Mutex; + use std::collections::HashMap; + use tauri::{AppHandle, Listener, Manager, Window, WindowEvent, plugin::TauriPlugin}; + + const FILE: &str = tauri_plugin_window_state::DEFAULT_FILENAME; + + #[derive(Default)] + enum Phase { + #[default] + Mapping, + Size { + target: Size, + scale: f64, + request: u64, + }, + Mode, + Tracking, + } + + #[derive(Default)] + struct Pending { + limits: Limits, + configured: bool, + presented: bool, + presentation_request: u64, + saved: Geometry, + normal: Size, + history: ModeHistory, + restore_position: bool, + phase: Phase, + mode_requested: bool, + mode_request: u64, + } + + #[derive(Default)] + struct Cache { + saved: HashMap, + windows: HashMap, + next_request: u64, + } + + struct Frame { + size: Size, + scale: f64, + mode: Mode, + position: Option>, + monitors: Vec, + work_area: Option, + visible: bool, + } + + fn frame(window: &Window) -> Option { + let size = window.inner_size().ok()?; + Some(Frame { + visible: window.is_visible().ok()?, + size: Size(size.width, size.height), + scale: window.scale_factor().ok()?, + mode: Mode { + maximized: window.is_maximized().ok()?, + fullscreen: window.is_fullscreen().ok()?, + minimized: window.is_minimized().ok()?, + }, + position: window.outer_position().ok(), + monitors: window.available_monitors().unwrap_or_default(), + work_area: window + .current_monitor() + .ok() + .flatten() + .map(|m| Size(m.work_area().size.width, m.work_area().size.height)), + }) + } + + fn key(label: &str) -> &str { + if crate::windows::is_settings_window(label) { "settings" } else { label } + } + + pub fn prepare(app: &AppHandle, config: &mut WindowConfig) -> bool { + let hint_monitor = app.primary_monitor().ok().flatten(); + let hint_scale = hint_monitor.as_ref().map_or(1.0, |m| m.scale_factor()); + let hint_area = hint_monitor.map(|m| Size(m.work_area().size.width, m.work_area().size.height)); + let cache = app.state::>(); + let mut cache = cache.lock(); + let saved = cache.saved.get(key(&config.label)).cloned().unwrap_or_else(|| Geometry { + maximized: config.maximized, + fullscreen: config.fullscreen, + ..Default::default() + }); + cache.next_request += 1; + let presentation_request = cache.next_request; + let pending = Pending { + presentation_request, + limits: Limits { + min: (config.min_width.unwrap_or(1.0), config.min_height.unwrap_or(1.0)), + max: (config.max_width, config.max_height), + }, + normal: Size(saved.width, saved.height), + saved, + restore_position: true, + ..Default::default() + }; + if pending.saved.width > 0 && pending.saved.height > 0 { + // This only reduces the first-frame jump; mapped geometry is authoritative. + let size = restore_size(pending.normal, hint_scale, pending.limits.in_work_area(hint_area, hint_scale)); + config.width = f64::from(size.0) / hint_scale; + config.height = f64::from(size.1) / hint_scale; + } + cache.windows.insert(config.label.clone(), pending); + // Establish a normal window before asking GTK to maximize or fullscreen it. + config.maximized = false; + config.fullscreen = false; + build_hidden(config) + } + + fn sample(window: &Window, allow_restore: bool, accept_constrained_size: bool) { + let Some(frame) = frame(window) else { return }; + if !frame.visible { + return; + } + let cache = window.state::>(); + let mut cache = cache.lock(); + cache.next_request += 1; + let request = cache.next_request; + let Some(pending) = cache.windows.get_mut(window.label()) else { return }; + if !pending.presented && pending.saved.width == 0 { + pending.normal = remember_size(pending.normal, frame.size, false, frame.mode); + } + if !pending.configured || !pending.presented { + return; + } + if !allow_restore && !matches!(pending.phase, Phase::Tracking) { + return; + } + let transition = if matches!(pending.phase, Phase::Tracking) { + pending.history.observe(frame.mode, allow_restore) + } else { + NormalTransition::Stable + }; + if transition == NormalTransition::WaitForResize { + let normal = pending.normal; + let saved = cache.saved.entry(key(window.label()).to_owned()).or_default(); + (saved.width, saved.height) = (normal.0, normal.1); + saved.maximized = frame.mode.maximized; + saved.fullscreen = frame.mode.fullscreen; + return; + } + if transition == NormalTransition::Restore { + // The compositor may have changed output/DPI while maximized or minimized. + pending.saved.set_normal_size(pending.normal); + pending.phase = Phase::Mapping; + pending.mode_requested = false; + } + let mut fit_settings = false; + let mut position = None; + if matches!(pending.phase, Phase::Mapping) { + if pending.restore_position { + pending.restore_position = false; + let saved = &pending.saved; + let (x, y) = if saved.maximized { (saved.prev_x, saved.prev_y) } else { (saved.x, saved.y) }; + if frame.monitors.iter().any(|m| { + let p = m.position(); + let s = m.size(); + i64::from(x) < i64::from(p.x) + i64::from(s.width) + && i64::from(x) + i64::from(saved.width) > i64::from(p.x) + && i64::from(y) < i64::from(p.y) + i64::from(s.height) + && i64::from(y) + i64::from(saved.height) > i64::from(p.y) + }) { + position = Some(tauri::PhysicalPosition::new(x, y)); + } + } + if pending.saved.width > 0 && pending.saved.height > 0 { + let target = restore_size( + Size(pending.saved.width, pending.saved.height), + frame.scale, + pending.limits.in_work_area(frame.work_area, frame.scale), + ); + pending.phase = Phase::Size { target, scale: frame.scale, request: 0 }; + } else { + pending.normal = remember_size(pending.normal, frame.size, false, frame.mode); + pending.phase = Phase::Mode; + } + } + if let Phase::Size { target, scale, request: sent } = pending.phase { + if frame.size != target && !accept_constrained_size { + if sent != 0 && scale == frame.scale { + return; + } + // Re-evaluate after output scale changes, rather than reusing monitor 0's scale. + let target = restore_size( + Size(pending.saved.width, pending.saved.height), + frame.scale, + pending.limits.in_work_area(frame.work_area, frame.scale), + ); + pending.phase = Phase::Size { target, scale: frame.scale, request }; + drop(cache); + if let Some(position) = position { + let _ = window.set_position(position); + } + if let Err(error) = window.set_size(tauri::PhysicalSize::new(target.0, target.1)) { + tracing::warn!("cannot restore Linux window size: {error}"); + } + settle_constrained_size(window, request); + return; + } + if scale != frame.scale && !accept_constrained_size { + pending.phase = Phase::Mapping; + drop(cache); + sample(window, true, false); + return; + } + pending.normal = remember_size(pending.normal, frame.size, false, frame.mode); + pending.phase = Phase::Mode; + } + if matches!(pending.phase, Phase::Mode) { + let (maximize, fullscreen) = (pending.saved.maximized, pending.saved.fullscreen); + let reached = if fullscreen { frame.mode.fullscreen } else { !maximize || frame.mode.maximized }; + let action = mode_action(reached, pending.mode_requested, accept_constrained_size); + if action == ModeAction::Wait { + return; + } + if action == ModeAction::Request { + pending.mode_requested = true; + pending.mode_request = request; + let mut snapshot = pending.saved.clone(); + (snapshot.width, snapshot.height) = (pending.normal.0, pending.normal.1); + cache.saved.insert(key(window.label()).to_owned(), snapshot); + drop(cache); + if let Some(position) = position { + let _ = window.set_position(position); + } + if maximize { + let _ = window.maximize(); + } + if fullscreen { + let _ = window.set_fullscreen(true); + } + settle_constrained_size(window, request); + return; + } + pending.phase = Phase::Tracking; + fit_settings = crate::windows::is_settings_window(window.label()); + } + pending.history = ModeHistory(frame.mode); + pending.normal = remember_size(pending.normal, frame.size, false, frame.mode); + let normal = pending.normal; + let saved = cache.saved.entry(key(window.label()).to_owned()).or_default(); + (saved.width, saved.height) = (normal.0, normal.1); + saved.maximized = frame.mode.maximized; + saved.fullscreen = frame.mode.fullscreen; + if frame.mode.normal() + && let Some(pos) = frame.position + { + (saved.x, saved.y, saved.prev_x, saved.prev_y) = (pos.x, pos.y, pos.x, pos.y); + } + drop(cache); + if let Some(position) = position { + let _ = window.set_position(position); + } + if fit_settings { + let app = window.app_handle().clone(); + let label = window.label().to_owned(); + let _ = window.run_on_main_thread(move || { + if let Some(parent) = crate::windows::settings_parent(&label).and_then(|p| app.get_webview_window(p)) + && let Some(settings) = app.get_webview_window(&label) + && let Err(error) = crate::windows::place_settings_window(&settings, &parent) + { + tracing::warn!("cannot fit restored settings window: {error}"); + } + }); + } + } + + fn settle_constrained_size(window: &Window, request: u64) { + let window = window.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + let cloned = window.clone(); + let _ = window.run_on_main_thread(move || { + let waiting = cloned.state::>().lock().windows.get(cloned.label()).is_some_and(|p| { + matches!(p.phase, Phase::Size { request: current, .. } if current == request) + || matches!(p.phase, Phase::Mode) && p.mode_request == request + }); + if waiting { + // Window-manager constraints may reject a size without producing an exact match. + sample(&cloned, true, true); + } + }); + }); + } + + pub fn normal_size_requested(app: &AppHandle, label: &str, size: tauri::PhysicalSize) { + let cache = app.state::>(); + let mut cache = cache.lock(); + cache.next_request += 1; + let request = cache.next_request; + if let Some(pending) = cache.windows.get_mut(label) { + pending.normal = Size(size.width, size.height); + pending.saved.set_normal_size(pending.normal); + pending.mode_requested = false; + pending.presented = false; + pending.presentation_request = request; + pending.phase = Phase::Mapping; + } + let saved = cache.saved.entry(key(label).to_owned()).or_default(); + saved.set_normal_size(Size(size.width, size.height)); + drop(cache); + if let Some(webview) = app.get_webview_window(label) { + request_presentation(webview.as_ref(), request); + } + } + + fn request_presentation(webview: &tauri::Webview, request: u64) { + // A configure precedes output-enter. A second animation frame follows the + // first paint, allowing GTK to learn the scale of the assigned output. + let script = r#" +(() => { + const state = window.__OPENIDEA_WINDOW_GEOMETRY__ ??= { request: 0, scheduled: false }; + state.request = __REQUEST_ID__; + state.ready ??= () => { + if (document.hidden || state.scheduled) return; + const request = state.request; + state.scheduled = true; + requestAnimationFrame(() => requestAnimationFrame(() => { + state.scheduled = false; + if (document.hidden) return; + const api = window.__TAURI_INTERNALS__; + const label = api?.metadata?.currentWindow?.label; + if (label) api.invoke('plugin:event|emit', { + event: 'openidea://window-state-presented', payload: { label, request } + }); + if (request !== state.request) state.ready(); + })); + }; + if (!state.listening) { + state.listening = true; + document.addEventListener('visibilitychange', state.ready); + } + state.ready(); +})(); +"# + .replace("__REQUEST_ID__", &request.to_string()); + let _ = webview.eval(script); + } + + pub fn save_before_close(app: &AppHandle) { + for window in app.webview_windows().values() { + sample(&window.as_ref().window(), false, false); + } + persist(app); + } + + fn persist(app: &AppHandle) { + let write = || -> Result<(), Box> { + let dir = app.path().app_config_dir()?; + std::fs::create_dir_all(&dir)?; + let cache = app.state::>(); + let data = serde_json::to_vec_pretty(&cache.lock().saved)?; + let temporary = dir.join(format!("{FILE}.tmp")); + std::fs::write(&temporary, data)?; + std::fs::rename(temporary, dir.join(FILE))?; + Ok(()) + }; + if let Err(error) = write() { + tracing::warn!("cannot save Linux window state: {error}"); + } + } + + pub fn plugin() -> TauriPlugin { + tauri::plugin::Builder::new("linux-window-state") + .setup(|app, _| { + let saved = app + .path() + .app_config_dir() + .ok() + .and_then(|dir| std::fs::read(dir.join(FILE)).ok()) + .and_then(|data| serde_json::from_slice(&data).ok()) + .unwrap_or_default(); + app.manage(Mutex::new(Cache { saved, ..Default::default() })); + let handle = app.clone(); + app.listen("openidea://window-state-presented", move |event| { + #[derive(serde::Deserialize)] + struct Presented { + label: String, + request: u64, + } + let Ok(presented) = serde_json::from_str::(event.payload()) else { return }; + let Some(webview) = handle.get_webview_window(&presented.label) else { return }; + let window = webview.as_ref().window(); + let cloned = window.clone(); + let _ = window.run_on_main_thread(move || { + if !cloned.is_visible().unwrap_or(false) { + return; + } + if let Some(pending) = cloned.state::>().lock().windows.get_mut(cloned.label()) { + if pending.presentation_request != presented.request { + return; + } + pending.presented = true; + } + sample(&cloned, true, false); + }); + }); + Ok(()) + }) + .on_page_load(|webview, payload| { + if payload.event() == tauri::webview::PageLoadEvent::Finished { + let request = webview + .state::>() + .lock() + .windows + .get(webview.window().label()) + .map(|p| p.presentation_request); + if let Some(request) = request { + request_presentation(webview, request); + } + } + }) + .on_window_ready(|window| { + let cloned = window.clone(); + window.on_window_event(move |event| match event { + WindowEvent::Resized(_) => { + if let Some(pending) = cloned.state::>().lock().windows.get_mut(cloned.label()) { + pending.configured = true; + } + let request = cloned + .state::>() + .lock() + .windows + .get(cloned.label()) + .filter(|p| !p.presented) + .map(|p| p.presentation_request); + if let Some(request) = request + && let Some(webview) = cloned.app_handle().get_webview_window(cloned.label()) + { + request_presentation(webview.as_ref(), request); + } + sample(&cloned, true, false); + } + WindowEvent::Moved(_) => sample(&cloned, false, false), + WindowEvent::Destroyed => { + cloned.state::>().lock().windows.remove(cloned.label()); + } + _ => {} + }); + }) + .on_event(|app, event| { + if let tauri::RunEvent::Exit = event { + persist(app); + } + }) + .build() + } +} + +#[cfg(target_os = "linux")] +pub use runtime::{normal_size_requested, plugin, prepare, save_before_close}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hidden_creation_preserves_geometry() { + let mut config = WindowConfig { width: 1201.0, height: 801.0, ..Default::default() }; + assert!(build_hidden(&mut config)); + assert!(!config.visible); + assert_eq!((config.width, config.height), (1201.0, 801.0)); + assert!(!build_hidden(&mut config)); + } +} diff --git a/src-tauri/src/linux_window_state/geometry.rs b/src-tauri/src/linux_window_state/geometry.rs new file mode 100644 index 000000000..4bd747b4f --- /dev/null +++ b/src-tauri/src/linux_window_state/geometry.rs @@ -0,0 +1,237 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct Size(pub(super) u32, pub(super) u32); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct Mode { + pub(super) maximized: bool, + pub(super) fullscreen: bool, + pub(super) minimized: bool, +} + +impl Mode { + pub(super) fn normal(self) -> bool { + !self.maximized && !self.fullscreen && !self.minimized + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub(super) struct ModeHistory(pub(super) Mode); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum NormalTransition { + Stable, + WaitForResize, + Restore, +} + +impl ModeHistory { + pub(super) fn observe(&mut self, current: Mode, can_restore: bool) -> NormalTransition { + let returned = !self.0.normal() && current.normal(); + if returned && !can_restore { + return NormalTransition::WaitForResize; + } + self.0 = current; + if returned { NormalTransition::Restore } else { NormalTransition::Stable } + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub(super) struct Limits { + pub(super) min: (f64, f64), + pub(super) max: (Option, Option), +} + +impl Limits { + pub(super) fn in_work_area(self, area: Option, scale: f64) -> Self { + let Some(Size(width, height)) = area.filter(|s| s.0 > 0 && s.1 > 0) else { return self }; + Self { + max: ( + Some(self.max.0.unwrap_or(f64::INFINITY).min((f64::from(width) / scale).floor())), + Some(self.max.1.unwrap_or(f64::INFINITY).min((f64::from(height) / scale).floor())), + ), + ..self + } + } +} + +pub(super) fn restore_size(saved: Size, scale: f64, limits: Limits) -> Size { + let dimension = |pixels: u32, min: f64, max: Option| { + let logical = (f64::from(pixels) / scale).max(min).max(1.0); + // gtk_window_resize takes integer logical dimensions. + (logical.min(max.unwrap_or(f64::INFINITY).max(min)).round() * scale).round() as u32 + }; + Size(dimension(saved.0, limits.min.0, limits.max.0), dimension(saved.1, limits.min.1, limits.max.1)) +} + +pub(super) fn remember_size(previous: Size, current: Size, restoring: bool, mode: Mode) -> Size { + if restoring || !mode.normal() || current.0 == 0 || current.1 == 0 { previous } else { current } +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum ModeAction { + Request, + Wait, + Track, +} + +pub(super) fn mode_action(reached: bool, requested: bool, timed_out: bool) -> ModeAction { + if reached { + ModeAction::Track + } else if !requested { + ModeAction::Request + } else if timed_out { + ModeAction::Track + } else { + ModeAction::Wait + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default)] +pub(super) struct Geometry { + pub(super) width: u32, + pub(super) height: u32, + pub(super) x: i32, + pub(super) y: i32, + pub(super) prev_x: i32, + pub(super) prev_y: i32, + pub(super) maximized: bool, + pub(super) fullscreen: bool, + pub(super) visible: bool, + pub(super) decorated: bool, +} + +impl Geometry { + pub(super) fn set_normal_size(&mut self, size: Size) { + (self.width, self.height) = (size.0, size.1); + self.maximized = false; + self.fullscreen = false; + } +} + +impl Default for Geometry { + fn default() -> Self { + Self { + width: 0, + height: 0, + x: 0, + y: 0, + prev_x: 0, + prev_y: 0, + maximized: false, + fullscreen: false, + visible: true, + decorated: true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constrained_size_does_not_skip_the_first_mode_request() { + assert_eq!(mode_action(false, false, true), ModeAction::Request); + assert_eq!(mode_action(false, false, false), ModeAction::Request); + assert_eq!(mode_action(false, true, false), ModeAction::Wait); + assert_eq!(mode_action(false, true, true), ModeAction::Track); + assert_eq!(mode_action(true, false, false), ModeAction::Track); + } + + #[test] + fn physical_sizes_use_the_actual_output_scale() { + for scale in [1.0, 2.0] { + assert_eq!(restore_size(Size(2468, 1356), scale, Limits::default()), Size(2468, 1356)); + } + // Saved at scale 1, mapped on scale 2: keep physical pixels, not twice that size. + assert_eq!(restore_size(Size(1234, 678), 2.0, Limits::default()), Size(1234, 678)); + } + + #[test] + fn gtk_limits_and_integer_logical_dimensions_are_applied_once() { + let limits = Limits { min: (800.0, 500.0), max: (Some(1600.0), Some(1000.0)) }; + assert_eq!(restore_size(Size(1201, 801), 1.0, limits), Size(1201, 801)); + assert_eq!(restore_size(Size(1201, 801), 2.0, limits), Size(1600, 1000)); + assert_eq!(restore_size(Size(2001, 1401), 2.0, limits), Size(2002, 1402)); + assert_eq!(restore_size(Size(9000, 6000), 2.0, limits), Size(3200, 2000)); + } + + #[test] + fn damaged_sizes_are_bounded_in_the_actual_outputs_units() { + let limits = Limits { min: (800.0, 500.0), ..Default::default() }; + for scale in [1.0, 2.0] { + let bounded = limits.in_work_area(Some(Size(3840, 2160)), scale); + assert_eq!(restore_size(Size(45068, 30000), scale, bounded), Size(3840, 2160)); + assert_eq!(restore_size(Size(2468, 1356), scale, bounded), Size(2468, 1356)); + } + } + + #[test] + fn restoration_and_non_normal_events_preserve_the_normal_size() { + let normal = Size(1234, 678); + for current in [Size(2468, 1356), Size(2648, 1602), Size(5476, 3204)] { + assert_eq!(remember_size(normal, current, true, Mode::default()), normal); + for mode in [ + Mode { maximized: true, ..Default::default() }, + Mode { fullscreen: true, ..Default::default() }, + Mode { minimized: true, ..Default::default() }, + ] { + assert_eq!(remember_size(normal, current, false, mode), normal); + } + } + assert_eq!(remember_size(normal, Size(1201, 801), false, Mode::default()), Size(1201, 801)); + assert_eq!(remember_size(normal, Size(0, 678), false, Mode::default()), normal); + } + + #[test] + fn a_mixed_dpi_maximized_launch_does_not_compound() { + let mut saved = Size(2468, 1356); + for _ in 0..3 { + saved = remember_size(saved, Size(saved.0 * 2, saved.1 * 2), true, Mode::default()); + let restored = restore_size(saved, 2.0, Limits::default()); + saved = remember_size(saved, restored, false, Mode::default()); + saved = remember_size(saved, Size(3840, 2160), false, Mode { maximized: true, ..Default::default() }); + assert_eq!(saved, Size(2468, 1356)); + } + } + + #[test] + fn moved_does_not_consume_the_unmaximize_resize() { + for mode in [ + Mode { maximized: true, ..Default::default() }, + Mode { fullscreen: true, ..Default::default() }, + Mode { minimized: true, ..Default::default() }, + ] { + let mut history = ModeHistory(mode); + assert_eq!(history.observe(Mode::default(), false), NormalTransition::WaitForResize); + assert_eq!(history.0, mode); + assert_eq!(history.observe(Mode::default(), true), NormalTransition::Restore); + assert_eq!(history.observe(Mode::default(), true), NormalTransition::Stable); + } + } + + #[test] + fn an_explicit_normal_frame_overrides_old_window_modes() { + let mut saved = Geometry { maximized: true, fullscreen: true, x: 123, y: 456, ..Default::default() }; + saved.set_normal_size(Size(1100, 700)); + assert_eq!((saved.width, saved.height), (1100, 700)); + assert!(!saved.maximized && !saved.fullscreen); + assert_eq!((saved.x, saved.y), (123, 456)); + } + + #[test] + fn state_files_remain_compatible_and_keep_closed_windows() { + let saved: std::collections::HashMap = serde_json::from_str( + r#"{"main":{"width":1234,"height":678,"maximized":true},"settings":{"width":1001,"height":701}}"#, + ) + .unwrap(); + let copy: std::collections::HashMap = + serde_json::from_value(serde_json::to_value(&saved).unwrap()).unwrap(); + assert_eq!(copy["settings"].height, 701); + assert!(copy["main"].maximized); + assert_eq!(copy["main"].width, 1234); + } +} diff --git a/src-tauri/src/titlebar.rs b/src-tauri/src/titlebar.rs index 432d951c2..fdf68376b 100644 --- a/src-tauri/src/titlebar.rs +++ b/src-tauri/src/titlebar.rs @@ -5,19 +5,26 @@ use std::collections::HashMap; use std::rc::Rc; use block2::RcBlock; -use objc2::rc::Retained; -use objc2::runtime::ProtocolObject; -use objc2_app_kit::{NSWindow, NSWindowButton, NSWindowDidUpdateNotification, NSWindowStyleMask}; -use objc2_foundation::{NSNotificationCenter, NSObjectProtocol, NSPoint}; +use objc2_app_kit::{NSWindow, NSWindowButton, NSWindowStyleMask}; +use objc2_core_foundation::{ + CFIndex, CFOptionFlags, CFRetained, CFRunLoop, CFRunLoopActivity, CFRunLoopObserver, kCFRunLoopCommonModes, +}; +use objc2_foundation::NSPoint; + +/// AppKit lays out the title bar in a run loop observer and puts the buttons back at their +/// defaults on resize, title changes and fullscreen exit. Core Animation commits at 2000000, so +/// correcting the frames just before it keeps AppKit's positions off screen. +const BEFORE_COMMIT: CFIndex = 1_999_999; struct Titlebar { height: Rc>, - observer: Retained>, + spacing: f64, + observer: CFRetained, } impl Drop for Titlebar { fn drop(&mut self) { - unsafe { NSNotificationCenter::defaultCenter().removeObserver((*self.observer).as_ref()) }; + self.observer.invalidate(); } } @@ -30,22 +37,30 @@ pub fn set_height(window: &tauri::WebviewWindow, height: f64) -> tauri::Result<( // with_webview runs on the AppKit thread; the observer is also delivered there. window.with_webview(move |webview| unsafe { let native = &*webview.ns_window().cast::(); + let Some(close) = native.standardWindowButton(NSWindowButton::CloseButton) else { return }; + let Some(minimize) = native.standardWindowButton(NSWindowButton::MiniaturizeButton) else { return }; + let Some(run_loop) = CFRunLoop::main() else { return }; + let spacing = minimize.frame().origin.x - close.frame().origin.x; TITLEBARS.with_borrow_mut(|bars| { - let bar = bars.entry(label).or_insert_with(|| { - let height = Rc::new(Cell::new(height)); - let current = height.clone(); - let pointer = native as *const NSWindow; - let block = RcBlock::new(move |_| center_buttons(&*pointer, current.get())); - let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock( - Some(NSWindowDidUpdateNotification), - Some(native), - None, - &block, - ); - Titlebar { height, observer } + if let Some(bar) = bars.get(&label) { + bar.height.set(height); + center_buttons(native, height, bar.spacing); + return; + } + let shared = Rc::new(Cell::new(height)); + let current = shared.clone(); + let pointer = native as *const NSWindow; + let block = RcBlock::new(move |_: *mut CFRunLoopObserver, _: CFRunLoopActivity| { + center_buttons(&*pointer, current.get(), spacing) }); - bar.height.set(height); - center_buttons(native, height); + let activities = CFRunLoopActivity::BeforeWaiting.0 as CFOptionFlags; + let Some(observer) = CFRunLoopObserver::with_handler(None, activities, true, BEFORE_COMMIT, Some(&block)) + else { + return; + }; + run_loop.add_observer(Some(&observer), kCFRunLoopCommonModes); + center_buttons(native, height, spacing); + bars.insert(label, Titlebar { height: shared, spacing, observer }); }); }) } @@ -57,14 +72,12 @@ pub fn forget(window: &tauri::Window) { }); } -unsafe fn center_buttons(window: &NSWindow, height: f64) { +unsafe fn center_buttons(window: &NSWindow, height: f64, spacing: f64) { // Fullscreen uses AppKit's own revealable title bar. if window.styleMask().contains(NSWindowStyleMask::FullScreen) { return; } let Some(close) = window.standardWindowButton(NSWindowButton::CloseButton) else { return }; - let Some(minimize) = window.standardWindowButton(NSWindowButton::MiniaturizeButton) else { return }; - let spacing = minimize.frame().origin.x - close.frame().origin.x; let Some(container) = (unsafe { close.superview().and_then(|view| view.superview()) }) else { return }; let Some(content) = window.contentView() else { return }; let mut frame = container.frame(); diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index 4aaea46dc..66fe0c88f 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -60,7 +60,10 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { if !maximized { to.unmaximize().map_err(err)?; to.set_position(from.outer_position().map_err(err)?).map_err(err)?; - to.set_size(from.inner_size().map_err(err)?).map_err(err)?; + let size = from.inner_size().map_err(err)?; + #[cfg(target_os = "linux")] + crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size); + to.set_size(size).map_err(err)?; } else { to.maximize().map_err(err)?; } diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 30a13ed84..f905ed51f 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -284,6 +284,11 @@ fn hex_color(hex: &str) -> Option { /// Builds a project window painted in the saved theme's frame colour, so it /// doesn't flash the light default before its page (and splash) loads. fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult { + let visible = cfg!(target_os = "macos") && config.visible; + let focused = config.focus; + if visible { + config.visible = false; + } let state = app.try_state::(); let appearance = state.as_ref().map(|s| s.global_settings.effective().appearance); // A plugin theme replaces the built-in one in either OS mode; the UI records its resolved frame. @@ -293,6 +298,8 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< } else if let Some(a) = &appearance { config.background_color = Some(frame_color(a, false)); } + #[cfg(target_os = "linux")] + let show_after_build = crate::linux_window_state::prepare(app, &mut config); let window = WebviewWindowBuilder::from_config(app, &config) .and_then(|b| dev_font_preferences(b).build()) .map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?; @@ -305,6 +312,16 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< { let _ = window.set_background_color(Some(frame_color(&a, true))); } + #[cfg(target_os = "linux")] + if show_after_build { + window.show().map_err(|e| IdeError::internal(format!("cannot show window: {e}")))?; + } + if visible { + window.show().map_err(|e| IdeError::internal(format!("cannot show window: {e}")))?; + if focused { + let _ = window.set_focus(); + } + } Ok(window) } @@ -754,6 +771,8 @@ pub fn open_settings_window( config.background_color = Some(color); } windows.set_launch(&label, Launch { settings_page: page, new_projects, ..Default::default() }); + #[cfg(target_os = "linux")] + crate::linux_window_state::prepare(app, &mut config); let builder = WebviewWindowBuilder::from_config(app, &config); // AppKit child windows can disappear when dragged to another display's Space. // The settings label already tracks ownership and parent-close cleanup. @@ -821,7 +840,7 @@ fn fit_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri: /// may still be on another display with a different scale, so its size is converted with its own /// scale and its position is given in physical pixels. #[cfg(not(target_os = "macos"))] -fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> { +pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> { let Some(monitor) = parent.current_monitor()?.or(parent.primary_monitor()?) else { return Ok(()); }; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9cbf6ee47..9a1c84fbf 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenIDEA", - "version": "0.1.32", + "version": "0.1.33", "identifier": "ai.l0ng.openidea", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/bindings.ts b/src/bindings.ts index 20ee0eba2..b65240187 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -145,6 +145,7 @@ export const commands = { notebookInterrupt: (id: string) => typedError(__TAURI_INVOKE("notebook_interrupt", { id })), notebookRestart: (id: string) => typedError(__TAURI_INVOKE("notebook_restart", { id })), fsReadDir: (path: string) => typedError(__TAURI_INVOKE("fs_read_dir", { path })), + fsEntryKind: (path: string) => typedError(__TAURI_INVOKE("fs_entry_kind", { path })), /** Size and text/binary classification, from the file's head only. */ fsProbe: (path: string) => typedError(__TAURI_INVOKE("fs_probe", { path })), /** Up to `len` bytes from `offset` (capped per call), base64-encoded. */ @@ -167,6 +168,8 @@ export const commands = { /** Moving to the trash can take a while (large trees): kept off the main thread. */ fsDelete: (path: string) => typedError(__TAURI_INVOKE("fs_delete", { path })), fsCopy: (from: string, to: string) => typedError(__TAURI_INVOKE("fs_copy", { from, to })), + fsCopyMerge: (sourceRoot: string, destinationRoot: string, path: string, overwrite: boolean) => typedError(__TAURI_INVOKE("fs_copy_merge", { sourceRoot, destinationRoot, path, overwrite })), + fsCompareDirectories: (left: string, right: string, request: RequestId, onProgress: Channel) => typedError(__TAURI_INVOKE("fs_compare_directories", { left, right, request, onProgress })), /** * Reads a text file without opening it as a document (previews, quick * definition). Prefers the unsaved buffer when the file is open. @@ -372,10 +375,11 @@ export const commands = { */ lspImplementations: (request: RequestId, doc: DocId, positions: Pos[]) => typedError(__TAURI_INVOKE("lsp_implementations", { request, doc, positions })), /** - * Contents of a virtual document (library class, decompiled code) that a - * language server returned a non-`file:` URI for. Cached per project. + * Contents of a read-only language-server document, including JDT library + * classes and disk class files. Cached per project. */ lspVirtualDocument: (uri: string) => typedError(__TAURI_INVOKE("lsp_virtual_document", { uri })), + lspResolveClassFile: (path: string) => typedError(__TAURI_INVOKE("lsp_resolve_class_file", { path })), /** * References of the symbol at `pos`. `project_wide` also searches project files * outside the language server's analysis scope; bulk scans over many symbols @@ -1418,6 +1422,8 @@ export type AppearanceSettings = { /** Apply `ui_font_family` / `ui_font_size` (otherwise the theme's). */ useCustomFont?: boolean, uiFontFamily?: string, + /** Font tried when `ui_font_family` lacks a glyph; empty = none. */ + uiFontFallback?: string, contrastScrollbars?: boolean, /** Adjust colours for red-green vision deficiency. */ redGreenVision?: boolean, @@ -2512,6 +2518,25 @@ export type Diagnostic = { server?: string | null, }; +export type DirectoryCompareEntry = { + path: string, + status: DirectoryCompareStatus, + leftKind: EntryKind | null, + rightKind: EntryKind | null, + leftLink?: string | null, + rightLink?: string | null, +}; + +export type DirectoryCompareProgress = { + scanned: number, + compared: number, + total: number | null, + bytesRead: number | null, + path: string, +}; + +export type DirectoryCompareStatus = "leftOnly" | "rightOnly" | "different" | "equal"; + /** Changes under a folder (Show History on a directory). */ export type DirectoryHistory = { /** Snapshots of files under the folder and labels, newest first. */ @@ -2950,9 +2975,11 @@ export type EnvReloaded = { shellRead: boolean, }; +export type ErrorData = { type: "tool"; tool: string; missing: boolean }; + export type ErrorKind = "notFound" | "alreadyExists" | "permissionDenied" | "invalidInput" | /** Document version mismatch; the UI must resync the full text. */ -"outOfSync" | "cancelled" | +"outOfSync" | "cancelled" | "timedOut" | /** A required external tool (language server, git, debugger) is unavailable. */ "unavailable" | "io" | "protocol" | "internal"; @@ -3182,6 +3209,7 @@ export type FormField = { export type FormatOptions = { tabSize: number, insertSpaces: boolean, + onSave?: boolean, }; /** One occurrence of a duplicated code fragment. */ @@ -3518,6 +3546,8 @@ export type HunkRange = { export type IdeError = { kind: ErrorKind, message: string, + /** Structured detail for errors the UI acts on; `null` for plain errors. */ + data?: ErrorData | null, }; export type IdeEvent = @@ -3873,6 +3903,10 @@ export type LanguageCodeStyle = LanguageCodeStyle_Serialize | LanguageCodeStyle_ /** Editor › Code Style › › Tabs and Indents (unset fields inherit). */ export type LanguageCodeStyle_Deserialize = { + /** Java formatter: `default` | `intellij` | `custom` (other languages ignore it). */ + formatter?: string | null, + /** Eclipse formatter XML; relative paths start at the project root. */ + formatterPath?: string | null, useTab?: boolean | null, tabSize?: number | null, indent?: number | null, @@ -3885,6 +3919,10 @@ export type LanguageCodeStyle_Deserialize = { /** Editor › Code Style › › Tabs and Indents (unset fields inherit). */ export type LanguageCodeStyle_Serialize = { + /** Java formatter: `default` | `intellij` | `custom` (other languages ignore it). */ + formatter?: string | null, + /** Eclipse formatter XML; relative paths start at the project root. */ + formatterPath?: string | null, useTab?: boolean | null, tabSize?: number | null, indent?: number | null, @@ -4003,6 +4041,7 @@ export type LanguagesSettings = { rust?: RustSettings, go?: GoSettings, typescript?: TypeScriptSettings, + prettier?: PrettierSettings, java?: JavaSettings, }; @@ -4746,6 +4785,15 @@ export type PreparedRequest = { body: string | null, }; +export type PrettierSettings = { + /** Empty path: project node_modules, managed installation, then PATH. */ + path?: string, + languages?: string[], + /** Prefer Prettier even when a language server offers formatting. */ + prefer?: boolean, + runOnSave?: boolean, +}; + /** Memory of one row of the breakdown. */ export type ProcessMemory = { category: MemoryCategory, @@ -6161,7 +6209,7 @@ export type ToolInfo = { export type ToolKind = "languageServer" | "debugger" | /** Loaded into another tool's process, e.g. a jdtls agent. */ -"plugin"; +"plugin" | "formatter"; export type ToolSettings = { /** Executable to launch instead of the managed / `PATH` one; empty = auto-detect. */ @@ -6310,7 +6358,7 @@ export type VirtualDocument = { title: string, /** Origin shown in the tooltip / banner, e.g. the jar path. */ description: string | null, - /** Generated by a decompiler / disassembler rather than real sources. */ + /** Generated Java from a decompiler, rather than real source or a disassembler stub. */ decompiled: boolean, }; diff --git a/src/editor/api.ts b/src/editor/api.ts index 8ce6db51b..9f2ab9a0f 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -42,7 +42,7 @@ export interface EditorDocContext { /** * A read-only virtual document (library class contents from a language * server); `path` is then its URI, not a file: skip file-based features - * (VCS, local history, TODO scanning, breakpoints…). + * (VCS, local history, TODO scanning…). JDT class documents support breakpoints. */ virtual?: boolean; } diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index 5c2250168..8e4b020cf 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -36,7 +36,7 @@ export interface DocPatchedEvent { export interface DocBackend { open(path: string): Promise; - /** Opens the read-only virtual document behind a non-`file:` URI (library class contents). */ + /** Opens a read-only language-server document (library or disk class contents). */ openVirtual(uri: string): Promise; close(doc: DocId): Promise; edit(doc: DocId, baseVersion: number, changes: TextChange[]): Promise; diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index be343fbf4..90e12af6d 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -89,6 +89,7 @@ import { NavigationHistory } from "./model/navHistory"; import { EditHistory } from "./model/editHistory"; import { loadSession, restoreTabsState, serializeSession, storeSession, type FileViewState } from "./model/session"; import { docBackend, type DocBackend } from "./backend"; +import { isClassFile } from "@/platform/classFiles"; import { isVirtualUri, virtualDocument } from "@/platform/virtualDocs"; import type { VirtualDocument } from "@/platform/ipc"; import type { Scheduler } from "./sync/DocSync"; @@ -860,9 +861,21 @@ export class EditorController implements SlotHost { throw new Error("The project is closing"); } - async openFile(path: string, o: OpenOptions = {}) { + async openFile(path: string, o: OpenOptions = {}): Promise { const generation = this.openingGeneration(); if (isVirtualUri(path)) return this.openVirtual(path, o); + if (isClassFile(path)) { + await this.projectTransition; + this.checkOpening(generation); + try { + const target = await call(commands.lspResolveClassFile(path)); + this.checkOpening(generation); + return isVirtualUri(target) ? this.openVirtual(target, o) : this.openFile(target, o); + } catch (e) { + notifications().notify({ level: "warning", title: "Cannot open library class", body: String(e), group: "lsp" }); + return; + } + } await this.projectTransition; this.checkOpening(generation); // Images, binary and huge files open in a contributed viewer unless already open as text. @@ -901,6 +914,7 @@ export class EditorController implements SlotHost { } async openInput(input: EditorInput, o: OpenOptions = {}) { + if (input.path && isClassFile(input.path)) return this.openFile(input.path, o); return this.admitInput(input, o, this.openingGeneration()); } diff --git a/src/editor/core/view/Banner.tsx b/src/editor/core/view/Banner.tsx index 0023ee02a..963665f22 100644 --- a/src/editor/core/view/Banner.tsx +++ b/src/editor/core/view/Banner.tsx @@ -3,6 +3,7 @@ import { Icon, T } from "@/ui"; import { useEditorBanners, type EditorBannerContribution } from "@/workbench/contributions"; import { useDocMeta } from "../model/document"; import styles from "./EditorArea.module.css"; +import { classFileBreakpointReason } from "@/platform/classFiles"; import { tx } from "@/platform/i18n"; const TEXT = { @@ -28,12 +29,22 @@ export const documentBanner: EditorBannerContribution = { * Notice above a library class opened from a language server (IDEA's * "Decompiled .class file" / library source bar): read-only, with its origin. */ -export function VirtualBanner({ decompiled, description }: { decompiled: boolean; description: string | null }) { +export function VirtualBanner({ + decompiled, + description, + path, +}: { + decompiled: boolean; + description: string | null; + path: string; +}) { + const reason = classFileBreakpointReason(path); return (
- + {decompiled ? tx("Decompiled .class file: sources are not available.") : tx("Library source (read-only).")} + {reason && ` ${tx(reason)}`} {description && ( diff --git a/src/editor/core/view/EditorArea.module.css b/src/editor/core/view/EditorArea.module.css index 40fbc0dd1..593cd1ac4 100644 --- a/src/editor/core/view/EditorArea.module.css +++ b/src/editor/core/view/EditorArea.module.css @@ -90,6 +90,10 @@ background: var(--bg-feedback-warning); border-bottom-color: var(--border-feedback-warning); } +.virtualBannerText { + min-width: 0; + padding: 8px 0; +} .bannerText { flex: none; white-space: nowrap; diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index bd4b709a3..cc172aa56 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -398,7 +398,7 @@ function PaneContent({ paneId, tab, tabsVisible }: { paneId: string; tab: Tab; t return ( <> {virtual ? ( - + ) : ( )} diff --git a/src/editor/core/virtual.test.ts b/src/editor/core/virtual.test.ts index e4417eb4d..aaa464afa 100644 --- a/src/editor/core/virtual.test.ts +++ b/src/editor/core/virtual.test.ts @@ -10,7 +10,7 @@ vi.mock("@/platform/virtualDocs", async (importOriginal) => { return { ...actual, virtualDocument: vi.fn(async (uri: string): Promise => { - if (uri !== JDT) throw new Error(`no contents for ${uri}`); + if (uri !== JDT && uri !== "file:///p/target/classes/Lib.class") throw new Error(`no contents for ${uri}`); return { uri, text: TEXT, @@ -30,6 +30,7 @@ const { serializeSession } = await import("./model/session"); const { useDocMeta } = await import("./model/document"); const { registerMenuGroup } = await import("@/workbench/contributions"); const { registerActions } = await import("@/platform/actions"); +const { commands } = await import("@/platform/ipc"); const { resolveMenuItems } = await import("@/ui"); let ctrl: InstanceType | null = null; @@ -55,6 +56,24 @@ describe("virtual editor input", () => { expect(isTextInput({ kind: "diff", key: "d" })).toBe(false); }); + it.each([ + ["/m2/lib.jar!/org/example/Lib.class", JDT, "virtual"], + ["/p/target/classes/Lib.class", "/p/Lib.java", "file"], + ["/p/build/classes/Lib.class", "file:///p/target/classes/Lib.class", "virtual"], + ])("routes %s before choosing a binary viewer", async (path, target, kind) => { + const resolve = vi.spyOn(commands, "lspResolveClassFile").mockResolvedValue({ status: "ok", data: target }); + ctrl = new EditorController(createMemoryBackend({ [target]: TEXT })); + ctrl.start(); + try { + await ctrl.openFile(path!, { focus: false }); + expect(resolve).toHaveBeenCalledWith(path); + expect(activeTab().input).toMatchObject({ path: target, kind }); + expect(ctrl.slotFor(activeTab().id)!.model.ctx().readOnly).toBe(kind === "virtual"); + } finally { + resolve.mockRestore(); + } + }); + it("opens a URI read-only at the target and keeps it out of the saved session", async () => { ctrl = new EditorController(createMemoryBackend({ "/p/App.java": "class App {}\n", [JDT]: TEXT })); ctrl.start(); diff --git a/src/editor/lsp/actions.ts b/src/editor/lsp/actions.ts index 44ca77656..f774f6779 100644 --- a/src/editor/lsp/actions.ts +++ b/src/editor/lsp/actions.ts @@ -5,6 +5,7 @@ import { executeAction, registerActions, type Action } from "@/platform/actions" import { activeEditor, editorsFor, registerSaveParticipant, type TextEditorHandle } from "@/editor/api"; import { maybeEditorService } from "@/workbench/contributions"; import { useProject } from "@/platform/project"; +import { validateLanguages } from "@/platform/languageSettings"; import { explicitCompletion } from "./completion"; import { triggerSignatureHelp } from "./signature"; import { quickDefinition, quickDocumentation } from "./hover"; @@ -122,8 +123,15 @@ export function registerFormatOnSave(): () => void { id: "lsp.formatOnSave", order: 50, beforeSave: async (ctx, handle) => { - if (!handle || ctx.readOnly || !useProject.getState().settings?.editor?.formatOnSave) return; - await formatDocument(handle.view, { explicit: false, selectionOnly: false }); + const settings = useProject.getState().settings; + const prettier = validateLanguages(settings?.languages).prettier; + if ( + !handle || + ctx.readOnly || + (!settings?.editor?.formatOnSave && !(prettier.runOnSave && prettier.languages.includes(ctx.language))) + ) + return; + await formatDocument(handle.view, { explicit: false, selectionOnly: false, onSave: true }); }, }); } diff --git a/src/editor/lsp/format.module.css b/src/editor/lsp/format.module.css new file mode 100644 index 000000000..54460af1f --- /dev/null +++ b/src/editor/lsp/format.module.css @@ -0,0 +1,3 @@ +.diagnostics { + white-space: pre-wrap; +} diff --git a/src/editor/lsp/format.test.ts b/src/editor/lsp/format.test.ts index 75c31b429..8f21dd7c7 100644 --- a/src/editor/lsp/format.test.ts +++ b/src/editor/lsp/format.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from "vitest"; -import { Text } from "@codemirror/state"; +import { describe, expect, it, vi } from "vitest"; +import { EditorState, Text } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { CONTRIBUTED_FORMAT } from "@/editor/api"; +import { useProject } from "@/platform/project"; +import type { Settings } from "@/bindings"; import { formatExcluded, guardFormatChanges, @@ -7,7 +11,7 @@ import { protectedRegions, type FormatGuardOptions, } from "@/platform/formatGuard"; -import { dedentsToColumnZero, formattedLines } from "./format"; +import { dedentsToColumnZero, formatDocument, formattedLines } from "./format"; describe("formattedLines", () => { const doc = Text.of(["fn main(){", "let x=1;", "}", "", "fn other() {}"]); @@ -105,3 +109,28 @@ describe("dedentsToColumnZero", () => { expect(dedentsToColumnZero("fun f() {}\n", "fun f() { }\n")).toBe(false); }); }); + +describe("format on save provider isolation", () => { + it("does not enable contributed formatters when only Prettier save is enabled", async () => { + const previous = useProject.getState(); + const formatter = vi.fn(() => [{ from: 0, to: 6, insert: "SELECT" }]); + const view = new EditorView({ + state: EditorState.create({ + doc: "select", + extensions: [EditorState.languageData.of(() => [{ [CONTRIBUTED_FORMAT]: formatter }])], + }), + }); + try { + const settings = { editor: { formatOnSave: false }, languages: { prettier: { runOnSave: true } } } as Settings; + useProject.setState({ settings }); + expect((await formatDocument(view, { explicit: false, onSave: true })).applied).toBe(false); + expect(formatter).not.toHaveBeenCalled(); + useProject.setState({ settings: { ...settings, editor: { ...settings.editor, formatOnSave: true } } }); + expect((await formatDocument(view, { explicit: false, onSave: true })).applied).toBe(true); + expect(view.state.doc.toString()).toBe("SELECT"); + } finally { + view.destroy(); + useProject.setState(previous); + } + }); +}); diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts index 1e37e1554..061e377c1 100644 --- a/src/editor/lsp/format.ts +++ b/src/editor/lsp/format.ts @@ -5,11 +5,14 @@ * reports "Formatted N lines" with a link to the Reformat File dialog * (⌥⇧⌘L: scope and Optimize Imports). */ +import { createElement } from "react"; +import styles from "./format.module.css"; import { indentUnit } from "@codemirror/language"; import { isolateHistory } from "@codemirror/commands"; import type { EditorState, Text } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; -import { commands } from "@/platform/ipc"; +import { commands, IpcError } from "@/platform/ipc"; +import { notifications } from "@/workbench/contributions"; import { shortcutLabel } from "@/platform/keymap"; import { executeAction } from "@/platform/actions"; import { @@ -78,14 +81,17 @@ export function formatOptionsOf(state: EditorState): { tabSize: number; insertSp export async function formatDocument( view: EditorView, - opts: { explicit: boolean; selectionOnly?: boolean } = { explicit: true }, + opts: { explicit: boolean; selectionOnly?: boolean; onSave?: boolean } = { explicit: true }, ): Promise { const result = await format(view, opts); if (opts.explicit && result.applied) formattedHint(view, result.lines); return result; } -async function format(view: EditorView, opts: { explicit: boolean; selectionOnly?: boolean }): Promise { +async function format( + view: EditorView, + opts: { explicit: boolean; selectionOnly?: boolean; onSave?: boolean }, +): Promise { const { project, settings } = useProject.getState(); const guard = formatGuardOptions(settings); const path = docOf(view)?.path; @@ -95,7 +101,10 @@ async function format(view: EditorView, opts: { explicit: boolean; selectionOnly return NOTHING; } const own = contributedAt(view.state, CONTRIBUTED_FORMAT, 0); - if (own) return applyContributedFormat(view, own, opts, guard); + if (own) { + if (opts.onSave && !settings?.editor?.formatOnSave) return NOTHING; + return applyContributedFormat(view, own, opts, guard); + } const doc = docOf(view); if (!doc || doc.readOnly) return NOTHING; // The server formats the backend's copy: let pending keystrokes reach it first. @@ -107,9 +116,25 @@ async function format(view: EditorView, opts: { explicit: boolean; selectionOnly const options = formatOptionsOf(state); let edits; try { - edits = await lspRequest(() => commands.lspFormat(doc.doc, range, options)); + edits = await lspRequest(() => commands.lspFormat(doc.doc, range, { ...options, onSave: opts.onSave })); } catch (e) { - reportLspError(view, e, opts.explicit); + if (e instanceof IpcError && e.data?.type === "tool" && e.data.tool === "prettier") { + if (e.kind === "notFound" && e.data.missing) { + await executeAction("tools.promptInstall", { name: "prettier", sticky: !opts.onSave }); + } else if (e.kind !== "cancelled") { + notifications().notify({ + group: "tools", + level: "error", + title: tx("Prettier formatting failed"), + body: + e.kind === "timedOut" + ? tx("Prettier formatting timed out") + : createElement("span", { className: styles.diagnostics }, e.message), + key: `prettier-format-error:${doc.path}`, + sticky: false, + }); + } + } else reportLspError(view, e, opts.explicit); return NOTHING; } if (view.state.doc !== state.doc) { @@ -179,7 +204,7 @@ export async function formatLines(view: EditorView, from: number, to: number): P function applyContributedFormat( view: EditorView, format: ContributedFormatter, - opts: { explicit: boolean; selectionOnly?: boolean }, + opts: { explicit: boolean; selectionOnly?: boolean; onSave?: boolean }, guard: FormatGuardOptions, ): FormatResult { const state = view.state; diff --git a/src/editor/lsp/formatErrors.test.tsx b/src/editor/lsp/formatErrors.test.tsx new file mode 100644 index 000000000..aa21c997f --- /dev/null +++ b/src/editor/lsp/formatErrors.test.tsx @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen } from "@testing-library/react"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { IdeError } from "@/bindings"; +import { registerAction } from "@/platform/actions"; +import { _resetI18nForTests, setLocale } from "@/platform/i18n"; +import { IpcError } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { provideNotificationService } from "@/workbench/contributions"; +import { Balloons } from "@/workbench/Notifications"; +import { + balloonTimeout, + clearNotifications, + notificationServiceImpl, + useNotifications, +} from "@/workbench/notificationStore"; +import { formatDocument } from "./format"; +import * as lsp from "./lsp"; + +const tool = { type: "tool", tool: "prettier", missing: false } as const; +const views: EditorView[] = []; +const viewFor = (path = "/project/file.css") => { + const view = new EditorView({ + state: EditorState.create({ + doc: ".card {", + extensions: [lsp.lspDoc.of({ doc: 1, path, language: "css", uiMode: null, readOnly: false })], + }), + }); + views.push(view); + return view; +}; +const failWith = (error: IdeError) => vi.spyOn(lsp, "lspRequest").mockRejectedValue(new IpcError(error)); + +beforeEach(() => { + clearNotifications(); + provideNotificationService(notificationServiceImpl); +}); +afterEach(() => { + views.splice(0).forEach((view) => view.destroy()); + clearNotifications(); + vi.restoreAllMocks(); + _resetI18nForTests(); +}); + +describe("external formatter errors", () => { + it("uses structured missing-tool information to request the shared install prompt", async () => { + const prompt = vi.fn(); + const dispose = registerAction({ + id: "tools.promptInstall", + title: "Install Missing Tool", + run: ({ arg }) => prompt(arg), + }); + try { + failWith({ kind: "notFound", message: "unrelated diagnostic", data: { ...tool, missing: true } }); + await formatDocument(viewFor()); + expect(prompt).toHaveBeenCalledWith({ name: "prettier", sticky: true }); + await formatDocument(viewFor(), { explicit: false, onSave: true }); + expect(prompt).toHaveBeenLastCalledWith({ name: "prettier", sticky: false }); + expect(useNotifications.getState().items).toHaveLength(0); + } finally { + dispose(); + } + }); + + it("does not treat text alone or a broken custom executable as a missing installation", async () => { + const prompt = vi.fn(); + const dispose = registerAction({ + id: "tools.promptInstall", + title: "Install Missing Tool", + run: ({ arg }) => prompt(arg), + }); + try { + const request = failWith({ kind: "unavailable", message: "Prettier is not installed" }); + const view = viewFor(); + await formatDocument(view, { explicit: false }); + expect(useNotifications.getState().items).toHaveLength(0); + request.mockRejectedValue(new IpcError({ kind: "notFound", message: "custom executable missing", data: tool })); + await formatDocument(view); + expect(prompt).not.toHaveBeenCalled(); + expect(useNotifications.getState().items).toHaveLength(1); + } finally { + dispose(); + } + }); + + it("keeps stderr untranslated in a Chinese notification and replaces failures per file, including on save", async () => { + await setLocale("zh"); + const previous = useProject.getState().settings; + useProject.setState({ settings: null }); + try { + const stderr = "Formatting timed out"; + failWith({ kind: "invalidInput", message: stderr, data: tool }); + const view = viewFor(); + for (let i = 0; i < 3; i++) await formatDocument(view, { explicit: false, onSave: true }); + const notes = useNotifications.getState().items; + expect(notes).toHaveLength(1); + expect(notes[0]).toMatchObject({ sticky: false, title: "Prettier 格式化失败" }); + expect(balloonTimeout(notes[0]!)).toBe(10000); + render(); + expect(screen.getByRole("alert")).toHaveTextContent(stderr); + expect(screen.queryByText("格式化超时")).toBeNull(); + expect(view.state.doc.toString()).toBe(".card {"); + await act(async () => { + await formatDocument(viewFor("/project/other.css")); + }); + expect(useNotifications.getState().items).toHaveLength(2); + } finally { + useProject.setState({ settings: previous }); + } + }); + + it("localizes the timeout explanation by kind and ignores cancellation", async () => { + await setLocale("zh"); + const request = failWith({ kind: "timedOut", message: "arbitrary backend text", data: tool }); + const view = viewFor(); + await formatDocument(view); + expect(useNotifications.getState().items[0]).toMatchObject({ body: "Prettier 格式化超时", sticky: false }); + clearNotifications(); + request.mockRejectedValue(new IpcError({ kind: "cancelled", message: "cancelled", data: tool })); + await formatDocument(view); + expect(useNotifications.getState().items).toHaveLength(0); + }); +}); diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index 9dc2d5cdf..7afdda099 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -1,8 +1,8 @@ /** - * Go to declaration / implementation / type declaration (⌘B, ⌥⌘B, ⇧⌘B and - * ⌘-click with a link underline while ⌘ is held), the multiple-targets - * chooser, and document highlights (occurrences of the symbol at the caret, - * plus the sticky find.highlightUsages variant). + * Go to declaration / implementation / type declaration (⌘B, ⌥⌘B, ⇧⌘B, + * ⌘-click with a link underline while ⌘ is held, and a plain middle-button + * click), the multiple-targets chooser, and document highlights (occurrences + * of the symbol at the caret, plus the sticky find.highlightUsages variant). */ import { EditorSelection, Prec, StateEffect, StateField, type Extension, type Text } from "@codemirror/state"; import { @@ -489,7 +489,7 @@ const linkPlugin = ViewPlugin.fromClass( this.show(false); }, mousedown(e, view) { - if (e.button !== 0) return false; + if (e.button !== 0 && e.button !== 1) return false; if ( dispatchMouseShortcut(e, () => { const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false); @@ -502,7 +502,8 @@ const linkPlugin = ViewPlugin.fromClass( e.preventDefault(); return true; } - if (!isNavigationGesture(e)) return false; + // IDEA parity: middle button navigates on a plain click, without the modifier. + if (e.button === 1 ? e.metaKey || e.ctrlKey || e.altKey || e.shiftKey : !isNavigationGesture(e)) return false; const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false); if (pos == null) return false; e.preventDefault(); diff --git a/src/editor/lsp/navigationMouse.test.ts b/src/editor/lsp/navigationMouse.test.ts index 3940c61df..5ec3b9d53 100644 --- a/src/editor/lsp/navigationMouse.test.ts +++ b/src/editor/lsp/navigationMouse.test.ts @@ -94,6 +94,14 @@ describe("mouse navigation", () => { expect(lspGoto).toHaveBeenCalledExactlyOnceWith(1, { line: 0, col: 9 }, "definition"); }); + it("opens the definition from a plain middle-button click", async () => { + const event = click({ button: 1 }); + await vi.waitFor(() => expect(openFile).toHaveBeenCalledOnce()); + expect(lspGoto).toHaveBeenCalledExactlyOnceWith(1, { line: 0, col: 9 }, "definition"); + expect(view.state.selection.main.head).toBe(9); + expect(event.defaultPrevented).toBe(true); + }); + it.each([ { button: 1, metaKey: true, altKey: true }, { button: 2, metaKey: true, altKey: true }, diff --git a/src/editor/lsp/rename.ts b/src/editor/lsp/rename.ts index 48fb7ec45..aaeed7547 100644 --- a/src/editor/lsp/rename.ts +++ b/src/editor/lsp/rename.ts @@ -15,12 +15,14 @@ import type { EditorView } from "@codemirror/view"; import { commands, isCancelled, + isNoServer, nextRequestId, unavailableText, type Pos, type RequestId, type WorkspaceEdit, } from "@/platform/ipc"; +import { executeAction } from "@/platform/actions"; import { whenSynced, type EditorDocContext } from "@/editor/api"; import { structure } from "@/editor/structure"; import { docOf, incompleteNote, isUnavailable, lspRequest, reportLspError } from "./lsp"; @@ -78,7 +80,10 @@ export async function startRename(view: EditorView) { range = await lspRequest(() => commands.lspPrepareRename(doc.doc, pos)); } catch (e) { if (isCancelled(e)) return; - if (!(await startSyntaxRename(view, doc, pos, e))) reportLspError(view, e, true); + if (await startSyntaxRename(view, doc, pos, e)) return; + // No server covers this file (a plain text file): Shift+F6 renames the file, like IDEA. + if (isNoServer(e) && (await executeAction("project.rename"))) return; + reportLspError(view, e, true); return; } if (!range) { diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index 2b811ad5d..366cbb6da 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -30,6 +30,13 @@ vi.mock("@/platform/ipc", async (orig) => { return { ...actual, isTauri: () => true, commands: { ...actual.commands, ...cmd } }; }); +const executeAction = vi.hoisted(() => vi.fn()); +vi.mock("@/platform/actions", async (orig) => { + const actual = await orig(); + executeAction.mockImplementation(actual.executeAction); + return { ...actual, executeAction }; +}); + const openFile = vi.fn(async () => {}); const { provideEditorService } = await import("@/workbench/contributions"); provideEditorService({ openFile } as never); @@ -488,6 +495,34 @@ describe("rename wiring", () => { }); }); + it("renames the file itself when no server covers it (a plain text file)", async () => { + cmd.lspPrepareRename.mockReturnValueOnce( + Promise.resolve({ + status: "error", + error: { kind: "unavailable", message: "no running language server supports rename" } as IdeError, + }), + ); + cmd.syntaxSupport.mockReturnValueOnce(ok({ tree: false, locals: false })); + executeAction.mockResolvedValueOnce(true); + const view = editor("# notes", "markdown", 2); + await startRename(view); + expect(executeAction).toHaveBeenCalledWith("project.rename"); + expect(document.querySelector(".cm-lsp-hint")).toBeNull(); + }); + + it("keeps the hint when a server runs but lacks rename (no file fallback)", async () => { + cmd.lspPrepareRename.mockReturnValueOnce( + Promise.resolve({ + status: "error", + error: { kind: "unavailable", message: "jdtls doesn't support rename" } as IdeError, + }), + ); + const view = editor("class A {}", "java", 6); + await startRename(view); + expect(executeAction).not.toHaveBeenCalledWith("project.rename"); + expect(document.querySelector(".cm-lsp-hint")?.textContent).toContain("jdtls doesn't support rename"); + }); + it("respects a server refusal over valid code, but falls back over broken code", async () => { cmd.lspPrepareRename.mockReturnValueOnce(ok(null)); const view = editor("const x = 1;", "typescript", 6); diff --git a/src/features/agents/Agents.module.css b/src/features/agents/Agents.module.css index ff72619d1..a9b31d3f2 100644 --- a/src/features/agents/Agents.module.css +++ b/src/features/agents/Agents.module.css @@ -3,7 +3,6 @@ min-height: 0; display: flex; flex-direction: column; - container-type: inline-size; } .content { flex: 1; @@ -137,7 +136,7 @@ gap: 5px; } -/* A conversation docked in the tool window, above the task list. */ +/* A docked conversation owns the tool window; history opens on demand. */ .docked { flex: 1 1 0; min-height: 0; @@ -168,30 +167,31 @@ flex-direction: column; } .tasks { - flex: 2 1 0; + flex: 1; min-height: 0; min-width: 0; display: flex; flex-direction: column; - border-top: 1px solid var(--border); +} +.history { + display: flex; + flex-direction: column; + width: min(400px, calc(100vw - 24px)); + height: min(420px, 65vh); + overflow: hidden; +} +.historyTitle { + flex: none; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + font-weight: 600; } -/* Keep the conversation usable in a short bottom tool window; the tree scrolls. */ -.docked + .tasks { - flex: 0 0 20%; - max-height: 160px; -} - -/* A wide bottom window has room beside the conversation, preserving message height. */ -@container (min-width: 600px) { - .content { - flex-direction: row; - } - .docked + .tasks { - flex: 0 0 220px; - max-width: 30%; - max-height: none; - border-top: 0; - border-left: 1px solid var(--border); - } +.location { + display: flex; + align-items: center; + gap: 2px; + padding: 1px; + border: 1px solid var(--border); + border-radius: var(--radius-small); } diff --git a/src/features/agents/AgentsView.test.tsx b/src/features/agents/AgentsView.test.tsx index a8ec9d370..a154ef438 100644 --- a/src/features/agents/AgentsView.test.tsx +++ b/src/features/agents/AgentsView.test.tsx @@ -6,9 +6,9 @@ import { registerActions } from "@/platform/actions"; import { EDITOR_TAB_MIME, type EditorInput } from "@/workbench/contributions"; import { confirmChecked, showError } from "@/workbench/messages"; import { ContextMenuHost, dragSession, hideContextMenu } from "@/ui"; -import { AgentsView } from "./AgentsView"; +import { AgentsHeaderActions, AgentsLocationActions, AgentsView } from "./AgentsView"; import { SESSION_KIND } from "./model"; -import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, undockTask, useAgents } from "./store"; +import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, setSessionLocation, useAgents } from "./store"; import { taskActions, taskMenu } from "./taskActions"; import styles from "./Agents.module.css"; @@ -39,7 +39,7 @@ vi.mock("./store", async (original) => ({ unarchiveTask: vi.fn(async () => {}), deleteTask: vi.fn(async () => {}), dockTask: vi.fn(), - undockTask: vi.fn(), + setSessionLocation: vi.fn(), })); vi.mock("./SessionEditor", () => ({ SessionEditor: () =>
})); @@ -103,6 +103,8 @@ beforeEach(() => { error: null, tasks: new Map([task("one"), task("two"), task("three"), task("four")].map((t) => [t.id, t])), docked: null, + homeOpen: false, + sessionLocation: "editor", }); dispose = registerActions(taskActions); }); @@ -115,6 +117,8 @@ afterEach(() => { function setup() { render( <> + + , @@ -158,24 +162,44 @@ describe("Agents selection", () => { }); describe("Agents docked view", () => { - it("shows the docked conversation above the list and switches it by selecting a task", () => { + it("opens session history on demand and closes it after switching tasks", () => { useAgents.setState({ docked: "one" }); setup(); expect(screen.getByTestId("docked-session")).toBeTruthy(); - expect(screen.getByRole("button", { name: "Open in Editor" })).toBeTruthy(); - expect(openTask).not.toHaveBeenCalled(); - select("two"); - expect(actions.executeAction).toHaveBeenCalledWith("agents.dockTask", "two"); - expect(dockTask).toHaveBeenCalledWith("two"); + expect(screen.getByRole("button", { name: "Chat in Editor" })).toBeTruthy(); expect(openTask).not.toHaveBeenCalled(); + expect(screen.queryByRole("tree", { name: "Agent tasks" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Agent sessions" })); + fireEvent.click(within(screen.getByRole("tree", { name: "Agent tasks" })).getByText("two")); + expect(screen.queryByRole("dialog", { name: "Agent sessions" })).toBeNull(); + expect(actions.executeAction).toHaveBeenCalledWith("agents.openTask", "two"); + expect(openTask).toHaveBeenCalledWith("two"); }); - it("hides the docked task from the list below", () => { + it("lets keyboard users browse history before confirming a session", () => { useAgents.setState({ docked: "one" }); setup(); + fireEvent.click(screen.getByRole("button", { name: "Agent sessions" })); + const tree = screen.getByRole("tree", { name: "Agent tasks" }); + fireEvent.keyDown(tree, { key: "ArrowDown" }); + expect(screen.getByRole("dialog", { name: "Agent sessions" })).toBeTruthy(); + expect(openTask).not.toHaveBeenCalled(); + fireEvent.keyDown(tree, { key: "Enter" }); + expect(openTask).toHaveBeenCalledWith("two"); + expect(screen.queryByRole("dialog", { name: "Agent sessions" })).toBeNull(); + }); + + it("includes the current session in history and keeps the conversation mounted", () => { + useAgents.setState({ docked: "one" }); + setup(); + const session = screen.getByTestId("docked-session"); + fireEvent.click(screen.getByRole("button", { name: "Agent sessions" })); const tree = within(screen.getByRole("tree", { name: "Agent tasks" })); - expect(tree.queryByText("one")).toBeNull(); + expect(tree.getByText("one")).toBeTruthy(); expect(tree.getByText("two")).toBeTruthy(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("tree", { name: "Agent tasks" })).toBeNull(); + expect(screen.getByTestId("docked-session")).toBe(session); }); it("gives the whole tool window to the conversation when the docked task is the only one", () => { @@ -185,11 +209,15 @@ describe("Agents docked view", () => { expect(screen.getByTestId("docked-session")).toBeTruthy(); }); - it("undocks from the docked conversation's bar", () => { - useAgents.setState({ docked: "one" }); + it("switches placement from the global Agents header", () => { + useAgents.setState({ docked: "one", sessionLocation: "toolWindow" }); setup(); - fireEvent.click(screen.getByRole("button", { name: "Open in Editor" })); - expect(undockTask).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("button", { name: "Open in Editor" })).toBeNull(); + expect(screen.getByRole("button", { name: "Chat in Tool Window" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "Chat in Editor" })); + expect(setSessionLocation).toHaveBeenCalledWith("editor"); + fireEvent.click(screen.getByRole("button", { name: "Chat in Tool Window" })); + expect(setSessionLocation).toHaveBeenCalledWith("toolWindow"); }); it("opens tabs as usual without a docked conversation", () => { @@ -282,21 +310,12 @@ describe("Agents bulk actions", () => { }); describe("Agents task menu", () => { - it("offers to dock a task into the tool window", async () => { - setup(); - context("one"); - fireEvent.click(screen.getByRole("menuitem", { name: "Open in Tool Window" })); - await waitFor(() => expect(dockTask).toHaveBeenCalledWith("one")); - }); - - it("offers to move the docked task back to an editor", async () => { - useAgents.setState({ docked: "one" }); - setup(); - // The docked task is not listed; its menu lives on the docked conversation's header. + it.each([null, "one"])("keeps placement out of session menus (docked: %s)", (docked) => { + useAgents.setState({ docked }); const menu = taskMenu([useAgents.getState().tasks.get("one")!]); - expect(menu).toContainEqual(expect.objectContaining({ action: "agents.undockTask", label: "Open in Editor" })); - await taskActions.find((action) => action.id === "agents.undockTask")!.run({ ctx: {}, arg: "one" }); - await waitFor(() => expect(undockTask).toHaveBeenCalledTimes(1)); + expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.dockTask" })); + expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.undockTask" })); + expect(menu).toContainEqual(expect.objectContaining({ action: "agents.openTask" })); }); }); diff --git a/src/features/agents/AgentsView.tsx b/src/features/agents/AgentsView.tsx index 7dafe617a..30651b9d6 100644 --- a/src/features/agents/AgentsView.tsx +++ b/src/features/agents/AgentsView.tsx @@ -7,6 +7,7 @@ import { EmptyState, Icon, IconButton, + Popup, Spinner, T, Tree, @@ -18,16 +19,29 @@ import { SESSION_KIND, TASK_DRAG, age, branchLabel, groupTasks, sessionKey, type import { openHome, useAgents } from "./store"; import { taskMenu } from "./taskActions"; import { SessionEditor } from "./SessionEditor"; +import { HomeEditor } from "./HomeEditor"; import styles from "./Agents.module.css"; import { basename, useProject } from "@/platform/project"; import { useTx } from "@/platform/i18n"; import { executeAction } from "@/platform/actions"; export function AgentsHeaderActions() { + return ; +} + +export function AgentsLocationActions() { + const location = useAgents((s) => s.sessionLocation); + const tx = useTx(); return ( - <> - - +
+ + +
); } @@ -77,6 +91,10 @@ export function AgentsView() { const unread = useAgents((s) => s.unread); const providers = useAgents((s) => s.providers); const docked = useAgents((s) => s.docked); + const homeOpen = useAgents((s) => s.homeOpen); + const inline = docked !== null || homeOpen; + const historyRef = useRef(null); + const [historyOpen, setHistoryOpen] = useState(false); const [collapsed, setCollapsed] = useState>(() => new Set(["g:archived"])); const [selected, setSelected] = useState>(() => { const task = sessionTask(maybeEditorService()?.activeInput?.()); @@ -95,11 +113,7 @@ export function AgentsView() { }), [], ); - // The docked conversation is already shown above; listing it again is noise. - const roots = useMemo( - () => groupTasks([...tasks.values()].filter((t) => t.id !== docked)).map((group) => ({ kind: "group", group })), - [tasks, docked], - ); + const roots = useMemo(() => groupTasks(tasks.values()).map((group) => ({ kind: "group", group })), [tasks]); const expanded = useMemo(() => new Set(roots.map(nodeKey).filter((k) => !collapsed.has(k))), [roots, collapsed]); // Dropping a session editor tab anywhere on the tool window docks it. const viewRef = useRef(null); @@ -126,123 +140,156 @@ export function AgentsView() { ); if (error) return {error}; const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id; - // While a conversation is docked, clicking a task switches it instead of opening a tab. - const showTask = (task: string) => void executeAction(docked ? "agents.dockTask" : "agents.openTask", task); + // Opening any session follows the global placement preference. + const showTask = (task: string) => { + setHistoryOpen(false); + void executeAction("agents.openTask", task); + }; const dockedTitle = docked ? (tasks.get(docked)?.title ?? docked) : null; const dragTask = (e: ReactPointerEvent, task: string) => startPointerDrag(e, e.currentTarget, { type: TASK_DRAG, payload: task }); + const taskList = ( +
+ + aria-label="Agent tasks" + roots={roots} + getKey={nodeKey} + getLabel={(n) => (n.kind === "group" ? n.group.label : n.task.title)} + getChildren={(n) => + n.kind === "group" ? n.group.tasks.map((task) => ({ kind: "task", task }) as Node) : undefined + } + isExpandable={(n) => n.kind === "group"} + expanded={expanded} + onExpandedChange={(_next, node, open) => + setCollapsed((c) => { + const s = new Set(c); + if (open) s.delete(nodeKey(node)); + else s.add(nodeKey(node)); + return s; + }) + } + selected={selected} + onSelectionChange={(sel, lead) => { + setSelected(sel); + if (!inline && sel.size === 1 && lead?.kind === "task" && sel.has(lead.task.id)) showTask(lead.task.id); + }} + onActivate={(n) => n.kind === "task" && showTask(n.task.id)} + onContextMenu={(n, sel, e) => { + if (n?.kind !== "task") return; + const selection = [...sel].flatMap((id) => { + const task = tasks.get(id); + return task ? [task] : []; + }); + showContextMenu(e, taskMenu(selection)); + }} + renderNode={(n) => + n.kind === "group" ? ( + + + {n.group.label} + + {n.group.tasks.length} + + ) : ( + dragTask(e, n.task.id)} + onClick={(e) => { + if (inline && !e.metaKey && !e.ctrlKey && !e.shiftKey) showTask(n.task.id); + }} + > + + + {n.task.title} + + {unread.has(n.task.id) && ( + + )} + {project?.workspace && ( + + {basename(n.task.projectRoot ?? project.root)} + + )} + + {providerName(n.task.provider)} + {n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""} + + {age(n.task.updatedMs ?? 0)} + + ) + } + empty={ +
+ No agent tasks yet + +
+ } + /> +
+ ); + return (
- {docked && ( + {inline && (
+ { + setSelected(new Set(docked ? [docked] : [])); + setHistoryOpen((open) => !open); + }} + /> dragTask(e, docked)} + onPointerDown={(e) => docked && dragTask(e, docked)} > - {dockedTitle} + {homeOpen ? New Agent Task : dockedTitle} -
- + {homeOpen ? ( + + ) : ( + docked && ( + + ) + )}
)} - {/* Docked with nothing else to list: give the whole window to the conversation. */} - {(roots.length > 0 || !docked) && ( -
- - aria-label="Agent tasks" - roots={roots} - getKey={nodeKey} - getLabel={(n) => (n.kind === "group" ? n.group.label : n.task.title)} - getChildren={(n) => - n.kind === "group" ? n.group.tasks.map((task) => ({ kind: "task", task }) as Node) : undefined - } - isExpandable={(n) => n.kind === "group"} - expanded={expanded} - onExpandedChange={(_next, node, open) => - setCollapsed((c) => { - const s = new Set(c); - if (open) s.delete(nodeKey(node)); - else s.add(nodeKey(node)); - return s; - }) - } - selected={selected} - onSelectionChange={(sel, lead) => { - setSelected(sel); - if (sel.size === 1 && lead?.kind === "task" && sel.has(lead.task.id)) showTask(lead.task.id); - }} - onActivate={(n) => n.kind === "task" && showTask(n.task.id)} - onContextMenu={(n, sel, e) => { - if (n?.kind !== "task") return; - const selection = [...sel].flatMap((id) => { - const task = tasks.get(id); - return task ? [task] : []; - }); - showContextMenu(e, taskMenu(selection)); - }} - renderNode={(n) => - n.kind === "group" ? ( - - - {n.group.label} - - {n.group.tasks.length} - - ) : ( - dragTask(e, n.task.id)} - > - - - {n.task.title} - - {unread.has(n.task.id) && ( - - )} - {project?.workspace && ( - - {basename(n.task.projectRoot ?? project.root)} - - )} - - {providerName(n.task.provider)} - {n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""} - - {age(n.task.updatedMs ?? 0)} - - ) - } - empty={ -
- No agent tasks yet - -
- } - /> -
+ {!inline && taskList} + {inline && ( + setHistoryOpen(false)} + aria-label={tx("Agent sessions")} + className={styles.history} + > +
+ Agent sessions +
+ {taskList} +
)}
diff --git a/src/features/agents/Composer.module.css b/src/features/agents/Composer.module.css index f803502f0..e6f095e35 100644 --- a/src/features/agents/Composer.module.css +++ b/src/features/agents/Composer.module.css @@ -1,5 +1,7 @@ /* A framed text area (IDEA text field frame), with the pickers in its bottom row. */ .composer { + container: agent-composer / inline-size; + min-width: 0; position: relative; display: flex; flex-direction: column; @@ -44,9 +46,85 @@ padding: 4px 6px 6px; cursor: default; } +.toolbar { + display: flex; + flex: 1; + flex-wrap: wrap; + align-items: center; + min-width: 0; + gap: 2px; +} +.actions { + display: flex; + flex: none; + align-items: center; + gap: 2px; + margin-left: auto; +} .spacer { flex: 1; } +.configPicker { + display: flex; + min-width: 0; +} +.configPicker > button { + max-width: 100%; +} +.settingsMenu { + display: none; +} +.settingsPopup { + width: min(320px, calc(100vw - 24px)); + padding: 12px; +} +.settingsTitle { + margin-bottom: 12px; + font-weight: 600; +} +.settingsRow { + display: grid; + grid-template-columns: minmax(0, 80px) minmax(0, 1fr); + align-items: center; + gap: 12px; + margin-top: 8px; +} +.settingsLabel { + min-width: 0; + color: var(--fg-muted); + overflow-wrap: anywhere; +} +@container agent-composer (max-width: 600px) { + .bar { + flex-direction: column; + align-items: stretch; + gap: 6px; + } + .toolbar { + flex-wrap: nowrap; + } + .toolbar > button, + .toolbar > .configPicker { + flex: 0 1 auto; + min-width: 0; + max-width: calc((100% - 28px) / 2); + } + .configPicker > button { + width: 100%; + } + .inlineSetting { + display: none; + } + .settingsMenu { + display: flex; + flex: none; + margin-left: auto; + } + .actions { + width: 100%; + margin-left: 0; + } +} .pickerValue { display: inline-flex; align-items: baseline; diff --git a/src/features/agents/Composer.tsx b/src/features/agents/Composer.tsx index 21535d108..15ca6b5cb 100644 --- a/src/features/agents/Composer.tsx +++ b/src/features/agents/Composer.tsx @@ -403,30 +403,37 @@ export function Composer(props: ComposerProps) { }} />
e.stopPropagation()}> - {props.toolbar} - - { - const glue = text && !/\s$/.test(text) ? " " : ""; - setText(`${text}${glue}@`, text.length + glue.length + 1); - }} - /> - imageInput.current?.click()} /> - { - readImageFiles(e.target.files ?? []); - e.target.value = ""; - }} - /> - {props.actions} +
{props.toolbar}
+
+ { + const glue = text && !/\s$/.test(text) ? " " : ""; + setText(`${text}${glue}@`, text.length + glue.length + 1); + }} + /> + imageInput.current?.click()} + /> + { + readImageFiles(e.target.files ?? []); + e.target.value = ""; + }} + /> + + {props.actions} +
); diff --git a/src/features/agents/ConfigPickers.test.tsx b/src/features/agents/ConfigPickers.test.tsx index 2c7c737de..e0c0a828c 100644 --- a/src/features/agents/ConfigPickers.test.tsx +++ b/src/features/agents/ConfigPickers.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { AgentConfigOption } from "@/bindings"; import { ConfigPickers, configPickerOptions } from "./ConfigPickers"; @@ -85,3 +85,28 @@ it("sends the complete nested model id through ConfigPickers", () => { fireEvent.click(screen.getByRole("option", { name: "Beamcmc/Beam" })); expect(onChange).toHaveBeenCalledExactlyOnceWith("model", "9router/cmc/Beam"); }); + +it("edits secondary settings from the compact popup and restores focus when dismissed", () => { + const approval: AgentConfigOption = { + id: "approvals", + name: "Approvals", + category: "approval", + description: null, + current: "ask", + via: "option", + values: [value("ask", "Ask first"), value("bypass", "Bypass approvals")], + }; + const onChange = vi.fn(); + render(); + const trigger = screen.getByRole("button", { name: "Agent settings" }); + trigger.focus(); + fireEvent.click(trigger); + const dialog = screen.getByRole("dialog", { name: "Agent settings" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Approvals" })); + fireEvent.click(screen.getByRole("option", { name: "Ask first" })); + expect(onChange).toHaveBeenCalledExactlyOnceWith("approvals", "ask"); + expect(dialog).toBeInTheDocument(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("dialog", { name: "Agent settings" })).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); +}); diff --git a/src/features/agents/ConfigPickers.tsx b/src/features/agents/ConfigPickers.tsx index 08a0e8af8..dbb6f2331 100644 --- a/src/features/agents/ConfigPickers.tsx +++ b/src/features/agents/ConfigPickers.tsx @@ -2,9 +2,11 @@ * Model, mode, approvals and thinking-effort dropdowns for a composer's bottom row. * Modes and approvals use icons; levels are plain text. */ +import { useRef, useState } from "react"; +import clsx from "clsx"; import type { AgentConfigOption } from "@/bindings"; import { useTx } from "@/platform/i18n"; -import { Select } from "@/ui"; +import { IconButton, Popup, Select, T } from "@/ui"; import type { SelectOption } from "@/ui/Select"; import { mainOptions, modeIcon } from "./composerModel"; import styles from "./Composer.module.css"; @@ -43,47 +45,100 @@ export function ConfigPickers({ onChange: (option: string, value: string) => void; }) { const tx = useTx(); + const [open, setOpen] = useState(false); + const anchor = useRef(null); + const options = mainOptions(config); + const secondary = options.filter((o) => o.category !== "model"); + const approval = secondary.find((o) => o.category === "approval" || o.category === "mode"); + const summary = secondary + .map((o) => { + const value = values?.[o.id] ?? o.current; + return `${tx(o.name)}: ${o.values.find((v) => v.value === value)?.name ?? tx("Default")}`; + }) + .join(" · "); return ( <> - {mainOptions(config).map((o) => { - const isMode = o.category === "mode"; - const value = values?.[o.id] ?? o.current ?? null; - const options = configPickerOptions(o); - - return ( - ({ + ...opt, + icon: + option.category === "approval" + ? opt.value === "bypass" + ? "unlock" + : "lock" + : option.category === "mode" + ? modeIcon(opt.value) + : undefined, + }))} + searchable={option.category === "model"} + onChange={(v) => onChange(option.id, v)} + renderValue={ + option.category === "thought_level" && !framed + ? (current) => ( + + {option.name} + {current?.label ?? tx("Default")} + + ) + : undefined + } + /> + ); +} diff --git a/src/features/agents/Home.module.css b/src/features/agents/Home.module.css index f86221152..67cad0ade 100644 --- a/src/features/agents/Home.module.css +++ b/src/features/agents/Home.module.css @@ -60,3 +60,47 @@ .warning { color: var(--danger); } + +.docked { + justify-content: flex-end; + padding: 16px 12px 12px; +} +.docked .inner { + gap: 8px; +} +.docked .where { + order: -1; + flex-wrap: nowrap; + min-width: 0; + gap: 6px; +} +.docked .project, +.docked .projectPicker { + flex: 0 1 auto; + min-width: 0; + max-width: 30%; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.docked .branch, +.docked .basePicker { + order: 1; + flex: 1 1 0; + min-width: 0; + padding: 0; +} +.docked .isolation { + order: 2; + flex: 0 1 auto; + min-width: 0; + max-width: 45%; + margin-left: auto; +} +.branchName { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/features/agents/HomeEditor.tsx b/src/features/agents/HomeEditor.tsx index d7dfa29e9..3ff76b005 100644 --- a/src/features/agents/HomeEditor.tsx +++ b/src/features/agents/HomeEditor.tsx @@ -1,4 +1,5 @@ /** New Task page: one prompt box with the agent and its settings; where it works under it. */ +import clsx from "clsx"; import { useEffect, useState } from "react"; import type { AgentProbe, Branch } from "@/bindings"; import { executeAction } from "@/platform/actions"; @@ -7,7 +8,7 @@ import { useTx, useTxTemplate } from "@/platform/i18n"; import { basename, useProject } from "@/platform/project"; import { Icon, Select, T, type SelectOption } from "@/ui"; import { defaultTaskProject } from "./project"; -import { maybeEditorService } from "@/workbench/contributions"; +import { maybeEditorService, type EditorInput } from "@/workbench/contributions"; import { Composer } from "./Composer"; import { SubmitButton } from "./SubmitButton"; import { canSend, emptyDraft } from "./composerModel"; @@ -17,7 +18,7 @@ import { ADD_AGENT, providerOptions as agentOptions } from "./providerOptions"; import { HOME, NO_COMMANDS, refreshProviders, startTask, updateDraft, useAgentCommands, useAgents } from "./store"; import styles from "./Home.module.css"; -export function HomeEditor() { +export function HomeEditor({ docked = false }: { docked?: boolean; input?: EditorInput }) { const tx = useTx(); const txTemplate = useTxTemplate(); const draft = useAgents((s) => s.draft); @@ -86,15 +87,19 @@ export function HomeEditor() { const setOption = (id: string, value: string) => updateDraft({ config: { ...draft.config, [id]: value } }); return ( -
+
-

- What should the agent do? -

+ {!docked && ( +

+ What should the agent do? +

+ )} ({ value: path, label: basename(path), detail: path }))} onChange={(path) => { @@ -139,12 +145,15 @@ export function HomeEditor() { }} /> ) : ( - {members.length ? basename(root) : project?.name} + + {members.length ? basename(root) : project?.name} + )} {isGit && ( ({ value: b.name, label: b.name }))} onChange={(b) => updateDraft({ base: b === current ? null : b })} @@ -184,7 +198,7 @@ export function HomeEditor() { )}
- {probe && ( + {probe && (!docked || probe.error) && (

{probe.error ? txTemplate(`${provider?.name ?? probe.provider} didn't start: ${probe.error}`) diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index 5b104f185..a28570ef3 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -16,6 +16,20 @@ border-right: 1px solid var(--border); } +.conversation[hidden] { + display: none; +} +.docked { + grid-template-columns: minmax(0, 1fr); + overflow: hidden; +} +.docked .conversation { + border-right: 0; +} +.docked .changes { + width: auto; +} + /* The conversation hugs its content; spare space stays below the composer instead of inside the chat. */ .streamArea, @@ -568,6 +582,7 @@ width: 280px; } .changesHeader { + min-width: 0; display: flex; align-items: center; gap: 6px; @@ -577,11 +592,17 @@ border-bottom: 1px solid var(--border); } .changesTitle { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-weight: 600; color: var(--fg-strong); } .changesSummary { flex: none; + overflow: hidden; + text-overflow: ellipsis; padding: 4px 10px; color: var(--fg-subtle); white-space: pre; @@ -608,8 +629,11 @@ gap: 4px; } .changeName { - flex: none; + flex: 0 1 auto; + min-width: 0; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .changeDir { flex: 1 1 0; diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 00d2ae510..deddc4c4c 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -68,15 +68,13 @@ import { setFocusedSession, focusedSession, type FocusedSession } from "./sessio import { taskMenu } from "./taskActions"; import styles from "./Session.module.css"; -export function SessionEditor({ input }: { input: EditorInput }) { +export function SessionEditor({ input, docked = false }: { input: EditorInput; docked?: boolean }) { const id = (input.data as { task: string }).task; - return ; + return ; } -function TaskSession({ id }: { id: string }) { +function TaskSession({ id, docked }: { id: string; docked: boolean }) { const task = useAgents((s) => s.tasks.get(id)); - // The tool window bar above already shows the title of a docked conversation. - const docked = useAgents((s) => s.docked === id); const entries = useAgents((s) => s.entries.get(id)); const changes = useAgents((s) => s.changes.get(id)); const project = useProject((s) => s.project); @@ -84,7 +82,7 @@ function TaskSession({ id }: { id: string }) { task?.isolation === "local" ? task.status : null, project?.workspace ? (task?.projectRoot ?? project.root) : null, ); - const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null); + const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null, docked); // The subagent conversation shown, with the subagents that lead to it (empty: the task's own). const [path, setPath] = useState([]); const view = path.at(-1) ?? null; @@ -96,8 +94,10 @@ function TaskSession({ id }: { id: string }) { useEffect(() => { void loadEntries(id).catch(() => undefined); void refreshChanges(id); - return markVisible(id); }, [id]); + useEffect(() => { + if (!docked || !changesOpen) return markVisible(id); + }, [id, docked, changesOpen]); useEffect(() => { if (view) void loadEntries(id, view).catch(() => undefined); }, [id, view]); @@ -127,7 +127,7 @@ function TaskSession({ id }: { id: string }) { } }} > -

+ ); @@ -270,6 +276,7 @@ function Header({ className={styles.changesToggle} aria-pressed={changesOpen} disabled={count === 0 && !changesOpen} + aria-label={changesOpen ? tx("Hide changes") : tx("Show changes")} title={changesOpen ? tx("Hide changes") : tx("Show changes")} onClick={onToggleChanges} > @@ -1292,16 +1299,22 @@ function loadChangesOpen(): Record { /** * Whether the Changes pane is shown for `task`: the user's choice once made, else - * only while the task has changes (so it opens when the agent first edits a file). + * only while the task has changes. Docked sessions stay in the conversation until + * the user explicitly opens changes; navigation there does not change editor preferences. */ -function useChangesOpen(task: string, changes: AgentChange[] | null): [boolean, (open: boolean) => void] { +function useChangesOpen( + task: string, + changes: AgentChange[] | null, + docked: boolean, +): [boolean, (open: boolean) => void] { const [state, setState] = useState<{ task: string; open: boolean | undefined }>(() => ({ task, - open: loadChangesOpen()[task], + open: docked ? false : loadChangesOpen()[task], })); - const chosen = state.task === task ? state.open : loadChangesOpen()[task]; + const chosen = state.task === task ? state.open : docked ? false : loadChangesOpen()[task]; const set = (open: boolean) => { setState({ task, open }); + if (docked) return; try { globalThis.localStorage?.setItem(CHANGES_OPEN_KEY, JSON.stringify({ ...loadChangesOpen(), [task]: open })); } catch { @@ -1315,11 +1328,13 @@ function ChangesPane({ task, changes, head, + docked, onHide, }: { task: AgentTask; changes: AgentChange[] | null; head: string | null; + docked: boolean; onHide: () => void; }) { const [selected, setSelected] = useState>(() => new Set()); @@ -1328,6 +1343,7 @@ function ChangesPane({ return (