diff --git a/Cargo.lock b/Cargo.lock index 2f5903ab9..6a87905c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4188,6 +4188,7 @@ dependencies = [ "objc2-core-foundation", "objc2-core-text", "serde", + "serde_json", "specta", "windows", ] @@ -6186,6 +6187,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", + "objc2-web-kit 0.3.2", "parking_lot", "serde", "serde_json", diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 9a377fecd..94751e75a 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -1,5 +1,13 @@ # Third-party licenses +## Project Lombok + +`plugins/lombok/lombok-1.18.48-openidea.1.jar` contains Project Lombok v1.18.48 with the Equinox +classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059). +Copyright (C) 2009-2021 The Project Lombok Authors. MIT license and bundled component +notices are retained in [plugins/lombok/LICENSE](plugins/lombok/LICENSE) and inside the jar. +Source tag, patch commits and build instructions: [plugins/lombok/README.md](plugins/lombok/README.md). + ## JetBrains IntelliJ Platform icons `src/ui/expui/**` contains SVG icons from the IntelliJ Platform "new UI" (expui) 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/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 5900f7e80..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, @@ -292,6 +294,7 @@ pub struct AppearanceSettings { pub editor_antialiasing: String, pub show_tool_window_bars: bool, pub show_tool_window_names: bool, + pub show_tool_window_badges: bool, pub widescreen_tool_windows: bool, /// Status bar widgets shown (`true`) or hidden (`false`) by the user /// (status bar context menu); widgets not listed use their default. @@ -325,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, @@ -338,6 +342,7 @@ impl Default for AppearanceSettings { editor_antialiasing: "greyscale".into(), show_tool_window_bars: true, show_tool_window_names: false, + show_tool_window_badges: true, widescreen_tool_windows: false, status_bar_widgets: BTreeMap::new(), main_toolbar: None, @@ -503,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 { @@ -719,6 +765,35 @@ 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(); + assert!(old.appearance.show_tool_window_badges); + assert!(Settings::default().appearance.show_tool_window_badges); + for enabled in [false, true] { + let mut settings = Settings::default(); + settings.appearance.show_tool_window_badges = enabled; + let json = serde_json::to_value(&settings).unwrap(); + assert_eq!(json["appearance"]["showToolWindowBadges"], enabled); + let restored: Settings = serde_json::from_value(json).unwrap(); + assert_eq!(restored.appearance.show_tool_window_badges, enabled); + } + } + #[test] fn main_menu_style_defaults_and_round_trips() { let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":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 Vec<(String, bool)> { .collect() } +/// Binary font bundle: little-endian u32 JSON-header length, a face metadata +/// array, then raw font bytes. Paths never cross IPC; only CoreText-enumerated +/// files under user font directories can be read, selected by family name. +/// Includes only the nearest regular/bold faces and their optional italics. +pub fn user_font_data(family: &str) -> std::io::Result> { + #[cfg(target_os = "macos")] + { + let entries = imp::user_faces(); + bundle_family(family, &entries) + } + #[cfg(not(target_os = "macos"))] + { + let _ = family; + Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "User font loading is macOS only")) + } +} + +#[cfg(any(target_os = "macos", test))] +#[derive(Debug)] +struct UserFace { + family: String, + path: std::path::PathBuf, + weight: u16, + italic: bool, +} + +#[cfg(any(target_os = "macos", test))] +fn user_font_path(path: &std::path::Path, roots: &[std::path::PathBuf]) -> Option { + let path = path.canonicalize().ok()?; + roots.iter().any(|root| path.starts_with(root)).then_some(path) +} + +#[cfg(any(target_os = "macos", test))] +fn family_faces<'a>(family: &str, entries: &'a [UserFace]) -> Vec<&'a UserFace> { + let mut selected: Vec<&UserFace> = Vec::new(); + for italic in [false, true] { + for weight in [400u16, 700] { + let closest = entries + .iter() + .filter(|e| e.family.eq_ignore_ascii_case(family.trim()) && e.italic == italic) + .min_by_key(|e| (e.weight.abs_diff(weight), e.weight, &e.path)); + if let Some(face) = closest + && !selected.iter().any(|f| std::ptr::eq(*f, face)) + { + selected.push(face); + } + } + } + selected +} + +#[cfg(any(target_os = "macos", test))] +fn bundle_family(family: &str, entries: &[UserFace]) -> std::io::Result> { + #[derive(Serialize)] + struct Face { + weight: u16, + style: &'static str, + offset: usize, + length: usize, + } + let mut faces = Vec::new(); + let mut data = Vec::new(); + let mut files = BTreeMap::new(); + // Bound large CJK families to regular/bold and their nearest italic faces. + for entry in family_faces(family, entries) { + // Reject a file replaced by a symlink after enumeration. + if entry.path.canonicalize()? != entry.path { + return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Font file changed")); + } + let (offset, length) = if let Some(range) = files.get(&entry.path) { + *range + } else { + let bytes = std::fs::read(&entry.path)?; + let range = (data.len(), bytes.len()); + data.extend(bytes); + files.insert(entry.path.clone(), range); + range + }; + faces.push(Face { + weight: entry.weight, + style: if entry.italic { "italic" } else { "normal" }, + offset, + length, + }); + } + if faces.is_empty() { + return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "No installed user font family")); + } + let header = serde_json::to_vec(&faces)?; + let mut out = (header.len() as u32).to_le_bytes().to_vec(); + out.extend(header); + out.extend(data); + Ok(out) +} + #[cfg(target_os = "macos")] mod imp { - use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType}; + use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType, CFURL}; use objc2_core_text::{ CTFontCollection, CTFontDescriptor, CTFontSymbolicTraits, kCTFontFamilyNameAttribute, kCTFontSymbolicTrait, - kCTFontTraitsAttribute, + kCTFontTraitsAttribute, kCTFontURLAttribute, kCTFontWeightTrait, }; + pub(crate) fn user_faces() -> Vec { + let mut roots = vec![std::path::PathBuf::from("/Library/Fonts")]; + if let Some(home) = std::env::var_os("HOME") { + roots.push(std::path::PathBuf::from(home).join("Library/Fonts")); + } + let roots: Vec<_> = roots.into_iter().filter_map(|r| r.canonicalize().ok()).collect(); + // SAFETY: CoreText descriptors carry documented CFString, CFURL and trait dictionary values. + unsafe { + let collection = CTFontCollection::from_available_fonts(None); + let Some(descriptors) = collection.matching_font_descriptors() else { return Vec::new() }; + let mut seen = std::collections::BTreeSet::new(); + descriptors + .cast_unchecked::() + .iter() + .filter_map(|d| { + let family = d.attribute(kCTFontFamilyNameAttribute)?.downcast::().ok()?.to_string(); + let path = d.attribute(kCTFontURLAttribute)?.downcast::().ok()?.to_file_path()?; + let path = super::user_font_path(&path, &roots)?; + let traits = d.attribute(kCTFontTraitsAttribute)?.downcast::().ok()?; + let traits = traits.cast_unchecked::(); + let symbolic = traits + .get(kCTFontSymbolicTrait) + .and_then(|n| n.downcast::().ok()) + .and_then(|n| n.as_i64()) + .unwrap_or(0) as u32; + let weight = traits + .get(kCTFontWeightTrait) + .and_then(|n| n.downcast::().ok()) + .and_then(|n| n.as_f64()) + .unwrap_or(0.0); + // CoreText's normalized weight values corresponding to CSS's nine standard weights. + let weight = [ + (-0.8, 100), + (-0.6, 200), + (-0.4, 300), + (0.0, 400), + (0.23, 500), + (0.3, 600), + (0.4, 700), + (0.56, 800), + (0.62, 900), + ] + .into_iter() + .min_by(|(a, _), (b, _)| (weight - a).abs().total_cmp(&(weight - b).abs()))? + .1; + let italic = symbolic & CTFontSymbolicTraits::TraitItalic.0 != 0; + if !seen.insert((family.clone(), path.clone(), weight, italic)) { + return None; + } + Some(super::UserFace { family, path, weight, italic }) + }) + .collect() + } + } + /// Every face CoreText makes available to this process, as (family, fixed pitch). pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> { // SAFETY: CoreText's documented types: the matching descriptors array @@ -210,6 +359,92 @@ mod tests { use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; + #[test] + fn selects_only_regular_bold_and_matching_italics_from_dense_families() { + let entries: Vec<_> = [false, true] + .into_iter() + .flat_map(|italic| { + (100..=900).step_by(100).map(move |weight| UserFace { + family: "CJK".into(), + path: format!("{weight}-{italic}.ttf").into(), + weight, + italic, + }) + }) + .collect(); + let selected = family_faces(" cjk ", &entries); + assert_eq!( + selected.iter().map(|f| (f.weight, f.italic)).collect::>(), + [(400, false), (700, false), (400, true), (700, true),] + ); + assert!(family_faces("unknown", &entries).is_empty()); + } + + #[test] + fn selects_nearest_available_weights_once_with_optional_italics() { + let entries: Vec<_> = [(300, false), (500, false), (800, false), (500, true)] + .into_iter() + .map(|(weight, italic)| UserFace { + family: "Sparse".into(), + path: format!("{weight}-{italic}.ttf").into(), + weight, + italic, + }) + .collect(); + let selected = family_faces("Sparse", &entries); + assert_eq!( + selected.iter().map(|f| (f.weight, f.italic)).collect::>(), + [(300, false), (800, false), (500, true),] + ); + assert_eq!(family_faces("Sparse", &entries[..1]).len(), 1); + } + + #[test] + fn bundle_only_reads_enumerated_family_faces() { + let root = std::env::current_dir().unwrap().join(format!("../../.font-bundle-test-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let root = root.canonicalize().unwrap(); + let regular = root.join("regular.ttf"); + let bold = root.join("bold.ttf"); + std::fs::write(®ular, [1, 2, 3]).unwrap(); + std::fs::write(&bold, [4, 5]).unwrap(); + let entries = [ + UserFace { family: "Test Nerd Font".into(), path: regular.clone(), weight: 400, italic: false }, + UserFace { family: "Test Nerd Font".into(), path: bold, weight: 700, italic: false }, + // A non-selected face is never opened, even if its file has disappeared. + UserFace { family: "Test Nerd Font".into(), path: root.join("thin.ttf"), weight: 100, italic: false }, + ]; + let result = bundle_family("test nerd font", &entries).unwrap(); + let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize; + let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap(); + assert_eq!(faces[0]["weight"], 400); + assert_eq!(faces[1]["weight"], 700); + assert_eq!(faces.as_array().unwrap().len(), 2); + assert_eq!(&result[4 + len..], &[1, 2, 3, 4, 5]); + // Existing files are not inputs: neither paths nor unenumerated families grant access. + assert!(bundle_family(regular.to_str().unwrap(), &entries).is_err()); + assert!(bundle_family("Unenumerated", &entries).is_err()); + assert!(user_font_path(®ular, &[]).is_none()); + assert!(user_font_path(®ular, std::slice::from_ref(&root)).is_some()); + // Multiple selected descriptors in a collection still transfer the file only once. + let shared = [ + UserFace { family: "Collection".into(), path: regular.clone(), weight: 400, italic: false }, + UserFace { family: "Collection".into(), path: regular, weight: 700, italic: false }, + ]; + let result = bundle_family("Collection", &shared).unwrap(); + let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize; + let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap(); + assert_eq!(faces[0]["offset"], faces[1]["offset"]); + assert_eq!(&result[4 + len..], &[1, 2, 3]); + #[cfg(unix)] + { + let link = root.join("outside.ttf"); + std::os::unix::fs::symlink(std::env::current_exe().unwrap(), &link).unwrap(); + assert!(user_font_path(&link, std::slice::from_ref(&root)).is_none()); + } + std::fs::remove_dir_all(root).unwrap(); + } + fn fam(name: &str, monospaced: bool) -> FontFamily { FontFamily { name: name.into(), monospaced } } diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index 4518fa0ff..c63304344 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -48,7 +48,7 @@ parking_lot.workspace = true dunce.workspace = true dirs.workspace = true uuid.workspace = true +tempfile.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] } -tempfile.workspace = true diff --git a/crates/ide-host/src/formatter.rs b/crates/ide-host/src/formatter.rs new file mode 100644 index 000000000..2da08e368 --- /dev/null +++ b/crates/ide-host/src/formatter.rs @@ -0,0 +1,107 @@ +//! Select the document's formatting provider before executing it on this host. + +use crate::Host; +use ide_core::event::NotifyLevel; +use ide_core::settings::PrettierSettings; +use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit}; +use ide_language::FormatterConfig; +use ide_lsp::types::FormatOptions; +use ide_tools::{Tools, formatter}; + +fn external_selected(config: Option<&FormatterConfig>, 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 ccb01db4a..5937b9725 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -4,7 +4,9 @@ //! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]). pub mod environment; +mod formatter; mod java_runtime; +mod kotlin_bridge; pub mod notebook; pub mod ops; mod project; @@ -148,9 +150,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 }); } @@ -158,7 +166,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(); } @@ -166,6 +181,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; } @@ -186,7 +204,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. @@ -197,12 +219,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 4eccfb01f..47beb38d5 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.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..4c17cdde1 100644 --- a/crates/ide-host/src/ops/project.rs +++ b/crates/ide-host/src/ops/project.rs @@ -177,6 +177,9 @@ pub fn set_settings( .any(|k| key == *k || key.starts_with(&format!("{k}."))) { for project in affected() { + if 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/term.rs b/crates/ide-host/src/ops/term.rs index 20393ead2..50d8ac8cc 100644 --- a/crates/ide-host/src/ops/term.rs +++ b/crates/ide-host/src/ops/term.rs @@ -16,11 +16,15 @@ pub fn spawn( options.args.clear(); options.env = global.env; } - // Python › "Activate virtualenv": typed into the new shell, like PyCharm. - let activation = - host.project(window).ok().filter(|p| p.is_trusted()).and_then(|p| { - ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref()) - }); + let (shell, args) = ide_terminal::resolve_shell(options.shell.as_deref(), &options.args); + options.shell = Some(shell.clone()); + options.args = args; + // Python › "Activate virtualenv": typed into the same shell the PTY starts. + let activation = host + .project(window) + .ok() + .filter(|p| p.is_trusted()) + .and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell)); let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?; if let Some(line) = activation { let _ = host.terminals.write(info.id, &format!(" {line}\r")); diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs index f87b5799c..f8f05b1ff 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 24cc4aa34..f28c7cffe 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>, @@ -150,6 +153,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 +230,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 +266,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 +346,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 +382,14 @@ impl Host { syntax, detected_roots, cache_dir: self.data_dir.join("cache").join(&project_key), + java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new( + self.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 +408,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 +504,14 @@ impl Host { syntax, detected_roots: Arc::default(), cache_dir: self.data_dir.join("cache").join(&project_key), + java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new( + self.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 +805,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 +945,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 +956,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 9e1bfb826..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" @@ -235,6 +262,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "agent_registry_remove" | "coverage_import" | "system_font_families" + | "user_font_data" | "plugin_subscribe" | "plugin_list" | "plugin_set_active_file" diff --git a/crates/ide-host/tests/properties.rs b/crates/ide-host/tests/properties.rs index 097d92a63..dce783dd4 100644 --- a/crates/ide-host/tests/properties.rs +++ b/crates/ide-host/tests/properties.rs @@ -104,7 +104,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(); @@ -129,7 +129,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); @@ -169,7 +169,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(), @@ -181,5 +181,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 d16f408fc..58e4ede39 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -94,6 +94,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/terminal_activation.rs b/crates/ide-host/tests/terminal_activation.rs new file mode 100644 index 000000000..9561a23eb --- /dev/null +++ b/crates/ide-host/tests/terminal_activation.rs @@ -0,0 +1,62 @@ +//! Own test process: the inherited shell deliberately differs from the login shell. +#![cfg(unix)] + +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +#[test] +fn terminal_activation_uses_the_resolved_default_shell() { + // SAFETY: no runtime or worker threads have been started. + unsafe { std::env::set_var("SHELL", "/not-the-terminal/docker") }; + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap().join("project"); + let bin = root.join(".venv/bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(root.join(".venv/pyvenv.cfg"), "home = /usr/bin\n").unwrap(); + std::fs::write(bin.join("python"), "").unwrap(); + for script in ["activate", "activate.fish", "activate.csh", "activate.nu", "Activate.ps1"] { + std::fs::write(bin.join(script), "echo activated > activation.marker\n").unwrap(); + } + let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap(); + runtime.block_on(async { + let host = Arc::new(Host::new(HostConfig { + config_dir: tmp.path().join("config"), + data_dir: tmp.path().join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + host.trust.decide(&root, true, false).unwrap(); + host.open_project("main", &root).unwrap(); + for shell in [None, Some("".to_owned()), Some(" \t ".to_owned())] { + let terminal = ops::term::spawn( + &host, + "main", + ide_terminal::TerminalOptions { + cwd: root.clone().into(), + shell, + args: vec![], + env: Default::default(), + cols: 80, + rows: 24, + shell_integration: false, + }, + Arc::new(|_| {}), + ) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while !std::fs::read_to_string(root.join("activation.marker")).is_ok_and(|s| s.trim() == "activated") { + assert!(Instant::now() < deadline, "default terminal shell must run the activation script"); + tokio::time::sleep(Duration::from_millis(20)).await; + } + host.terminals.kill(terminal.id).unwrap(); + std::fs::remove_file(root.join("activation.marker")).unwrap(); + } + host.shutdown().await; + }); +} diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index bf6a0a057..4a1d0250a 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -20,6 +20,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/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 6b47e143c..f2b79e0c1 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -189,6 +189,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 +396,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 +452,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 +570,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 +633,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 +665,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/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/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index e909af815..a636e5e4d 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() @@ -1248,12 +1249,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 +1623,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 +2743,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..43c250a20 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -55,6 +55,7 @@ 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, diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index f093c8d4c..a397529c2 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -735,8 +735,8 @@ fn sh_quote(s: &str) -> String { } /// Command typed into a new terminal to activate the project virtualenv -/// (PyCharm's Tools › Terminal › "Activate virtualenv"), for the given shell. -pub fn terminal_activation(root: &Path, s: &Settings, shell: Option<&str>) -> Option { +/// (Tools › Terminal › "Activate virtualenv"). `shell` must be the resolved PTY shell. +pub fn terminal_activation(root: &Path, s: &Settings, shell: &str) -> Option { if !s.languages.python.activate_virtualenv { return None; } @@ -744,12 +744,8 @@ pub fn terminal_activation(root: &Path, s: &Settings, shell: Option<&str>) -> Op if std::env::var_os("VIRTUAL_ENV").is_some_and(|v| Path::new(&v) == venv) { return None; } - let shell = shell - .map(str::to_owned) - .or_else(|| std::env::var("SHELL").ok()) - .or_else(|| std::env::var("COMSPEC").ok()) - .unwrap_or_default(); - let name = Path::new(&shell).file_stem().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); + let name = shell.rsplit(['/', '\\']).next().unwrap_or(shell); + let name = Path::new(name).file_stem().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); let script = |file: &str| venv.join(bin_dir()).join(file); let line = match name.as_str() { "fish" => format!("source {}", sh_quote(&script("activate.fish").to_string_lossy())), diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 755778568..9d104583a 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -717,6 +717,20 @@ mod tests { configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None); let data = |c: &LanguageServerConfig| c.args[c.args.iter().position(|a| a == "-data").unwrap() + 1].clone(); assert_ne!(data(&cfg), data(&plain), "a separate workspace, so everything is rebuilt with the agent"); + let patched = Path::new("/managed/lombok-1.18.48-openidea.1.jar"); + let mut fixed = jdtls(); + configure_jdtls( + &mut fixed, + Path::new("/p"), + Path::new("/data"), + &[], + None, + None, + JDTLS_MIN_JAVA, + &[], + Some(patched), + ); + assert_ne!(data(&cfg), data(&fixed), "the patch must rebuild classes compiled with the official agent"); let before = cfg.clone(); configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], Some(jar)); assert_eq!(cfg, before, "idempotent"); @@ -934,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/lombok.rs b/crates/ide-sdk/src/lombok.rs index f8958bc40..67c383385 100644 --- a/crates/ide-sdk/src/lombok.rs +++ b/crates/ide-sdk/src/lombok.rs @@ -1,6 +1,6 @@ //! Lombok for jdtls: ECJ only generates Lombok members when the language //! server's JVM runs `-javaagent:lombok.jar`, so a project using Lombok gets the -//! newest jar available, managed (installed by the IDE) or in the Maven / Gradle caches. +//! app-provided jar, or falls back to the newest release in the Maven / Gradle caches. use crate::home_dir; use std::path::{Path, PathBuf}; @@ -18,7 +18,7 @@ pub enum LombokAgent { Missing, Found { jar: PathBuf, - /// `None` for the managed jar (the latest release when installed). + /// `None` for the app-provided jar (its version is shown by the tool registry). version: Option, }, } @@ -64,7 +64,7 @@ pub fn agent_in_args<'a>(args: impl IntoIterator) -> Option Vec { /// Picks the shell binary and its arguments. A default (or explicitly chosen but /// argument-less) POSIX shell is started as a login shell, like Terminal.app/IDEA. -fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec) { +pub fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec) { let shell = shell.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).unwrap_or_else(default_shell); if !args.is_empty() { return (shell, args.to_vec()); @@ -808,8 +808,12 @@ mod tests { assert_eq!((shell.as_str(), args), ("/bin/zsh", vec!["-l".to_owned()])); let (_, args) = resolve_shell(Some("/usr/bin/python3"), &[]); assert!(args.is_empty()); - let (_, args) = resolve_shell(None, &[]); - assert_eq!(args, vec!["-l".to_owned()]); + for configured in [None, Some(""), Some(" \t ")] { + let (shell, args) = resolve_shell(configured, &[]); + assert_eq!(shell, default_shell_path()); + assert_eq!(args, vec!["-l".to_owned()]); + } + assert_eq!(resolve_shell(Some(" /bin/bash "), &["-i".into()]), ("/bin/bash".into(), vec!["-i".into()])); assert_eq!(shell_title("C:\\Program Files\\PowerShell\\7\\pwsh.exe"), "pwsh"); let env = terminal_env(&BTreeMap::from([("TERM".into(), "dumb".into())])); assert_eq!(env["TERM"], "dumb"); diff --git a/crates/ide-tools/src/bundled.rs b/crates/ide-tools/src/bundled.rs new file mode 100644 index 000000000..03cbbb875 --- /dev/null +++ b/crates/ide-tools/src/bundled.rs @@ -0,0 +1,28 @@ +//! Versioned app-owned plugin jars, shared by tool detection and language-server launches. + +use crate::{Method, ToolSpec}; +use std::path::{Path, PathBuf}; + +pub(crate) fn install(root: &Path, spec: &ToolSpec) -> std::io::Result { + let Method::Bundled { filename, bytes, .. } = spec.method else { unreachable!("bundled tool") }; + let dir = root.join(spec.name).join("bin"); + // The agent path participates in the JDT LS workspace key: upgrades must + // discard incremental state compiled with the previous agent. + let path = dir.join(filename); + if std::fs::read(&path).is_ok_and(|old| old == bytes) { + return Ok(path); + } + std::fs::create_dir_all(&dir)?; + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(); + let temporary = dir.join(format!(".{filename}-{}-{nonce}.tmp", std::process::id())); + let result = std::fs::write(&temporary, bytes).and_then(|_| std::fs::rename(&temporary, &path)); + if let Err(error) = result { + let _ = std::fs::remove_file(&temporary); + // Another window may have installed the same bytes concurrently. + if !std::fs::read(&path).is_ok_and(|old| old == bytes) { + tracing::warn!(tool = spec.name, %error, "Bundled plugin unavailable"); + return Err(error); + } + } + Ok(path) +} 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 d5cd36c0d..1822367fe 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -10,6 +10,8 @@ //! [`tools_bin_dirs`] on the process `PATH` at startup. pub mod archive; +mod bundled; +pub mod formatter; pub mod jdk; pub mod platform; mod process; @@ -146,7 +148,7 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option

{ Some(root.join(spec.name).join("venv").join(if platform.os == Os::Windows { "Scripts" } else { "bin" })) } - Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None, + Method::Bundled { .. } | Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None, } } @@ -322,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); } @@ -389,9 +394,12 @@ fn is_bundle(command: &str) -> bool { command.ends_with(".jar") } -/// Path of an installed plugin bundle (`//bin/`), if present. +/// Path of an app-provided or installed plugin jar. Embedded jars are materialized on demand. pub fn managed_bundle(app_data: &Path, name: &str) -> Option { let spec = registry::find(name)?; + if matches!(spec.method, Method::Bundled { .. }) { + return bundled::install(&tools_dir(app_data), spec).ok(); + } let jar = spec.commands.first().filter(|c| is_bundle(c))?; let path = tools_dir(app_data).join(spec.name).join("bin").join(jar); path.is_file().then_some(path) @@ -542,6 +550,7 @@ impl Tools { fn method_missing(&self, method: Method) -> Option { let need = |ok: bool, msg: &str| (!ok).then(|| msg.to_string()); match method { + Method::Bundled { .. } => None, Method::Rustup { .. } => need(self.which("rustup").is_some(), "rustup is required (https://rustup.rs)"), Method::Npm { .. } => need( self.which_any(&["npm", "pnpm"]).is_some(), @@ -604,7 +613,8 @@ impl Tools { }; let (status, path, version) = self.detect(spec); let unavailable_reason = self.unavailable_reason(spec); - let can_uninstall = status == ToolStatus::Installed; + let bundled = matches!(spec.method, Method::Bundled { .. }); + let can_uninstall = status == ToolStatus::Installed && !bundled; Some(ToolInfo { name: spec.name.to_string(), display_name: spec.display.to_string(), @@ -615,7 +625,7 @@ impl Tools { path: path.map(Into::into), version, install_method: Some(self.install_method(spec).label().to_string()), - can_install: unavailable_reason.is_none(), + can_install: unavailable_reason.is_none() && !bundled, unavailable_reason, can_uninstall, install_hint, @@ -654,6 +664,12 @@ impl Tools { } fn detect(&self, spec: &ToolSpec) -> (ToolStatus, Option, Option) { + if let Method::Bundled { version, .. } = spec.method { + return match bundled::install(&self.root, spec) { + Ok(path) => (ToolStatus::Installed, Some(path), Some(version.to_owned())), + Err(_) => (ToolStatus::Missing, None, None), + }; + } let main = spec.commands[0]; let managed = managed_bin_dir(&self.root, spec, self.platform).and_then(|d| self.exe_in(&d, main)); if let Some(path) = managed { @@ -782,6 +798,9 @@ impl Tools { let method = self.install_method(spec); emit(InstallEvent::Line { text: format!("Installing {} via {}…", spec.display, method.label()) }); match method { + Method::Bundled { .. } => { + bundled::install(&self.root, spec)?; + } Method::Rustup { component } => { let mut c = self.command(self.which("rustup").expect("checked")); c.args(["component", "add", component]); @@ -1188,6 +1207,8 @@ impl Tools { let cancel = CancellationToken::new(); let quiet = |_: InstallEvent| {}; match self.install_method(spec) { + // App-owned resources remain available for every project/window. + Method::Bundled { .. } => {} Method::Rustup { component } => { let rustup = self.which("rustup").ok_or_else(|| IdeError::unavailable("rustup not found"))?; let mut c = self.command(rustup); diff --git a/crates/ide-tools/src/process.rs b/crates/ide-tools/src/process.rs index cd3e15788..fad1f755d 100644 --- a/crates/ide-tools/src/process.rs +++ b/crates/ide-tools/src/process.rs @@ -464,3 +464,120 @@ mod tests { assert_eq!(run_quiet(cmd, Duration::from_secs(5)), Some((true, "v1.2.3\n".into()))); } } + +/// 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(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 8bac71906..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. @@ -31,6 +32,8 @@ pub enum ReleaseSource { #[derive(Clone, Copy, Debug)] pub enum Method { + /// An app-owned plugin jar, materialized without a network download. + Bundled { filename: &'static str, version: &'static str, bytes: &'static [u8] }, /// `rustup component add `. Rustup { component: &'static str }, /// npm / pnpm packages installed into `/node_modules`; the first is the primary one. @@ -56,6 +59,7 @@ pub enum Method { impl Method { pub fn label(&self) -> &'static str { match self { + Method::Bundled { .. } => "Bundled", Method::Rustup { .. } => "rustup", Method::Npm { .. } => "npm", Method::Go { .. } => "go install", @@ -102,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)")]; @@ -119,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", @@ -578,15 +593,13 @@ pub static TOOLS: &[ToolSpec] = &[ display: "Lombok (jdtls agent)", kind: Plugin, commands: &["lombok.jar"], - // Newer than the Lombok a project pins: old ones fail inside current ECJ. - method: Method::Release { - source: ReleaseSource::MavenCentral { group: "org.projectlombok", artifact: "lombok" }, - assets: &[ - any_arch(Mac, "lombok-*.jar"), - any_arch(Linux, "lombok-*.jar"), - any_arch(Windows, "lombok-*.jar"), - ], - locate: &["lombok.jar"], + // Equinox removed getModuleClassLoader(boolean), breaking lombok.config lookup. + // Return to Maven Central once an official release includes lombok#4059. + // Source commit, build instructions and checksum: plugins/lombok/README.md. + method: Method::Bundled { + filename: "lombok-1.18.48-openidea.1.jar", + version: "1.18.48-openidea.1", + bytes: include_bytes!("../../../plugins/lombok/lombok-1.18.48-openidea.1.jar"), }, fallback: None, version: V, @@ -942,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/src/sources.rs b/crates/ide-tools/src/sources.rs index 09ec12a4b..2337bc75c 100644 --- a/crates/ide-tools/src/sources.rs +++ b/crates/ide-tools/src/sources.rs @@ -434,7 +434,6 @@ mod tests { assert_eq!(d.version, "1.18.48"); assert_eq!(d.name, "lombok-1.18.48.jar"); assert_eq!(d.url, "https://repo1.maven.org/maven2/org/projectlombok/lombok/1.18.48/lombok-1.18.48.jar"); - assert!(select_asset(&[d.name.as_str()], find("lombok").unwrap().asset_rules(), LINUX).is_some()); assert!(maven_pick("g", "a", "").is_err()); assert!(maven_pick("g", "a", "../x").is_err()); } diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index e6aa29c27..00a3c55ea 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -43,6 +43,61 @@ fn find<'a>(list: &'a [ToolInfo], name: &str) -> &'a ToolInfo { list.iter().find(|t| t.name == name).unwrap_or_else(|| panic!("{name} not listed")) } +#[test] +fn bundled_lombok_replaces_legacy_installs_on_every_platform_without_downloads() { + for os in [Os::Mac, Os::Linux, Os::Windows] { + for arch in [platform::Arch::Arm64, platform::Arch::X64] { + let env = Env::new(); + let legacy = env.root.join("lombok/bin/lombok.jar"); + fs::create_dir_all(legacy.parent().unwrap()).unwrap(); + fs::write(&legacy, b"old official agent").unwrap(); + fs::write(env.root.join("lombok").join(MANIFEST), r#"{"version":"1.18.48"}"#).unwrap(); + let tools = Tools::with_paths(env.root.clone(), OsString::new(), Platform { os, arch }); + let info = tools.info("lombok", &[]).unwrap(); + let path = managed_bundle(env.root.parent().unwrap(), "lombok").unwrap(); + assert_eq!(info.status, ToolStatus::Installed); + assert_eq!(info.version.as_deref(), Some("1.18.48-openidea.1")); + assert_eq!(info.path, Some(path.clone().into())); + assert_eq!(path.file_name().unwrap(), "lombok-1.18.48-openidea.1.jar"); + assert!(!info.can_install && !info.can_uninstall); + assert_eq!(tools.unavailable_reason(registry::find("lombok").unwrap()), None); + assert_eq!(fs::read(&legacy).unwrap(), b"old official agent", "other windows may still use it"); + } + } +} + +#[test] +fn bundled_lombok_is_available_offline_and_repairs_missing_or_corrupt_files() { + let env = Env::new(); + let data = env.root.parent().unwrap(); + let agent = managed_bundle(data, "lombok").unwrap(); + let checksum = include_str!("../../../plugins/lombok/SHA256SUMS").split_whitespace().next().unwrap(); + assert_eq!(sources::sha256_file(&agent).unwrap(), checksum); + for contents in [None, Some(b"incomplete jar".as_slice())] { + if let Some(contents) = contents { + fs::write(&agent, contents).unwrap(); + } else { + fs::remove_file(&agent).unwrap(); + } + assert_eq!(managed_bundle(data, "lombok"), Some(agent.clone())); + assert_eq!(sources::sha256_file(&agent).unwrap(), checksum); + } + assert_eq!(fs::read_dir(agent.parent().unwrap()).unwrap().count(), 1, "no temporary jars left behind"); +} + +#[test] +fn concurrent_windows_get_the_same_complete_bundled_lombok() { + let env = Env::new(); + let data = env.root.parent().unwrap(); + std::thread::scope(|scope| { + let windows: Vec<_> = (0..8).map(|_| scope.spawn(|| managed_bundle(data, "lombok").unwrap())).collect(); + let paths: Vec<_> = windows.into_iter().map(|window| window.join().unwrap()).collect(); + assert!(paths.iter().all(|path| path == &paths[0])); + let checksum = include_str!("../../../plugins/lombok/SHA256SUMS").split_whitespace().next().unwrap(); + assert_eq!(sources::sha256_file(&paths[0]).unwrap(), checksum); + }); +} + #[test] fn version_parsing() { assert_eq!(parse_version("rust-analyzer 1.85.0 (4d91de4e 2025-02-17)").as_deref(), Some("1.85.0")); 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/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/plugins/lombok/LICENSE b/plugins/lombok/LICENSE new file mode 100644 index 000000000..465d9bf8f --- /dev/null +++ b/plugins/lombok/LICENSE @@ -0,0 +1,104 @@ +Copyright (C) 2009-2021 The Project Lombok Authors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +============================================================================== +Licenses for included components: + +org.ow2.asm:asm +org.ow2.asm:asm-analysis +org.ow2.asm:asm-commons +org.ow2.asm:asm-tree +org.ow2.asm:asm-util +ASM: a very small and fast Java bytecode manipulation framework + Copyright (c) 2000-2011 INRIA, France Telecom + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + 3. Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------ +rzwitserloot/com.zwitserloot.cmdreader + + Copyright © 2010 Reinier Zwitserloot. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + +------------------------------------------------------------------------------ + +projectlombok/lombok.patcher + + Copyright (C) 2009-2021 The Project Lombok Authors. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + +------------------------------------------------------------------------------ diff --git a/plugins/lombok/README.md b/plugins/lombok/README.md new file mode 100644 index 000000000..701198345 --- /dev/null +++ b/plugins/lombok/README.md @@ -0,0 +1,71 @@ +# Lombok agent for JDT LS + +`lombok-1.18.48-openidea.1.jar` is the app-provided Java agent for JDT LS. It includes +[projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059), which restores +`lombok.config` lookup after Equinox removed `getModuleClassLoader(boolean)`. + +The jar is embedded by `ide-tools`, so every platform receives the same bytes without a download. +Both Java server startup and launch diagnostics obtain it through `managed_bundle`. Tool detection +reports the same version and path, replacing older IDE-installed official agents automatically. +An explicit user `-javaagent` still takes precedence. Project Maven/Gradle dependencies are unchanged. +The filename includes the patch version because JDT LS workspace keys include the agent path; +changing agents must rebuild classes produced by the old agent. Old jars remain for running windows. + +Replace this temporary bundle with an official Maven Central release once that release includes +#4059. Restore the registry's Maven Central install method and remove the embedded jar. Keep the +workspace migration test when changing its path. + +## Provenance and build + +- Repository: https://github.com/projectlombok/lombok +- Base tag: `v1.18.48` (`b48657c83ce44d027984f539bd36048293ce5e8e`). +- Cherry-picked commits from PR #4059, in order: + - `2d535482a913de46c27d06c5b0b294918ede3f9c` + - `3098f4dd78b5349c2f382e7310e64db810dd380b` +- Upstream version: `1.18.48`; `openidea.1` identifies this distribution of the patched agent. + Upstream's own `java -jar ... --version` still reports `1.18.48`. +- SHA-256: `10f5cd1a32b946d3ff1c075735b94f35fffdbac11dea2be29d24dbf591dd51a4` +- License: [MIT, including bundled component notices](LICENSE), also retained inside the jar. +- Build environment: macOS arm64, OpenJDK 23.0.1, Apache Ant 1.10.15, upstream Ivy setup. + +Build using the upstream Ant entry point described in its contributing guide: + +```sh +git clone https://github.com/projectlombok/lombok.git +cd lombok +git checkout -b openidea-1.18.48 v1.18.48 +git fetch https://github.com/chagong/lombok.git fix-equinox-bundle-wiring +git cherry-pick 2d535482a913de46c27d06c5b0b294918ede3f9c 3098f4dd78b5349c2f382e7310e64db810dd380b +ant dist +cp dist/lombok.jar /path/to/openidea/plugins/lombok/lombok-1.18.48-openidea.1.jar +``` + +The changelog context conflicts because the first commit adds a release-note line under the +older development heading and the second removes it. Resolve mechanically by adding then removing +only that line, retaining the complete v1.18.48 changelog. The final source diff from v1.18.48 changes +only `src/eclipseAgent/lombok/launch/PatchFixesHider.java`, `test/core/src/lombok/TestEclipse.java`, +and `test/core/src/lombok/launch/PatchFixesHiderTest.java`; no other Java source changes were made. +In the build checkout, the two remote resolvers in +`buildScripts/ivysettings.xml` were reordered to try Maven Central before Eclipse staging; the +local resolver and all dependency coordinates were retained. The JVM was configured to use the +local HTTP/HTTPS proxy for downloads. Ant's timestamps mean a rebuild need not be byte-identical; +`SHA256SUMS` records the checked-in artifact and is verified by the Rust regression tests. + +## Validation + +A parent POM with DTO and application modules uses Lombok 1.18.46. Root `lombok.config` enables +`config.stopBubbling = true` and `lombok.accessors.chain = true`. + +On macOS arm64 with JDT LS 1.63.0 / Equinox 3.25.0, server OpenJDK 25 and project JDK 21: + +- Official 1.18.46 and 1.18.48 agents generated `void` setters; the following Maven incremental + build reused the DTO classes and failed with `void cannot be dereferenced`. +- This agent passed five fresh project/workspace builds. `javap` confirmed setters returning + the DTO class; the following Maven incremental install passed. +- Without `lombok.config`, both official 1.18.48 and patched agents built successfully and produced + identical `javap` output, including default `void` setters. +- Upstream `PatchFixesHiderTest` passed both the standard OSGi and legacy Equinox lookup tests. + +Windows and Linux runtime validation has not been performed. Rust tests cover source selection +for both supported architectures on all three OS variants, offline provisioning, corruption +repair, concurrent windows, explicit user overrides, and JDT LS workspace migration. diff --git a/plugins/lombok/SHA256SUMS b/plugins/lombok/SHA256SUMS new file mode 100644 index 000000000..67cbcd675 --- /dev/null +++ b/plugins/lombok/SHA256SUMS @@ -0,0 +1 @@ +10f5cd1a32b946d3ff1c075735b94f35fffdbac11dea2be29d24dbf591dd51a4 lombok-1.18.48-openidea.1.jar diff --git a/plugins/lombok/lombok-1.18.48-openidea.1.jar b/plugins/lombok/lombok-1.18.48-openidea.1.jar new file mode 100644 index 000000000..d8a4dce2f Binary files /dev/null and b/plugins/lombok/lombok-1.18.48-openidea.1.jar differ diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 909f3e638..5ebf05c5e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -76,3 +76,5 @@ 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/fonts.rs b/src-tauri/src/commands/fonts.rs index 705da8e2d..8a27a0faa 100644 --- a/src-tauri/src/commands/fonts.rs +++ b/src-tauri/src/commands/fonts.rs @@ -13,3 +13,13 @@ pub async fn system_font_families(refresh: bool) -> IdeResult> { .await .map_err(|e| IdeError::internal(e.to_string())) } + +/// Raw font bundle, selected by family, never by a frontend-supplied path. +#[tauri::command] +pub async fn user_font_data(family: String) -> IdeResult { + tauri::async_runtime::spawn_blocking(move || ide_fonts::user_font_data(&family)) + .await + .map_err(|e| IdeError::internal(e.to_string()))? + .map(tauri::ipc::Response::new) + .map_err(|e| IdeError::invalid(e.to_string())) +} 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 2af828bb3..4cfeb1ff0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -87,7 +87,12 @@ pub fn run() { // Paths macOS asked to open (Finder's Open With, the Dock) before setup ran: the first window takes them. let early_opens: Arc>>> = Arc::default(); let setup_opens = early_opens.clone(); - let invoke = Arc::new(builder.invoke_handler()); + let typed_invoke = builder.invoke_handler(); + // Raw Response is not a JSON/Specta type; keep the binary command out of generated bindings. + let raw_invoke: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![commands::fonts::user_font_data]; + let invoke = Arc::new(move |request: tauri::ipc::Invoke| { + if request.message.command() == "user_font_data" { raw_invoke(request) } else { typed_invoke(request) } + }); app.invoke_handler(move |request| { let webview = request.message.webview_ref(); if let Some(state) = webview.try_state::() { diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 0108a4b97..54b6a35ce 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -299,7 +299,7 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< config.background_color = Some(frame_color(a, false)); } let window = WebviewWindowBuilder::from_config(app, &config) - .and_then(|b| b.build()) + .and_then(|b| dev_font_preferences(b).build()) .map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?; apply_zoom(app, &window); #[cfg(target_os = "macos")] @@ -771,7 +771,7 @@ pub fn open_settings_window( #[cfg(not(target_os = "macos"))] let builder = builder.and_then(|b| b.parent(&parent)); let window = builder - .and_then(|b| b.build()) + .and_then(|b| dev_font_preferences(b).build()) .map_err(|e| IdeError::internal(format!("cannot create settings window: {e}")))?; apply_zoom(app, &window); #[cfg(target_os = "macos")] @@ -885,6 +885,39 @@ fn centered(px: f64, py: f64, pw: f64, ph: f64, w: f64, h: f64) -> (f64, f64) { (px + (pw - w) / 2.0, py + ((ph - h) / 2.0).max(0.0)) } +/// Reproduce WKWebView's user-font restriction before WKWebView creation. The +/// private selector and environment override are absent from release builds. +#[cfg(all(debug_assertions, target_os = "macos"))] +fn dev_font_preferences( + builder: WebviewWindowBuilder<'_, tauri::Wry, AppHandle>, +) -> WebviewWindowBuilder<'_, tauri::Wry, AppHandle> { + if std::env::var("OPENIDEA_DEV_DISALLOW_USER_FONTS").as_deref() == Ok("1") { + use objc2::{MainThreadMarker, msg_send, sel}; + use objc2_foundation::NSObjectProtocol; + let config = unsafe { + objc2_web_kit::WKWebViewConfiguration::new(MainThreadMarker::new().expect("window creation on main thread")) + }; + let preferences = unsafe { config.preferences() }; + if preferences.respondsToSelector(sel!(_setShouldAllowUserInstalledFonts:)) { + unsafe { + let _: () = msg_send![&preferences, _setShouldAllowUserInstalledFonts: false]; + } + let allowed: bool = unsafe { msg_send![&preferences, _shouldAllowUserInstalledFonts] }; + tracing::info!(allowed, "Development user-installed fonts override"); + return builder.with_webview_configuration(config); + } + tracing::warn!("WKPreferences user-font development selector unavailable"); + } + builder +} + +#[cfg(not(all(debug_assertions, target_os = "macos")))] +fn dev_font_preferences( + builder: WebviewWindowBuilder<'_, tauri::Wry, AppHandle>, +) -> WebviewWindowBuilder<'_, tauri::Wry, AppHandle> { + builder +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/bindings.ts b/src/bindings.ts index a70080cf7..c3d5811cd 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, @@ -1434,6 +1440,7 @@ export type AppearanceSettings = { editorAntialiasing?: string, showToolWindowBars?: boolean, showToolWindowNames?: boolean, + showToolWindowBadges?: boolean, widescreenToolWindows?: boolean, /** * Status bar widgets shown (`true`) or hidden (`false`) by the user @@ -2511,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. */ @@ -2949,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"; @@ -3181,6 +3209,7 @@ export type FormField = { export type FormatOptions = { tabSize: number, insertSpaces: boolean, + onSave?: boolean, }; /** One occurrence of a duplicated code fragment. */ @@ -3517,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 = @@ -4002,6 +4033,7 @@ export type LanguagesSettings = { rust?: RustSettings, go?: GoSettings, typescript?: TypeScriptSettings, + prettier?: PrettierSettings, java?: JavaSettings, }; @@ -4745,6 +4777,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, @@ -6160,7 +6201,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. */ @@ -6309,7 +6350,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/app/__tests__/host.test.ts b/src/features/app/__tests__/host.test.ts new file mode 100644 index 000000000..27f7d38ee --- /dev/null +++ b/src/features/app/__tests__/host.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import type { BuildInfo } from "@/platform/ipc"; + +const { appBuildInfo, isTauri } = vi.hoisted(() => ({ appBuildInfo: vi.fn(), isTauri: vi.fn() })); +vi.mock("@/platform/ipc", () => ({ commands: { appBuildInfo }, isTauri })); + +const build: BuildInfo = { + version: "0.1.32", + build: "dev", + profile: "debug", + os: "windows", + arch: "x86_64", + tauri: "2.11.3", + updates: false, +}; + +beforeEach(() => { + vi.resetModules(); + appBuildInfo.mockReset(); + isTauri.mockReturnValue(true); +}); + +it("retries after a rejected request and caches the successful build info", async () => { + appBuildInfo.mockRejectedValueOnce(new Error("Project is not trusted")).mockResolvedValueOnce(build); + const { buildInfo, hostOs } = await import("../host"); + + const first = buildInfo(); + expect(buildInfo()).toBe(first); + await expect(first).resolves.toBeNull(); + await expect(hostOs()).resolves.toBe("windows"); + await expect(buildInfo()).resolves.toBe(build); + expect(appBuildInfo).toHaveBeenCalledTimes(2); +}); + +it("shares an in-flight successful request", async () => { + let resolve!: (value: BuildInfo) => void; + appBuildInfo.mockReturnValue(new Promise((done) => (resolve = done))); + const { buildInfo } = await import("../host"); + const first = buildInfo(); + expect(buildInfo()).toBe(first); + expect(appBuildInfo).toHaveBeenCalledTimes(1); + resolve(build); + await expect(first).resolves.toBe(build); +}); + +it("does not invoke the backend outside Tauri", async () => { + isTauri.mockReturnValue(false); + const { buildInfo } = await import("../host"); + await expect(buildInfo()).resolves.toBeNull(); + expect(appBuildInfo).not.toHaveBeenCalled(); +}); diff --git a/src/features/app/host.ts b/src/features/app/host.ts index 649365e98..4e1fdacdb 100644 --- a/src/features/app/host.ts +++ b/src/features/app/host.ts @@ -13,7 +13,12 @@ function normalize(os: string): HostOs { let info: Promise | null = null; export function buildInfo(): Promise { - info ??= isTauri() ? commands.appBuildInfo().catch(() => null) : Promise.resolve(null); + info ??= isTauri() + ? commands.appBuildInfo().catch(() => { + info = null; + return null; + }) + : Promise.resolve(null); return info; } diff --git a/src/features/debugger/__tests__/actions.test.ts b/src/features/debugger/__tests__/actions.test.ts index 0fe45b753..295b6944e 100644 --- a/src/features/debugger/__tests__/actions.test.ts +++ b/src/features/debugger/__tests__/actions.test.ts @@ -268,3 +268,23 @@ describe("debugger actions (IDEA parity)", () => { expect(useOverlays.getState().evaluate?.expression).toBe("total = order.items\nfor i in x:"); }); }); + +describe("output class breakpoint restrictions", () => { + it.each([ + "debug.toggleBreakpoint", + "debug.toggleTemporaryBreakpoint", + "debug.toggleMethodBreakpoint", + "debug.toggleFieldWatchpoint", + "debug.runToCursor", + "debug.forceRunToCursor", + ])("blocks %s for a source-less output class", async (id) => { + paused(); + const path = "file:///p/target/classes/p/C.class"; + caret(path, "java", "class C {\n int n;\n void run() {}\n}", 2); + await executeAction(id); + expect(breakpointsOf(path)).toHaveLength(0); + expect(useSpecialBreakpoints.getState().methods).toHaveLength(0); + expect(useSpecialBreakpoints.getState().fields).toHaveLength(0); + expect(ipc.debugRunTo).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/debugger/__tests__/editor.test.ts b/src/features/debugger/__tests__/editor.test.ts index 57d96067d..b975cca61 100644 --- a/src/features/debugger/__tests__/editor.test.ts +++ b/src/features/debugger/__tests__/editor.test.ts @@ -53,6 +53,32 @@ describe("breakpoint gutter", () => { }); }); +describe("output class gutter", () => { + it("disables ordinary, temporary and logging breakpoints and explains why", () => { + loadBreakpoints("/proj"); + const path = "file:///proj/target/classes/C.class"; + const view = new EditorView({ + state: EditorState.create({ + doc: "class C {}", + extensions: [ + lineNumbers(), + breakpointGutter({ ...ctx, path, language: "java", readOnly: true, virtual: true }), + ], + }), + parent: document.body, + }); + views.push(view); + const cell = view.dom.querySelector(".cm-lineNumbers .cm-gutterElement")!; + cell.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + expect(cell.closest(".cm-gutters")?.getAttribute("title")).toMatch(/output classes without source code/); + for (const modifier of [{}, { altKey: true }, { shiftKey: true }]) { + cell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, ...modifier })); + cell.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0, ...modifier })); + } + expect(breakpointsOf(path)).toHaveLength(0); + }); +}); + describe("breakpoint look", () => { it("shows the verified icon only while a session has bound it", () => { const b = { id: "1", line: 0, enabled: true, condition: null, logMessage: null, hitCondition: null }; diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 78a08fe4e..3112affc7 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -5,6 +5,7 @@ import { downloadJdkActions } from "@/platform/jdk"; import { useProject } from "@/platform/project"; import { activeEditor } from "@/editor/api"; import { maybeEditorService, notifications, showStatusInfo, toolWindowService } from "@/workbench/contributions"; +import { classFileBreakpointReason } from "@/platform/classFiles"; import { canPlaceBreakpoint } from "./breakpoints/languages"; import { removeAllBreakpoints, @@ -75,10 +76,18 @@ function pausedTarget(): { s: DebugSession; thread: number } | null { return thread == null ? null : { s, thread }; } +function breakpointPathAllowed(path: string): boolean { + const reason = classFileBreakpointReason(path); + if (!reason) return true; + showStatusInfo(tx(reason)); + return false; +} + /** Run to Cursor at `line` (0-based) of `path` in the paused session. */ export function runToLine(path: string, line: number) { const t = pausedTarget(); - if (t) void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, path, line, false))); + if (t && breakpointPathAllowed(path)) + void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, path, line, false))); } function notSupported(what: string, s: DebugSession) { @@ -518,7 +527,7 @@ export function registerDebugActions(): () => void { run: () => { const t = pausedTarget(); const c = cursor(); - if (t && c) + if (t && c && breakpointPathAllowed(c.path)) void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false))); }, }, @@ -532,7 +541,7 @@ export function registerDebugActions(): () => void { run: () => { const t = pausedTarget(); const c = cursor(); - if (t && c) + if (t && c && breakpointPathAllowed(c.path)) void report("Force Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true))); }, }, @@ -543,7 +552,7 @@ export function registerDebugActions(): () => void { icon: "breakpoint", run: () => { const c = cursor(); - if (!c) return; + if (!c || !breakpointPathAllowed(c.path)) return; const exists = useBreakpoints.getState().byFile[c.path]?.some((b) => b.line === c.line); const language = activeEditor()?.ctx.language ?? ""; if (!exists && !canPlaceBreakpoint(language)) { @@ -568,7 +577,8 @@ export function registerDebugActions(): () => void { description: "A line breakpoint that is removed after it is hit", run: () => { const c = cursor(); - if (c && canPlaceBreakpoint(activeEditor()?.ctx.language ?? "")) toggleTemporaryBreakpoint(c.path, c.line); + if (c && breakpointPathAllowed(c.path) && canPlaceBreakpoint(activeEditor()?.ctx.language ?? "", c.path)) + toggleTemporaryBreakpoint(c.path, c.line); }, }, { @@ -579,7 +589,7 @@ export function registerDebugActions(): () => void { run: () => { const ed = activeEditor(); const c = cursor(); - if (!ed || !c) return; + if (!ed || !c || !breakpointPathAllowed(c.path)) return; const existing = specialAt(c.path).find((b) => b.kind === "method" && b.line === c.line); const method = methodAt(ed.ctx.language, ed.view.state.doc.toString(), c.line); if (!existing && !method) { @@ -602,7 +612,7 @@ export function registerDebugActions(): () => void { run: () => { const ed = activeEditor(); const c = cursor(); - if (!ed || !c) return; + if (!ed || !c || !breakpointPathAllowed(c.path)) return; const existing = specialAt(c.path).find((b) => b.kind === "field" && b.line === c.line); const field = fieldAt(ed.ctx.language, ed.view.state.doc.toString(), c.line); if (!existing && !field) { diff --git a/src/features/debugger/breakpoints/languages.ts b/src/features/debugger/breakpoints/languages.ts index 1496645db..e532b40d3 100644 --- a/src/features/debugger/breakpoints/languages.ts +++ b/src/features/debugger/breakpoints/languages.ts @@ -3,6 +3,7 @@ * when off, new line breakpoints go only into files of languages that have a * debug adapter (`languages/*.toml` `[debugger]`). */ +import { classFileBreakpointReason } from "@/platform/classFiles"; import { commands, isTauri } from "@/platform/ipc"; import { workflowSettings } from "@/platform/workflowSettings"; @@ -22,7 +23,8 @@ function load() { } /** Whether a new breakpoint may be placed in a file of `language`. */ -export function canPlaceBreakpoint(language: string): boolean { +export function canPlaceBreakpoint(language: string, path = ""): boolean { + if (classFileBreakpointReason(path)) return false; if (workflowSettings().debugger.breakpointsAnyFile) return true; load(); // Until the language list is known, don't block anything. diff --git a/src/features/debugger/editor/breakpointGutter.ts b/src/features/debugger/editor/breakpointGutter.ts index 075dc954b..ada2140ce 100644 --- a/src/features/debugger/editor/breakpointGutter.ts +++ b/src/features/debugger/editor/breakpointGutter.ts @@ -15,6 +15,9 @@ import { updateBreakpoint, useBreakpoints, } from "../breakpoints/store"; +import { classFileBreakpointReason } from "@/platform/classFiles"; +import { tx } from "@/platform/i18n"; +import { showStatusInfo } from "@/workbench/contributions"; import { canPlaceBreakpoint } from "../breakpoints/languages"; import { removeSpecial, @@ -129,6 +132,31 @@ function inBreakpointArea(e: MouseEvent): boolean { } export function breakpointGutter(ctx: EditorDocContext): Extension { + const reason = classFileBreakpointReason(ctx.path); + if (reason) { + const message = tx(reason); + return ViewPlugin.fromClass( + class { + constructor(readonly view: EditorView) { + view.dom.addEventListener("mouseover", this.hover); + view.dom.addEventListener("mousedown", this.down, true); + } + hover = (e: MouseEvent) => { + if (inBreakpointArea(e)) (e.target as HTMLElement).closest(".cm-gutters")?.setAttribute("title", message); + }; + down = (e: MouseEvent) => { + if (!inBreakpointArea(e)) return; + showStatusInfo(message); + }; + destroy() { + this.view.dom.removeEventListener("mouseover", this.hover); + this.view.dom.removeEventListener("mousedown", this.down, true); + const gutters = this.view.dom.querySelector(".cm-gutters"); + if (gutters?.getAttribute("title") === message) gutters.removeAttribute("title"); + } + }, + ); + } const path = ctx.path; const field = StateField.define>({ create: (state) => buildMarkers(state, path), diff --git a/src/features/debugger/editor/runToCursor.ts b/src/features/debugger/editor/runToCursor.ts index f1f3ca7d2..1db51c42c 100644 --- a/src/features/debugger/editor/runToCursor.ts +++ b/src/features/debugger/editor/runToCursor.ts @@ -3,6 +3,7 @@ * line shows a small button left of its text that runs to that line (Resume on * the execution line itself). Settings › Debugger › "Show inlay Run to Cursor popup". */ +import { classFileBreakpointReason } from "@/platform/classFiles"; import type { Extension } from "@codemirror/state"; import { EditorView, ViewPlugin } from "@codemirror/view"; import type { EditorDocContext } from "@/editor/api"; @@ -30,7 +31,7 @@ export function hoveredLine( } export function runToCursorInlay(ctx: EditorDocContext): Extension { - if (ctx.virtual) return []; + if (classFileBreakpointReason(ctx.path) || (ctx.virtual && !ctx.path.startsWith("jdt:"))) return []; const path = ctx.path; return ViewPlugin.fromClass( class { diff --git a/src/features/project-tree/contextMenu.test.ts b/src/features/project-tree/contextMenu.test.ts index 622ddf3b0..d3f18a6d5 100644 --- a/src/features/project-tree/contextMenu.test.ts +++ b/src/features/project-tree/contextMenu.test.ts @@ -40,6 +40,7 @@ const OTHER_ACTIONS = [ "localHistory.putLabel", "diff.compareWith", "diff.compareFiles", + "diff.compareDirectories", "vcs.commit", "vcs.addToVcs", "vcs.annotate", @@ -75,6 +76,14 @@ afterEach(() => { }); describe("project view context menu", () => { + it("offers directory comparison only for two folders and Compare With for a single folder", () => { + expect(labels(contextMenuItems([node("/p/left", true), node("/p/right", true)]))).toContain("Compare Directories"); + expect(labels(contextMenuItems([node("/p/left", true)]))).toContain("Compare With…"); + const mixed = labels(contextMenuItems([node("/p/left", true), node("/p/a.txt")])); + for (const label of ["Compare Directories", "Compare Files", "Compare With…"]) expect(mixed).not.toContain(label); + expect(labels(contextMenuItems([node("/p/a.txt"), node("/p/b.txt")]))).toContain("Compare Files"); + }); + it("follows IDEA's order for a file", () => { const items = contextMenuItems([node(`${ROOT}/src/a.py`)]); expect(labels(items)).toEqual([ @@ -161,7 +170,7 @@ describe("project view context menu", () => { const l = labels(contextMenuItems([node(`${ROOT}/src`, true)])); expect(l).toContain("Mark Directory as"); expect(sub(contextMenuItems([node(`${ROOT}/src`, true)]), "Refactor")).toContain("Move Directory…"); - for (const fileOnly of ["Find Usages", "Reformat Code", "Optimize Imports", "Compare With…", "Run 'a'"]) + for (const fileOnly of ["Find Usages", "Reformat Code", "Optimize Imports", "Run 'a'"]) expect(l).not.toContain(fileOnly); }); diff --git a/src/features/project-tree/contextMenu.ts b/src/features/project-tree/contextMenu.ts index 1b35cf483..9775c9b4c 100644 --- a/src/features/project-tree/contextMenu.ts +++ b/src/features/project-tree/contextMenu.ts @@ -361,7 +361,17 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] { ); } - // IDEA: Compare With… (one file) / Compare Files (two files). + // Compare selections of the same kind; mixed file/folder selections cannot apply. + const compareArg = single?.dir ? { path: single.path, directory: true } : single?.path; + if (nodes.length === 2 && nodes.every((n) => n.dir && !n.placeholder)) + push( + delegated( + "diff.compareDirectories", + nodes.map((n) => n.path), + "Compare Directories", + { icon: "diff" }, + ), + ); if (files.length === 2 && nodes.length === 2) push( delegated( @@ -371,9 +381,9 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] { { icon: "diff" }, ), ); - else if (singleFile) + else if (single && !single.placeholder) push( - delegated("diff.compareWith", singleFile.path, "Compare With…", { + delegated("diff.compareWith", compareArg, "Compare With…", { icon: "diff", shortcut: keyLabel("project.compareWith"), }), diff --git a/src/features/search/__tests__/dialogs.test.tsx b/src/features/search/__tests__/dialogs.test.tsx index 3e10bacd2..202c6dffe 100644 --- a/src/features/search/__tests__/dialogs.test.tsx +++ b/src/features/search/__tests__/dialogs.test.tsx @@ -1,12 +1,17 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { registerAction } from "@/platform/actions"; import type { SeDeps } from "../everywhere/controller"; +const defaultFindFiles: SeDeps["findFiles"] = async (q) => + q ? [{ path: "/p/src/OrderController.kt", relPath: "src/OrderController.kt", score: 1, indices: [4, 5, 6] }] : []; +let findFiles = defaultFindFiles; +let findSymbols: SeDeps["findSymbols"] = async () => []; +let settings: NonNullable = async () => []; const fakeDeps = (): SeDeps => ({ - findFiles: async (q) => - q ? [{ path: "/p/src/OrderController.kt", relPath: "src/OrderController.kt", score: 1, indices: [4, 5, 6] }] : [], - findSymbols: async () => [], + findFiles: (...args) => findFiles(...args), + findSymbols: (...args) => findSymbols(...args), + settings: () => settings(), searchText: async () => {}, cancel: () => {}, nextRequestId: () => 1, @@ -18,6 +23,11 @@ const fakeDeps = (): SeDeps => ({ now: () => 0, }); +vi.mock("../common/open", async (orig) => ({ + ...(await orig()), + openLocation: vi.fn(async () => {}), +})); + vi.mock("../everywhere/deps", () => ({ ipcDeps: () => fakeDeps(), loadLanguages: async () => [ @@ -42,6 +52,12 @@ vi.mock("../find/windowStore", async (orig) => ({ }), })); +const contributions = await import("@/workbench/contributions"); +const { commands } = await import("@/platform/ipc"); +const { RecentPopupsOverlay } = await import("../recent/RecentPopups"); +const { showRecentPopup, hideRecentPopup, useRecentPopup } = await import("../recent/store"); +const { openLocation } = await import("../common/open"); +const { usePlatform } = await import("@/platform/platformInfo"); const { allActions } = await import("@/platform/actions"); (globalThis as unknown as { __actions: typeof allActions }).__actions = allActions; const { SearchEverywhereOverlay } = await import("../everywhere/SearchEverywhere"); @@ -52,10 +68,18 @@ const { showFindPopup, hideFindPopup } = await import("../find/store"); const tick = () => act(() => new Promise((r) => setTimeout(r, 100))); +beforeEach(() => { + findFiles = defaultFindFiles; + findSymbols = async () => []; + settings = async () => []; + vi.mocked(openLocation).mockClear(); +}); + afterEach(() => { act(() => { hideSearchEverywhere(); hideFindPopup(); + hideRecentPopup(); }); }); @@ -203,6 +227,254 @@ describe("Search Everywhere dialog", () => { }); }); +const duplicateFiles: Awaited> = ["one", "two", "three"].map((dir) => ({ + path: `/p/${dir}/index.ts`, + relPath: `${dir}/index.ts`, + score: 1, + indices: [0, 1, 2, 3, 4], +})); +const resultOptions = () => within(document.getElementById("se-results")!).getAllByRole("option"); +const selectedOptions = () => resultOptions().filter((r) => r.getAttribute("aria-selected") === "true"); +async function showDuplicateFiles() { + findFiles = async () => duplicateFiles; + render(); + act(() => showSearchEverywhere("files", { query: "index" })); + await tick(); + return screen.getByLabelText("Search query"); +} +function clickOption(index: number, modifiers = {}) { + const row = resultOptions()[index]!; + fireEvent.mouseDown(row, modifiers); + fireEvent.click(row, modifiers); +} + +describe("Search Everywhere multi-selection", () => { + it("extends, shrinks, crosses the anchor and clamps Shift selection; plain arrows select one", async () => { + const input = await showDuplicateFiles(); + const key = (key: string, shiftKey = true) => fireEvent.keyDown(input, { key, shiftKey }); + key("ArrowDown", false); + key("ArrowDown"); + expect(selectedOptions()).toEqual(resultOptions().slice(1)); + key("ArrowDown"); + expect(selectedOptions()).toHaveLength(2); + key("ArrowUp"); + expect(selectedOptions()).toEqual([resultOptions()[1]]); + key("ArrowUp"); + expect(selectedOptions()).toEqual(resultOptions().slice(0, 2)); + key("ArrowDown", false); + expect(selectedOptions()).toEqual([resultOptions()[1]]); + expect(screen.getByRole("listbox", { name: "Results" })).toHaveAttribute("aria-multiselectable", "true"); + }); + + it("Shift-click selects a range without opening; an ordinary click opens just that file", async () => { + await showDuplicateFiles(); + clickOption(2, { shiftKey: true }); + expect(selectedOptions()).toHaveLength(3); + expect(openLocation).not.toHaveBeenCalled(); + expect(useSearchEverywhere.getState().open).toBe(true); + clickOption(1); + await tick(); + expect(openLocation).toHaveBeenCalledExactlyOnceWith("/p/two/index.ts", null, { side: false }); + expect(useSearchEverywhere.getState().open).toBe(false); + }); + + it.each(["macos", "windows", "linux"] as const)("uses the primary modifier to toggle a row on %s", async (os) => { + const previous = usePlatform.getState().os; + usePlatform.setState({ os }); + try { + const input = await showDuplicateFiles(); + const modifier = os === "macos" ? { metaKey: true } : { ctrlKey: true }; + clickOption(2, modifier); + expect(selectedOptions()).toEqual([resultOptions()[0], resultOptions()[2]]); + clickOption(0, modifier); + expect(selectedOptions()).toEqual([resultOptions()[2]]); + expect(openLocation).not.toHaveBeenCalled(); + fireEvent.keyDown(input, { key: "Enter" }); + await tick(); + expect(openLocation).toHaveBeenCalledExactlyOnceWith("/p/three/index.ts", null, { side: false }); + } finally { + usePlatform.setState({ os: previous }); + } + }); + + it("awaits each open in list order, keeps tabs and restores an upward range's lead", async () => { + const input = await showDuplicateFiles(); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "ArrowUp", shiftKey: true }); + let finishFirst!: () => void; + vi.mocked(openLocation).mockImplementationOnce( + () => + new Promise((r) => { + finishFirst = r; + }), + ); + fireEvent.keyDown(input, { key: "Enter" }); + await tick(); + expect(openLocation).toHaveBeenCalledTimes(1); + expect(useSearchEverywhere.getState().open).toBe(true); + fireEvent.keyDown(input, { key: "Enter" }); + expect(openLocation).toHaveBeenCalledTimes(1); + expect(openLocation).toHaveBeenNthCalledWith(1, "/p/two/index.ts", null, { + preview: false, + focus: false, + side: false, + }); + await act(async () => finishFirst()); + await tick(); + expect(vi.mocked(openLocation).mock.calls).toEqual([ + ["/p/two/index.ts", null, { preview: false, focus: false, side: false }], + ["/p/three/index.ts", null, { preview: false, focus: false, side: false }], + ["/p/two/index.ts", null, { preview: false, focus: true, side: false }], + ]); + expect(useSearchEverywhere.getState().open).toBe(false); + }); + + it("resets selection immediately on query changes and tab switches", async () => { + const input = await showDuplicateFiles(); + clickOption(2, { shiftKey: true }); + fireEvent.change(input, { target: { value: "index.t" } }); + expect(selectedOptions()).toHaveLength(1); + await tick(); + clickOption(2, { shiftKey: true }); + fireEvent.click(screen.getByRole("tab", { name: "All" })); + expect(selectedOptions()).toHaveLength(1); + await tick(); + expect(selectedOptions()).toHaveLength(1); + }); + + it.each(["classes", "symbols"] as const)("opens selected %s at their positions", async (tab) => { + findSymbols = async () => + ["First", "Second"].map((name, line) => ({ + name, + kind: tab === "classes" ? "class" : "function", + container: null, + path: `/p/${name}.ts`, + relPath: `${name}.ts`, + score: 1, + indices: [0], + range: { start: { line, col: 2 }, end: { line, col: 5 } }, + })); + render(); + act(() => showSearchEverywhere(tab, { query: "s" })); + await tick(); + const input = screen.getByLabelText("Search query"); + fireEvent.keyDown(input, { key: "ArrowDown", shiftKey: true }); + fireEvent.keyDown(input, { key: "Enter" }); + await tick(); + expect(openLocation).toHaveBeenNthCalledWith( + 1, + "/p/First.ts", + { line: 0, col: 2 }, + expect.objectContaining({ preview: false }), + ); + expect(openLocation).toHaveBeenNthCalledWith( + 2, + "/p/Second.ts", + { line: 1, col: 2 }, + expect.objectContaining({ preview: false }), + ); + }); + + it("ignores actions and settings in a mixed selection even when the lead is an action", async () => { + const run = vi.fn(); + const dispose = registerAction({ id: "test.index", title: "Index", category: "Test", run }); + findFiles = async () => duplicateFiles.slice(0, 2); + settings = async () => [{ pageId: "editor", title: "Index", path: ["Editor"] }]; + render(); + act(() => showSearchEverywhere("all", { query: "index" })); + await tick(); + const input = screen.getByLabelText("Search query"); + const count = resultOptions().length; + for (let i = 1; i < count; i++) fireEvent.keyDown(input, { key: "ArrowDown" }); + for (let i = 1; i < count; i++) fireEvent.keyDown(input, { key: "ArrowUp", shiftKey: true }); + expect(selectedOptions()).toHaveLength(count); + fireEvent.keyDown(input, { key: "Enter" }); + await tick(); + expect(run).not.toHaveBeenCalled(); + expect(vi.mocked(openLocation).mock.calls.map((c) => c[0])).toEqual([ + "/p/one/index.ts", + "/p/two/index.ts", + "/p/two/index.ts", + ]); + dispose(); + }); + + it("does not execute a multi-selection containing only actions", async () => { + const run = vi.fn(); + const disposers = ["One", "Two"].map((name) => + registerAction({ id: `test.index${name}`, title: `Index ${name}`, run }), + ); + render(); + act(() => showSearchEverywhere("actions", { query: "index" })); + await tick(); + const input = screen.getByLabelText("Search query"); + fireEvent.keyDown(input, { key: "ArrowDown", shiftKey: true }); + fireEvent.keyDown(input, { key: "Enter" }); + await tick(); + expect(run).not.toHaveBeenCalled(); + expect(openLocation).not.toHaveBeenCalled(); + disposers.forEach((d) => d()); + }); +}); + +describe("Recent Files multi-selection", () => { + async function showFiles() { + vi.spyOn(commands, "recentFiles").mockResolvedValue({ status: "ok", data: duplicateFiles.map((f) => f.path) }); + render(); + act(() => showRecentPopup("files")); + await tick(); + return screen.getByLabelText("Filter recent files"); + } + const options = () => within(screen.getByRole("listbox", { name: "Recent files" })).getAllByRole("option"); + afterEach(() => vi.restoreAllMocks()); + + it("extends and shrinks a keyboard range, then opens its files in order", async () => { + const input = await showFiles(); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "ArrowUp", shiftKey: true }); + expect(options().filter((r) => r.getAttribute("aria-selected") === "true")).toHaveLength(2); + fireEvent.keyDown(input, { key: "Enter" }); + await tick(); + expect(vi.mocked(openLocation).mock.calls.map((c) => c[0])).toEqual([ + "/p/one/index.ts", + "/p/two/index.ts", + "/p/one/index.ts", + ]); + expect(useRecentPopup.getState().open).toBeNull(); + }); + + it("starts a range at the initially selected previous file", async () => { + vi.spyOn(contributions, "maybeEditorService").mockReturnValue({ + activePath: () => "/p/one/index.ts", + } as import("@/workbench/contributions").EditorService); + const input = await showFiles(); + expect(options()[1]).toHaveAttribute("aria-selected", "true"); + fireEvent.keyDown(input, { key: "ArrowDown", shiftKey: true }); + expect(options()[0]).toHaveAttribute("aria-selected", "false"); + expect(options()[1]).toHaveAttribute("aria-selected", "true"); + expect(options()[2]).toHaveAttribute("aria-selected", "true"); + }); + + it("handles modifier clicks and resets on filter text changes", async () => { + const input = await showFiles(); + const click = (i: number, modifiers: object) => { + fireEvent.mouseDown(options()[i]!, modifiers); + fireEvent.click(options()[i]!, modifiers); + }; + click(2, { shiftKey: true }); + expect(options().filter((r) => r.getAttribute("aria-selected") === "true")).toHaveLength(3); + click(1, { metaKey: true }); + expect(options().filter((r) => r.getAttribute("aria-selected") === "true")).toEqual([options()[0], options()[2]]); + expect(openLocation).not.toHaveBeenCalled(); + fireEvent.change(input, { target: { value: "index" } }); + expect(options().filter((r) => r.getAttribute("aria-selected") === "true")).toHaveLength(1); + click(1, {}); + await tick(); + expect(openLocation).toHaveBeenCalledExactlyOnceWith("/p/two/index.ts", null, { side: false }); + }); +}); + describe("Find in Files dialog", () => { it("streams results and previews the replacement", async () => { render(); diff --git a/src/features/search/common/open.ts b/src/features/search/common/open.ts index c5f52601f..a11da3857 100644 --- a/src/features/search/common/open.ts +++ b/src/features/search/common/open.ts @@ -82,3 +82,17 @@ export function saveJson(key: string, value: unknown) { /* storage unavailable */ } } + +/** Open persistent tabs in list order, then restore the lead's focus and position. */ +export async function openInListOrder( + items: readonly T[], + lead: T | undefined, + open: (item: T, options: Omit) => Promise, + side = false, + afterOpen?: () => Promise, +): Promise { + for (const item of items) await open(item, { preview: false, focus: false, side }); + await afterOpen?.(); + const target = lead && items.includes(lead) ? lead : items.at(-1); + if (target) await open(target, { preview: false, focus: true, side }); +} diff --git a/src/features/search/everywhere/SearchEverywhere.tsx b/src/features/search/everywhere/SearchEverywhere.tsx index f5bd6c410..c5c50460f 100644 --- a/src/features/search/everywhere/SearchEverywhere.tsx +++ b/src/features/search/everywhere/SearchEverywhere.tsx @@ -18,12 +18,16 @@ import { Select, Toggle, listNavigate, + selectRow, + selectedRows, + rowSelectionMode, + type RowSelection, type MenuItem, T, } from "@/ui"; import { useFileDecoration } from "@/workbench/contributions"; import { highlightTokens, languageForPath, type CodeToken } from "@/editor/lsp/markdown"; -import { badgeKind, basename, openLocation, relDir } from "../common/open"; +import { badgeKind, basename, openInListOrder, openLocation, relDir } from "../common/open"; import { parseLocationQuery } from "../common/query"; import { CodePreview, type PreviewHighlight } from "../find/parts"; import { SeController, type SeSnapshot } from "./controller"; @@ -81,7 +85,8 @@ export function SearchEverywhereOverlay() { return open ? : null; } -type Selection = { key: string | null; index: number; moved: boolean }; +type Selection = RowSelection & { key: string | null; anchorKey: string | null; moved: boolean }; +const INITIAL_SELECTION: Selection = { key: null, anchorKey: null, lead: -1, anchor: -1, keys: null, moved: false }; function SearchEverywhereDialog() { const tx = useTx(); @@ -100,7 +105,7 @@ function SearchEverywhereDialog() { const [extra, setExtra] = useState>>({}); const [snap, setSnap] = useState(null); const [controller] = useState(() => new SeController(ipcDeps(ctx), setSnap)); - const [sel, setSel] = useState({ key: null, index: -1, moved: false }); + const [sel, setSel] = useState(INITIAL_SELECTION); const [menu, setMenu] = useState<{ anchor: Element; items: MenuItem[]; @@ -134,12 +139,19 @@ function SearchEverywhereDialog() { return () => clearTimeout(t); }, [query, debounced]); + // Reset selection immediately, including while the new query is debouncing. + const [selectionInputs, setSelectionInputs] = useState({ query, tab }); + if (selectionInputs.query !== query || selectionInputs.tab !== tab) { + setSelectionInputs({ query, tab }); + setSel(INITIAL_SELECTION); + } + // New query / scope: forget "Show more" and the user's selection. const [prevInputs, setPrevInputs] = useState({ debounced, tab }); if (prevInputs.debounced !== debounced || prevInputs.tab !== tab) { setPrevInputs({ debounced, tab }); setExtra({}); - setSel({ key: null, index: -1, moved: false }); + setSel(INITIAL_SELECTION); } // "/fi" lists scope commands instead of searching. @@ -177,7 +189,7 @@ function SearchEverywhereDialog() { () => listing ?? (snap ? buildRows(snap.sections, { headers: !parsed.text.trim(), mix }) : []), [listing, snap, parsed.text, mix], ); - const active = stableIndex(rows, sel.key, sel.index, sel.moved); + const active = stableIndex(rows, sel.key, sel.lead, sel.moved); const activeRow = active >= 0 ? rows[active] : undefined; // Rows still answer an older query (debounce / in-flight search): Enter must wait. const settled = @@ -187,8 +199,20 @@ function SearchEverywhereDialog() { snap.tab === tab && topResultSettled(snap.sections, mix); const pendingEnter = useRef<{ side: boolean } | null>(null); + const opening = useRef(false); - const select = (index: number) => setSel({ key: rows[index]?.key ?? null, index, moved: true }); + const chosen = selectedRows(rows, { ...sel, lead: active }); + const select = (index: number, e?: KeyboardEvent | MouseEvent) => { + const anchor = rows.findIndex((r) => r.key === sel.anchorKey); + const next = selectRow( + rows, + { ...sel, lead: active, anchor: anchor < 0 ? active : anchor }, + index, + rowSelectionMode(e), + isSectionRow, + ); + setSel({ ...next, key: rows[next.lead]?.key ?? null, anchorKey: rows[next.anchor]?.key ?? null, moved: true }); + }; /** Typed or picked query; "/files foo" switches to Files with "foo". */ const setQuery = (value: string) => { history.current.pos = -1; @@ -246,24 +270,13 @@ function SearchEverywhereDialog() { case "more": { const step = tab === "all" ? MORE_STEP_ALL : MORE_STEP_TAB; setExtra((e) => ({ ...e, [row.section]: (e[row.section] ?? 0) + step })); - setSel({ key: null, index: rows.indexOf(row), moved: true }); + setSel({ ...INITIAL_SELECTION, lead: rows.indexOf(row), moved: true }); return; } case "file": - return closeThen( - () => void openLocation(row.path, row.line !== null ? { line: row.line, col: row.col ?? 0 } : null, { side }), - ); case "symbol": - return closeThen(() => void openLocation(row.path, { line: row.line, col: row.col }, { side })); case "text": - return closeThen( - () => - void openLocation( - row.path, - { line: row.line, col: row.col, endLine: row.line, endCol: row.endCol }, - { side }, - ), - ); + return closeThen(() => void openResult(row, { side })); case "action": if (!row.enabled) return; pushRecentAction(row.id); @@ -277,11 +290,27 @@ function SearchEverywhereDialog() { } }; + const activateSelection = (side = false) => { + if (opening.current) return; + if (chosen.length <= 1) return activate(chosen[0], side); + const navigable = chosen.filter(isNavigationRow); + if (!navigable.length) return close(); + opening.current = true; + pushSearchHistory(tab, query); + void openInListOrder( + navigable, + activeRow && isNavigationRow(activeRow) ? activeRow : undefined, + openResult, + side, + () => new Promise((resolve) => closeThen(resolve)), + ); + }; + useEffect(() => { const pending = pendingEnter.current; if (!pending || !settled) return; pendingEnter.current = null; - activate(activeRow, pending.side); + activateSelection(pending.side); }); /** IDEA's ⌥↵ on an action: record a shortcut for it (written to the active keymap). */ @@ -291,7 +320,7 @@ function SearchEverywhereDialog() { const row = activeRow; if (!row || row.kind === "section" || row.kind === "more" || row.kind === "command") return; if (row.kind === "action") return assignShortcut(row.id); - const anchor = listRef.current?.querySelector('[aria-selected="true"]') ?? inputRef.current; + const anchor = listRef.current?.querySelector('[data-lead="true"]') ?? inputRef.current; if (!anchor) return; const copy = (text: string) => void navigator.clipboard?.writeText(text).catch(() => {}); const items: MenuItem[] = @@ -331,7 +360,7 @@ function SearchEverywhereDialog() { closeThen(() => void executeAction("find.inPath", { query: parsed.text })); else if (listing) activate(activeRow); else if (!settled && !sel.moved) pendingEnter.current = { side: e.shiftKey }; - else activate(activeRow, e.shiftKey); + else activateSelection(e.shiftKey); return; } if (e.altKey && (e.key === "ArrowUp" || e.key === "ArrowDown")) { @@ -347,15 +376,15 @@ function SearchEverywhereDialog() { const jump = groupJump(rows, active, e.key === "PageDown" || e.key === "ArrowDown" ? 1 : -1); if (jump !== null) { e.preventDefault(); - select(jump); + select(jump, e); return; } } if (!["ArrowDown", "ArrowUp", "PageDown", "PageUp"].includes(e.key)) return; - const next = listNavigate(e.key, { items: rows, active, isSection: isSectionRow, wrap: true, pageSize: 10 }); + const next = listNavigate(e.key, { items: rows, active, isSection: isSectionRow, wrap: !e.shiftKey, pageSize: 10 }); if (next === null) return; e.preventDefault(); - select(next); + select(next, e); }; const maxList = preview ? MAX_LIST_PREVIEW : MAX_LIST; @@ -522,7 +551,8 @@ function SearchEverywhereDialog() { {r.kind === "section" ? {r.title} : ""} )} activeIndex={active} - onActiveChange={select} + onSelect={select} + isSelected={(r) => !!sel.keys?.has(r.key)} onActivate={(r, _i, e) => activate(r, (e as MouseEvent).shiftKey)} focusable={false} rowHeight={ROW} @@ -844,3 +874,21 @@ function FileRow({ ); } + +type NavigationRow = Extract; +function isNavigationRow(row: SeRow): row is NavigationRow { + return row.kind === "file" || row.kind === "symbol" || row.kind === "text"; +} +function openResult(row: NavigationRow, options: Parameters[2]): Promise { + if (row.kind === "file") + return openLocation(row.path, row.line !== null ? { line: row.line, col: row.col ?? 0 } : null, options); + return openLocation( + row.path, + { + line: row.line, + col: row.col, + ...(row.kind === "text" ? { endLine: row.line, endCol: row.endCol } : {}), + }, + options, + ); +} diff --git a/src/features/search/find/rowSelection.ts b/src/features/search/find/rowSelection.ts index 19d35b58a..6930c0073 100644 --- a/src/features/search/find/rowSelection.ts +++ b/src/features/search/find/rowSelection.ts @@ -1,52 +1 @@ -/** - * Multi-selection of the Find in Files results list: click / ↑↓ select one row, - * ⇧ extends from the anchor, ⌘/Ctrl toggles a row (IDEA's results table). - */ - -export interface RowSelection { - /** Active row (keyboard focus, preview). */ - lead: number; - /** Start of ⇧ ranges. */ - anchor: number; - /** Keys of the selected rows; `null` when only the lead is selected. */ - keys: ReadonlySet | null; -} - -export const SINGLE_FIRST: RowSelection = { lead: 0, anchor: 0, keys: null }; - -export function selectRow( - rows: ReadonlyArray<{ key: string }>, - sel: RowSelection, - index: number, - mode: "single" | "range" | "toggle" = "single", -): RowSelection { - if (index < 0 || index >= rows.length) return sel; - if (mode === "range") { - const anchor = Math.min(Math.max(0, sel.anchor), rows.length - 1); - const [a, b] = anchor <= index ? [anchor, index] : [index, anchor]; - return { lead: index, anchor, keys: new Set(rows.slice(a, b + 1).map((r) => r.key)) }; - } - if (mode === "toggle") { - const keys = new Set(sel.keys ?? (rows[sel.lead] ? [rows[sel.lead]!.key] : [])); - const key = rows[index]!.key; - if (!keys.has(key)) return { lead: index, anchor: index, keys: keys.add(key) }; - keys.delete(key); - const rest = rows.findLastIndex((r) => keys.has(r.key)); - if (rest < 0) return { lead: index, anchor: index, keys: null }; - return { lead: rest, anchor: rest, keys }; - } - return { lead: index, anchor: index, keys: null }; -} - -/** The selected rows in list order (the lead when nothing else is selected). */ -export function selectedRows(rows: readonly T[], sel: RowSelection): T[] { - if (!rows.length) return []; - const lead = Math.min(sel.lead, rows.length - 1); - if (!sel.keys) return [rows[lead]!]; - const out = rows.filter((r) => sel.keys!.has(r.key)); - return out.length ? out : [rows[lead]!]; -} - -export function isRowSelected(sel: RowSelection, key: string): boolean { - return !!sel.keys?.has(key); -} +export { SINGLE_FIRST, isRowSelected, selectRow, selectedRows, type RowSelection } from "@/ui/rowSelection"; diff --git a/src/features/search/recent/RecentPopups.tsx b/src/features/search/recent/RecentPopups.tsx index efab57b66..a6b79e08b 100644 --- a/src/features/search/recent/RecentPopups.tsx +++ b/src/features/search/recent/RecentPopups.tsx @@ -5,7 +5,21 @@ import { editorsFor, getNavigationHistory } from "@/editor/api"; import { call, commands, type OutlineItem } from "@/platform/ipc"; import { shortcutLabel } from "@/platform/keymap"; import { useProject } from "@/platform/project"; -import { Checkbox, Dialog, HighlightedText, Icon, List, fileIconName, listNavigate, T } from "@/ui"; +import { + Checkbox, + Dialog, + HighlightedText, + Icon, + List, + fileIconName, + listNavigate, + selectRow, + selectedRows, + rowSelectionMode, + SINGLE_FIRST, + type RowSelection, + T, +} from "@/ui"; import { maybeEditorService, toolWindowService, @@ -15,7 +29,7 @@ import { type ToolWindowContribution, } from "@/workbench/contributions"; import { highlightTokens, languageForPath, type CodeToken } from "@/editor/lsp/markdown"; -import { basename, openLocation } from "../common/open"; +import { basename, openInListOrder, openLocation } from "../common/open"; import { outlinePathAt } from "../structure/outline"; import { fromNavigationHistory, isDismissed, mergeLocations, type RecentLocation } from "../common/locations"; import { dismissLocation, useTracking } from "../common/tracking"; @@ -73,6 +87,14 @@ function RecentFilesDialog() { const [column, setColumn] = useState<"files" | "tools">("files"); const [fileIndex, setFileIndex] = useState(null); const [toolIndex, setToolIndex] = useState(0); + const opening = useRef(false); + const [previousEditedOnly, setPreviousEditedOnly] = useState(editedOnly); + const [fileSelection, setFileSelection] = useState(SINGLE_FIRST); + if (previousEditedOnly !== editedOnly) { + setPreviousEditedOnly(editedOnly); + setFileSelection(SINGLE_FIRST); + setFileIndex(null); + } const inputRef = useRef(null); // The current editor: a file path, or the key of a non-file editor (Switcher lists those too). const activePath = useMemo(() => { @@ -106,6 +128,7 @@ function RecentFilesDialog() { ), [paths, tabs, removed, query, root, edited, editedOnly], ); + const fileRows = useMemo(() => files.map((f) => ({ ...f, key: f.path })), [files]); const tools = useMemo(() => switcherToolWindows(toolWindows.values(), !!root, query), [toolWindows, root, query]); const fIndex = files.length ? Math.min(fileIndex ?? initialFileIndex(files, activePath, query), files.length - 1) @@ -123,6 +146,31 @@ function RecentFilesDialog() { ); }; + const selectFile = (index: number, e?: KeyboardEvent | ReactMouseEvent) => { + const next = selectRow( + fileRows, + { ...fileSelection, lead: fIndex, anchor: fileSelection.keys ? fileSelection.anchor : fIndex }, + index, + switcher ? "single" : rowSelectionMode(e), + ); + setFileSelection(next); + setFileIndex(next.lead); + setColumn("files"); + }; + const openFiles = (side = false) => { + if (opening.current) return; + const chosen = selectedRows(fileRows, { ...fileSelection, lead: fIndex }); + if (chosen.length <= 1) return openFile(chosen[0], side); + opening.current = true; + void openInListOrder( + chosen, + fileRows[fIndex], + (f, options) => openLocation(f.path, null, options), + side, + () => new Promise((resolve) => afterClose(resolve)), + ); + }; + const openTool = (tw: ToolWindowContribution | undefined) => { if (tw) afterClose(() => toolWindowService().show(tw.id, true)); }; @@ -143,6 +191,7 @@ function RecentFilesDialog() { if (!switcher) void call(commands.removeRecent(item.path)).catch(() => {}); setRemoved((r) => new Set(r).add(item.path)); setFileIndex(fIndex); + setFileSelection({ lead: fIndex, anchor: fIndex, keys: null }); }; // Switcher: each ⌃⇥ moves one row, past the end into the other column; releasing Ctrl opens the selection. @@ -182,7 +231,7 @@ function RecentFilesDialog() { // Nothing recent matches: carry the text over to Go to File. const text = query.trim(); afterClose(() => showSearchEverywhere("files", { query: text })); - } else if (effectiveColumn === "files") openFile(files[fIndex], e.shiftKey); + } else if (effectiveColumn === "files") openFiles(e.shiftKey); else openTool(tools[tIndex]?.tw); return; } @@ -201,6 +250,7 @@ function RecentFilesDialog() { if (e.key === "Tab" || ((e.key === "ArrowLeft" || e.key === "ArrowRight") && !query)) { e.preventDefault(); e.stopPropagation(); + setFileSelection(SINGLE_FIRST); setColumn( e.key === "ArrowLeft" ? "tools" @@ -215,8 +265,8 @@ function RecentFilesDialog() { if (!NAV_KEYS.includes(e.key)) return; e.preventDefault(); if (effectiveColumn === "files") { - const next = listNavigate(e.key, { items: files, active: fIndex, wrap: true }); - if (next !== null) setFileIndex(next); + const next = listNavigate(e.key, { items: files, active: fIndex, wrap: switcher || !e.shiftKey }); + if (next !== null) selectFile(next, e); } else { const next = listNavigate(e.key, { items: tools, active: tIndex, wrap: true }); if (next !== null) setToolIndex(next); @@ -248,6 +298,7 @@ function RecentFilesDialog() { onChange={(e) => { setQuery(e.target.value); setFileIndex(null); + setFileSelection(SINGLE_FIRST); setToolIndex(0); }} onKeyDown={onKeyDown} @@ -280,6 +331,7 @@ function RecentFilesDialog() { getKey={(t) => t.tw.id} activeIndex={effectiveColumn === "tools" ? tIndex : -1} onActiveChange={(i) => { + setFileSelection(SINGLE_FIRST); setToolIndex(i); setColumn("tools"); }} @@ -307,10 +359,9 @@ function RecentFilesDialog() { items={files} getKey={(f) => f.path} activeIndex={effectiveColumn === "files" ? fIndex : -1} - onActiveChange={(i) => { - setFileIndex(i); - setColumn("files"); - }} + onActiveChange={switcher ? (i) => selectFile(i) : undefined} + onSelect={switcher ? undefined : selectFile} + isSelected={(f) => effectiveColumn === "files" && !!fileSelection.keys?.has(f.path)} onActivate={(f, _i, e) => openFile(f, e.shiftKey)} focusable={false} rowHeight={24} diff --git a/src/features/settings/__tests__/behavior.test.ts b/src/features/settings/__tests__/behavior.test.ts index de00873e7..e17a9eb13 100644 --- a/src/features/settings/__tests__/behavior.test.ts +++ b/src/features/settings/__tests__/behavior.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; import { formatPattern, formatTimestamp } from "@/platform/dateFormat"; import { resolveThemeId } from "@/workbench/theme"; -import { nextZoom } from "../appearanceRuntime"; +import { applyAppearanceOptions, nextZoom } from "../appearanceRuntime"; +import { validateSettings } from "../model/schema"; import { validateAppearanceExtras, validateNotifications } from "../model/behaviorSchema"; describe("appearance & behavior settings", () => { @@ -21,6 +22,33 @@ describe("appearance & behavior settings", () => { expect(validateAppearanceExtras({ theme: "system" }).syncWithOs).toBe(true); }); + it("defaults Show tool window badges to on and honours the stored value", () => { + expect(validateAppearanceExtras({}).showToolWindowBadges).toBe(true); + expect(validateAppearanceExtras({ showToolWindowBadges: false }).showToolWindowBadges).toBe(false); + expect(validateAppearanceExtras({ showToolWindowBadges: 1 }).showToolWindowBadges).toBe(true); + }); + + it("keeps the UI fallback font empty by default and trims a stored one", () => { + expect(validateAppearanceExtras({}).uiFontFallback).toBe(""); + expect(validateAppearanceExtras({ uiFontFallback: " 思源黑体 " }).uiFontFallback).toBe("思源黑体"); + expect(validateAppearanceExtras({ uiFontFallback: 5 }).uiFontFallback).toBe(""); + }); + + it("applies and clears the UI fallback, and removes custom fonts when disabled", () => { + const root = document.createElement("div"); + const settings = validateSettings({ + appearance: { useCustomFont: true, uiFontFamily: "Menlo", uiFontFallback: "Songti SC" }, + }); + applyAppearanceOptions(settings, root); + expect(root.style.getPropertyValue("--font-ui")).toBe('"Menlo", "Songti SC", system-ui, sans-serif'); + settings.appearance.uiFontFallback = ""; + applyAppearanceOptions(settings, root); + expect(root.style.getPropertyValue("--font-ui")).toBe('"Menlo", system-ui, sans-serif'); + settings.appearance.useCustomFont = false; + applyAppearanceOptions(settings, root); + expect(root.style.getPropertyValue("--font-ui")).toBe(""); + }); + it("formats dates with an override pattern and pretty dates", () => { const d = new Date(2026, 0, 5, 14, 7); expect(formatPattern(d, "dd.MM.yyyy")).toBe("05.01.2026"); diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index 4a11b98c0..dbdb5f024 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -222,6 +222,24 @@ describe("Settings dialog", () => { ]); }); + it("enables, saves and clears the custom UI fallback font", async () => { + const dialog = await openDialog("appearance"); + const fallback = within(dialog).getByRole("combobox", { name: "UI fallback font" }); + const custom = within(dialog).getByRole("checkbox", { name: "Use custom font:" }); + expect(fallback).toBeDisabled(); + fireEvent.click(custom); + expect(fallback).toBeEnabled(); + fireEvent.change(fallback, { target: { value: "Songti SC" } }); + expect(settingsSet).not.toHaveBeenCalled(); + await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" }))); + expect(settingsSet).toHaveBeenCalledWith("global", "appearance.uiFontFallback", JSON.stringify("Songti SC")); + fireEvent.change(fallback, { target: { value: "" } }); + await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" }))); + expect(settingsSet).toHaveBeenCalledWith("global", "appearance.uiFontFallback", JSON.stringify("")); + fireEvent.click(custom); + expect(fallback).toBeDisabled(); + }); + it("edits and saves independent custom font weights", async () => { const dialog = await openDialog("editor.font"); for (const [label, value] of [ diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index 0fd9ac555..5864b0586 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -35,6 +35,16 @@ describe("languages settings", () => { expect(validateSettings({}).languages).toEqual(DEFAULT_LANGUAGES); }); + it("keeps Prettier opt-in on save and normalizes its enabled languages", () => { + expect(validateLanguages({}).prettier).toMatchObject({ prefer: false, runOnSave: false, path: "" }); + expect( + validateLanguages({ + prettier: { path: " ./bin/prettier ", languages: ["markdown", 4, "markdown"], prefer: true, runOnSave: true }, + }).prettier, + ).toEqual({ path: "./bin/prettier", languages: ["markdown"], prefer: true, runOnSave: true }); + expect(validateLanguages({ prettier: { languages: [] } }).prettier.languages).toEqual([]); + }); + it("marks folders with one role each", () => { expect(normalizeFolder("./src/main/")).toBe("src/main"); let ps = validateProjectStructure({ sourceRoots: ["src/", "src", 3], excluded: ["/dist"] }); @@ -58,6 +68,7 @@ describe("language pages", () => { "Java", "Node.js", "Python Interpreter", + "Prettier", "Rust", "TypeScript", ]); diff --git a/src/features/settings/appearanceRuntime.ts b/src/features/settings/appearanceRuntime.ts index 3052bf659..c60c5744b 100644 --- a/src/features/settings/appearanceRuntime.ts +++ b/src/features/settings/appearanceRuntime.ts @@ -33,7 +33,7 @@ export function applyAppearanceOptions(s: ValidSettings, root: HTMLElement = doc if (size === DEFAULT_SETTINGS.appearance.uiFontSize) root.style.removeProperty(name); else root.style.setProperty(name, `${size + delta}px`); } - if (a.useCustomFont) root.style.setProperty("--font-ui", uiFontFamily(a.uiFontFamily)); + if (a.useCustomFont) root.style.setProperty("--font-ui", uiFontFamily(a.uiFontFamily, a.uiFontFallback)); else root.style.removeProperty("--font-ui"); flag(root, "compact", a.compactMode); diff --git a/src/features/settings/languages/PrettierPage.tsx b/src/features/settings/languages/PrettierPage.tsx new file mode 100644 index 000000000..ea20c7b01 --- /dev/null +++ b/src/features/settings/languages/PrettierPage.tsx @@ -0,0 +1,72 @@ +import { useId } from "react"; +import { Input } from "@/ui"; +import { Row, Section, Select, Toggle } from "../components/Form"; +import { useSetting } from "../components/context"; +import { DEFAULT_LANGUAGES } from "@/platform/languageSettings"; + +const names: Record = { + markdown: "Markdown", + yaml: "YAML", + css: "CSS", + scss: "SCSS", + less: "Less", + html: "HTML", + graphql: "GraphQL", + javascript: "JavaScript", + javascriptreact: "JavaScript JSX", + typescript: "TypeScript", + typescriptreact: "TypeScript JSX", + json: "JSON", + jsonc: "JSON with Comments", + vue: "Vue", +}; + +export function PrettierPage() { + const id = useId(); + const [path, setPath] = useSetting("languages.prettier.path"); + const [languages, setLanguages] = useSetting("languages.prettier.languages"); + const [prefer, setPrefer] = useSetting("languages.prettier.prefer"); + const [save, setSave] = useSetting("languages.prettier.runOnSave"); + return ( + <> +
+ + setPath(e.target.value)} /> + + + - )} + + + + {on ? ( + + ) : ( + + )} +
); } @@ -284,6 +302,11 @@ function ToolWindowsGrid() { <> + } right={ @@ -526,7 +549,7 @@ export const BEHAVIOR_PAGES: PageSpec[] = [ { kind: "custom", label: "Use custom font", - keywords: ["UI font size", "Size", "font family"], + keywords: ["UI font size", "Size", "font family", "fallback font"], component: CustomFontRow, }, { kind: "toggle", key: "appearance.contrastScrollbars", label: "Use contrast scrollbars" }, diff --git a/src/features/settings/pages/languages.tsx b/src/features/settings/pages/languages.tsx index b8c320c46..181afc052 100644 --- a/src/features/settings/pages/languages.tsx +++ b/src/features/settings/pages/languages.tsx @@ -4,6 +4,7 @@ * `../languages/`; settings are `languages.*` / `projectStructure.*`. */ import { GoPage, JavaPage, NodePage, RustPage, TypeScriptPage } from "../languages/LanguagePages"; +import { PrettierPage } from "../languages/PrettierPage"; import { ProjectStructurePage } from "../languages/ProjectStructurePage"; import { PythonInterpreterPage } from "../languages/PythonInterpreterPage"; import { GoBuildTagsPage, GO_BUILD_TAG_SECTIONS } from "../languages/GoBuildTags"; @@ -107,6 +108,16 @@ export const LANGUAGE_PAGES: PageSpec[] = [ keywords: ["node interpreter", "package manager", "npm", "pnpm", "yarn", "bun", "nvm"], component: NodePage, }, + { + id: "languages.prettier", + projectScope: true, + defaultScope: "project", + title: "Prettier", + group: LANGUAGES_GROUP, + order: 403.5, + keywords: ["formatter", "prettier", "markdown", "yaml", "css", "format on save", "node_modules"], + component: PrettierPage, + }, { id: "languages.rust", projectScope: true, diff --git a/src/features/terminal/theme.ts b/src/features/terminal/theme.ts index 5996a12dc..5eeb02b9b 100644 --- a/src/features/terminal/theme.ts +++ b/src/features/terminal/theme.ts @@ -6,7 +6,7 @@ */ import type { ITheme, Terminal } from "@xterm/xterm"; import { fontWeightOf } from "@/platform/editorSettings"; -import { BUNDLED_MONO_FAMILY, fontsLoaded, monoFontFamily, monoStack } from "@/platform/fonts"; +import { BUNDLED_MONO_FAMILY, familyFaces, fontsLoaded, monoFontFamily, monoStack } from "@/platform/fonts"; import { parseColor, type Rgba } from "@/platform/cssColor"; export { parseColor, type Rgba }; @@ -152,9 +152,7 @@ const NERD_FONT_FALLBACKS = [ "FiraCode Nerd Font", "Hasklug Nerd Font", "CaskaydiaCove Nerd Font", -] - .map((f) => `"${f}"`) - .join(", "); +]; export interface TerminalFontSettings { useEditorFont: boolean; @@ -189,7 +187,8 @@ export interface TerminalFont { * common installs before the generic family so per-glyph fallback finds them. */ function withNerdFonts(family: string): string { - return `${family.replace(/,\s*monospace\s*$/i, "")}, ${NERD_FONT_FALLBACKS}, monospace`; + const fallbacks = NERD_FONT_FALLBACKS.flatMap(familyFaces).join(", "); + return `${family.replace(/,\s*monospace\s*$/i, "")}, ${fallbacks}, monospace`; } function cssNumber(name: string): number { diff --git a/src/features/tools/LanguageServersPage.tsx b/src/features/tools/LanguageServersPage.tsx index 933b34cda..206456514 100644 --- a/src/features/tools/LanguageServersPage.tsx +++ b/src/features/tools/LanguageServersPage.tsx @@ -39,6 +39,7 @@ const KIND_LABEL: Record = { languageServer: "Language server", debugger: "Debugger", plugin: "Plugin", + formatter: "Formatter", }; export function filterTools(tools: readonly ToolInfo[], query: string, filter: Filter): ToolInfo[] { diff --git a/src/features/tools/__tests__/tools.test.tsx b/src/features/tools/__tests__/tools.test.tsx index 4d4c6e9f2..0d4bd57cc 100644 --- a/src/features/tools/__tests__/tools.test.tsx +++ b/src/features/tools/__tests__/tools.test.tsx @@ -7,7 +7,7 @@ import { useTasks } from "@/workbench/tasks"; import { MemoryToolsBackend, sampleTools, setToolsBackend } from "../backend"; import { cancelInstall, installTool, refreshTools, uninstallTool, useTools } from "../store"; import { filterTools, LanguageServersPage } from "../LanguageServersPage"; -import { installMissingServerPrompts, missingKey } from "../missing"; +import { installMissingServerPrompts, missingKey, promptInstall } from "../missing"; import { resetTestForm, stagedFor, TestSettingsForm } from "./settingsForm"; type Note = Parameters[0]; @@ -104,6 +104,34 @@ describe("tools store", () => { }); describe("missing language server prompt", () => { + it("uses the same install prompt for Prettier with a Reformat Code explanation", async () => { + const template = sampleTools()[0]!; + backend.tools.push({ + ...template, + name: "prettier", + displayName: "Prettier", + command: "prettier", + kind: "formatter", + status: "missing", + installMethod: "npm", + languages: ["Markdown"], + }); + await promptInstall("prettier"); + expect(notes[0]).toMatchObject({ + title: "Prettier is not installed", + key: missingKey("prettier"), + sticky: true, + body: "Reformat Code needs Prettier. Install it via npm?", + }); + expect(notes[0]!.actions!.map((a) => a.label)).toEqual(["Install", "Language Servers…"]); + await promptInstall("prettier", false); + expect(notes.at(-1)).toMatchObject({ key: missingKey("prettier"), sticky: false }); + notes[0]!.actions![0]!.run(); + await waitFor(() => + expect(notes.at(-1)).toMatchObject({ title: "Prettier installed", key: missingKey("prettier") }), + ); + }); + it("offers Install for installable servers, once, replacing the generic notice", async () => { const off = installMissingServerPrompts(); const ev = { type: "languageServer", server: 1, name: "gopls", status: "missing", message: null } as const; diff --git a/src/features/tools/index.ts b/src/features/tools/index.ts index 5086f5781..76736a609 100644 --- a/src/features/tools/index.ts +++ b/src/features/tools/index.ts @@ -9,7 +9,7 @@ import { lazyComponent } from "@/ui"; import { executeAction, registerActions } from "@/platform/actions"; import { registerSettingsPage } from "@/workbench/contributions"; import { installJdkPrompts, registerJdkActions } from "./jdk"; -import { installMissingServerPrompts } from "./missing"; +import { installMissingServerPrompts, promptInstall } from "./missing"; import { installTool, refreshTools, SETTINGS_PAGE_ID, toolByName, useTools } from "./store"; /** The Language Servers page (also used by the Settings window). */ @@ -40,6 +40,16 @@ export function activate(): () => void { const disposers = [ activateSettingsPages(), registerActions([ + { + id: "tools.promptInstall", + title: "Install Missing Tool", + hidden: true, + run: async ({ arg }) => { + if (arg && typeof arg === "object" && "name" in arg && typeof arg.name === "string") { + await promptInstall(arg.name, !("sticky" in arg) || arg.sticky !== false); + } + }, + }, { id: "tools.languageServers", title: "Language Servers…", diff --git a/src/features/tools/missing.ts b/src/features/tools/missing.ts index e5af938e9..c1c48d1a2 100644 --- a/src/features/tools/missing.ts +++ b/src/features/tools/missing.ts @@ -12,7 +12,7 @@ import { tx } from "@/platform/i18n"; export const missingKey = (name: string) => `lsp-missing-${name}`; -export async function promptInstall(name: string): Promise { +export async function promptInstall(name: string, sticky = true): Promise { if (!useTools.getState().loaded) await refreshTools(); const tool = toolByName(name); if (!tool || !tool.canInstall || tool.status !== "missing") return false; @@ -26,16 +26,19 @@ export async function promptInstall(name: string): Promise { t.status !== "missing" && t.languages.some((l) => tool.languages.includes(l)), ); - if (served) return false; + if (tool.kind !== "formatter" && served) return false; const key = missingKey(name); const langs = tool.languages.length ? tool.languages.join(", ") : "these files"; notifications().notify({ group: "tools", level: "warning", - title: `${tool.displayName} is not installed`, - body: tx`Code insight for ${langs} needs ${tool.command}. Install it via ${tool.installMethod}?`, + title: tx`${tool.displayName} is not installed`, + body: + tool.kind === "formatter" + ? tx`Reformat Code needs ${tool.displayName}. Install it via ${tool.installMethod}?` + : tx`Code insight for ${langs} needs ${tool.command}. Install it via ${tool.installMethod}?`, key, - sticky: true, + sticky, actions: [ { label: "Install", run: () => void installTool(name, { notificationKey: key }) }, { label: "Language Servers…", run: () => void executeAction("file.settings", SETTINGS_PAGE_ID) }, diff --git a/src/features/vcs/ComparisonTree.tsx b/src/features/vcs/ComparisonTree.tsx new file mode 100644 index 000000000..e2fd952c6 --- /dev/null +++ b/src/features/vcs/ComparisonTree.tsx @@ -0,0 +1,78 @@ +import { useState, type ReactNode } from "react"; +import { Icon, Tree, fileIconName } from "@/ui"; +import { DiffEditor } from "./DiffEditor"; +import { diffInput, openDiff, type DiffData } from "./diffInput"; +import { dirKeys, type FileTreeNode } from "./fileTree"; +import styles from "./common.module.css"; + +/** Shared changes tree and on-demand diff surface for revisions and disk folders. */ +export function ComparisonTree({ + tree, + current, + onSelect, + diffFor, + renderDetails, + ariaLabel, + openOnActivate = false, + getColor, + onOpen, +}: { + tree: FileTreeNode[]; + current: F | null; + onSelect: (entry: F | null) => void; + diffFor: (entry: F) => DiffData | null; + renderDetails: (node: FileTreeNode) => ReactNode; + ariaLabel: string; + openOnActivate?: boolean; + getColor?: (entry: F) => string | undefined; + onOpen?: () => void; +}) { + const [selected, setSelected] = useState>(new Set()); + const [expanded, setExpanded] = useState | null>(null); + const data = current && diffFor(current); + const input = data ? diffInput(data) : null; + return ( +
+ > + roots={tree} + getKey={(n) => n.key} + getLabel={(n) => n.name} + getChildren={(n) => (n.file ? undefined : n.children)} + isExpandable={(n) => !n.file} + expanded={expanded ?? new Set(dirKeys(tree))} + onExpandedChange={setExpanded} + selected={selected} + onSelectionChange={(sel, lead) => { + setSelected(sel); + onSelect(lead?.file ?? lead?.directory ?? null); + }} + onActivate={(n) => { + const entry = n.file ?? n.directory; + if (!entry) return; + onSelect(entry); + const diff = diffFor(entry); + if (openOnActivate && diff) { + void openDiff(diff); + onOpen?.(); + } + }} + aria-label={ariaLabel} + className={styles.comparisonTree} + renderNode={(n) => ( + + + + {n.name} + + {renderDetails(n)} + + )} + /> + {input && ( +
+ +
+ )} +
+ ); +} diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index 49a2dc674..f44a47d02 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -200,6 +200,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { const data = input.data as DiffData; const { path, left, right } = data; const [attached, setAttached] = useState(null); + const [binary, setBinary] = useState(null); /** Text of the sides that are not live documents. */ const [statics, setStatics] = useState<{ a: string | null; b: string | null } | null>(null); const [error, setError] = useState(null); @@ -263,15 +264,18 @@ export function DiffEditor({ input }: { input: EditorInput }) { const reload = useCallback(async () => { if (!attached) return; try { - const [a, b] = await Promise.all([ + const paths = [sideFile(left, path), sideFile(right, path)].filter((p): p is string => !!p); + const [a, b, probes] = await Promise.all([ attached.a ? null : loadSide(path, left), attached.b ? null : loadSide(path, right), + Promise.all(paths.map((p) => call(commands.fsProbe(p)).catch(() => null))), ]); const prev = staticsRef.current; if (!prev || prev.a !== a || prev.b !== b) { staticsRef.current = { a, b }; setStatics({ a, b }); } + setBinary(probes.some((p) => p?.binary)); setError(null); } catch (e) { setError(errorMessage(e)); @@ -311,9 +315,9 @@ export function DiffEditor({ input }: { input: EditorInput }) { left.rev === "HEAD" && !left.path && isWorktree(right); - const editableA = !!attached?.a && !attached.a.readOnly; - const editableB = !!attached?.b && !attached.b.readOnly; - const ready = !!attached && !!statics && lang !== null; + const editableA = !binary && !!attached?.a && !attached.a.readOnly; + const editableB = !binary && !!attached?.b && !attached.b.readOnly; + const ready = !!attached && !!statics && lang !== null && binary !== null; // Read when the views are built; toggling it later goes through `setSync`. const syncScroll = useRef(opts.syncScroll); @@ -983,7 +987,8 @@ export function DiffEditor({ input }: { input: EditorInput }) { void reload()} /> - {!ready + {binary && tx("Binary content")} + {!ready || binary ? "" : chunkInfo.count === 0 ? identical @@ -1047,7 +1052,11 @@ export function DiffEditor({ input }: { input: EditorInput }) { /> )} - {error ? ( + {binary ? ( +
+ Binary files cannot be shown as text +
+ ) : error ? (
{error}
) : !ready ? (
@@ -1061,7 +1070,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { opts.highlight === "none" && styles.noHighlight, opts.mode === "split" && (opts.alignChanges ? !opts.syncScroll && styles.unsynced : styles.unaligned), )} - hidden={!!error || !ready} + hidden={!!binary || !!error || !ready} aria-label={diffTitle(data)} role="region" onContextMenu={onContextMenu} diff --git a/src/features/vcs/DirectoryCompare.tsx b/src/features/vcs/DirectoryCompare.tsx new file mode 100644 index 000000000..b70e2acad --- /dev/null +++ b/src/features/vcs/DirectoryCompare.tsx @@ -0,0 +1,291 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Channel } from "@tauri-apps/api/core"; +import type { DirectoryCompareEntry, DirectoryCompareProgress, DirectoryCompareStatus } from "@/bindings"; +import { call, commands, emitLocalIdeEvent, IpcError, isCancelled, nextRequestId } from "@/platform/ipc"; +import { notifyFileOperation } from "@/platform/fileOps"; +import { tx } from "@/platform/i18n"; +import { Button, Checkbox, Dialog, Input, Spinner, T } from "@/ui"; +import { upsertTask } from "@/workbench/tasks"; +import { ComparisonTree } from "./ComparisonTree"; +import { buildFileTree } from "./fileTree"; +import { confirmDialog, openDialog } from "./dialogs"; +import { openDiff, type DiffData, type DiffSide } from "./diffInput"; +import { compareFilesData } from "./compareData"; +import styles from "./common.module.css"; + +const STATUS: Record = { + leftOnly: "Only on left", + rightOnly: "Only on right", + different: "Contents differ", + equal: "Identical", +}; +const join = (root: string, path: string) => `${root.replace(/\/$/, "")}/${path}`; +const directory = (e: DirectoryCompareEntry) => e.leftKind === "dir" || e.rightKind === "dir"; + +export function directoryDiff(left: string, right: string, entry: DirectoryCompareEntry): DiffData | null { + if (directory(entry)) return null; + const data = compareFilesData(join(left, entry.path), join(right, entry.path)); + const missing = (): DiffSide => ({ type: "text", text: "", label: tx("(does not exist)") }); + return { + ...data, + title: entry.path, + left: + entry.leftKind === "symlink" + ? { type: "text", text: entry.leftLink ?? "", label: data.left.label ?? join(left, entry.path) } + : entry.leftKind + ? data.left + : missing(), + right: + entry.rightKind === "symlink" + ? { type: "text", text: entry.rightLink ?? "", label: data.right.label ?? join(right, entry.path) } + : entry.rightKind + ? data.right + : missing(), + }; +} + +export function showDirectoryComparison(left: string, right: string) { + if (left === right) return; + openDialog((close) => ); +} + +export function DirectoryCompareDialog({ left, right, onClose }: { left: string; right: string; onClose: () => void }) { + const [entries, setEntries] = useState([]); + const [current, setCurrent] = useState(null); + const [showEqual, setShowEqual] = useState(false); + const [filter, setFilter] = useState(""); + const [generation, setGeneration] = useState(0); + const [busy, setBusy] = useState(true); + const [operating, setOperating] = useState(false); + const [error, setError] = useState(null); + const [operationError, setOperationError] = useState(null); + const [progress, setProgress] = useState(null); + const [cancelled, setCancelled] = useState(false); + const cancel = useRef<() => void>(() => {}); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + useEffect(() => { + let alive = true; + let pending = true; + const request = nextRequestId(); + const task = { + id: `directory-compare-${request}`, + title: tx("Comparing directories"), + message: null as string | null, + fraction: null as number | null, + cancel: () => { + void commands.cancelRequest(request); + }, + }; + cancel.current = task.cancel; + upsertTask({ ...task, done: false }); + const channel = new Channel(); + channel.onmessage = (p) => { + if (!alive) return; + setProgress(p); + task.message = p.path; + task.fraction = p.total ? p.compared / p.total : null; + upsertTask({ ...task, done: false }); + }; + void call(commands.fsCompareDirectories(left, right, request, channel)) + .then((rows) => { + if (!alive) return; + setEntries(rows); + setCurrent((prev) => rows.find((e) => e.path === prev?.path) ?? null); + }) + .catch((e) => { + if (!alive) return; + if (isCancelled(e)) setCancelled(true); + else setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + pending = false; + upsertTask({ ...task, done: true }); + if (alive) setBusy(false); + }); + return () => { + alive = false; + if (pending) task.cancel(); + upsertTask({ ...task, done: true }); + }; + }, [left, right, generation]); + + const visible = useMemo(() => { + const paths = new Set(); + const query = filter.trim().toLocaleLowerCase(); + for (const e of entries) { + if ((!showEqual && e.status === "equal") || !e.path.toLocaleLowerCase().includes(query)) continue; + let path = e.path; + while (path) { + paths.add(path); + const slash = path.lastIndexOf("/"); + path = slash < 0 ? "" : path.slice(0, slash); + } + } + return entries.filter((e) => paths.has(e.path)); + }, [entries, showEqual, filter]); + const tree = useMemo(() => buildFileTree(visible, (e) => e.path, directory), [visible]); + const selected = cancelled || error ? null : (visible.find((e) => e.path === current?.path) ?? null); + const diff = selected && directoryDiff(left, right, selected); + const refresh = () => { + setBusy(true); + setCancelled(false); + setError(null); + setProgress(null); + setGeneration((g) => g + 1); + }; + + const operate = async (side: "left" | "right" | "delete") => { + if (!selected || busy || operating) return; + setOperating(true); + setOperationError(null); + let attempted = false; + const targetRoot = side === "left" ? left : right; + try { + if (side === "delete") { + if (!!selected.leftKind === !!selected.rightKind) return; + const path = join(selected.leftKind ? left : right, selected.path); + if (!(await confirmDialog({ title: "Delete", message: tx`Delete ${path}?`, okLabel: "Delete", danger: true }))) + return; + attempted = true; + await call(commands.fsDelete(path)); + notifyFileOperation({ kind: "deleted", paths: [path] }); + emitLocalIdeEvent({ type: "fsChanged", created: [], modified: [], removed: [path] }); + } else { + const sourceRoot = side === "left" ? right : left; + const to = join(targetRoot, selected.path); + try { + attempted = true; + await call(commands.fsCopyMerge(sourceRoot, targetRoot, selected.path, false)); + } catch (e) { + if (!(e instanceof IpcError) || e.kind !== "alreadyExists") throw e; + if ( + !(await confirmDialog({ + title: "Overwrite files?", + message: tx`Copy to ${to} and overwrite existing files?`, + okLabel: "Overwrite", + danger: true, + })) + ) + return; + await call(commands.fsCopyMerge(sourceRoot, targetRoot, selected.path, true)); + } + notifyFileOperation({ kind: "created", paths: [to] }); + emitLocalIdeEvent({ type: "fsChanged", created: [to], modified: [to], removed: [] }); + } + } catch (e) { + if (mounted.current) setOperationError(e instanceof Error ? e.message : String(e)); + } finally { + if (mounted.current) { + setOperating(false); + if (attempted) refresh(); + } + } + }; + + return ( + { + if (diff) { + void openDiff(diff); + onClose(); + } + }, + }, + { label: "Close", role: "cancel" }, + ]} + > +
{tx`Left: ${left}`}
+
{tx`Right: ${right}`}
+
+ setFilter(e.target.value)} + /> + + +
+
+ + + + {operating && } +
+ {error && ( +
+ {tx(error)} +
+ )} + {operationError && ( +
+ {tx(operationError)} +
+ )} + {busy && ( +
+ + + {progress?.total != null + ? tx`Compared ${progress.compared} of ${progress.total} entries` + : tx`Scanned ${progress?.scanned ?? 0} entries`} + + +
+ )} + {cancelled && ( +
+ Comparison cancelled +
+ )} + {!busy && !cancelled && !error && !visible.length && ( +
+ No matching differences +
+ )} + {!!tree.length && ( + directoryDiff(left, right, e)} + openOnActivate={!busy && !operating} + onOpen={onClose} + ariaLabel="Compared files" + renderDetails={(n) => { + const entry = n.file ?? n.directory; + return {entry ? tx(STATUS[entry.status]) : n.count}; + }} + /> + )} +
+ ); +} diff --git a/src/features/vcs/__tests__/diffToolbar.test.tsx b/src/features/vcs/__tests__/diffToolbar.test.tsx index 9bd19206f..dbf74852f 100644 --- a/src/features/vcs/__tests__/diffToolbar.test.tsx +++ b/src/features/vcs/__tests__/diffToolbar.test.tsx @@ -66,6 +66,21 @@ beforeEach(() => { }); describe("Diff toolbar", () => { + it("shows a binary notice for disk comparisons instead of identical empty text", async () => { + probe.mockResolvedValue({ status: "ok", data: { size: 3, binary: true, tooLarge: false } }); + const binaryInput = { + ...input, + data: { path: "/p/right.bin", left: { type: "file", path: "/p/left.bin" }, right: { type: "worktree" } }, + } as EditorInput; + render(); + expect(await screen.findByText("Binary files cannot be shown as text")).toBeInTheDocument(); + expect(screen.queryByText("Contents are identical")).toBeNull(); + expect(screen.queryByRole("region", { name: /left.bin/ })).toBeNull(); + probe.mockResolvedValue({ status: "ok", data: { size: 3, binary: false, tooLarge: false } }); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(screen.queryByText("Binary files cannot be shown as text")).toBeNull()); + }); + it("puts Jump to Source right after Next Difference, in the navigation group", async () => { render(); await screen.findByRole("button", { name: "Jump to Source" }); diff --git a/src/features/vcs/__tests__/directoryCompare.test.tsx b/src/features/vcs/__tests__/directoryCompare.test.tsx new file mode 100644 index 000000000..896fc57e1 --- /dev/null +++ b/src/features/vcs/__tests__/directoryCompare.test.tsx @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { DirectoryCompareEntry } from "@/bindings"; + +const mocks = vi.hoisted(() => ({ + compare: vi.fn(), + cancel: vi.fn(), + copy: vi.fn(), + remove: vi.fn(), + confirm: vi.fn(), + open: vi.fn(), +})); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { + ...real, + commands: { + ...real.commands, + fsCompareDirectories: mocks.compare, + fsCopyMerge: mocks.copy, + fsDelete: mocks.remove, + cancelRequest: mocks.cancel, + }, + }; +}); +vi.mock("@tauri-apps/api/core", async (original) => ({ + ...(await original()), + Channel: class { + onmessage = () => {}; + }, +})); +vi.mock("../DiffEditor", () => ({ + DiffEditor: ({ input }: { input: unknown }) =>
{JSON.stringify(input)}
, +})); +vi.mock("../dialogs", () => ({ confirmDialog: mocks.confirm, openDialog: vi.fn(), promptText: vi.fn() })); +vi.mock("../diffInput", async (original) => ({ + ...(await original()), + openDiff: mocks.open, +})); +import { DirectoryCompareDialog, directoryDiff } from "../DirectoryCompare"; + +const row = ( + path: string, + status: DirectoryCompareEntry["status"], + leftKind: DirectoryCompareEntry["leftKind"] = "file", + rightKind: DirectoryCompareEntry["rightKind"] = "file", +): DirectoryCompareEntry => ({ path, status, leftKind, rightKind }); +const rows = [ + row("nested", "different", "dir", "dir"), + row("nested/中文.txt", "different"), + row("only-left.txt", "leftOnly", "file", null), + row("only-right.txt", "rightOnly", null, "file"), + row("same.txt", "equal"), + row("empty", "leftOnly", "dir", null), +]; +const ok = (data: unknown = null) => ({ status: "ok", data }); +const setup = () => render( {}} />); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.compare.mockResolvedValue(ok(rows)); + mocks.copy.mockResolvedValue(ok()); + mocks.remove.mockResolvedValue(ok()); + mocks.confirm.mockResolvedValue(true); +}); +afterEach(cleanup); + +describe("disk directory comparison", () => { + it("reuses the changes tree, filters names and toggles identical files", async () => { + setup(); + expect(await screen.findByText("中文.txt")).toBeInTheDocument(); + expect(screen.getAllByText("Only on left")).toHaveLength(2); + expect(screen.getByText("Only on right")).toBeInTheDocument(); + expect(screen.queryByText("same.txt")).toBeNull(); + fireEvent.click(screen.getByRole("checkbox", { name: "Show identical files" })); + expect(screen.getByText("same.txt")).toBeInTheDocument(); + fireEvent.change(screen.getByRole("textbox", { name: "Filter by name" }), { target: { value: "中文" } }); + expect(screen.queryByText("same.txt")).toBeNull(); + expect(screen.getByText("nested")).toBeInTheDocument(); + fireEvent.mouseDown(screen.getByText("nested"), { button: 0 }); + expect(screen.getByRole("button", { name: "Copy to Right" })).toBeEnabled(); + }); + + it("previews on selection and opens the existing diff on double click, including missing sides", async () => { + setup(); + fireEvent.mouseDown(await screen.findByText("中文.txt"), { button: 0 }); + expect(screen.getByTestId("diff")).toHaveTextContent("/p/left/nested/中文.txt"); + fireEvent.doubleClick(screen.getByText("中文.txt")); + expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ path: "/p/right/nested/中文.txt" })); + expect(directoryDiff("/l", "/r", rows[2]!)?.right.type).toBe("text"); + expect(directoryDiff("/l", "/r", rows[3]!)?.left.type).toBe("text"); + expect(directoryDiff("/l", "/r", row("type", "different", "file", "dir"))).toBeNull(); + expect( + directoryDiff("/l", "/r", { + ...row("link", "different", "symlink", "symlink"), + leftLink: "one", + rightLink: "two", + }), + ).toMatchObject({ + left: { type: "text", text: "one" }, + right: { type: "text", text: "two" }, + }); + }); + + it.each(["Copy to Left", "Copy to Right"])( + "confirms before overwriting, refreshes and restores selection after %s", + async (label) => { + mocks.copy + .mockResolvedValueOnce({ status: "error", error: { kind: "alreadyExists", message: "exists" } }) + .mockResolvedValueOnce(ok()); + setup(); + fireEvent.mouseDown(await screen.findByText("中文.txt"), { button: 0 }); + fireEvent.click(screen.getByRole("button", { name: label })); + await waitFor(() => + expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Overwrite files?" })), + ); + await waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(2)); + expect(mocks.copy.mock.calls[0]![3]).toBe(false); + expect(mocks.copy.mock.calls[1]![3]).toBe(true); + expect(mocks.copy.mock.calls[0]!.slice(0, 3)).toEqual( + label === "Copy to Left" + ? ["/p/right", "/p/left", "nested/中文.txt"] + : ["/p/left", "/p/right", "nested/中文.txt"], + ); + expect(screen.getByTestId("diff")).toBeInTheDocument(); + }, + ); + + it("does not overwrite when confirmation is declined and reports operation errors", async () => { + mocks.copy.mockResolvedValue({ status: "error", error: { kind: "alreadyExists", message: "exists" } }); + mocks.confirm.mockResolvedValue(false); + setup(); + fireEvent.mouseDown(await screen.findByText("中文.txt"), { button: 0 }); + fireEvent.click(screen.getByRole("button", { name: "Copy to Right" })); + await waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(2)); + expect(mocks.copy).toHaveBeenCalledTimes(1); + mocks.copy.mockResolvedValue({ status: "error", error: { kind: "permissionDenied", message: "Cannot write" } }); + fireEvent.click(screen.getByRole("button", { name: "Copy to Right" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Cannot write"); + }); + + it("copies empty and nonempty subdirectories and confirms deletion only for one-sided entries", async () => { + setup(); + fireEvent.mouseDown(await screen.findByText("nested"), { button: 0 }); + expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Copy to Right" })); + await waitFor(() => expect(mocks.copy).toHaveBeenCalledWith("/p/left", "/p/right", "nested", false)); + await waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(2)); + fireEvent.mouseDown(screen.getByText("empty"), { button: 0 }); + fireEvent.click(screen.getByRole("button", { name: "Copy to Right" })); + await waitFor(() => expect(mocks.copy).toHaveBeenCalledWith("/p/left", "/p/right", "empty", false)); + await waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(3)); + fireEvent.mouseDown(screen.getByText("only-left.txt"), { button: 0 }); + mocks.confirm.mockResolvedValueOnce(false); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + await waitFor(() => expect(mocks.confirm).toHaveBeenCalled()); + expect(mocks.remove).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + await waitFor(() => expect(mocks.remove).toHaveBeenCalledWith("/p/left/only-left.txt")); + }); + + it("streams progress, cancels on demand and on close, and ignores stale results after refresh", async () => { + let resolve!: (value: unknown) => void; + mocks.compare.mockImplementationOnce((_l, _r, _request, channel) => { + channel.onmessage({ scanned: 20, compared: 5, total: 10, bytesRead: 100, path: "big" }); + return new Promise((r) => { + resolve = r; + }); + }); + const view = setup(); + expect(await screen.findByText("Compared 5 of 10 entries")).toBeInTheDocument(); + const request = mocks.compare.mock.calls[0]![2]; + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(mocks.cancel).toHaveBeenCalledWith(request); + view.unmount(); + expect(mocks.cancel).toHaveBeenCalledTimes(2); + resolve(ok(rows)); + }); +}); diff --git a/src/features/vcs/__tests__/directoryCompareActions.test.ts b/src/features/vcs/__tests__/directoryCompareActions.test.ts new file mode 100644 index 000000000..4a0d5cbae --- /dev/null +++ b/src/features/vcs/__tests__/directoryCompareActions.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const mocks = vi.hoisted(() => ({ + show: vi.fn(), + read: vi.fn(), + notify: vi.fn(), + pick: vi.fn(), + remote: vi.fn(), + prompt: vi.fn(), + openDiff: vi.fn(), +})); +vi.mock("../DirectoryCompare", () => ({ showDirectoryComparison: mocks.show })); +vi.mock("../dialogs", () => ({ promptText: mocks.prompt })); +vi.mock("../diffInput", () => ({ openDiff: mocks.openDiff })); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.pick })); +vi.mock("@/platform/remote", () => ({ isRemoteWindow: mocks.remote })); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original()), + isTauri: () => true, + commands: { fsEntryKind: mocks.read }, +})); +vi.mock("@/workbench/contributions", async (original) => ({ + ...(await original()), + notifications: () => ({ notify: mocks.notify }), +})); +import { getAction, isActionEnabled } from "@/platform/actions"; +import { provideProjectViewSelection } from "@/workbench/contributions"; +import { registerCompareActions } from "../compare"; + +let dispose: () => void; +beforeEach(() => { + vi.clearAllMocks(); + dispose = registerCompareActions(); + mocks.remote.mockReturnValue(false); + mocks.read.mockImplementation((path: string) => + Promise.resolve({ status: "ok", data: path.endsWith(".txt") ? "file" : "dir" }), + ); +}); +afterEach(() => dispose()); + +describe("directory compare actions", () => { + it("has a stable searchable action and resolves the selection for Find Action", async () => { + const unselect = provideProjectViewSelection(() => ["/p/left", "/p/right"]); + try { + const action = getAction("diff.compareDirectories")!; + expect(action.title).toBe("Compare Directories"); + expect(action.hidden).not.toBe(true); + expect(isActionEnabled(action, { projectOpen: true })).toBe(true); + await action.run({ ctx: { projectOpen: true } }); + expect(mocks.show).toHaveBeenCalledWith("/p/left", "/p/right"); + await action.run({ ctx: { projectOpen: true }, arg: { paths: ["/x/left", "/x/right"] } }); + expect(mocks.show).toHaveBeenLastCalledWith("/x/left", "/x/right"); + } finally { + unselect(); + } + }); + + it("rejects mixed selections and does not start a directory comparison", async () => { + await getAction("diff.compareDirectories")!.run({ ctx: {}, arg: ["/p/left", "/p/file.txt"] }); + expect(mocks.show).not.toHaveBeenCalled(); + expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Select two directories to compare" })); + }); + + it("uses the native folder picker for Compare With on a local directory", async () => { + mocks.pick.mockResolvedValue("/p/right"); + await getAction("diff.compareWith")!.run({ ctx: {}, arg: { path: "/p/left", directory: true } }); + expect(mocks.pick).toHaveBeenCalledWith(expect.objectContaining({ directory: true })); + expect(mocks.show).toHaveBeenCalledWith("/p/left", "/p/right"); + expect(mocks.openDiff).not.toHaveBeenCalled(); + }); + + it("uses remote paths and validates the selected directory via the routed filesystem", async () => { + mocks.remote.mockReturnValue(true); + mocks.prompt.mockResolvedValue({ value: "/remote/right" }); + const unselect = provideProjectViewSelection(() => ["/remote/left"]); + try { + await getAction("diff.compareWith")!.run({ ctx: {} }); + expect(mocks.prompt).toHaveBeenCalledWith( + expect.objectContaining({ label: "Directory path", initial: "/remote/left" }), + ); + expect(mocks.pick).not.toHaveBeenCalled(); + expect(mocks.read).toHaveBeenCalledWith("/remote/right"); + expect(mocks.show).toHaveBeenCalledWith("/remote/left", "/remote/right"); + } finally { + unselect(); + } + }); + + it("leaves file Compare With on the existing diff flow", async () => { + mocks.pick.mockResolvedValue("/p/b.txt"); + await getAction("diff.compareWith")!.run({ ctx: {}, arg: "/p/a.txt" }); + expect(mocks.pick).toHaveBeenCalledWith(expect.objectContaining({ directory: false })); + expect(mocks.openDiff).toHaveBeenCalledWith(expect.objectContaining({ path: "/p/b.txt" })); + expect(mocks.show).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/vcs/__tests__/fileTree.test.ts b/src/features/vcs/__tests__/fileTree.test.ts index c0327edca..158f32829 100644 --- a/src/features/vcs/__tests__/fileTree.test.ts +++ b/src/features/vcs/__tests__/fileTree.test.ts @@ -9,4 +9,21 @@ describe("buildFileTree", () => { expect(tree[1]!.children.map((c) => c.name)).toEqual(["a.ts", "b.ts"]); expect(dirKeys(tree)).toEqual(["d:docs/x/y", "d:src/app"]); }); + + it("retains explicit empty directories and their entries for subtree operations", () => { + const entries = [ + { path: "empty", dir: true }, + { path: "src", dir: true }, + { path: "src/nested", dir: true }, + { path: "src/nested/a", dir: false }, + ]; + const tree = buildFileTree( + entries, + (e) => e.path, + (e) => e.dir, + ); + expect(tree[0]).toMatchObject({ name: "empty", directory: entries[0], children: [], count: 0 }); + expect(tree[1]).toMatchObject({ name: "src", directory: entries[1] }); + expect(tree[1]!.children[0]).toMatchObject({ name: "nested", directory: entries[2] }); + }); }); diff --git a/src/features/vcs/common.module.css b/src/features/vcs/common.module.css index c18a071e9..9e9acc8df 100644 --- a/src/features/vcs/common.module.css +++ b/src/features/vcs/common.module.css @@ -310,6 +310,17 @@ color: var(--fg-subtle); overflow-wrap: anywhere; } +.compareToolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 6px 14px; +} +.compareToolbar > input { + flex: 1; + min-width: 140px; +} .compareAdded { color: var(--vcs-added); font-variant-numeric: tabular-nums; diff --git a/src/features/vcs/compare.tsx b/src/features/vcs/compare.tsx index a2799e617..1437abdbe 100644 --- a/src/features/vcs/compare.tsx +++ b/src/features/vcs/compare.tsx @@ -4,10 +4,13 @@ */ import { activeEditor } from "@/editor/api"; import { registerActions } from "@/platform/actions"; -import { isTauri } from "@/platform/ipc"; -import { useProject } from "@/platform/project"; -import { maybeEditorService, notifications } from "@/workbench/contributions"; +import { call, commands, isTauri } from "@/platform/ipc"; +import { isRemoteWindow } from "@/platform/remote"; +import { maybeEditorService, notifications, projectViewSelection } from "@/workbench/contributions"; +import { promptText } from "./dialogs"; import { focusedDiffNavigator } from "./diffNavigator"; +import { compareFilesData, displayPath } from "./compareData"; +export { compareFilesData, displayPath } from "./compareData"; import { openDiff, type DiffData } from "./diffInput"; import { baseName } from "./model"; import { tx } from "@/platform/i18n"; @@ -27,20 +30,6 @@ function argPaths(arg: unknown): string[] { return p ? [p] : []; } -/** Project-relative path for labels. */ -export function displayPath(path: string, root = useProject.getState().project?.root ?? null): string { - return root && path.startsWith(root + "/") ? path.slice(root.length + 1) : path; -} - -/** Diff of two files on disk; both sides are live, editable documents. */ -export function compareFilesData(leftPath: string, rightPath: string): DiffData { - return { - path: rightPath, - left: { type: "file", path: leftPath, label: displayPath(leftPath) }, - right: { type: "worktree", label: displayPath(rightPath) }, - }; -} - /** Clipboard ↔ the file (editable) or ↔ the editor selection. */ export function clipboardDiffData(path: string, clipboard: string, selection: string | null): DiffData { return { @@ -122,6 +111,29 @@ async function pickComparison( const contextPath = (arg: unknown) => argPath(arg) ?? maybeEditorService()?.activePath() ?? undefined; +async function showDirectories(left: string, right: string) { + const { showDirectoryComparison } = await import("./DirectoryCompare"); + showDirectoryComparison(left, right); +} + +async function isDirectory(path: string): Promise { + try { + return (await call(commands.fsEntryKind(path))) === "dir"; + } catch { + return false; + } +} + +async function pickDirectory(near: string): Promise { + if (isRemoteWindow() || !isTauri()) { + const picked = await promptText({ title: "Compare With", label: "Directory path", initial: near, mono: true }); + return picked?.value ?? null; + } + const { open } = await import("@tauri-apps/plugin-dialog"); + const picked = await open({ multiple: false, directory: true, title: tx("Compare With"), defaultPath: near }); + return (Array.isArray(picked) ? picked[0] : picked) ?? null; +} + export function registerCompareActions(): () => void { return registerActions([ { @@ -210,10 +222,18 @@ export function registerCompareActions(): () => void { category: "Diff", icon: "diff", when: "projectOpen", - keywords: ["diff", "compare files"], + keywords: ["diff", "compare files", "compare folders"], run: async ({ arg }) => { - const path = contextPath(arg); + const path = argPath(arg) ?? projectViewSelection()[0] ?? contextPath(arg); if (!path) return info("Open or select a file to compare"); + const directoryArg = !!arg && typeof arg === "object" && "directory" in arg && arg.directory === true; + if (directoryArg || (isTauri() && (await isDirectory(path)))) { + const other = await pickDirectory(path); + if (!other || other === path) return; + if (!(await isDirectory(other))) return info("Select a directory to compare"); + await showDirectories(path, other); + return; + } const other = await pickComparison(path); if (!other) return; if (other.type === "file") { @@ -228,6 +248,20 @@ export function registerCompareActions(): () => void { } }, }, + { + id: "diff.compareDirectories", + title: "Compare Directories", + category: "Diff", + icon: "diff", + when: "projectOpen", + keywords: ["diff", "folders", "compare folders"], + run: async ({ arg }) => { + const paths = arg === undefined ? projectViewSelection() : argPaths(arg); + if (paths.length !== 2 || !(await isDirectory(paths[0]!)) || !(await isDirectory(paths[1]!))) + return info("Select two directories to compare"); + await showDirectories(paths[0]!, paths[1]!); + }, + }, { id: "diff.compareFiles", title: "Compare Files", diff --git a/src/features/vcs/compareData.ts b/src/features/vcs/compareData.ts new file mode 100644 index 000000000..fc693fce1 --- /dev/null +++ b/src/features/vcs/compareData.ts @@ -0,0 +1,16 @@ +import { useProject } from "@/platform/project"; +import type { DiffData } from "./diffInput"; + +/** Project-relative path for labels. */ +export function displayPath(path: string, root = useProject.getState().project?.root ?? null): string { + return root && path.startsWith(root + "/") ? path.slice(root.length + 1) : path; +} + +/** Diff of two files on disk; both sides are live, editable documents. */ +export function compareFilesData(leftPath: string, rightPath: string): DiffData { + return { + path: rightPath, + left: { type: "file", path: leftPath, label: displayPath(leftPath) }, + right: { type: "worktree", label: displayPath(rightPath) }, + }; +} diff --git a/src/features/vcs/fileTree.ts b/src/features/vcs/fileTree.ts index a31265634..72bd576fb 100644 --- a/src/features/vcs/fileTree.ts +++ b/src/features/vcs/fileTree.ts @@ -5,15 +5,22 @@ export interface FileTreeNode { /** Directory label (`src/app`) or file name. */ name: string; file: F | null; + /** An explicit directory entry, including empty directories. */ + directory?: F; children: FileTreeNode[]; /** Number of files below (1 for a file). */ count: number; } -export function buildFileTree(files: readonly F[], relPath: (f: F) => string): FileTreeNode[] { +export function buildFileTree( + files: readonly F[], + relPath: (f: F) => string, + isDirectory?: (f: F) => boolean, +): FileTreeNode[] { interface Dir { dirs: Map; files: { name: string; file: F; rel: string }[]; + entry?: F; } const root: Dir = { dirs: new Map(), files: [] }; for (const f of files) { @@ -26,7 +33,11 @@ export function buildFileTree(files: readonly F[], relPath: (f: F) => string) if (!next) d.dirs.set(p, (next = { dirs: new Map(), files: [] })); d = next; } - d.files.push({ name, file: f, rel }); + if (isDirectory?.(f)) { + let sub = d.dirs.get(name); + if (!sub) d.dirs.set(name, (sub = { dirs: new Map(), files: [] })); + sub.entry = f; + } else d.files.push({ name, file: f, rel }); } const convert = (d: Dir, prefix: string): FileTreeNode[] => { @@ -35,7 +46,7 @@ export function buildFileTree(files: readonly F[], relPath: (f: F) => string) let label = name; let path = prefix ? `${prefix}/${name}` : name; let cur = sub; - while (cur.files.length === 0 && cur.dirs.size === 1) { + while (!cur.entry && cur.files.length === 0 && cur.dirs.size === 1) { const [n, only] = [...cur.dirs][0]!; label += `/${n}`; path += `/${n}`; @@ -46,6 +57,7 @@ export function buildFileTree(files: readonly F[], relPath: (f: F) => string) key: `d:${path}`, name: label, file: null, + directory: cur.entry, children, count: children.reduce((n, c) => n + c.count, 0), }); diff --git a/src/features/vcs/operations.tsx b/src/features/vcs/operations.tsx index e0099acd0..b18a7ae1d 100644 --- a/src/features/vcs/operations.tsx +++ b/src/features/vcs/operations.tsx @@ -8,7 +8,7 @@ import { useEffect, useMemo, useState } from "react"; import type { ChangedFile, Commit, CompareResult, ResetMode, WorkingTreeChange } from "@/bindings"; import { executeAction } from "@/platform/actions"; import { call, commands, nextRequestId } from "@/platform/ipc"; -import { Checkbox, Dialog, Icon, Input, Tree, Spinner, fileIconName, T } from "@/ui"; +import { Checkbox, Dialog, Icon, Input, Spinner, T } from "@/ui"; import { notifications } from "@/workbench/contributions"; import { diffInput, openDiff, type DiffData, type DiffSide } from "./diffInput"; import { openDialog, promptText } from "./dialogs"; @@ -17,10 +17,10 @@ import { runGitOperation, runOp } from "./ops"; import { reloadLog, useGitLog } from "./logStore"; import { conflictedFiles, currentRepo, currentRepoRoot, refreshNow, repoName, repoOf, useVcs } from "./store"; import styles from "./common.module.css"; -import { DiffEditor } from "./DiffEditor"; -import { buildFileTree, dirKeys, type FileTreeNode } from "./fileTree"; +import { ComparisonTree } from "./ComparisonTree"; +import { buildFileTree, type FileTreeNode } from "./fileTree"; import { tx } from "@/platform/i18n"; -import { displayPath } from "./compare"; +import { displayPath } from "./compareData"; // --------------------------------------------------------------------------- // Operation in progress @@ -528,8 +528,6 @@ export function ChangesBetweenDialog({ const [files, setFiles] = useState(null); const [result, setResult] = useState(null); const [error, setError] = useState(null); - const [selected, setSelected] = useState>(new Set()); - const [expanded, setExpanded] = useState | null>(null); const [current, setCurrent] = useState(null); const fromRev = from.rev; const toRev = to?.rev ?? null; @@ -665,52 +663,25 @@ export function ChangesBetweenDialog({
) : null} {files && files.length > 0 ? ( -
- > - roots={tree} - getKey={(n) => n.key} - getLabel={(n) => n.name} - getChildren={(n) => (n.file ? undefined : n.children)} - isExpandable={(n) => !n.file} - expanded={expanded ?? new Set(dirKeys(tree))} - onExpandedChange={setExpanded} - selected={selected} - onSelectionChange={(sel, lead) => { - setSelected(sel); - if (lead?.file) setCurrent(lead.file); - }} - onActivate={(n) => n.file && setCurrent(n.file)} - aria-label="Changed files" - className={styles.comparisonTree} - renderNode={(n) => ( - - - - {n.name} - - {n.file ? ( - <> - {tx(n.file.kind)} - {n.file.added !== undefined && +{n.file.added}} - {n.file.removed !== undefined && −{n.file.removed}} - - ) : ( - {n.count} - )} - - )} - /> - {input && ( -
- -
- )} -
+ KIND_COLOR[f.kind === "untracked" ? "added" : f.kind]} + ariaLabel="Changed files" + renderDetails={(n) => + n.file ? ( + <> + {tx(n.file.kind)} + {n.file.added !== undefined && +{n.file.added}} + {n.file.removed !== undefined && −{n.file.removed}} + + ) : ( + {n.count} + ) + } + /> ) : null} ); diff --git a/src/features/viewers/markdown/mermaid.ts b/src/features/viewers/markdown/mermaid.ts index 903d190ca..cce3cca23 100644 --- a/src/features/viewers/markdown/mermaid.ts +++ b/src/features/viewers/markdown/mermaid.ts @@ -3,6 +3,7 @@ * `[data-mermaid]` placeholder holding the source in `pre > code`; this module * turns placeholders into SVG. The library is imported on first use only. */ +import { fontsLoaded } from "@/platform/fonts"; import type { Mermaid } from "mermaid"; import { resolveTokenColor, toRgb } from "@/platform/cssColor"; import { attachPanZoom } from "../panZoom"; @@ -32,7 +33,9 @@ function loadMermaid(): Promise { export function themeKey(): string { const root = document.documentElement; const { theme = "", contrast = "", colorTheme = "", pluginTheme = "" } = root.dataset; - return `${theme}|${contrast}|${colorTheme}|${pluginTheme}|${root.style.getPropertyValue("--accent")}`; + const accent = root.style.getPropertyValue("--accent"); + const font = getComputedStyle(root).getPropertyValue("--font-ui"); + return `${theme}|${contrast}|${colorTheme}|${pluginTheme}|${accent}|${font}`; } /** Calls `cb` when the theme attributes (a plugin theme's included) or the accent on the root change. */ @@ -102,6 +105,7 @@ function removeTemp(id: string) { async function renderOne(source: string, key: string): Promise { const mermaid = await loadMermaid(); + await fontsLoaded(getComputedStyle(document.documentElement).getPropertyValue("--font-ui").trim(), 16); configure(mermaid, key); const id = `openidea-mermaid-${++seq}`; try { diff --git a/src/platform/candidateFonts.test.ts b/src/platform/candidateFonts.test.ts index e9ea10c02..a83cb22d5 100644 --- a/src/platform/candidateFonts.test.ts +++ b/src/platform/candidateFonts.test.ts @@ -18,8 +18,26 @@ it("probes the legacy list and classifies measured mono and proportional familie ]); }); it("returns no inferred families when Canvas is absent", () => { + const remove = vi.spyOn(HTMLCanvasElement.prototype, "remove"); vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); expect(detectCandidateFonts()).toEqual([]); + expect(remove).toHaveBeenCalledOnce(); +}); +it("detects user-installed candidates with a connected probe and removes it afterwards", () => { + const probes: HTMLCanvasElement[] = []; + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (this: HTMLCanvasElement) { + probes.push(this); + const isConnected = () => this.isConnected; + return { + font: "", + measureText(this: { font: string }) { + return { width: isConnected() && this.font.includes('"Fira Code"') ? 120 : 100 }; + }, + } as unknown as CanvasRenderingContext2D; + }); + expect(detectCandidateFonts()).toEqual([{ name: "Fira Code", monospaced: true }]); + expect(probes[0]?.hidden).toBe(true); + expect(probes[0]?.isConnected).toBe(false); }); it("contains failures from Canvas and never claims candidates are installed", () => { vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => { diff --git a/src/platform/candidateFonts.ts b/src/platform/candidateFonts.ts index e566fc36c..72d9d8970 100644 --- a/src/platform/candidateFonts.ts +++ b/src/platform/candidateFonts.ts @@ -3,7 +3,7 @@ * can miss installed families and are only hints; they say nothing about coverage. */ import type { FontFamily } from "@/bindings"; -import { monoFontFamily } from "./fonts"; +import { createFontProbeCanvas, quoteFontFamily } from "./fonts"; // Keep the former editor's 31 candidates for recovery, not as an installed-font whitelist. const CANDIDATES = [ @@ -41,9 +41,11 @@ const CANDIDATES = [ ]; export function detectCandidateFonts(): FontFamily[] { + let canvas: HTMLCanvasElement | undefined; try { if (typeof document === "undefined") return []; - const ctx = document.createElement("canvas").getContext("2d"); + canvas = createFontProbeCanvas(); + const ctx = canvas.getContext("2d"); if (!ctx) return []; const width = (font: string, text: string) => { ctx.font = `32px ${font}`; @@ -53,13 +55,13 @@ export function detectCandidateFonts(): FontFamily[] { const out: FontFamily[] = []; for (const name of CANDIDATES) { const present = ["monospace", "serif", "sans-serif"].some((base) => { - const measured = width(monoFontFamily(name, "", base), sample); + const measured = width(`${quoteFontFamily(name)}, ${base}`, sample); const baseline = width(base, sample); return Number.isFinite(measured) && Number.isFinite(baseline) && measured !== baseline; }); if (!present) continue; - const narrow = width(monoFontFamily(name, "", "monospace"), "iiiiiiiiii"); - const wide = width(monoFontFamily(name, "", "monospace"), "WWWWWWWWWW"); + const narrow = width(`${quoteFontFamily(name)}, monospace`, "iiiiiiiiii"); + const wide = width(`${quoteFontFamily(name)}, monospace`, "WWWWWWWWWW"); out.push({ name, monospaced: Number.isFinite(narrow) && Number.isFinite(wide) && Math.abs(narrow - wide) < 0.5, @@ -70,5 +72,7 @@ export function detectCandidateFonts(): FontFamily[] { // Canvas can be absent or disabled in browser development / hardened webviews. // Never mark unmeasurable candidates as installed. The service adds bundled faces. return []; + } finally { + canvas?.remove(); } } diff --git a/src/platform/classFiles.test.ts b/src/platform/classFiles.test.ts new file mode 100644 index 000000000..a7c869099 --- /dev/null +++ b/src/platform/classFiles.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { classFileBreakpointReason, isClassFile } from "./classFiles"; +import { isVirtualUri } from "./virtualDocs"; +import { canPlaceBreakpoint } from "@/features/debugger/breakpoints/languages"; +vi.mock("@/platform/workflowSettings", () => ({ + workflowSettings: () => ({ debugger: { breakpointsAnyFile: true } }), +})); + +describe("compiled class routing and breakpoint policy", () => { + it.each([ + "/p/target/classes/p/C.class", + "/p/build/classes/java/main/p/C$1.class", + "C:\\p\\out\\C.class", + "file:///p/out/C.class", + ])("disables output breakpoints even with any-file enabled: %s", (path) => { + expect(isClassFile(path)).toBe(true); + expect(classFileBreakpointReason(path)).toBeTruthy(); + expect(canPlaceBreakpoint("java", path)).toBe(false); + }); + it.each([ + "jdt://contents/a.jar/p/C.class?=p/a.jar%3Cp%28C.class", + "jdt://contents/a.jar/p/C.java?=p/a.jar%3Cp%28C.class", + "/p/src/main/java/p/C.java", + ])("keeps source and JDT breakpoints enabled: %s", (path) => { + expect(isClassFile(path)).toBe(false); + expect(classFileBreakpointReason(path)).toBeNull(); + expect(canPlaceBreakpoint("java", path)).toBe(true); + }); + it("treats disk class URIs as read-only server documents", () => { + expect(isVirtualUri("file:///p/out/C.class")).toBe(true); + expect(isVirtualUri("file:///p/src/C.java")).toBe(false); + expect(isClassFile("/m2/a.jar!/p/C.class")).toBe(true); + }); +}); diff --git a/src/platform/classFiles.ts b/src/platform/classFiles.ts new file mode 100644 index 000000000..8af7e7351 --- /dev/null +++ b/src/platform/classFiles.ts @@ -0,0 +1,8 @@ +/** Compiled Java files are resolved before the binary viewer, at every opening entry. */ +export function isClassFile(path: string): boolean { + return !path.startsWith("jdt:") && /\.class$/i.test(path); +} + +export function classFileBreakpointReason(path: string): string | null { + return isClassFile(path) ? "Breakpoints are not supported in output classes without source code." : null; +} diff --git a/src/platform/fonts.test.ts b/src/platform/fonts.test.ts index 00f23c6de..65cf7520f 100644 --- a/src/platform/fonts.test.ts +++ b/src/platform/fonts.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { monoFontFamily, quoteFontFamily, uiFontFamily } from "./fonts"; const BASE = "var(--font-mono)"; @@ -36,10 +36,150 @@ it("escapes CSS special characters without turning a family into a list", () => describe("uiFontFamily", () => { it.each([ - ["Inter", '"Inter Variable", "Inter", system-ui, sans-serif'], - ["JetBrains Mono", '"JetBrains Mono Variable", "JetBrains Mono", system-ui, sans-serif'], - [' "PingFang SC" ', '"PingFang SC", system-ui, sans-serif'], - ])("%j", (family, expected) => { - expect(uiFontFamily(family)).toBe(expected); + ["Inter", "", '"Inter Variable", "Inter", system-ui, sans-serif'], + ["JetBrains Mono", "", '"JetBrains Mono Variable", "JetBrains Mono", system-ui, sans-serif'], + [' "PingFang SC" ', "", '"PingFang SC", system-ui, sans-serif'], + ["Inter", "思源黑体", '"Inter Variable", "Inter", "思源黑体", system-ui, sans-serif'], + ["Inter", " ", '"Inter Variable", "Inter", system-ui, sans-serif'], + ])("%j + %j", (family, fallback, expected) => { + expect(uiFontFamily(family, fallback)).toBe(expected); + }); +}); + +describe("macOS user font resolver", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.resetModules(); + }); + + async function resolver(available: boolean | "symbols" | "connected", os = "macos") { + vi.resetModules(); + const invoke = vi.fn(); + vi.doMock("@tauri-apps/api/core", () => ({ isTauri: () => true, invoke })); + vi.doMock("./platformInfo", () => ({ guessOs: () => os })); + const canvases: HTMLCanvasElement[] = []; + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (this: HTMLCanvasElement) { + const isConnected = () => this.isConnected; + canvases.push(this); + return { + font: "", + measureText(this: { font: string }, text: string) { + const covered = + available === true || + (available === "symbols" && text.includes("\uF418")) || + (available === "connected" && isConnected()); + return { width: covered && this.font.includes('"') ? 200 : 100 }; + }, + } as unknown as CanvasRenderingContext2D; + }); + const registered: { family: string; descriptors: unknown }[] = []; + vi.stubGlobal( + "FontFace", + class { + constructor( + public family: string, + _bytes: ArrayBuffer, + public descriptors: unknown, + ) { + registered.push(this); + } + load() { + return Promise.resolve(this); + } + }, + ); + const load = vi.fn().mockResolvedValue([]); + Object.defineProperty(document, "fonts", { configurable: true, value: { add: vi.fn(), delete: vi.fn(), load } }); + const header = new TextEncoder().encode( + JSON.stringify([ + { weight: 400, style: "normal", offset: 0, length: 2 }, + { weight: 700, style: "normal", offset: 2, length: 2 }, + { weight: 400, style: "italic", offset: 4, length: 2 }, + ]), + ); + const bundle = new Uint8Array(4 + header.length + 6); + new DataView(bundle.buffer).setUint32(0, header.length, true); + bundle.set(header, 4); + invoke.mockResolvedValue(bundle.buffer); + return { fonts: await import("./fonts"), invoke, registered, load, canvases }; + } + + it("loads an unavailable family once across primary, fallback and UI, including regular/bold/italic", async () => { + const { fonts, invoke, registered, load } = await resolver(false); + const family = fonts.monoFontFamily("JetBrainsMono Nerd Font Mono"); + expect(family).toContain('"OpenIDEA User Font 0", "JetBrainsMono Nerd Font Mono"'); + fonts.uiFontFamily("jetbrainsmono nerd font mono"); + fonts.monoFontFamily("Inter", "JetBrainsMono Nerd Font Mono"); + await fonts.fontsLoaded(family, 14); + expect(invoke).toHaveBeenCalledExactlyOnceWith("user_font_data", { family: "JetBrainsMono Nerd Font Mono" }); + expect(registered.map((f) => f.descriptors)).toEqual([ + { weight: "400", style: "normal" }, + { weight: "700", style: "normal" }, + { weight: "400", style: "italic" }, + ]); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("uses available local/system families without reading font data", async () => { + const { fonts, invoke } = await resolver(true); + expect(fonts.uiFontFamily("Menlo")).toBe('"Menlo", system-ui, sans-serif'); + fonts.monoFontFamily("Maple Mono NF CN"); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("loads a UI fallback through the shared macOS user font resolver", async () => { + const { fonts, invoke, registered } = await resolver(false); + const family = fonts.uiFontFamily("Inter", "Maple Mono NF CN"); + expect(family).toBe('"Inter Variable", "Inter", "OpenIDEA User Font 0", "Maple Mono NF CN", system-ui, sans-serif'); + expect(fonts.monoFontFamily("Inter", "Maple Mono NF CN")).toContain('"OpenIDEA User Font 0"'); + await fonts.fontsLoaded(family, 13); + expect(invoke).toHaveBeenCalledExactlyOnceWith("user_font_data", { family: "Maple Mono NF CN" }); + expect(registered).toHaveLength(3); + }); + + it("detects user fonts that WKWebView exposes only to connected canvases without loading bytes", async () => { + const { fonts, invoke, canvases } = await resolver("connected"); + expect(fonts.fontAvailable("Maple Mono NF CN")).toBe(true); + expect(fonts.familyFaces("Maple Mono NF CN")).toEqual(['"Maple Mono NF CN"']); + expect(fonts.familyFaces("JetBrainsMono Nerd Font Mono")).toEqual(['"JetBrainsMono Nerd Font Mono"']); + expect(invoke).not.toHaveBeenCalled(); + expect(canvases.length).toBeGreaterThan(0); + expect(canvases.every((canvas) => canvas.hidden && !canvas.isConnected)).toBe(true); + }); + + it("removes the probe when a font is unavailable or canvas measurement fails", async () => { + const { fonts, canvases } = await resolver(false); + expect(fonts.fontAvailable("Unavailable")).toBe(false); + expect(canvases.every((canvas) => !canvas.isConnected)).toBe(true); + const remove = vi.spyOn(HTMLCanvasElement.prototype, "remove"); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => { + throw new Error("Canvas unavailable"); + }); + expect(fonts.fontAvailable("Maple Mono NF CN")).toBe(true); + expect(remove).toHaveBeenCalledOnce(); + }); + + it("does not read a locally available symbol-only Nerd fallback", async () => { + const { fonts, invoke } = await resolver("symbols"); + expect(fonts.familyFaces("Symbols Nerd Font Mono")).toEqual(['"Symbols Nerd Font Mono"']); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("never reads bundled webfonts even before their CSS faces load", async () => { + const { fonts, invoke } = await resolver(false); + fonts.monoFontFamily("Inter"); + fonts.monoFontFamily("JetBrains Mono"); + fonts.uiFontFamily("Inter Variable"); + fonts.monoFontFamily("JetBrains Mono Variable"); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("does not read bundled faces or alter other platforms", async () => { + const { fonts, invoke } = await resolver(false, "windows"); + fonts.monoFontFamily("Inter"); + fonts.monoFontFamily("JetBrains Mono"); + expect(fonts.uiFontFamily("User Font")).toBe('"User Font", system-ui, sans-serif'); + expect(invoke).not.toHaveBeenCalled(); }); }); diff --git a/src/platform/fonts.ts b/src/platform/fonts.ts index dc0b7cd74..9acf9eb31 100644 --- a/src/platform/fonts.ts +++ b/src/platform/fonts.ts @@ -1,3 +1,6 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { guessOs } from "./platformInfo"; + /** * Monospace font-family lists shared by every consumer of the editor / terminal * font. "JetBrains Mono" is the bundled default, but the bundle registers it as @@ -10,6 +13,8 @@ export const BUNDLED_MONO_FAMILY = "JetBrains Mono"; export const BUNDLED_UI_FAMILY = "Inter"; const BUNDLED_FACES: ReadonlyMap = new Map([ [BUNDLED_MONO_FAMILY.toLowerCase(), ['"JetBrains Mono Variable"', '"JetBrains Mono"']], + ["jetbrains mono variable", ['"JetBrains Mono Variable"']], + ["inter variable", ['"Inter Variable"']], [BUNDLED_UI_FAMILY.toLowerCase(), ['"Inter Variable"', '"Inter"']], ]); const MONO_STACK_FALLBACK = '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, Menlo, monospace'; @@ -22,10 +27,83 @@ export const quoteFontFamily = (family: string) => // Preserve compatibility with previously saved names wrapped in CSS quotes. const familyName = (family: string) => family.trim().replace(/^"(.*)"$/, "$1"); +// Per webview: the CSS alias stays stable while its faces load; DOM consumers +// reflow automatically and canvas consumers await the same promise below. +const userFonts = new Map }>(); + +/** WKWebView needs a connected canvas to resolve user fonts. Remove it in finally after probing. */ +export function createFontProbeCanvas(): HTMLCanvasElement { + const canvas = document.createElement("canvas"); + canvas.hidden = true; + (document.body ?? document.documentElement).append(canvas); + return canvas; +} + +/** A pure local-name probe; FontFaceSet.check also returns true for unknown families. */ +export function fontAvailable(name: string): boolean { + let canvas: HTMLCanvasElement | undefined; + try { + canvas = createFontProbeCanvas(); + const ctx = canvas.getContext("2d"); + if (!ctx) return true; + // Symbol-only Nerd fonts and CJK faces may have no Latin glyphs. + return ["mmmmmmmmmmlli1WQ@", "\uF418\uE725\u{F0001}", "汉字かな한글"].some((sample) => + ["monospace", "serif", "sans-serif"].some((base) => { + ctx.font = `32px ${base}`; + const baseline = ctx.measureText(sample).width; + ctx.font = `32px ${quoteFontFamily(name)}, ${base}`; + const width = ctx.measureText(sample).width; + return Number.isFinite(width) && Number.isFinite(baseline) && width !== baseline; + }), + ); + } catch { + return true; + } finally { + canvas?.remove(); + } +} + +async function loadUserFont(name: string, alias: string): Promise { + const buffer = await invoke("user_font_data", { family: name }); + const headerLength = new DataView(buffer).getUint32(0, true); + const start = 4 + headerLength; + const faces: { weight: number; style: string; offset: number; length: number }[] = JSON.parse( + new TextDecoder().decode(buffer.slice(4, start)), + ); + await Promise.all( + faces.map(async ({ weight, style, offset, length }) => { + const face = new FontFace(alias, buffer.slice(start + offset, start + offset + length), { + weight: String(weight), + style, + }); + document.fonts.add(face); + try { + await face.load(); + } catch { + document.fonts.delete(face); + } + }), + ); +} + /** One resolver for primary, fallback and UI families, matching the bundled CSS registrations. */ -function familyFaces(family: string): readonly string[] { +export function familyFaces(family: string): readonly string[] { const name = familyName(family); - return BUNDLED_FACES.get(name.toLowerCase()) ?? [quoteFontFamily(name)]; + const bundled = BUNDLED_FACES.get(name.toLowerCase()); + if (bundled) return bundled; + if (!name || !isTauri() || guessOs() !== "macos" || typeof FontFace === "undefined") return [quoteFontFamily(name)]; + const key = name.toLowerCase(); + let entry = userFonts.get(key); + if (!entry) { + if (fontAvailable(name)) return [quoteFontFamily(name)]; + const alias = `OpenIDEA User Font ${userFonts.size}`; + const loaded = loadUserFont(name, alias).catch(() => { + /* retain local-name and bundled fallbacks */ + }); + entry = { alias, loaded }; + userFonts.set(key, entry); + } + return [quoteFontFamily(entry.alias), quoteFontFamily(name)]; } /** The `--font-mono` stack, resolved (canvas / xterm can't use `var()`). */ @@ -53,6 +131,8 @@ export function monoFontFamily(family: string | undefined, fallback = "", base = export async function fontsLoaded(family: string, size: number): Promise { const fonts = typeof document !== "undefined" ? document.fonts : undefined; if (!fonts?.load) return; + const pending = [...userFonts.values()].filter(({ alias }) => family.includes(quoteFontFamily(alias))); + if (pending.length) await Promise.all(pending.map(({ loaded }) => loaded)); try { await Promise.all([fonts.load(`${size}px ${family}`), fonts.load(`bold ${size}px ${family}`)]); } catch { @@ -60,7 +140,8 @@ export async function fontsLoaded(family: string, size: number): Promise { } } -/** Bundled aliases also apply when selected for the interface. */ -export function uiFontFamily(family: string): string { - return [...familyFaces(family), "system-ui", "sans-serif"].join(", "); +/** Bundled aliases also apply when selected for the interface; `fallback` covers glyphs `family` lacks (empty = none). */ +export function uiFontFamily(family: string, fallback = ""): string { + const fb = familyName(fallback); + return [...familyFaces(family), ...(fb ? familyFaces(fb) : []), "system-ui", "sans-serif"].join(", "); } diff --git a/src/platform/ipc.ts b/src/platform/ipc.ts index 9936911de..2fd06e815 100644 --- a/src/platform/ipc.ts +++ b/src/platform/ipc.ts @@ -14,10 +14,12 @@ export type * from "@/bindings"; export class IpcError extends Error { readonly kind: IdeError["kind"]; + readonly data: IdeError["data"]; constructor(error: IdeError) { super(error.message); this.name = "IpcError"; this.kind = error.kind; + this.data = error.data; } } @@ -46,6 +48,11 @@ export function unavailableText(e: unknown, fallback = "No language server avail return e instanceof IpcError && e.kind === "unavailable" && LACKS_FEATURE.test(e.message) ? e.message : fallback; } +/** True when `unavailable` means no server is running for the file, rather than a running server lacking the feature. */ +export function isNoServer(e: unknown): boolean { + return e instanceof IpcError && e.kind === "unavailable" && !LACKS_FEATURE.test(e.message); +} + // Ids are shared by every window of the backend and outlive a reload there (a cancel that // arrives before its request began is remembered), so each page starts at a random point. let nextId = 1 + Math.floor(Math.random() * 0x7000_0000); diff --git a/src/platform/languageSettings.ts b/src/platform/languageSettings.ts index ca4aa81eb..055ff9d47 100644 --- a/src/platform/languageSettings.ts +++ b/src/platform/languageSettings.ts @@ -42,6 +42,7 @@ export interface LanguagesValid { modules: { env: Record; autoVendor: boolean }; }; typescript: { version: string }; + prettier: { path: string; languages: string[]; prefer: boolean; runOnSave: boolean }; java: { jdk: string; jdtlsJdk: string; @@ -80,6 +81,27 @@ export const DEFAULT_LANGUAGES: LanguagesValid = { rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" }, go: { goroot: "", gopath: "", buildTags: DEFAULT_GO_BUILD_TAGS, modules: { env: {}, autoVendor: true } }, typescript: { version: "bundled" }, + prettier: { + path: "", + languages: [ + "markdown", + "yaml", + "css", + "scss", + "less", + "html", + "graphql", + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "json", + "jsonc", + "vue", + ], + prefer: false, + runOnSave: false, + }, java: { jdk: "", jdtlsJdk: "", @@ -142,6 +164,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { const tags = obj(go.buildTags); const modules = obj(go.modules); const ts = obj(l.typescript); + const prettier = obj(l.prettier); const java = obj(l.java); const maven = obj(java.maven); const gradle = obj(java.gradle); @@ -177,6 +200,14 @@ export function validateLanguages(raw: unknown): LanguagesValid { }, }, typescript: { version: text(ts.version, d.typescript.version) || d.typescript.version }, + prettier: { + path: text(prettier.path, d.prettier.path), + languages: Array.isArray(prettier.languages) + ? [...new Set(prettier.languages.filter((v): v is string => typeof v === "string"))] + : d.prettier.languages, + prefer: bool(prettier.prefer, d.prettier.prefer), + runOnSave: bool(prettier.runOnSave, d.prettier.runOnSave), + }, java: { jdk: text(java.jdk, d.java.jdk), jdtlsJdk: text(java.jdtlsJdk, d.java.jdtlsJdk), diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index daff493a2..47c0c4be9 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,8 @@ { + "Kotlin bridge requires a Maven build root": "Kotlin 桥接需要 Maven 构建根目录", + "Kotlin bridge bundle is unavailable": "Kotlin 桥接扩展不可用", + "Kotlin bridge command did not acknowledge its library snapshot": "Kotlin 桥接命令未确认类库更新", + "Kotlin editor compilation failed": "Kotlin 编辑器编译失败", "Updating results…": "正在更新结果…", "Uploading files": "正在上传文件", "No files on the clipboard": "剪贴板中没有文件", @@ -760,6 +764,7 @@ "Failed": "失败", "Failed to restart language server": "重启语言服务器失败", "Fallback font": "备用字体", + "Fallback font:": "备用字体:", "Fetch": "获取", "Fetches remotes in the background so incoming commits show in the Branches popup. Auto stops after an authentication failure.": "在后台获取远程,以便传入的提交显示在“分支”弹出窗口中。“自动”在身份验证失败后停止。", "Field access": "字段访问", @@ -1595,6 +1600,27 @@ "Refactor This…": "重构此项…", "Refactoring undone": "重构已撤消", "Reformat Code": "重新格式化代码", + "Prettier path": "Prettier 路径", + "Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection.": "自动:依次查找最近的项目 node_modules、受管安装和 PATH。指定路径将覆盖自动检测。", + "Formatting provider": "格式化工具", + "Automatic (language server first)": "自动(优先语言服务器)", + "Run on save": "保存时运行", + "Run Prettier on save for enabled languages when it is the selected formatting provider.": "对已启用的语言,在选择 Prettier 作为格式化工具时于保存时运行。", + "Enabled languages": "启用的语言", + "Reformat Code needs {name}. Install it via {method}?": "重新格式化代码需要 {name}。是否通过 {method} 安装?", + "Install Missing Tool": "安装缺失的工具", + "Prettier formatting failed": "Prettier 格式化失败", + "Prettier formatting timed out": "Prettier 格式化超时", + "Prettier does not support selection formatting for this language; formatted the whole file": "Prettier 不支持此语言的选区格式化,已格式化整个文件", + "Formatting timed out": "格式化超时", + "Prettier: Node.js is required": "Prettier:需要 Node.js", + "Formatter": "格式化工具", + "JSON with Comments": "带注释的 JSON", + "Unknown external formatter: {tool}": "未知的外部格式化工具:{tool}", + "Prettier: package has no CLI entry point": "Prettier:软件包未提供命令行入口", + "Prettier: CLI does not exist: {path}": "Prettier:命令行程序不存在:{path}", + "Prettier: file has no directory": "Prettier:文件路径不含目录", + "Prettier: {error}": "Prettier:{error}", "Reformat File": "重新格式化文件", "Reformat code": "重新格式化代码", "Reformat code on save": "保存时重新格式化代码", @@ -1985,6 +2011,8 @@ "Show the parameter info popup in": "在以下时间后显示形参信息弹出窗口", "Show the selected coverage suite in editors and the project view": "在编辑器和项目视图中显示所选覆盖率套件", "Show tool window bars": "显示工具窗口栏", + "Show tool window badges": "显示工具窗口角标", + "Counts on the stripe icons, e.g. changed files or agent tasks needing input": "侧栏图标上的数字,如变更文件数、等待输入的智能体任务数", "Show tool window names": "显示工具窗口名称", "Show value tooltip on code selection": "选中代码时显示值提示", "Show value tooltip on mouse hover": "鼠标悬停时显示值工具提示", @@ -2221,6 +2249,7 @@ "Merged with main toolbar": "与主工具栏合并", "UI Options": "UI 选项", "UI elements take up less screen space": "UI 元素占用更少的屏幕空间", + "UI fallback font": "UI 备用字体", "UI font": "UI 字体", "UI font size": "UI 字体大小", "Uninstalled {name}": "已卸载 {name}", @@ -4465,6 +4494,7 @@ "Plan node properties": "计划节点属性", "Values": "值", "Vim rc file": "Vim rc 文件", + "Breakpoints are not supported in output classes without source code.": "输出目录中无源码的类不支持断点。", "Decompiled .class file: sources are not available.": "已反编译的 .class 文件:没有可用的源代码。", "No column of the file matches the table": "文件中没有与该表匹配的列", "Not a URL of PostgreSQL, MySQL, SQLite or a compatible database": "不是 PostgreSQL、MySQL、SQLite 或兼容数据库的 URL", @@ -5379,5 +5409,36 @@ "in {length} repositories": "在 {length} 个仓库中", "No repository in the selection has revision {label}": "所选内容中的仓库均无版本 {label}", "{repoName} has no revision {label}": "{repoName} 没有版本 {label}", - "Waiting for language server…": "正在等待语言服务器…" + "Waiting for language server…": "正在等待语言服务器…", + "Compare Directories": "比较目录", + "Select two directories to compare": "选择两个要比较的目录", + "Select a directory to compare": "选择要比较的目录", + "Directory path": "目录路径", + "Only on left": "仅左侧", + "Only on right": "仅右侧", + "Contents differ": "内容不同", + "Comparing directories": "正在比较目录", + "Filter by name": "按名称过滤", + "Show identical files": "显示相同文件", + "Copy to Left": "复制到左侧", + "Copy to Right": "复制到右侧", + "Compared files": "比较的文件", + "Comparison cancelled": "已取消比较", + "No matching differences": "没有匹配的差异", + "Overwrite files?": "覆盖文件?", + "Copy to {to} and overwrite existing files?": "复制到 {to} 并覆盖已有文件?", + "Left: {left}": "左侧:{left}", + "Right: {right}": "右侧:{right}", + "Compared {compared} of {total} entries": "已比较 {compared} / {total} 个条目", + "Scanned {scanned} entries": "已扫描 {scanned} 个条目", + "Binary content": "二进制内容", + "Binary files cannot be shown as text": "二进制文件无法以文本形式显示", + "Delete {path}?": "删除 {path}?", + "copy requires an absolute path": "复制需要绝对路径", + "missing file name": "缺少文件名", + "copy requires absolute paths": "复制需要绝对路径", + "copy paths must belong to the comparison roots": "复制路径必须位于比较的根目录内", + "cannot copy overlapping paths": "无法在相互包含的路径之间复制", + "cannot copy through a destination symlink": "无法通过目标目录内的符号链接复制", + "expected a relative comparison entry path": "需要比较条目的相对路径" } diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index b0963067f..86841e022 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -79,6 +79,8 @@ const BUILT_AT_RUNTIME = new Set([]); /** Text that stays as written in every locale: names, code, formats, shortcuts. */ const UNTRANSLATED = new Set([ + // Product names + "Prettier", // Go environment variables and placeholder values "CGO", "GOPROXY", diff --git a/src/platform/virtualDocs.ts b/src/platform/virtualDocs.ts index 8a31ef0ef..f0ced78e8 100644 --- a/src/platform/virtualDocs.ts +++ b/src/platform/virtualDocs.ts @@ -4,12 +4,13 @@ * code). Locations into them carry the URI as their `path`; the editor opens * them as `virtual` editor inputs instead of files. */ +import { isClassFile } from "./classFiles"; import { call, commands, type VirtualDocument } from "./ipc"; -/** True for `scheme:…` URIs other than `file:` (scheme of 2+ chars, so `C:\…` is a path). */ +/** Server document URIs, including decompiled disk classes; drive letters remain paths. */ export function isVirtualUri(s: string | null | undefined): boolean { const m = /^([a-zA-Z][a-zA-Z0-9+.-]+):/.exec(s ?? ""); - return !!m && m[1]!.toLowerCase() !== "file"; + return !!m && (m[1]!.toLowerCase() !== "file" || isClassFile(s!)); } /** Last path segment of a URI (without query / fragment), e.g. `SpringApplication.class`. */ diff --git a/src/ui/List.tsx b/src/ui/List.tsx index 5a361ddcd..71417a26d 100644 --- a/src/ui/List.tsx +++ b/src/ui/List.tsx @@ -16,6 +16,7 @@ import { } from "react"; import clsx from "clsx"; import { useVirtualizer } from "@tanstack/react-virtual"; +import { rowSelectionMode } from "./rowSelection"; import styles from "./List.module.css"; export interface ListRowState { @@ -32,6 +33,8 @@ export interface ListProps { activeIndex?: number; defaultActiveIndex?: number; onActiveChange?: (index: number) => void; + /** Opt into modifier selection; modified clicks select without activating. */ + onSelect?: (index: number, e: KeyboardEvent | MouseEvent) => void; /** Rows selected besides the active one (multi-selection); drawn like the active row. */ isSelected?: (item: T, index: number) => boolean; onActivate?: (item: T, index: number, e: KeyboardEvent | MouseEvent) => void; @@ -115,6 +118,7 @@ export function List({ activeIndex, defaultActiveIndex, onActiveChange, + onSelect, isSelected, onActivate, onContextMenu, @@ -169,7 +173,8 @@ export function List({ const next = listNavigate(e.key, { items, active, isSection, pageSize }); if (next === null) return; e.preventDefault(); - if (next !== active) setActive(next); + if (onSelect) onSelect(next, e); + else if (next !== active) setActive(next); }; if (!items.length && empty) return
{empty}
; @@ -178,6 +183,7 @@ export function List({
({ id={`${baseId}-${v.index}`} role="option" aria-selected={selected} + data-lead={isActive || undefined} aria-posinset={optionIndex.idx[v.index]} aria-setsize={optionIndex.count} className={clsx(styles.row, selected && styles.active)} @@ -217,9 +224,12 @@ export function List({ onMouseDown={(e) => { if (e.button !== 0) return; if (!focusable) e.preventDefault(); - setActive(v.index); + if (!onSelect) setActive(v.index); + }} + onClick={(e) => { + onSelect?.(v.index, e); + if (!onSelect || rowSelectionMode(e) === "single") onActivate?.(item, v.index, e); }} - onClick={(e) => onActivate?.(item, v.index, e)} onContextMenu={onContextMenu ? (e) => onContextMenu(item, v.index, e) : undefined} > {renderItem(item, { active: isActive, index: v.index })} diff --git a/src/ui/index.ts b/src/ui/index.ts index 21427a32f..669975184 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -33,3 +33,4 @@ export { lazyComponent, prefetchLazyComponents } from "./lazy"; export { T } from "./T"; export { selectableText, copyMenuFor, copyText } from "./selectableText"; export { useSelectionBoundary } from "./useSelectionBoundary"; +export * from "./rowSelection"; diff --git a/src/ui/rowSelection.test.ts b/src/ui/rowSelection.test.ts new file mode 100644 index 000000000..c06936aec --- /dev/null +++ b/src/ui/rowSelection.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { selectRow, selectedRows } from "./rowSelection"; + +describe("list selection across sections", () => { + it("selects only options across headers in either direction", () => { + const rows = [ + { key: "files", section: true }, + { key: "a", section: false }, + { key: "symbols", section: true }, + { key: "b", section: false }, + { key: "actions", section: true }, + { key: "c", section: false }, + ]; + const single = { lead: 3, anchor: 3, keys: null }; + const section = (row: (typeof rows)[number]) => row.section; + const down = selectRow(rows, single, 5, "range", section); + expect(selectedRows(rows, down).map((r) => r.key)).toEqual(["b", "c"]); + const up = selectRow(rows, down, 1, "range", section); + expect(selectedRows(rows, up).map((r) => r.key)).toEqual(["a", "b"]); + expect(selectRow(rows, up, 2, "toggle", section)).toBe(up); + }); +}); diff --git a/src/ui/rowSelection.ts b/src/ui/rowSelection.ts new file mode 100644 index 000000000..05c8261dc --- /dev/null +++ b/src/ui/rowSelection.ts @@ -0,0 +1,74 @@ +import { hasPrimaryModifier } from "@/platform/keymap"; + +/** + * Multi-selection of list rows: click / ↑↓ select one row, + * ⇧ extends from the anchor, ⌘/Ctrl toggles a row. + */ + +export interface RowSelection { + /** Active row (keyboard focus, preview). */ + lead: number; + /** Start of ⇧ ranges. */ + anchor: number; + /** Keys of the selected rows; `null` when only the lead is selected. */ + keys: ReadonlySet | null; +} + +export const SINGLE_FIRST: RowSelection = { lead: 0, anchor: 0, keys: null }; + +export function selectRow( + rows: readonly T[], + sel: RowSelection, + index: number, + mode: "single" | "range" | "toggle" = "single", + isSection?: (row: T) => boolean, +): RowSelection { + if (index < 0 || index >= rows.length || isSection?.(rows[index]!)) return sel; + if (mode === "range") { + const anchor = Math.min(Math.max(0, sel.anchor), rows.length - 1); + const [a, b] = anchor <= index ? [anchor, index] : [index, anchor]; + return { + lead: index, + anchor, + keys: new Set( + rows + .slice(a, b + 1) + .filter((r) => !isSection?.(r)) + .map((r) => r.key), + ), + }; + } + if (mode === "toggle") { + const keys = new Set(sel.keys ?? (rows[sel.lead] ? [rows[sel.lead]!.key] : [])); + const key = rows[index]!.key; + if (!keys.has(key)) return { lead: index, anchor: index, keys: keys.add(key) }; + keys.delete(key); + const rest = rows.findLastIndex((r) => keys.has(r.key)); + if (rest < 0) return { lead: index, anchor: index, keys: null }; + return { lead: rest, anchor: rest, keys }; + } + return { lead: index, anchor: index, keys: null }; +} + +/** The selected rows in list order (the lead when nothing else is selected). */ +export function selectedRows(rows: readonly T[], sel: RowSelection): T[] { + if (!rows.length || sel.lead < 0) return []; + const lead = Math.min(sel.lead, rows.length - 1); + if (!sel.keys) return [rows[lead]!]; + const out = rows.filter((r) => sel.keys!.has(r.key)); + return out.length ? out : [rows[lead]!]; +} + +export function isRowSelected(sel: RowSelection, key: string): boolean { + return !!sel.keys?.has(key); +} + +export function rowSelectionMode(e?: { + shiftKey: boolean; + metaKey: boolean; + ctrlKey: boolean; + type: string; +}): "single" | "range" | "toggle" { + if (e?.shiftKey) return "range"; + return e?.type === "click" && hasPrimaryModifier(e) ? "toggle" : "single"; +} diff --git a/src/workbench/Workbench.test.tsx b/src/workbench/Workbench.test.tsx index 67ff74783..f8f8f7175 100644 --- a/src/workbench/Workbench.test.tsx +++ b/src/workbench/Workbench.test.tsx @@ -148,6 +148,17 @@ describe("notifications", () => { expect(useNotifications.getState().items).toHaveLength(2); }); + it("explicitly non-sticky errors fade after the ordinary balloon timeout", () => { + vi.useFakeTimers(); + vi.spyOn(document, "hasFocus").mockReturnValue(true); + render(); + act(() => void notify({ level: "error", title: "Formatting failed", sticky: false })); + expect(screen.getByRole("alert")).toHaveTextContent("Formatting failed"); + act(() => vi.advanceTimersByTime(BALLOON_TIMEOUT.info! + 10)); + expect(screen.queryByText("Formatting failed")).toBeNull(); + expect(useNotifications.getState().items[0]).toMatchObject({ sticky: false, balloon: false }); + }); + it("sticky balloons fade into the tool window after 5 minutes, staying unread", () => { vi.useFakeTimers(); vi.spyOn(document, "hasFocus").mockReturnValue(true); diff --git a/src/workbench/notificationStore.ts b/src/workbench/notificationStore.ts index f67a384f5..5069884a5 100644 --- a/src/workbench/notificationStore.ts +++ b/src/workbench/notificationStore.ts @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { create } from "zustand"; import { call, commands, onIdeEvent } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; import { useProject } from "@/platform/project"; import type { NotificationService } from "./contributions"; @@ -116,8 +117,8 @@ export const STICKY_BALLOON_TIMEOUT = 5 * 60 * 1000; /** Fade-out delay of a balloon, or null when it stays until dismissed. */ export function balloonTimeout(n: Pick): number | null { - if (n.sticky || BALLOON_TIMEOUT[n.level] === null) return n.logged ? STICKY_BALLOON_TIMEOUT : null; - return BALLOON_TIMEOUT[n.level]; + if (n.sticky) return n.logged ? STICKY_BALLOON_TIMEOUT : null; + return BALLOON_TIMEOUT[n.level] ?? BALLOON_TIMEOUT.info; } const MAX_HISTORY = 200; let seq = 0; @@ -189,5 +190,8 @@ export const notificationServiceImpl: NotificationService = { }; export function installNotifyEvents(): () => void { - return onIdeEvent("notify", (e) => void notify({ level: e.level, title: e.title, body: e.body ?? undefined })); + return onIdeEvent( + "notify", + (e) => void notify({ level: e.level, title: tx(e.title), body: e.body ? tx(e.body) : undefined }), + ); } diff --git a/src/workbench/toolwindows/ToolWindows.tsx b/src/workbench/toolwindows/ToolWindows.tsx index 3d874c903..d957e2a7d 100644 --- a/src/workbench/toolwindows/ToolWindows.tsx +++ b/src/workbench/toolwindows/ToolWindows.tsx @@ -359,8 +359,10 @@ function startStripeDrag(e: PointerEvent, id: string) { } function StripeBadge({ useBadge }: { useBadge: () => number | string | null }) { + // Settings › Appearance › Tool Windows › "Show tool window badges". + const showBadges = useProject((s) => s.settings?.appearance?.showToolWindowBadges) !== false; const value = useBadge(); - if (value === null || value === 0 || value === "") return null; + if (!showBadges || value === null || value === 0 || value === "") return null; return {typeof value === "number" && value > 99 ? "99+" : value}; } diff --git a/src/workbench/toolwindows/badges.test.tsx b/src/workbench/toolwindows/badges.test.tsx new file mode 100644 index 000000000..1a6741e63 --- /dev/null +++ b/src/workbench/toolwindows/badges.test.tsx @@ -0,0 +1,43 @@ +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import { create } from "zustand"; +import { useProject } from "@/platform/project"; +import { ToolWindowStripe } from "./ToolWindows"; +import { resetToolWindowLayout, useToolWindowLayout } from "./store"; + +beforeEach(() => { + resetToolWindowLayout(); + useProject.setState({ settings: null }); +}); + +afterEach(() => useProject.setState({ settings: null })); + +it("hides stripe counts immediately and restores the current count when enabled", () => { + const useCount = create(() => ({ count: 3 })); + render( + null, + useBadge: () => useCount((s) => s.count), + }, + ]} + />, + ); + const button = screen.getByRole("button", { name: "Git" }); + expect(button).toHaveTextContent("3"); + act(() => useProject.setState({ settings: { appearance: { showToolWindowBadges: false } } })); + expect(button).not.toHaveTextContent("3"); + act(() => useCount.setState({ count: 7 })); + expect(button).not.toHaveTextContent("7"); + act(() => useProject.setState({ settings: { appearance: { showToolWindowBadges: true } } })); + expect(button).toHaveTextContent("7"); + act(() => useCount.setState({ count: 0 })); + expect(button).not.toHaveTextContent("0"); +});