diff --git a/Cargo.lock b/Cargo.lock index 9b16ceaac..004622c25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4366,6 +4366,7 @@ dependencies = [ name = "ide-lsp" version = "0.1.29" dependencies = [ + "dunce", "futures", "globset", "ide-core", @@ -4460,7 +4461,9 @@ dependencies = [ name = "ide-run" version = "0.1.29" dependencies = [ + "dunce", "encoding_rs", + "ide-build", "ide-core", "libc", "parking_lot", diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 0c7118219..6903ad3de 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -12,12 +12,13 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor `python/python-requirements` (`fileTypes/requirements`); `python/pipenv` (`fileTypes/pipenv`); `python/python-pyproject` (`expui/toml.svg` → `fileTypes/pyprojectToml`); `plugins/kotlin/base` (`fileTypes/kotlin`, - `kotlinScript`, `kotlinGradleScript`); `java/openapi` (`fileTypes/maven`); + `kotlinScript`, `kotlinGradleScript`, `icons/expui/objectKotlin` → `kotlin/objectKotlin`); `java/openapi` (`fileTypes/maven`); `plugins/markdown` (`fileTypes/markdown`); `plugins/toml` (`fileTypes/toml`); `plugins/terminal` (`toolwindows/terminal`); `plugins/maven` (`images/expui/toolwindow/maven` → `toolwindows/maven`, `images/expui/build/maven*` → `maven/`); `plugins/gradle` (`icons/expui/gradle` → `toolwindows/gradle`, - `icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`); + `icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`, + `icons/expui/gradle`, `icons/gradleSubproject` → `gradle/`); `platform/external-system-impl` (`icons/newui/task` → `externalSystem/task`). Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below). - [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin diff --git a/crates/ide-agent/src/native/mod.rs b/crates/ide-agent/src/native/mod.rs index 665e9a049..d7e34e77c 100644 --- a/crates/ide-agent/src/native/mod.rs +++ b/crates/ide-agent/src/native/mod.rs @@ -27,7 +27,8 @@ pub(crate) fn newest_install(command: &str) -> String { let path = ide_core::process::host_var_os("PATH") .or_else(|| ide_core::process::shell_env().find(|(k, _)| *k == "PATH").map(|(_, v)| v.to_owned())) .unwrap_or_default(); - newest_install_in(command, &path) + // Windows: read afresh, so a tool the user installed since the IDE started is found too. + newest_install_in(command, &ide_core::process::with_user_path(&path)) } fn newest_install_in(command: &str, search: &std::ffi::OsStr) -> String { diff --git a/crates/ide-build/src/process.rs b/crates/ide-build/src/process.rs index d0335def8..b598eb791 100644 --- a/crates/ide-build/src/process.rs +++ b/crates/ide-build/src/process.rs @@ -58,6 +58,18 @@ pub(crate) fn run( cwd: &Path, env: &BTreeMap, cancel: &CancellationToken, + on_line: impl FnMut(Stream, &str), +) -> IdeResult { + run_with_env(program, args, cwd, env, false, cancel, on_line) +} + +pub(crate) fn run_with_env( + program: &str, + args: &[String], + cwd: &Path, + env: &BTreeMap, + clear_env: bool, + cancel: &CancellationToken, mut on_line: impl FnMut(Stream, &str), ) -> IdeResult { tracing::info!("build: {program} {} (in {})", args.join(" "), cwd.display()); @@ -66,7 +78,7 @@ pub(crate) fn run( ide_core::output_encoding::fix(program, args, env, |k| std::env::var(k).ok(), "UTF-8", cfg!(windows)); let program = program_path(program, env); let mut command = Command::new(&program); - command.args(args).current_dir(cwd).envs(env).envs(encoding.env).stdin(Stdio::null()); + command.args(args).current_dir(cwd).envs(env).envs(encoding.env.iter().map(|(k, v)| (k, v))).stdin(Stdio::null()); command.stdout(Stdio::piped()).stderr(Stdio::piped()); #[cfg(unix)] { @@ -74,6 +86,9 @@ pub(crate) fn run( command.process_group(0); } ide_core::process::prepare(&mut command); + if clear_env { + command.env_clear().envs(env).envs(encoding.env); + } let mut child = command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {}: {e}", program.display())))?; let (tx, rx) = mpsc::channel::<(Stream, String)>(); diff --git a/crates/ide-build/src/web.rs b/crates/ide-build/src/web.rs index c918bb8ee..9f25039ac 100644 --- a/crates/ide-build/src/web.rs +++ b/crates/ide-build/src/web.rs @@ -1,6 +1,6 @@ //! WAR modules and build-tool-owned archive resolution (including custom output names). use crate::BuildModel; -use ide_core::{IdeError, IdeResult}; +use ide_core::{CancellationToken, IdeError, IdeResult}; use serde::{Deserialize, Serialize}; use specta::Type; use std::{collections::BTreeMap, fs, path::Path}; @@ -47,6 +47,50 @@ pub struct WebBuild { pub args: Vec, pub cwd: String, pub env: BTreeMap, + maven_project: Option<(Vec, std::path::PathBuf)>, +} + +impl WebBuild { + /// Resolve the selected module's effective identity using the build's options and environment. + /// Runs off the UI thread, before launching the reactor build. + pub fn resolve_project( + &mut self, + root: &Path, + env: &BTreeMap, + cancel: &CancellationToken, + ) -> IdeResult<()> { + let Some((args, output)) = &self.maven_project else { + return Ok(()); + }; + if output.exists() { + fs::remove_file(output)?; + } + let cwd = root.join(&self.cwd).canonicalize()?; + let mut env = env.clone(); + if !env.iter().any(|(k, v)| k.eq_ignore_ascii_case("MAVEN_BASEDIR") && !v.is_empty()) { + // -f changes Maven's .mvn search origin; keep the reactor's launcher settings. + let base = cwd.ancestors().find(|p| p.join(".mvn").is_dir()).unwrap_or(&cwd); + env.retain(|k, _| !k.eq_ignore_ascii_case("MAVEN_BASEDIR")); + env.insert("MAVEN_BASEDIR".into(), base.to_string_lossy().into_owned()); + } + let result = crate::process::run_with_env(&self.command, args, &cwd, &env, true, cancel, |_, _| {})?; + if !result.success { + return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", result.text.trim()))); + } + let id = fs::read_to_string(output)?; + let parts: Vec<_> = id.trim().split(':').collect(); + if parts.len() != 4 || parts[2] != "war" || parts.iter().any(|s| s.is_empty()) { + return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", id.trim()))); + } + let project = format!("{}:{}:{}", parts[0], parts[1], parts[3]); + self.args.push(format!("-Dexpression=session.projectMap({project}).artifact.file")); + self.maven_project = None; + Ok(()) + } +} + +fn groovy_literal(s: &str) -> String { + format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")) } pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResult { @@ -81,21 +125,36 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu .replace('\\', "/"); args.extend(["-pl".into(), module, "-am".into()]); } + // help:evaluate runs once, in the reactor's first project. Query the selected + // POM's effective identity first; profiles and -D options can change its GAV. + let output = cache.join("artifact-project.txt"); + let mut probe = top.options.clone(); + probe.extend([ + "-f".into(), + root.join(&p.pom).to_string_lossy().into_owned(), + "help:evaluate".into(), + "-Dexpression=project.id".into(), + format!("-Doutput={}", output.display()), + ]); args.extend([ "-DskipTests".into(), "package".into(), "help:evaluate".into(), - "-Dexpression=project.artifact.file".into(), format!("-Doutput={}", manifest.display()), ]); - return Ok(WebBuild { command: top.command.clone(), args, cwd: top.dir.clone(), env: p.env.clone() }); + return Ok(WebBuild { + command: top.command.clone(), + args, + cwd: top.dir.clone(), + env: p.env.clone(), + maven_project: Some((probe, output)), + }); } if let Some(path) = id.strip_prefix("gradle:") { let g = model.gradle.as_ref().ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?; if !artifacts(root, model).iter().any(|a| a.id == id) { return Err(IdeError::invalid("Choose a Gradle WAR module")); } - let literal = |s: &str| format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")); let script = cache.join("war.init.gradle"); fs::write( &script, @@ -105,12 +164,12 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu def warTask = p.tasks.named('war') p.tasks.register('openideaTomcatArtifact') {{ dependsOn warTask - doLast {{ new File({}).text = warTask.get().archiveFile.get().asFile.absolutePath }} + doLast {{ new File({}).setText(warTask.get().archiveFile.get().asFile.absolutePath, 'UTF-8') }} }} }} "#, - literal(path), - literal(&manifest.to_string_lossy()) + groovy_literal(path), + groovy_literal(&manifest.to_string_lossy()) ), )?; let task = format!("{}:openideaTomcatArtifact", path.trim_end_matches(':')); @@ -119,6 +178,7 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu args: vec!["--init-script".into(), script.to_string_lossy().into_owned(), task], cwd: g.dir.clone(), env: g.env.clone(), + maven_project: None, }); } Err(IdeError::invalid("Choose a WAR artifact")) @@ -136,7 +196,9 @@ mod tests { assert_eq!(a.len(), 1); assert_eq!(a[0].name, "web"); let plan = build(tmp.path(), &model, &a[0].id, &tmp.path().join("base with spaces")).unwrap(); - assert!(plan.args.contains(&"-Dexpression=project.artifact.file".into())); + let (probe, _) = plan.maven_project.as_ref().unwrap(); + assert!(probe.contains(&"-Dexpression=project.id".into())); + assert!(probe.contains(&tmp.path().join("pom.xml").to_string_lossy().into_owned())); assert!( plan.args.iter().any(|a| a.ends_with("base with spaces/artifact-path.txt") || a.ends_with("base with spaces\\artifact-path.txt")) @@ -152,6 +214,13 @@ mod tests { let plan = build(tmp.path(), &model, "maven:web/pom.xml", &tmp.path().join("base")).unwrap(); assert_eq!(plan.cwd, ""); assert_eq!(&plan.args[..3], &["-pl", "web", "-am"]); + assert!( + plan.maven_project + .as_ref() + .unwrap() + .0 + .contains(&tmp.path().join("web/pom.xml").to_string_lossy().into_owned()) + ); } #[test] fn gradle_war_uses_archive_provider_and_ignores_comments() { @@ -169,4 +238,31 @@ mod tests { assert!(fs::read_to_string(tmp.path().join("base/war.init.gradle")).unwrap().contains("archiveFile.get()")); assert!(!crate::gradle::has_war_plugin("// plugins { war }")); } + + #[test] + fn gradle_manifest_paths_preserve_windows_characters() { + for path in [r"C:\工作 区\Tomcat's cache\artifact-path.txt", r"\\server\share\artifact-path.txt"] { + let literal = groovy_literal(path); + assert_eq!(literal[1..literal.len() - 1].replace("\\'", "'").replace("\\\\", "\\"), path); + } + } + + #[test] + fn gradle_root_and_nested_modules_write_utf8_archive_paths() { + for module in [":", ":apps:timesheet"] { + let tmp = tempfile::tempdir().unwrap(); + let dir = if module == ":" { "" } else { "apps/timesheet" }; + fs::create_dir_all(tmp.path().join(dir)).unwrap(); + fs::write(tmp.path().join("settings.gradle"), "rootProject.name = 'timesheet'\ninclude 'apps:timesheet'") + .unwrap(); + fs::write(tmp.path().join(dir).join("build.gradle"), "plugins { id 'war' }").unwrap(); + let model = crate::load(tmp.path(), &tmp.path().join("cache"), None); + let cache = tmp.path().join("工作 cache"); + let plan = build(tmp.path(), &model, &format!("gradle:{module}"), &cache).unwrap(); + assert!(plan.args.contains(&format!("{}:openideaTomcatArtifact", module.trim_end_matches(':')))); + let script = fs::read_to_string(cache.join("war.init.gradle")).unwrap(); + assert!(script.contains(&format!("rootProject.project('{}')", module))); + assert!(script.contains("archiveFile.get().asFile.absolutePath, 'UTF-8'")); + } + } } diff --git a/crates/ide-core/Cargo.toml b/crates/ide-core/Cargo.toml index af0d6b992..a2a26d945 100644 --- a/crates/ide-core/Cargo.toml +++ b/crates/ide-core/Cargo.toml @@ -19,4 +19,4 @@ tokio-util.workspace = true tempfile.workspace = true [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Globalization"] } +windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_System_Registry", "Win32_Foundation"] } diff --git a/crates/ide-core/src/editor_settings.rs b/crates/ide-core/src/editor_settings.rs index 73cc773a4..a478558c6 100644 --- a/crates/ide-core/src/editor_settings.rs +++ b/crates/ide-core/src/editor_settings.rs @@ -438,6 +438,8 @@ pub struct EncodingSettings { pub project: String, /// Fallback for `.properties` files. pub properties: String, + /// Display properties Unicode escapes as characters and save non-ASCII as escapes. + pub native_to_ascii: bool, /// Create UTF-8 files: `never` (no BOM) | `always` (with BOM) | `windows` (with BOM on Windows). pub utf8_bom: String, } @@ -448,6 +450,7 @@ impl Default for EncodingSettings { global: "utf-8".into(), project: "utf-8".into(), properties: "iso-8859-1".into(), + native_to_ascii: false, utf8_bom: "never".into(), } } diff --git a/crates/ide-core/src/process.rs b/crates/ide-core/src/process.rs index f06ea74ff..85ee20f5d 100644 --- a/crates/ide-core/src/process.rs +++ b/crates/ide-core/src/process.rs @@ -237,10 +237,110 @@ pub fn find_executable(name: &str, search: &OsStr) -> Option find_executable_in(name, search, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str())) } +/// The user's own `PATH` entries on Windows (`HKCU\Environment`), the part of the +/// merged `PATH` a process may not inherit — an IDE started before the variable was +/// edited, or as a different user, sees only the machine entries. `%VAR%` references +/// (`REG_EXPAND_SZ`) are expanded against the current environment. `None` elsewhere: +/// there a login shell already supplies the user's whole `PATH`. +#[cfg(windows)] +pub fn user_path() -> Option { + use windows_sys::Win32::Foundation::ERROR_SUCCESS; + use windows_sys::Win32::System::Registry::{ + HKEY, HKEY_CURRENT_USER, KEY_READ, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey, RegGetValueW, + RegOpenKeyExW, + }; + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + + unsafe { + let mut key: HKEY = std::ptr::null_mut(); + if RegOpenKeyExW(HKEY_CURRENT_USER, wide("Environment").as_ptr(), 0, KEY_READ, &mut key) != ERROR_SUCCESS { + return None; + } + let read = || { + // Only `Path`; `REG_EXPAND_SZ` is accepted so `%USERPROFILE%\…` entries expand. + let mut kind = 0u32; + let mut len = 0u32; + let name = wide("Path"); + let status = RegGetValueW( + key, + std::ptr::null(), + name.as_ptr(), + RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ, + &mut kind, + std::ptr::null_mut(), + &mut len, + ); + if status != ERROR_SUCCESS || len == 0 { + return None; + } + let mut buf = vec![0u16; (len as usize).div_ceil(2)]; + let status = RegGetValueW( + key, + std::ptr::null(), + name.as_ptr(), + RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ, + &mut kind, + buf.as_mut_ptr() as *mut _, + &mut len, + ); + if status != ERROR_SUCCESS { + return None; + } + let text = String::from_utf16_lossy(&buf); + Some(text.trim_end_matches('\0').to_owned()) + }; + let value = read(); + RegCloseKey(key); + value.filter(|v| !v.trim().is_empty()).map(OsString::from) + } +} + +/// The user's own `PATH` entries; `None` outside Windows (see the Windows impl). +#[cfg(not(windows))] +pub fn user_path() -> Option { + None +} + +/// `path` followed by the [`user_path`] entries it lacks. +pub fn with_user_path(path: &OsStr) -> OsString { + user_path().map_or_else(|| path.to_owned(), |user| append_path_entries(path, &user)) +} + +fn append_path_entries(path: &OsStr, extra: &OsStr) -> OsString { + let mut dirs: Vec = std::env::split_paths(path).filter(|d| !d.as_os_str().is_empty()).collect(); + for dir in std::env::split_paths(extra).filter(|d| !d.as_os_str().is_empty()) { + if !dirs.contains(&dir) { + dirs.push(dir); + } + } + std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned()) +} + #[cfg(test)] mod tests { use super::*; + #[cfg(windows)] + #[test] + fn user_path_reads_the_current_users_path() { + // This host has a user `Path` only when the test account set one; either way the call + // must not fail, and a present value is a non-empty `;`-joined list of directories. + if let Some(path) = user_path() { + let dirs: Vec<_> = std::env::split_paths(&path).collect(); + assert!(!dirs.is_empty(), "user PATH should hold at least one directory"); + } + } + + #[test] + fn append_path_entries_keeps_the_order_and_adds_what_is_missing() { + let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap(); + assert_eq!(append_path_entries(&join(&["a", "b"]), &join(&["b", "c"])), join(&["a", "b", "c"])); + assert_eq!(append_path_entries(OsStr::new(""), &join(&["c"])), join(&["c"])); + } + #[test] fn shell_env_adds_only_what_the_process_lacks_and_the_overlay_wins() { set_shell_env(vec![("OPENIDEA_SHELL_ONLY_VAR".into(), "1".into()), ("PATH".into(), "/from/shell".into())]); diff --git a/crates/ide-core/src/remote_settings.rs b/crates/ide-core/src/remote_settings.rs index 771c30735..3d1703b6f 100644 --- a/crates/ide-core/src/remote_settings.rs +++ b/crates/ide-core/src/remote_settings.rs @@ -103,10 +103,19 @@ impl Default for DeploymentSettings { } } -/// An SFTP server and how project folders map onto it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum DeploymentType { + #[default] + Sftp, + Local, +} + +/// A deployment filesystem and how project folders map onto it. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct DeploymentServer { + pub kind: DeploymentType, pub id: String, pub name: String, /// [`SshConfiguration::id`]. @@ -148,3 +157,23 @@ pub struct RunTarget { pub node: String, pub java: String, } + +#[cfg(test)] +mod deployment_tests { + use super::*; + + #[test] + fn deployment_type_defaults_and_round_trips() { + let legacy: DeploymentServer = + serde_json::from_str(r#"{"id":"existing","sshConfig":"ssh","rootPath":"/srv"}"#).unwrap(); + assert_eq!(legacy.kind, DeploymentType::Sftp); + assert_eq!(legacy.ssh_config, "ssh"); + for kind in [DeploymentType::Sftp, DeploymentType::Local] { + let server = DeploymentServer { kind, ..legacy.clone() }; + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&server).unwrap()).unwrap(), + server + ); + } + } +} diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 434f5a30a..e2b347abf 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -342,6 +342,15 @@ impl Default for AppearanceSettings { } } +/// Imported editor colour scheme on top of a built-in base. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct CustomColorScheme { + pub name: String, + pub parent: String, + pub attributes: BTreeMap, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct EditorSettings { @@ -357,6 +366,8 @@ pub struct EditorSettings { pub format_on_save: bool, /// Editor colour scheme id; `theme-default` follows the IDE theme. pub color_scheme: String, + /// Imported editor schemes, stored in user settings. + pub custom_schemes: BTreeMap, /// Color Scheme edits: scheme id → attribute id → override. pub color_overrides: BTreeMap>, /// Color Scheme › Language Defaults › Semantic highlighting: local variables and @@ -404,6 +415,7 @@ impl Default for EditorSettings { format_on_save: false, color_scheme: "theme-default".into(), color_overrides: BTreeMap::new(), + custom_schemes: BTreeMap::new(), semantic_highlighting: false, rainbow_brackets: false, ligatures: false, @@ -434,6 +446,8 @@ impl Default for EditorSettings { pub struct FileSettings { /// Save on focus loss / tab switch, like IDEA. pub auto_save: bool, + /// Re-check open files against disk when the IDE window gains focus. + pub sync_on_focus: bool, pub exclude: Vec, /// Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`]. pub ignored_names: Vec, @@ -465,6 +479,7 @@ impl Default for FileSettings { fn default() -> Self { Self { auto_save: true, + sync_on_focus: true, exclude: vec!["**/.git".into(), "**/node_modules".into(), "**/target".into()], ignored_names: crate::ignored::DEFAULT_IGNORED_NAMES.iter().map(|s| (*s).into()).collect(), trim_trailing_whitespace: true, @@ -546,6 +561,35 @@ pub struct GoSettings { pub goroot: String, /// Empty = `go env GOPATH`. pub gopath: String, + pub build_tags: GoBuildTags, + pub modules: GoModules, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct GoModules { + pub env: BTreeMap, + pub auto_vendor: bool, +} + +impl Default for GoModules { + fn default() -> Self { + Self { env: BTreeMap::new(), auto_vendor: true } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct GoBuildTags { + /// Empty values inherit the selected toolchain's defaults. + pub os: String, + pub arch: String, + pub version: String, + pub compiler: String, + /// Empty = automatic, "1" = enabled, "0" = disabled. + pub cgo: String, + pub custom: String, + pub experiments: String, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] @@ -754,3 +798,19 @@ mod gradle_settings_tests { } } } + +#[cfg(test)] +mod go_module_tests { + use super::*; + #[test] + fn old_go_settings_inherit_module_defaults() { + let old: GoSettings = serde_json::from_str(r#"{"goroot":"/go"}"#).unwrap(); + assert!(old.modules.auto_vendor); + assert!(old.modules.env.is_empty()); + let configured: GoSettings = + serde_json::from_str(r#"{"modules":{"autoVendor":false,"env":{"GOPRIVATE":"example.com/private"}}}"#) + .unwrap(); + assert!(!configured.modules.auto_vendor); + assert_eq!(configured.modules.env["GOPRIVATE"], "example.com/private"); + } +} diff --git a/crates/ide-core/src/text.rs b/crates/ide-core/src/text.rs index 9f96ff766..456481283 100644 --- a/crates/ide-core/src/text.rs +++ b/crates/ide-core/src/text.rs @@ -99,6 +99,44 @@ pub struct WorkspaceEdit { pub files: Vec, } +impl WorkspaceEdit { + /// Coalesces aliases of the same file before applying edits against its original text. + /// Resource operations separate batches because later edits may use the new contents. + pub fn deduplicate(&mut self) { + let mut files = Vec::new(); + let mut batch = std::collections::HashMap::::new(); + let mut resolve_aliases = true; + for file in std::mem::take(&mut self.files) { + match file { + FileEdit::Edit { path, edits } => { + let path = if resolve_aliases { + dunce::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf()) + } else { + path.to_path_buf() + }; + let index = *batch.entry(path.clone()).or_insert_with(|| { + files.push(FileEdit::Edit { path: path.into(), edits: Vec::new() }); + files.len() - 1 + }); + let FileEdit::Edit { edits: own, .. } = &mut files[index] else { unreachable!() }; + for edit in edits { + if !own.contains(&edit) { + own.push(edit); + } + } + } + op => { + batch.clear(); + // Later paths refer to a filesystem state the resource operation has yet to create. + resolve_aliases = false; + files.push(op); + } + } + } + self.files = files; + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum FileEdit { @@ -203,3 +241,34 @@ pub struct OutlineItem { /// Fuzzy-match highlight: indices (in chars of the displayed string) that matched. pub type MatchIndices = Vec; + +#[cfg(all(test, unix))] +mod workspace_edit_tests { + use super::*; + + #[test] + fn aliases_are_one_edit_but_resource_operations_preserve_order() { + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("main.cpp"); + let alias = dir.path().join("alias.cpp"); + std::fs::write(&real, "add()").unwrap(); + std::os::unix::fs::symlink(&real, &alias).unwrap(); + let change = TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }; + let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()] }; + let mut workspace = WorkspaceEdit { + files: vec![ + edit(real.clone()), + edit(alias), + FileEdit::Create { path: dir.path().join("other").into(), overwrite: false }, + edit(real), + ], + }; + workspace.deduplicate(); + assert_eq!(workspace.files.len(), 3); + let FileEdit::Edit { edits, .. } = &workspace.files[0] else { panic!() }; + assert_eq!(edits, &[change]); + let once = workspace.clone(); + workspace.deduplicate(); + assert_eq!(workspace, once); + } +} diff --git a/crates/ide-core/src/tool_path.rs b/crates/ide-core/src/tool_path.rs index 51dc4ee4d..266f5bae0 100644 --- a/crates/ide-core/src/tool_path.rs +++ b/crates/ide-core/src/tool_path.rs @@ -17,7 +17,16 @@ pub fn search_path() -> OsString { let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from); let shell_path = login_env().and_then(|env| env.iter().find(|(k, _)| k == "PATH").map(|(_, v)| v.clone())); let shims: Vec = version_managers::managers().iter().map(|m| m.shims()).collect(); - let dirs = search_dirs(host_var_os("PATH").as_deref(), shell_path.as_deref(), home.as_deref(), &shims); + // The Windows user `PATH` may not reach a process started before it was set; append its + // entries after the inherited ones so missing user tools still resolve. + let user_path = crate::process::user_path(); + let dirs = search_dirs( + host_var_os("PATH").as_deref(), + shell_path.as_deref(), + user_path.as_deref(), + home.as_deref(), + &shims, + ); std::env::join_paths(dirs).unwrap_or_default() } @@ -56,10 +65,12 @@ fn is_executable(path: &Path) -> bool { fn search_dirs( process_path: Option<&OsStr>, shell_path: Option<&OsStr>, + user_path: Option<&OsStr>, home: Option<&Path>, shims: &[PathBuf], ) -> Vec { - let mut dirs: Vec = process_path.into_iter().chain(shell_path).flat_map(std::env::split_paths).collect(); + let mut dirs: Vec = + process_path.into_iter().chain(shell_path).chain(user_path).flat_map(std::env::split_paths).collect(); if let Some(home) = home { dirs.extend(USER_DIRS.iter().map(|d| home.join(d))); dirs.extend(nvm_bins(&home.join(".nvm/versions/node"))); @@ -172,6 +183,7 @@ mod tests { Some(OsStr::new("/usr/bin:.:node_modules/.bin:/opt/tools")), // nushell prints `$PATH` as is. Some(OsStr::new("$PATH:/opt/tools:/home/me/.asdf/shims")), + None, Some(Path::new("/home/me")), &shims, ); @@ -185,10 +197,28 @@ mod tests { let unique: HashSet<&PathBuf> = dirs.iter().collect(); assert_eq!(unique.len(), dirs.len()); // A `:` can't be in a `PATH` entry: those below such a home are left out. - let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, Some(Path::new("/home/a:b")), &[]); + let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, None, Some(Path::new("/home/a:b")), &[]); assert!(dirs.contains(&PathBuf::from("/usr/bin")) && !dirs.iter().any(|d| d.starts_with("/home"))); } + #[test] + fn search_dirs_append_user_path_entries_once_after_the_inherited_path() { + let (system, dup, usertool) = + if cfg!(windows) { ("C:/system", "C:/dup", "C:/usertool") } else { ("/system", "/dup", "/usertool") }; + let home = Path::new(if cfg!(windows) { "C:/home/me" } else { "/home/me" }); + let join = |dirs: &[&str]| std::env::join_paths(dirs.iter().map(PathBuf::from)).unwrap(); + let inherited = join(&[system, dup]); + let user = join(&[dup, usertool]); + let dirs = search_dirs(Some(inherited.as_os_str()), None, Some(user.as_os_str()), Some(home), &[]); + let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap(); + // User entries follow the inherited PATH, de-duplicated, ahead of the fallbacks. + assert_eq!(dirs.iter().filter(|d| *d == Path::new(dup)).count(), 1); + assert!(at(dup) < at(usertool)); + // And before this platform's home/system fallbacks. + let fallback = dirs.iter().position(|d| d == &home.join(USER_DIRS[0])).unwrap(); + assert!(at(usertool) < fallback); + } + #[test] fn nvm_versions_newest_first() { let dir = std::env::temp_dir().join(format!("openidea-nvm-{}", std::process::id())); diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs index ade3d7c00..341e0663b 100644 --- a/crates/ide-dap/src/adapter.rs +++ b/crates/ide-dap/src/adapter.rs @@ -348,8 +348,17 @@ pub struct AdapterIo { pub port: Option, /// Recent stderr lines of the adapter process, for error messages. pub stderr: Arc>>, + /// (category, line) printed by the debuggee when it shares the adapter's stdio. + pub program_output: Option, } +pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>; +type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>; + +/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints. +const SHARES_STDIO: &str = "delve"; +const DELVE_BANNER: &str = "DAP server listening at:"; + pub type LaunchFuture = Pin> + Send>>; /// Starts adapters. Tests substitute in-memory adapters. @@ -385,6 +394,7 @@ impl AdapterLauncher for ProcessLauncher { child: None, port: Some(port), stderr, + program_output: None, }); } let embedded = match spec.connect.as_deref().filter(|_| spec.transport == Transport::Tcp) { @@ -407,6 +417,7 @@ impl AdapterLauncher for ProcessLauncher { child: None, port: Some(*port), stderr, + program_output: None, }); } if spec.adapter == DEBUGPY_TOOL { @@ -445,8 +456,14 @@ impl AdapterLauncher for ProcessLauncher { .kill_on_drop(true); let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?; + let (program_sink, program_output) = if spec.adapter == SHARES_STDIO { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + (Some(tx), Some(rx)) + } else { + (None, None) + }; if let Some(err) = child.stderr.take() { - pump_lines(err, spec.adapter.clone(), stderr.clone()); + pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr"))); } match port { None => { @@ -458,11 +475,12 @@ impl AdapterLauncher for ProcessLauncher { child: Some(child), port: None, stderr, + program_output, }) } Some(port) => { if let Some(out) = child.stdout.take() { - pump_lines(out, spec.adapter.clone(), stderr.clone()); + pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout"))); } let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT }; let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await { @@ -484,6 +502,7 @@ impl AdapterLauncher for ProcessLauncher { child: Some(child), port: Some(port), stderr, + program_output, }) } } @@ -495,9 +514,15 @@ fn pump_lines( stream: R, name: String, sink: Arc>>, + program: Option<(ProgramSink, &'static str)>, ) { tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| { tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}"); + if let Some((tx, category)) = &program + && !line.starts_with(DELVE_BANNER) + { + let _ = tx.send((category, format!("{line}\n"))); + } push_line(&sink, line); })); } @@ -549,6 +574,18 @@ async fn connect_with_retries( mod tests { use super::*; + #[tokio::test] + async fn program_output_is_forwarded_without_the_adapter_banner() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let sink = Arc::default(); + let text: &[u8] = b"DAP server listening at: 127.0.0.1:1\n[hello two words]\nno newline"; + pump_lines(text, "delve".into(), Arc::clone(&sink), Some((tx, "stdout"))); + assert_eq!(rx.recv().await, Some(("stdout", "[hello two words]\n".into()))); + assert_eq!(rx.recv().await, Some(("stdout", "no newline\n".into()))); + assert_eq!(rx.recv().await, None); + assert_eq!(sink.lock().len(), 3); + } + /// Managed tools win over `PATH`, and debugpy falls back to the private copy. #[cfg(unix)] #[tokio::test] diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 550f85cfa..638ec8d38 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -697,6 +697,14 @@ impl DebugManager { }; let session = Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child)); + if let Some(mut output) = io.program_output { + let session = session.clone(); + tokio::spawn(async move { + while let Some((category, output)) = output.recv().await { + session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output }); + } + }); + } self.sessions.lock().insert(id, session.clone()); self.order.lock().push(id); // Announced before the event loop runs, so it precedes every adapter event. diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index 16381f157..6bcd57b65 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -32,7 +32,14 @@ impl AdapterLauncher for FakeLauncher { tokio::spawn(fake_adapter(server, log, script)); let (r, w) = tokio::io::split(client); Box::pin(async move { - Ok(AdapterIo { reader: Box::new(r), writer: Box::new(w), child: None, port: None, stderr: Arc::default() }) + Ok(AdapterIo { + reader: Box::new(r), + writer: Box::new(w), + child: None, + port: None, + stderr: Arc::default(), + program_output: None, + }) }) } } diff --git a/crates/ide-host/src/ops/analysis.rs b/crates/ide-host/src/ops/analysis.rs index 7d693b52e..e40a4edf1 100644 --- a/crates/ide-host/src/ops/analysis.rs +++ b/crates/ide-host/src/ops/analysis.rs @@ -39,12 +39,13 @@ fn load(project: &Project, languages: &LanguageRegistry, path: &Path) -> Option< let snap = project.documents.text_snapshot(doc).ok()?; return (!snap.large).then_some((snap.language, snap.text)); } - let loaded = ide_vfs::load_text(path).ok()?; + let loaded = project.documents.load_file(path).ok()?; if loaded.binary || loaded.large || loaded.too_large { return None; } - let language = languages.detect(path, loaded.text.lines().next()); - Some((language, loaded.text)) + let text = ide_text::normalize_line_endings(&loaded.text).into_owned(); + let language = languages.detect(path, text.lines().next()); + Some((language, text)) } // ---- Inspect Code ---- diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 242960e4f..fcba1485a 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -22,6 +22,21 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide Ok(project) => project.settings.effective(), Err(_) => host.global_settings.effective(), }; + if config.adapter == "delve" && config.request == "launch" { + let root = host.project(window).map(|p| p.root.clone()).unwrap_or_default(); + let settings = settings.clone(); + config = host + .blocking(move || { + ide_sdk::go::debug_environment(&root, &settings, &mut config.env); + ide_sdk::go::apply_to_debug( + &mut config.env, + &mut config.extra_json, + &settings.languages.go.build_tags, + )?; + Ok(config) + }) + .await?; + } if config.adapter == ide_dap::kotlin::ADAPTER { let data_dir = host.data_dir.clone(); let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default(); diff --git a/crates/ide-host/src/ops/deploy.rs b/crates/ide-host/src/ops/deploy.rs new file mode 100644 index 000000000..e6b0bf021 --- /dev/null +++ b/crates/ide-host/src/ops/deploy.rs @@ -0,0 +1,207 @@ +use crate::Host; +use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId}; +use ide_remote::{DeployedPath, DeploymentCoverage, SyncEntry, SyncStep, TransferReport}; +use ide_ssh::{FsConnection, RemoteEntry}; +use std::path::PathBuf; + +/// Reports a transfer as a background task (status bar progress) whose Cancel fires `token`. +fn progress( + sink: SharedSink, + title: &str, + token: CancellationToken, +) -> (impl FnMut(&str, usize, usize) + Send, impl FnOnce()) { + let id = TaskId::next(); + let cancel = token; + let handle = ide_core::task::on_cancel(id, move || cancel.cancel()); + let title = title.to_owned(); + let (s, t) = (sink.clone(), title.clone()); + let report = move |path: &str, i: usize, n: usize| { + let fraction = if n == 0 { None } else { Some(i as f32 / n as f32) }; + let message = Some(path.to_owned()); + s.emit(IdeEvent::Task { id, title: t.clone(), message, fraction, done: false, cancellable: true }); + }; + let done = move || { + drop(handle); + sink.emit(IdeEvent::Task { id, title, message: None, fraction: Some(1.0), done: true, cancellable: false }) + }; + (report, done) +} + +fn local(paths: Vec) -> Vec { + paths.into_iter().map(|p| p.to_path_buf()).collect() +} + +/// Eligible paths per server, without contacting any deployment host. +pub async fn deploy_coverage(window: &str, state: &Host, paths: Vec) -> IdeResult> { + let project = state.project(window)?; + Ok(ide_remote::coverage(&project.settings.effective(), &project.root, &local(paths))) +} + +/// Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`). +pub async fn deploy_upload( + window: &str, + state: &Host, + paths: Vec, + server: Option, + request: RequestId, +) -> IdeResult { + let project = state.project(window)?; + let token = state.begin_request(request); + let (mut report, done) = progress(project.sink.clone(), "Uploading", token.clone()); + let r = state + .remote + .upload(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report) + .await; + done(); + state.end_request(request); + r +} + +/// Download from…: the remote counterparts of files and folders. +pub async fn deploy_download( + window: &str, + state: &Host, + paths: Vec, + server: Option, + request: RequestId, +) -> IdeResult { + let project = state.project(window)?; + let token = state.begin_request(request); + let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone()); + let r = state + .remote + .download(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report) + .await; + done(); + state.end_request(request); + r +} + +/// Deletes the remote counterparts of files and folders. +pub async fn deploy_delete_remote( + window: &str, + state: &Host, + paths: Vec, + server: Option, +) -> IdeResult { + let project = state.project(window)?; + state.remote.delete_remote(&project.settings.effective(), &project.root, server.as_deref(), &local(paths)).await +} + +/// Sync with Deployed: local and remote files under `path`, side by side. +pub async fn deploy_compare( + window: &str, + state: &Host, + path: IpcPath, + server: Option, + request: RequestId, +) -> IdeResult> { + let project = state.project(window)?; + let token = state.begin_request(request); + let r = state.remote.compare(&project.settings.effective(), &project.root, server.as_deref(), &path, token).await; + state.end_request(request); + r +} + +/// Applies the Sync with Deployed choices. +pub async fn deploy_sync( + window: &str, + state: &Host, + steps: Vec, + server: Option, + request: RequestId, +) -> IdeResult { + let project = state.project(window)?; + let token = state.begin_request(request); + let (mut report, done) = progress(project.sink.clone(), "Synchronizing", token.clone()); + let r = state + .remote + .sync(&project.settings.effective(), &project.root, server.as_deref(), &steps, token, &mut report) + .await; + done(); + state.end_request(request); + r +} + +/// Where `path` is deployed (remote path, web URL); `None` when not mapped. +pub async fn deploy_info( + window: &str, + state: &Host, + path: IpcPath, + server: Option, +) -> IdeResult> { + let project = state.project(window)?; + state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await +} + +/// Remote Host tool window: the server root of `server`, resolved on the host. +pub async fn deploy_server_root(window: &str, state: &Host, server: String) -> IdeResult { + let settings = match state.project(window) { + Ok(p) => p.settings.effective(), + Err(_) => state.global_settings.effective(), + }; + state.remote.server_root(&settings, &server).await +} + +/// Entries of a folder on the selected filesystem (`~`: its home directory). +pub async fn remote_list(state: &Host, connection: FsConnection, path: String) -> IdeResult> { + state.remote.list(&connection, &path).await +} + +/// Opens a remote file for editing: a local copy, uploaded back on save. +pub async fn remote_open(state: &Host, connection: FsConnection, path: String) -> IdeResult { + Ok(state.remote.open_remote(&connection, &path).await?.into()) +} + +/// The filesystem connection and path a cached editing copy stands for. +pub fn remote_of(state: &Host, path: IpcPath) -> Option<(FsConnection, String)> { + state.remote.remote_of(&path) +} + +pub async fn remote_delete(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> { + state.remote.remote_delete(&connection, &path).await +} + +pub async fn remote_mkdir(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> { + state.remote.remote_mkdir(&connection, &path).await +} + +pub async fn remote_rename(state: &Host, connection: FsConnection, from: String, to: String) -> IdeResult<()> { + state.remote.remote_rename(&connection, &from, &to).await +} + +pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> { + state.remote.test_connection(&connection, &path).await +} + +pub(crate) fn register(d: &mut crate::rpc::Dispatcher) { + d.add("deploy_coverage", |c| async move { deploy_coverage(&c.window, &c.host, c.arg("paths")?).await }); + d.add("deploy_upload", |c| async move { + deploy_upload(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await + }); + d.add("deploy_download", |c| async move { + deploy_download(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await + }); + d.add("deploy_delete_remote", |c| async move { + deploy_delete_remote(&c.window, &c.host, c.arg("paths")?, c.arg("server")?).await + }); + d.add("deploy_compare", |c| async move { + deploy_compare(&c.window, &c.host, c.arg("path")?, c.arg("server")?, c.arg("request")?).await + }); + d.add("deploy_sync", |c| async move { + deploy_sync(&c.window, &c.host, c.arg("steps")?, c.arg("server")?, c.arg("request")?).await + }); + d.add("deploy_info", |c| async move { deploy_info(&c.window, &c.host, c.arg("path")?, c.arg("server")?).await }); + d.add("deploy_server_root", |c| async move { deploy_server_root(&c.window, &c.host, c.arg("server")?).await }); + d.add("remote_list", |c| async move { remote_list(&c.host, c.arg("connection")?, c.arg("path")?).await }); + d.add("remote_open", |c| async move { remote_open(&c.host, c.arg("connection")?, c.arg("path")?).await }); + d.add("remote_of", |c| async move { Ok(remote_of(&c.host, c.arg("path")?)) }); + d.add("remote_delete", |c| async move { remote_delete(&c.host, c.arg("connection")?, c.arg("path")?).await }); + d.add("remote_mkdir", |c| async move { remote_mkdir(&c.host, c.arg("connection")?, c.arg("path")?).await }); + d.add("remote_rename", |c| async move { + remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await + }); + d.add("deploy_test_connection", |c| async move { + deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?).await + }); +} diff --git a/crates/ide-host/src/ops/doc.rs b/crates/ide-host/src/ops/doc.rs index dd9693879..c366a2182 100644 --- a/crates/ide-host/src/ops/doc.rs +++ b/crates/ide-host/src/ops/doc.rs @@ -38,6 +38,18 @@ pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> { host.debugger.refresh_source_mirrors(host.owner_window(window)).await } +/// Recovers external changes missed by the project's file watchers. +pub async fn sync_from_disk(host: &Host, window: &str) -> IdeResult<()> { + let project = host.project(window)?; + host.blocking(move || { + if project.settings.effective().files.sync_on_focus { + project.documents.sync_from_disk(); + } + Ok(()) + }) + .await +} + pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult { let project = host.project(window)?; host.blocking(move || project.documents.snapshot(doc)).await @@ -45,3 +57,6 @@ pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult IdeResult< } /// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label. -pub fn doc_set_encoding(host: &Host, window: &str, doc: DocId, encoding: String, reload: bool) -> IdeResult { - host.project(window)?.documents.set_encoding(doc, &encoding, reload) +pub async fn doc_set_encoding( + host: &Host, + window: &str, + doc: DocId, + encoding: String, + reload: bool, +) -> IdeResult { + let project = host.project(window)?; + host.blocking(move || project.documents.set_encoding(doc, &encoding, reload)).await } pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: LineEnding) -> IdeResult<()> { @@ -117,7 +124,7 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("doc_open_virtual", |c| async move { doc_open_virtual(&c.host, &c.window, c.arg("uri")?).await }); d.add("doc_ui_mode", |c| async move { doc_ui_mode(&c.host, &c.window, c.arg("path")?).await }); d.add("doc_set_encoding", |c| async move { - doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?) + doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?).await }); d.add("doc_set_line_ending", |c| async move { doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?) diff --git a/crates/ide-host/src/ops/doc/tests.rs b/crates/ide-host/src/ops/doc/tests.rs new file mode 100644 index 000000000..2f8d8bda2 --- /dev/null +++ b/crates/ide-host/src/ops/doc/tests.rs @@ -0,0 +1,200 @@ +use super::*; +use crate::{HostConfig, Project}; +use ide_core::{IdeEvent, TextChange, event::NotifyLevel}; +use parking_lot::Mutex; +use std::{path::Path, sync::Arc}; + +struct Fixture { + host: Host, + project: Arc, + events: Arc>>, + path: PathBuf, + outside: PathBuf, + _temp: tempfile::TempDir, +} + +impl Fixture { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + // Keep watcher delivery out of these tests: the op must recover it itself. + let excluded = root.join("node_modules"); + std::fs::create_dir_all(&excluded).unwrap(); + let path = excluded.join("file.txt"); + let outside = base.join("outside.txt"); + std::fs::write(&path, "original\n").unwrap(); + std::fs::write(&outside, "outside\n").unwrap(); + let events = Arc::new(Mutex::new(Vec::new())); + let captured = events.clone(); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(move |_| { + let events = captured.clone(); + Arc::new(move |event| events.lock().push(event)) + }), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + let project = host.open_project("main", &root).unwrap(); + Self { _temp: temp, host, project, events, path, outside } + } + + async fn open(&self, path: &Path) -> DocumentInfo { + open(&self.host, "main", path.to_path_buf()).await.unwrap() + } + + async fn sync(&self) { + sync_from_disk(&self.host, "main").await.unwrap(); + } + + fn document_events(&self) -> Vec { + self.events + .lock() + .iter() + .filter(|e| matches!(e, IdeEvent::DocPatched { .. } | IdeEvent::DocDirty { .. } | IdeEvent::Notify { .. })) + .cloned() + .collect() + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn changed_clean_document_reloads_once() { + let f = Fixture::new(); + let original = f.open(&f.path).await; + std::fs::write(&f.path, "externally changed\n").unwrap(); + f.sync().await; + let loaded = f.project.documents.snapshot(original.id).unwrap(); + assert_eq!(loaded.text, "externally changed\n"); + assert!(!loaded.dirty); + assert!(loaded.version > original.version); + f.events.lock().clear(); + f.sync().await; + assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, loaded.version); + assert!(f.document_events().is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unchanged_document_has_no_patch_or_event() { + let f = Fixture::new(); + let original = f.open(&f.path).await; + f.events.lock().clear(); + f.sync().await; + let loaded = f.project.documents.snapshot(original.id).unwrap(); + assert_eq!(loaded.text, original.text); + assert_eq!(loaded.version, original.version); + assert!(f.document_events().is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn dirty_document_keeps_edits_and_warns_once() { + let f = Fixture::new(); + let original = f.open(&f.path).await; + edit( + &f.host, + "main", + original.id, + original.version, + ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "local ".into() }] }, + ) + .unwrap(); + f.events.lock().clear(); + std::fs::write(&f.path, "external replacement\n").unwrap(); + f.sync().await; + let loaded = f.project.documents.snapshot(original.id).unwrap(); + assert_eq!(loaded.text, "local original\n"); + assert!(loaded.dirty); + assert!(matches!(f.document_events().as_slice(), [IdeEvent::Notify { level: NotifyLevel::Warning, .. }])); + f.events.lock().clear(); + f.sync().await; + assert!(f.document_events().is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn deleted_document_keeps_its_buffer_and_other_documents_still_reload() { + let f = Fixture::new(); + let original = f.open(&f.path).await; + let outside = f.open(&f.outside).await; + std::fs::remove_file(&f.path).unwrap(); + std::fs::write(&f.outside, "outside changed after deletion\n").unwrap(); + f.sync().await; + let loaded = f.project.documents.snapshot(original.id).unwrap(); + assert_eq!(loaded.text, original.text); + assert_eq!(loaded.version, original.version); + assert_eq!(f.project.documents.snapshot(outside.id).unwrap().text, "outside changed after deletion\n"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn outside_project_document_reloads() { + let f = Fixture::new(); + assert!(!f.outside.starts_with(&f.project.root)); + let original = f.open(&f.outside).await; + std::fs::write(&f.outside, "outside changed externally\n").unwrap(); + f.sync().await; + assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "outside changed externally\n"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn setting_defaults_on_and_disabled_sync_keeps_buffer() { + let f = Fixture::new(); + assert!(f.project.settings.effective().files.sync_on_focus); + assert!(serde_json::from_value::(serde_json::json!({})).unwrap().sync_on_focus); + let original = f.open(&f.outside).await; + f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", false.into()).unwrap(); + std::fs::write(&f.outside, "external change while disabled\n").unwrap(); + f.sync().await; + assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, original.version); + f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", true.into()).unwrap(); + f.sync().await; + assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change while disabled\n"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn properties_reload_preserves_native_to_ascii_decoding() { + let f = Fixture::new(); + let path = f.outside.with_extension("properties"); + std::fs::write(&path, "key=\\u4F60\n").unwrap(); + let mut defaults = f.project.documents.file_defaults(); + defaults.native_to_ascii = true; + f.project.documents.set_file_defaults(defaults); + let original = f.open(&path).await; + assert_eq!(original.text, "key=你\n"); + std::fs::write(&path, "key=\\u4E16\\u754C\n").unwrap(); + f.sync().await; + assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "key=世界\n"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn binary_and_virtual_documents_are_skipped() { + let f = Fixture::new(); + let path = f.outside.with_extension("bin"); + std::fs::write(&path, b"\0\0binary payload").unwrap(); + let binary = f.open(&path).await; + assert!(binary.read_only); + let virtual_doc = + f.project.documents.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual").unwrap(); + std::fs::write(&path, "now ordinary text").unwrap(); + f.events.lock().clear(); + f.sync().await; + assert_eq!(f.project.documents.snapshot(binary.id).unwrap().version, binary.version); + assert_eq!(f.project.documents.snapshot(virtual_doc.id).unwrap().text, "virtual"); + assert!(f.document_events().is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn synchronization_only_changes_the_calling_windows_documents() { + let f = Fixture::new(); + let original = f.open(&f.outside).await; + let other_root = f.outside.parent().unwrap().join("other-project"); + std::fs::create_dir(&other_root).unwrap(); + let other = f.host.open_project("other", &other_root).unwrap(); + let other_doc = open(&f.host, "other", f.outside.clone()).await.unwrap(); + std::fs::write(&f.outside, "external change in shared file\n").unwrap(); + f.sync().await; + assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change in shared file\n"); + assert_eq!(other.documents.snapshot(other_doc.id).unwrap().text, other_doc.text); +} diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index 69b02708a..7092a1b51 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -34,58 +34,72 @@ async fn resolve_launch( build: bool, cancel: &CancellationToken, ) -> IdeResult { + let started = std::time::Instant::now(); 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 built = if build && compile::has_non_java_sources(&module) { - let (root, cache, module, settings) = - (project.root.clone(), project.build_cache_dir(), module.clone(), settings.clone()); - let cancel = cancel.clone(); - tokio::task::spawn_blocking(move || { - compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &cancel) - }) - .await - .map_err(|e| IdeError::internal(e.to_string()))?? - } else { - Vec::new() - }; - let mut class_paths: Vec = built.iter().map(|p| p.to_string_lossy().into_owned()).collect(); - for p in std::mem::take(&mut info.class_paths) { - if !class_paths.contains(&p) { - class_paths.push(p); + 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( + &project.root, + &load(&project.root, &project.build_cache_dir(), &settings), + &module, + test, + &cancel, + )? + } else { + Vec::new() + }; + let mut class_paths: Vec = built.iter().map(|p| p.to_string_lossy().into_owned()).collect(); + for p in std::mem::take(&mut info.class_paths) { + if !class_paths.contains(&p) { + class_paths.push(p); + } } - } - info.class_paths = class_paths; - // The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version. - let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings); - let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file()); - let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| { - let release = ide_sdk::java::project_java_version(&module); - let jdks = ide_sdk::java::installed_jdks(); - info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release)); - ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home)) - }); - info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned())); - let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java")); - let shorten = ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows)); - info.shorten_command_line = Some(shorten.as_str().to_owned()); - if info.error_count > 0 { - let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace()); - let managed = ide_tools::managed_bundle(&host.data_dir, "lombok"); - let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings); - let root = project.root.clone(); - // Detection may read the project's sources. - let lombok = tokio::task::spawn_blocking(move || ide_sdk::lombok::lombok_agent(&root, own, managed, repo)) - .await - .map_err(|e| IdeError::internal(e.to_string()))?; - info.hints.extend(lombok.problem()); - } - if cancel.is_cancelled() { - return Err(IdeError::cancelled()); - } - Ok(info) + info.class_paths = class_paths; + let compile_ms = started.elapsed().as_millis() - lsp_ms; + // The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version. + let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings); + let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file()); + let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| { + let release = ide_sdk::java::project_java_version(&module); + let jdks = ide_sdk::java::installed_jdks(); + info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release)); + ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home)) + }); + info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned())); + let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java")); + let shorten = + ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows)); + info.shorten_command_line = Some(shorten.as_str().to_owned()); + if info.error_count > 0 { + let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace()); + let managed = ide_tools::managed_bundle(&data_dir, "lombok"); + let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings); + let root = project.root.clone(); + // Detection may read the project's sources. + let lombok = ide_sdk::lombok::lombok_agent(&root, own, managed, repo); + info.hints.extend(lombok.problem()); + } + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + tracing::info!( + lsp_ms, + compile_ms, + jdk_ms = started.elapsed().as_millis() - lsp_ms - compile_ms, + total_ms = started.elapsed().as_millis(), + "Java launch resolved" + ); + Ok(info) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))? } /// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand). diff --git a/crates/ide-host/src/ops/jvm_icons.rs b/crates/ide-host/src/ops/jvm_icons.rs new file mode 100644 index 000000000..a9d24773a --- /dev/null +++ b/crates/ide-host/src/ops/jvm_icons.rs @@ -0,0 +1,102 @@ +//! Bounded, read-only JVM presentation for visible files, also served remotely. +use crate::Host; +use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId}; +use ide_syntax::{JvmPresentation, Snapshot}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::{ + collections::HashMap, + fs::File, + io::Read, + path::PathBuf, + sync::{Mutex, OnceLock}, + time::SystemTime, +}; + +pub const PREFIX_BYTES: u64 = 64 * 1024; +const MAX_BATCH: usize = 64; +const MAX_CACHE: usize = 4096; + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct JvmFilePresentation { + pub path: PathBuf, + pub presentation: JvmPresentation, +} + +#[derive(Clone)] +struct Cached { + modified: SystemTime, + size: u64, + presentation: JvmPresentation, +} + +fn prefix(path: &PathBuf) -> std::io::Result { + let mut bytes = Vec::new(); + File::open(path)?.take(PREFIX_BYTES).read_to_end(&mut bytes)?; + // A truncated final UTF-8 character must not invalidate the entire prefix. + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} + +pub async fn jvm_file_presentations( + host: &Host, + window: &str, + paths: Vec, +) -> IdeResult> { + if paths.len() > MAX_BATCH { + return Err(IdeError::invalid("at most 64 JVM files per batch")); + } + let project = host.project(window)?; + host.blocking(move || { + static CACHE: OnceLock>> = OnceLock::new(); + let cache = CACHE.get_or_init(Default::default); + let mut results = Vec::new(); + for path in paths { + let language = match path.extension().and_then(|e| e.to_str()) { + Some("java") => LanguageId::new("java"), + Some("kt") => LanguageId::new("kotlin"), + _ => continue, + }; + let Ok(meta) = path.metadata() else { continue }; + if !meta.is_file() { + continue; + } + let Ok(modified) = meta.modified() else { continue }; + let hit = + cache.lock().unwrap().get(&path).filter(|c| c.modified == modified && c.size == meta.len()).cloned(); + let presentation = if let Some(hit) = hit { + hit.presentation + } else { + let Ok(source) = prefix(&path) else { continue }; + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default(); + let presentation = project.structure.jvm_presentation( + &Snapshot::text(&language, &source), + stem, + &CancellationToken::new(), + )?; + let mut cache = cache.lock().unwrap(); + if cache.len() >= MAX_CACHE { + cache.clear(); + } + cache.insert(path.clone(), Cached { modified, size: meta.len(), presentation: presentation.clone() }); + presentation + }; + results.push(JvmFilePresentation { path, presentation }); + } + Ok(results) + }) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn huge_files_read_only_a_prefix() { + let path = std::env::temp_dir().join(format!("openidea-icon-prefix-{}.java", std::process::id())); + let file = File::create(&path).unwrap(); + file.set_len(100 * 1024 * 1024).unwrap(); + assert_eq!(prefix(&path).unwrap().len(), PREFIX_BYTES as usize); + std::fs::remove_file(path).unwrap(); + } +} diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs index 4baf33cac..228d02fcd 100644 --- a/crates/ide-host/src/ops/mod.rs +++ b/crates/ide-host/src/ops/mod.rs @@ -8,11 +8,13 @@ pub mod build; pub mod bytes; pub mod coverage; pub mod debug; +pub mod deploy; pub mod doc; pub mod env; pub mod fs; pub mod history; pub mod java; +pub mod jvm_icons; pub mod lsp; pub mod nav; pub mod notebook; @@ -80,6 +82,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) { search::register(d); run::register(d); env::register(d); + deploy::register(d); } impl Host { diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs index b4ee7b745..7055d2e50 100644 --- a/crates/ide-host/src/ops/project.rs +++ b/crates/ide-host/src/ops/project.rs @@ -146,6 +146,14 @@ pub fn set_settings( } } let affected = || if project_scope { current.iter().cloned().collect() } else { host.projects() }; + if key == "editor" || key == "editor.encodings" || key == "editor.encodings.nativeToAscii" { + let affected = affected(); + host.runtime.spawn_blocking(move || { + for project in affected { + project.documents.reload_properties(); + } + }); + } // File type associations re-detect open documents (off the command thread). if key == "files" || key.starts_with("files.fileType") { let affected = affected(); diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index 8188b9ce1..7160c3117 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -21,6 +21,11 @@ pub fn configs(host: &Host, window: &str) -> IdeResult> { let key = crate::project::project_key(&member); let name = member.file_name().unwrap_or_default().to_string_lossy(); for mut config in configs { + for task in &mut config.extras.before_launch { + if task.kind == "run" { + task.target = format!("workspace:{}:{}", key, task.target); + } + } config.id = format!("workspace:{}:{}", key, config.id); config.name = format!("{name} / {}", config.name); config.cwd = Some(config.cwd.map(|p| member.join(&*p)).unwrap_or_else(|| member.clone()).into()); @@ -97,6 +102,10 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit } else { ide_sdk::apply::resolve_run_command(&mut config, root, &settings); } + if config.kind == "go" { + ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings); + ide_sdk::go::apply_to_run(&mut config, &settings.languages.go.build_tags); + } if config.command == ide_sdk::godot::COMMAND { return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND)); } @@ -112,12 +121,32 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit .await .map_err(|e| IdeError::internal(e.to_string()))??; } + if config.kind == "go" && config.extras.params.get("scope").is_some_and(|s| s == "file") { + let root = project.root.clone(); + config = host + .blocking(move || { + ide_run::go::prepare_test_file(&root, &mut config)?; + Ok(config) + }) + .await?; + } let encoding = settings.console.encoding; let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()), python_paths: if member.is_some() { vec![root.clone()] } else { super::python::paths(&project) }, }; - host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c))) + let mut sink = project.sink.clone(); + if config.kind == "go" && matches!(config.args.first().map(String::as_str), Some("mod" | "work")) { + let previous = sink; + let root = project.root.clone(); + sink = std::sync::Arc::new(move |event: ide_core::IdeEvent| { + if matches!(&event, ide_core::IdeEvent::RunExited { .. }) { + ide_sdk::libraries::invalidate_go(&root); + } + previous.emit(event); + }); + } + host.processes.start(&project.root, &config, &options, sink, Box::new(move |c| on_output(c))) } /// Stops a run (interrupt, then kill after Settings › Run's termination timeout). diff --git a/crates/ide-host/src/ops/run/commands.rs b/crates/ide-host/src/ops/run/commands.rs index fdfc21af8..86754386c 100644 --- a/crates/ide-host/src/ops/run/commands.rs +++ b/crates/ide-host/src/ops/run/commands.rs @@ -81,22 +81,21 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I .filter(|s| !s.is_empty()) .or_else(|| artifacts.first().map(|a| &a.id)) .ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?; - let plan = ide_build::web::build( + let mut plan = ide_build::web::build( &project.root, &model, id, &ide_run::tomcat::base_dir(&project.cache_dir, &config.id), )?; - let mut env = plan.env; - env.extend(config.env.clone()); - Ok(Some(RunConfig { + plan.env.extend(config.env.clone()); + let mut build = RunConfig { id: format!("tomcat-build:{}", config.id), name: format!("Build {}", config.name), kind: if id.starts_with("maven:") { "maven" } else { "gradle" }.into(), - command: plan.command, - args: plan.args, - cwd: Some(std::path::PathBuf::from(plan.cwd).into()), - env, + command: plan.command.clone(), + args: vec![], + cwd: Some(std::path::PathBuf::from(&plan.cwd).into()), + env: plan.env.clone(), detected: false, is_test: false, debug_adapter: None, @@ -106,7 +105,14 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I env_files: config.extras.env_files, ..Default::default() }, - })) + }; + plan.resolve_project( + &project.root, + &ide_run::resolved_env(&project.root, &build)?, + &ide_core::CancellationToken::new(), + )?; + build.args = plan.args; + Ok(Some(build)) }) .await .map_err(|e| IdeError::internal(e.to_string()))? diff --git a/crates/ide-host/src/ops/search.rs b/crates/ide-host/src/ops/search.rs index 40122b0a0..ac2de1168 100644 --- a/crates/ide-host/src/ops/search.rs +++ b/crates/ide-host/src/ops/search.rs @@ -75,7 +75,7 @@ pub async fn start( &query, Some(&filter), Some(&literals), - &|p| documents.text_of(p), + &|p| documents.search_text_of(p), &cancel, &|mut file| { if dir != &root { diff --git a/crates/ide-host/src/ops/syntax.rs b/crates/ide-host/src/ops/syntax.rs index 027444b97..d4b5033ce 100644 --- a/crates/ide-host/src/ops/syntax.rs +++ b/crates/ide-host/src/ops/syntax.rs @@ -124,7 +124,7 @@ pub async fn syntax_identifiers_in_files( let snap = project.documents.text_snapshot(doc).ok()?; return (!snap.large).then_some((snap.language, Some((doc, snap.version)), snap.text)); } - let text = ide_vfs::load_text(path).ok()?.text; + let text = project.text_of(path).ok()?; let first_line = text.lines().next(); Some((languages.detect(path, first_line), None, text)) }; diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 5eaa490ab..754f15348 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -187,10 +187,13 @@ pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option) - } /// Gutter change markers for a file, computed against its current (unsaved) text. -pub fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResult> { +pub async fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResult> { let project = host.project(window)?; - let text = project.text_of(&path)?; - project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?)) + blocking(move || { + let text = project.text_of(&path)?; + project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?)) + }) + .await } pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option) -> IdeResult> { @@ -210,8 +213,8 @@ pub async fn vcs_blame( ) -> IdeResult> { let project = host.project(window)?; // An explicit text wins (merge view annotates its side panes, not the worktree). - let text = text.or_else(|| project.documents.text_of(&path)); blocking(move || { + let text = text.or_else(|| project.documents.text_of(&path)); project.with_repo_of(&path, |r| { r.blame(&path, text.as_deref(), revision.as_deref(), project.vcs_encoding(&path, revision.as_deref())?) }) @@ -510,14 +513,17 @@ pub fn vcs_diff_hunks(_host: &Host, _window: &str, old_text: String, new_text: S /// Stages only the selected hunks of a file's working-tree changes. /// Indices refer to `vcs_diff_hunks(index_text, worktree_text)`. -pub fn vcs_stage_hunks(host: &Host, window: &str, path: PathBuf, hunks: Vec) -> IdeResult<()> { +pub async fn vcs_stage_hunks(host: &Host, window: &str, path: PathBuf, hunks: Vec) -> IdeResult<()> { let project = host.project(window)?; - let encoding = project.vcs_encoding(&path, Some(":0"))?; - let current = ide_vfs::load_text_as(&path, encoding.name())?.text; - project.with_repo_of(&path, |r| { - let selected: Vec = hunks.iter().map(|&h| h as usize).collect(); - r.stage_hunks(&path, ¤t, &selected, encoding) + blocking(move || { + let encoding = project.vcs_encoding(&path, Some(":0"))?; + let current = project.documents.load_file_as(&path, encoding.charset.name())?.text; + project.with_repo_of(&path, |r| { + let selected: Vec = hunks.iter().map(|&h| h as usize).collect(); + r.stage_hunks(&path, ¤t, &selected, encoding) + }) }) + .await } /// Clones a repository (Get from VCS). Works without an open project. @@ -806,17 +812,25 @@ pub fn vcs_shelves(host: &Host, window: &str) -> IdeResult IdeResult { +pub async fn vcs_unshelve( + host: &Host, + window: &str, + id: String, + remove: bool, + path: Option, +) -> IdeResult { let project = host.project(window)?; blocking(move || { - project.with_repo_recorded(None, Some("Unshelve"), |r| r.unshelve(&shelf_dir(&project.root), &id, remove)) + project.with_repo_recorded(None, Some("Unshelve"), |r| { + ide_vcs::shelf::unshelve_path(r, &shelf_dir(&project.root), &id, remove, path.as_deref()) + }) }) .await } -pub fn vcs_shelf_delete(host: &Host, window: &str, id: String) -> IdeResult<()> { +pub fn vcs_shelf_delete(host: &Host, window: &str, id: String, path: Option) -> IdeResult<()> { let project = host.project(window)?; - ide_vcs::shelf::delete(&shelf_dir(&project.root), &id) + ide_vcs::shelf::delete_path(&shelf_dir(&project.root), &id, path.as_deref()) } pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) -> IdeResult<()> { @@ -824,9 +838,19 @@ pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) -> ide_vcs::shelf::rename(&shelf_dir(&project.root), &id, &name) } -pub fn vcs_shelf_patch(host: &Host, window: &str, id: String) -> IdeResult { +pub fn vcs_shelf_patch(host: &Host, window: &str, id: String, path: Option) -> IdeResult { let project = host.project(window)?; - ide_vcs::shelf::patch_of(&shelf_dir(&project.root), &id) + ide_vcs::shelf::selected_patch(&shelf_dir(&project.root), &id, path.as_deref()) +} + +pub async fn vcs_shelf_diff(host: &Host, window: &str, id: String) -> IdeResult> { + let project = host.project(window)?; + blocking(move || project.with_repo(|r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id))).await +} + +pub async fn vcs_shelf_import(host: &Host, window: &str, name: String, patch: String) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch)).await } pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult { @@ -856,7 +880,7 @@ pub async fn vcs_write_patch(_host: &Host, _window: &str, path: PathBuf, patch: pub(crate) fn register(d: &mut Dispatcher) { d.add("vcs_roots", |c| async move { vcs_roots(&c.host, &c.window) }); d.add("vcs_branch_info", |c| async move { vcs_branch_info(&c.host, &c.window, c.arg("repo")?).await }); - d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?) }); + d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?).await }); d.add("vcs_file_at", |c| async move { vcs_file_at(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await }); d.add("vcs_blame", |c| async move { vcs_blame(&c.host, &c.window, c.arg("path")?, c.arg("text")?, c.arg("revision")?).await @@ -940,7 +964,10 @@ pub(crate) fn register(d: &mut Dispatcher) { "vcs_diff_hunks", |c| async move { Ok(vcs_diff_hunks(&c.host, &c.window, c.arg("oldText")?, c.arg("newText")?)) }, ); - d.add("vcs_stage_hunks", |c| async move { vcs_stage_hunks(&c.host, &c.window, c.arg("path")?, c.arg("hunks")?) }); + d.add( + "vcs_stage_hunks", + |c| async move { vcs_stage_hunks(&c.host, &c.window, c.arg("path")?, c.arg("hunks")?).await }, + ); d.add("vcs_clone", |c| async move { vcs_clone(&c.host, &c.window, c.arg("request")?, c.arg("url")?, c.arg("dest")?, c.sender("onOutput")?).await }); @@ -1004,10 +1031,16 @@ pub(crate) fn register(d: &mut Dispatcher) { ); d.add("vcs_shelve", |c| async move { vcs_shelve(&c.host, &c.window, c.arg("name")?, c.arg("paths")?).await }); d.add("vcs_shelves", |c| async move { vcs_shelves(&c.host, &c.window) }); - d.add("vcs_unshelve", |c| async move { vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?).await }); - d.add("vcs_shelf_delete", |c| async move { vcs_shelf_delete(&c.host, &c.window, c.arg("id")?) }); + d.add("vcs_unshelve", |c| async move { + vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?, c.arg("path")?).await + }); + d.add("vcs_shelf_delete", |c| async move { vcs_shelf_delete(&c.host, &c.window, c.arg("id")?, c.arg("path")?) }); d.add("vcs_shelf_rename", |c| async move { vcs_shelf_rename(&c.host, &c.window, c.arg("id")?, c.arg("name")?) }); - d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?) }); + d.add("vcs_shelf_diff", |c| async move { vcs_shelf_diff(&c.host, &c.window, c.arg("id")?).await }); + d.add("vcs_shelf_import", |c| async move { + vcs_shelf_import(&c.host, &c.window, c.arg("name")?, c.arg("patch")?).await + }); + d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?, c.arg("path")?) }); d.add("vcs_changelists_load", |c| async move { vcs_changelists_load(&c.host, &c.window) }); d.add("vcs_changelists_save", |c| async move { vcs_changelists_save(&c.host, &c.window, c.arg("changelists")?) }); d.add( diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 8fadce6ae..1630411f5 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -689,7 +689,7 @@ impl Project { limit: q.limit, }; let filter = |rel: &str| q.languages.contains(&self.languages.detect(&root.join(rel), None)); - match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.text_of(p), cancel) { + match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.search_text_of(p), cancel) { Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit }, Err(e) => { tracing::debug!("reference candidates: {}", e.message); @@ -747,7 +747,17 @@ impl Project { } /// Use the open document's selected encoding, or the same detection/settings as opening it. - pub fn vcs_encoding(&self, path: &Path, rev: Option<&str>) -> IdeResult<&'static encoding_rs::Encoding> { + pub fn vcs_encoding(&self, path: &Path, rev: Option<&str>) -> IdeResult { + let charset = self.file_encoding(path, rev)?; + let encoding = ide_vcs::TextEncoding::from(charset); + Ok(if self.documents.native_to_ascii_for(path) { + encoding.with_ascii_conversion(ide_vfs::decode_properties_escapes, ide_vfs::encode_properties_ascii) + } else { + encoding + }) + } + + fn file_encoding(&self, path: &Path, rev: Option<&str>) -> IdeResult<&'static encoding_rs::Encoding> { if let Some(id) = self.documents.by_path(path) { let info = self.documents.snapshot(id)?; return encoding_rs::Encoding::for_label(info.encoding.as_bytes()) @@ -768,7 +778,7 @@ impl Project { pub fn text_of(&self, path: &Path) -> IdeResult { match self.documents.text_of(path) { Some(text) => Ok(text), - None => Ok(ide_vfs::load_text_with_fallback(path, self.documents.file_defaults().fallback_for(path))?.text), + None => Ok(ide_text::normalize_line_endings(&self.documents.load_file(path)?.text).into_owned()), } } } @@ -776,6 +786,8 @@ impl Project { /// Applies a multi-file edit through the document layer so open editors update live. pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> { use ide_core::FileEdit; + let mut edit = edit.clone(); + edit.deduplicate(); for file in &edit.files { match file { FileEdit::Edit { path, edits } => { @@ -1119,3 +1131,28 @@ mod index_event_tests { assert!(vcs_path_changed(&index)); } } + +#[cfg(all(test, unix))] +mod workspace_edit_tests { + use super::*; + use ide_core::{FileEdit, LanguageId, Pos, Range, TextEdit, WorkspaceEdit}; + + #[test] + fn rename_via_symlink_updates_the_open_document_once() { + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("main.cpp"); + let alias = dir.path().join("alias.cpp"); + std::fs::write(&real, "add();\n").unwrap(); + std::os::unix::fs::symlink(&real, &alias).unwrap(); + let documents = Documents::new(Arc::new(|_: IdeEvent| {}), Box::new(|_, _| LanguageId::new("cpp"))); + let doc = documents.open(&alias).unwrap(); + let changes = |path: PathBuf| FileEdit::Edit { + path: path.into(), + edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }], + }; + apply_workspace_edit(&documents, &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)] }) + .unwrap(); + assert_eq!(std::fs::read_to_string(real).unwrap(), "sum_values();\n"); + assert_eq!(documents.snapshot(doc.id).unwrap().text, "sum_values();\n"); + } +} diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index 56055d2d8..811a9c53c 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -148,6 +148,9 @@ impl Dispatcher { c.host.close_project(&c.window); Ok(()) }) + .add("jvm_file_presentations", |c| async move { + ops::jvm_icons::jvm_file_presentations(&c.host, &c.window, c.arg("paths")?).await + }) .add("project_roots", |c| async move { ops::project::roots(&c.host, &c.window) }) .add("settings_get", |c| async move { Ok(ops::project::settings(&c.host, &c.window)) }) .add("settings_set", |c| async move { @@ -186,6 +189,7 @@ impl Dispatcher { }) .add("doc_save", |c| async move { ops::doc::save(&c.host, &c.window, c.arg("doc")?).await }) .add("doc_save_all", |c| async move { ops::doc::save_all(&c.host, &c.window).await }) + .add("doc_sync_from_disk", |c| async move { ops::doc::sync_from_disk(&c.host, &c.window).await }) .add("doc_snapshot", |c| async move { ops::doc::snapshot(&c.host, &c.window, c.arg("doc")?).await }) .add("search_start", |c| async move { let on_event = c.channel("onEvent")?; diff --git a/crates/ide-host/tests/properties.rs b/crates/ide-host/tests/properties.rs new file mode 100644 index 000000000..a9760fe4c --- /dev/null +++ b/crates/ide-host/tests/properties.rs @@ -0,0 +1,184 @@ +use ide_core::{ChangeSet, NullSink, RequestId, TextChange}; +use ide_host::{ + Host, HostConfig, + ops::{doc, search, vcs}, +}; +use ide_search::{SearchQuery, SearchScope}; +use std::{ + path::{Path, PathBuf}, + process::Command, + sync::{Arc, Mutex}, + time::Duration, +}; + +fn git(root: &Path, args: &[&str]) { + let out = Command::new("git").current_dir(root).args(args).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); +} + +fn host(base: &Path) -> Host { + Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.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, + }) +} + +async fn find(host: &Host, paths: Vec, pattern: &str) -> Vec { + let files = Arc::new(Mutex::new(Vec::new())); + let found = files.clone(); + search::start( + host, + "main", + RequestId(519), + SearchQuery { + pattern: pattern.into(), + is_regex: false, + case_sensitive: true, + whole_word: false, + include: Vec::new(), + exclude: Vec::new(), + scope: SearchScope::Files { paths: paths.into_iter().map(Into::into).collect() }, + max_matches: 0, + context: Default::default(), + }, + Arc::new(move |event| { + if let search::SearchEvent::File { file } = event { + found.lock().unwrap().push(file); + } + }), + ) + .await + .unwrap(); + Arc::try_unwrap(files).unwrap().into_inner().unwrap() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn properties_search_git_and_document_coordinates_agree() { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + std::fs::create_dir(&root).unwrap(); + git(&root, &["init", "-b", "main"]); + git(&root, &["config", "user.name", "Test"]); + git(&root, &["config", "user.email", "test@example.com"]); + git(&root, &["config", "commit.gpgsign", "false"]); + git(&root, &["config", "core.autocrlf", "false"]); + let path = root.join("messages.properties"); + let original = "hello=\\u4f60\\u597d\\uD83D\\uDE00target\nsecond=unchanged\nescaped=line\\u000Alogical\n"; + std::fs::write(&path, original).unwrap(); + let host = host(&base); + let project = host.open_project("main", &root).unwrap(); + for _ in 0..500 { + if !project.repos().is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + git(&root, &["add", "."]); + git(&root, &["commit", "-m", "Properties"]); + let mut defaults = project.documents.file_defaults(); + defaults.native_to_ascii = true; + project.documents.set_file_defaults(defaults); + let displayed = "hello=你好😀target\nsecond=unchanged\nescaped=line\\u000Alogical\n"; + assert_eq!(project.text_of(&path).unwrap(), displayed); + let closed = find(&host, vec![path.clone()], "target").await; + let closed_json = serde_json::to_value(&closed).unwrap(); + let opened = doc::open(&host, "main", path.clone()).await.unwrap(); + assert_eq!(opened.text, displayed); + assert_eq!(serde_json::to_value(find(&host, vec![path.clone()], "target").await).unwrap(), closed_json); + assert_eq!(closed[0].matches[0].ranges, vec![(10, 16)]); + assert_eq!(find(&host, vec![path.clone()], "你好").await[0].matches.len(), 1); + assert!(find(&host, vec![path.clone()], r"\u4f60").await.is_empty()); + assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty()); + assert_eq!(vcs::vcs_file_at(&host, "main", path.clone(), None).await.unwrap().as_deref(), Some(displayed)); + assert!(vcs::vcs_blame(&host, "main", path.clone(), None, None).await.unwrap().iter().all(|l| l.author == "Test")); + doc::edit( + &host, + "main", + opened.id, + opened.version, + ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: "世界".into() }] }, + ) + .unwrap(); + let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap(); + assert_eq!(hunks.len(), 1); + assert_eq!(hunks[0].old_text, "hello=你好😀target\n"); + doc::save(&host, "main", opened.id).await.unwrap(); + let saved = std::fs::read(&path).unwrap(); + assert!(saved.is_ascii()); + assert!(String::from_utf8(saved.clone()).unwrap().contains(r"\u4F60\u597D\uD83D\uDE00\u4E16\u754C")); + vcs::vcs_stage_hunks(&host, "main", path.clone(), vec![0]).await.unwrap(); + let repo = ide_vcs::Repo::discover(&root).unwrap().unwrap(); + assert_eq!(repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), saved); + repo.unstage(std::slice::from_ref(&path)).unwrap(); + // Line staging must match the decoded base even when Git stores lowercase escapes. + let patch = ide_vcs::build_partial_patch("messages.properties", displayed, &project.text_of(&path).unwrap(), &[0]); + repo.stage_patch(&patch, &|p| project.vcs_encoding(p, Some(":0"))).unwrap(); + assert_eq!(repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), saved); + repo.unstage(std::slice::from_ref(&path)).unwrap(); + let snap = project.documents.snapshot(opened.id).unwrap(); + project.documents.replace_all(opened.id, displayed).unwrap(); + doc::save(&host, "main", opened.id).await.unwrap(); + 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"); + 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); + assert_eq!(hunks.len(), 2); + let selected = &hunks[0]; + let options = ide_vcs::CommitOptions { + partial: vec![ide_vcs::PartialCommitFile { + path: path.clone().into(), + hunks: vec![ide_vcs::HunkRange { + start: selected.start, + lines: selected.lines, + old_start: selected.old_start, + old_lines: selected.old_lines, + }], + }], + ..Default::default() + }; + repo.commit_with("Selected properties change", &options, &|p| project.vcs_encoding(p, None)).unwrap(); + let head = vcs::vcs_file_at(&host, "main", path.clone(), None).await.unwrap().unwrap(); + assert_eq!(head, displayed.replace("target", "世界")); + assert!(repo.file_bytes_at(&path, None).unwrap().unwrap().is_ascii()); + let closed_path = root.join("closed.properties"); + std::fs::write(&closed_path, r"value=\u4F60\u597D\uD83D\uDE00target").unwrap(); + let query = SearchQuery { + pattern: "target".into(), + is_regex: false, + case_sensitive: true, + whole_word: false, + include: Vec::new(), + exclude: Vec::new(), + scope: SearchScope::Files { paths: vec![closed_path.clone().into()] }, + max_matches: 0, + context: Default::default(), + }; + search::replace_in_files( + &host, + "main", + RequestId(520), + query, + "世界".into(), + false, + vec![ide_search::ReplaceTarget { + path: closed_path.clone().into(), + only: Some(vec![ide_core::Pos { line: 0, col: 10 }]), + }], + ide_host::ops::Channel::new(|_| {}), + ) + .await + .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()); + host.close_project("main"); +} diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index 3e195abfc..ca2bd49e6 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -81,6 +81,27 @@ 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}"); + // 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"); + std::fs::write(&java, "abstract class Foo {}").unwrap(); + std::fs::write(&kotlin, "object Singleton {}").unwrap(); + let icons = invoke(&peer, "jvm_file_presentations", json!({ "paths": [java, kotlin] })).await.unwrap(); + assert_eq!(icons[0]["presentation"]["kind"], "abstractClass"); + assert_eq!(icons[1]["presentation"]["kind"], "object"); + // Replacing a file invalidates its cached metadata, including same-size writes. + std::fs::write(&java, "interface Foo {} ").unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(&java) + .unwrap() + .set_modified(std::time::SystemTime::UNIX_EPOCH) + .unwrap(); + let icons = invoke(&peer, "jvm_file_presentations", json!({ "paths": [java] })).await.unwrap(); + assert_eq!(icons[0]["presentation"]["kind"], "interface"); + let oversized = invoke(&peer, "jvm_file_presentations", json!({ "paths": vec![java.clone(); 65] })).await; + assert!(oversized.is_err()); + // Edit and save a document. let file = project.join("src/main.py"); let doc = invoke(&peer, "doc_open", json!({ "path": file })).await.unwrap(); @@ -91,6 +112,14 @@ async fn serves_projects_files_documents_search_and_runs() { invoke(&peer, "doc_save", json!({ "doc": id })).await.unwrap(); assert_eq!(std::fs::read_to_string(&file).unwrap(), "print('needle')\nprint('more')\n"); + let outside = tmp.path().join("outside.txt"); + std::fs::write(&outside, "original\n").unwrap(); + let external = invoke(&peer, "doc_open", json!({ "path": outside })).await.unwrap(); + std::fs::write(&outside, "external change\n").unwrap(); + invoke(&peer, "doc_sync_from_disk", json!({})).await.unwrap(); + let snapshot = invoke(&peer, "doc_snapshot", json!({ "doc": external["id"] })).await.unwrap(); + assert_eq!(snapshot["text"], "external change\n"); + // Commands shared with the desktop shell (camelCase argument names, as Tauri takes them). invoke(&peer, "doc_set_line_ending", json!({ "doc": id, "lineEnding": "crLf" })).await.unwrap(); invoke(&peer, "doc_save", json!({ "doc": id })).await.unwrap(); @@ -170,3 +199,132 @@ impl EventType for EventMessage { serde_json::to_value(&self.event).unwrap()["type"].as_str().unwrap_or_default().to_owned() } } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn local_deployment_runs_on_the_project_host() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("project"); + let target = tmp.path().join("mounted"); + std::fs::create_dir(&project).unwrap(); + std::fs::create_dir(&target).unwrap(); + let project = dunce::canonicalize(project).unwrap(); + let file = project.join("local.txt"); + std::fs::write(&file, "host file").unwrap(); + let slot = Arc::new(OnceLock::new()); + let sink_slot = slot.clone(); + 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(move |w| rpc::event_sink(sink_slot.clone(), Some(w.to_owned()))), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + let dispatcher = Dispatcher::standard(); + for cmd in [ + "deploy_coverage", + "deploy_upload", + "deploy_download", + "deploy_delete_remote", + "deploy_compare", + "deploy_sync", + "deploy_info", + "deploy_server_root", + "deploy_test_connection", + "remote_list", + "remote_open", + "remote_of", + "remote_delete", + "remote_mkdir", + "remote_rename", + ] { + assert!(dispatcher.commands().contains(&cmd), "missing host command {cmd}"); + } + let (client_io, server_io) = tokio::io::duplex(1 << 20); + let (sr, sw) = tokio::io::split(server_io); + tokio::spawn(rpc::serve(host, dispatcher, slot, sr, sw)); + let (cr, cw) = tokio::io::split(client_io); + let peer = Peer::connect(cr, cw, Arc::new(Client::default())); + invoke(&peer, "open_project", json!({"path": project})).await.unwrap(); + let deployment = json!({"servers": [{"id":"local", "kind":"local", "rootPath":target}], "defaultServer":"local"}); + invoke(&peer, "settings_set", json!({"scope":"project", "key":"deployment", "value":deployment.to_string()})) + .await + .unwrap(); + let coverage = invoke(&peer, "deploy_coverage", json!({"paths":[file]})).await.unwrap(); + assert_eq!(coverage[0]["server"], "local"); + assert_eq!(coverage[0]["paths"], json!([ide_core::IpcPath::from(&file)])); + invoke(&peer, "deploy_test_connection", json!({"connection":{"kind":"local"}, "path":target})).await.unwrap(); + let report = invoke(&peer, "deploy_upload", json!({"paths":[file], "request":1001})).await.unwrap(); + assert_eq!(report["files"], 1, "{report}"); + assert_eq!(std::fs::read_to_string(target.join("local.txt")).unwrap(), "host file"); + let entries = invoke(&peer, "remote_list", json!({"connection":{"kind":"local"}, "path":target})).await.unwrap(); + assert_eq!(entries[0]["name"], "local.txt"); + std::fs::write(target.join("local.txt"), "host download").unwrap(); + let report = invoke(&peer, "deploy_download", json!({"paths":[file], "request":1002})).await.unwrap(); + assert_eq!(report["files"], 1); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "host download"); + invoke(&peer, "deploy_delete_remote", json!({"paths":[file]})).await.unwrap(); + assert!(!target.join("local.txt").exists()); + invoke(&peer, "close_project", json!({})).await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn properties_conversion_through_remote_dispatch_without_a_remote_host() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("properties-project"); + std::fs::create_dir(&root).unwrap(); + let path = root.join("messages.properties"); + std::fs::write(&path, r"value=\u4F60\u597D\uD83D\uDE00!").unwrap(); + let peer_slot = Arc::new(OnceLock::new()); + 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(ide_core::NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + let (client_io, server_io) = tokio::io::duplex(1 << 20); + let (sr, sw) = tokio::io::split(server_io); + tokio::spawn(rpc::serve(host, Dispatcher::standard(), peer_slot, sr, sw)); + let (cr, cw) = tokio::io::split(client_io); + let peer = Peer::connect(cr, cw, Arc::new(Client::default())); + invoke(&peer, "open_project", json!({"path":root})).await.unwrap(); + let settings = invoke(&peer, "settings_get", json!({})).await.unwrap(); + assert_eq!(settings["editor"]["encodings"]["nativeToAscii"], false); + let opened = invoke(&peer, "doc_open", json!({"path":path})).await.unwrap(); + let doc = opened["id"].clone(); + invoke(&peer, "settings_set", json!({"scope":"project", "key":"editor.encodings.nativeToAscii", "value":"true"})) + .await + .unwrap(); + let mut snap = Value::Null; + for _ in 0..500 { + snap = invoke(&peer, "doc_snapshot", json!({"doc":doc})).await.unwrap(); + if snap["text"] == "value=你好😀!" { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(snap["text"], "value=你好😀!"); + assert_eq!(snap["dirty"], false); + invoke( + &peer, + "doc_edit", + json!({"doc":doc, "baseVersion":snap["version"], + "changes":{"changes":[{"from":10,"to":11,"insert":"界"}]}}), + ) + .await + .unwrap(); + invoke(&peer, "doc_save", json!({"doc":doc})).await.unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), r"value=\u4F60\u597D\uD83D\uDE00\u754C"); + invoke(&peer, "doc_set_encoding", json!({"doc":doc,"encoding":"utf-8","reload":true})).await.unwrap(); + let reloaded = invoke(&peer, "doc_snapshot", json!({"doc":doc})).await.unwrap(); + assert_eq!(reloaded["text"], "value=你好😀界"); + assert_eq!(reloaded["dirty"], false); + invoke(&peer, "close_project", json!({})).await.unwrap(); + peer.kill(); +} diff --git a/crates/ide-host/tests/run_configs.rs b/crates/ide-host/tests/run_configs.rs index 4008b0e33..93d3ca5c4 100644 --- a/crates/ide-host/tests/run_configs.rs +++ b/crates/ide-host/tests/run_configs.rs @@ -77,3 +77,22 @@ async fn gradle_defaults_are_not_saved_as_explicit_run_choices() { assert_eq!(saved.env["JAVA_HOME"], "/explicit-jdk"); assert_eq!(saved.command, gradle.command); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + let member = root.join("native"); + std::fs::create_dir(&member).unwrap(); + std::fs::write(member.join("CMakeLists.txt"), "project(demo)").unwrap(); + let workspace = root.join("workspace"); + ide_workspace::workspace::Workspace::save(&workspace, "Native", vec![member.clone()], true).unwrap(); + let host = host(&root.join("app")); + host.open_project("main", &workspace).unwrap(); + let configs = ops::run::configs(&host, "main").unwrap(); + let build = configs.iter().find(|c| c.kind == "cmake" && c.args.first().is_some_and(|a| a == "--build")).unwrap(); + assert_eq!(build.cwd.as_deref(), Some(member.as_path())); + for task in &build.extras.before_launch { + assert!(configs.iter().any(|c| c.id == task.target && c.cwd == build.cwd)); + } +} diff --git a/crates/ide-host/tests/vcs_encoding.rs b/crates/ide-host/tests/vcs_encoding.rs index 65104008a..7bb1442ab 100644 --- a/crates/ide-host/tests/vcs_encoding.rs +++ b/crates/ide-host/tests/vcs_encoding.rs @@ -53,7 +53,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() { let mut defaults = project.documents.file_defaults(); defaults.project_fallback = "windows-1252".into(); project.documents.set_file_defaults(defaults); - assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap().is_empty()); + assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty()); let from = old.find("value = 1").unwrap() + "value = ".len(); let offset = old[..from].encode_utf16().count() as u32; project @@ -64,12 +64,12 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() { ChangeSet { changes: vec![TextChange { from: offset, to: offset + 1, insert: "2".into() }] }, ) .unwrap(); - let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap(); + let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap(); assert_eq!(hunks.len(), 1); assert_eq!((hunks[0].start, hunks[0].lines), (3, 1)); assert_eq!(hunks[0].old_text, " int value = 1;\n"); project.documents.save(doc.id).unwrap(); - vcs::vcs_stage_hunks(&host, "main", path.clone(), vec![0]).unwrap(); + vcs::vcs_stage_hunks(&host, "main", path.clone(), vec![0]).await.unwrap(); let repo = ide_vcs::Repo::discover(&root).unwrap().unwrap(); assert_eq!( repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), @@ -95,7 +95,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() { .unwrap(); project.documents.save(doc.id).unwrap(); assert_eq!(std::fs::read(&path).unwrap(), original); - assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap().is_empty()); + assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty()); assert!(vcs::vcs_blame(&host, "main", path.clone(), None, None).await.unwrap().iter().all(|l| l.author == "Test")); // Missing working files still use configured encoding for historical content. project.documents.close(doc.id); diff --git a/crates/ide-lsp/Cargo.toml b/crates/ide-lsp/Cargo.toml index a8d2605fc..8b011c795 100644 --- a/crates/ide-lsp/Cargo.toml +++ b/crates/ide-lsp/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] ide-core.workspace = true +dunce.workspace = true ide-text.workspace = true ide-language.workspace = true ide-jsonrpc.workspace = true diff --git a/crates/ide-lsp/src/convert.rs b/crates/ide-lsp/src/convert.rs index 08032f6ac..2e29724c1 100644 --- a/crates/ide-lsp/src/convert.rs +++ b/crates/ide-lsp/src/convert.rs @@ -661,7 +661,9 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit { } } } - WorkspaceEdit { files } + let mut edit = WorkspaceEdit { files }; + edit.deduplicate(); + edit } /// Adds the changes of `extra`, another server's edit for the same request, to @@ -710,6 +712,14 @@ pub fn goto_response(conv: &Conv, r: lsp::GotoDefinitionResponse) -> Vec) { + let mut canonical = std::collections::HashMap::new(); + for location in v.iter_mut().filter(|l| l.uri.is_none()) { + let path = + canonical.entry(location.path.clone()).or_insert_with(|| dunce::canonicalize(&location.path).ok()).clone(); + if let Some(path) = path { + location.path = path.into(); + } + } let mut seen = std::collections::HashSet::new(); v.retain(|l| seen.insert(l.clone())); } @@ -1405,3 +1415,24 @@ mod tests { assert!(document_link(&conv, here, link(None)).is_none()); } } + +#[cfg(all(test, unix))] +mod alias_tests { + use super::*; + + #[test] + fn references_to_a_file_and_its_symlink_are_one_location() { + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("main.cpp"); + let alias = dir.path().join("alias.cpp"); + std::fs::write(&real, "add()").unwrap(); + std::os::unix::fs::symlink(&real, &alias).unwrap(); + let mut locations: Vec = [real.clone(), alias] + .into_iter() + .map(|p| Location { path: p.into(), range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), uri: None }) + .collect(); + dedup_locations(&mut locations); + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].path.as_ref(), dunce::canonicalize(real).unwrap()); + } +} diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 24b9fa2db..1efe3be34 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -231,6 +231,9 @@ impl Handler for ClientHandler { } } "tsserver/request" => mgr.forward_tsserver_requests(self.server.clone(), self.generation, params), + "language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => { + mgr.java_import_ready(self.server.clone(), self.generation); + } // The Angular server's project loading, shown like `$/progress`; one // task while several projects load. "angular/projectLoadingStart" if !self.server.progress.lock().contains_key(ANGULAR_LOADING) => self diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index fa00ed9e5..3c8da6806 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -30,6 +30,55 @@ const MAX_LAUNCH_ERRORS: usize = 10; /// Build markers can be published just after `java/buildWorkspace` answers. const ERRORS_WAIT: Duration = Duration::from_secs(2); +const ACCESS_OPTIONS: [&str; 2] = [ + "org.eclipse.jdt.core.compiler.problem.forbiddenReference", + "org.eclipse.jdt.core.compiler.problem.discouragedReference", +]; + +fn compiler_defaults(cfg: &ide_language::LanguageServerConfig) -> Value { + // updateSettings persists project options. Only use it when jdtls redirects + // generated metadata into its workspace, and never touch an existing .settings. + let metadata = cfg.args.iter().rev().find_map(|a| { + a.strip_prefix("--jvm-arg=").unwrap_or(a).strip_prefix("-Djava.import.generatesMetadataFilesAtProjectRoot=") + }); + if metadata != Some("false") { + return json!({}); + } + let settings = crate::handler::all_settings(cfg); + let Some(url) = settings + .pointer("/java/settings/url") + .or_else(|| settings.pointer("/settings/java/settings/url")) + .and_then(Value::as_str) + else { + return json!({}); + }; + let path = crate::uri::uri_to_path(url).unwrap_or_else(|| PathBuf::from(url)); + let Ok(prefs) = std::fs::read_to_string(path) else { return json!({}) }; + let mut options = serde_json::Map::new(); + for line in prefs.lines() { + if let Some((key, value)) = line.split_once('=') + && ACCESS_OPTIONS.contains(&key.trim()) + && ["error", "warning", "info", "ignore"].contains(&value.trim()) + { + options.insert(key.trim().into(), json!(value.trim())); + } + } + Value::Object(options) +} + +fn cache_owned_maven_settings(root: &Path) -> bool { + root.join("pom.xml").is_file() && matches!(root.join(".settings").try_exists(), Ok(false)) +} + +fn project_path(uri: &str) -> Option { + // jdtls commands use Java's `file:/p` as well as LSP's `file:///p`. + let normalized = match uri.strip_prefix("file:") { + Some(rest) if !rest.starts_with("//") => format!("file://{rest}"), + _ => uri.to_owned(), + }; + crate::uri::uri_to_path(&normalized) +} + /// Result of `java/buildWorkspace` (jdtls `BuildWorkspaceStatus`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -128,14 +177,10 @@ pub fn parse_classpaths(v: &Value) -> JavaClasspath { .unwrap_or_default() }; JavaClasspath { - project_root: v.get("projectRoot").and_then(Value::as_str).map(|r| { - // jdtls answers `file:/p/demo` (Java URI style) as well as `file:///p/demo`. - let normalized = match r.strip_prefix("file:") { - Some(rest) if !rest.starts_with("//") => format!("file://{rest}"), - _ => r.to_owned(), - }; - crate::uri::uri_to_path(&normalized).unwrap_or_else(|| PathBuf::from(r)).into() - }), + project_root: v + .get("projectRoot") + .and_then(Value::as_str) + .map(|r| project_path(r).unwrap_or_else(|| PathBuf::from(r)).into()), class_paths: strings("classpaths"), module_paths: strings("modulepaths"), } @@ -176,6 +221,56 @@ async fn cancellable_wait(cancel: &CancellationToken, wait: impl std::future: } impl LspManager { + /// m2e generates a project-specific forbidden-reference error, overriding + /// workspace defaults. Apply those defaults only to cache-owned metadata. + async fn java_configure_compiler(&self, server: &Server, run: &Running) -> IdeResult { + let options = compiler_defaults(&server.effective_config()); + if options.as_object().is_none_or(|o| o.is_empty()) { + return Ok(false); + } + let command = |name: &str, arguments: Value| { + let params = json!({"command": name, "arguments": arguments}); + let peer = run.peer.clone(); + async move { + tokio::time::timeout( + COMMAND_TIMEOUT, + peer.request::<_, Value>("workspace/executeCommand", params, None), + ) + .await + .unwrap_or(Err(ide_jsonrpc::RpcError::Timeout)) + .map_err(|e| crate::rpc_err(SERVER, "workspace/executeCommand", e)) + } + }; + let projects = command("java.project.getAll", json!([])).await?; + let mut changed = false; + for uri in projects.as_array().into_iter().flatten().filter_map(Value::as_str) { + let Some(root) = project_path(uri) else { continue }; + // An existing .settings is owned by the project, even if it currently + // contains preferences for another Eclipse plugin. + if !cache_owned_maven_settings(&root) { + continue; + } + let keys: Vec<_> = options.as_object().unwrap().keys().collect(); + let current = command("java.project.getSettings", json!([uri, keys])).await?; + if options.as_object().unwrap().iter().all(|(k, v)| current.get(k) == Some(v)) { + continue; + } + command("java.project.updateSettings", json!([uri, options])).await?; + changed = true; + } + Ok(changed) + } + + pub(crate) fn java_import_ready(self: &Arc, server: Arc, generation: u64) { + let mgr = self.clone(); + self.runtime.spawn(async move { + let Some(run) = server.running_gen(generation) else { return }; + if let Err(e) = mgr.java_configure_compiler(&server, &run).await { + tracing::warn!("jdtls: cannot apply compiler defaults: {e}"); + } + }); + } + /// The jdtls instance serving `path` (a module directory or source file), /// started if needed and waited for. async fn jdtls(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<(Arc, Running)> { @@ -211,9 +306,12 @@ impl LspManager { timeout: Duration, cancel: &CancellationToken, ) -> IdeResult { + let started = Instant::now(); let (server, run) = self.jdtls(path, cancel).await?; + let ready_ms = started.elapsed().as_millis(); + let phase = params.get("command").and_then(Value::as_str).unwrap_or(method).to_owned(); let fut = run.peer.request::(method, params, Some(cancel.clone())); - match tokio::time::timeout(timeout, fut).await { + let result = match tokio::time::timeout(timeout, fut).await { Ok(r) => r.map_err(|e| crate::rpc_err(&server.name, method, e)), Err(_) => Err(IdeError::new( ErrorKind::Protocol, @@ -222,7 +320,15 @@ impl LspManager { timeout.as_secs() ), )), - } + }; + tracing::info!( + phase, + ready_ms, + request_ms = started.elapsed().as_millis() - ready_ms, + success = result.is_ok(), + "Java launch request" + ); + result } /// `workspace/executeCommand` on the jdtls serving `path`. @@ -288,7 +394,16 @@ impl LspManager { /// 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 v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).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?; + let changed = cancellable_wait(cancel, self.java_configure_compiler(&server, &run)).await??; + // ServiceReady can update the options concurrently with that first build. + let recheck = v.as_i64() == Some(2) + && compiler_defaults(&server.effective_config()).as_object().is_some_and(|o| !o.is_empty()); + if changed || recheck { + v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?; + } Ok(JavaBuildStatus::from_code(v.as_i64().unwrap_or(0))) } @@ -439,6 +554,79 @@ mod tests { assert_eq!(shown, [(4, 5, "a1"), (10, 5, "a2")]); } + #[test] + fn compiler_defaults_require_redirected_metadata_and_respect_server_settings() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("compiler defaults 测试.prefs"); + std::fs::write(&file, format!("{}=warning\n{}=ignore\nother=error\n", ACCESS_OPTIONS[0], ACCESS_OPTIONS[1])) + .unwrap(); + let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap(); + cfg.initialization_options = Some( + toml::Value::try_from(json!({"settings": {"java": {"settings": {"url": file.to_string_lossy()}}}})) + .unwrap(), + ); + assert_eq!(compiler_defaults(&cfg), json!({})); + for prefix in ["", "--jvm-arg="] { + cfg.args = vec![format!("{prefix}-Djava.import.generatesMetadataFilesAtProjectRoot=false")]; + assert_eq!(compiler_defaults(&cfg), json!({ACCESS_OPTIONS[0]: "warning", ACCESS_OPTIONS[1]: "ignore"})); + } + cfg.args.push("--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=true".into()); + assert_eq!(compiler_defaults(&cfg), json!({}), "the final JVM property wins"); + cfg.args.pop(); + cfg.settings = Some(json!({"java": {"settings": {"url": "https://example.invalid/custom.prefs"}}})); + assert_eq!(compiler_defaults(&cfg), json!({}), "explicit server settings win"); + } + + #[test] + fn project_settings_are_never_updated_on_disk() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + assert!(!cache_owned_maven_settings(root)); + std::fs::write(root.join("pom.xml"), "").unwrap(); + assert!(cache_owned_maven_settings(root)); + let lsp_uri = crate::uri::path_to_uri_string(root); + let java_uri = lsp_uri.replacen("file://", "file:", 1); + for uri in [lsp_uri, java_uri] { + assert_eq!(project_path(&uri).as_deref(), Some(root)); + assert!(cache_owned_maven_settings(&project_path(&uri).unwrap())); + } + std::fs::create_dir(root.join(".settings")).unwrap(); + assert!(!cache_owned_maven_settings(root), "even unrelated Eclipse settings belong to the project"); + let prefs = root.join(".settings/org.eclipse.jdt.core.prefs"); + std::fs::write(&prefs, format!("{}=error\n", ACCESS_OPTIONS[0])).unwrap(); + assert!(!cache_owned_maven_settings(root)); + assert_eq!(std::fs::read_to_string(prefs).unwrap(), format!("{}=error\n", ACCESS_OPTIONS[0])); + } + + #[test] + fn access_warnings_do_not_hide_real_compile_errors() { + let path = PathBuf::from("src/Main.java"); + let restriction = + "Access restriction: The type 'BASE64Decoder' is not accessible (restriction on required library rt.jar)"; + let diagnostic = |severity, message: &str| lsp_types::Diagnostic { + severity: Some(severity), + message: message.into(), + ..Default::default() + }; + let warnings: Vec<_> = [restriction, "The type 'BASE64Encoder' is discouraged"] + .into_iter() + .map(|message| (path.clone(), diagnostic(lsp_types::DiagnosticSeverity::WARNING, message))) + .collect(); + assert_eq!(compile_errors(warnings.clone(), 10), (Vec::new(), 0)); + let mut diagnostics = warnings; + diagnostics.push(( + path.clone(), + diagnostic(lsp_types::DiagnosticSeverity::ERROR, "Missing cannot be resolved to a type"), + )); + let (errors, count) = compile_errors(diagnostics, 10); + assert_eq!(count, 1); + assert_eq!(errors[0].message, "Missing cannot be resolved to a type"); + // A project's explicit error severity must still be respected. + let (_, count) = + compile_errors(vec![(path, diagnostic(lsp_types::DiagnosticSeverity::ERROR, restriction))], 10); + assert_eq!(count, 1); + } + #[test] fn classpaths() { let v = json!({ diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 784675a34..5604023cc 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -90,6 +90,8 @@ struct Attached { struct DocState { path: PathBuf, + /// `path` with links resolved, to recognise a document under another name. + canonical: PathBuf, uri: lsp::Uri, language: LanguageId, version: i32, @@ -685,6 +687,11 @@ impl LspManager { if let Some(t) = self.docs.lock().values().find(|d| d.path == path).map(|d| d.text.clone()) { return Some(t); } + if let Ok(key) = dunce::canonicalize(path) + && let Some(text) = self.docs.lock().values().find(|d| d.canonical == key).map(|d| d.text.clone()) + { + return Some(text); + } let key = path.to_str().filter(|s| is_virtual_uri(s))?; self.virtual_docs.lock().get(key).map(|d| Arc::new(DocText::new(&d.text))) } @@ -730,8 +737,10 @@ impl LspManager { text: &str, servers: Vec>, ) { + let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone()); let mut state = DocState { path, + canonical, uri, language: language.clone(), version: version as i32, diff --git a/crates/ide-lsp/src/scope.rs b/crates/ide-lsp/src/scope.rs index 237f33f16..bac2188f6 100644 --- a/crates/ide-lsp/src/scope.rs +++ b/crates/ide-lsp/src/scope.rs @@ -167,23 +167,27 @@ impl LspManager { /// readable as text are left out and flagged on the guard. async fn open_transient(&self, t: &Target, files: Vec, cancel: &CancellationToken) -> TransientFiles<'_> { // The server already sees the user's files, however large. - let open: HashSet = self.docs.lock().values().map(|d| d.path.clone()).collect(); + let open: HashSet = self.docs.lock().values().map(|d| d.canonical.clone()).collect(); let (read, skipped) = { let cancel = cancel.clone(); tokio::task::spawn_blocking(move || { let mut read = Vec::new(); let mut skipped = false; - for f in files.into_iter().filter(|f| !open.contains(f)) { + for f in files { if cancel.is_cancelled() { break; } + let canonical = dunce::canonicalize(&f).unwrap_or_else(|_| f.clone()); + if open.contains(&canonical) { + continue; + } // A file gone since the search no longer mentions anything. let Ok(meta) = std::fs::metadata(&f) else { continue }; if !meta.is_file() { continue; } match (meta.len() <= MAX_CANDIDATE_BYTES).then(|| std::fs::read_to_string(&f)) { - Some(Ok(text)) => read.push((f, text)), + Some(Ok(text)) => read.push((f, canonical, text)), _ => skipped = true, } } @@ -198,8 +202,8 @@ impl LspManager { return guard; } let docs = self.docs.lock(); - for (path, text) in read { - if docs.values().any(|d| d.path == path) { + for (path, canonical, text) in read { + if docs.values().any(|d| d.canonical == canonical) { continue; } let uri = crate::uri::path_to_uri(&path); diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 0e5df1e47..95aac5643 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -235,7 +235,8 @@ fn harness_configured( adjust: impl Fn(&mut LanguageServerConfig), ) -> Harness { let dir = tempfile::tempdir().unwrap(); - let root = dir.path().to_path_buf(); + // Edits and references report canonical paths. + let root = dunce::canonicalize(dir.path()).unwrap(); let lang = LanguageConfig { id: LanguageId::new(lang_id), name: "Mock".into(), diff --git a/crates/ide-remote/src/deploy.rs b/crates/ide-remote/src/deploy.rs index 81887a01d..a9197e40f 100644 --- a/crates/ide-remote/src/deploy.rs +++ b/crates/ide-remote/src/deploy.rs @@ -1,5 +1,5 @@ //! Upload, download, delete and synchronise project files with a deployment -//! server over SFTP. +//! server through a shared filesystem interface. use crate::mapping::{Excludes, Mappings, posix}; use ide_core::{CancellationToken, IdeError, IdeResult}; @@ -10,6 +10,45 @@ use specta::Type; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +/// Mapping eligibility uses a common synthetic server root: mapping and exclusion +/// paths are both relative to that root, so checking never opens a connection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentCoverage { + pub server: String, + pub paths: Vec, + pub skipped: Vec, +} + +pub fn coverage(settings: &ide_core::settings::Settings, root: &Path, paths: &[PathBuf]) -> Vec { + settings + .deployment + .servers + .iter() + .map(|server| { + let map = Mappings::new(server, root, "/"); + let exclude = Excludes::new(&settings.deployment, server, "/"); + let mut result = DeploymentCoverage { server: server.id.clone(), paths: Vec::new(), skipped: Vec::new() }; + for path in paths { + let name = ide_core::ipc_path::to_ipc(path); + match mapped_path(&map, &exclude, path) { + Ok(_) => result.paths.push(name), + Err(message) => result.skipped.push(TransferError { path: name, message: message.into() }), + } + } + result + }) + .collect() +} + +fn mapped_path(map: &Mappings, exclude: &Excludes, local: &Path) -> Result { + let remote = map.remote(local).ok_or("Not covered by this server's mappings")?; + if exclude.local(local.strip_prefix(map.project_root()).unwrap_or(local)) || exclude.remote(&remote) { + return Err("Excluded from deployment"); + } + Ok(remote) +} + /// Modification times closer than this count as equal (FAT, rounding by servers). const MTIME_SLACK: u32 = 2; @@ -96,7 +135,7 @@ pub fn status(local: Option, remote: Option) -> SyncStatus } } -/// Operations on one server through one SFTP session. +/// Operations on one server through its filesystem. pub struct Deployment<'a> { pub fs: &'a RemoteFs, pub map: &'a Mappings, @@ -171,7 +210,7 @@ impl Deployment<'_> { break; } let rel = self.rel(local); - let Some(remote) = self.map.remote(local).filter(|r| !self.exclude.remote(r)) else { + let Some(remote) = mapped_path(self.map, self.exclude, local).ok() else { report.skipped += 1; continue; }; @@ -193,7 +232,7 @@ impl Deployment<'_> { let mut report = TransferReport::default(); let mut jobs: Vec<(String, PathBuf)> = Vec::new(); for p in paths { - let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r)) else { + let Some(remote) = mapped_path(self.map, self.exclude, p).ok() else { report.skipped += 1; continue; }; @@ -239,7 +278,7 @@ impl Deployment<'_> { let mut report = TransferReport::default(); for p in paths { // Never a whole mapping (the project folder went away, or a mapped folder did). - let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r) && !self.map.is_root(r)) else { + let Some(remote) = mapped_path(self.map, self.exclude, p).ok().filter(|r| !self.map.is_root(r)) else { report.skipped += 1; continue; }; @@ -257,12 +296,12 @@ impl Deployment<'_> { /// Sync with Deployed: local and remote files under `local` side by side. pub async fn compare(&self, local: &Path) -> IdeResult> { - let remote_root = - self.map.remote(local).ok_or_else(|| IdeError::invalid(format!("{} is not mapped", local.display())))?; + let remote_root = mapped_path(self.map, self.exclude, local) + .map_err(|reason| IdeError::invalid(format!("{}: {reason}", local.display())))?; let mut by_rel: BTreeMap = BTreeMap::new(); let mut ignored = TransferReport::default(); for f in self.local_files(&[local.to_path_buf()], &mut ignored) { - let Some(remote_path) = self.map.remote(&f) else { continue }; + let Ok(remote_path) = mapped_path(self.map, self.exclude, &f) else { continue }; let size = std::fs::metadata(&f).map(|m| m.len()).unwrap_or(0); let stamp = FileStamp { size, mtime: mtime_of(&f) }; by_rel.insert( @@ -319,9 +358,7 @@ impl Deployment<'_> { break; } let local = step.path.split('/').filter(|s| !s.is_empty()).fold(root.clone(), |p, s| p.join(s)); - let Some(remote) = - self.map.remote(&local).filter(|r| !self.exclude.remote(r) && !self.excluded_local(&local)) - else { + let Some(remote) = mapped_path(self.map, self.exclude, &local).ok() else { report.skipped += 1; continue; }; diff --git a/crates/ide-remote/src/lib.rs b/crates/ide-remote/src/lib.rs index 09af9f62e..2bdd2dbb4 100644 --- a/crates/ide-remote/src/lib.rs +++ b/crates/ide-remote/src/lib.rs @@ -6,7 +6,9 @@ pub mod mapping; pub mod service; pub mod target; -pub use deploy::{Deployment, SyncAction, SyncEntry, SyncStatus, SyncStep, TransferReport}; +pub use deploy::{ + Deployment, DeploymentCoverage, SyncAction, SyncEntry, SyncStatus, SyncStep, TransferReport, coverage, +}; pub use mapping::{Excludes, Mappings}; pub use service::{DeployedPath, RemoteService}; pub use target::{RemoteRuns, RemoteStart}; diff --git a/crates/ide-remote/src/service.rs b/crates/ide-remote/src/service.rs index 9be7444b5..12b682936 100644 --- a/crates/ide-remote/src/service.rs +++ b/crates/ide-remote/src/service.rs @@ -1,14 +1,14 @@ -//! The deployment and run-target operations of one app, over the shared SSH -//! connections: what the IPC commands and the save hook call. +//! Deployment over SFTP or local folders, and SSH run targets. +//! Shared by IPC commands and project save/delete hooks. use crate::deploy::{Deployment, SyncEntry, SyncStep, TransferReport}; use crate::mapping::{Excludes, Mappings}; use crate::target::{RemoteRuns, RemoteStart, SyncFn}; -use ide_core::remote_settings::{AutoUpload, DeploymentServer, RunTarget}; +use ide_core::remote_settings::{AutoUpload, DeploymentServer, DeploymentType, RunTarget}; use ide_core::settings::Settings; use ide_core::{CancellationToken, IdeError, IdeResult, SharedSink}; use ide_run::{OutputChunk, RunConfig, RunInfo}; -use ide_ssh::{RemoteEntry, RemoteFs, SshManager}; +use ide_ssh::{FsConnection, RemoteEntry, RemoteFs, SshManager}; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use specta::Type; @@ -22,7 +22,7 @@ pub type Progress<'a> = &'a mut (dyn FnMut(&str, usize, usize) + Send); /// A remote file opened for editing (a local copy uploaded back on save). #[derive(Debug, Clone)] struct RemoteEdit { - ssh_config: String, + connection: FsConnection, remote: String, } @@ -37,7 +37,7 @@ pub struct DeployedPath { pub struct RemoteService { ssh: Arc, - /// Local copies of remote files: `//`. + /// Local copies of remote files, separated by filesystem connection. cache: PathBuf, edits: Mutex>, pub runs: RemoteRuns, @@ -71,12 +71,24 @@ impl RemoteService { Self { ssh, cache, edits: Mutex::default(), runs: RemoteRuns::new() } } - async fn fs(&self, ssh_config: &str) -> IdeResult { - if ssh_config.is_empty() { - return Err(IdeError::invalid("the deployment server has no SSH configuration")); + async fn fs(&self, connection: &FsConnection) -> IdeResult { + match connection { + FsConnection::Local => Ok(RemoteFs::Local), + FsConnection::Sftp { ssh } => { + if ssh.is_empty() { + return Err(IdeError::invalid("the deployment server has no SSH configuration")); + } + let session = self.ssh.session(ssh).await?; + Ok(RemoteFs::new(session.sftp().await?)) + } } - let session = self.ssh.session(ssh_config).await?; - Ok(RemoteFs::new(session.sftp().await?)) + } + + pub async fn test_connection(&self, connection: &FsConnection, path: &str) -> IdeResult<()> { + let fs = self.fs(connection).await?; + let result = fs.test(path).await; + fs.close().await; + result } /// Runs `f` with a [`Deployment`] on `server`. @@ -88,7 +100,7 @@ impl RemoteService { cancel: CancellationToken, f: impl AsyncFnOnce(&Deployment<'_>) -> T, ) -> IdeResult { - let fs = self.fs(&server.ssh_config).await?; + let fs = self.fs(&connection(server)).await?; let remote_root = match fs.resolve(&server.root_path).await { Ok(r) => r, Err(e) => { @@ -198,54 +210,57 @@ impl RemoteService { /// The server root of `server_id`, resolved on the host. pub async fn server_root(&self, settings: &Settings, server_id: &str) -> IdeResult { let server = server(settings, Some(server_id))?; - let fs = self.fs(&server.ssh_config).await?; + let fs = self.fs(&connection(&server)).await?; let r = fs.resolve(&server.root_path).await; fs.close().await; r } - pub async fn list(&self, ssh_config: &str, path: &str) -> IdeResult> { - let fs = self.fs(ssh_config).await?; + pub async fn list(&self, connection: &FsConnection, path: &str) -> IdeResult> { + let fs = self.fs(connection).await?; let r = async { fs.list(&fs.resolve(path).await?).await }.await; fs.close().await; r } /// Downloads a remote file for editing; saving the copy uploads it back. - pub async fn open_remote(&self, ssh_config: &str, remote: &str) -> IdeResult { - let rel = remote.split('/').filter(|s| !s.is_empty() && *s != "..").fold(PathBuf::new(), |p, s| p.join(s)); - let local = self.cache.join(sanitize(ssh_config)).join(rel); - let fs = self.fs(ssh_config).await?; + pub async fn open_remote(&self, connection: &FsConnection, remote: &str) -> IdeResult { + let rel = remote + .split('/') + .filter(|s| !s.is_empty() && *s != ".." && *s != ".") + .fold(PathBuf::new(), |p, s| p.join(s.replace('%', "%25").replace(':', "%3A").replace('\\', "%5C"))); + let local = self.cache.join(sanitize(&cache_key(connection))).join(rel); + let fs = self.fs(connection).await?; let r = fs.download(remote, &local).await; fs.close().await; r?; self.edits .lock() - .insert(local.clone(), RemoteEdit { ssh_config: ssh_config.to_owned(), remote: remote.to_owned() }); + .insert(local.clone(), RemoteEdit { connection: connection.clone(), remote: remote.to_owned() }); Ok(local) } /// The remote file a local copy stands for. - pub fn remote_of(&self, local: &Path) -> Option<(String, String)> { - self.edits.lock().get(local).map(|e| (e.ssh_config.clone(), e.remote.clone())) + pub fn remote_of(&self, local: &Path) -> Option<(FsConnection, String)> { + self.edits.lock().get(local).map(|e| (e.connection.clone(), e.remote.clone())) } - pub async fn remote_delete(&self, ssh_config: &str, remote: &str) -> IdeResult<()> { - let fs = self.fs(ssh_config).await?; + pub async fn remote_delete(&self, connection: &FsConnection, remote: &str) -> IdeResult<()> { + let fs = self.fs(connection).await?; let r = fs.remove(remote).await; fs.close().await; r } - pub async fn remote_mkdir(&self, ssh_config: &str, remote: &str) -> IdeResult<()> { - let fs = self.fs(ssh_config).await?; + pub async fn remote_mkdir(&self, connection: &FsConnection, remote: &str) -> IdeResult<()> { + let fs = self.fs(connection).await?; let r = fs.mkdir_all(remote).await; fs.close().await; r } - pub async fn remote_rename(&self, ssh_config: &str, from: &str, to: &str) -> IdeResult<()> { - let fs = self.fs(ssh_config).await?; + pub async fn remote_rename(&self, connection: &FsConnection, from: &str, to: &str) -> IdeResult<()> { + let fs = self.fs(connection).await?; let r = fs.rename(from, to).await; fs.close().await; r @@ -366,3 +381,18 @@ impl RemoteService { fn sanitize(s: &str) -> String { s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect() } + +/// A server's transport, also used by the browser and remote editor save hook. +pub fn connection(server: &DeploymentServer) -> FsConnection { + match server.kind { + DeploymentType::Local => FsConnection::Local, + DeploymentType::Sftp => FsConnection::Sftp { ssh: server.ssh_config.clone() }, + } +} + +fn cache_key(connection: &FsConnection) -> String { + match connection { + FsConnection::Local => "local".to_owned(), + FsConnection::Sftp { ssh } => format!("sftp-{ssh}"), + } +} diff --git a/crates/ide-remote/tests/local.rs b/crates/ide-remote/tests/local.rs new file mode 100644 index 000000000..91aee56fe --- /dev/null +++ b/crates/ide-remote/tests/local.rs @@ -0,0 +1,264 @@ +//! Local deployment uses the service entry points used by SFTP and save hooks. +use ide_core::CancellationToken; +use ide_core::remote_settings::{AutoUpload, DeploymentMapping, DeploymentServer, DeploymentType}; +use ide_core::settings::Settings; +use ide_remote::{RemoteService, SyncAction, SyncStatus, SyncStep}; +use ide_ssh::{FsConnection, SshFiles, SshManager}; +use std::path::Path; +use std::sync::Arc; + +fn service(root: &Path) -> RemoteService { + RemoteService::new( + Arc::new(SshManager::with_files( + root.join("secrets"), + SshFiles { known_hosts: root.join("known_hosts"), config: None }, + )), + root.join("cache"), + ) +} + +fn settings(target: &Path) -> Settings { + let mut s = Settings::default(); + s.deployment.servers.push(DeploymentServer { + id: "local".into(), + kind: DeploymentType::Local, + root_path: target.to_string_lossy().into_owned(), + mappings: vec![DeploymentMapping { deployment: "app".into(), ..Default::default() }], + excluded_paths: vec!["app/private".into()], + ..Default::default() + }); + s.deployment.default_server = "local".into(); + s.deployment.auto_upload = AutoUpload::Always; + s.deployment.delete_remote = true; + s +} + +#[tokio::test] +async fn local_deployment_and_browser_share_all_paths() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + let target = tmp.path().join("mounted"); + std::fs::create_dir_all(root.join("src/nested")).unwrap(); + std::fs::create_dir_all(root.join(".git")).unwrap(); + std::fs::create_dir_all(root.join("private")).unwrap(); + std::fs::create_dir(&target).unwrap(); + std::fs::write(root.join("src/nested/a.txt"), "initial").unwrap(); + std::fs::write(root.join(".git/config"), "excluded").unwrap(); + std::fs::write(root.join("private/key"), "excluded remote path").unwrap(); + let service = service(tmp.path()); + let settings = settings(&target); + let cancel = CancellationToken::new(); + service.test_connection(&FsConnection::Local, target.to_str().unwrap()).await.unwrap(); + let report = service + .upload(&settings, &root, None, std::slice::from_ref(&root), cancel.clone(), &mut |_, _, _| {}) + .await + .unwrap(); + assert_eq!(report.files, 1, "{report:?}"); + assert!(report.errors.is_empty(), "{report:?}"); + assert!(!target.join("app/.git").exists()); + assert!(!target.join("app/private").exists()); + assert_eq!( + ide_ssh::sftp::mtime_of(&root.join("src/nested/a.txt")), + ide_ssh::sftp::mtime_of(&target.join("app/src/nested/a.txt")) + ); + let compared = service.compare(&settings, &root, None, &root, cancel.clone()).await.unwrap(); + assert_eq!(compared.iter().find(|e| e.path == "src/nested/a.txt").unwrap().status, SyncStatus::Same); + std::fs::write(root.join("src/nested/a.txt"), "saved").unwrap(); + let saved = service.on_saved(&settings, &root, &root.join("src/nested/a.txt")).await.unwrap().unwrap(); + assert!(saved.errors.is_empty()); + let remote_file = target.join("app/src/nested/a.txt"); + assert_eq!(std::fs::read_to_string(&remote_file).unwrap(), "saved"); + std::fs::write(&remote_file, "downloaded").unwrap(); + let report = + service.download(&settings, &root, None, &[root.join("src")], cancel.clone(), &mut |_, _, _| {}).await.unwrap(); + assert!(report.errors.is_empty()); + assert_eq!(std::fs::read_to_string(root.join("src/nested/a.txt")).unwrap(), "downloaded"); + let connection = FsConnection::Local; + let entries = service.list(&connection, target.join("app/src/nested").to_str().unwrap()).await.unwrap(); + assert_eq!(entries[0].name, "a.txt"); + let cached = service.open_remote(&connection, remote_file.to_str().unwrap()).await.unwrap(); + std::fs::write(&cached, "remote edit").unwrap(); + assert!(service.on_saved(&settings, &root, &cached).await.unwrap().unwrap().errors.is_empty()); + assert_eq!(std::fs::read_to_string(&remote_file).unwrap(), "remote edit"); + service.remote_mkdir(&connection, target.join("browser/new/nested").to_str().unwrap()).await.unwrap(); + service + .remote_rename(&connection, target.join("browser").to_str().unwrap(), target.join("renamed").to_str().unwrap()) + .await + .unwrap(); + service.remote_delete(&connection, target.join("renamed").to_str().unwrap()).await.unwrap(); + assert!(!target.join("renamed").exists()); + let report = service + .sync( + &settings, + &root, + None, + &[SyncStep { path: "src/nested/a.txt".into(), action: SyncAction::Download }], + cancel, + &mut |_, _, _| {}, + ) + .await + .unwrap(); + assert_eq!(report.files, 1); + std::fs::remove_file(root.join("src/nested/a.txt")).unwrap(); + assert!( + service + .on_deleted(&settings, &root, &[root.join("src/nested/a.txt")]) + .await + .unwrap() + .unwrap() + .errors + .is_empty() + ); + assert!(!remote_file.exists()); +} + +#[tokio::test] +async fn self_upload_download_and_aliases_never_destroy_content() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let service = service(root); + let mut settings = settings(root); + settings.deployment.servers[0].mappings[0].deployment.clear(); + let file = root.join("file"); + std::fs::write(&file, "keep me").unwrap(); + for download in [false, true] { + let report = if download { + service + .download( + &settings, + root, + None, + std::slice::from_ref(&file), + CancellationToken::new(), + &mut |_, _, _| {}, + ) + .await + } else { + service + .upload(&settings, root, None, std::slice::from_ref(&file), CancellationToken::new(), &mut |_, _, _| {}) + .await + } + .unwrap(); + assert!(report.errors[0].message.contains("same file")); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "keep me"); + } + #[cfg(unix)] + { + let alias = root.join("alias"); + std::fs::hard_link(&file, &alias).unwrap(); + assert!( + ide_ssh::RemoteFs::Local + .upload(&file, alias.to_str().unwrap(), true) + .await + .unwrap_err() + .message + .contains("same file") + ); + std::fs::remove_file(&alias).unwrap(); + std::os::unix::fs::symlink(&file, &alias).unwrap(); + assert!( + ide_ssh::RemoteFs::Local + .upload(&file, alias.to_str().unwrap(), true) + .await + .unwrap_err() + .message + .contains("same file") + ); + ide_ssh::RemoteFs::Local.remove(alias.to_str().unwrap()).await.unwrap(); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "keep me"); + } +} + +#[cfg(unix)] +#[tokio::test] +async fn missing_non_directory_and_unwritable_targets_are_clear() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let service = service(tmp.path()); + let target = tmp.path().join("target"); + assert!( + service + .test_connection(&FsConnection::Local, target.to_str().unwrap()) + .await + .unwrap_err() + .message + .contains("does not exist") + ); + std::fs::write(&target, "file").unwrap(); + assert!( + service + .test_connection(&FsConnection::Local, target.to_str().unwrap()) + .await + .unwrap_err() + .message + .contains("Not a folder") + ); + std::fs::remove_file(&target).unwrap(); + std::fs::create_dir(&target).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o555)).unwrap(); + let result = service.test_connection(&FsConnection::Local, target.to_str().unwrap()).await; + let file = tmp.path().join("source"); + std::fs::write(&file, "data").unwrap(); + let mut settings = settings(&target); + settings.deployment.servers[0].mappings[0].deployment.clear(); + let report = service + .upload(&settings, tmp.path(), None, &[file], CancellationToken::new(), &mut |_, _, _| {}) + .await + .unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(result.unwrap_err().message.contains("not writable")); + assert_eq!(report.errors.len(), 1, "{report:?}"); +} + +#[tokio::test] +async fn coverage_matches_transfers_without_connecting() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + let target = tmp.path().join("target"); + for dir in ["src", "src-other", "assets", "src/private", "src/node_modules"] { + std::fs::create_dir_all(root.join(dir)).unwrap(); + } + let names = ["src/a.txt", "assets/b.txt", "src-other/c.txt", "src/private/key", "src/node_modules/pkg"]; + let paths: Vec<_> = names.iter().map(|name| root.join(name)).collect(); + for path in &paths { + std::fs::write(path, "content").unwrap(); + } + let mut settings = settings(&target); + settings.deployment.exclude = vec!["node_modules".into()]; + settings.deployment.servers[0].mappings[0].local = "src".into(); + settings.deployment.servers[0].excluded_paths = vec!["app/private".into()]; + let mut assets = settings.deployment.servers[0].clone(); + assets.id = "assets".into(); + assets.mappings[0].local = "assets".into(); + let mut offline = assets.clone(); + offline.id = "offline".into(); + offline.kind = DeploymentType::Sftp; + offline.ssh_config = "does-not-exist".into(); + settings.deployment.servers.extend([assets, offline]); + let coverage = ide_remote::coverage(&settings, &root, &paths); + assert_eq!(coverage[0].paths, vec![ide_core::ipc_path::to_ipc(&paths[0])]); + assert_eq!(coverage[1].paths, vec![ide_core::ipc_path::to_ipc(&paths[1])]); + assert_eq!(coverage[2].paths, coverage[1].paths); + assert_eq!(coverage[0].skipped.len(), 4); + assert!(coverage[0].skipped[0].message.contains("mappings")); + assert!(coverage[0].skipped[2].message.contains("Excluded")); + let service = service(tmp.path()); + let report = service + .upload(&settings, &root, Some("local"), &paths, CancellationToken::new(), &mut |_, _, _| {}) + .await + .unwrap(); + assert_eq!((report.files, report.skipped), (1, 4)); + let entries = + service.compare(&settings, &root, Some("local"), &root.join("src"), CancellationToken::new()).await.unwrap(); + assert_eq!(entries.len(), 1); + let report = service + .download(&settings, &root, Some("local"), &paths, CancellationToken::new(), &mut |_, _, _| {}) + .await + .unwrap(); + assert_eq!((report.files, report.skipped), (1, 4)); + let report = service.delete_remote(&settings, &root, Some("local"), &paths).await.unwrap(); + assert_eq!((report.files, report.skipped), (1, 4)); + settings.deployment.servers[0].mappings.clear(); + assert_eq!(ide_remote::coverage(&settings, &root, &[root.join("assets/b.txt")])[0].paths.len(), 1); + assert!(ide_remote::coverage(&settings, &root, &[root.join("../outside")])[0].paths.is_empty()); +} diff --git a/crates/ide-run/Cargo.toml b/crates/ide-run/Cargo.toml index 23df66031..1e362ef3a 100644 --- a/crates/ide-run/Cargo.toml +++ b/crates/ide-run/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] ide-core.workspace = true +dunce.workspace = true encoding_rs.workspace = true serde.workspace = true serde_json.workspace = true @@ -24,4 +25,5 @@ quick-xml = "0.42" libc = "0.2" [dev-dependencies] +ide-build = { path = "../ide-build" } tempfile.workspace = true diff --git a/crates/ide-run/src/config.rs b/crates/ide-run/src/config.rs index 7855f074f..63c6a35f3 100644 --- a/crates/ide-run/src/config.rs +++ b/crates/ide-run/src/config.rs @@ -340,7 +340,11 @@ fn write_array(path: &Path, key: &str, configs: &[&RunConfig], suppressed: Optio /// Keeps the workspace-local file out of version control (`.openidea/.gitignore`). fn ignore_local(root: &Path) -> IdeResult<()> { - let entry = format!("/{LOCAL_FILE}"); + ignore_private(root, &format!("/{LOCAL_FILE}")) +} + +/// Adds `entry` to `.openidea/.gitignore` unless it is already listed. +pub(crate) fn ignore_private(root: &Path, entry: &str) -> IdeResult<()> { let file = project_dir::path(root, ".gitignore"); let current = fs::read_to_string(&file).unwrap_or_default(); if current.lines().any(|l| l.trim() == entry) { @@ -352,7 +356,7 @@ fn ignore_local(root: &Path) -> IdeResult<()> { } else if !text.ends_with('\n') { text.push('\n'); } - text.push_str(&entry); + text.push_str(entry); text.push('\n'); if let Some(dir) = file.parent() { fs::create_dir_all(dir)?; diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index a56fec5d6..939dfc2d1 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -3,6 +3,8 @@ //! //! Detection never fails: unreadable or malformed files simply contribute nothing. +pub(crate) mod cmake; + use crate::RunConfig; use serde_json::Value as Json; use std::collections::{BTreeMap, HashSet}; @@ -22,6 +24,7 @@ pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec bool { } fn detect_go(root: &Path, out: &mut Vec) { + if let Some(work) = read(&root.join("go.work")) { + let mut seen = HashSet::new(); + for member in crate::go::work_modules(&work) { + let dir = root.join(member); + let dir = dir.canonicalize().unwrap_or(dir); + if dir.join("go.mod").is_file() && seen.insert(dir.clone()) { + let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + let rel = dir.strip_prefix(&canonical_root).unwrap_or(&dir).to_path_buf(); + detect_go_module(&dir, (!rel.as_os_str().is_empty()).then_some(rel), out); + } + } + return; + } if root.join("go.mod").is_file() { detect_go_module(root, None, out); return; diff --git a/crates/ide-run/src/detect/cmake.rs b/crates/ide-run/src/detect/cmake.rs new file mode 100644 index 000000000..2f4b69775 --- /dev/null +++ b/crates/ide-run/src/detect/cmake.rs @@ -0,0 +1,173 @@ +//! CMake targets come from the File API codemodel, including evaluated subdirectories. + +use super::{ConfigExt, config}; +use crate::{BeforeLaunchTask, RunConfig}; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +fn json(path: &Path) -> Option { + serde_json::from_slice(&fs::read(path).ok()?).ok() +} + +fn reply_file(reply: &Path, name: &str) -> Option { + // File API references are file names, never paths outside the reply directory. + (Path::new(name).components().count() == 1).then(|| json(&reply.join(name))).flatten() +} + +fn before(config: &mut RunConfig, id: &str) { + config.extras.before_launch.push(BeforeLaunchTask { kind: "run".into(), target: id.into() }); +} + +pub(super) fn detect(root: &Path, out: &mut Vec) { + if !root.join("CMakeLists.txt").is_file() { + return; + } + let mut builds = Vec::new(); + if let Ok(entries) = fs::read_dir(root) { + builds.extend(entries.flatten().filter_map(|e| { + let p = e.path(); + (p.is_dir() && p.join("CMakeCache.txt").is_file()).then_some(p) + })); + } + if builds.is_empty() { + builds.push(root.join("build")); + } + builds.sort(); + builds.dedup(); + for build in builds { + let dir = build.strip_prefix(root).unwrap_or(&build).to_string_lossy().replace('\\', "/"); + let configuration = fs::read_to_string(build.join("CMakeCache.txt")) + .ok() + .and_then(|cache| { + let values = cache.lines().find_map(|line| line.strip_prefix("CMAKE_CONFIGURATION_TYPES:STRING="))?; + Some(if values.split(';').any(|c| c == "Debug") { + "Debug".to_owned() + } else { + values.split(';').find(|s| !s.is_empty())?.to_owned() + }) + }) + .unwrap_or_else(|| "Debug".into()); + let prefix = format!("cmake:{dir}"); + let configure_id = format!("{prefix}:configure"); + let build_id = format!("{prefix}:build"); + let mut args = vec!["-S", ".", "-B", &dir, "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"]; + if !build.join("CMakeCache.txt").is_file() { + args.push("-DCMAKE_BUILD_TYPE=Debug"); + } + let mut configure = config(&configure_id, format!("CMake Configure ({dir})"), "cmake", "cmake", &args); + configure.extras.params.insert("buildDir".into(), dir.clone()); + out.push(configure); + let mut all = config( + &build_id, + format!("CMake Build ({dir})"), + "cmake", + "cmake", + &["--build", &dir, "--config", &configuration], + ); + // `cmake --build` regenerates by itself once a build directory is configured. + if !build.join("CMakeCache.txt").is_file() { + before(&mut all, &configure_id); + } + out.push(all); + let mut test = config( + format!("{prefix}:test"), + format!("CTest ({dir})"), + "cmake", + "ctest", + &["--test-dir", &dir, "-C", &configuration, "--output-on-failure"], + ); + before(&mut test, &build_id); + out.push(test); + targets(root, &build, &dir, &prefix, out); + } +} + +fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec) { + let reply = build.join(".cmake/api/v1/reply"); + let Some(index_path) = fs::read_dir(&reply).ok().and_then(|entries| { + entries + .flatten() + .map(|e| e.path()) + .filter(|p| { + let name = p.file_name().unwrap_or_default().to_string_lossy(); + name.starts_with("index-") && name.ends_with(".json") + }) + .max() + }) else { + return; + }; + let Some(index) = json(&index_path) else { return }; + let Some(reference) = index["objects"] + .as_array() + .and_then(|a| a.iter().find(|o| o["kind"] == "codemodel" && o["version"]["major"] == 2)) + else { + return; + }; + let Some(model) = reference["jsonFile"].as_str().and_then(|n| reply_file(&reply, n)) else { return }; + let Some(configurations) = model["configurations"].as_array() else { return }; + let source_root = model["paths"]["source"].as_str().map(PathBuf::from).unwrap_or_else(|| root.into()); + if source_root.canonicalize().ok() != root.canonicalize().ok() { + return; + } + let source_root = dunce::canonicalize(&source_root).unwrap_or(source_root); + for configuration in configurations { + let name = configuration["name"].as_str().unwrap_or(""); + let Some(targets) = configuration["targets"].as_array() else { continue }; + for target in targets { + let Some(target) = target["jsonFile"].as_str().and_then(|n| reply_file(&reply, n)) else { continue }; + if target["type"] != "EXECUTABLE" { + continue; + } + let (Some(target_name), Some(artifact)) = + (target["name"].as_str(), target["artifacts"][0]["path"].as_str()) + else { + continue; + }; + let id = format!("{prefix}:{name}:{target_name}"); + let target_build_id = format!("{id}:build"); + let mut args = vec!["--build", dir, "--target", target_name]; + if !name.is_empty() { + args.extend(["--config", name]); + } + let build_config = + config(&target_build_id, format!("Build {target_name} ({dir} {name})"), "cmake", "cmake", &args); + out.push(build_config); + let binary = build.join(artifact); + let mut run = + config(&id, format!("{target_name} ({dir} {name})"), "native", &binary.to_string_lossy(), &[]) + .debug("lldb-dap"); + before(&mut run, &target_build_id); + let sources: Vec = target["sources"] + .as_array() + .into_iter() + .flatten() + .filter_map(|s| { + let p = source_root.join(s["path"].as_str()?); + Some(p.to_string_lossy().replace('\\', "/")) + }) + .collect(); + run.extras.params.insert("cmakeSources".into(), sources.join("\n")); + if let Some(recorded) = model["paths"]["source"].as_str() { + run.extras.params.insert("cmakeSourceRoot".into(), recorded.into()); + run.extras + .params + .insert("cmakeCanonicalSourceRoot".into(), source_root.to_string_lossy().replace('\\', "/")); + } + out.push(run); + } + } +} + +/// Request the codemodel only when the user runs a configure configuration. +pub(crate) fn prepare(config: &RunConfig, cwd: &Path) -> crate::IdeResult<()> { + if config.kind == "cmake" + && config.args.first().is_some_and(|a| a == "-S") + && let Some(dir) = config.extras.params.get("buildDir") + { + let query = cwd.join(dir).join(".cmake/api/v1/query/client-openidea"); + fs::create_dir_all(&query)?; + fs::write(query.join("codemodel-v2"), "")?; + } + Ok(()) +} diff --git a/crates/ide-run/src/go.rs b/crates/ide-run/src/go.rs new file mode 100644 index 000000000..96169a682 --- /dev/null +++ b/crates/ide-run/src/go.rs @@ -0,0 +1,292 @@ +//! Typed Go configurations use the process service for both build and execution. +use crate::RunConfig; +use ide_core::{IdeError, IdeResult}; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +pub struct Plan { + pub build: RunConfig, + pub run: Option, +} + +pub fn plan(root: &Path, config: &RunConfig) -> IdeResult> { + if config.kind != "go" || !config.extras.params.contains_key("goKind") { + return Ok(None); + } + let p = &config.extras.params; + let get = |key: &str| p.get(key).map(String::as_str).unwrap_or(""); + let module = root.join(if get("module").is_empty() { "." } else { get("module") }); + let mut build = config.clone(); + build.cwd = Some(module.clone().into()); + let test = get("goKind") == "test"; + if get("scope") == "directory" { + let target = module.join(get("target")); + build.cwd = Some(target.into()); + if let Some(arg) = build.args.iter_mut().skip(1).find(|a| a.as_str() == get("target")) { + *arg = ".".into(); + } + } + if test { + return Ok(Some(Plan { build, run: None })); + } + let output = if get("outputDir").is_empty() { + crate::config::ignore_private(root, "/bin/")?; + ide_core::project_dir::path(root, "bin") + } else { + root.join(get("outputDir")) + }; + std::fs::create_dir_all(&output)?; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + config.id.hash(&mut hash); + let mut binary = output.join(format!("go-app-{:x}{}", hash.finish(), std::env::consts::EXE_SUFFIX)); + // An explicit Go -o selects the executable just as it does on the command line. + let mut i = 1; + while i < build.args.len() { + if build.args[i] == "-o" { + if let Some(path) = build.args.get(i + 1) { + binary = module.join(path); + } + build.args.drain(i..(i + 2).min(build.args.len())); + } else if let Some(path) = build.args[i].strip_prefix("-o=") { + binary = module.join(path); + build.args.remove(i); + } else { + i += 1; + } + } + if binary.is_dir() || binary.to_string_lossy().ends_with(['/', '\\']) { + let target = Path::new(get("target").split('|').next().unwrap_or(".")); + let name = target.file_stem().or_else(|| module.file_name()).unwrap_or_default().to_string_lossy(); + binary = binary.join(format!("{name}{}", std::env::consts::EXE_SUFFIX)); + } + if let Some(dir) = binary.parent() { + std::fs::create_dir_all(dir)?; + } + build.args.splice(1..1, ["-o".into(), binary.to_string_lossy().into_owned()]); + let run = if get("runAfterBuild") == "true" { + let mut run = config.clone(); + run.command = binary.to_string_lossy().into_owned(); + // Program args are stored separately as an argv vector by the form. + run.args = p + .get("programArgv") + .map(|s| serde_json::from_str(s)) + .transpose() + .map_err(|e| IdeError::invalid(e.to_string()))? + .unwrap_or_default(); + run.extras.params.clear(); + Some(run) + } else { + None + }; + Ok(Some(Plan { build, run })) +} + +/// Uses Go's own build-file selection, including platform suffixes, cgo and build tags. +/// `-find` avoids dependency traversal and package downloads for this local directory. +pub fn package_sources(mut command: Command, dir: &Path, flags: &[String]) -> IdeResult> { + command.args(["list", "-find", "-e", "-json"]); + let mut it = flags.iter(); + while let Some(arg) = it.next() { + let name = arg.split('=').next().unwrap_or(arg); + if ["-tags", "-compiler", "-mod", "-modfile", "-overlay"].contains(&name) { + command.arg(arg); + if name == arg + && let Some(value) = it.next() + { + command.arg(value); + } + } else if ["-race", "-msan", "-asan"].contains(&name) { + command.arg(arg); + } + } + command.arg(".").current_dir(dir).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = command.spawn()?; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let reader = std::thread::spawn(move || { + use std::io::Read; + let mut text = String::new(); + if let Some(mut stdout) = stdout { + let _ = stdout.read_to_string(&mut text); + } + text + }); + let errors = std::thread::spawn(move || { + use std::io::Read; + let mut text = String::new(); + if let Some(mut stderr) = stderr { + let _ = stderr.read_to_string(&mut text); + } + text + }); + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + match child.try_wait()? { + Some(status) => break status, + None if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)), + None => { + let _ = child.kill(); + let _ = child.wait(); + return Err(IdeError::unavailable("Go package discovery timed out")); + } + } + }; + let text = reader.join().unwrap_or_default(); + let errors = errors.join().unwrap_or_default(); + if !status.success() { + return Err(IdeError::invalid(format!("Go package discovery failed: {errors}"))); + } + let package: serde_json::Value = serde_json::from_str(&text).map_err(|e| IdeError::invalid(e.to_string()))?; + Ok(["GoFiles", "CgoFiles"] + .iter() + .flat_map(|key| package[key].as_array().into_iter().flatten()) + .filter_map(|p| p.as_str()) + .map(|p| dir.join(p)) + .collect()) +} + +pub fn prepare_test_file(root: &Path, config: &mut RunConfig) -> IdeResult<()> { + let p = &config.extras.params; + if config.kind != "go" || p.get("goKind").is_none_or(|s| s != "test") || p.get("scope").is_none_or(|s| s != "file") + { + return Ok(()); + } + let target = p.get("target").ok_or_else(|| IdeError::invalid("Go test file target is missing"))?; + let file = root.join(p.get("module").map(String::as_str).unwrap_or(".")).join(target); + let dir = file.parent().ok_or_else(|| IdeError::invalid("Go test file has no directory"))?; + let mut command = Command::new(&config.command); + crate::process::configure_environment(&mut command, root, config)?; + let mut files = package_sources(command, dir, &config.args)?; + files.push(file); + let at = config + .args + .iter() + .position(|a| a == target) + .ok_or_else(|| IdeError::invalid("Go test file target is missing"))?; + config.args.splice(at..=at, files.iter().map(|p| p.to_string_lossy().into_owned())); + Ok(()) +} + +/// Module directories from a go.work file. Quoted paths and comments follow Go's file syntax. +pub fn work_modules(text: &str) -> Vec { + let mut tokens = Vec::new(); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '\n' | '(' | ')' => tokens.push(ch.to_string()), + ch if ch.is_whitespace() => {} + '/' if chars.peek() == Some(&'/') => { + for c in chars.by_ref() { + if c == '\n' { + tokens.push("\n".into()); + break; + } + } + } + '"' | '`' => { + let Some(token) = quoted_work_path(&mut chars, ch) else { return Vec::new() }; + tokens.push(token); + } + _ => { + let mut token = ch.to_string(); + while chars.peek().is_some_and(|c| !c.is_whitespace() && *c != '(' && *c != ')') { + token.push(chars.next().unwrap()); + } + tokens.push(token); + } + } + } + let mut out = Vec::new(); + let mut start = true; + let mut tokens = tokens.into_iter(); + while let Some(token) = tokens.next() { + if token == "\n" { + start = true; + continue; + } + if start + && token == "use" + && let Some(path) = tokens.next() + { + if path == "(" { + let mut paths = Vec::new(); + let mut closed = false; + for path in tokens.by_ref() { + if path == ")" { + closed = true; + break; + } + if path != "\n" && !path.is_empty() { + paths.push(PathBuf::from(path)); + } + } + if closed { + out.extend(paths); + } + } else if path != "\n" && !path.is_empty() { + out.push(PathBuf::from(path)); + } + } + start = false; + } + let mut seen = std::collections::HashSet::new(); + out.retain(|path| seen.insert(path.clone())); + out +} + +fn quoted_work_path(chars: &mut std::iter::Peekable>, quote: char) -> Option { + let mut bytes = Vec::new(); + while let Some(ch) = chars.next() { + if ch == quote { + return String::from_utf8(bytes).ok(); + } + if ch == '\\' && quote == '"' { + let escaped = chars.next()?; + let byte = match escaped { + 'a' => 7, + 'b' => 8, + 'f' => 12, + 'n' => 10, + 'r' => 13, + 't' => 9, + 'v' => 11, + '\\' => b'\\', + '"' => b'"', + 'x' => { + let digits: String = chars.by_ref().take(2).collect(); + if digits.len() != 2 { + return None; + } + u8::from_str_radix(&digits, 16).ok()? + } + 'u' | 'U' => { + let width = if escaped == 'u' { 4 } else { 8 }; + let digits: String = chars.by_ref().take(width).collect(); + if digits.len() != width { + return None; + } + let ch = char::from_u32(u32::from_str_radix(&digits, 16).ok()?)?; + bytes.extend_from_slice(ch.to_string().as_bytes()); + continue; + } + '0'..='7' => { + let digits = format!("{escaped}{}", chars.by_ref().take(2).collect::()); + if digits.len() != 3 { + return None; + } + u8::from_str_radix(&digits, 8).ok()? + } + _ => return None, + }; + bytes.push(byte); + } else { + if ch == '\n' && quote == '"' { + return None; + } + bytes.extend_from_slice(ch.to_string().as_bytes()); + } + } + None +} diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index b32ec8b53..db94a4586 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -3,6 +3,7 @@ mod config; mod detect; pub mod envfile; +pub mod go; pub mod gradle_jvm; pub mod java_cmdline; pub mod junit; @@ -240,6 +241,11 @@ pub fn system_env() -> BTreeMap { ide_core::process::host_vars().filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))).collect() } +/// Complete environment for a preparatory tool belonging to this run configuration. +pub fn resolved_env(root: &Path, config: &RunConfig) -> IdeResult> { + process::resolved_env(root, config) +} + /// Path of the user run configuration file for `root`. pub fn user_configs_path(root: &Path) -> PathBuf { config::config_path(root) diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 6fbf8b58c..1179c9688 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -49,7 +49,7 @@ struct Entry { id: RunId, config_id: String, name: String, - pid: Option, + pid: Mutex>, stdin: Mutex>>, /// The PTY master of a terminal-emulated run (resize; dropped on exit). pty: Option>>>, @@ -65,7 +65,7 @@ impl Entry { id: self.id, config_id: self.config_id.clone(), name: self.name.clone(), - pid: self.pid, + pid: *self.pid.lock(), running: !self.exited.load(Ordering::Acquire), pty: self.pty.is_some(), } @@ -299,10 +299,17 @@ impl ProcessManager { if config.command.trim().is_empty() { return Err(IdeError::invalid("run configuration has no command")); } + let plan = crate::go::plan(root, config)?; + let followup = plan.as_ref().and_then(|p| p.run.as_ref()).map(|run| { + let cwd = resolve_cwd(root, run.cwd.as_deref()); + (run.clone(), cwd) + }); + let config = plan.as_ref().map_or(config, |p| &p.build); let cwd = resolve_cwd(root, config.cwd.as_deref()); if !cwd.is_dir() { return Err(IdeError::not_found(format!("working directory does not exist: {}", cwd.display()))); } + crate::detect::cmake::prepare(config, &cwd)?; let mut inv = invocation(config, &cwd); let extras = &config.extras; if extras.emulate_terminal && extras.stdin_file.is_some() { @@ -326,6 +333,9 @@ impl ProcessManager { let notice = if extras.emulate_terminal { None } else { match_console_encoding(&mut inv, &mut env, config, options) }; let temp = shorten_java(&mut inv, config, &cwd)?; + if extras.emulate_terminal && followup.is_some() { + return Err(IdeError::invalid("Go build and run requires the output console")); + } if extras.emulate_terminal { return self.start_pty(config, &inv, &cwd, &env, stop, options, sink, on_output, temp); } @@ -377,7 +387,7 @@ impl ProcessManager { id, config_id: config.id.clone(), name: config.name.clone(), - pid: Some(child.id()), + pid: Mutex::new(Some(child.id())), stdin: Mutex::new(stdin), pty: None, stop, @@ -399,7 +409,7 @@ impl ProcessManager { if let Some(err) = stderr { spawn_reader(id, Stream::Stderr, err, tx.clone())?; } - spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), notice)?; + spawn_pump(rx, on_output.clone(), entry.clone(), sink, options.encoding.clone(), notice)?; Ok(()) })(); if let Err(e) = result { @@ -410,10 +420,17 @@ impl ProcessManager { } // The waiter owns the child; it must exist for the pump to ever finish. let waiter_tx = tx; + let waiter_entry = entry.clone(); + let run_root = root.to_path_buf(); std::thread::Builder::new() .name(format!("openidea-run-wait-{}", id.0)) .spawn(move || { - let status = child.wait(); + let mut status = child.wait(); + if status.as_ref().is_ok_and(ExitStatus::success) + && let Some((run, cwd)) = followup + { + status = run_followup(&run_root, &run, &cwd, &waiter_entry, &waiter_tx); + } drop(temp); let _ = waiter_tx.send(Msg::Exited(Exit::Pipes(status))); }) @@ -479,7 +496,7 @@ impl ProcessManager { id, config_id: config.id.clone(), name: config.name.clone(), - pid: child.process_id(), + pid: Mutex::new(child.process_id()), stdin: Mutex::new(Some(writer)), pty: Some(Mutex::new(Some(pair.master))), stop, @@ -578,8 +595,9 @@ impl ProcessManager { if entry.exited.load(Ordering::Acquire) { return Ok(()); } - let Some(pid) = entry.pid else { return Ok(()) }; - if entry.stopping.swap(true, Ordering::AcqRel) { + let already_stopping = entry.stopping.swap(true, Ordering::AcqRel); + let Some(pid) = *entry.pid.lock() else { return Ok(()) }; + if already_stopping { // Second stop request: don't wait out the grace period. kill_tree(pid, true); return Ok(()); @@ -624,8 +642,9 @@ impl ProcessManager { /// Kills every running process tree at once (shutting down). pub fn kill_all(&self) { for entry in self.runs.lock().values() { + entry.stopping.store(true, Ordering::Release); if !entry.exited.load(Ordering::Acquire) - && let Some(pid) = entry.pid + && let Some(pid) = *entry.pid.lock() { kill_tree(pid, true); } @@ -641,6 +660,67 @@ impl Drop for ProcessManager { /// The environment a configuration's process gets on top of the inherited one: `.env` /// files, then the configured variables (later wins). +fn run_followup( + root: &Path, + config: &RunConfig, + cwd: &Path, + entry: &Entry, + tx: &Sender, +) -> std::io::Result { + let mut pid = entry.pid.lock(); + if entry.stopping.load(Ordering::Acquire) { + return Err(std::io::Error::other("run stopped after build")); + } + let inv = invocation(config, cwd); + let mut cmd = Command::new(&inv.program); + cmd.args(&inv.args).current_dir(cwd).stdout(Stdio::piped()).stderr(Stdio::piped()); + match config.extras.stdin_file.as_deref() { + Some(file) => { + cmd.stdin(std::fs::File::open(resolve_cwd(root, Some(Path::new(file))))?); + } + None => { + cmd.stdin(Stdio::piped()); + } + } + let env = launch_env(root, config).map_err(|e| std::io::Error::other(e.to_string()))?; + apply_env(&mut cmd, &env); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0000_0200 | ide_core::process::CREATE_NO_WINDOW); + } + (entry.output)(OutputChunk::System { text: format!("{}\n", inv.display) }); + let mut child = cmd.spawn()?; + *pid = Some(child.id()); + *entry.stdin.lock() = child.stdin.take().map(|p| Box::new(p) as Box); + drop(pid); + let readers = (|| { + if let Some(out) = child.stdout.take() { + spawn_reader(entry.id, Stream::Stdout, out, tx.clone())?; + } + if let Some(err) = child.stderr.take() { + spawn_reader(entry.id, Stream::Stderr, err, tx.clone())?; + } + Ok::<_, std::io::Error>(()) + })(); + if let Err(e) = readers { + let _ = child.kill(); + let _ = child.wait(); + return Err(e); + } + child.wait() +} + +pub(crate) fn configure_environment(command: &mut Command, root: &Path, config: &RunConfig) -> IdeResult<()> { + apply_env(command, &launch_env(root, config)?); + Ok(()) +} + fn launch_env(root: &Path, config: &RunConfig) -> IdeResult { let extras = &config.extras; let mut vars = if extras.env_files.is_empty() { @@ -661,6 +741,18 @@ fn launch_env(root: &Path, config: &RunConfig) -> IdeResult { Ok(LaunchEnv { clear: !extras.include_system_env, vars }) } +pub(crate) fn resolved_env(root: &Path, config: &RunConfig) -> IdeResult> { + let env = launch_env(root, config)?; + let mut vars = if env.clear { BTreeMap::new() } else { crate::system_env() }; + if !env.clear { + vars.extend( + ide_core::process::shell_env().filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))), + ); + } + vars.extend(env.vars); + Ok(vars) +} + fn apply_env(cmd: &mut Command, env: &LaunchEnv) { if env.clear { cmd.env_clear(); diff --git a/crates/ide-run/src/tomcat.rs b/crates/ide-run/src/tomcat.rs index 0f452e0bc..dffb377a4 100644 --- a/crates/ide-run/src/tomcat.rs +++ b/crates/ide-run/src/tomcat.rs @@ -216,7 +216,7 @@ pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only: .map_err(|_| invalid("Build the WAR artifact before launching Tomcat"))?; let war = PathBuf::from(war.trim()); if !war.is_file() || !war.extension().is_some_and(|e| e.eq_ignore_ascii_case("war")) { - return Err(invalid("The built WAR artifact does not exist")); + return Err(invalid(format!("The built WAR artifact does not exist: {}", war.display()))); } let dest = base.join("deployment"); explode(&war, &dest, resources_only)?; @@ -227,11 +227,14 @@ pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only: .get("deploymentPath") .filter(|v| !v.trim().is_empty()) .ok_or_else(|| invalid("Choose a WAR file or exploded directory"))?; - let path = root.join(value).canonicalize()?; + let path = root.join(value); + let path = path + .canonicalize() + .map_err(|e| invalid(format!("Invalid Tomcat deployment: {} ({e})", path.display())))?; if (kind == "war" && (!path.is_file() || !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("war")))) || (kind == "directory" && !path.is_dir()) { - return Err(invalid("Invalid Tomcat deployment")); + return Err(invalid(format!("Invalid Tomcat deployment: {}", path.display()))); } Ok(path) } @@ -436,6 +439,28 @@ mod tests { assert!(deployment(tmp.path(), &base, &c, false).unwrap().ends_with("WebContent")); } #[test] + fn deployment_errors_identify_the_checked_path() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path().join("cache"); + fs::create_dir_all(&base).unwrap(); + for path in ["missing.war", r"C:\工作\timesheet\target\timesheet.war", r"\\server\share\timesheet.war"] { + fs::write(base.join("artifact-path.txt"), format!("{path}\r\n")).unwrap(); + let error = deployment(tmp.path(), &base, &config(), false).unwrap_err().to_string(); + assert!(error.contains(path), "{error}"); + } + for kind in ["war", "directory"] { + let mut c = config(); + c.extras.params.insert("deployment".into(), kind.into()); + c.extras.params.insert("deploymentPath".into(), "missing".into()); + let error = deployment(tmp.path(), &base, &c, false).unwrap_err().to_string(); + assert!(error.contains(&tmp.path().join("missing").display().to_string()), "{error}"); + c.extras.params.insert("deploymentPath".into(), "cache".into()); + if kind == "war" { + assert!(deployment(tmp.path(), &base, &c, false).unwrap_err().to_string().contains("cache")); + } + } + } + #[test] fn prepares_isolated_base_and_jdwp_only_for_run() { let tmp = tempfile::tempdir().unwrap(); let home = tmp.path().join("Tomcat Home"); diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 31a672a3f..d0ed4e184 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -550,3 +550,90 @@ fn root_package_disables_nested_scan() { assert!(ids(&c).contains(&"go:test")); assert!(!ids(&c).contains(&"go:test:svc")); } + +#[test] +fn cmake_detection_is_read_only_and_preserves_existing_build_directories() { + let dir = project(&[ + ("CMakeLists.txt", "project(demo)"), + ("cmake-build-debug/CMakeCache.txt", "CMAKE_CONFIGURATION_TYPES:STRING=Release;RelWithDebInfo\n"), + ]); + let configs = detect(dir.path()); + let configure = find(&configs, "cmake:cmake-build-debug:configure"); + assert_eq!(configure.args, ["-S", ".", "-B", "cmake-build-debug", "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"]); + assert!(!dir.path().join("build").exists()); + assert!(!dir.path().join("cmake-build-debug/.cmake").exists()); + let build = find(&configs, "cmake:cmake-build-debug:build"); + assert!(build.extras.before_launch.is_empty(), "a configured build regenerates by itself"); + assert!(build.args.windows(2).any(|a| a == ["--config", "Release"])); + let test = find(&configs, "cmake:cmake-build-debug:test"); + assert_eq!(test.extras.before_launch[0].target, build.id); +} + +#[test] +fn cmake_configures_before_the_first_build() { + let dir = project(&[("CMakeLists.txt", "project(demo)")]); + let configs = detect(dir.path()); + let build = find(&configs, "cmake:build:build"); + assert_eq!(build.extras.before_launch[0].target, "cmake:build:configure"); +} + +#[test] +fn cmake_ignores_malformed_file_api_replies() { + let dir = project(&[ + ("CMakeLists.txt", "project(demo)"), + ("build/CMakeCache.txt", ""), + ("build/.cmake/api/v1/reply/index-1.json", "not JSON"), + ]); + let configs = detect(dir.path()); + assert_eq!(configs.iter().filter(|c| c.kind == "cmake").count(), 3); + assert!(!configs.iter().any(|c| c.kind == "native")); +} + +#[test] +fn go_work_members_define_configs_including_deep_and_quoted_directories() { + let dir = project(&[ + ( + "go.work", + "go 1.22\n// use ./ignored\nuse (\n ./apps/deep/server // member\n \"./module with spaces\"\n)\nuse ./apps/deep/server\n", + ), + ("apps/deep/server/go.mod", "module example.com/server\ngo 1.22\n"), + ("apps/deep/server/main.go", "package main\nfunc main() {}"), + ("module with spaces/go.mod", "module example.com/lib\ngo 1.22\n"), + ("ignored/go.mod", "module example.com/ignored\ngo 1.22\n"), + ]); + let configs = detect(dir.path()); + assert_eq!(configs.iter().filter(|c| c.kind == "go" && c.is_test).count(), 2); + assert_eq!(find(&configs, "go:run:apps/deep/server").cwd.as_deref(), Some(Path::new("apps/deep/server"))); + assert!(configs.iter().all(|c| !c.id.contains("ignored"))); + assert!(configs.iter().any(|c| c.id == "go:test:module with spaces")); +} + +#[test] +fn go_work_parser_ignores_other_directives_comments_and_malformed_groups() { + use ide_run::go::work_modules; + assert_eq!( + work_modules("replace use => ./not-a-member\n// use ./also-not\nuse ./real\n"), + vec![std::path::PathBuf::from("./real")] + ); + assert!(work_modules("use (\n./missing-close\n").is_empty()); + assert!(work_modules("use \"unterminated").is_empty()); +} + +#[test] +fn go_work_paths_decode_go_string_escapes() { + use ide_run::go::work_modules; + for path in [r#""./\u0061pp""#, r#""./\x61pp""#, r#""./\141pp""#, "`./app`"] { + assert_eq!(work_modules(&format!("use {path}\n")), vec![std::path::PathBuf::from("./app")]); + } + assert!(work_modules(r#"use "./\qinvalid""#).is_empty()); +} + +#[test] +fn go_default_build_output_is_ignored_by_version_control() { + let dir = project(&[("go.mod", "module x\ngo 1.22\n"), ("main.go", "package main\nfunc main() {}")]); + let mut config = detect(dir.path()).into_iter().find(|c| c.kind == "go" && !c.is_test).unwrap(); + config.extras.params.insert("goKind".into(), "package".into()); + assert!(ide_run::go::plan(dir.path(), &config).unwrap().is_some()); + let ignore = fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap(); + assert!(ignore.lines().any(|l| l == "/bin/"), "{ignore}"); +} diff --git a/crates/ide-run/tests/process.rs b/crates/ide-run/tests/process.rs index de49fcbde..e94b0e666 100644 --- a/crates/ide-run/tests/process.rs +++ b/crates/ide-run/tests/process.rs @@ -291,6 +291,10 @@ fn env_files_system_env_and_redirects() { config.extras.include_system_env = false; config.extras.stdin_file = Some("input.txt".into()); config.extras.output_file = Some("logs/out.log".into()); + let prepared = ide_run::resolved_env(root, &config).unwrap(); + assert_eq!(prepared.get("FROM_FILE").map(String::as_str), Some("file")); + assert_eq!(prepared.get("OVERRIDDEN").map(String::as_str), Some("config")); + assert!(!prepared.contains_key("HOME")); let (id, chunks) = start(&h, &config); // stdin comes from the file: writing to it is refused. assert!(h.pm.write_stdin(id, "x").is_err()); @@ -302,6 +306,7 @@ fn env_files_system_env_and_redirects() { // With the system environment, HOME is inherited. config.extras.include_system_env = true; + assert!(ide_run::resolved_env(root, &config).unwrap().contains_key("HOME")); config.extras.stdin_file = None; config.extras.output_file = None; config.args = vec!["-c".into(), "printf '%s' \"${HOME:+set}\"".into()]; @@ -468,3 +473,113 @@ fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() { assert_eq!(text(&chunks, stdout), "import OK vendor OK\n"); } } + +#[test] +#[ignore = "requires CMake, CTest and a C++ compiler"] +fn cmake_configure_discovers_builds_and_runs_a_multifile_target() { + let h = harness(); + std::fs::write(h.root.path().join("CMakeLists.txt"), "cmake_minimum_required(VERSION 3.20)\nproject(smoke LANGUAGES CXX)\nadd_executable(smoke main.cpp math.cpp)\nenable_testing()\nadd_test(NAME smoke COMMAND smoke)\n").unwrap(); + std::fs::write(h.root.path().join("main.cpp"), "int add(int,int); int main() { return add(2,3)==5 ? 0 : 1; }\n") + .unwrap(); + std::fs::write(h.root.path().join("math.cpp"), "int add(int a,int b) { return a+b; }\n").unwrap(); + let initial = ide_run::detect(h.root.path()); + assert!(!h.root.path().join("build").exists(), "detection must not modify the project"); + let configure = initial.iter().find(|c| c.id.ends_with(":configure")).unwrap(); + let (id, output) = start(&h, configure); + assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&output, stderr)); + assert!(h.root.path().join("build/.cmake/api/v1/query/client-openidea/codemodel-v2").exists()); + let root = h.root.path().canonicalize().unwrap(); + let configs = ide_run::detect(&root); + let target = configs.iter().find(|c| c.kind == "native").expect("File API executable"); + assert_eq!(target.debug_adapter.as_deref(), Some("lldb-dap")); + let sources = &target.extras.params["cmakeSources"]; + assert!(sources.lines().any(|s| s.ends_with("/main.cpp"))); + assert!(sources.lines().any(|s| s.ends_with("/math.cpp"))); + assert!(sources.lines().any(|s| s == root.join("main.cpp").to_string_lossy())); + assert_eq!(target.extras.params["cmakeCanonicalSourceRoot"], root.to_string_lossy()); + let build = configs.iter().find(|c| c.id == target.extras.before_launch[0].target).unwrap(); + let (id, output) = start(&h, build); + assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&output, stderr)); + let (id, _) = start(&h, target); + assert_eq!(wait_exit(&h, id, Duration::from_secs(10)), Some(0)); + let ctest = configs.iter().find(|c| c.command == "ctest").unwrap(); + let (id, output) = start(&h, ctest); + assert_eq!(wait_exit(&h, id, Duration::from_secs(10)), Some(0), "{}", text(&output, stdout)); +} + +#[test] +fn go_build_runs_with_separate_arguments_environment_and_working_directory() { + let h = harness(); + let Some(go) = ide_run::which::find_executable("go", &std::env::var_os("PATH").unwrap_or_default()) else { + eprintln!("skipped: no Go toolchain"); + return; + }; + let module = h.root.path().join("module"); + std::fs::create_dir_all(&module).unwrap(); + std::fs::write(module.join("go.mod"), "module example.com/demo\ngo 1.22\n").unwrap(); + std::fs::write(module.join("main.go"), "//go:build demo\n\npackage main\nimport (\"fmt\"; \"os\")\nfunc main(){cwd,_:=os.Getwd();fmt.Printf(\"%q %s %s\\n\",os.Args[1:],os.Getenv(\"GO_RUN_VALUE\"),cwd)}\n").unwrap(); + let mut c = cfg("go-config", &go.to_string_lossy(), &["build", "-tags", "demo", "."]); + c.kind = "go".into(); + c.env.insert("GO_RUN_VALUE".into(), "configured".into()); + c.extras.params = BTreeMap::from([ + ("goKind".into(), "build".into()), + ("scope".into(), "package".into()), + ("target".into(), ".".into()), + ("module".into(), "module".into()), + ("outputDir".into(), "bin with spaces".into()), + ("runAfterBuild".into(), "true".into()), + ("programArgv".into(), r#"["-tags","two words"]"#.into()), + ]); + let (id, out) = start(&h, &c); + assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&out, stderr)); + let output = text(&out, stdout); + assert!(output.contains(r#"["-tags" "two words"] configured"#), "{output}"); + assert!(output.contains(&h.root.path().display().to_string()), "{output}"); + assert!(std::fs::read_dir(h.root.path().join("bin with spaces")).unwrap().next().is_some()); + + c.extras.params.insert("runAfterBuild".into(), "false".into()); + let (id, out) = start(&h, &c); + assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0)); + assert!(text(&out, stdout).is_empty()); + + c.extras.params.insert("runAfterBuild".into(), "true".into()); + c.args = vec!["build".into(), "missing.go".into()]; + let (id, out) = start(&h, &c); + assert_ne!(wait_exit(&h, id, Duration::from_secs(60)), Some(0)); + assert!(text(&out, stdout).is_empty(), "a failed build must never run an older binary"); +} + +#[test] +fn go_file_tests_use_build_selected_sources_and_only_the_selected_test_file() { + let h = harness(); + let Some(go) = ide_run::which::find_executable("go", &std::env::var_os("PATH").unwrap_or_default()) else { + eprintln!("skipped: no Go toolchain"); + return; + }; + std::fs::write(h.root.path().join("go.mod"), "module example.com/filetests\ngo 1.22\n").unwrap(); + std::fs::write(h.root.path().join("main.go"), "package filetests\nfunc value() int { return 2 }\n").unwrap(); + std::fs::write(h.root.path().join("generate.go"), "//go:build ignore\n\npackage main\nfunc main() {}\n").unwrap(); + std::fs::write( + h.root.path().join("demo_test.go"), + "package filetests\nimport \"testing\"\nfunc TestChosen(t *testing.T) { if value()!=2 { t.Fatal(\"bad\") } }\n", + ) + .unwrap(); + std::fs::write( + h.root.path().join("other_test.go"), + "package filetests\nimport \"testing\"\nfunc TestOther(t *testing.T) { t.Fatal(\"must not run\") }\n", + ) + .unwrap(); + let mut c = cfg("go-file", &go.to_string_lossy(), &["test", "-json", "demo_test.go"]); + c.kind = "go".into(); + c.extras.params = BTreeMap::from([ + ("goKind".into(), "test".into()), + ("scope".into(), "file".into()), + ("target".into(), "demo_test.go".into()), + ]); + ide_run::go::prepare_test_file(h.root.path(), &mut c).unwrap(); + assert!(!c.args.iter().any(|a| a.ends_with("generate.go") || a.ends_with("other_test.go"))); + let (id, out) = start(&h, &c); + assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&out, stderr)); + assert!(text(&out, stdout).contains("TestChosen")); + assert!(!text(&out, stdout).contains("TestOther")); +} diff --git a/crates/ide-run/tests/tomcat_artifact.rs b/crates/ide-run/tests/tomcat_artifact.rs new file mode 100644 index 000000000..52c5045d7 --- /dev/null +++ b/crates/ide-run/tests/tomcat_artifact.rs @@ -0,0 +1,150 @@ +//! Opt-in build-tool integration checks: cargo test -p ide-run --test tomcat_artifact -- --ignored +use std::{fs, path::Path, process::Command}; + +fn write(root: &Path, path: &str, text: &str) { + let path = root.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); +} + +fn build_and_deploy(root: &Path, id: &str, expected: &Path) { + let cache = root.join("工作 cache"); + let model = ide_build::load(root, &cache, None); + let mut plan = ide_build::web::build(root, &model, id, &cache).unwrap(); + plan.resolve_project(root, &std::env::vars().collect(), &ide_core::CancellationToken::new()).unwrap(); + let output = Command::new(&plan.command) + .args(&plan.args) + .envs(&plan.env) + .current_dir(root.join(&plan.cwd)) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let manifest = fs::read_to_string(cache.join("artifact-path.txt")).unwrap(); + assert_eq!(Path::new(manifest.trim()).canonicalize().unwrap(), expected.canonicalize().unwrap()); + let mut config: ide_run::RunConfig = serde_json::from_value(serde_json::json!({ + "id": "tomcat:test", "name": "Tomcat", "kind": "tomcat", "command": "catalina", + "args": [], "env": {}, "detected": false, "isTest": false + })) + .unwrap(); + config.cwd = Some("timesheet".into()); + config.extras.params.insert("artifact".into(), id.into()); + let deployed = ide_run::tomcat::deployment(root, &cache, &config, false).unwrap(); + assert_eq!(fs::read_to_string(deployed.join("index.html")).unwrap(), "issue 515 timesheet"); + let siblings: Vec<_> = fs::read_dir(expected.parent().unwrap()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|name| name.contains(".war.")) + .collect(); + assert!(siblings.is_empty(), "Unexpected WAR siblings: {siblings:?}"); +} + +#[test] +#[ignore = "requires Maven and downloads Maven plugins"] +fn maven_packaged_artifacts_deploy_from_root_and_nested_reactors() { + for (reactor, module, custom) in + [("", "", false), ("", "timesheet", false), ("parent", "apps/timesheet", true), ("timesheet", "", true)] + { + let tmp = tempfile::Builder::new().prefix("tomcat maven ").tempdir().unwrap(); + let root = tmp.path(); + let reactor_dir = root.join(reactor); + let module_dir = reactor_dir.join(module); + let plugins = "maven-war-plugin3.4.0false"; + let parent = if module.is_empty() && custom { + write(&reactor_dir, ".mvn/maven.config", "-Drevision=2\n"); + "test${revision}1" + .to_owned() + } else if module.is_empty() { + "test1".to_owned() + } else { + write( + &reactor_dir, + "pom.xml", + &format!( + "4.0.0testparent1pomcore{module}{plugins}" + ), + ); + write( + &reactor_dir, + "core/pom.xml", + "4.0.0testparent1core", + ); + let relative = "../".repeat(module.split('/').count()); + format!( + "testparent1{relative}pom.xml" + ) + }; + let dependency = if module.is_empty() { + "" + } else { + "testcore1" + }; + let output = if custom { + "${project.basedir}/custom outputcustom-timesheet" + } else { + "timesheet" + }; + write( + &module_dir, + "pom.xml", + &format!( + "4.0.0{parent}timesheetwar{dependency}{plugins}{output}" + ), + ); + write(&module_dir, "src/main/webapp/index.html", "issue 515 timesheet"); + if custom && !module.is_empty() { + for dir in [&reactor_dir, &reactor_dir.join("core"), &module_dir] { + let pom = dir.join("pom.xml"); + let text = + fs::read_to_string(&pom).unwrap().replace("1", "${revision}"); + fs::write(pom, text).unwrap(); + } + let pom = reactor_dir.join("pom.xml"); + fs::write( + &pom, + fs::read_to_string(&pom).unwrap().replace( + "pom", + "pom1", + ), + ) + .unwrap(); + write(&reactor_dir, ".mvn/maven.config", "-Drevision=2\n"); + write(&module_dir, ".mvn/maven.config", "-Drevision=3\n"); + } + let pom = module_dir.join("pom.xml").strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"); + let expected = + module_dir.join(if custom { "custom output/custom-timesheet.war" } else { "target/timesheet.war" }); + build_and_deploy(root, &format!("maven:{pom}"), &expected); + } +} + +#[test] +#[ignore = "requires Gradle"] +fn gradle_packaged_artifacts_deploy_with_custom_archive_names() { + for module in ["", "apps/timesheet"] { + let tmp = tempfile::Builder::new().prefix("tomcat gradle ").tempdir().unwrap(); + let root = tmp.path(); + let settings = if module.is_empty() { + "rootProject.name = 'timesheet'\n" + } else { + "rootProject.name = 'timesheet'\ninclude 'apps:timesheet'\n" + }; + write(root, "settings.gradle", settings); + write( + &root.join(module), + "build.gradle", + "plugins { id 'war' }\ntasks.named('war') { archiveFileName = 'custom-timesheet.war'; destinationDirectory = layout.projectDirectory.dir('custom output') }\n", + ); + write(&root.join(module), "src/main/webapp/index.html", "issue 515 timesheet"); + let project = if module.is_empty() { ":" } else { ":apps:timesheet" }; + build_and_deploy( + root, + &format!("gradle:{project}"), + &root.join(module).join("custom output/custom-timesheet.war"), + ); + } +} diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 297f14f75..f093c8d4c 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -277,6 +277,32 @@ pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) { } } +/// Workspace compiler defaults; Eclipse project preferences still take precedence. +pub(crate) fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path) { + let file = data_dir.join("lsp/jdtls/compiler-defaults.prefs"); + const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\ +org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n"; + let write = || -> ide_core::IdeResult<()> { + std::fs::create_dir_all(file.parent().expect("compiler preferences directory"))?; + if std::fs::read_to_string(&file).ok().as_deref() != Some(DEFAULTS) { + ide_vfs::write_atomic(&file, DEFAULTS.as_bytes())?; + } + Ok(()) + }; + if let Err(e) = write() { + tracing::warn!("jdtls: cannot write compiler defaults: {e}"); + return; + } + // jdtls accepts absolute filesystem paths as well as URLs (including paths + // containing spaces and Windows backslashes). Explicit server settings win later. + let defaults = json!({"settings": {"java": {"settings": {"url": file.to_string_lossy()}}}}); + let existing = cfg.initialization_options.clone(); + merge_init_options(cfg, defaults); + if let Some(existing) = existing { + merge_init_options(cfg, serde_json::to_value(existing).expect("TOML initialization options")); + } +} + /// Adjusts a language server's launch configuration from settings: interpreter /// paths, toolchain `PATH`, env (`GOROOT`, `RUST_SRC_PATH`, `VIRTUAL_ENV`) and /// server settings (`python.pythonPath`, rust-analyzer `check.command`, `typescript.tsdk`). @@ -358,6 +384,22 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root } if name == "gopls" { + let mut build = crate::go::build_settings(&s.languages.go.build_tags); + let module_env = crate::go::module_environment(server_root, &s.languages.go.modules); + for (key, value) in module_env { + build["env"][key] = json!(value); + } + let mut settings = cfg.settings.take().unwrap_or_else(|| json!({})); + // gopls retains omitted keys on reconfiguration. Explicit empty values + // restore defaults when the last project constraint is cleared. + let init = cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()); + for (key, default) in [("buildFlags", json!([])), ("env", json!({}))] { + if settings.get(key).is_none() { + settings[key] = init.as_ref().and_then(|v| v.get(key)).cloned().unwrap_or(default); + } + } + merge(&mut settings, build); + cfg.settings = Some(settings); if let Some(goroot) = goroot(root, s) { path_dirs.push(goroot.join("bin")); cfg.env.insert("GOROOT".into(), goroot.to_string_lossy().into_owned()); @@ -625,6 +667,9 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings } } "go" => { + for (key, value) in crate::go::module_environment(root, &s.languages.go.modules) { + c.env.entry(key).or_insert(value); + } if let Some(goroot) = &go { let bin = goroot.join("bin"); if c.command == "go" && bin.join(exe("go")).is_file() { @@ -729,6 +774,70 @@ pub fn terminal_activation(root: &Path, s: &Settings, shell: Option<&str>) -> Op script(target).is_file().then_some(line) } +#[cfg(test)] +mod java_compiler_tests { + use super::*; + + fn options(cfg: &LanguageServerConfig) -> Value { + serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap() + } + + #[test] + fn compiler_defaults_live_in_the_cache_and_preserve_other_options() { + let tmp = tempfile::tempdir().unwrap(); + let cache = tmp.path().join("cache with spaces 测试"); + let mut cfg = custom_server("jdtls", &LanguageServerSettings::default()); + merge_init_options(&mut cfg, json!({"settings": {"java": {"format": {"tabSize": 4}}}})); + configure_java_compiler(&mut cfg, &cache); + let options = options(&cfg); + let path = PathBuf::from(options.pointer("/settings/java/settings/url").unwrap().as_str().unwrap()); + assert!(path.starts_with(&cache)); + let prefs = std::fs::read_to_string(&path).unwrap(); + assert_eq!(prefs.lines().count(), 2); + for problem in ["forbiddenReference", "discouragedReference"] { + assert!(prefs.contains(&format!("org.eclipse.jdt.core.compiler.problem.{problem}=warning\n"))); + } + assert_eq!(options.pointer("/settings/java/format/tabSize"), Some(&json!(4))); + let modified = std::fs::metadata(&path).unwrap().modified().unwrap(); + configure_java_compiler(&mut cfg, &cache); + assert_eq!(std::fs::metadata(path).unwrap().modified().unwrap(), modified); + assert_eq!(self::options(&cfg), options); + } + + #[test] + fn explicit_compiler_settings_keep_priority() { + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = custom_server("jdtls", &LanguageServerSettings::default()); + merge_init_options(&mut cfg, json!({"settings": {"java": {"settings": {"url": "own.prefs"}}}})); + configure_java_compiler(&mut cfg, tmp.path()); + assert_eq!(options(&cfg).pointer("/settings/java/settings/url"), Some(&json!("own.prefs"))); + + for settings in [ + json!({"java": {"settings": {"url": "custom.prefs"}}}), + json!({"settings": {"java": {"settings": {"url": "custom.prefs"}}}}), + ] { + cfg.settings = None; + let mut s = Settings::default(); + s.language_servers.insert( + "jdtls".into(), + LanguageServerSettings { settings: Some(settings.clone()), ..Default::default() }, + ); + server_protocol_overrides(&mut cfg, &s); + assert_eq!(cfg.settings, Some(settings)); + } + let mut s = Settings::default(); + s.language_servers.insert( + "jdtls".into(), + LanguageServerSettings { + initialization_options: Some(json!({"settings": {"java": {"settings": {"url": "user.prefs"}}}})), + ..Default::default() + }, + ); + server_protocol_overrides(&mut cfg, &s); + assert_eq!(options(&cfg).pointer("/settings/java/settings/url"), Some(&json!("user.prefs"))); + } +} + #[cfg(test)] mod maven_linkage_tests { use super::*; diff --git a/crates/ide-sdk/src/go.rs b/crates/ide-sdk/src/go.rs new file mode 100644 index 000000000..3a30f772c --- /dev/null +++ b/crates/ide-sdk/src/go.rs @@ -0,0 +1,329 @@ +//! Go build constraints shared by language services and command execution. +use ide_core::settings::GoBuildTags; +use serde_json::{Value, json}; + +pub fn custom_tags(tags: &str) -> Vec<&str> { + tags.split(|c: char| c.is_whitespace() || c == ',').filter(|s| !s.is_empty()).collect() +} + +/// The build category of gopls settings uses flat protocol keys (`buildFlags`, `env`). +/// Keep these in protocol settings so changes do not restart the server process. +pub fn build_settings(tags: &GoBuildTags) -> Value { + let mut out = json!({}); + let mut flags = Vec::new(); + let mut selected = custom_tags(&tags.custom); + if !tags.version.trim().is_empty() { + selected.push(tags.version.trim()); + } + if !selected.is_empty() { + flags.push(format!("-tags={}", selected.join(","))); + } + if !tags.compiler.trim().is_empty() { + flags.push(format!("-compiler={}", tags.compiler.trim())); + } + if !flags.is_empty() { + out["buildFlags"] = json!(flags); + } + for (key, value) in [("GOOS", &tags.os), ("GOARCH", &tags.arch), ("CGO_ENABLED", &tags.cgo)] { + if !value.trim().is_empty() { + out["env"][key] = json!(value.trim()); + } + } + let experiments = tags.experiments.split(',').map(str::trim).filter(|s| !s.is_empty()).collect::>(); + if !experiments.is_empty() { + out["env"]["GOEXPERIMENT"] = json!(experiments.join(",")); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_do_not_override_toolchain_or_existing_server_settings() { + assert_eq!(build_settings(&GoBuildTags::default()), json!({})); + assert!(custom_tags(" , \n ").is_empty()); + } + + #[test] + fn constraints_map_to_protocol_build_settings() { + let tags = GoBuildTags { + os: "linux".into(), + arch: "arm64".into(), + version: "go1.22".into(), + compiler: "gc".into(), + cgo: "0".into(), + custom: "integration debug,feature".into(), + experiments: "loopvar, rangefunc ".into(), + }; + assert_eq!( + build_settings(&tags), + json!({ + "buildFlags": ["-tags=integration,debug,feature,go1.22", "-compiler=gc"], + "env": {"GOOS":"linux", "GOARCH":"arm64", "CGO_ENABLED":"0", "GOEXPERIMENT":"loopvar,rangefunc"} + }) + ); + } +} + +/// Project build constraints for Go command launches. Explicit run environment wins. +pub fn apply_to_run(config: &mut ide_run::RunConfig, tags: &GoBuildTags) { + let settings = build_settings(tags); + if let Some(env) = settings["env"].as_object() { + for (key, value) in env { + if let Some(value) = value.as_str() { + config.env.entry(key.clone()).or_insert_with(|| value.into()); + } + } + } + if config.extras.params.get("useTags").is_some_and(|v| v == "true") { + let extra = settings["buildFlags"] + .as_array() + .into_iter() + .flatten() + .filter_map(|v| v.as_str()) + .map(str::to_owned) + .collect::>(); + let mut args = config.args.split_off(usize::from(!config.args.is_empty())); + merge_flags(&mut args, &extra); + config.args.extend(args); + } +} + +fn merge_flags(args: &mut Vec, extra: &[String]) { + let boundary = args.iter().position(|a| a == "-args" || a == "--").unwrap_or(args.len()); + let tail = args.split_off(boundary); + let mut tags = Vec::new(); + let mut merged = Vec::new(); + let mut all = extra.iter().chain(args.iter()); + while let Some(arg) = all.next() { + if arg == "-tags" { + if let Some(value) = all.next() { + tags.push(value.as_str()); + } + } else if let Some(value) = arg.strip_prefix("-tags=") { + tags.push(value); + } else { + merged.push(arg.clone()); + } + } + if !tags.is_empty() { + merged.insert(0, format!("-tags={}", tags.join(","))); + } + merged.extend(tail); + *args = merged; +} + +fn delve_build_flags(args: &[String], files: bool) -> Value { + if !files { + return json!(args); + } + json!( + args.iter() + .map(|arg| format!("'{}'", arg.replace('\\', "\\\\").replace('\'', "\\'"))) + .collect::>() + .join(" ") + ) +} + +pub fn apply_to_debug( + env: &mut std::collections::BTreeMap, + extra_json: &mut Option, + tags: &GoBuildTags, +) -> ide_core::IdeResult<()> { + let mut extra: Value = extra_json + .as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|e| ide_core::IdeError::invalid(e.to_string()))? + .unwrap_or_else(|| json!({})); + let settings = build_settings(tags); + if let Some(values) = settings["env"].as_object() { + for (key, value) in values { + if let Some(value) = value.as_str() { + env.entry(key.clone()).or_insert_with(|| value.into()); + } + } + } + if extra.get("__goToolArgs").is_some() { + let mut args: Vec = extra + .get("__goToolArgs") + .cloned() + .map(serde_json::from_value) + .transpose() + .map_err(|e| ide_core::IdeError::invalid(e.to_string()))? + .unwrap_or_default(); + if extra.get("__goUseTags").and_then(Value::as_bool) == Some(true) { + let flags = settings["buildFlags"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect::>(); + merge_flags(&mut args, &flags); + } + if let Some(files) = extra.get("__goFiles").and_then(Value::as_array) { + if extra["mode"] == "test" { + if let Some(dir) = files.first().and_then(Value::as_str).and_then(|f| std::path::Path::new(f).parent()) + { + let mut command = std::process::Command::new("go"); + ide_core::process::host_env(&mut command); + command.envs(env.iter()); + let sources = ide_run::go::package_sources(command, dir, &args)?; + args.extend(sources.iter().map(|p| p.to_string_lossy().into_owned())); + } + } else { + args.extend(files.iter().skip(1).filter_map(Value::as_str).map(str::to_owned)); + } + } + // Delve appends its -o after array build flags; positional source files + // require the string form so all compiler flags precede the file list. + extra["buildFlags"] = delve_build_flags(&args, extra.get("__goFiles").is_some()); + } + if let Some(output) = extra.get("output").and_then(Value::as_str).and_then(|p| std::path::Path::new(p).parent()) { + std::fs::create_dir_all(output)?; + } + if let Some(map) = extra.as_object_mut() { + map.remove("__goUseTags"); + map.remove("__goToolArgs"); + map.remove("__goFiles"); + } + *extra_json = Some(extra.to_string()); + Ok(()) +} + +#[cfg(test)] +mod command_tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn merges_custom_tags_without_touching_program_arguments() { + let mut flags = vec!["-tags".into(), "explicit".into(), ".".into(), "-args".into(), "-tags=program".into()]; + merge_flags(&mut flags, &["-tags=project,debug".into()]); + assert_eq!(flags, ["-tags=project,debug,explicit", ".", "-args", "-tags=program"]); + } + + #[test] + fn file_debug_flags_keep_delves_output_flags_before_source_files() { + let flags = vec!["-tags=demo".into(), "C:\\a b\\main.go".into(), "quote'file.go".into()]; + assert_eq!(delve_build_flags(&flags, false), json!(flags)); + assert_eq!(delve_build_flags(&flags, true), json!("'-tags=demo' 'C:\\\\a b\\\\main.go' 'quote\\'file.go'")); + } + + #[test] + fn debug_env_and_flags_use_the_same_constraints_and_preserve_overrides() { + let tags = GoBuildTags { custom: "project".into(), os: "linux".into(), ..Default::default() }; + let mut env = BTreeMap::from([("GOOS".into(), "darwin".into())]); + let mut extra = + Some(json!({"mode":"test", "__goUseTags":true, "__goToolArgs":["-tags", "explicit"]}).to_string()); + apply_to_debug(&mut env, &mut extra, &tags).unwrap(); + let extra: Value = serde_json::from_str(extra.as_deref().unwrap()).unwrap(); + assert_eq!(extra["buildFlags"], json!(["-tags=project,explicit"])); + assert!(extra.get("__goUseTags").is_none()); + assert_eq!(env["GOOS"], "darwin"); + assert!(apply_to_debug(&mut env, &mut Some("{".into()), &tags).is_err()); + } +} + +pub fn debug_environment( + root: &std::path::Path, + settings: &ide_core::settings::Settings, + env: &mut std::collections::BTreeMap, +) { + for (key, value) in module_environment(root, &settings.languages.go.modules) { + env.entry(key).or_insert(value); + } + if let Some(goroot) = crate::apply::goroot(root, settings) { + env.entry("GOROOT".into()).or_insert_with(|| goroot.to_string_lossy().into_owned()); + env.entry("PATH".into()).or_insert_with(|| crate::exec::path_with(&[&goroot.join("bin")])); + } + if let Some(gopath) = crate::apply::setting_path(root, &settings.languages.go.gopath) { + env.entry("GOPATH".into()).or_insert_with(|| gopath.to_string_lossy().into_owned()); + } +} + +pub fn module_environment( + root: &std::path::Path, + modules: &ide_core::settings::GoModules, +) -> std::collections::BTreeMap { + let mut env: std::collections::BTreeMap = modules + .env + .iter() + .filter(|(key, value)| { + !key.is_empty() + && !key.contains(['=', '\0']) + && !key.contains(char::is_whitespace) + && !value.trim().is_empty() + && !value.contains('\0') + }) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + for key in ["GOWORK", "GOMODCACHE"] { + if let Some(value) = env.get_mut(key) + && value != "off" + && value != "auto" + { + *value = root.join(value.as_str()).to_string_lossy().into_owned(); + } + } + if !modules.auto_vendor { + let flags = env + .get("GOFLAGS") + .cloned() + .or_else(|| ide_core::process::host_var_os("GOFLAGS").map(|v| v.to_string_lossy().into_owned())) + .unwrap_or_default(); + if !flags.split_whitespace().any(|flag| flag.starts_with("-mod=")) { + let workspace = match env.get("GOWORK").map(String::as_str) { + Some("off") => false, + Some(path) if path != "auto" => !path.is_empty(), + _ => root.ancestors().any(|dir| dir.join("go.work").is_file()), + }; + env.insert( + "GOFLAGS".into(), + format!("{} -mod={}", flags.trim(), if workspace { "readonly" } else { "mod" }).trim().into(), + ); + } + } + env +} + +#[cfg(test)] +mod module_tests { + use super::*; + use ide_core::settings::GoModules; + use std::collections::BTreeMap; + + #[test] + fn defaults_and_invalid_entries_leave_the_environment_unchanged() { + let dir = tempfile::tempdir().unwrap(); + assert!(module_environment(dir.path(), &GoModules::default()).is_empty()); + let modules = GoModules { + env: BTreeMap::from([ + ("BAD KEY".into(), "x".into()), + ("GOPROXY".into(), "".into()), + ("GOPRIVATE".into(), "example.com/private".into()), + ]), + ..Default::default() + }; + assert_eq!( + module_environment(dir.path(), &modules), + BTreeMap::from([("GOPRIVATE".into(), "example.com/private".into())]) + ); + } + + #[test] + fn disabling_automatic_vendor_uses_workspace_legal_flags_and_keeps_explicit_flags() { + let dir = tempfile::tempdir().unwrap(); + let mut modules = GoModules { auto_vendor: false, env: BTreeMap::from([("GOFLAGS".into(), "-race".into())]) }; + assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-race -mod=mod"); + std::fs::write(dir.path().join("go.work"), "go 1.22\n").unwrap(); + assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-race -mod=readonly"); + modules.env.insert("GOWORK".into(), "off".into()); + assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-race -mod=mod"); + modules.env.insert("GOFLAGS".into(), "-mod=vendor".into()); + assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-mod=vendor"); + } +} diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 40659ff31..525d916f2 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -333,6 +333,7 @@ pub fn configure_jdtls( if cfg.name != "jdtls" { return; } + crate::apply::configure_java_compiler(cfg, data_dir); // A Lombok agent from the user's JVM arguments wins. let own_agent = crate::lombok::agent_in_args(cfg.args.iter().map(String::as_str)); let agent = own_agent.as_deref().or(lombok); diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index 3d178c62c..ac98b5716 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -9,6 +9,7 @@ mod angular; pub mod apply; mod discover; mod exec; +pub mod go; pub mod godot; pub mod java; pub mod jdtls; diff --git a/crates/ide-sdk/src/libraries.rs b/crates/ide-sdk/src/libraries.rs index 08b1161bb..4c50b2f55 100644 --- a/crates/ide-sdk/src/libraries.rs +++ b/crates/ide-sdk/src/libraries.rs @@ -92,6 +92,10 @@ struct Cached { static CACHE: LazyLock>> = LazyLock::new(Default::default); +pub fn invalidate_go(root: &Path) { + CACHE.lock().unwrap_or_else(|e| e.into_inner()).remove(&(root.to_path_buf(), "go")); +} + fn stamp(files: &[PathBuf]) -> Stamp { files .iter() @@ -410,18 +414,27 @@ pub(crate) fn parse_cargo_metadata(json: &str) -> Vec<(String, PathBuf)> { // ------------------------------------------------------------------ go fn go_groups(root: &Path, s: &Settings) -> Vec { - let Some(files) = manifests(root, GO_MANIFESTS) else { return Vec::new() }; + let Some(mut files) = manifests(root, GO_MANIFESTS) else { return Vec::new() }; if !root.join("go.mod").is_file() && !root.join("go.work").is_file() { return Vec::new(); } + if let Ok(work) = std::fs::read_to_string(root.join("go.work")) { + for module in ide_run::go::work_modules(&work) { + files.extend([root.join(&module).join("go.mod"), root.join(&module).join("go.sum")]); + } + } + files.extend([root.join("go.work.sum"), root.join("vendor/modules.txt")]); let g = &s.languages.go; - cached(root, "go", format!("{}\n{}", g.goroot, g.gopath), &files, || { + cached(root, "go", serde_json::to_string(g).unwrap_or_default(), &files, || { let goroot = crate::apply::goroot(root, s); let Some(go) = tool(goroot.as_ref().map(|r| r.join("bin")), "go") else { return Vec::new() }; let goroot_s = goroot.as_ref().map(|p| p.to_string_lossy().into_owned()); let gopath = crate::apply::setting_path(root, &g.gopath).map(|p| p.to_string_lossy().into_owned()); // Never download modules or toolchains just to list libraries. - let mut env = vec![("GOPROXY", "off"), ("GOTOOLCHAIN", "local")]; + let module_env = crate::go::module_environment(root, &g.modules); + let mut env: Vec<(&str, &str)> = module_env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + // Listing libraries must stay offline even when module commands use a proxy. + env.extend([("GOPROXY", "off"), ("GOTOOLCHAIN", "local")]); env.extend(goroot_s.as_deref().map(|v| ("GOROOT", v))); env.extend(gopath.as_deref().map(|v| ("GOPATH", v))); let mut out = Vec::new(); @@ -436,13 +449,38 @@ fn go_groups(root: &Path, s: &Settings) -> Vec { out.extend(group(name, "go", library_roots(vec![Path::new(dir).join("src")]))); } } - if let Some(text) = query_in(&go, &["list", "-m", "-e", "-json", "all"], Some(root), &env, GRAPH_TIMEOUT) { + let flags = module_env.get("GOFLAGS").map(String::as_str).unwrap_or(""); + let vendor = root.join("vendor"); + let use_vendor = vendor.is_dir() + && (flags.split_whitespace().any(|f| f == "-mod=vendor") + || (g.modules.auto_vendor && !flags.split_whitespace().any(|f| f.starts_with("-mod=")))); + if use_vendor { + if let Ok(text) = std::fs::read_to_string(vendor.join("modules.txt")) { + let modules = vendor_roots(&vendor, &text); + out.extend(group("Go Modules", "goModules", named_roots(modules))); + } + } else if let Some(text) = query_in(&go, &["list", "-m", "-e", "-json", "all"], Some(root), &env, GRAPH_TIMEOUT) + { out.extend(group("Go Modules", "goModules", named_roots(parse_go_modules(&text)))); } out }) } +fn vendor_roots(vendor: &Path, text: &str) -> Vec<(String, PathBuf)> { + let mut seen = std::collections::HashSet::new(); + text.lines() + .filter_map(|line| { + let module = line.strip_prefix("# ")?.split_whitespace().next()?; + if module == "=>" || !seen.insert(module) { + return None; + } + let path = vendor.join(module); + path.is_dir().then(|| (module.to_string(), path)) + }) + .collect() +} + /// `(path@version, dir)` of the downloaded non-main modules in `go list -m -json` output. pub(crate) fn parse_go_modules(text: &str) -> Vec<(String, PathBuf)> { #[derive(Deserialize)] @@ -467,3 +505,34 @@ pub(crate) fn parse_go_modules(text: &str) -> Vec<(String, PathBuf)> { }) .collect() } + +#[cfg(test)] +mod go_cache_tests { + use super::*; + #[test] + fn vendor_roots_include_local_replacements_once() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("example.com/local")).unwrap(); + let text = "# example.com/local v0.0.0 => ../local\n## explicit; go 1.22\nexample.com/local\n# example.com/local => ../local\n"; + assert_eq!( + vendor_roots(dir.path(), text), + vec![("example.com/local".into(), dir.path().join("example.com/local"))] + ); + } + + #[test] + fn go_commands_invalidate_the_graph_even_when_manifests_are_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let count = std::cell::Cell::new(0); + let detect = || { + count.set(count.get() + 1); + Vec::new() + }; + cached(dir.path(), "go", "same".into(), &[], detect); + cached(dir.path(), "go", "same".into(), &[], detect); + assert_eq!(count.get(), 1); + invalidate_go(dir.path()); + cached(dir.path(), "go", "same".into(), &[], detect); + assert_eq!(count.get(), 2); + } +} diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 59adefe02..1dcfee226 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -967,3 +967,70 @@ fn node_on_the_search_path_keeps_its_version_manager_as_source() { assert_eq!(source("/home/me/.local/bin/node"), "path"); assert_eq!(source("/usr/bin/node"), "system"); } + +#[test] +fn go_constraints_change_only_protocol_settings() { + use serde_json::json; + let tmp = tempfile::tempdir().unwrap(); + let mut settings = Settings::default(); + let mut original = server("gopls"); + original.settings = Some(json!({"analyses":{"unusedparams":true}})); + let mut defaults = original.clone(); + configure_server(&mut defaults, tmp.path(), tmp.path(), &settings); + assert_eq!(defaults.settings, Some(json!({"analyses":{"unusedparams":true}, "buildFlags":[], "env":{}}))); + defaults.settings = original.settings.clone(); + assert_eq!(defaults, original); + settings.languages.go.build_tags.custom = "integration".into(); + settings.languages.go.build_tags.os = "linux".into(); + let mut configured = original.clone(); + configure_server(&mut configured, tmp.path(), tmp.path(), &settings); + assert_eq!( + configured.settings.take(), + Some(json!({ + "analyses":{"unusedparams":true}, "buildFlags":["-tags=integration"], "env":{"GOOS":"linux"} + })) + ); + configured.settings = original.settings.clone(); + assert_eq!(configured, original, "launch configuration stays unchanged for in-place reconfiguration"); + let mut other = server("rust-analyzer"); + configure_server(&mut other, tmp.path(), tmp.path(), &settings); + assert_eq!(other.settings, None); +} + +#[test] +fn go_empty_constraints_preserve_explicit_server_defaults() { + use serde_json::json; + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = server("gopls"); + cfg.initialization_options = + Some(toml::Value::try_from(json!({"buildFlags":["-tags=legacy"], "env":{"GOPROXY":"direct"}})).unwrap()); + configure_server(&mut cfg, tmp.path(), tmp.path(), &Settings::default()); + assert_eq!(cfg.settings, Some(json!({"buildFlags":["-tags=legacy"], "env":{"GOPROXY":"direct"}}))); +} + +#[test] +fn go_modules_configure_gopls_in_place_and_commands_with_explicit_run_overrides() { + use serde_json::json; + let tmp = tempfile::tempdir().unwrap(); + let mut settings = Settings::default(); + settings.languages.go.modules.env = BTreeMap::from([ + ("GOPROXY".into(), "https://proxy.example".into()), + ("GOPRIVATE".into(), "example.com/private".into()), + ("GOFLAGS".into(), "-race".into()), + ]); + settings.languages.go.modules.auto_vendor = false; + let mut cfg = server("gopls"); + configure_server(&mut cfg, tmp.path(), tmp.path(), &settings); + let protocol = cfg.settings.take().unwrap(); + assert_eq!(protocol["env"]["GOFLAGS"], "-race -mod=mod"); + assert_eq!(protocol["env"]["GOPRIVATE"], "example.com/private"); + assert!(cfg.env.is_empty(), "module environment changes must not restart gopls"); + let mut run = run("go", "go", &["mod", "tidy"]); + run.env.insert("GOPROXY".into(), "off".into()); + apply_to_run_configs(std::slice::from_mut(&mut run), tmp.path(), &settings); + assert_eq!(run.env["GOPROXY"], "off"); + assert_eq!(run.env["GOPRIVATE"], "example.com/private"); + let mut cleared = server("gopls"); + configure_server(&mut cleared, tmp.path(), tmp.path(), &Settings::default()); + assert_eq!(cleared.settings.unwrap()["env"], json!({})); +} diff --git a/crates/ide-ssh/src/filesystem.rs b/crates/ide-ssh/src/filesystem.rs new file mode 100644 index 000000000..04bdbed7c --- /dev/null +++ b/crates/ide-ssh/src/filesystem.rs @@ -0,0 +1,274 @@ +//! Filesystem operations shared by deployment and remote development bootstrap. +use crate::sftp::{RemoteEntry, SftpFs}; +use ide_core::{IdeError, IdeResult}; +use russh_sftp::client::SftpSession; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::Path; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum FsConnection { + Sftp { ssh: String }, + Local, +} + +pub enum RemoteFs { + Sftp(SftpFs), + Local, +} + +impl RemoteFs { + /// Keeps SSH bootstrap callers explicitly on SFTP. + pub fn new(sftp: SftpSession) -> Self { + Self::Sftp(SftpFs::new(sftp)) + } + + pub async fn home(&self) -> IdeResult { + match self { + Self::Sftp(fs) => fs.home().await, + Self::Local => { + dirs::home_dir().map(|p| path_string(&p)).ok_or_else(|| IdeError::invalid("Home folder is unavailable")) + } + } + } + + pub async fn resolve(&self, path: &str) -> IdeResult { + match self { + Self::Sftp(fs) => fs.resolve(path).await, + Self::Local => { + let expanded = crate::expand(path.trim()); + let path = if path.trim().is_empty() || path.trim() == "~" { + std::path::PathBuf::from(self.home().await?) + } else { + expanded + }; + let path = if path.is_absolute() { path } else { std::env::current_dir()?.join(path) }; + Ok(path_string(&path)) + } + } + } + + pub async fn stat(&self, path: &str) -> IdeResult> { + match self { + Self::Sftp(fs) => fs.stat(path).await, + Self::Local => match tokio::fs::metadata(path).await { + Ok(meta) => Ok(Some(local_entry(path, meta).await?)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + }, + } + } + + pub async fn list(&self, dir: &str) -> IdeResult> { + match self { + Self::Sftp(fs) => fs.list(dir).await, + Self::Local => { + let mut read = tokio::fs::read_dir(dir).await?; + let mut entries = Vec::new(); + while let Some(e) = read.next_entry().await? { + let path = path_string(&e.path()); + entries.push(local_entry(&path, tokio::fs::symlink_metadata(e.path()).await?).await?); + } + entries.sort_by(|a, b| { + b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + Ok(entries) + } + } + } + + pub async fn read(&self, path: &str) -> IdeResult> { + match self { + Self::Sftp(fs) => fs.read(path).await, + Self::Local => Ok(tokio::fs::read(path).await?), + } + } + + pub async fn write(&self, path: &str, data: &[u8], mtime: Option) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.write(path, data, mtime).await, + Self::Local => { + if let Some(parent) = Path::new(path).parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(path, data).await?; + if let Some(t) = mtime { + set_mtime(path, t).await; + } + Ok(()) + } + } + } + + pub async fn upload(&self, local: &Path, remote: &str, preserve_time: bool) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.upload(local, remote, preserve_time).await, + Self::Local => { + reject_same_file(local, Path::new(remote)).await?; + copy_file(local, Path::new(remote)).await?; + if let Some(t) = preserve_time.then(|| crate::sftp::mtime_of(local)).flatten() { + set_mtime(remote, t).await; + } + Ok(()) + } + } + } + + pub async fn download(&self, remote: &str, local: &Path) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.download(remote, local).await, + Self::Local => { + reject_same_file(Path::new(remote), local).await?; + copy_file(Path::new(remote), local).await + } + } + } + + pub async fn mkdir_all(&self, path: &str) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.mkdir_all(path).await, + Self::Local => Ok(tokio::fs::create_dir_all(path).await?), + } + } + + pub async fn remove(&self, path: &str) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.remove(path).await, + Self::Local => { + let meta = tokio::fs::symlink_metadata(path).await?; + if meta.is_dir() { + tokio::fs::remove_dir_all(path).await?; + } else { + tokio::fs::remove_file(path).await?; + } + Ok(()) + } + } + } + + pub async fn rename(&self, from: &str, to: &str) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.rename(from, to).await, + Self::Local => Ok(tokio::fs::rename(from, to).await?), + } + } + + /// Probe actual write access, including ACLs and read-only mounts. + pub async fn test(&self, path: &str) -> IdeResult<()> { + let path = self.resolve(path).await?; + let entry = + self.stat(&path).await?.ok_or_else(|| IdeError::not_found(format!("Folder does not exist: {path}")))?; + if !entry.is_dir { + return Err(IdeError::invalid(format!("Not a folder: {path}"))); + } + if matches!(self, Self::Local) { + let name = format!( + ".openidea-write-test-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos() + ); + let probe = Path::new(&path).join(name); + let mut file = + tokio::fs::OpenOptions::new().write(true).create_new(true).open(&probe).await.map_err(|e| { + IdeError::new( + ide_core::error::ErrorKind::PermissionDenied, + format!("Folder is not writable: {path}: {e}"), + ) + })?; + use tokio::io::AsyncWriteExt; + let result = file.write_all(b"test").await; + drop(file); + let cleanup = tokio::fs::remove_file(probe).await; + result.and(cleanup).map_err(|e| { + IdeError::new( + ide_core::error::ErrorKind::PermissionDenied, + format!("Folder is not writable: {path}: {e}"), + ) + })?; + } else { + self.list(&path).await?; + } + Ok(()) + } + + pub async fn close(&self) { + if let Self::Sftp(fs) = self { + fs.close().await; + } + } +} + +/// Copies without holding the file in memory: deployments can include large archives. +async fn copy_file(from: &Path, to: &Path) -> IdeResult<()> { + if let Some(parent) = to.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::copy(from, to).await?; + Ok(()) +} + +async fn set_mtime(path: &str, seconds: u32) { + let result = async { + let file = tokio::fs::OpenOptions::new().write(true).open(path).await?.into_std().await; + file.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(u64::from(seconds))) + }; + if let Err(e) = result.await { + tracing::debug!("Cannot set the time of {path}: {e}"); + } +} + +fn path_string(path: &Path) -> String { + let path = path.to_string_lossy().into_owned(); + #[cfg(windows)] + let path = path.replace('\\', "/"); + path +} + +async fn local_entry(path: &str, meta: std::fs::Metadata) -> IdeResult { + #[cfg(unix)] + let permissions = { + use std::os::unix::fs::PermissionsExt; + crate::sftp::mode_string(meta.permissions().mode()) + }; + #[cfg(not(unix))] + let permissions = if meta.permissions().readonly() { "r--r--r--" } else { "rw-rw-rw-" }.to_owned(); + let is_dir = meta.is_dir() || (meta.is_symlink() && tokio::fs::metadata(path).await.is_ok_and(|m| m.is_dir())); + Ok(RemoteEntry { + name: Path::new(path).file_name().unwrap_or_default().to_string_lossy().into_owned(), + path: path.to_owned(), + is_dir, + is_symlink: meta.is_symlink(), + size: meta.len(), + mtime: meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .and_then(|t| t.as_secs().try_into().ok()), + permissions, + }) +} + +async fn reject_same_file(from: &Path, to: &Path) -> IdeResult<()> { + let source = tokio::fs::metadata(from).await?; + let target = match tokio::fs::metadata(to).await { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.into()), + }; + #[cfg(unix)] + let same = { + use std::os::unix::fs::MetadataExt; + source.dev() == target.dev() && source.ino() == target.ino() + }; + #[cfg(not(unix))] + let same = { + let _ = (source, target); + tokio::fs::canonicalize(from).await? == tokio::fs::canonicalize(to).await? + }; + if same { + Err(IdeError::invalid(format!("Source and destination are the same file: {}", from.display()))) + } else { + Ok(()) + } +} diff --git a/crates/ide-ssh/src/lib.rs b/crates/ide-ssh/src/lib.rs index 1ab25a185..c5790b281 100644 --- a/crates/ide-ssh/src/lib.rs +++ b/crates/ide-ssh/src/lib.rs @@ -3,6 +3,7 @@ //! processes, SFTP, local port forwarding, and askpass for `ssh` / `git`. pub mod askpass; +pub mod filesystem; pub mod git_creds; pub mod manager; pub mod secrets; @@ -14,9 +15,10 @@ pub mod target; pub mod testing; pub mod tunnel; +pub use filesystem::{FsConnection, RemoteFs}; pub use manager::SshManager; pub use russh::Sig; pub use session::{ConnectError, ExecOutput, Output, RemoteProcess, Session, UnknownHostKey}; -pub use sftp::{RemoteEntry, RemoteFs}; +pub use sftp::RemoteEntry; pub use target::{SshFiles, SshTarget, expand, openssh_args}; pub use tunnel::Tunnel; diff --git a/crates/ide-ssh/src/sftp.rs b/crates/ide-ssh/src/sftp.rs index c14c14463..38a8fde74 100644 --- a/crates/ide-ssh/src/sftp.rs +++ b/crates/ide-ssh/src/sftp.rs @@ -41,7 +41,7 @@ pub fn parent(path: &str) -> Option<&str> { Some(if i == 0 { "/" } else { &trimmed[..i] }) } -fn mode_string(mode: u32) -> String { +pub(crate) fn mode_string(mode: u32) -> String { let bits = [ (0o400, 'r'), (0o200, 'w'), @@ -78,11 +78,11 @@ fn err(path: &str, e: SftpError) -> IdeError { } /// Higher-level operations on an [`SftpSession`]. -pub struct RemoteFs { +pub struct SftpFs { sftp: SftpSession, } -impl RemoteFs { +impl SftpFs { pub fn new(sftp: SftpSession) -> Self { Self { sftp } } diff --git a/crates/ide-syntax/src/jvm.rs b/crates/ide-syntax/src/jvm.rs new file mode 100644 index 000000000..9da0f7b46 --- /dev/null +++ b/crates/ide-syntax/src/jvm.rs @@ -0,0 +1,189 @@ +//! Syntactic presentation of the primary top-level JVM declaration. +use crate::{Snapshot, StructureResult, StructureService}; +use ide_core::CancellationToken; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tree_sitter::Node; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum JvmTypeKind { + Class, + AbstractClass, + Interface, + Enum, + Record, + Annotation, + Exception, + Object, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct JvmPresentation { + pub kind: Option, + pub spring_boot: bool, + pub runnable: bool, +} + +fn text<'a>(node: Node, source: &'a str) -> &'a str { + source.get(node.byte_range()).unwrap_or_default() +} + +fn children(node: Node) -> Vec { + node.children(&mut node.walk()).collect() +} + +fn descendant(node: Node<'_>, predicate: &impl Fn(Node<'_>) -> bool) -> bool { + predicate(node) || node.children(&mut node.walk()).any(|n| descendant(n, predicate)) +} + +fn keyword(node: Node, source: &str, word: &str) -> bool { + descendant(node, &|n| { + n.child_count() == 0 && (n.kind() == word || n.kind().ends_with("_modifier") && text(n, source) == word) + }) +} + +fn annotation(node: Node, source: &str, name: &str) -> bool { + node.named_children(&mut node.walk()).any(|n| { + if !matches!(n.kind(), "annotation" | "marker_annotation") { + return false; + } + if let Some(id) = n.child_by_field_name("name") { + return text(id, source).rsplit('.').next() == Some(name); + } + // Kotlin: @ -> constructor_invocation -> user_type (or type -> user_type). + let mut current = n; + while let Some(first) = current.named_children(&mut current.walk()).next() { + if matches!(first.kind(), "use_site_target") { + return false; + } + if first.kind() == "user_type" { + let ids: Vec<_> = + first.named_children(&mut first.walk()).filter(|c| c.kind() == "identifier").collect(); + return ids.last().is_some_and(|id| text(*id, source) == name); + } + current = first; + } + false + }) +} + +fn exception_base(node: Node, source: &str) -> bool { + if matches!(node.kind(), "type_arguments" | "value_arguments" | "explicit_delegation") { + return false; + } + if matches!(node.kind(), "type_identifier" | "user_type" | "scoped_type_identifier") { + let name = if node.kind() == "user_type" || node.kind() == "scoped_type_identifier" { + node.named_children(&mut node.walk()) + .filter(|n| matches!(n.kind(), "identifier" | "type_identifier")) + .last() + .map(|n| text(n, source)) + .unwrap_or_default() + } else { + text(node, source) + }; + return name.ends_with("Exception") || name.ends_with("Error") || name == "Throwable"; + } + node.named_children(&mut node.walk()).any(|n| exception_base(n, source)) +} + +fn members(node: Node<'_>) -> Vec> { + let mut out = Vec::new(); + for child in node.named_children(&mut node.walk()) { + if matches!(child.kind(), "method_declaration" | "function_declaration") { + out.push(child); + } + if matches!(child.kind(), "companion_object" | "class_body" | "enum_body_declarations") { + out.extend(members(child)); + } + } + out +} + +fn is_type(node: Node) -> bool { + matches!( + node.kind(), + "class_declaration" + | "interface_declaration" + | "enum_declaration" + | "record_declaration" + | "annotation_type_declaration" + | "object_declaration" + ) +} + +impl StructureService { + /// Only top-level declarations participate; the filename-matching declaration + /// wins, otherwise the first type does. Strings/comments never supply evidence. + pub fn jvm_presentation( + &self, + snap: &Snapshot, + stem: &str, + cancel: &CancellationToken, + ) -> StructureResult { + self.run(snap, cancel, |cx| { + if !matches!(cx.grammar, "java" | "kotlin") { + return Ok(JvmPresentation::default()); + } + let top = cx.tree.root_node(); + let types: Vec<_> = top.named_children(&mut top.walk()).filter(|n| is_type(*n)).collect(); + let Some(node) = types + .iter() + .find(|n| n.child_by_field_name("name").is_some_and(|id| text(id, snap.text) == stem)) + .or(types.first()) + .copied() + else { + return Ok(JvmPresentation::default()); + }; + let parts = children(node); + let modifiers = parts.iter().find(|n| n.kind() == "modifiers").copied(); + let has = |word| modifiers.is_some_and(|m| keyword(m, snap.text, word)); + let supertype = parts.iter().find(|n| matches!(n.kind(), "superclass" | "delegation_specifiers")); + let exception = supertype.is_some_and(|s| exception_base(*s, snap.text)); + let kind = match node.kind() { + "annotation_type_declaration" => JvmTypeKind::Annotation, + "interface_declaration" => JvmTypeKind::Interface, + "enum_declaration" => JvmTypeKind::Enum, + "record_declaration" => JvmTypeKind::Record, + "object_declaration" => JvmTypeKind::Object, + _ if has("annotation") => JvmTypeKind::Annotation, + _ if has("enum") => JvmTypeKind::Enum, + _ if parts.iter().any(|n| n.kind() == "interface") => JvmTypeKind::Interface, + _ if exception => JvmTypeKind::Exception, + _ if has("abstract") => JvmTypeKind::AbstractClass, + _ => JvmTypeKind::Class, + }; + let spring_boot = modifiers.is_some_and(|m| annotation(m, snap.text, "SpringBootApplication")); + let body = parts.iter().find(|n| n.kind() == "class_body"); + let runnable = body.is_some_and(|b| { + members(*b).into_iter().any(|n| { + if !matches!(n.kind(), "method_declaration" | "function_declaration") + || !n.child_by_field_name("name").is_some_and(|id| text(id, snap.text) == "main") + { + return false; + } + if cx.grammar == "kotlin" { + return children(n) + .iter() + .any(|m| m.kind() == "modifiers" && annotation(*m, snap.text, "JvmStatic")); + } + let modifiers = children(n).into_iter().find(|m| m.kind() == "modifiers"); + let params = n.child_by_field_name("parameters"); + modifiers.is_some_and(|m| keyword(m, snap.text, "static") && keyword(m, snap.text, "public")) + && n.child_by_field_name("type").is_some_and(|t| text(t, snap.text) == "void") + && params.is_some_and(|p| { + let args: Vec<_> = p.named_children(&mut p.walk()).collect(); + args.len() == 1 + && descendant(args[0], &|t| { + t.kind() == "type_identifier" + && matches!(text(t, snap.text), "String" | "java.lang.String") + }) + && (keyword(args[0], snap.text, "[") || keyword(args[0], snap.text, "...")) + }) + }) + }); + Ok(JvmPresentation { kind: Some(kind), spring_boot, runnable }) + }) + } +} diff --git a/crates/ide-syntax/src/lib.rs b/crates/ide-syntax/src/lib.rs index 09253c3ae..b72382883 100644 --- a/crates/ide-syntax/src/lib.rs +++ b/crates/ide-syntax/src/lib.rs @@ -19,6 +19,7 @@ //! the query loops honour the [`CancellationToken`]. mod identifiers; +mod jvm; mod kinds; mod lines; mod locals; @@ -28,6 +29,7 @@ mod types; #[cfg(test)] mod tests; +pub use jvm::{JvmPresentation, JvmTypeKind}; pub use types::*; use ide_core::{CancellationToken, DocId, IdeError, LanguageId, Pos, Range}; diff --git a/crates/ide-syntax/src/tests/jvm.rs b/crates/ide-syntax/src/tests/jvm.rs new file mode 100644 index 000000000..fb95ef155 --- /dev/null +++ b/crates/ide-syntax/src/tests/jvm.rs @@ -0,0 +1,78 @@ +use super::*; +use crate::{JvmPresentation, JvmTypeKind::*}; + +fn presentation(language: &str, text: &str) -> JvmPresentation { + service().jvm_presentation(&Snapshot::text(&lang(language), text), "Foo", &none()).unwrap() +} + +#[test] +fn jvm_declaration_kinds() { + for (language, source, kind) in [ + ("java", "public class Foo {}", Class), + ("java", "abstract class Foo {}", AbstractClass), + ("java", "interface Foo {}", Interface), + ("java", "enum Foo { A }", Enum), + ("java", "record Foo(int x) {}", Record), + ("java", "@interface Foo {}", Annotation), + ("java", "class Foo extends java.lang.RuntimeException {}", Exception), + ("kotlin", "class Foo", Class), + ("kotlin", "abstract class Foo", AbstractClass), + ("kotlin", "interface Foo", Interface), + ("kotlin", "enum class Foo { A }", Enum), + ("kotlin", "annotation class Foo", Annotation), + ("kotlin", "class Foo : RuntimeException()", Exception), + ("kotlin", "object Foo {}", Object), + ("kotlin", "data class Foo(val x: Int)", Class), + ] { + assert_eq!(presentation(language, source).kind, Some(kind), "{language}: {source}"); + } +} + +#[test] +fn boot_annotations_are_on_the_primary_type_only() { + for language in ["java", "kotlin"] { + for annotation in ["@SpringBootApplication", "@org.springframework.boot.autoconfigure.SpringBootApplication"] { + assert!(presentation(language, &format!("{annotation} class Foo {{}}")).spring_boot); + } + for source in [ + "// @SpringBootApplication\nclass Foo {}", + "/* @SpringBootApplication */ class Foo {}", + "@Service class Foo {}", + "@Controller class Foo {}", + "@Wrapper(\"@SpringBootApplication\") class Foo {}", + "class Outer { @SpringBootApplication class Nested {} } class Foo {}", + ] { + assert!(!presentation(language, source).spring_boot, "{source}"); + } + } + assert!(!presentation("java", "class Foo { String s = \"@SpringBootApplication\"; }").spring_boot); + assert!(!presentation("kotlin", "class Foo { val s = \"@SpringBootApplication\" }").spring_boot); + assert!(!presentation("java", "class Foo { @SpringBootApplication void method() {} }").spring_boot); + assert!(!presentation("java", "@Wrapper(@SpringBootApplication) class Foo {}").spring_boot); + assert_eq!(presentation("java", "class Foo extends Box {}").kind, Some(Class)); + assert_eq!(presentation("kotlin", "class Foo : Box()").kind, Some(Class)); + assert_eq!(presentation("java", "// no type\n"), JvmPresentation::default()); + assert_eq!(presentation("kotlin", "val text = \"class Foo\""), JvmPresentation::default()); + assert_eq!(presentation("java", "interface Other {} class Foo {} ").kind, Some(Class)); +} + +#[test] +fn runnable_methods() { + assert!(presentation("java", "class Foo { public static void main(String[] args) {} }").runnable); + assert!(presentation("java", "class Foo { public static void main(String... args) {} }").runnable); + assert!(!presentation("java", "class Foo { void main() {} }").runnable); + assert!(!presentation("java", "class Foo { class Nested { public static void main(String[] a) {} } }").runnable); + assert!(!presentation("java", "class Foo { String s = \"public static void main(String[] args) {}\"; }").runnable); + assert!( + presentation( + "kotlin", + "class Foo { + companion object { + @JvmStatic + fun main(args: Array) {} + } + }" + ) + .runnable + ); +} diff --git a/crates/ide-syntax/src/tests/mod.rs b/crates/ide-syntax/src/tests/mod.rs index 3c677d41b..fe753dc42 100644 --- a/crates/ide-syntax/src/tests/mod.rs +++ b/crates/ide-syntax/src/tests/mod.rs @@ -1,6 +1,7 @@ //! Test helpers: sources are plain strings; positions are named by the n-th //! whole-word occurrence of an identifier (1-based), so fixtures stay readable. +mod jvm; mod locals_langs; mod structure; diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 66b67a8cb..d5cd36c0d 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -138,7 +138,7 @@ fn bin_dirs_in(root: &Path, platform: Platform) -> Vec { } fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option { - match spec.method { + match spec.fallback.unwrap_or(spec.method) { Method::Npm { .. } => Some(root.join("node_modules").join(".bin")), Method::Go { .. } | Method::Gem { .. } | Method::DotnetTool { .. } | Method::Release { .. } => { Some(root.join(spec.name).join("bin")) @@ -151,11 +151,13 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option

Method { + let Some(fallback) = spec.fallback else { return spec.method }; + let managed = managed_bin_dir(&self.root, spec, self.platform) + .and_then(|dir| self.exe_in(&dir, spec.commands[0])) + .is_some(); + if managed || self.method_missing(spec.method).is_some() { fallback } else { spec.method } + } + /// Requirement check for installing `spec` right now. fn unavailable_reason(&self, spec: &ToolSpec) -> Option { - self.installer_missing(spec) + self.method_missing(self.install_method(spec)) .or_else(|| spec.needs.iter().find(|(cmd, _)| self.which(cmd).is_none()).map(|(_, why)| why.to_string())) } /// What the install method itself lacks (npm, a build for this platform, …). - fn installer_missing(&self, spec: &ToolSpec) -> Option { + fn method_missing(&self, method: Method) -> Option { let need = |ok: bool, msg: &str| (!ok).then(|| msg.to_string()); - match spec.method { + match method { Method::Rustup { .. } => need(self.which("rustup").is_some(), "rustup is required (https://rustup.rs)"), Method::Npm { .. } => need( self.which_any(&["npm", "pnpm"]).is_some(), @@ -602,7 +614,7 @@ impl Tools { status, path: path.map(Into::into), version, - install_method: Some(spec.method.label().to_string()), + install_method: Some(self.install_method(spec).label().to_string()), can_install: unavailable_reason.is_none(), unavailable_reason, can_uninstall, @@ -643,7 +655,16 @@ impl Tools { fn detect(&self, spec: &ToolSpec) -> (ToolStatus, Option, Option) { let main = spec.commands[0]; - match spec.method { + let managed = managed_bin_dir(&self.root, spec, self.platform).and_then(|d| self.exe_in(&d, main)); + if let Some(path) = managed { + let version = match spec.method { + Method::Npm { packages } => self.npm_package_version(npm_name(packages[0])), + _ => self.manifest(spec.name).and_then(|m| m.version), + } + .or_else(|| self.query_version(spec, &path)); + return (ToolStatus::Installed, Some(path), version); + } + match self.install_method(spec) { Method::Rustup { .. } => { let Some(path) = self.which(main) else { return (ToolStatus::Missing, None, None) }; // The rustup proxy exists even without the component; it only answers when installed. @@ -678,24 +699,13 @@ impl Tools { _ => (ToolStatus::Missing, None, None), } } - _ => { - let managed = managed_bin_dir(&self.root, spec, self.platform).and_then(|d| self.exe_in(&d, main)); - if let Some(path) = managed { - let version = match spec.method { - Method::Npm { packages } => self.npm_package_version(npm_name(packages[0])), - _ => self.manifest(spec.name).and_then(|m| m.version), - } - .or_else(|| self.query_version(spec, &path)); - return (ToolStatus::Installed, Some(path), version); + _ => match self.which(main) { + Some(path) => { + let version = self.query_version(spec, &path); + (ToolStatus::OnPath, Some(path), version) } - match self.which(main) { - Some(path) => { - let version = self.query_version(spec, &path); - (ToolStatus::OnPath, Some(path), version) - } - None => (ToolStatus::Missing, None, None), - } - } + None => (ToolStatus::Missing, None, None), + }, } } @@ -769,8 +779,9 @@ impl Tools { let emit = &fan as Emit; let result = (|| { std::fs::create_dir_all(&self.root)?; - emit(InstallEvent::Line { text: format!("Installing {} via {}…", spec.display, spec.method.label()) }); - match spec.method { + let method = self.install_method(spec); + emit(InstallEvent::Line { text: format!("Installing {} via {}…", spec.display, method.label()) }); + match method { Method::Rustup { component } => { let mut c = self.command(self.which("rustup").expect("checked")); c.args(["component", "add", component]); @@ -1072,6 +1083,10 @@ impl Tools { } ReleaseSource::OpenVsx { namespace, extension } => { let json = http.get_text(&sources::openvsx_latest_url(namespace, extension), cancel)?; + let json = match sources::openvsx_platform_url(&json, assets, self.platform)? { + Some(url) => http.get_text(&url, cancel)?, + None => json, + }; sources::openvsx_pick(&json, assets, self.platform) } ReleaseSource::MavenCentral { group, artifact } => { @@ -1172,7 +1187,7 @@ impl Tools { let _guard = InstallGuard::acquire(&format!("uninstall-{name}"))?; let cancel = CancellationToken::new(); let quiet = |_: InstallEvent| {}; - match spec.method { + match self.install_method(spec) { 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/registry.rs b/crates/ide-tools/src/registry.rs index ce0c38dd3..8bac71906 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -82,6 +82,8 @@ pub struct ToolSpec { /// is a plugin bundle loaded by another tool (copied to `bin/` under this name). pub commands: &'static [&'static str], pub method: Method, + /// Used when the primary installer is unavailable. + pub fallback: Option, /// Version query: a command (looked up next to the main binary first) and its args. pub version: Option<(&'static str, &'static [&'static str])>, pub homepage: &'static str, @@ -123,6 +125,18 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["rust-analyzer"], method: Method::Rustup { component: "rust-analyzer" }, + fallback: Some(Method::Release { + source: ReleaseSource::OpenVsx { namespace: "rust-lang", extension: "rust-analyzer" }, + assets: &[ + rule(Mac, Arm64, "rust-lang.rust-analyzer-*@darwin-arm64.vsix"), + rule(Mac, X64, "rust-lang.rust-analyzer-*@darwin-x64.vsix"), + rule(Linux, Arm64, "rust-lang.rust-analyzer-*@linux-arm64.vsix"), + rule(Linux, X64, "rust-lang.rust-analyzer-*@linux-x64.vsix"), + rule(Windows, Arm64, "rust-lang.rust-analyzer-*@win32-arm64.vsix"), + rule(Windows, X64, "rust-lang.rust-analyzer-*@win32-x64.vsix"), + ], + locate: &["rust-analyzer"], + }), version: ver("rust-analyzer"), homepage: "https://rust-analyzer.github.io", needs: &[], @@ -133,6 +147,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["vtsls"], method: Method::Npm { packages: &["@vtsls/language-server"] }, + fallback: None, version: V, homepage: "https://github.com/yioneko/vtsls", needs: &[], @@ -143,6 +158,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["typescript-language-server"], method: Method::Npm { packages: &["typescript-language-server", TYPESCRIPT] }, + fallback: None, version: ver("typescript-language-server"), homepage: "https://github.com/typescript-language-server/typescript-language-server", needs: &[], @@ -153,6 +169,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["pyright-langserver", "pyright"], method: Method::Npm { packages: &["pyright"] }, + fallback: None, version: ver("pyright"), homepage: "https://github.com/microsoft/pyright", needs: &[], @@ -163,6 +180,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["basedpyright-langserver", "basedpyright"], method: Method::PipVenv { package: "basedpyright" }, + fallback: None, version: ver("basedpyright"), homepage: "https://docs.basedpyright.com", needs: &[], @@ -173,6 +191,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["ty"], method: Method::PipVenv { package: "ty" }, + fallback: None, version: ver("ty"), homepage: "https://docs.astral.sh/ty/", needs: &[], @@ -183,6 +202,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["bash-language-server"], method: Method::Npm { packages: &["bash-language-server"] }, + fallback: None, version: ver("bash-language-server"), homepage: "https://github.com/bash-lsp/bash-language-server", needs: &[], @@ -193,6 +213,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["yaml-language-server"], method: Method::Npm { packages: &["yaml-language-server"] }, + fallback: None, version: V, homepage: "https://github.com/redhat-developer/yaml-language-server", needs: &[], @@ -203,6 +224,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["vscode-json-language-server"], method: Method::Npm { packages: &["vscode-langservers-extracted"] }, + fallback: None, version: V, homepage: "https://github.com/hrsh7th/vscode-langservers-extracted", needs: &[], @@ -213,6 +235,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["vscode-css-language-server"], method: Method::Npm { packages: &["vscode-langservers-extracted"] }, + fallback: None, version: V, homepage: "https://github.com/hrsh7th/vscode-langservers-extracted", needs: &[], @@ -223,6 +246,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["vscode-html-language-server"], method: Method::Npm { packages: &["vscode-langservers-extracted"] }, + fallback: None, version: V, homepage: "https://github.com/hrsh7th/vscode-langservers-extracted", needs: &[], @@ -233,6 +257,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["docker-langserver"], method: Method::Npm { packages: &["dockerfile-language-server-nodejs"] }, + fallback: None, version: V, homepage: "https://github.com/rcjsuen/dockerfile-language-server", needs: &[], @@ -243,6 +268,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["svelteserver"], method: Method::Npm { packages: &["svelte-language-server", TYPESCRIPT] }, + fallback: None, version: V, homepage: "https://github.com/sveltejs/language-tools", needs: &[], @@ -253,6 +279,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["vue-language-server"], method: Method::Npm { packages: &["@vue/language-server", TYPESCRIPT] }, + fallback: None, version: V, homepage: "https://github.com/vuejs/language-tools", needs: &[], @@ -264,6 +291,7 @@ pub static TOOLS: &[ToolSpec] = &[ commands: &["ngserver"], // Brings the TypeScript and `@angular/language-service` it loads. method: Method::Npm { packages: &["@angular/language-server"] }, + fallback: None, version: V, homepage: "https://angular.dev/tools/language-service", needs: &[], @@ -274,6 +302,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["graphql-lsp"], method: Method::Npm { packages: &["graphql-language-service-cli"] }, + fallback: None, version: V, homepage: "https://github.com/graphql/graphiql", needs: &[], @@ -284,6 +313,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["intelephense"], method: Method::Npm { packages: &["intelephense"] }, + fallback: None, version: V, homepage: "https://intelephense.com", needs: &[], @@ -294,6 +324,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["gopls"], method: Method::Go { module: "golang.org/x/tools/gopls@latest" }, + fallback: None, version: Some(("gopls", &["version"])), homepage: "https://pkg.go.dev/golang.org/x/tools/gopls", needs: &[], @@ -304,6 +335,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["sqls"], method: Method::Go { module: "github.com/sqls-server/sqls@latest" }, + fallback: None, version: Some(("sqls", &["--version"])), homepage: "https://github.com/sqls-server/sqls", needs: &[], @@ -314,6 +346,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: Debugger, commands: &["dlv"], method: Method::Go { module: "github.com/go-delve/delve/cmd/dlv@latest" }, + fallback: None, version: Some(("dlv", &["version"])), homepage: "https://github.com/go-delve/delve", needs: &[], @@ -324,6 +357,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: Debugger, commands: &["python3"], method: Method::PipTarget { package: "debugpy", module: "debugpy" }, + fallback: None, version: V, homepage: "https://github.com/microsoft/debugpy", needs: &[], @@ -342,6 +376,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["clangd"], }, + fallback: None, version: ver("clangd"), homepage: "https://clangd.llvm.org", needs: &[], @@ -362,6 +397,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["lua-language-server"], }, + fallback: None, version: ver("lua-language-server"), homepage: "https://luals.github.io", needs: &[], @@ -381,6 +417,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["marksman"], }, + fallback: None, version: ver("marksman"), homepage: "https://github.com/artempyanykh/marksman", needs: &[], @@ -402,6 +439,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["taplo"], }, + fallback: None, version: ver("taplo"), homepage: "https://taplo.tamasfe.dev", needs: &[], @@ -423,6 +461,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["zls"], }, + fallback: None, version: ver("zls"), homepage: "https://github.com/zigtools/zls", needs: &[], @@ -444,6 +483,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["terraform-ls"], }, + fallback: None, version: Some(("terraform-ls", &["version"])), homepage: "https://github.com/hashicorp/terraform-ls", needs: &[], @@ -469,6 +509,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["codelldb"], }, + fallback: None, version: V, homepage: "https://github.com/vadimcn/codelldb", needs: &[], @@ -488,6 +529,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["dapDebugServer.js"], }, + fallback: None, version: V, homepage: "https://github.com/microsoft/vscode-js-debug", needs: &[], @@ -506,6 +548,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["jdtls", "jdtls.bat"], }, + fallback: None, version: V, homepage: "https://github.com/eclipse-jdtls/eclipse.jdt.ls", needs: &[], @@ -525,6 +568,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["com.microsoft.java.debug.plugin-*.jar"], }, + fallback: None, version: V, homepage: "https://github.com/microsoft/java-debug", needs: &[], @@ -544,6 +588,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["lombok.jar"], }, + fallback: None, version: V, homepage: "https://projectlombok.org", needs: &[], @@ -568,6 +613,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["kotlin-lsp", "kotlin-lsp.sh", "kotlin-lsp.cmd"], }, + fallback: None, version: V, homepage: "https://github.com/Kotlin/kotlin-lsp", needs: &[], @@ -584,6 +630,7 @@ pub static TOOLS: &[ToolSpec] = &[ assets: ELIXIR_LS_ASSETS, locate: &["language_server.sh", "language_server.bat"], }, + fallback: None, version: V, homepage: "https://github.com/elixir-lsp/elixir-ls", needs: ELIXIR, @@ -598,6 +645,7 @@ pub static TOOLS: &[ToolSpec] = &[ assets: ELIXIR_LS_ASSETS, locate: &["debug_adapter.sh", "debug_adapter.bat"], }, + fallback: None, version: V, homepage: "https://github.com/elixir-lsp/elixir-ls", needs: ELIXIR, @@ -619,6 +667,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["buf"], }, + fallback: None, version: ver("buf"), homepage: "https://buf.build", needs: &[], @@ -639,6 +688,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["clojure-lsp"], }, + fallback: None, version: V, homepage: "https://clojure-lsp.io", needs: &[], @@ -660,6 +710,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["texlab"], }, + fallback: None, version: ver("texlab"), homepage: "https://github.com/latex-lsp/texlab", needs: &[], @@ -680,6 +731,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["neocmakelsp"], }, + fallback: None, version: ver("neocmakelsp"), homepage: "https://github.com/Decodetalkers/neocmakelsp", needs: &[], @@ -700,6 +752,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["lemminx-*"], }, + fallback: None, version: V, homepage: "https://github.com/eclipse/lemminx", needs: &[], @@ -718,6 +771,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["perlnavigator"], }, + fallback: None, version: V, homepage: "https://github.com/bscan/PerlNavigator", needs: &[("perl", "Perl is required (https://www.perl.org/get.html)")], @@ -732,6 +786,7 @@ pub static TOOLS: &[ToolSpec] = &[ assets: &[any_arch(Mac, "server.zip"), any_arch(Linux, "server.zip"), any_arch(Windows, "server.zip")], locate: &["kotlin-language-server"], }, + fallback: None, version: V, homepage: "https://github.com/fwcd/kotlin-language-server", needs: JAVA, @@ -746,6 +801,7 @@ pub static TOOLS: &[ToolSpec] = &[ assets: &[any_arch(Mac, "adapter.zip"), any_arch(Linux, "adapter.zip"), any_arch(Windows, "adapter.zip")], locate: &["kotlin-debug-adapter"], }, + fallback: None, version: V, homepage: "https://github.com/fwcd/kotlin-debug-adapter", needs: JAVA, @@ -765,6 +821,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["netcoredbg"], }, + fallback: None, version: V, homepage: "https://github.com/Samsung/netcoredbg", needs: &[], @@ -783,6 +840,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["bashDebug.js"], }, + fallback: None, version: V, homepage: "https://github.com/rogalmic/vscode-bash-debug", needs: NODE, @@ -801,6 +859,7 @@ pub static TOOLS: &[ToolSpec] = &[ ], locate: &["phpDebug.js"], }, + fallback: None, version: V, homepage: "https://github.com/xdebug/vscode-php-debug", needs: NODE, @@ -811,6 +870,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["ruby-lsp"], method: Method::Gem { gem: "ruby-lsp" }, + fallback: None, version: V, homepage: "https://shopify.github.io/ruby-lsp", needs: &[], @@ -821,6 +881,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["solargraph"], method: Method::Gem { gem: "solargraph" }, + fallback: None, version: ver("solargraph"), homepage: "https://solargraph.org", needs: &[], @@ -831,6 +892,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["csharp-ls"], method: Method::DotnetTool { package: "csharp-ls" }, + fallback: None, version: V, homepage: "https://github.com/razzmatazz/csharp-language-server", needs: &[], @@ -841,6 +903,7 @@ pub static TOOLS: &[ToolSpec] = &[ kind: LanguageServer, commands: &["fsautocomplete"], method: Method::DotnetTool { package: "fsautocomplete" }, + fallback: None, version: V, homepage: "https://github.com/ionide/FsAutoComplete", needs: &[], diff --git a/crates/ide-tools/src/sources.rs b/crates/ide-tools/src/sources.rs index aa10a9852..09ec12a4b 100644 --- a/crates/ide-tools/src/sources.rs +++ b/crates/ide-tools/src/sources.rs @@ -177,6 +177,8 @@ pub fn jdtls_download(version: &str, latest_txt: &str) -> IdeResult { #[derive(Deserialize)] struct VsxExtension { version: String, + #[serde(default)] + downloads: std::collections::BTreeMap, files: VsxFiles, } @@ -191,7 +193,22 @@ pub fn openvsx_latest_url(namespace: &str, extension: &str) -> String { format!("https://open-vsx.org/api/{namespace}/{extension}/latest") } -/// The `.vsix` of an Open VSX `…/latest` response (platform-independent extensions only). +/// Select platform metadata when the universal package does not match the asset rules. +pub fn openvsx_platform_url(json: &str, rules: &[AssetRule], platform: Platform) -> IdeResult> { + let ext: VsxExtension = + serde_json::from_str(json).map_err(|e| proto(format!("unexpected Open VSX response: {e}")))?; + if select_asset(&[file_name(&ext.files.download)], rules, platform).is_some() { + return Ok(None); + } + let names: Vec<&str> = ext.downloads.values().map(|url| file_name(url)).collect(); + let name = select_asset(&names, rules, platform) + .ok_or_else(|| IdeError::unavailable("No Open VSX package for this platform"))?; + let url = ext.downloads.values().find(|url| file_name(url) == name).expect("selected download"); + let (metadata, _) = url.rsplit_once("/file/").ok_or_else(|| proto("unexpected Open VSX download URL"))?; + Ok(Some(metadata.to_string())) +} + +/// The `.vsix` of an Open VSX `…/latest` response. pub fn openvsx_pick(json: &str, rules: &[AssetRule], platform: Platform) -> IdeResult { let ext: VsxExtension = serde_json::from_str(json).map_err(|e| proto(format!("unexpected Open VSX response: {e}")))?; @@ -355,6 +372,56 @@ mod tests { assert!(openvsx_pick(&other, rules, LINUX).is_err()); } + #[test] + fn openvsx_rust_analyzer_platform_packages() { + use crate::platform::{Arch, Os}; + let crate::registry::Method::Release { assets, .. } = find("rust-analyzer").unwrap().fallback.unwrap() else { + panic!("expected release fallback") + }; + let targets = [ + (Os::Mac, Arch::Arm64, "darwin-arm64"), + (Os::Mac, Arch::X64, "darwin-x64"), + (Os::Linux, Arch::Arm64, "linux-arm64"), + (Os::Linux, Arch::X64, "linux-x64"), + (Os::Windows, Arch::Arm64, "win32-arm64"), + (Os::Windows, Arch::X64, "win32-x64"), + ]; + let base = "https://open-vsx.org/api/rust-lang/rust-analyzer"; + let downloads: serde_json::Map = targets + .iter() + .map(|(_, _, target)| { + ( + target.to_string(), + format!("{base}/{target}/1.0.0/file/rust-lang.rust-analyzer-1.0.0@{target}.vsix").into(), + ) + }) + .collect(); + let universal = serde_json::json!({ + "version": "1.0.0", "downloads": downloads, + "files": {"download": format!("{base}/1.0.0/file/rust-lang.rust-analyzer-1.0.0.vsix")} + }) + .to_string(); + for (os, arch, target) in targets { + let platform = Platform { os, arch }; + assert_eq!( + openvsx_platform_url(&universal, assets, platform).unwrap(), + Some(format!("{base}/{target}/1.0.0")) + ); + let json = serde_json::json!({ + "version": "1.0.0", + "files": { + "download": downloads[target], + "sha256": format!("{base}/{target}/1.0.0/file/rust-lang.rust-analyzer-1.0.0@{target}.sha256") + } + }) + .to_string(); + let download = openvsx_pick(&json, assets, platform).unwrap(); + assert!(download.name.ends_with(&format!("@{target}.vsix"))); + assert!(download.checksum_url.unwrap().ends_with(&format!("@{target}.sha256"))); + } + assert!(openvsx_platform_url(&universal.replace("downloads", "unused"), assets, MAC_ARM).is_err()); + } + #[test] fn maven_central() { let xml = diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index b5cba6656..e6aa29c27 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -65,8 +65,8 @@ fn everything_missing_on_an_empty_system() { let ra = find(&list, "rust-analyzer"); assert_eq!(ra.status, ToolStatus::Missing); assert_eq!(ra.languages, vec!["Rust".to_string()]); - assert!(!ra.can_install, "rustup is not on the fake PATH"); - assert!(ra.unavailable_reason.as_deref().unwrap().contains("rustup")); + assert!(!ra.can_install, "curl is not on the fake PATH"); + assert!(ra.unavailable_reason.as_deref().unwrap().contains("curl")); let vtsls = find(&list, "vtsls"); assert!(vtsls.languages.iter().any(|l| l == "TypeScript")); assert!(vtsls.unavailable_reason.as_deref().unwrap().contains("npm")); @@ -552,3 +552,74 @@ fn the_installing_request_can_still_cancel_its_shared_install() { assert!(slot.cancel.is_cancelled()); release_install_slot("taplo", &slot); } + +#[cfg(unix)] +#[test] +fn rust_analyzer_selects_openvsx_without_rustup() { + let env = Env::new(); + env.script(&env.sys, "curl", "exit 0"); + let tools = env.tools(); + let spec = registry::find("rust-analyzer").unwrap(); + assert!(matches!( + tools.install_method(spec), + Method::Release { source: ReleaseSource::OpenVsx { namespace: "rust-lang", extension: "rust-analyzer" }, .. } + )); + let info = tools.info("rust-analyzer", &langs()).unwrap(); + assert_eq!(info.status, ToolStatus::Missing); + assert_eq!(info.install_method.as_deref(), Some("Open VSX")); + assert!(info.can_install); + assert!(info.unavailable_reason.is_none()); + assert!(bin_dirs_in(&env.root, Platform::current()).contains(&env.root.join("rust-analyzer/bin"))); +} + +#[cfg(unix)] +static RUST_ANALYZER_INSTALL: parking_lot::Mutex<()> = parking_lot::Mutex::new(()); + +#[cfg(unix)] +#[test] +fn rust_analyzer_prefers_rustup_and_installs_component() { + let _guard = RUST_ANALYZER_INSTALL.lock(); + let env = Env::new(); + env.script(&env.sys, "rustup", "test \"$*\" = 'component add rust-analyzer'"); + env.script(&env.sys, "rust-analyzer", "echo 'rust-analyzer 1.0.0'"); + let tools = env.tools(); + assert!(matches!( + tools.install_method(registry::find("rust-analyzer").unwrap()), + Method::Rustup { component: "rust-analyzer" } + )); + let info = tools.info("rust-analyzer", &langs()).unwrap(); + assert_eq!(info.install_method.as_deref(), Some("rustup")); + assert!(info.can_install, "rustup does not require curl"); + let mut lines = Vec::new(); + let emit = parking_lot::Mutex::new(&mut lines); + tools + .install("rust-analyzer", &langs(), &CancellationToken::new(), &|event| { + if let InstallEvent::Line { text } = event { + emit.lock().push(text); + } + }) + .unwrap(); + assert!(lines.iter().any(|line| line.contains("via rustup"))); +} + +#[cfg(unix)] +#[test] +fn rust_analyzer_managed_copy_survives_rustup_becoming_available() { + let _guard = RUST_ANALYZER_INSTALL.lock(); + let env = Env::new(); + let bin = env.root.join("rust-analyzer/bin"); + env.script(&bin, "rust-analyzer", "echo 'rust-analyzer 1.0.0'"); + for with_rustup in [false, true] { + if with_rustup { + env.script(&env.sys, "rustup", "exit 1"); + env.script(&env.sys, "rust-analyzer", "exit 1"); + } + let info = env.tools().info("rust-analyzer", &langs()).unwrap(); + assert_eq!(info.status, ToolStatus::Installed); + assert_eq!(info.path.as_deref(), Some(bin.join("rust-analyzer").as_path())); + assert_eq!(info.install_method.as_deref(), Some("Open VSX"), "Reinstall refreshes the copy in use"); + assert!(info.can_uninstall); + } + env.tools().uninstall("rust-analyzer", &langs()).unwrap(); + assert!(!env.root.join("rust-analyzer").exists()); +} diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 1c3509bc3..6777005fd 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -2,12 +2,12 @@ //! writes (commit, checkout, push/pull, stash, merge) shell out to `git` so //! hooks, credentials and signing behave exactly as on the command line. -use encoding_rs::Encoding; use ide_core::ignored::IgnoredNames; use ide_core::{CancellationToken, IdeResult}; use serde::{Deserialize, Serialize}; use specta::Type; use std::path::{Path, PathBuf}; +pub use text_encoding::TextEncoding; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -540,7 +540,13 @@ impl Repo { } /// Content of `path` at `rev` (default HEAD); `None` if absent there. /// `rev` may be any revision git understands, or `":"` / `":0"` for the index. - pub fn file_at(&self, path: &Path, rev: Option<&str>, encoding: &'static Encoding) -> IdeResult> { + pub fn file_at( + &self, + path: &Path, + rev: Option<&str>, + encoding: impl Into, + ) -> IdeResult> { + let encoding = encoding.into(); let rel = self.rel_path(path)?; let repo = self.gix()?; Ok(repo::blob_at(&repo, &rel, rev.unwrap_or("HEAD"))?.map(|b| text_encoding::decode(&b, encoding).into_owned())) @@ -555,8 +561,9 @@ impl Repo { path: &Path, current: &str, selected: &[usize], - encoding: &'static Encoding, + encoding: impl Into, ) -> IdeResult<()> { + let encoding = encoding.into(); write::stage_hunks(self, path, current, selected, encoding) } /// Hunks of `current_text` against HEAD (or the index when `against_index`). @@ -570,8 +577,9 @@ impl Repo { path: &Path, current_text: &str, against_index: bool, - encoding: &'static Encoding, + encoding: impl Into, ) -> IdeResult> { + let encoding = encoding.into(); let Ok(rel) = self.rel_path(path) else { return Ok(Vec::new()) }; if rel.is_empty() || rel == ".git" || rel.starts_with(".git/") { return Ok(Vec::new()); @@ -598,8 +606,9 @@ impl Repo { path: &Path, current_text: Option<&str>, revision: Option<&str>, - encoding: &'static Encoding, + encoding: impl Into, ) -> IdeResult> { + let encoding = encoding.into(); write::blame(self, path, current_text, revision, encoding) } /// Commits newest-first in topological order (children before parents). @@ -631,7 +640,8 @@ impl Repo { } /// Unified diff of a file: index↔worktree (`staged == false`, untracked files diff /// against /dev/null) or HEAD↔index (`staged == true`). Empty when unchanged. - pub fn diff_text(&self, path: &Path, staged: bool, encoding: &'static Encoding) -> IdeResult { + pub fn diff_text(&self, path: &Path, staged: bool, encoding: impl Into) -> IdeResult { + let encoding = encoding.into(); write::diff_text(self, path, staged, encoding) } @@ -650,8 +660,12 @@ impl Repo { } /// Applies a unified-diff patch to the index (partial staging of hunks/lines). /// Zero-context patches (see [`build_partial_patch`]) are accepted. - pub fn stage_patch(&self, patch: &str, encoding: &dyn Fn(&Path) -> IdeResult<&'static Encoding>) -> IdeResult<()> { - write::stage_patch(self, patch, encoding) + pub fn stage_patch>( + &self, + patch: &str, + encoding: &dyn Fn(&Path) -> IdeResult, + ) -> IdeResult<()> { + write::stage_patch(self, patch, &|path| encoding(path).map(Into::into)) } /// Discards unstaged changes: tracked paths are restored from the index (staged /// changes are kept), untracked files and directories are deleted. @@ -662,16 +676,16 @@ impl Repo { /// `amend` keeps the previous message. pub fn commit(&self, message: &str, amend: bool, sign_off: bool) -> IdeResult { let options = CommitOptions { amend, sign_off, author: None, run_hooks: true, partial: Vec::new() }; - write::commit(self, message, &options, &|_| Ok(encoding_rs::UTF_8)) + write::commit(self, message, &options, &|_| Ok(encoding_rs::UTF_8.into())) } /// Commits with the Commit Options popup's choices. - pub fn commit_with( + pub fn commit_with>( &self, message: &str, options: &CommitOptions, - encoding: &dyn Fn(&Path) -> IdeResult<&'static Encoding>, + encoding: &dyn Fn(&Path) -> IdeResult, ) -> IdeResult { - write::commit(self, message, options, encoding) + write::commit(self, message, options, &|path| encoding(path).map(Into::into)) } /// Switches to `branch`. With `create`, creates it at HEAD. Without, a remote branch /// (`origin/x`) gets a local tracking branch and any other revision is checked out @@ -897,7 +911,7 @@ impl Repo { /// Patch of the local changes of `paths` against HEAD (untracked files included). pub fn create_patch(&self, paths: &[PathBuf], reverse: bool) -> IdeResult { let rel = self.rel_paths(paths)?; - Ok(patch::working_patch(self, &rel, reverse)?.0) + Ok(patch::working_patch(self, &rel, reverse, true)?.0) } /// Patch of the changes made by `commits` (oldest first). pub fn create_commits_patch(&self, commits: &[String], reverse: bool) -> IdeResult { @@ -925,7 +939,8 @@ impl Repo { } /// The versions of a conflicted file with the user's work as `ours`, as IDEA's merge /// dialog shows them (sides swapped during a rebase). - pub fn merge_versions(&self, path: &Path, encoding: &'static Encoding) -> IdeResult { + pub fn merge_versions(&self, path: &Path, encoding: impl Into) -> IdeResult { + let encoding = encoding.into(); let (base, ours, theirs) = self.conflict_versions(path, encoding)?; let sides = self.merge_sides()?; let (ours, theirs) = if sides.reversed { (theirs, ours) } else { (ours, theirs) }; @@ -939,8 +954,9 @@ impl Repo { pub fn conflict_versions( &self, path: &Path, - encoding: &'static Encoding, + encoding: impl Into, ) -> IdeResult<(Option, Option, Option)> { + let encoding = encoding.into(); let rel = self.rel_path(path)?; let repo = self.gix()?; let stage = |n: u8| -> IdeResult> { diff --git a/crates/ide-vcs/src/patch.rs b/crates/ide-vcs/src/patch.rs index 15610cf8a..5f40ae750 100644 --- a/crates/ide-vcs/src/patch.rs +++ b/crates/ide-vcs/src/patch.rs @@ -36,7 +36,12 @@ impl Drop for TempIndex { /// Binary-safe patch of the working-tree changes (staged or not, untracked files /// included) of `rel_paths` against HEAD, plus the files it touches (renames split /// into delete + add). The real index is left untouched. -pub(crate) fn working_patch(r: &Repo, rel_paths: &[String], reverse: bool) -> IdeResult<(String, Vec)> { +pub(crate) fn working_patch( + r: &Repo, + rel_paths: &[String], + reverse: bool, + detect_renames: bool, +) -> IdeResult<(String, Vec)> { if rel_paths.is_empty() { return Err(IdeError::invalid("no files selected")); } @@ -48,8 +53,16 @@ pub(crate) fn working_patch(r: &Repo, rel_paths: &[String], reverse: bool) -> Id run_env(r.root(), &["read-tree", "--empty"], None, &env)?; } cli::run_paths_env(r.root(), &["--literal-pathspecs", "add", "-A", "--"], rel_paths, None, &env)?; - let mut diff = - vec!["diff", "--cached", "--binary", "--full-index", "-M", "--no-color", "--no-ext-diff", "--no-textconv"]; + let mut diff = vec![ + "diff", + "--cached", + "--binary", + "--full-index", + if detect_renames { "-M" } else { "--no-renames" }, + "--no-color", + "--no-ext-diff", + "--no-textconv", + ]; if reverse { diff.push("-R"); } @@ -163,13 +176,49 @@ fn unmerged(r: &Repo) -> IdeResult> { Ok(out.stdout_str().split('\0').filter(|s| !s.is_empty()).map(str::to_string).collect()) } +fn git_path(p: &str) -> String { + let Some(p) = p.strip_prefix('"').and_then(|p| p.strip_suffix('"')) else { return p.to_string() }; + let mut bytes = Vec::new(); + let mut chars = p.bytes().peekable(); + while let Some(c) = chars.next() { + if c != b'\\' { + bytes.push(c); + continue; + } + let Some(c) = chars.next() else { break }; + bytes.push(match c { + b'n' => b'\n', + b't' => b'\t', + b'r' => b'\r', + b'a' => 7, + b'b' => 8, + b'v' => 11, + b'f' => 12, + b'0'..=b'7' => { + let mut n = c - b'0'; + for _ in 0..2 { + if let Some(d @ b'0'..=b'7') = chars.peek().copied() { + chars.next(); + n = n.wrapping_mul(8).wrapping_add(d - b'0'); + } else { + break; + } + } + n + } + c => c, + }); + } + String::from_utf8_lossy(&bytes).into_owned() +} + fn strip_prefix(p: &str) -> Option { - let p = p.split('\t').next().unwrap_or(p).trim_end(); + let p = if p.starts_with('"') { p } else { p.split('\t').next().unwrap_or(p) }; if p == "/dev/null" { return None; } - let p = p.strip_prefix("\"").and_then(|q| q.strip_suffix('"')).unwrap_or(p); - Some(p.strip_prefix("a/").or_else(|| p.strip_prefix("b/")).unwrap_or(p).to_string()) + let p = git_path(p); + Some(p.strip_prefix("a/").or_else(|| p.strip_prefix("b/")).unwrap_or(&p).to_string()) } /// Files changed by a unified diff (git extended headers or plain `---`/`+++`). @@ -210,7 +259,12 @@ pub(crate) fn parse_patch(patch: &str) -> Vec { } if let Some(rest) = line.strip_prefix("diff --git ") { out.extend(cur.take()); - let path = rest.rsplit_once(" b/").map(|(_, b)| b.to_string()).unwrap_or_else(|| rest.to_string()); + let path = rest + .rsplit_once(" \"b/") + .map(|(_, b)| git_path(&format!("\"{b}"))) + .or_else(|| rest.rsplit_once(" b/").map(|(_, b)| b.to_string())) + .unwrap_or_else(|| rest.to_string()); + let path = path.strip_prefix("b/").unwrap_or(&path).to_string(); cur = Some(new_file(path)); fresh_header = true; } else if let Some(rest) = line.strip_prefix("--- ") { @@ -249,10 +303,10 @@ pub(crate) fn parse_patch(patch: &str) -> Vec { } else if line.starts_with("deleted file mode") { c.kind = ChangeKind::Deleted; } else if let Some(p) = line.strip_prefix("rename from ") { - c.old_path = Some(p.to_string()); + c.old_path = Some(git_path(p)); c.kind = ChangeKind::Renamed; } else if let Some(p) = line.strip_prefix("rename to ") { - c.path = p.to_string(); + c.path = git_path(p); } else if line.starts_with("GIT binary patch") || line.starts_with("Binary files ") { c.binary = true; } @@ -295,7 +349,7 @@ fn lossless_diff(r: &Repo, args: &[&str], env: &[(&str, &OsStr)]) -> IdeResult IdeResult<&'static encoding_rs::Encoding>, + encoding: &dyn Fn(&std::path::Path) -> IdeResult, ) -> IdeResult> { let mut out = Vec::new(); let mut header = String::new(); @@ -325,6 +379,52 @@ pub(crate) fn encode_text_patch( Ok(out) } +/// Apply displayed-text line selections in a temporary UTF-8 index, then +/// serialize the result. Literal escape spelling in the real index must not +/// prevent a selected patch from matching its decoded base. +pub(crate) fn stage_converted_patch( + r: &Repo, + patch: &str, + encoding: &dyn Fn(&std::path::Path) -> IdeResult, +) -> IdeResult<()> { + use crate::text_encoding::{decode, encode, has_bom}; + let tmp = TempIndex::new(r); + let env = tmp.env(); + let index = r.git_dir.join("index"); + if index.exists() { + std::fs::copy(index, &tmp.0)?; + } else { + run_env(r.root(), &["read-tree", "--empty"], None, &env)?; + } + let mut files = Vec::new(); + for file in parse_patch(patch) { + let path = r.abs_path(&file.path); + let enc = encoding(&path)?; + let original = r.file_bytes_at(&path, Some(":0"))?; + let bom = original.as_ref().is_some_and(|bytes| has_bom(bytes, enc)); + if let Some(original) = original { + crate::write::write_index_blob(r, &file.path, decode(&original, enc).as_bytes(), &env)?; + } + files.push((file.path, enc, bom)); + } + run_env( + r.root(), + &["apply", "--cached", "--unidiff-zero", "--whitespace=nowarn", "--recount", "-"], + Some(patch.as_bytes()), + &env, + )?; + for (rel, enc, bom) in files { + let staged = cli::run_raw_env(r.root(), &["show", &format!(":0:{rel}")], None, &env)?; + if staged.code == Some(0) { + let text = std::str::from_utf8(&staged.stdout).map_err(|e| IdeError::invalid(e.to_string()))?; + crate::write::write_index_blob(r, &rel, &encode(text, enc, bom)?, &[])?; + } else { + run(r.root(), &["--literal-pathspecs", "update-index", "--force-remove", "--", &rel], None)?; + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ide-vcs/src/shelf.rs b/crates/ide-vcs/src/shelf.rs index 867a65d06..502867130 100644 --- a/crates/ide-vcs/src/shelf.rs +++ b/crates/ide-vcs/src/shelf.rs @@ -2,7 +2,7 @@ //! `.idea/shelf`. Each shelved change list is `//shelved.patch` plus //! `//shelf.json` with its name, date and files. -use crate::cli::run_paths; +use crate::cli::{run_env, run_paths}; use crate::{ApplyOutcome, ChangeKind, Repo, ShelvedChangeList}; use ide_core::{IdeError, IdeResult}; use serde::{Deserialize, Serialize}; @@ -81,7 +81,10 @@ pub fn list(shelf: &Path) -> IdeResult> { name: info.name, time_ms: info.time_ms, branch: info.branch, - files: info.files, + files: crate::patch::parse_patch(&std::fs::read_to_string(dir.join(PATCH))?) + .into_iter() + .map(|f| crate::ChangedFile { path: f.path, kind: f.kind, old_path: f.old_path }) + .collect(), }); } out.sort_by(|a, b| b.time_ms.total_cmp(&a.time_ms).then_with(|| a.name.cmp(&b.name))); @@ -117,7 +120,7 @@ pub(crate) fn shelve(r: &Repo, shelf: &Path, name: &str, rel_paths: &[String]) - if name.is_empty() { return Err(IdeError::invalid("name must not be empty")); } - let (patch, files) = crate::patch::working_patch(r, rel_paths, false)?; + let (patch, files) = crate::patch::working_patch(r, rel_paths, false, false)?; if files.is_empty() || patch.trim().is_empty() { return Err(IdeError::invalid("no changes to shelve")); } @@ -173,14 +176,174 @@ fn revert_files(r: &Repo, files: &[crate::ChangedFile]) -> IdeResult<()> { /// Applies a shelved change list; with `remove`, deletes it from the shelf once it /// applied without conflicts (conflicts are left for the merge UI and the entry is kept). pub(crate) fn unshelve(r: &Repo, shelf: &Path, id: &str, remove: bool) -> IdeResult { - let patch = patch_of(shelf, id)?; + unshelve_path(r, shelf, id, remove, None) +} + +pub fn unshelve_path(r: &Repo, shelf: &Path, id: &str, remove: bool, path: Option<&str>) -> IdeResult { + let patch = selected_patch(shelf, id, path)?; let outcome = crate::patch::apply(r, &patch)?; if remove && outcome.conflicts.is_empty() { - delete(shelf, id)?; + delete_path(shelf, id, path)?; } Ok(outcome) } +#[derive(Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ShelfDiffFile { + pub path: String, + pub patch: String, + pub before: Option, + pub after: Option, +} + +fn sections(patch: &str) -> Vec<(&str, crate::PatchFile)> { + let mut starts: Vec<_> = patch + .match_indices("diff --git ") + .filter(|(i, _)| *i == 0 || patch.as_bytes()[i - 1] == b'\n') + .map(|(i, _)| i) + .collect(); + if starts.is_empty() { + let (mut old, mut new) = (0u32, 0u32); + let mut offset = 0; + let mut lines = patch.split_inclusive('\n').peekable(); + while let Some(line) = lines.next() { + if old > 0 || new > 0 { + match line.as_bytes().first() { + Some(b'-') => old = old.saturating_sub(1), + Some(b'+') => new = new.saturating_sub(1), + Some(b' ') => { + old = old.saturating_sub(1); + new = new.saturating_sub(1); + } + _ => {} + } + } else if line.starts_with("--- ") && lines.peek().is_some_and(|l| l.starts_with("+++ ")) { + starts.push(offset); + } + if let Some(range) = line.strip_prefix("@@ ") { + let mut parts = range.split_whitespace(); + let count = |s: &str| s.split_once(',').map_or(1, |(_, n)| n.parse().unwrap_or(0)); + old = parts.next().map(count).unwrap_or(0); + new = parts.next().map(count).unwrap_or(0); + } + offset += line.len(); + } + } + starts + .iter() + .enumerate() + .filter_map(|(n, &start)| { + let section = &patch[start..starts.get(n + 1).copied().unwrap_or(patch.len())]; + crate::patch::parse_patch(section).into_iter().next().map(|file| (section, file)) + }) + .collect() +} + +pub fn selected_patch(shelf: &Path, id: &str, path: Option<&str>) -> IdeResult { + let patch = patch_of(shelf, id)?; + let Some(path) = path else { return Ok(patch) }; + let selected: String = sections(&patch).into_iter().filter(|(_, f)| f.path == path).map(|(p, _)| p).collect(); + if selected.is_empty() { + return Err(IdeError::not_found("shelved file not found")); + } + Ok(selected) +} + +pub fn delete_path(shelf: &Path, id: &str, path: Option<&str>) -> IdeResult<()> { + let Some(path) = path else { return delete(shelf, id) }; + selected_patch(shelf, id, Some(path))?; + let patch = patch_of(shelf, id)?; + let remaining: String = sections(&patch).into_iter().filter(|(_, f)| f.path != path).map(|(p, _)| p).collect(); + if remaining.is_empty() { + return delete(shelf, id); + } + let dir = entry_dir(shelf, id)?; + let mut info = read_info(&dir).ok_or_else(|| IdeError::invalid("shelf metadata is missing"))?; + info.files = crate::patch::parse_patch(&remaining) + .into_iter() + .map(|f| crate::ChangedFile { path: f.path, kind: f.kind, old_path: f.old_path }) + .collect(); + std::fs::write(dir.join(PATCH), remaining)?; + std::fs::write(dir.join(INFO), serde_json::to_vec_pretty(&info).map_err(|e| IdeError::internal(e.to_string()))?)?; + Ok(()) +} + +/// Reconstruct each side in a throwaway index; neither HEAD nor the working files are used. +pub fn diff_files(r: &Repo, shelf: &Path, id: &str) -> IdeResult> { + let patch = patch_of(shelf, id)?; + let mut out = Vec::new(); + for (section, file) in sections(&patch) { + let mut item = ShelfDiffFile { path: file.path.clone(), patch: section.to_string(), before: None, after: None }; + if !file.binary { + let reconstruct = || -> IdeResult<(String, String)> { + let temp = tempfile::tempdir()?; + let index = temp.path().join("index"); + let env = [("GIT_INDEX_FILE", index.as_os_str())]; + run_env( + r.root(), + &["apply", &format!("--build-fake-ancestor={}", index.display()), "-"], + Some(section.as_bytes()), + &env, + )?; + let read = |path: &str| -> IdeResult { + let bytes = run_env(r.root(), &["show", &format!(":{path}")], None, &env)?.stdout; + if bytes.contains(&0) { + return Err(IdeError::invalid("binary file")); + } + String::from_utf8(bytes).map_err(|_| IdeError::invalid("non-UTF-8 file")) + }; + let before = if file.kind == ChangeKind::Added { + String::new() + } else { + read(file.old_path.as_deref().unwrap_or(&file.path))? + }; + run_env( + r.root(), + &["apply", "--cached", "--binary", "--whitespace=nowarn", "-"], + Some(section.as_bytes()), + &env, + )?; + let after = if file.kind == ChangeKind::Deleted { String::new() } else { read(&file.path)? }; + Ok((before, after)) + }; + if let Ok((before, after)) = reconstruct() { + item.before = Some(before); + item.after = Some(after); + } + } + out.push(item); + } + Ok(out) +} + +/// Import into the shelf without applying to the working tree. +pub fn import_patch(shelf: &Path, name: &str, patch: &str) -> IdeResult<()> { + let files = crate::patch::parse_patch(patch); + if files.is_empty() || sections(patch).len() != files.len() { + return Err(IdeError::invalid("no file changes found in the patch")); + } + std::fs::create_dir_all(shelf)?; + let mut id = slug(name); + while shelf.join(&id).exists() { + id.push('_'); + } + let dir = shelf.join(id); + std::fs::create_dir(&dir)?; + let info = Info { + name: name.to_string(), + time_ms: now_ms(), + branch: None, + files: files + .into_iter() + .map(|f| crate::ChangedFile { path: f.path, kind: f.kind, old_path: f.old_path }) + .collect(), + }; + std::fs::write(dir.join(PATCH), patch)?; + std::fs::write(dir.join(INFO), serde_json::to_vec_pretty(&info).map_err(|e| IdeError::internal(e.to_string()))?)?; + Ok(()) +} + #[cfg(test)] mod tests { use super::slug; diff --git a/crates/ide-vcs/src/text_encoding.rs b/crates/ide-vcs/src/text_encoding.rs index 9d23171a4..038256a95 100644 --- a/crates/ide-vcs/src/text_encoding.rs +++ b/crates/ide-vcs/src/text_encoding.rs @@ -3,23 +3,77 @@ use encoding_rs::{Encoding, UTF_8, UTF_16BE, UTF_16LE}; use ide_core::{IdeError, IdeResult}; use std::borrow::Cow; -pub(crate) fn has_bom(bytes: &[u8], encoding: &'static Encoding) -> bool { - Encoding::for_bom(bytes).is_some_and(|(enc, _)| enc == encoding) +/// File charset plus an optional reversible ASCII representation (for properties). +/// The host supplies conversion functions so Git and documents share one codec. +#[derive(Clone, Copy)] +pub struct TextEncoding { + pub charset: &'static Encoding, + ascii_conversion: Option, } -pub(crate) fn decode<'a>(bytes: &'a [u8], encoding: &'static Encoding) -> Cow<'a, str> { +#[derive(Clone, Copy)] +struct AsciiConversion { + decode: fn(&str) -> String, + encode: fn(&str) -> String, +} + +impl From<&'static Encoding> for TextEncoding { + fn from(charset: &'static Encoding) -> Self { + Self { charset, ascii_conversion: None } + } +} + +impl TextEncoding { + pub(crate) fn display_text(self, text: &str) -> String { + match self.ascii_conversion { + Some(conversion) => (conversion.decode)(text), + None => text.to_owned(), + } + } + + pub(crate) fn has_ascii_conversion(self) -> bool { + self.ascii_conversion.is_some() + } + + pub fn with_ascii_conversion(mut self, decode: fn(&str) -> String, encode: fn(&str) -> String) -> Self { + self.ascii_conversion = Some(AsciiConversion { decode, encode }); + self + } +} + +pub(crate) fn has_bom(bytes: &[u8], encoding: TextEncoding) -> bool { + Encoding::for_bom(bytes).is_some_and(|(enc, _)| enc == encoding.charset) +} + +pub(crate) fn decode_raw<'a>(bytes: &'a [u8], encoding: TextEncoding) -> Cow<'a, str> { let body = match Encoding::for_bom(bytes) { - Some((enc, len)) if enc == encoding => &bytes[len..], + Some((enc, len)) if enc == encoding.charset => &bytes[len..], _ => bytes, }; - encoding.decode_without_bom_handling(body).0 + if encoding.ascii_conversion.is_some() && body.is_ascii() { + Cow::Borrowed(std::str::from_utf8(body).expect("ASCII")) + } else { + encoding.charset.decode_without_bom_handling(body).0 + } } -pub(crate) fn is_binary(bytes: &[u8], encoding: &'static Encoding) -> bool { - encoding != UTF_16LE && encoding != UTF_16BE && bytes.contains(&0) +pub(crate) fn decode<'a>(bytes: &'a [u8], encoding: TextEncoding) -> Cow<'a, str> { + let text = decode_raw(bytes, encoding); + match encoding.ascii_conversion { + Some(conversion) => Cow::Owned((conversion.decode)(&text)), + None => text, + } } -pub(crate) fn encode(text: &str, encoding: &'static Encoding, bom: bool) -> IdeResult> { +pub(crate) fn is_binary(bytes: &[u8], encoding: TextEncoding) -> bool { + encoding.charset != UTF_16LE && encoding.charset != UTF_16BE && bytes.contains(&0) +} + +pub(crate) fn encode(text: &str, encoding: TextEncoding, bom: bool) -> IdeResult> { + if let Some(conversion) = encoding.ascii_conversion { + return Ok((conversion.encode)(text).into_bytes()); + } + let encoding = encoding.charset; let mut out = Vec::new(); if encoding == UTF_16LE || encoding == UTF_16BE { let le = encoding == UTF_16LE; diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index 535be279d..0b45d3c8c 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -1,5 +1,6 @@ //! Operations delegated to the git CLI (all writes, plus blame and textual diffs). +use crate::TextEncoding; use crate::cli::{self, run}; use crate::repo::{gx, resolve}; use crate::text_encoding::{decode, encode, has_bom, is_binary}; @@ -7,7 +8,6 @@ use crate::{ BlameLine, CheckoutMode, CheckoutOptions, CheckoutOutcome, CommitOptions, DeletedBranch, DivergedLocal, PushBranchOptions, Repo, }; -use encoding_rs::Encoding; use gix::bstr::{BStr, ByteSlice}; use ide_core::{CancellationToken, IdeError, IdeResult}; use std::path::{Path, PathBuf}; @@ -46,7 +46,7 @@ pub(crate) fn blame( path: &Path, current_text: Option<&str>, revision: Option<&str>, - encoding: &'static Encoding, + encoding: TextEncoding, ) -> IdeResult> { let rel = r.rel_path(path)?; let revision = match revision { @@ -59,7 +59,11 @@ pub(crate) fn blame( Err(error) => return Err(error.into()), }; let text = current_text.map(str::to_owned).unwrap_or_else(|| decode(&disk, encoding).into_owned()); + if encoding.has_ascii_conversion() { + return converted_blame(r, path, &rel, &text, revision, encoding); + } let contents = match current_text { + Some(_) if crate::diff::normalize_eol(&text) == crate::diff::normalize_eol(&decode(&disk, encoding)) => disk, Some(_) => { let disk_text = decode(&disk, encoding); let eol = if disk_text.contains("\r\n") { @@ -102,6 +106,46 @@ pub(crate) fn blame( Ok(lines) } +fn converted_blame( + r: &Repo, + path: &Path, + rel: &str, + text: &str, + revision: &str, + encoding: TextEncoding, +) -> IdeResult> { + let current = crate::diff::normalize_eol(text); + let mut result = uncommitted_lines(current.split_inclusive('\n').count()); + let Some(base) = r.file_bytes_at(path, Some(revision))? else { return Ok(result) }; + let out = run(r.root(), &["--literal-pathspecs", "blame", "--porcelain", revision, "--", rel], None)?; + let physical = crate::blame::parse_porcelain(&out.stdout_str()); + let raw = crate::text_encoding::decode_raw(&base, encoding); + let raw = crate::diff::normalize_eol(&raw); + let mut logical = Vec::new(); + for (line, metadata) in raw.split_inclusive('\n').zip(physical) { + let displayed = encoding.display_text(line); + let displayed = crate::diff::normalize_eol(&displayed); + logical.extend(std::iter::repeat_n(metadata, displayed.split_inclusive('\n').count())); + } + let base = decode(&base, encoding); + let hunks = crate::diff::line_hunks(&base, ¤t); + let (mut old, mut new) = (0usize, 0usize); + for h in hunks { + let unchanged = h.start as usize - new; + for i in 0..unchanged { + if let Some(metadata) = logical.get(old + i) { + result[new + i] = metadata.clone(); + } + } + old = (h.old_start + h.old_lines) as usize; + new = (h.start + h.lines) as usize; + } + for (line, metadata) in result[new..].iter_mut().zip(&logical[old.min(logical.len())..]) { + *line = metadata.clone(); + } + Ok(result) +} + fn uncommitted_lines(n: usize) -> Vec { let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as f64).unwrap_or(0.0); @@ -115,7 +159,7 @@ fn uncommitted_lines(n: usize) -> Vec { .collect() } -pub(crate) fn diff_text(r: &Repo, path: &Path, staged: bool, encoding: &'static Encoding) -> IdeResult { +pub(crate) fn diff_text(r: &Repo, path: &Path, staged: bool, encoding: TextEncoding) -> IdeResult { let rel = r.rel_path(path)?; let base = ["-c", "diff.noprefix=false", "-c", "diff.mnemonicPrefix=false", "-c", "diff.external=", "--literal-pathspecs"]; @@ -156,11 +200,7 @@ pub(crate) fn unstage(r: &Repo, paths: &[PathBuf]) -> IdeResult<()> { } } -pub(crate) fn stage_patch( - r: &Repo, - patch: &str, - encoding: &dyn Fn(&Path) -> IdeResult<&'static Encoding>, -) -> IdeResult<()> { +pub(crate) fn stage_patch(r: &Repo, patch: &str, encoding: &dyn Fn(&Path) -> IdeResult) -> IdeResult<()> { if patch.trim().is_empty() { return Ok(()); } @@ -168,6 +208,12 @@ pub(crate) fn stage_patch( if !patch.ends_with('\n') { patch.push('\n'); } + if crate::patch::parse_patch(&patch) + .iter() + .any(|file| encoding(&r.abs_path(&file.path)).is_ok_and(TextEncoding::has_ascii_conversion)) + { + return crate::patch::stage_converted_patch(r, &patch, encoding); + } let bytes = crate::patch::encode_text_patch(r, &patch, encoding)?; run(r.root(), &["apply", "--cached", "--unidiff-zero", "--whitespace=nowarn", "--recount", "-"], Some(&bytes)) .map(|_| ()) @@ -257,7 +303,7 @@ pub(crate) fn commit( r: &Repo, message: &str, options: &CommitOptions, - encoding: &dyn Fn(&Path) -> IdeResult<&'static Encoding>, + encoding: &dyn Fn(&Path) -> IdeResult, ) -> IdeResult { let mut args = vec!["commit", "-q", "--cleanup=whitespace"]; if options.amend { @@ -311,7 +357,7 @@ fn commit_partial( commit_args: &[&str], stdin: Option<&[u8]>, partial: &[crate::PartialCommitFile], - encoding: &dyn Fn(&Path) -> IdeResult<&'static Encoding>, + encoding: &dyn Fn(&Path) -> IdeResult, ) -> IdeResult<()> { let tmp = TempIndex(r.git_dir.join("openidea-partial-index")); let index = r.git_dir.join("index"); @@ -852,7 +898,7 @@ fn push_remote(r: &Repo) -> IdeResult { .ok_or_else(|| IdeError::invalid("no remote configured")) } -fn write_index_blob(r: &Repo, rel: &str, bytes: &[u8], env: &[(&str, &std::ffi::OsStr)]) -> IdeResult<()> { +pub(crate) fn write_index_blob(r: &Repo, rel: &str, bytes: &[u8], env: &[(&str, &std::ffi::OsStr)]) -> IdeResult<()> { let entry = cli::run_env(r.root(), &["--literal-pathspecs", "ls-files", "--stage", "--", rel], None, env)?.stdout_str(); let mode = entry.split_whitespace().next().unwrap_or("100644"); @@ -866,7 +912,7 @@ pub(crate) fn stage_hunks( path: &Path, current: &str, selected: &[usize], - encoding: &'static Encoding, + encoding: TextEncoding, ) -> IdeResult<()> { if selected.is_empty() { return Ok(()); @@ -887,7 +933,7 @@ pub(crate) fn stage_hunks( } // Git headers contain UTF-8 paths; only source lines use the file's encoding. -fn decode_diff(bytes: &[u8], encoding: &'static Encoding) -> String { +fn decode_diff(bytes: &[u8], encoding: TextEncoding) -> String { let mut hunks = false; let mut text = String::new(); for line in bytes.split_inclusive(|&b| b == b'\n') { @@ -898,7 +944,7 @@ fn decode_diff(bytes: &[u8], encoding: &'static Encoding) -> String { hunks = true; } if hunks { - text.push_str(&decode(line, encoding)); + text.push_str(&crate::text_encoding::decode_raw(line, encoding)); } else { text.push_str(&String::from_utf8_lossy(line)); } diff --git a/crates/ide-vcs/tests/history_ops.rs b/crates/ide-vcs/tests/history_ops.rs index 0ef3d0b78..dd854c949 100644 --- a/crates/ide-vcs/tests/history_ops.rs +++ b/crates/ide-vcs/tests/history_ops.rs @@ -567,3 +567,98 @@ fn update_branch_fast_forwards_a_branch_not_checked_out() { // The checked-out branch and branches without upstream are refused. assert!(repo.update_branch("work", &cancel, &out).is_err()); } + +/// `"` and tabs are illegal in Windows file names, and only they make Git quote a path. +const QUOTED: &str = if cfg!(windows) { "quote '.txt" } else { "quote\"\t.txt" }; + +#[test] +fn shelf_diff_and_scoped_operations_preserve_siblings() { + let fx = Fixture::new(); + for path in ["a.txt", "b.txt", QUOTED, "gone.txt"] { + fx.write(path, "base\n"); + } + fx.commit_all("base"); + for path in ["a.txt", "b.txt", QUOTED] { + fx.write(path, "shelved\n"); + } + std::fs::remove_file(fx.path("gone.txt")).unwrap(); + fx.write("new.txt", "new\n"); + std::fs::write(fx.path("binary.dat"), [0, 1, 255]).unwrap(); + let repo = fx.repo(); + let shelf = fx._tmp.path().join("shelf"); + let paths = ["a.txt", "b.txt", QUOTED, "gone.txt", "new.txt", "binary.dat"].map(|p| fx.path(p)); + let entry = repo.shelve(&shelf, "work", &paths).unwrap(); + fx.write("a.txt", "new head\n"); + fx.commit_all("advance head"); + fx.write("a.txt", "local edits\n"); + let status = fx.git(&["status", "--porcelain"]); + let index = fx.git(&["ls-files", "--stage"]); + let diffs = shelf::diff_files(&repo, &shelf, &entry.id).unwrap(); + for path in ["a.txt", "b.txt", QUOTED] { + let f = diffs.iter().find(|f| f.path == path).unwrap(); + assert_eq!(f.before.as_deref(), Some("base\n"), "{path}"); + assert_eq!(f.after.as_deref(), Some("shelved\n"), "{path}"); + } + assert_eq!(diffs.iter().find(|f| f.path == "new.txt").unwrap().before.as_deref(), Some("")); + assert_eq!(diffs.iter().find(|f| f.path == "gone.txt").unwrap().after.as_deref(), Some("")); + assert!(diffs.iter().find(|f| f.path == "binary.dat").unwrap().before.is_none()); + assert_eq!(fx.git(&["status", "--porcelain"]), status); + assert_eq!(fx.git(&["ls-files", "--stage"]), index); + assert_eq!(fx.read("a.txt"), "local edits\n"); + let patch = shelf::selected_patch(&shelf, &entry.id, Some("b.txt")).unwrap(); + assert!(patch.contains("b/b.txt")); + assert!(!patch.contains("b/a.txt")); + assert!(shelf::selected_patch(&shelf, &entry.id, Some("missing")).is_err()); + shelf::unshelve_path(&repo, &shelf, &entry.id, true, Some("b.txt")).unwrap(); + assert_eq!(fx.read("b.txt"), "shelved\n"); + assert!(!shelf::list(&shelf).unwrap()[0].files.iter().any(|f| f.path == "b.txt")); + shelf::delete_path(&shelf, &entry.id, Some(QUOTED)).unwrap(); + assert!(shelf::selected_patch(&shelf, &entry.id, Some("a.txt")).is_ok()); + shelf::import_patch(&shelf, "imported", &patch).unwrap(); + assert_eq!(shelf::list(&shelf).unwrap().len(), 2); + assert_eq!(fx.read("a.txt"), "local edits\n"); +} + +#[test] +fn shelf_imported_rename_diff_and_last_file_delete() { + let fx = Fixture::new(); + fx.write("old.txt", "same\n"); + fx.commit_all("base"); + fx.git(&["mv", "old.txt", "new.txt"]); + let patch = fx.git(&["diff", "--cached", "--binary", "--full-index", "-M"]) + "\n"; + fx.git(&["reset", "--hard", "HEAD"]); + let repo = fx.repo(); + let shelf = fx._tmp.path().join("shelf"); + shelf::import_patch(&shelf, "rename", &patch).unwrap(); + let id = &shelf::list(&shelf).unwrap()[0].id; + let diffs = shelf::diff_files(&repo, &shelf, id).unwrap(); + assert_eq!(diffs.len(), 1); + assert_eq!(diffs[0].path, "new.txt"); + // A rename-only imported patch has no embedded blob ids: do not guess from HEAD. + assert!(diffs[0].before.is_none()); + assert!(diffs[0].patch.contains("rename from old.txt")); + shelf::delete_path(&shelf, id, Some("new.txt")).unwrap(); + assert!(shelf::list(&shelf).unwrap().is_empty()); +} + +#[test] +fn shelf_new_renames_keep_self_contained_sides_and_plain_imports_keep_scope() { + let fx = Fixture::new(); + fx.write("old.txt", "renamed content\n"); + fx.commit_all("base"); + fx.git(&["mv", "old.txt", "new.txt"]); + let repo = fx.repo(); + let shelf = fx._tmp.path().join("shelf"); + let entry = repo.shelve(&shelf, "rename", &[fx.path("old.txt"), fx.path("new.txt")]).unwrap(); + let diffs = shelf::diff_files(&repo, &shelf, &entry.id).unwrap(); + assert_eq!(diffs.len(), 2); + assert_eq!(diffs.iter().find(|f| f.path == "old.txt").unwrap().before.as_deref(), Some("renamed content\n")); + assert_eq!(diffs.iter().find(|f| f.path == "new.txt").unwrap().after.as_deref(), Some("renamed content\n")); + let plain = "--- a/one.txt\n+++ b/one.txt\n@@ -1 +1 @@\n--- removed\n+++ added\n--- a/two.txt\n+++ b/two.txt\n@@ -1 +1 @@\n-old\n+new\n"; + shelf::import_patch(&shelf, "plain", plain).unwrap(); + let selected = shelf::selected_patch(&shelf, "plain", Some("one.txt")).unwrap(); + assert!(selected.contains("--- removed\n+++ added")); + assert!(!selected.contains("two.txt")); + shelf::delete_path(&shelf, "plain", Some("one.txt")).unwrap(); + assert_eq!(shelf::list(&shelf).unwrap().iter().find(|s| s.id == "plain").unwrap().files[0].path, "two.txt"); +} diff --git a/crates/ide-vfs/src/encoding.rs b/crates/ide-vfs/src/encoding.rs index 9153abded..05504a1b2 100644 --- a/crates/ide-vfs/src/encoding.rs +++ b/crates/ide-vfs/src/encoding.rs @@ -22,6 +22,87 @@ pub(crate) fn encoding_label(enc: &'static Encoding) -> String { enc.name().to_ascii_lowercase() } +fn unicode_escape(text: &str) -> Option { + let digits = text.strip_prefix("\\u")?.as_bytes().get(..4)?; + digits.iter().try_fold(0u16, |n, b| Some(n * 16 + (*b as char).to_digit(16)? as u16)) +} + +fn unicode_character(text: &str) -> Option<(char, usize)> { + let unit = unicode_escape(text)?; + if (0xD800..=0xDBFF).contains(&unit) { + let low = unicode_escape(&text[6..]).filter(|low| (0xDC00..=0xDFFF).contains(low))?; + char::from_u32(0x10000 + ((unit as u32 - 0xD800) << 10) + low as u32 - 0xDC00).map(|c| (c, 12)) + } else { + // A decoded line terminator would save as a real line break and split the property. + char::from_u32(unit as u32).filter(|c| !matches!(c, '\n' | '\r')).map(|c| (c, 6)) + } +} + +/// Decodes eligible Java properties Unicode escapes once. Backslash pairs, +/// malformed escapes and unpaired surrogates retain their literal spelling. +pub fn decode_properties_escapes(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while !rest.is_empty() { + if rest.starts_with("\\\\") { + out.push_str("\\\\"); + rest = &rest[2..]; + continue; + } + if let Some((c, len)) = unicode_character(rest) { + out.push(c); + rest = &rest[len..]; + continue; + } + let c = rest.chars().next().expect("nonempty"); + out.push(c); + rest = &rest[c.len_utf8()..]; + } + out +} + +/// Serializes displayed properties text as ASCII, using uppercase UTF-16 units. +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. + out.push_str("\\u005C"); + } else { + out.push(c); + } + } else if c.is_ascii() && c != '\0' { + out.push(c); + } else { + for unit in c.encode_utf16(&mut [0; 2]) { + write!(out, "\\u{unit:04X}").expect("write to string"); + } + } + } + out +} + +/// Preserve an ASCII file's exact escape spelling when edits were undone or +/// replaced with identical displayed text. Otherwise serialize the new buffer. +pub fn save_properties_text(path: &Path, text: &str) -> IdeResult { + if let Ok(bytes) = fs::read(path) + && bytes.is_ascii() + && decode_properties_escapes(std::str::from_utf8(&bytes).expect("ASCII")) == text + { + return Ok(mtime_ms(&fs::metadata(path)?)); + } + write_atomic(path, encode_properties_ascii(text).as_bytes()) +} + /// Detects BOM-less UTF-16 from the distribution of NUL bytes in mostly-ASCII text. fn sniff_utf16(sample: &[u8]) -> Option<&'static Encoding> { if sample.len() < 4 { @@ -285,6 +366,42 @@ fn write_atomic_impl(path: &Path, bytes: &[u8], allow_in_place: bool) -> IdeResu mod tests { use super::*; + #[test] + fn properties_unicode_escapes() { + for (source, displayed) in [ + (r"test.key1=Hello", "test.key1=Hello"), + (r"test.key2=\u4F60\u597D", "test.key2=你好"), + (r"test.key3=\u4f60\u597d", "test.key3=你好"), + (r"\u0041\uD83D\uDE00!", "A😀!"), + (r"\\u0041", r"\\u0041"), + (r"\\\u0041", r"\\A"), + (r"\\\\u0041", r"\\\\u0041"), + (r"\u005Cu0041", r"\u0041"), + (r"\u123 \u12xz \uu0041 \uD800 \uDC00", r"\u123 \u12xz \uu0041 \uD800 \uDC00"), + (r"\uD800\u0041", r"\uD800A"), + (r"\uD800\uD83D\uDE00", "\\uD800😀"), + ] { + assert_eq!(decode_properties_escapes(source), displayed, "{source}"); + } + assert_eq!(encode_properties_ascii("你好😀"), r"\u4F60\u597D\uD83D\uDE00"); + 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"] { + let ascii = encode_properties_ascii(text); + assert!(ascii.is_ascii()); + assert_eq!(decode_properties_escapes(&ascii), text, "{ascii}"); + } + } + + #[test] + fn properties_line_terminator_escapes_survive_an_edit() { + let source = r"key=one\u000Atwo\u000Dthree"; + let displayed = decode_properties_escapes(source); + assert_eq!(displayed, source); + assert_eq!(encode_properties_ascii(&displayed.replace("three", "3")), r"key=one\u000Atwo\u000D3"); + } + #[test] fn strict_write_replaces_complete_contents() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-vfs/src/lib.rs b/crates/ide-vfs/src/lib.rs index f1542f3d8..55c28b71b 100644 --- a/crates/ide-vfs/src/lib.rs +++ b/crates/ide-vfs/src/lib.rs @@ -22,7 +22,8 @@ pub use archive::{ pub use bytes::{ByteRange, FileProbe, MAX_RANGE_LEN, base64, image_mime, probe, read_data_url, read_range}; use encoding::SNIFF_LEN; pub use encoding::{ - LARGE_FILE_THRESHOLD, MAX_OPEN_SIZE, READ_ONLY_THRESHOLD, encode, write_atomic, write_atomic_strict, + LARGE_FILE_THRESHOLD, MAX_OPEN_SIZE, READ_ONLY_THRESHOLD, decode_properties_escapes, encode, + encode_properties_ascii, save_properties_text, write_atomic, write_atomic_strict, }; pub use rules::ExcludeMatcher; pub use watch::{WatchMode, Watcher}; diff --git a/crates/ide-workspace/src/documents.rs b/crates/ide-workspace/src/documents.rs index 0f44819dc..2be270353 100644 --- a/crates/ide-workspace/src/documents.rs +++ b/crates/ide-workspace/src/documents.rs @@ -118,6 +118,7 @@ struct DocState { ui_mode: Option, encoding: String, has_bom: bool, + native_to_ascii: bool, read_only: bool, large: bool, editorconfig: Option, @@ -189,6 +190,72 @@ impl Documents { *self.defaults.write() = defaults; } + /// Re-decode only clean properties buffers after the conversion setting changes. + /// Runs on the host's blocking pool; dirty documents retain their current codec. + pub fn reload_properties(&self) { + for id in self.open_documents() { + let result = self.with_doc(id, |st| { + let enabled = self.file_defaults().native_to_ascii_for(&st.path); + if st.dirty || st.uri.is_some() || st.binary || enabled == st.native_to_ascii { + return Ok(()); + } + let loaded = Self::load_as(&st.path, &st.encoding, enabled)?; + self.replace_locked(id, st, &loaded.text)?; + st.native_to_ascii = enabled; + st.disk_mtime = loaded.mtime_ms; + st.disk_len = disk_len(&st.path); + Ok(()) + }); + if let Err(e) = result { + tracing::warn!("reload properties {id}: {e}"); + } + } + } + + pub fn native_to_ascii_for(&self, path: &Path) -> bool { + if let Some(id) = self.by_path(path) + && let Ok(enabled) = self.with_doc(id, |st| Ok(st.native_to_ascii)) + { + return enabled; + } + self.file_defaults().native_to_ascii_for(path) + } + + fn load_as(path: &Path, encoding: &str, native_to_ascii: bool) -> IdeResult { + let mut loaded = if native_to_ascii { + let detected = ide_vfs::load_text(path)?; + // Converted files are ASCII even when the selected source charset is UTF-16. + if detected.text.is_ascii() && !detected.binary && detected.encoding == "utf-8" { + detected + } else { + ide_vfs::load_text_as(path, encoding)? + } + } else { + ide_vfs::load_text_as(path, encoding)? + }; + if native_to_ascii { + loaded.text = ide_vfs::decode_properties_escapes(&loaded.text); + } + Ok(loaded) + } + + /// Closed-file readers use the same decoding as document opening. + pub fn load_file(&self, path: &Path) -> IdeResult { + Self::load_with_defaults(path, &self.file_defaults()) + } + + pub fn load_file_as(&self, path: &Path, encoding: &str) -> IdeResult { + Self::load_as(path, encoding, self.native_to_ascii_for(path)) + } + + fn load_with_defaults(path: &Path, defaults: &FileDefaults) -> IdeResult { + let mut loaded = ide_vfs::load_text_with_fallback(path, defaults.fallback_for(&absolute(path)))?; + if !loaded.binary && defaults.native_to_ascii_for(path) { + loaded.text = ide_vfs::decode_properties_escapes(&loaded.text); + } + Ok(loaded) + } + pub fn file_defaults(&self) -> FileDefaults { self.defaults.read().clone() } @@ -263,7 +330,7 @@ impl Documents { return Ok(info); } let defaults = self.file_defaults(); - let loaded = ide_vfs::load_text_with_fallback(path, defaults.fallback_for(&absolute(path)))?; + let loaded = Self::load_with_defaults(path, &defaults)?; let language = (self.detect_language)(path, loaded.text.lines().next()); let ui_mode = (self.detect_ui_mode)(path, &language); // Archive entries (`!/`) are read-only and kept from language @@ -305,6 +372,7 @@ impl Documents { ui_mode, encoding, has_bom, + native_to_ascii: !loaded.binary && defaults.native_to_ascii_for(path), read_only: !observed || !writable, large: loaded.large, editorconfig, @@ -362,6 +430,7 @@ impl Documents { ui_mode: None, encoding: "utf-8".into(), has_bom: false, + native_to_ascii: false, read_only: true, large, editorconfig: None, @@ -524,7 +593,12 @@ impl Documents { if !st.dirty || st.uri.is_some() { return Ok(()); } - let mtime = ide_vfs::save_text(&st.path, &st.doc.text_for_save(), &st.encoding, st.has_bom)?; + let text = st.doc.text_for_save(); + let mtime = if st.native_to_ascii { + ide_vfs::save_properties_text(&st.path, &text)? + } else { + ide_vfs::save_text(&st.path, &text, &st.encoding, st.has_bom)? + }; st.disk_mtime = mtime; st.disk_len = disk_len(&st.path); self.set_dirty(doc, st, false); @@ -582,6 +656,18 @@ impl Documents { self.with_doc(id, |st| Ok(st.doc.text())).ok() } + /// Search overlays include closed properties files when their disk spelling + /// differs from the text that opening the document would display. + pub fn search_text_of(&self, path: &Path) -> Option { + self.text_of(path).or_else(|| { + if !self.native_to_ascii_for(path) { + return None; + } + let loaded = self.load_file(path).ok()?; + (!loaded.binary && !loaded.too_large).then(|| ide_text::normalize_line_endings(&loaded.text).into_owned()) + }) + } + /// Toggle Read-only Attribute: sets the file's writable bit and, when the /// file is open, whether its document accepts edits. Returns the resulting /// read-only state. @@ -672,14 +758,18 @@ impl Documents { if st.uri.is_some() { return Err(read_only_error(&st.path)); } + ide_vfs::encode("", encoding, false)?; if reload { if st.dirty { return Err(IdeError::invalid("save or revert the unsaved changes before reloading")); } - let loaded = ide_vfs::load_text_as(&st.path, encoding)?; + let enabled = self.file_defaults().native_to_ascii_for(&st.path); + let loaded = Self::load_as(&st.path, encoding, enabled)?; + st.native_to_ascii = enabled; st.encoding = loaded.encoding.clone(); st.has_bom = loaded.has_bom; st.disk_mtime = loaded.mtime_ms; + st.disk_len = disk_len(&st.path); self.replace_locked(doc, st, &loaded.text)?; // Reloading is not an edit: the buffer matches the file again. self.set_dirty(doc, st, false); @@ -691,7 +781,9 @@ impl Documents { let utf16 = encoding.starts_with("utf-16"); let has_bom = utf16 || (st.has_bom && encoding == "utf-8"); // Fails (with the offending character) when the text is not representable. - ide_vfs::encode(&st.doc.text_for_save(), encoding, has_bom)?; + if !st.native_to_ascii { + ide_vfs::encode(&st.doc.text_for_save(), encoding, has_bom)?; + } st.encoding = encoding.to_owned(); st.has_bom = has_bom; self.set_dirty(doc, st, true); @@ -699,6 +791,21 @@ impl Documents { }) } + /// Re-checks open file buffers, including files outside the watched roots. + /// Call from a blocking worker; unchanged files only need their metadata. + pub fn sync_from_disk(&self) { + let slots: Vec = self.registry.lock().docs.values().map(|e| e.slot.clone()).collect(); + let paths: Vec = slots + .iter() + .filter_map(|slot| { + let st = slot.lock(); + (!st.closed && st.uri.is_none() && !st.binary && !ide_vfs::is_archive_path(&st.path)) + .then(|| st.path.clone()) + }) + .collect(); + self.on_disk_changed(&paths); + } + /// Re-reads from disk after an external change. Clean documents reload /// silently; dirty ones are left alone and reported via `IdeEvent::Notify`. /// Changes matching our own last save are ignored. @@ -706,6 +813,9 @@ impl Documents { for path in paths { let Some(id) = self.by_path(path) else { continue }; let result = self.with_doc(id, |st| { + if st.uri.is_some() || st.binary || ide_vfs::is_archive_path(&st.path) { + return Ok(()); + } let Ok(meta) = std::fs::metadata(&st.path) else { return Ok(()) }; let mtime = ide_vfs::mtime_ms(&meta); if mtime == st.disk_mtime && meta.len() == st.disk_len { @@ -726,7 +836,9 @@ impl Documents { }); return Ok(()); } - let loaded = ide_vfs::load_text(&st.path)?; + let defaults = self.file_defaults(); + let loaded = Self::load_with_defaults(&st.path, &defaults)?; + st.native_to_ascii = defaults.native_to_ascii_for(&st.path); st.disk_mtime = loaded.mtime_ms; st.disk_len = disk_len(&st.path); if loaded.binary || st.binary { diff --git a/crates/ide-workspace/src/file_defaults.rs b/crates/ide-workspace/src/file_defaults.rs index f378109cc..e26484eb0 100644 --- a/crates/ide-workspace/src/file_defaults.rs +++ b/crates/ide-workspace/src/file_defaults.rs @@ -18,6 +18,7 @@ pub struct FileDefaults { /// Fallback for files outside `root`. pub global_fallback: String, pub properties_fallback: String, + pub native_to_ascii: bool, pub editorconfig: bool, pub root: Option, } @@ -47,6 +48,7 @@ impl FileDefaults { project_fallback: normalize(&enc.project), global_fallback: normalize(&enc.global), properties_fallback: normalize(&enc.properties), + native_to_ascii: enc.native_to_ascii, editorconfig: style.editorconfig, root: root.map(Path::to_path_buf), } @@ -64,6 +66,10 @@ impl FileDefaults { (label != "utf-8").then_some(label.as_str()) } + pub fn native_to_ascii_for(&self, path: &Path) -> bool { + self.native_to_ascii && path.extension().is_some_and(|e| e == "properties") + } + /// Bytes of a new empty file: a BOM when UTF-8 files are created with one. pub fn new_file_bytes(&self) -> Vec { if self.encoding == "utf-8" && self.utf8_bom { diff --git a/crates/ide-workspace/src/settings.rs b/crates/ide-workspace/src/settings.rs index 62abb99d0..3b278a0bc 100644 --- a/crates/ide-workspace/src/settings.rs +++ b/crates/ide-workspace/src/settings.rs @@ -27,6 +27,12 @@ pub enum SettingsScope { Project, } +fn remove_custom_schemes(layer: &mut Json) { + if let Some(editor) = layer.get_mut("editor").and_then(Json::as_object_mut) { + editor.remove("customSchemes"); + } +} + fn defaults_json() -> Json { serde_json::to_value(Settings::default()).expect("settings serialize") } @@ -178,10 +184,12 @@ impl SettingsStore { fn compute(&self) -> Settings { let mut merged = defaults_json(); - for (_, file) in self.layers() { - if let Some(layer) = read_layer(&file) - && let Err(e) = try_merge(&mut merged, layer) - { + for (scope, file) in self.layers() { + let Some(mut layer) = read_layer(&file) else { continue }; + if scope == SettingsScope::Project { + remove_custom_schemes(&mut layer); + } + if let Err(e) = try_merge(&mut merged, layer) { tracing::warn!("{} has invalid settings, ignoring it: {e}", file.display()); } } @@ -205,6 +213,13 @@ impl SettingsStore { /// `null` removes the key from that layer. Unknown keys and values of the /// wrong type are rejected with `InvalidInput`; the file is left untouched. pub fn set(&self, scope: SettingsScope, key: &str, value: serde_json::Value) -> IdeResult { + if scope == SettingsScope::Project + && (key == "editor.customSchemes" + || key.starts_with("editor.customSchemes.") + || (key == "editor" && value.get("customSchemes").is_some())) + { + return Err(IdeError::invalid("custom color schemes belong to user settings")); + } let file = self.file(scope).ok_or_else(|| IdeError::invalid("no project is open"))?.to_path_buf(); let segments: Vec<&str> = key.split('.').collect(); if segments.iter().any(|s| s.is_empty()) { @@ -242,7 +257,10 @@ impl SettingsStore { for (layer_scope, layer_file) in self.layers() { let is_target = layer_scope == scope; let layer = if is_target { parse_layer(&new_text).ok() } else { read_layer(&layer_file) }; - let Some(layer) = layer else { continue }; + let Some(mut layer) = layer else { continue }; + if layer_scope == SettingsScope::Project { + remove_custom_schemes(&mut layer); + } if let Err(e) = try_merge(&mut merged, layer) && is_target { @@ -316,6 +334,38 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn custom_color_schemes_are_user_settings_and_survive_reload() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = tmp.path().join("cfg"); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(proj.join(".openidea")).unwrap(); + let store = SettingsStore::load(&cfg, Some(&proj)); + let schemes = json!({"custom-forest": {"name": "Forest", "parent": "dark", "attributes": { + "keyword": {"foreground": "#99dd77", "bold": true} + }}}); + store.set(SettingsScope::Global, "editor.customSchemes", schemes.clone()).unwrap(); + store.set(SettingsScope::Global, "editor.colorScheme", json!("custom-forest")).unwrap(); + let loaded = SettingsStore::load(&cfg, Some(&proj)).effective(); + assert_eq!(loaded.editor.color_scheme, "custom-forest"); + assert_eq!(serde_json::to_value(&loaded.editor.custom_schemes).unwrap(), schemes); + assert!(store.set(SettingsScope::Project, "editor.customSchemes", schemes.clone()).is_err()); + assert!( + store + .set(SettingsScope::Project, "editor.customSchemes.custom-other", schemes["custom-forest"].clone()) + .is_err() + ); + assert!(store.set(SettingsScope::Project, "editor", json!({"customSchemes": schemes})).is_err()); + std::fs::write( + proj.join(".openidea/settings.toml"), + "[editor.customSchemes.custom-forest]\nname = \"Project\"\nparent = \"light\"\nattributes = {}\n", + ) + .unwrap(); + assert_eq!(store.reload().editor.custom_schemes["custom-forest"].name, "Forest"); + store.set(SettingsScope::Global, "editor.customSchemes", json!({})).unwrap(); + assert!(SettingsStore::load(&cfg, Some(&proj)).effective().editor.custom_schemes.is_empty()); + } + #[test] fn layering_and_fallback() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-workspace/tests/documents.rs b/crates/ide-workspace/tests/documents.rs index 681f9be57..028617eb8 100644 --- a/crates/ide-workspace/tests/documents.rs +++ b/crates/ide-workspace/tests/documents.rs @@ -597,5 +597,106 @@ fn archive_entries_open_read_only_without_observers() { assert_eq!((info.text.as_str(), info.language.as_str(), info.read_only), ("fn main() {}\n", "rust", true)); assert_eq!(*info.path, *path); assert!(f.rec.log.lock().is_empty(), "language servers / history never see archive entries"); + fs::remove_file(&jar).unwrap(); + f.docs.sync_from_disk(); + assert_eq!(f.docs.snapshot(info.id).unwrap().version, info.version); + assert_eq!(f.docs.snapshot(info.id).unwrap().text, info.text); f.docs.close(info.id); } + +#[test] +fn properties_conversion_uses_displayed_utf16_and_survives_reload() { + let f = fixture(); + let path = f.root.join("messages.properties"); + let original = "greeting=\\u4f60\\u597d\\uD83D\\uDE00!\r\nliteral=\\\\u0041\r\nbad=\\u12xz\r\n"; + fs::write(&path, original).unwrap(); + let mut defaults = f.docs.file_defaults(); + defaults.native_to_ascii = true; + f.docs.set_file_defaults(defaults); + let doc = f.docs.open(&path).unwrap(); + assert_eq!(doc.text, "greeting=你好😀!\nliteral=\\\\u0041\nbad=\\u12xz\n"); + assert!(!doc.dirty); + assert_eq!(f.docs.text_snapshot(doc.id).unwrap().text, doc.text); + f.docs.save(doc.id).unwrap(); + assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); + // Two BMP characters + one surrogate pair put ! at UTF-16 offset 13. + f.docs.edit(doc.id, doc.version, change(13, 14, "界")).unwrap(); + f.docs.save(doc.id).unwrap(); + let expected = "greeting=\\u4F60\\u597D\\uD83D\\uDE00\\u754C\r\nliteral=\\\\u0041\r\nbad=\\u12xz\r\n"; + assert_eq!(fs::read(&path).unwrap(), expected.as_bytes()); + assert!(f.rec.log.lock().iter().any(|line| line.contains("greeting=你好😀界"))); + let saved = f.docs.snapshot(doc.id).unwrap(); + f.docs.set_encoding(doc.id, "iso-8859-1", true).unwrap(); + assert_eq!(f.docs.snapshot(doc.id).unwrap().version, saved.version); + // Force a watcher event even though the serialized contents are identical. + fs::File::options().write(true).open(&path).unwrap().set_modified(std::time::SystemTime::UNIX_EPOCH).unwrap(); + f.docs.on_disk_changed(std::slice::from_ref(&path)); + let reloaded = f.docs.snapshot(doc.id).unwrap(); + assert_eq!(reloaded.text, saved.text); + assert_eq!(reloaded.version, saved.version); + assert!(!reloaded.dirty); + fs::write(&path, "greeting=\\u4E16\\u754C\r\n").unwrap(); + f.docs.on_disk_changed(std::slice::from_ref(&path)); + assert_eq!(f.docs.snapshot(doc.id).unwrap().text, "greeting=世界\n"); + assert!(!f.docs.is_dirty(doc.id).unwrap()); +} + +#[test] +fn properties_toggle_reloads_only_clean_properties_and_pins_dirty_codec() { + let f = fixture(); + let paths = ["clean.properties", "dirty.properties", "other.txt"].map(|name| f.root.join(name)); + for path in &paths { + fs::write(path, r"value=\u4F60").unwrap(); + } + let [clean, dirty, other] = paths.each_ref().map(|p| f.docs.open(p).unwrap()); + f.docs.edit(dirty.id, dirty.version, change(12, 12, "!")).unwrap(); + let mut defaults = f.docs.file_defaults(); + defaults.native_to_ascii = true; + f.docs.set_file_defaults(defaults.clone()); + f.docs.reload_properties(); + assert_eq!(f.docs.snapshot(clean.id).unwrap().text, "value=你"); + assert!(!f.docs.is_dirty(clean.id).unwrap()); + assert_eq!(f.docs.snapshot(dirty.id).unwrap().text, r"value=\u4F60!"); + assert!(!f.docs.native_to_ascii_for(&paths[1])); + assert_eq!(f.docs.snapshot(other.id).unwrap().text, r"value=\u4F60"); + f.docs.save(dirty.id).unwrap(); + assert_eq!(fs::read_to_string(&paths[1]).unwrap(), r"value=\u4F60!"); + f.docs.reload_properties(); + assert_eq!(f.docs.snapshot(dirty.id).unwrap().text, "value=你!"); + f.docs.edit(dirty.id, f.docs.snapshot(dirty.id).unwrap().version, change(7, 8, "好")).unwrap(); + defaults.native_to_ascii = false; + f.docs.set_file_defaults(defaults); + f.docs.reload_properties(); + assert_eq!(f.docs.snapshot(clean.id).unwrap().text, r"value=\u4F60"); + assert!(f.docs.native_to_ascii_for(&paths[1])); + f.docs.save(dirty.id).unwrap(); + assert_eq!(fs::read_to_string(&paths[1]).unwrap(), r"value=\u4F60\u597D"); +} + +#[test] +fn properties_ascii_noop_and_charset_conversion() { + let f = fixture(); + let mut defaults = f.docs.file_defaults(); + defaults.native_to_ascii = true; + f.docs.set_file_defaults(defaults); + for (i, source) in ["a=b\r\n# comment\r\n", r"a=\u0041", r"a=\\u0041", r"a=\uD800"].iter().enumerate() { + let path = f.root.join(format!("{i}.properties")); + fs::write(&path, source).unwrap(); + let doc = f.docs.open(&path).unwrap(); + f.docs.edit(doc.id, doc.version, change(0, 0, "x")).unwrap(); + f.docs.edit(doc.id, doc.version + 1, change(0, 1, "")).unwrap(); + f.docs.save(doc.id).unwrap(); + f.docs.set_encoding(doc.id, "utf-8", true).unwrap(); + f.docs.save(doc.id).unwrap(); + assert_eq!(fs::read_to_string(path).unwrap(), *source); + } + let path = f.root.join("utf16.properties"); + fs::write(&path, ide_vfs::encode("a=你好😀", "utf-16le", true).unwrap()).unwrap(); + let doc = f.docs.open(&path).unwrap(); + f.docs.replace_all(doc.id, "a=世界😀").unwrap(); + f.docs.save(doc.id).unwrap(); + assert!(fs::read(&path).unwrap().is_ascii()); + f.docs.set_encoding(doc.id, "utf-16le", true).unwrap(); + assert_eq!(f.docs.snapshot(doc.id).unwrap().text, "a=世界😀"); + assert!(!f.docs.is_dirty(doc.id).unwrap()); +} diff --git a/fixtures/ts-app/.openidea/.gitignore b/fixtures/ts-app/.openidea/.gitignore new file mode 100644 index 000000000..44ade5287 --- /dev/null +++ b/fixtures/ts-app/.openidea/.gitignore @@ -0,0 +1,3 @@ +# Per-user IDE state +/shelf/ +/changelists.json diff --git a/src-tauri/src/commands/deploy.rs b/src-tauri/src/commands/deploy.rs index 22df06480..ca486655d 100644 --- a/src-tauri/src/commands/deploy.rs +++ b/src-tauri/src/commands/deploy.rs @@ -1,38 +1,19 @@ use crate::state::AppState; -use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId}; -use ide_remote::{DeployedPath, SyncEntry, SyncStep, TransferReport}; -use ide_ssh::RemoteEntry; -use std::path::PathBuf; +use ide_core::{IdeResult, IpcPath, RequestId}; +use ide_remote::{DeployedPath, DeploymentCoverage, SyncEntry, SyncStep, TransferReport}; +use ide_ssh::{FsConnection, RemoteEntry}; use tauri::{State, Window}; -/// Reports a transfer as a background task (status bar progress) whose Cancel fires `token`. -fn progress( - sink: SharedSink, - title: &str, - token: CancellationToken, -) -> (impl FnMut(&str, usize, usize) + Send, impl FnOnce()) { - let id = TaskId::next(); - let cancel = token; - let handle = ide_core::task::on_cancel(id, move || cancel.cancel()); - let title = title.to_owned(); - let (s, t) = (sink.clone(), title.clone()); - let report = move |path: &str, i: usize, n: usize| { - let fraction = if n == 0 { None } else { Some(i as f32 / n as f32) }; - let message = Some(path.to_owned()); - s.emit(IdeEvent::Task { id, title: t.clone(), message, fraction, done: false, cancellable: true }); - }; - let done = move || { - drop(handle); - sink.emit(IdeEvent::Task { id, title, message: None, fraction: Some(1.0), done: true, cancellable: false }) - }; - (report, done) +#[tauri::command] +#[specta::specta] +pub async fn deploy_coverage( + window: Window, + state: State<'_, AppState>, + paths: Vec, +) -> IdeResult> { + ide_host::ops::deploy::deploy_coverage(window.label(), &state.host, paths).await } -fn local(paths: Vec) -> Vec { - paths.into_iter().map(|p| p.to_path_buf()).collect() -} - -/// Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`). #[tauri::command] #[specta::specta] pub async fn deploy_upload( @@ -42,19 +23,9 @@ pub async fn deploy_upload( server: Option, request: RequestId, ) -> IdeResult { - let project = state.project(window.label())?; - let token = state.begin_request(request); - let (mut report, done) = progress(project.sink.clone(), "Uploading", token.clone()); - let r = state - .remote - .upload(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report) - .await; - done(); - state.end_request(request); - r + ide_host::ops::deploy::deploy_upload(window.label(), &state.host, paths, server, request).await } -/// Download from…: the remote counterparts of files and folders. #[tauri::command] #[specta::specta] pub async fn deploy_download( @@ -64,19 +35,9 @@ pub async fn deploy_download( server: Option, request: RequestId, ) -> IdeResult { - let project = state.project(window.label())?; - let token = state.begin_request(request); - let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone()); - let r = state - .remote - .download(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report) - .await; - done(); - state.end_request(request); - r + ide_host::ops::deploy::deploy_download(window.label(), &state.host, paths, server, request).await } -/// Deletes the remote counterparts of files and folders. #[tauri::command] #[specta::specta] pub async fn deploy_delete_remote( @@ -85,11 +46,9 @@ pub async fn deploy_delete_remote( paths: Vec, server: Option, ) -> IdeResult { - let project = state.project(window.label())?; - state.remote.delete_remote(&project.settings.effective(), &project.root, server.as_deref(), &local(paths)).await + ide_host::ops::deploy::deploy_delete_remote(window.label(), &state.host, paths, server).await } -/// Sync with Deployed: local and remote files under `path`, side by side. #[tauri::command] #[specta::specta] pub async fn deploy_compare( @@ -99,14 +58,9 @@ pub async fn deploy_compare( server: Option, request: RequestId, ) -> IdeResult> { - let project = state.project(window.label())?; - let token = state.begin_request(request); - let r = state.remote.compare(&project.settings.effective(), &project.root, server.as_deref(), &path, token).await; - state.end_request(request); - r + ide_host::ops::deploy::deploy_compare(window.label(), &state.host, path, server, request).await } -/// Applies the Sync with Deployed choices. #[tauri::command] #[specta::specta] pub async fn deploy_sync( @@ -116,19 +70,9 @@ pub async fn deploy_sync( server: Option, request: RequestId, ) -> IdeResult { - let project = state.project(window.label())?; - let token = state.begin_request(request); - let (mut report, done) = progress(project.sink.clone(), "Synchronizing", token.clone()); - let r = state - .remote - .sync(&project.settings.effective(), &project.root, server.as_deref(), &steps, token, &mut report) - .await; - done(); - state.end_request(request); - r + ide_host::ops::deploy::deploy_sync(window.label(), &state.host, steps, server, request).await } -/// Where `path` is deployed (remote path, web URL); `None` when not mapped. #[tauri::command] #[specta::specta] pub async fn deploy_info( @@ -137,56 +81,66 @@ pub async fn deploy_info( path: IpcPath, server: Option, ) -> IdeResult> { - let project = state.project(window.label())?; - state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await + ide_host::ops::deploy::deploy_info(window.label(), &state.host, path, server).await } -/// Remote Host tool window: the server root of `server`, resolved on the host. #[tauri::command] #[specta::specta] pub async fn deploy_server_root(window: Window, state: State<'_, AppState>, server: String) -> IdeResult { - let settings = match state.project(window.label()) { - Ok(p) => p.settings.effective(), - Err(_) => state.global_settings.effective(), - }; - state.remote.server_root(&settings, &server).await -} - -/// Entries of a remote folder of SSH configuration `ssh` (`~`: the login directory). -#[tauri::command] -#[specta::specta] -pub async fn remote_list(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult> { - state.remote.list(&ssh, &path).await -} - -/// Opens a remote file for editing: a local copy, uploaded back on save. -#[tauri::command] -#[specta::specta] -pub async fn remote_open(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult { - Ok(state.remote.open_remote(&ssh, &path).await?.into()) -} - -/// The remote file a local copy stands for: (SSH configuration id, remote path). -#[tauri::command] -#[specta::specta] -pub fn remote_of(state: State<'_, AppState>, path: IpcPath) -> Option<(String, String)> { - state.remote.remote_of(&path) + ide_host::ops::deploy::deploy_server_root(window.label(), &state.host, server).await } #[tauri::command] #[specta::specta] -pub async fn remote_delete(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<()> { - state.remote.remote_delete(&ssh, &path).await +pub async fn remote_list( + state: State<'_, AppState>, + connection: FsConnection, + path: String, +) -> IdeResult> { + ide_host::ops::deploy::remote_list(&state.host, connection, path).await } #[tauri::command] #[specta::specta] -pub async fn remote_mkdir(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<()> { - state.remote.remote_mkdir(&ssh, &path).await +pub async fn remote_open(state: State<'_, AppState>, connection: FsConnection, path: String) -> IdeResult { + ide_host::ops::deploy::remote_open(&state.host, connection, path).await } #[tauri::command] #[specta::specta] -pub async fn remote_rename(state: State<'_, AppState>, ssh: String, from: String, to: String) -> IdeResult<()> { - state.remote.remote_rename(&ssh, &from, &to).await +pub fn remote_of(state: State<'_, AppState>, path: IpcPath) -> Option<(FsConnection, String)> { + ide_host::ops::deploy::remote_of(&state.host, path) +} + +#[tauri::command] +#[specta::specta] +pub async fn remote_delete(state: State<'_, AppState>, connection: FsConnection, path: String) -> IdeResult<()> { + ide_host::ops::deploy::remote_delete(&state.host, connection, path).await +} + +#[tauri::command] +#[specta::specta] +pub async fn remote_mkdir(state: State<'_, AppState>, connection: FsConnection, path: String) -> IdeResult<()> { + ide_host::ops::deploy::remote_mkdir(&state.host, connection, path).await +} + +#[tauri::command] +#[specta::specta] +pub async fn remote_rename( + state: State<'_, AppState>, + connection: FsConnection, + from: String, + to: String, +) -> IdeResult<()> { + ide_host::ops::deploy::remote_rename(&state.host, connection, from, to).await +} + +#[tauri::command] +#[specta::specta] +pub async fn deploy_test_connection( + state: State<'_, AppState>, + connection: FsConnection, + path: String, +) -> IdeResult<()> { + ide_host::ops::deploy::deploy_test_connection(&state.host, connection, path).await } diff --git a/src-tauri/src/commands/doc.rs b/src-tauri/src/commands/doc.rs index ddbd3b05f..91e0781eb 100644 --- a/src-tauri/src/commands/doc.rs +++ b/src-tauri/src/commands/doc.rs @@ -50,6 +50,12 @@ pub async fn doc_save_all(window: Window, state: State<'_, AppState>) -> IdeResu ide_host::ops::doc::save_all(&state.host, window.label()).await } +#[tauri::command] +#[specta::specta] +pub async fn doc_sync_from_disk(window: Window, state: State<'_, AppState>) -> IdeResult<()> { + ide_host::ops::doc::sync_from_disk(&state.host, window.label()).await +} + #[tauri::command] #[specta::specta] pub async fn doc_snapshot(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult { @@ -74,14 +80,14 @@ pub async fn doc_ui_mode(window: Window, state: State<'_, AppState>, path: PathB /// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label. #[tauri::command] #[specta::specta] -pub fn doc_set_encoding( +pub async fn doc_set_encoding( window: Window, state: State<'_, AppState>, doc: DocId, encoding: String, reload: bool, ) -> IdeResult { - ide_host::ops::doc::doc_set_encoding(&state.host, window.label(), doc, encoding, reload) + ide_host::ops::doc::doc_set_encoding(&state.host, window.label(), doc, encoding, reload).await } #[tauri::command] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ab1388209..3eeea13c1 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -143,6 +143,7 @@ pub fn builder() -> tauri_specta::Builder { doc::doc_edit, doc::doc_save, doc::doc_save_all, + doc::doc_sync_from_disk, doc::doc_snapshot, doc::doc_ui_mode, doc::doc_set_encoding, @@ -151,6 +152,7 @@ pub fn builder() -> tauri_specta::Builder { doc::doc_folding, doc::doc_highlights, doc::doc_selection_ranges, + syntax::jvm_file_presentations, syntax::syntax_support, syntax::syntax_node_at, syntax::syntax_expression_at, @@ -276,6 +278,8 @@ pub fn builder() -> tauri_specta::Builder { vcs::vcs_shelf_delete, vcs::vcs_shelf_rename, vcs::vcs_shelf_patch, + vcs::vcs_shelf_diff, + vcs::vcs_shelf_import, vcs::vcs_changelists_load, vcs::vcs_changelists_save, vcs::vcs_write_patch, @@ -448,6 +452,7 @@ pub fn builder() -> tauri_specta::Builder { ssh::git_credentials, ssh::git_credential_forget, ssh::ssh_terminal_command, + deploy::deploy_coverage, deploy::deploy_upload, deploy::deploy_download, deploy::deploy_delete_remote, @@ -456,11 +461,13 @@ pub fn builder() -> tauri_specta::Builder { deploy::deploy_info, deploy::deploy_server_root, deploy::remote_list, + deploy::deploy_test_connection, deploy::remote_open, deploy::remote_of, deploy::remote_delete, deploy::remote_mkdir, deploy::remote_rename, + crate::remote_dev::remote_bootstrap_list, crate::remote_dev::remote_connect, crate::remote_dev::remote_current, crate::remote_dev::remote_disconnect, diff --git a/src-tauri/src/commands/syntax.rs b/src-tauri/src/commands/syntax.rs index d3292ceee..ebf8585c4 100644 --- a/src-tauri/src/commands/syntax.rs +++ b/src-tauri/src/commands/syntax.rs @@ -112,3 +112,13 @@ pub async fn syntax_structural_ranges( ) -> IdeResult { ide_host::ops::syntax::syntax_structural_ranges(&state.host, window.label(), doc, pos, request).await } + +#[tauri::command] +#[specta::specta] +pub async fn jvm_file_presentations( + window: Window, + state: State<'_, AppState>, + paths: Vec, +) -> IdeResult> { + ide_host::ops::jvm_icons::jvm_file_presentations(&state.host, window.label(), paths).await +} diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index cfde2042c..1c90bf70b 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -35,8 +35,8 @@ pub async fn vcs_branch_info( /// Gutter change markers for a file, computed against its current (unsaved) text. #[tauri::command] #[specta::specta] -pub fn vcs_line_hunks(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult> { - ide_host::ops::vcs::vcs_line_hunks(&state.host, window.label(), path) +pub async fn vcs_line_hunks(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult> { + ide_host::ops::vcs::vcs_line_hunks(&state.host, window.label(), path).await } #[tauri::command] @@ -338,8 +338,13 @@ pub fn vcs_diff_hunks(state: State<'_, AppState>, old_text: String, new_text: St /// Indices refer to `vcs_diff_hunks(index_text, worktree_text)`. #[tauri::command] #[specta::specta] -pub fn vcs_stage_hunks(window: Window, state: State<'_, AppState>, path: PathBuf, hunks: Vec) -> IdeResult<()> { - ide_host::ops::vcs::vcs_stage_hunks(&state.host, window.label(), path, hunks) +pub async fn vcs_stage_hunks( + window: Window, + state: State<'_, AppState>, + path: PathBuf, + hunks: Vec, +) -> IdeResult<()> { + ide_host::ops::vcs::vcs_stage_hunks(&state.host, window.label(), path, hunks).await } /// Clones a repository (Get from VCS). Works without an open project. @@ -608,14 +613,15 @@ pub async fn vcs_unshelve( state: State<'_, AppState>, id: String, remove: bool, + path: Option, ) -> IdeResult { - ide_host::ops::vcs::vcs_unshelve(&state.host, window.label(), id, remove).await + ide_host::ops::vcs::vcs_unshelve(&state.host, window.label(), id, remove, path).await } #[tauri::command] #[specta::specta] -pub fn vcs_shelf_delete(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<()> { - ide_host::ops::vcs::vcs_shelf_delete(&state.host, window.label(), id) +pub fn vcs_shelf_delete(window: Window, state: State<'_, AppState>, id: String, path: Option) -> IdeResult<()> { + ide_host::ops::vcs::vcs_shelf_delete(&state.host, window.label(), id, path) } #[tauri::command] @@ -626,8 +632,13 @@ pub fn vcs_shelf_rename(window: Window, state: State<'_, AppState>, id: String, #[tauri::command] #[specta::specta] -pub fn vcs_shelf_patch(window: Window, state: State<'_, AppState>, id: String) -> IdeResult { - ide_host::ops::vcs::vcs_shelf_patch(&state.host, window.label(), id) +pub fn vcs_shelf_patch( + window: Window, + state: State<'_, AppState>, + id: String, + path: Option, +) -> IdeResult { + ide_host::ops::vcs::vcs_shelf_patch(&state.host, window.label(), id, path) } #[tauri::command] @@ -648,3 +659,24 @@ pub fn vcs_changelists_save(window: Window, state: State<'_, AppState>, changeli pub async fn vcs_write_patch(state: State<'_, AppState>, path: PathBuf, patch: String) -> IdeResult<()> { ide_host::ops::vcs::vcs_write_patch(&state.host, "", path, patch).await } + +#[tauri::command] +#[specta::specta] +pub async fn vcs_shelf_diff( + window: Window, + state: State<'_, AppState>, + id: String, +) -> IdeResult> { + ide_host::ops::vcs::vcs_shelf_diff(&state.host, window.label(), id).await +} + +#[tauri::command] +#[specta::specta] +pub async fn vcs_shelf_import( + window: Window, + state: State<'_, AppState>, + name: String, + patch: String, +) -> IdeResult<()> { + ide_host::ops::vcs::vcs_shelf_import(&state.host, window.label(), name, patch).await +} diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 0937bfce3..8ad7a7aee 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -1173,10 +1173,10 @@ pub fn model(spec: &[Entry], recent: &[RecentItem], config: &MenuConfig) -> Vec< out } -/// Whether the OS menu bar is used. Windows project windows draw IDEA's -/// hamburger menu in their own title bar instead. +/// Whether the OS menu bar is used. Windows and Linux project windows are +/// frameless and draw IDEA's hamburger menu in their own title bar instead. pub fn native_menu_bar() -> bool { - !cfg!(target_os = "windows") + cfg!(target_os = "macos") } /// Rebuilds and installs the app menu from the current config and recent list. diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index 876b15e68..8b5f97be0 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -17,6 +17,17 @@ use std::sync::Arc; use tauri::ipc::Channel; use tauri::{AppHandle, Manager, State, Window}; +/// Browse before connecting, using the desktop's SSH configuration even in a remote window. +#[tauri::command] +#[specta::specta] +pub async fn remote_bootstrap_list( + state: State<'_, AppState>, + ssh: String, + path: String, +) -> IdeResult> { + state.remote.list(&ide_ssh::FsConnection::Sftp { ssh }, &path).await +} + /// Where the server is installed on the remote host (per version). fn install_dir(version: &str) -> String { format!("~/.openidea/server/{version}") diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 78381f709..e7be69548 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -724,7 +724,7 @@ pub fn open_settings_window( let mut config = window_config(app)?; config.label = label.clone(); config.title = "Settings".into(); - // Frameless on Windows like project windows (tauri.windows.conf.json): the page draws + // Frameless on Windows and Linux like project windows (tauri..conf.json): the page draws // the title bar in the app theme instead of a system caption that follows the OS theme. (config.width, config.height) = SETTINGS_SIZE; (config.min_width, config.min_height) = (Some(SETTINGS_MIN_SIZE.0), Some(SETTINGS_MIN_SIZE.1)); diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json new file mode 100644 index 000000000..d3fb6d740 --- /dev/null +++ b/src-tauri/tauri.linux.conf.json @@ -0,0 +1,18 @@ +{ + "app": { + "windows": [ + { + "label": "main", + "create": false, + "title": "OpenIDEA", + "width": 1440, + "height": 900, + "minWidth": 800, + "minHeight": 500, + "decorations": false, + "backgroundColor": "#E9EAEE", + "dragDropEnabled": true + } + ] + } +} diff --git a/src/bindings.ts b/src/bindings.ts index 559ec6695..9af2daa45 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -199,6 +199,7 @@ export const commands = { docEdit: (doc: DocId, baseVersion: number, changes: ChangeSet) => typedError(__TAURI_INVOKE("doc_edit", { doc, baseVersion, changes })), docSave: (doc: DocId) => typedError(__TAURI_INVOKE("doc_save", { doc })), docSaveAll: () => typedError(__TAURI_INVOKE("doc_save_all")), + docSyncFromDisk: () => typedError(__TAURI_INVOKE("doc_sync_from_disk")), docSnapshot: (doc: DocId) => typedError(__TAURI_INVOKE("doc_snapshot", { doc })), /** * The CodeMirror mode `path` gets when opened (`DocumentInfo::ui_mode`), @@ -226,6 +227,7 @@ export const commands = { * Large documents fall back to word / line / document ranges. */ docSelectionRanges: (doc: DocId, offset: number) => typedError(__TAURI_INVOKE("doc_selection_ranges", { doc, offset })), + jvmFilePresentations: (paths: string[]) => typedError(__TAURI_INVOKE("jvm_file_presentations", { paths })), /** * What the structure service supports for a language (`tree`: every query, * `locals`: scope analysis). @@ -516,10 +518,12 @@ export const commands = { * Unshelve: applies the shelved patch (3-way merging; conflicts are left for the * merge UI); with `remove`, deletes it from the shelf. */ - vcsUnshelve: (id: string, remove: boolean) => typedError(__TAURI_INVOKE("vcs_unshelve", { id, remove })), - vcsShelfDelete: (id: string) => typedError(__TAURI_INVOKE("vcs_shelf_delete", { id })), + vcsUnshelve: (id: string, remove: boolean, path: string | null) => typedError(__TAURI_INVOKE("vcs_unshelve", { id, remove, path })), + vcsShelfDelete: (id: string, path: string | null) => typedError(__TAURI_INVOKE("vcs_shelf_delete", { id, path })), vcsShelfRename: (id: string, name: string) => typedError(__TAURI_INVOKE("vcs_shelf_rename", { id, name })), - vcsShelfPatch: (id: string) => typedError(__TAURI_INVOKE("vcs_shelf_patch", { id })), + vcsShelfPatch: (id: string, path: string | null) => typedError(__TAURI_INVOKE("vcs_shelf_patch", { id, path })), + vcsShelfDiff: (id: string) => typedError(__TAURI_INVOKE("vcs_shelf_diff", { id })), + vcsShelfImport: (name: string, patch: string) => typedError(__TAURI_INVOKE("vcs_shelf_import", { name, patch })), vcsChangelistsLoad: () => typedError(__TAURI_INVOKE("vcs_changelists_load")), vcsChangelistsSave: (changelists: ChangelistState) => typedError(__TAURI_INVOKE("vcs_changelists_save", { changelists })), /** Create Patch › Save to file. */ @@ -965,33 +969,27 @@ export const commands = { * (run in a terminal tab, so prompts and host-key questions are answered there). */ sshTerminalCommand: (id: string) => typedError(__TAURI_INVOKE("ssh_terminal_command", { id })), - /** Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`). */ + deployCoverage: (paths: IpcPath[]) => typedError(__TAURI_INVOKE("deploy_coverage", { paths })), deployUpload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_upload", { paths, server, request })), - /** Download from…: the remote counterparts of files and folders. */ deployDownload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_download", { paths, server, request })), - /** Deletes the remote counterparts of files and folders. */ deployDeleteRemote: (paths: IpcPath[], server: string | null) => typedError(__TAURI_INVOKE("deploy_delete_remote", { paths, server })), - /** Sync with Deployed: local and remote files under `path`, side by side. */ deployCompare: (path: IpcPath, server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_compare", { path, server, request })), - /** Applies the Sync with Deployed choices. */ deploySync: (steps: SyncStep[], server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_sync", { steps, server, request })), - /** Where `path` is deployed (remote path, web URL); `None` when not mapped. */ deployInfo: (path: IpcPath, server: string | null) => typedError<{ server: string, remotePath: string, webUrl: string | null, } | null, IdeError>(__TAURI_INVOKE("deploy_info", { path, server })), - /** Remote Host tool window: the server root of `server`, resolved on the host. */ deployServerRoot: (server: string) => typedError(__TAURI_INVOKE("deploy_server_root", { server })), - /** Entries of a remote folder of SSH configuration `ssh` (`~`: the login directory). */ - remoteList: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_list", { ssh, path })), - /** Opens a remote file for editing: a local copy, uploaded back on save. */ - remoteOpen: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_open", { ssh, path })), - /** The remote file a local copy stands for: (SSH configuration id, remote path). */ - remoteOf: (path: IpcPath) => __TAURI_INVOKE<[string, string] | null>("remote_of", { path }), - remoteDelete: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_delete", { ssh, path })), - remoteMkdir: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_mkdir", { ssh, path })), - remoteRename: (ssh: string, from: string, to: string) => typedError(__TAURI_INVOKE("remote_rename", { ssh, from, to })), + remoteList: (connection: FsConnection, path: string) => typedError(__TAURI_INVOKE("remote_list", { connection, path })), + deployTestConnection: (connection: FsConnection, path: string) => typedError(__TAURI_INVOKE("deploy_test_connection", { connection, path })), + remoteOpen: (connection: FsConnection, path: string) => typedError(__TAURI_INVOKE("remote_open", { connection, path })), + remoteOf: (path: IpcPath) => __TAURI_INVOKE<[FsConnection, string] | null>("remote_of", { path }), + remoteDelete: (connection: FsConnection, path: string) => typedError(__TAURI_INVOKE("remote_delete", { connection, path })), + remoteMkdir: (connection: FsConnection, path: string) => typedError(__TAURI_INVOKE("remote_mkdir", { connection, path })), + remoteRename: (connection: FsConnection, from: string, to: string) => typedError(__TAURI_INVOKE("remote_rename", { connection, from, to })), + /** Browse before connecting, using the desktop's SSH configuration even in a remote window. */ + remoteBootstrapList: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_bootstrap_list", { ssh, path })), /** * File › Remote Development: opens `path` on the host of SSH configuration * `ssh_config` in this window (installs / starts the server as needed). @@ -1991,6 +1989,23 @@ export type CreatePullRequest = { push: boolean, }; +/** Imported editor colour scheme on top of a built-in base. */ +export type CustomColorScheme = CustomColorScheme_Serialize | CustomColorScheme_Deserialize; + +/** Imported editor colour scheme on top of a built-in base. */ +export type CustomColorScheme_Deserialize = { + name: string, + parent: string, + attributes: { [key in string]: TextAttributesOverride_Deserialize }, +}; + +/** Imported editor colour scheme on top of a built-in base. */ +export type CustomColorScheme_Serialize = { + name: string, + parent: string, + attributes: { [key in string]: TextAttributesOverride_Serialize }, +}; + /** A named profile made with Copy. */ export type CustomInspectionProfile = { name?: string, @@ -2383,6 +2398,16 @@ export type DeployedPath = { webUrl: string | null, }; +/** + * Mapping eligibility uses a common synthetic server root: mapping and exclusion + * paths are both relative to that root, so checking never opens a connection. + */ +export type DeploymentCoverage = { + server: string, + paths: string[], + skipped: TransferError[], +}; + export type DeploymentMapping = { /** Project-relative folder; empty = the project root. */ local?: string, @@ -2392,8 +2417,9 @@ export type DeploymentMapping = { web?: string, }; -/** An SFTP server and how project folders map onto it. */ +/** A deployment filesystem and how project folders map onto it. */ export type DeploymentServer = { + kind?: DeploymentType, id?: string, name?: string, /** [`SshConfiguration::id`]. */ @@ -2421,6 +2447,8 @@ export type DeploymentSettings = { exclude?: string[], }; +export type DeploymentType = "sftp" | "local"; + export type Diagnostic = { range: Range, severity: Severity, @@ -2737,6 +2765,8 @@ export type EditorSettings_Deserialize = { formatOnSave?: boolean, /** Editor colour scheme id; `theme-default` follows the IDE theme. */ colorScheme?: string, + /** Imported editor schemes, stored in user settings. */ + customSchemes?: { [key in string]: CustomColorScheme_Deserialize }, /** Color Scheme edits: scheme id → attribute id → override. */ colorOverrides?: { [key in string]: { [key in string]: TextAttributesOverride_Deserialize } }, /** @@ -2784,6 +2814,8 @@ export type EditorSettings_Serialize = { formatOnSave: boolean, /** Editor colour scheme id; `theme-default` follows the IDE theme. */ colorScheme: string, + /** Imported editor schemes, stored in user settings. */ + customSchemes: { [key in string]: CustomColorScheme_Serialize }, /** Color Scheme edits: scheme id → attribute id → override. */ colorOverrides: { [key in string]: { [key in string]: TextAttributesOverride_Serialize } }, /** @@ -2853,6 +2885,8 @@ export type EncodingSettings = { project?: string, /** Fallback for `.properties` files. */ properties?: string, + /** Display properties Unicode escapes as characters and save non-ASCII as escapes. */ + nativeToAscii?: boolean, /** Create UTF-8 files: `never` (no BOM) | `always` (with BOM) | `windows` (with BOM on Windows). */ utf8Bom?: string, }; @@ -3017,6 +3051,8 @@ export type FileRenameEdits = { export type FileSettings = { /** Save on focus loss / tab switch, like IDEA. */ autoSave?: boolean, + /** Re-check open files against disk when the IDE window gains focus. */ + syncOnFocus?: boolean, exclude?: string[], /** Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`]. */ ignoredNames?: string[], @@ -3096,6 +3132,8 @@ export type Fragment = { text: string, }; +export type FsConnection = { kind: "sftp"; ssh: string } | { kind: "local" }; + /** * A method breakpoint (DAP function breakpoint). `name` uses the adapter's syntax: * `pkg.Class#method` for java-debug, `pkg.Func` / `pkg.(*T).Method` for Delve, a plain @@ -3212,11 +3250,30 @@ export type GitSettings = { combineStashAndShelf?: boolean, }; +export type GoBuildTags = { + /** Empty values inherit the selected toolchain's defaults. */ + os?: string, + arch?: string, + version?: string, + compiler?: string, + /** Empty = automatic, "1" = enabled, "0" = disabled. */ + cgo?: string, + custom?: string, + experiments?: string, +}; + +export type GoModules = { + env?: { [key in string]: string }, + autoVendor?: boolean, +}; + export type GoSettings = { /** Empty = the `go` on `PATH`. */ goroot?: string, /** Empty = `go env GOPATH`. */ gopath?: string, + buildTags?: GoBuildTags, + modules?: GoModules, }; export type GotoKind = "definition" | "declaration" | "typeDefinition" | "implementation"; @@ -3737,6 +3794,19 @@ export type JdkVendor = { name: string, }; +export type JvmFilePresentation = { + path: string, + presentation: JvmPresentation, +}; + +export type JvmPresentation = { + kind: JvmTypeKind | null, + springBoot: boolean, + runnable: boolean, +}; + +export type JvmTypeKind = "class" | "abstractClass" | "interface" | "enum" | "record" | "annotation" | "exception" | "object"; + /** Editor › Code Style › › Tabs and Indents (unset fields inherit). */ export type LanguageCodeStyle = LanguageCodeStyle_Serialize | LanguageCodeStyle_Deserialize; @@ -5497,6 +5567,13 @@ export type Settings_Serialize = { export type Severity = "error" | "warning" | "info" | "hint"; +export type ShelfDiffFile = { + path: string, + patch: string, + before: string | null, + after: string | null, +}; + /** Version Control › Shelf. */ export type ShelfSettings = { /** Initial state of Unshelve's "Remove successfully applied files from the shelf". */ diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index 8de51c9e9..ee4ee2c27 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -31,7 +31,7 @@ import { type SimpleRange, } from "./model/selectionStack"; import type { EditorSlot } from "./model/slot"; -import { findAgain, openFindBar } from "./view/FindBar"; +import { findAgain, findPanelTarget, openFindBar, openFindBarResults, useFindPreferences } from "./view/FindBar"; import { collapseAll, collapseRecursively, @@ -479,6 +479,19 @@ export function registerEditorActions(): () => void { const pane = s.panes[s.activePane]; return pane?.active ? { paneId: pane.id, tabId: pane.active } : null; }; + const occurrence = (id: string, title: string, run: (view: EditorView) => unknown): Action => ({ + id, + title, + category: "Edit", + when: `${TEXT_WHEN} || findBarFocus || findBarOpen && popupOpen`, + run: ({ arg, ctx }) => { + const view = typeof arg === "string" || ctx.findBarFocus ? findPanelTarget(arg)?.view() : null; + if (view) { + run(view); + view.focus(); + } else withView(run)(); + }, + }); return registerActions([ schemeSwitch("editor.toggleSemanticHighlighting", "Semantic Highlighting", "semanticHighlighting", [ "rainbow variables", @@ -552,10 +565,8 @@ export function registerEditorActions(): () => void { category: "Folding", keywords: ["unfold"], }), - text("code.addCaretNextOccurrence", "Add Selection for Next Occurrence", (v) => addNextOccurrence(v), { - category: "Edit", - }), - text("code.selectAllOccurrences", "Select All Occurrences", (v) => selectAllOccurrences(v), { category: "Edit" }), + occurrence("code.addCaretNextOccurrence", "Add Selection for Next Occurrence", addNextOccurrence), + occurrence("code.selectAllOccurrences", "Select All Occurrences", selectAllOccurrences), text("code.completeStatement", "Complete Current Statement", (v, slot) => completeCurrentStatement(v, slot)), text("code.startNewLine", "Start New Line", (v) => startNewLine(v)), text("code.startNewLineBefore", "Start New Line Before Current", (v) => startNewLineBefore(v)), @@ -623,7 +634,7 @@ export function registerEditorActions(): () => void { text("code.addCaretsToLineEnds", "Add Carets to Ends of Selected Lines", (v) => addCaretsToLineEnds(v), { category: "Edit", }), - text("code.unselectOccurrence", "Unselect Occurrence", (v) => unselectOccurrence(v), { category: "Edit" }), + occurrence("code.unselectOccurrence", "Unselect Occurrence", unselectOccurrence), text("code.pasteFromHistory", "Paste from History…", (v) => pasteFromHistory(v), { category: "Edit", keywords: ["clipboard", "paste multiple"], @@ -717,6 +728,22 @@ export function registerEditorActions(): () => void { if (slot) toggleReadOnly(slot.model); }, }, + { + id: "find.openInWindow", + title: "Open Results in Find Window", + category: "Find", + when: "findBarHasResults", + keyWhen: "findBarFocus", + run: ({ arg }) => openFindBarResults(arg), + }, + { + id: "find.scrollToResults", + title: "Scroll to Results During Typing", + category: "Find", + when: "findBarOpen", + toggled: () => useFindPreferences.getState().scrollToResults, + run: () => useFindPreferences.setState((s) => ({ scrollToResults: !s.scrollToResults })), + }, { id: "find.inFile", title: "Find", diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index bcaefa951..670c86320 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -38,6 +38,8 @@ export interface DocBackend { close(doc: DocId): Promise; edit(doc: DocId, baseVersion: number, changes: TextChange[]): Promise; save(doc: DocId): Promise; + /** Re-check every open file for external changes missed by file watchers. */ + syncFromDisk?(): Promise; snapshot(doc: DocId): Promise; setLineEnding(doc: DocId, lineEnding: LineEnding): Promise; /** "Reload with Encoding" (`reload`) or "Convert to Encoding"; resolves to the new encoding. */ @@ -67,6 +69,7 @@ export const ipcBackend: DocBackend = { close: async (doc) => void (await call(commands.docClose(doc))), edit: (doc, baseVersion, changes) => call(commands.docEdit(doc, baseVersion, { changes })), save: async (doc) => void (await call(commands.docSave(doc))), + syncFromDisk: async () => void (await call(commands.docSyncFromDisk())), snapshot: (doc) => call(commands.docSnapshot(doc)), setLineEnding: async (doc, le) => void (await call(commands.docSetLineEnding(doc, le))), setEncoding: (doc, encoding, reload) => call(commands.docSetEncoding(doc, encoding, reload)), diff --git a/src/editor/core/cm/cmake.test.ts b/src/editor/core/cm/cmake.test.ts new file mode 100644 index 000000000..8a42fbf49 --- /dev/null +++ b/src/editor/core/cm/cmake.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { ensureSyntaxTree, syntaxTree } from "@codemirror/language"; +import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight"; +import { findLanguageDescription, loadSupport } from "./languages"; + +const highlighter = tagHighlighter([ + { tag: t.standard(t.function(t.variableName)), class: "command" }, + { tag: t.function(t.definition(t.variableName)), class: "definition" }, + { tag: t.function(t.variableName), class: "call" }, + { tag: t.standard(t.variableName), class: "builtinVariable" }, + { tag: t.special(t.variableName), class: "expansion" }, + { tag: t.keyword, class: "keyword" }, + { tag: t.bool, class: "bool" }, + { tag: t.number, class: "number" }, + { tag: t.string, class: "string" }, + { tag: t.escape, class: "escape" }, + { tag: t.comment, class: "comment" }, +]); + +async function state(source: string) { + const support = await loadSupport(findLanguageDescription("CMake", "CMakeLists.txt")!); + return EditorState.create({ doc: source, extensions: [support!] }); +} + +function tokens(state: EditorState) { + ensureSyntaxTree(state, state.doc.length, 1000); + const out: { text: string; style: string }[] = []; + highlightTree(syntaxTree(state), highlighter, (from, to, style) => + out.push({ text: state.doc.sliceString(from, to), style }), + ); + return out; +} + +describe("CMake highlighting", () => { + it("styles the screenshot's commands, argument keywords and built-in variables", async () => { + const out = tokens( + await state(`cmake_minimum_required(VERSION 3.20) +project(OpenIdeaSmoke LANGUAGES C CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +add_executable(cpp_smoke main.cpp math.cpp) +enable_testing() +add_test(NAME cpp_result COMMAND cpp_smoke) +`), + ); + for (const text of ["cmake_minimum_required", "project", "set", "add_executable", "enable_testing", "add_test"]) + expect(out).toContainEqual({ text, style: "command" }); + for (const text of ["VERSION", "LANGUAGES", "NAME", "COMMAND"]) + expect(out).toContainEqual({ text, style: "keyword" }); + for (const text of ["CMAKE_CXX_STANDARD", "CMAKE_EXPORT_COMPILE_COMMANDS"]) + expect(out).toContainEqual({ text, style: "builtinVariable" }); + expect(out).toContainEqual({ text: "3.20", style: "number" }); + expect(out).toContainEqual({ text: "17", style: "number" }); + expect(out).toContainEqual({ text: "ON", style: "bool" }); + }); + + it("styles version ranges and the project, CTest and CPack predefined variables", async () => { + const out = tokens( + await state( + "find_package(Qt6 6.2...<7.0 REQUIRED)\nmessage(${PROJECT_NAME} ${CTEST_SOURCE_DIRECTORY} ${CPACK_GENERATOR})\nset(PROJECT_VERSION 1.2.3)", + ), + ); + expect(out).toContainEqual({ text: "6.2...<7.0", style: "number" }); + expect(out).toContainEqual({ text: "1.2.3", style: "number" }); + expect(out).toContainEqual({ text: "PROJECT_VERSION", style: "builtinVariable" }); + }); + + it("recognizes case-insensitive commands, control flow and user-defined functions", async () => { + const out = tokens( + await state( + "FUNCTION(Build target)\n MESSAGE(STATUS hello)\nENDFUNCTION()\nBuild(app)\nif(DEFINED CMAKE_BUILD_TYPE AND NOT OFF)\nendif()", + ), + ); + for (const text of ["FUNCTION", "ENDFUNCTION", "if", "DEFINED", "AND", "NOT", "endif"]) + expect(out).toContainEqual({ text, style: "keyword" }); + expect(out).toContainEqual({ text: "MESSAGE", style: "command" }); + expect(out).toContainEqual({ text: "Build", style: "definition" }); + expect(out).toContainEqual({ text: "Build", style: "call" }); + }); + + it("separates quoted text, nested variable expansions, environment/cache lookups and escapes", async () => { + const out = tokens(await state('message("hello ${${PREFIX}_NAME} $ENV{HOME} $CACHE{SDK} \\"quote\\" # text")')); + for (const text of ["${${PREFIX}_NAME}", "$ENV{HOME}", "$CACHE{SDK}"]) + expect(out).toContainEqual({ text, style: "expansion" }); + expect(out).toContainEqual({ text: '\\"', style: "escape" }); + expect(out.some((t) => t.style === "comment")).toBe(false); + expect(out.some((t) => t.style === "string" && t.text.includes("# text"))).toBe(true); + }); + + it("keeps multiline bracket comments and literal bracket strings opaque", async () => { + const out = tokens( + await state( + "#[=[\nset(CMAKE_FAKE ON)\n]=]\nset(TEXT [==[\n${LITERAL} # raw\n]==])\n# line comment\nmessage(hello)", + ), + ); + expect(out.some((t) => t.style === "command" && t.text === "set")).toBe(true); + expect(out.some((t) => t.text.includes("CMAKE_FAKE") && t.style === "comment")).toBe(true); + expect(out.some((t) => t.text.includes("${LITERAL}") && t.style === "string")).toBe(true); + expect(out.some((t) => t.style === "expansion")).toBe(false); + expect(out).toContainEqual({ text: "message", style: "command" }); + }); + + it("handles nested generator expressions without swallowing following commands", async () => { + const expression = "$,a,b>"; + const out = tokens(await state(`target_link_libraries(app PRIVATE ${expression})\nmessage(done)`)); + expect(out).toContainEqual({ text: expression, style: "expansion" }); + expect(out).toContainEqual({ text: "message", style: "command" }); + }); + + it("reparses multiline string edits and resolves CMake filename associations", async () => { + const before = await state("set(TEXT [=[\nmessage(hidden)\n]=])\nmessage(visible)"); + const out = tokens(before); + expect(out.filter((t) => t.style === "command" && t.text === "message")).toHaveLength(1); + const after = before.update({ changes: { from: 9, to: 12, insert: '"' } }).state; + expect(tokens(after).filter((t) => t.style === "command" && t.text === "message")).toHaveLength(0); + expect(tokens(before)).toEqual(out); + for (const path of ["CMakeLists.txt", "module.cmake", "toolchain.cmake.in"]) + expect(findLanguageDescription(null, path)?.name, path).toBe("CMake"); + }); +}); diff --git a/src/editor/core/cm/cmake.ts b/src/editor/core/cm/cmake.ts new file mode 100644 index 000000000..0b0c3e38f --- /dev/null +++ b/src/editor/core/cm/cmake.ts @@ -0,0 +1,159 @@ +import { StreamLanguage, type StreamParser, type StringStream } from "@codemirror/language"; +import { tags as t } from "@lezer/highlight"; + +const COMMANDS = new Set( + ` +add_compile_definitions add_compile_options add_custom_command add_custom_target add_definitions +add_dependencies add_executable add_library add_link_options add_subdirectory add_test aux_source_directory +build_command build_name cmake_file_api cmake_host_system_information cmake_language cmake_minimum_required +cmake_parse_arguments cmake_path cmake_policy configure_file create_test_sourcelist +ctest_build ctest_configure ctest_coverage ctest_empty_binary_directory ctest_memcheck ctest_read_custom_files +ctest_run_script ctest_sleep ctest_start ctest_submit ctest_test ctest_update ctest_upload define_property +enable_language enable_testing exec_program execute_process export export_library_dependencies file +find_file find_library find_package find_path find_program fltk_wrap_ui get_cmake_property +get_directory_property get_filename_component get_property get_source_file_property get_target_property +get_test_property include include_directories include_external_msproject include_guard include_regular_expression +install install_files install_programs install_targets link_directories link_libraries list load_cache +load_command make_directory mark_as_advanced math message option output_required_files project qt_wrap_cpp +qt_wrap_ui remove remove_definitions separate_arguments set set_directory_properties set_property +set_source_files_properties set_target_properties set_tests_properties site_name source_group string +subdir_depends subdirs target_compile_definitions target_compile_features target_compile_options + target_include_directories target_link_directories target_link_libraries target_link_options + target_precompile_headers target_sources try_compile try_run unset use_mangled_mesa utility_source +variable_requires variable_watch write_file +` + .trim() + .split(/\s+/), +); + +const CONTROL = new Set( + ` +block break continue else elseif endblock endforeach endfunction endif endmacro endwhile +foreach function if macro return while +` + .trim() + .split(/\s+/), +); + +const ARGUMENTS = new Set( + ` +VERSION LANGUAGES NAME COMMAND COMMANDS WORKING_DIRECTORY COMMENT DEPENDS OUTPUT OUTPUTS BYPRODUCTS +PRIVATE PUBLIC INTERFACE REQUIRED QUIET COMPONENTS OPTIONAL_COMPONENTS CONFIG MODULE EXACT GLOBAL +APPEND PROPERTY PROPERTIES CACHE INTERNAL FORCE PARENT_SCOPE PATH FILEPATH BOOL STRING +DIRECTORY DIRECTORIES FILE FILES TARGET TARGETS DESTINATION EXPORT NAMESPACE TYPE STATIC SHARED +MODULE OBJECT IMPORTED ALIAS EXCLUDE_FROM_ALL ALL BEFORE AFTER SYSTEM CONFIGURE_DEPENDS +AND OR NOT DEFINED EXISTS IS_DIRECTORY IS_SYMLINK IS_ABSOLUTE MATCHES LESS GREATER EQUAL +LESS_EQUAL GREATER_EQUAL STRLESS STRGREATER STREQUAL VERSION_LESS VERSION_GREATER VERSION_EQUAL +VERSION_LESS_EQUAL VERSION_GREATER_EQUAL IN_LIST COMMAND POLICY TARGET TEST +` + .trim() + .split(/\s+/), +); + +interface State { + quoted: boolean; + bracket: { end: string; style: "comment" | "string" } | null; + depth: number; + definition: boolean; +} + +function bracket(stream: StringStream, state: State): string { + const { end, style } = state.bracket!; + const close = stream.string.indexOf(end, stream.pos); + if (close < 0) stream.skipToEnd(); + else { + stream.pos = close + end.length; + state.bracket = null; + } + return style; +} + +function expansion(stream: StringStream): boolean { + const variable = stream.match(/^\$(?:ENV|CACHE)?\{/); + const generator = !variable && stream.match(/^\$")) depth--; + } + } + return true; +} + +const parser: StreamParser = { + name: "cmake", + startState: () => ({ quoted: false, bracket: null, depth: 0, definition: false }), + copyState: (s) => ({ ...s, bracket: s.bracket && { ...s.bracket } }), + languageData: { commentTokens: { line: "#", block: { open: "#[[", close: "]]" } } }, + tokenTable: { + command: t.standard(t.function(t.variableName)), + cmakeVariable: t.standard(t.variableName), + expansion: t.special(t.variableName), + functionDefinition: t.function(t.definition(t.variableName)), + }, + token(stream, state) { + if (state.bracket) return bracket(stream, state); + if (expansion(stream)) return "expansion"; + if (stream.eat("\\")) { + stream.next(); + return "escape"; + } + if (stream.eat('"')) { + state.quoted = !state.quoted; + return "string"; + } + if (state.quoted) { + while (!stream.eol() && !/["\\]/.test(stream.peek()!)) { + if (stream.match(/^\$(?:(?:ENV|CACHE)?\{|<)/, false)) break; + stream.next(); + } + return "string"; + } + if (stream.eatSpace()) return null; + const open = stream.match(/^(#?)\[(=*)\[/); + if (open) { + const [, comment, equals] = open as RegExpMatchArray; + state.bracket = { end: `]${equals}]`, style: comment ? "comment" : "string" }; + return bracket(stream, state); + } + if (stream.eat("#")) { + stream.skipToEnd(); + return "comment"; + } + if (stream.eat("(")) { + state.depth++; + return "paren"; + } + if (stream.eat(")")) { + state.depth = Math.max(0, state.depth - 1); + state.definition = false; + return "paren"; + } + const word = stream.match(/^[^\s()#"\\$]+/); + if (word) { + const text = (word as RegExpMatchArray)[0]; + if (state.depth === 0 && /^[A-Za-z_]\w*$/.test(text) && stream.match(/^\s*\(/, false)) { + const command = text.toLowerCase(); + state.definition = command === "function" || command === "macro"; + return CONTROL.has(command) ? "keyword" : COMMANDS.has(command) ? "command" : "variableName.function"; + } + if (state.definition) { + state.definition = false; + return "functionDefinition"; + } + if (/^[+-]?\d+(?:\.\d+)*(?:\.\.\. { } const starts = (v: EditorView) => countMatches(v.state).starts; + it.each(["inComments", "inStringLiterals", "exceptComments", "exceptCommentsAndStringLiterals"] as const)( + "exports the same %s syntax filter as the bar", + (context) => { + const v = makeCode({ context }); + const lines = findResultLines(v.state, { ...base, search: "foo", context }); + expect(lines.flatMap((m) => m.ranges.map(([from]) => v.state.doc.line(m.line + 1).from + from))).toEqual( + starts(v), + ); + }, + ); + it("filters matches by comment / string literal", () => { expect(starts(makeCode({}))).toEqual([0, 5, 14, 18]); expect(starts(makeCode({ context: "inComments" }))).toEqual([14]); @@ -160,3 +172,107 @@ describe("search context, exclude and preserve case", () => { expect(sel(w)).toEqual([4, 7]); }); }); + +describe("in-file Find result lines", () => { + it.each([ + [ + { search: "foo" }, + [ + [ + 0, + [ + [3, 6], + [7, 10], + [11, 14], + ], + ], + [1, [[0, 3]]], + ], + ], + [ + { search: "foo", caseSensitive: true }, + [ + [ + 0, + [ + [3, 6], + [11, 14], + ], + ], + [1, [[0, 3]]], + ], + ], + [ + { search: "foo", wholeWord: true }, + [ + [ + 0, + [ + [3, 6], + [7, 10], + ], + ], + [1, [[0, 3]]], + ], + ], + [ + { search: "f.o", regexp: true, caseSensitive: true }, + [ + [ + 0, + [ + [3, 6], + [11, 14], + ], + ], + [1, [[0, 3]]], + ], + ], + [{ search: "missing" }, []], + [{ search: "" }, []], + [{ search: "[", regexp: true }, []], + ] as const)("preserves matching options %j and UTF-16 columns", (options, expected) => { + const v = make("😀 foo FOO foobar\nfoo", options); + expect(findResultLines(v.state, { ...useOptions, ...options }).map((m) => [m.line, m.ranges])).toEqual(expected); + }); + + const useOptions: FindOptions = { search: "foo", regexp: false, caseSensitive: false, wholeWord: false, replace: "" }; + + it("searches edited text in the selection scope, grouping ranges on the same line", () => { + const v = make("foo\nfoo foo\nfoo", useOptions, { from: 4, to: 11 }); + v.dispatch({ changes: { from: 4, to: 7, insert: "FOO" } }); + expect(findResultLines(v.state, useOptions)).toEqual([ + { + line: 1, + text: "FOO foo", + textOffset: 0, + ranges: [ + [0, 3], + [4, 7], + ], + }, + ]); + }); + + it("splits multiline matches into highlighted result lines", () => { + const v = make("before foo\nbar after", { search: "foo\nbar" }); + expect(findResultLines(v.state, { ...useOptions, search: "foo\nbar" })).toEqual([ + { line: 0, text: "before foo", textOffset: 0, ranges: [[7, 10]] }, + { line: 1, text: "bar after", textOffset: 0, ranges: [[0, 3]] }, + ]); + }); + + it("retains excluded occurrences", () => { + const v = make("foo foo foo", useOptions); + v.dispatch({ effects: toggleFindExcluded.of({ from: 4, to: 7 }) }); + expect(findResultLines(v.state, useOptions)[0]?.ranges).toEqual([ + [0, 3], + [8, 11], + ]); + }); + + it("exports every match beyond the bar counter limit", () => { + const v = make("foo\n".repeat(10_005), useOptions); + expect(findResultLines(v.state, useOptions)).toHaveLength(10_005); + }); +}); diff --git a/src/editor/core/cm/find.ts b/src/editor/core/cm/find.ts index 27644fe65..9c7358d4d 100644 --- a/src/editor/core/cm/find.ts +++ b/src/editor/core/cm/find.ts @@ -28,6 +28,7 @@ import { setSearchQuery, } from "@codemirror/search"; import { scrollbarMarks, type ScrollbarMark } from "@/editor/api"; +import type { LineMatch } from "@/platform/ipc"; import { contextAccepts, preserveCase, type SearchContext } from "@/platform/search"; import { literalKindAt, literalsExtension } from "./literals"; @@ -376,3 +377,26 @@ export function excludeCurrent(view: EditorView): boolean { if (!excluded) step(view, true); return true; } + +/** Result lines from the same query, scope and syntax filters used by the bar. */ +export function findResultLines(state: EditorState, options: FindOptions, scope = scopeOf(state)) { + const query = makeQuery(options); + const lines = new Map(); + if (!query.valid) return []; + const cursor = query.getCursor(state, Math.min(scope.from, state.doc.length), Math.min(scope.to, state.doc.length)); + for (let r = cursor.next(); !r.done; r = cursor.next()) { + const { from, to } = r.value; + const first = state.doc.lineAt(from); + const last = state.doc.lineAt(Math.max(from, to - 1)); + for (let number = first.number; number <= last.number; number++) { + const line = state.doc.line(number); + let result = lines.get(number); + if (!result) { + result = { line: number - 1, text: line.text, textOffset: 0, ranges: [] }; + lines.set(number, result); + } + result.ranges.push([Math.max(from, line.from) - line.from, Math.min(to, line.to) - line.from]); + } + } + return [...lines.values()]; +} diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index 9b5662c98..bcd1d9bd2 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -60,8 +60,14 @@ export function uiModeForPath(path: string): string | null | undefined { const supports = new Map>(); -/** Frontend modes with template syntax missing from the bundled parsers. */ +/** Frontend modes that extend or replace the bundled parsers. */ const OWN_MODES = [ + LanguageDescription.of({ + name: "CMake", + extensions: ["cmake", "cmake.in"], + filename: /^(?:CMakeLists\.txt|.*\.cmake(?:\.in)?)$/, + load: async () => new LanguageSupport((await import("./cmake")).cmakeLanguage), + }), LanguageDescription.of({ name: "Shell", load: async () => { @@ -169,6 +175,8 @@ export function findLanguageDescription(uiMode: string | null, path: string): La if (loose) return OWN_MODES.find((l) => l.name === loose.name) ?? loose; } if (uiMode === null && infos) return null; + const own = LanguageDescription.matchFilename(OWN_MODES, path.slice(path.lastIndexOf("/") + 1)); + if (own) return own; const matched = LanguageDescription.matchFilename(BUILTIN_MODES, path.slice(path.lastIndexOf("/") + 1)); return OWN_MODES.find((l) => l.name === matched?.name) ?? matched; } diff --git a/src/editor/core/colorSchemeRuntime.ts b/src/editor/core/colorSchemeRuntime.ts index b94600d42..d6aa7221f 100644 --- a/src/editor/core/colorSchemeRuntime.ts +++ b/src/editor/core/colorSchemeRuntime.ts @@ -16,7 +16,7 @@ export function installColorScheme(): () => void { const apply = () => { const settings = useProject.getState().settings; const prefs = editorPrefs(settings); - const css = schemeCss(settings?.editor?.colorScheme, prefs.colorOverrides); + const css = schemeCss(settings?.editor?.colorScheme, prefs.colorOverrides, undefined, prefs.customSchemes); if (el.textContent !== css) el.textContent = css; for (const [name, value] of Object.entries(fontWeightVars(prefs.fontWeight, prefs.boldFontWeight))) if (root.style.getPropertyValue(name) !== value) root.style.setProperty(name, value); diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 7bf9e0282..cab61a97f 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -72,6 +72,7 @@ import { type TabsState, } from "./model/tabs"; import { DocumentManager, useDocMeta } from "./model/document"; +import { installDiskSync } from "./diskSync"; import { closedDocuments, combinedError, @@ -313,6 +314,13 @@ export class EditorController implements SlotHost { }; const onPageHide = () => this.saveSession(); if (typeof window !== "undefined") { + if (this.manager.backend.syncFromDisk) + this.disposers.push( + installDiskSync( + () => this.manager.backend.syncFromDisk!(), + () => !this.closeSession, + ), + ); window.addEventListener("blur", onWindowBlur); window.addEventListener("pagehide", onPageHide); window.addEventListener("beforeunload", onPageHide); diff --git a/src/editor/core/diskSync.test.ts b/src/editor/core/diskSync.test.ts new file mode 100644 index 000000000..a63543607 --- /dev/null +++ b/src/editor/core/diskSync.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { executeAction } from "@/platform/actions"; +import { useProject } from "@/platform/project"; +import { installDiskSync } from "./diskSync"; +import { EditorController } from "./controller"; +import { createMemoryBackend } from "./backend"; + +vi.mock("@/platform/actions", async (original) => ({ + ...(await original()), + executeAction: vi.fn().mockResolvedValue(true), +})); + +let dispose: (() => void) | undefined; +let controller: EditorController | undefined; +const focus = () => window.dispatchEvent(new Event("focus")); +const settings = (syncOnFocus = true) => ({ files: { syncOnFocus, autoSave: false } }) as never; + +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + useProject.setState({ + project: { root: "/project" } as never, + settings: settings(), + opening: false, + lightEdit: false, + }); +}); + +afterEach(() => { + dispose?.(); + dispose = undefined; + controller?.stop(); + controller = undefined; + useProject.setState({ project: null, settings: null, lightEdit: false, opening: false }); + vi.useRealTimers(); +}); + +describe("window disk synchronization", () => { + it("debounces focus events and refreshes the project tree and VCS", async () => { + const sync = vi.fn().mockResolvedValue(undefined); + dispose = installDiskSync(sync, () => true); + focus(); + await vi.advanceTimersByTimeAsync(150); + focus(); + await vi.advanceTimersByTimeAsync(199); + expect(sync).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(sync).toHaveBeenCalledOnce(); + expect(vi.mocked(executeAction).mock.calls).toEqual([["project.refresh"], ["vcs.refresh"]]); + }); + + it("defaults on for older settings and does nothing when the setting is off", async () => { + const sync = vi.fn().mockResolvedValue(undefined); + dispose = installDiskSync(sync, () => true); + useProject.setState({ settings: {} as never }); + focus(); + await vi.advanceTimersByTimeAsync(200); + expect(sync).toHaveBeenCalledOnce(); + sync.mockClear(); + vi.mocked(executeAction).mockClear(); + useProject.setState({ settings: settings(false) }); + focus(); + await vi.advanceTimersByTimeAsync(500); + expect(sync).not.toHaveBeenCalled(); + expect(executeAction).not.toHaveBeenCalled(); + }); + + it.each(["setting", "project", "opening", "closing", "dispose"])( + "cancels pending sync on %s changes", + async (change) => { + let canSync = true; + const sync = vi.fn().mockResolvedValue(undefined); + dispose = installDiskSync(sync, () => canSync); + focus(); + if (change === "setting") useProject.setState({ settings: settings(false) }); + if (change === "project") useProject.setState({ project: { root: "/other" } as never }); + if (change === "opening") useProject.setState({ opening: true }); + if (change === "closing") canSync = false; + if (change === "dispose") dispose(); + await vi.advanceTimersByTimeAsync(200); + expect(sync).not.toHaveBeenCalled(); + expect(executeAction).not.toHaveBeenCalled(); + }, + ); + + it("ignores welcome windows and synchronizes LightEdit without project refreshes", async () => { + const sync = vi.fn().mockResolvedValue(undefined); + dispose = installDiskSync(sync, () => true); + useProject.setState({ project: null }); + focus(); + await vi.advanceTimersByTimeAsync(200); + expect(sync).not.toHaveBeenCalled(); + useProject.setState({ lightEdit: true }); + focus(); + await vi.advanceTimersByTimeAsync(200); + expect(sync).toHaveBeenCalledOnce(); + expect(executeAction).not.toHaveBeenCalled(); + }); + + it("coalesces focus during a slow check into a follow-up and removes its listener", async () => { + let finish!: () => void; + const sync = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue(undefined); + dispose = installDiskSync(sync, () => true); + focus(); + await vi.advanceTimersByTimeAsync(200); + focus(); + await vi.advanceTimersByTimeAsync(200); + expect(sync).toHaveBeenCalledOnce(); + finish(); + await vi.advanceTimersByTimeAsync(200); + expect(sync).toHaveBeenCalledTimes(2); + dispose(); + focus(); + await vi.advanceTimersByTimeAsync(200); + expect(sync).toHaveBeenCalledTimes(2); + }); + + it("does not refresh a replacement project when an old check finishes", async () => { + let finish!: () => void; + dispose = installDiskSync( + () => + new Promise((resolve) => { + finish = resolve; + }), + () => true, + ); + focus(); + await vi.advanceTimersByTimeAsync(200); + useProject.setState({ project: { root: "/other" } as never }); + finish(); + await vi.advanceTimersByTimeAsync(200); + expect(executeAction).not.toHaveBeenCalled(); + }); + + it("controller preserves the unchanged document's selection and scroll, then stops checking", async () => { + useProject.setState({ project: null, lightEdit: true }); + const syncFromDisk = vi.fn().mockResolvedValue(undefined); + const backend = { ...createMemoryBackend({ "/file.txt": "line\n".repeat(100) }), syncFromDisk }; + controller = new EditorController(backend); + controller.start(); + await controller.openFile("/file.txt", { focus: false }); + const slot = controller.activeSlot!; + const host = document.createElement("div"); + document.body.append(host); + try { + const view = slot.mount(host); + controller.onMounted(slot); + view.dispatch({ selection: { anchor: 150 } }); + view.scrollDOM.scrollTop = 80; + const state = view.state; + focus(); + await vi.advanceTimersByTimeAsync(200); + expect(syncFromDisk).toHaveBeenCalledOnce(); + expect(view.state.doc).toBe(state.doc); + expect(view.state.selection.eq(state.selection)).toBe(true); + expect(view.state.selection.main.anchor).toBe(150); + expect(view.scrollDOM.scrollTop).toBe(80); + controller.stop(); + controller = undefined; + focus(); + await vi.advanceTimersByTimeAsync(200); + expect(syncFromDisk).toHaveBeenCalledOnce(); + } finally { + host.remove(); + } + }); +}); diff --git a/src/editor/core/diskSync.ts b/src/editor/core/diskSync.ts new file mode 100644 index 000000000..8ca828a27 --- /dev/null +++ b/src/editor/core/diskSync.ts @@ -0,0 +1,51 @@ +import { executeAction } from "@/platform/actions"; +import { useProject } from "@/platform/project"; + +/** One debounced disk check per window activation, scoped to its current project. */ +export function installDiskSync(sync: () => Promise, canSync: () => boolean): () => void { + let timer: ReturnType | undefined; + let running = false; + let pending = false; + let disposed = false; + const enabled = () => { + const s = useProject.getState(); + return ( + !disposed && canSync() && !s.opening && (s.project || s.lightEdit) && s.settings?.files?.syncOnFocus !== false + ); + }; + const onFocus = () => { + clearTimeout(timer); + if (!enabled()) return; + const { project, lightEdit } = useProject.getState(); + timer = setTimeout(async () => { + if (!enabled() || useProject.getState().project !== project || useProject.getState().lightEdit !== lightEdit) + return; + if (running) { + pending = true; + return; + } + running = true; + try { + await sync(); + if (enabled() && useProject.getState().project === project && !lightEdit) { + void executeAction("project.refresh"); + void executeAction("vcs.refresh"); + } + } catch (e) { + console.warn("Cannot synchronize external file changes", e); + } finally { + running = false; + if (pending) { + pending = false; + onFocus(); + } + } + }, 200); + }; + window.addEventListener("focus", onFocus); + return () => { + disposed = true; + clearTimeout(timer); + window.removeEventListener("focus", onFocus); + }; +} diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index 8b65856d1..e91bebf39 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -1,3 +1,4 @@ +import { useFilePresentationIcons } from "@/platform/filePresentation"; /** * The editor area: split layout of panes, each with IDEA-style tabs (preview * tab in italics, pinned tabs, modified dot, file icons coloured by file @@ -145,6 +146,9 @@ function PaneView({ paneId }: { paneId: string }) { const { showExtension, markModified, showCloseButton, showFileIcon, showDirectoryForNonUnique, showFullPathOnHover } = tabPrefs; + const fileIcons = useFilePresentationIcons( + showFileIcon ? tabs.flatMap((t) => (t.input.kind === "file" && t.input.path ? [t.input.path] : [])) : [], + ); const items: TabItem[] = useMemo(() => { const names = tabs.map((t) => tabTitle(t.input, kinds)); return tabs.map((t, i) => { @@ -167,7 +171,11 @@ function PaneView({ paneId }: { paneId: string }) { ), text: dupe ? `${name} — ${dupe}` : name, - icon: showFileIcon ? tabIcon(t.input, kinds) : undefined, + icon: showFileIcon + ? t.input.kind === "file" && t.input.path + ? fileIcons.get(t.input.path) + : tabIcon(t.input, kinds) + : undefined, italic: t.preview, modified: dirty[i], closable: showCloseButton, @@ -178,6 +186,7 @@ function PaneView({ paneId }: { paneId: string }) { }; }); }, [ + fileIcons, tabs, kinds, dirty, diff --git a/src/editor/core/view/FindBar.test.tsx b/src/editor/core/view/FindBar.test.tsx new file mode 100644 index 000000000..fbe166300 --- /dev/null +++ b/src/editor/core/view/FindBar.test.tsx @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { executeAction, registerAction } from "@/platform/actions"; +import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap"; +import { getContext } from "@/platform/context"; +import { provideFindResults } from "@/workbench/contributions"; +import { registerEditorActions } from "../actions"; +import { findExtension, findResultLines } from "../cm/find"; +import { FindPanel, useFindOptions, useFindPreferences, type FindTarget } from "./FindBar"; + +const disposers: Array<() => void> = []; +afterEach(() => { + disposers + .splice(0) + .reverse() + .forEach((dispose) => dispose()); + useFindOptions.setState({ search: "", regexp: false, caseSensitive: false, wholeWord: false }); + useFindPreferences.setState({ scrollToResults: true }); + useKeymap.setState({ preset: "intellij-macos", overrides: {} }); +}); + +function setup() { + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc: "foo\nFOO\nfoo", + extensions: [findExtension(), EditorState.allowMultipleSelections.of(true)], + }), + }); + const target: FindTarget = { + id: "test-find", + view: () => view, + watch: () => () => {}, + isOpen: () => true, + results: (options) => ({ + title: `Occurrences of '${options.search}'`, + query: { + pattern: options.search, + isRegex: options.regexp, + caseSensitive: options.caseSensitive, + wholeWord: options.wholeWord, + include: [], + exclude: [], + scope: { kind: "files", paths: ["/a.ts"] }, + maxMatches: 0, + }, + read: () => ({ path: "/a.ts", relPath: "a.ts", matches: findResultLines(view.state, options) }), + }), + }; + disposers.push(() => view.destroy(), registerEditorActions(), installKeyboardDispatcher()); + const panel = render( {}} />); + disposers.push(panel.unmount); + return { view, field: screen.getByRole("textbox", { name: "Find" }) }; +} + +const setSearch = (field: HTMLElement, value: string) => fireEvent.change(field, { target: { value } }); + +describe("Find bar More actions", () => { + it("opens current results from the menu and reserves Alt+F7 while focused", async () => { + const user = userEvent.setup(); + const open = vi.fn(); + const usages = vi.fn(); + disposers.push(provideFindResults(open), registerAction({ id: "find.usages", title: "Find Usages", run: usages })); + const { field } = setup(); + setSearch(field, "foo"); + await user.click(screen.getByRole("button", { name: "More" })); + await user.click(screen.getByRole("menuitem", { name: /Open Results in Find Window/ })); + expect(open).toHaveBeenCalledOnce(); + expect(open.mock.calls[0]![0].read().matches).toHaveLength(3); + field.focus(); + fireEvent.keyDown(field, { key: "F7", code: "F7", altKey: true }); + expect(open).toHaveBeenCalledTimes(2); + expect(usages).not.toHaveBeenCalled(); + setSearch(field, "missing"); + fireEvent.keyDown(field, { key: "F7", code: "F7", altKey: true }); + expect(open).toHaveBeenCalledTimes(2); + expect(usages).not.toHaveBeenCalled(); + expect(getContext().findBarHasResults).toBe(false); + }); + + it.each(["intellij-macos", "intellij-windows", "vscode-macos", "vscode-windows", "eclipse-macos", "eclipse-windows"])( + "exports with Alt+F7 in the %s preset", + (preset) => { + useKeymap.setState({ preset }); + const open = vi.fn(); + disposers.push(provideFindResults(open)); + const { field } = setup(); + setSearch(field, "foo"); + fireEvent.keyDown(field, { key: "F7", code: "F7", altKey: true }); + expect(open).toHaveBeenCalledOnce(); + }, + ); + + it("honors a rebound shortcut and releases it outside the bar", () => { + const open = vi.fn(); + const other = vi.fn(); + useKeymap.setState({ overrides: { "find.openInWindow": ["Alt+F8"], "find.usages": ["Alt+F8"] } }); + disposers.push(provideFindResults(open), registerAction({ id: "find.usages", title: "Find Usages", run: other })); + const { field, view } = setup(); + setSearch(field, "foo"); + fireEvent.keyDown(field, { key: "F8", code: "F8", altKey: true }); + expect(open).toHaveBeenCalledOnce(); + view.focus(); + fireEvent.keyDown(view.contentDOM, { key: "F8", code: "F8", altKey: true }); + expect(other).toHaveBeenCalledOnce(); + expect(open).toHaveBeenCalledOnce(); + }); + + it.each(["", "missing", "["])("disables export for %j", async (query) => { + const user = userEvent.setup(); + const { field } = setup(); + if (query === "[") act(() => useFindOptions.setState({ regexp: true })); + setSearch(field, query); + await user.click(screen.getByRole("button", { name: "More" })); + expect(screen.getByRole("menuitem", { name: /Open Results in Find Window/ })).toHaveAttribute( + "aria-disabled", + "true", + ); + }); + + it("runs existing multiple cursor actions on the searched editor", async () => { + const user = userEvent.setup(); + const { field, view } = setup(); + setSearch(field, "foo"); + await user.click(screen.getByRole("button", { name: "More" })); + await user.click(screen.getByRole("menuitem", { name: /Select All Occurrences/ })); + expect(view.state.selection.ranges).toHaveLength(2); + field.focus(); + await user.click(screen.getByRole("button", { name: "More" })); + await user.click(screen.getByRole("menuitem", { name: /Unselect Occurrence/ })); + expect(view.state.selection.ranges).toHaveLength(1); + }); + + it("turns off incremental selection while retaining results and explicit navigation", async () => { + const { field, view } = setup(); + await act(async () => { + await executeAction("find.scrollToResults"); + }); + expect(useFindPreferences.getState().scrollToResults).toBe(false); + setSearch(field, "foo"); + expect(view.state.selection.main.empty).toBe(true); + await waitFor(() => expect(screen.getByText("?/3")).toBeInTheDocument()); + fireEvent.keyDown(field, { key: "Enter", code: "Enter" }); + expect(view.state.selection.main.empty).toBe(false); + }); +}); diff --git a/src/editor/core/view/FindBar.tsx b/src/editor/core/view/FindBar.tsx index df139ed0e..438bee707 100644 --- a/src/editor/core/view/FindBar.tsx +++ b/src/editor/core/view/FindBar.tsx @@ -21,6 +21,7 @@ import { findNext, findPrevious, findScope, + findResultLines, isExcluded, regexValid, replaceAll, @@ -33,7 +34,12 @@ import { type FindScope, } from "../cm/find"; import styles from "./EditorArea.module.css"; -import { hasPrimaryModifier } from "@/platform/keymap"; +import { executeAction } from "@/platform/actions"; +import { setContext } from "@/platform/context"; +import { showFindResults, type FindResultsSource } from "@/workbench/contributions"; +import { relativePath, useProject } from "@/platform/project"; +import { tx } from "@/platform/i18n"; +import { bindingsFor, hasPrimaryModifier, normalizeBinding, registerKeyClaim, shortcutLabel } from "@/platform/keymap"; /** The query is shared by all panes and survives closing the bar, like IDEA. */ export const useFindOptions = create>(() => ({ @@ -67,6 +73,7 @@ export interface FindTarget { watch(fn: (u: { docChanged: boolean; selectionSet: boolean }) => void): () => void; /** Still open: an unmount that isn't a close (React's dev-mode remount) keeps the highlights. */ isOpen(): boolean; + results?(options: FindOptions): FindResultsSource | null; } function activeView(paneId: string): EditorView | null { @@ -91,12 +98,68 @@ function paneTarget(paneId: string): FindTarget { return () => void slot.listeners.delete(fn); }, isOpen: () => !!useFindBars.getState()[paneId], + results(options) { + const tab = useTabs.getState().panes[paneId]?.active; + const slot = tab ? editorController().slotFor(tab) : null; + if (!slot) return null; + const state = slot.currentState(); + const scope = state.field(findScope, false) ?? { from: 0, to: state.doc.length }; + const path = slot.ctx.path; + const root = useProject.getState().project?.root; + return { + title: tx`Occurrences of '${options.search}'`, + query: { + pattern: options.search, + isRegex: options.regexp, + caseSensitive: options.caseSensitive, + wholeWord: options.wholeWord, + context: options.context, + include: [], + exclude: [], + scope: { kind: "files", paths: [path] }, + maxMatches: 0, + }, + read: () => { + const current = slot.disposed ? state : slot.currentState(); + return { + path, + relPath: root ? relativePath(root, path) : path, + matches: findResultLines( + current, + options, + state.field(findScope, false) + ? (current.field(findScope, false) ?? scope) + : { from: 0, to: current.doc.length }, + ), + }; + }, + }; + }, }; paneTargets.set(paneId, target); } return target; } +export const useFindPreferences = create(() => ({ scrollToResults: true })); +const panelTargets = new Map(); +let focusedTarget: FindTarget | undefined; + +export function findPanelTarget(arg?: unknown): FindTarget | undefined { + return typeof arg === "string" ? panelTargets.get(arg) : focusedTarget; +} + +export function openFindBarResults(arg?: unknown) { + const target = findPanelTarget(arg); + const opts = useFindOptions.getState(); + if (!opts.search || (opts.regexp && !regexValid(opts.search))) return; + const source = target?.results?.({ ...opts }); + if (source?.read().matches.length) { + rememberQuery(false); + showFindResults(source); + } +} + export function closeFindBar(paneId: string) { useFindBars.setState({ [paneId]: undefined }); } @@ -236,11 +299,54 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo const replaceRef = useRef(null); const findBoxRef = useRef(null); const replaceBoxRef = useRef(null); + const panelRef = useRef(null); + const moreRef = useRef(null); + const scrollToResults = useFindPreferences((s) => s.scrollToResults); + const [moreMenu, setMoreMenu] = useState(false); const filterRef = useRef(null); const [historyMenu, setHistoryMenu] = useState<"find" | "replace" | null>(null); const [filterMenu, setFilterMenu] = useState(false); const setOpts = (patch: Partial) => useFindOptions.setState(patch); + useLayoutEffect(() => { + panelTargets.set(id, target); + setContext("findBarOpen", true); + return () => { + panelTargets.delete(id); + setContext("findBarOpen", panelTargets.size > 0); + if (focusedTarget === target) { + focusedTarget = undefined; + setContext("findBarFocus", false); + setContext("findBarHasResults", false); + } + }; + }, [id, target]); + + useEffect(() => { + if (focusedTarget === target) + setContext( + "findBarHasResults", + !!target.results && !!opts.search && (!opts.regexp || regexValid(opts.search)) && !!count?.total, + ); + }, [target, opts, count]); + + // Reserve this bar's shortcut even when there are no results to export. + useEffect( + () => + registerKeyClaim((e, stroke) => { + if ( + !panelRef.current?.contains(e.target as Node) || + !bindingsFor("find.openInWindow").some((binding) => normalizeBinding(binding) === stroke) + ) + return false; + e.preventDefault(); + e.stopPropagation(); + void executeAction("find.openInWindow", id); + return true; + }), + [id], + ); + // Opening (or ⌘F again): focus + select the field. useLayoutEffect(() => { inputRef.current?.focus(); @@ -254,7 +360,7 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo const valid = !opts.regexp || regexValid(opts.search); setQuery(view, valid ? opts : { ...opts, search: "" }); view.dispatch({ effects: setFindOpen.of(true) }); - if (valid && opts.search) { + if (valid && opts.search && useFindPreferences.getState().scrollToResults) { const from = view.state.selection.main.from; view.dispatch({ selection: { anchor: from } }); findNext(view); @@ -387,10 +493,42 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo run: () => setOpts({ context: c.value }), })), ]; + const invoke = (action: string) => void executeAction(action, id); + const moreItems: MenuItem[] = [ + { + label: "Open Results in Find Window", + shortcut: shortcutLabel("find.openInWindow"), + disabled: !target.results || !opts.search || invalid || !count?.total, + run: () => invoke("find.openInWindow"), + }, + { kind: "separator" }, + { kind: "header", label: "Multiple Cursors" }, + ...( + [ + ["code.addCaretNextOccurrence", "Add Selection for Next Occurrence"], + ["code.unselectOccurrence", "Unselect Occurrence"], + ["code.selectAllOccurrences", "Select All Occurrences"], + ] as const + ).map(([action, label]) => ({ label, shortcut: shortcutLabel(action), run: () => invoke(action) })), + { kind: "separator" }, + { label: "Scroll to Results During Typing", checked: scrollToResults, run: () => invoke("find.scrollToResults") }, + ]; const canReplace = !!opts.search && !invalid; return (

{ + focusedTarget = target; + setContext("findBarFocus", true); + setContext( + "findBarHasResults", + !!target.results && !!opts.search && (!opts.regexp || regexValid(opts.search)) && !!count?.total, + ); + }} + onBlurCapture={(e) => { + if (!panelRef.current?.contains(e.relatedTarget as Node | null)) setContext("findBarFocus", false); + }} className={clsx(styles.find, onReplaceShown && styles.findExpandable)} role="search" aria-label={replace ? "Replace in file" : "Find in file"} @@ -468,6 +606,7 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo pressed={scoped || opts.context !== "any"} onClick={() => setFilterMenu(true)} /> + setMoreMenu(true)} />
{replace && ( @@ -519,6 +658,14 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo aria-label="Recent searches" onClose={() => setHistoryMenu(null)} /> + setMoreMenu(false)} + /> { + expect( + goConstraintExcluded("No packages found for open file x.go.\nThis file may be excluded due to its build tags"), + ).toBe(true); + expect(goConstraintExcluded("This file may be excluded due to its GOOS/GOARCH, or other build constraints.")).toBe( + true, + ); + expect(goConstraintExcluded("No packages found for open file x.go.")).toBe(false); + expect(goConstraintExcluded("undefined: missingSymbol")).toBe(false); + expect(goConstraintExcluded("invalid build tags syntax")).toBe(false); +}); diff --git a/src/features/code-insight/goConstraints.ts b/src/features/code-insight/goConstraints.ts new file mode 100644 index 000000000..dacc80d86 --- /dev/null +++ b/src/features/code-insight/goConstraints.ts @@ -0,0 +1,4 @@ +/** Only gopls diagnostics that explicitly identify build exclusion qualify. */ +export function goConstraintExcluded(message: string): boolean { + return /excluded[^\n]*(?:build|GOOS\/GOARCH)/i.test(message); +} diff --git a/src/features/code-insight/index.ts b/src/features/code-insight/index.ts index cca63ee9a..1ad1f06e4 100644 --- a/src/features/code-insight/index.ts +++ b/src/features/code-insight/index.ts @@ -5,7 +5,12 @@ */ import { registerActions } from "@/platform/actions"; import { useProject } from "@/platform/project"; -import { registerOverlay, registerStatusItem, registerToolWindow } from "@/workbench/contributions"; +import { + registerEditorBanner, + registerOverlay, + registerStatusItem, + registerToolWindow, +} from "@/workbench/contributions"; import { clearProblems, installProblemsEvents, useProblems } from "./problemsStore"; import { installProblemDecorations } from "./decorations"; import { ProblemsHeaderActions, ProblemsTabs, ProblemsView, useProblemsHeaderMenu } from "./ProblemsView"; @@ -15,6 +20,7 @@ import { findUsages, rerunActiveUsages, resetUsages, showUsageResults, showUsage import { HierarchyTabs, HierarchyView } from "./HierarchyView"; import { resetHierarchy, showHierarchy } from "./hierarchy"; import { installServerEvents } from "./servers"; +import { goConstraintExcluded } from "./goConstraints"; import { ServerStatus } from "./ServerStatus"; const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)"; @@ -41,6 +47,16 @@ export function activate(): () => void { }); const disposers = [ unsubProject, + registerEditorBanner({ + id: "go.buildConstraints", + useBanner: (path) => { + const excluded = useProblems( + (s) => + path.endsWith(".go") && (s.map.get(path)?.get("gopls") ?? []).some((d) => goConstraintExcluded(d.message)), + ); + return excluded ? { text: "This Go file is excluded by the current build constraints." } : null; + }, + }), installProblemsEvents(), installProblemDecorations(), installServerEvents(), diff --git a/src/features/debugger/__tests__/console.test.ts b/src/features/debugger/__tests__/console.test.ts index 26ba33023..8a4758900 100644 --- a/src/features/debugger/__tests__/console.test.ts +++ b/src/features/debugger/__tests__/console.test.ts @@ -96,3 +96,14 @@ describe("multiline output", () => { expect(output.every((line) => line.text.length === 4001)).toBe(true); }); }); + +it("flushes output while an unfocused webview suspends animation frames", () => { + vi.useFakeTimers(); + vi.spyOn(window, "requestAnimationFrame").mockReturnValue(1); + adoptSession(13); + appendConsoleQueued(13, "stdout", "Spring Boot starting…\n"); + expect(lines(13)).toEqual([]); + vi.advanceTimersByTime(100); + expect(lines(13).map((l) => l.text)).toEqual(["Spring Boot starting…\n"]); + expect(useDebug.getState().sessions[13]?.status).toBe("running"); +}); diff --git a/src/features/debugger/__tests__/javaCancel.test.ts b/src/features/debugger/__tests__/javaCancel.test.ts index 4b8addb55..b4b1282af 100644 --- a/src/features/debugger/__tests__/javaCancel.test.ts +++ b/src/features/debugger/__tests__/javaCancel.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { executeAction } from "@/platform/actions"; import { useProject } from "@/platform/project"; -import { provideNotificationService } from "@/workbench/contributions"; +import { provideNotificationService, useDebugRunSessions } from "@/workbench/contributions"; import type { RunConfig } from "@/platform/ipc"; import { registerDebugActions } from "../actions"; import { useDebug } from "../state"; @@ -11,6 +11,7 @@ const ipc = vi.hoisted(() => ({ cancelRequest: vi.fn(async () => undefined), javaDebugServer: vi.fn(async () => ({ status: "ok", data: 5005 })), debugStart: vi.fn(async () => ({ status: "ok", data: 42 })), + debugStop: vi.fn(async () => ({ status: "ok", data: null })), fsReadDir: vi.fn(async () => ({ status: "ok", data: [] })), })); vi.mock("@/platform/ipc", async (original) => { @@ -52,23 +53,30 @@ afterEach(() => dispose()); async function startAndStop() { const starting = executeAction("debug.startConfig", config); await vi.waitFor(() => expect(ipc.javaResolveLaunch).toHaveBeenCalledOnce()); - const notification = notify.mock.calls[0]![0]; - expect(notification).toMatchObject({ sticky: true, title: "Starting debugger for Main…" }); - expect(notification.actions[0].label).toBe("Stop"); - notification.actions[0].run(); + const session = Object.values(useDebug.getState().sessions)[0]!; + expect(session).toMatchObject({ name: "Main", status: "starting", runConfig: config }); + expect( + useDebug + .getState() + .console[session.id]?.map((l) => l.text) + .join("\n"), + ).toContain("Building and resolving the classpath of Main…"); + expect(useDebugRunSessions.getState().sessions[0]?.configId).toBe(config.id); + await executeAction("debug.stop"); expect(ipc.cancelRequest).toHaveBeenCalledWith(ipc.javaResolveLaunch.mock.calls[0]![3]); - expect(expire).toHaveBeenCalledWith(notification.key); + expect(useDebug.getState().sessions[session.id]?.status).toBe("terminated"); + expect(useDebugRunSessions.getState().sessions).toEqual([]); return { starting }; } function expectNoLaunch() { expect(ipc.javaDebugServer).not.toHaveBeenCalled(); expect(ipc.debugStart).not.toHaveBeenCalled(); - expect(useDebug.getState().sessions).toEqual({}); - expect(notify).toHaveBeenCalledTimes(1); + expect(Object.values(useDebug.getState().sessions).every((s) => s.status === "terminated")).toBe(true); + expect(notify).not.toHaveBeenCalled(); } -it("Stop cancels the resolve request and ends silently without starting a session", async () => { +it("Stop cancels the resolve request and ends the preparation session without starting DAP", async () => { const { starting } = await startAndStop(); respond({ status: "error", error: { kind: "cancelled", message: "Cancelled" } }); await starting; @@ -89,7 +97,7 @@ it("ignores a failed resolve arriving after Stop", async () => { expectNoLaunch(); }); -it("still reports a real resolve failure and removes the starting notification", async () => { +it("still reports a real resolve failure in the preparation console", async () => { const starting = executeAction("debug.startConfig", config); await vi.waitFor(() => expect(ipc.javaResolveLaunch).toHaveBeenCalledOnce()); respond({ status: "error", error: { kind: "internal", message: "Build failed" } }); @@ -101,6 +109,73 @@ it("still reports a real resolve failure and removes the starting notification", body: "Build failed", }), ); - expect(expire).toHaveBeenCalledWith(notify.mock.calls[0]![0].key); + expect( + Object.values(useDebug.getState().console) + .flat() + .map((line) => line.text) + .join("\n"), + ).toContain("Build failed"); expect(ipc.debugStart).not.toHaveBeenCalled(); }); + +it("keeps progress and the originating configuration when DAP takes over", async () => { + const starting = executeAction("debug.startConfig", config); + await vi.waitFor(() => expect(ipc.javaResolveLaunch).toHaveBeenCalledOnce()); + respond({ + status: "ok", + data: { + build: "succeeded", + errorCount: 0, + errors: [], + hints: [], + classPaths: ["/p/target/classes"], + modulePaths: [], + pathListSeparator: ":", + javaExec: "java", + }, + }); + await starting; + expect(Object.keys(useDebug.getState().sessions)).toEqual(["42"]); + expect(useDebug.getState().sessions[42]).toMatchObject({ status: "running", runConfig: config }); + expect( + useDebug + .getState() + .console[42]?.map((line) => line.text) + .join("\n"), + ).toContain("Building and resolving the classpath of Main…"); +}); + +it.each(["success", "failure"])("handles a late DAP %s after preparation was cancelled", async (outcome) => { + let fail!: (error: Error) => void; + let finish!: (value: { status: "ok"; data: number }) => void; + ipc.debugStart.mockImplementationOnce( + () => + new Promise((resolve, reject) => { + finish = resolve; + fail = reject; + }), + ); + const starting = executeAction("debug.startConfig", config); + await vi.waitFor(() => expect(ipc.javaResolveLaunch).toHaveBeenCalledOnce()); + respond({ + status: "ok", + data: { + build: "succeeded", + errorCount: 0, + errors: [], + hints: [], + classPaths: ["/p/target/classes"], + modulePaths: [], + pathListSeparator: ":", + javaExec: "java", + }, + }); + await vi.waitFor(() => expect(ipc.debugStart).toHaveBeenCalledOnce()); + await executeAction("debug.stop"); + if (outcome === "success") finish({ status: "ok", data: 42 }); + else fail(new Error("Adapter start failed")); + await starting; + if (outcome === "success") expect(ipc.debugStop).toHaveBeenCalledWith(42); + expect(notify).not.toHaveBeenCalled(); + expect(useDebugRunSessions.getState().sessions).toEqual([]); +}); diff --git a/src/features/debugger/__tests__/javaSeparator.test.ts b/src/features/debugger/__tests__/javaSeparator.test.ts index a6a93d131..4673acc2a 100644 --- a/src/features/debugger/__tests__/javaSeparator.test.ts +++ b/src/features/debugger/__tests__/javaSeparator.test.ts @@ -18,6 +18,7 @@ const config: RunConfig = { id: "main", name: "Main", kind: "application", + module: "", command: "java", args: ["-cp", "${classpath}", "Main"], cwd: null, diff --git a/src/features/debugger/__tests__/launch.test.ts b/src/features/debugger/__tests__/launch.test.ts index 247916360..19fac437f 100644 --- a/src/features/debugger/__tests__/launch.test.ts +++ b/src/features/debugger/__tests__/launch.test.ts @@ -242,6 +242,20 @@ describe("run config → launch config", () => { expect(extra(dap)).toEqual({ env: ["K=v"] }); }); + it("maps CMake debug-info paths to the editor's canonical source root", () => { + const config = rc({ + kind: "native", + command: "/private/tmp/project/build/app", + debugAdapter: "lldb-dap", + env: { K: "v" }, + params: { cmakeSourceRoot: "/tmp/project", cmakeCanonicalSourceRoot: "/private/tmp/project" }, + }); + expect(extra(launchConfigFor(config, "/private/tmp/project")!)).toEqual({ + env: ["K=v"], + sourceMap: [["/tmp/project", "/private/tmp/project"]], + }); + }); + it("Godot: the editor's adapter plays the main scene or the chosen one of the project", () => { const project = rc({ kind: "godot", @@ -259,3 +273,51 @@ describe("run config → launch config", () => { expect(extra(relative)).toEqual({ project: "/p/game", scene: "res://menu.tscn" }); }); }); + +describe("Go build configuration fields", () => { + it("separates split build flags from program args for legacy run and test configs", () => { + for (const sub of ["run", "test"]) { + const launch = launchConfigFor( + rc({ + kind: "go", + command: "go", + args: [sub, "-tags", "demo", "-gcflags", "all=-N -l", "./cmd/app", "--", "hello"], + debugAdapter: "delve", + }), + "/p", + )!; + expect(launch.program).toBe("/p/cmd/app"); + expect(launch.args).toEqual(["hello"]); + expect(JSON.parse(launch.extraJson!).buildFlags).toBe("-tags demo -gcflags 'all=-N -l'"); + } + }); + it("uses the selected module for building and the working directory for execution", () => { + const launch = launchConfigFor( + rc({ + kind: "go", + command: "go", + args: ["build", "."], + cwd: "runtime", + debugAdapter: "delve", + params: { + goKind: "build", + scope: "directory", + target: "cmd/app", + module: "backend", + toolArgs: "-tags demo", + programArgs: 'hello "two words"', + useTags: "true", + }, + }), + "/p", + )!; + expect(launch.program).toBe("/p/backend/cmd/app"); + expect(launch.cwd).toBe("/p/runtime"); + expect(launch.args).toEqual(["hello", "two words"]); + expect(JSON.parse(launch.extraJson!)).toMatchObject({ + mode: "debug", + __goToolArgs: ["-tags", "demo"], + __goUseTags: true, + }); + }); +}); diff --git a/src/features/debugger/__tests__/runOrigin.test.ts b/src/features/debugger/__tests__/runOrigin.test.ts index 1f108bf1a..0472f27cb 100644 --- a/src/features/debugger/__tests__/runOrigin.test.ts +++ b/src/features/debugger/__tests__/runOrigin.test.ts @@ -9,17 +9,89 @@ import { } from "@/workbench/contributions"; import type { RunConfig } from "@/platform/ipc"; import { registerDebugActions } from "../actions"; -import { adoptSession, publishContext, rerunSession, useDebug } from "../state"; +import { + adoptSession, + appendConsole, + handleDebugEvent, + publishContext, + rerunSession, + resetSessions, + startSession, + stopSession, + useDebug, +} from "../state"; const ipc = vi.hoisted(() => ({ javaDebugServer: vi.fn(async () => ({ status: "ok", data: 5005 })), debugStart: vi.fn(async () => ({ status: "ok", data: 42 })), debugStop: vi.fn(async () => ({ status: "ok", data: null })), + runStop: vi.fn(async () => ({ status: "ok", data: null })), })); vi.mock("@/platform/ipc", async (original) => { const real = await original(); return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } }; }); + +it("retains only configured debug roots for Services without changing live toolbar sessions", () => { + adoptSession(1, { runConfig: config, process: 7, startedAt: 10 }); + adoptSession(2, { parent: 1, status: "paused" }); + adoptSession(3); + publishContext(); + expect(useDebugRunSessions.getState().serviceSessions).toMatchObject([ + { id: 1, config, processId: 7, startedAt: 10, status: "paused" }, + ]); + handleDebugEvent(1, { kind: "terminated" }); + expect(useDebugRunSessions.getState().sessions.map((s) => s.id)).toEqual([3]); + expect(useDebugRunSessions.getState().serviceSessions).toMatchObject([{ id: 1, status: "terminated" }]); +}); + +it("does not republish service metadata when console output changes", () => { + adoptSession(1, { runConfig: config }); + const summaries = useDebugRunSessions.getState().serviceSessions; + expect(summaries).toHaveLength(1); + appendConsole(1, "stdout", "application output\n"); + expect(useDebugRunSessions.getState().serviceSessions).toBe(summaries); +}); + +it("stops shared-process debugger roots without stopping their process until requested", async () => { + adoptSession(1, { runConfig: config, process: 7 }); + await stopSession(1, { stopProcess: false }); + expect(ipc.debugStop).toHaveBeenCalledWith(1); + expect(ipc.runStop).not.toHaveBeenCalled(); + await stopSession(1); + expect(ipc.runStop).toHaveBeenCalledWith(7); +}); + +it("pins Show in Debug to the configured session and clears summaries with the project", () => { + adoptSession(1, { runConfig: config }); + adoptSession(2, { runConfig: { ...config, id: "other" } }); + const summary = useDebugRunSessions.getState().serviceSessions[0]!; + summary.show(); + expect(useDebug.getState().active).toBe(1); + resetSessions(); + expect(useDebugRunSessions.getState().serviceSessions).toEqual([]); + summary.show(); + expect(useDebug.getState().active).toBeNull(); +}); + +it("removes terminated summaries when a subsequent debug start prunes their source", async () => { + adoptSession(1, { runConfig: config }); + handleDebugEvent(1, { kind: "terminated" }); + await startSession( + { + name: "next", + adapter: "js-debug", + request: "launch", + program: null, + args: [], + cwd: "/p", + env: {}, + extraJson: null, + }, + { runConfig: { ...config, id: "next" } }, + ); + expect(useDebugRunSessions.getState().serviceSessions.map((s) => s.id)).toEqual([42]); +}); const config: RunConfig = { id: "node:server", name: "server", diff --git a/src/features/debugger/__tests__/serviceConsole.test.tsx b/src/features/debugger/__tests__/serviceConsole.test.tsx new file mode 100644 index 000000000..d897c7460 --- /dev/null +++ b/src/features/debugger/__tests__/serviceConsole.test.tsx @@ -0,0 +1,73 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { adoptSession, appendConsole, useDebug } from "../state"; +import { DebugConsole, DebugServiceConsole } from "../ui/DebugConsole"; + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getTotalSize: () => count * 18, + getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, key: index, start: index * 18 })), + measureElement: () => {}, + scrollToIndex: () => {}, + }), +})); + +beforeEach(() => useDebug.setState({ sessions: {}, console: {}, active: null })); + +it("shows the requested session without changing or following the active debugger", () => { + adoptSession(1, { name: "one" }); + adoptSession(2, { name: "two" }); + appendConsole(1, "stdout", "service one\n"); + appendConsole(2, "stdout", "service two\n"); + useDebug.setState({ active: 2 }); + const view = render(); + expect(screen.getByRole("log")).toHaveTextContent("service one"); + expect(screen.getByRole("log")).not.toHaveTextContent("service two"); + expect(screen.queryByRole("textbox")).toBeNull(); + expect(useDebug.getState().active).toBe(2); + act(() => appendConsole(2, "stdout", "unrelated\n")); + expect(screen.getByRole("log")).not.toHaveTextContent("unrelated"); + view.rerender(); + expect(screen.getByRole("log")).toHaveTextContent("service two"); + expect(screen.getByRole("log")).not.toHaveTextContent("service one"); +}); + +it("shares output and clearing with the interactive debugger console", () => { + const session = adoptSession(1); + appendConsole(1, "stdout", "shared log\n"); + render( + <> + + + , + ); + expect(screen.getAllByRole("log").every((log) => log.textContent?.includes("shared log"))).toBe(true); + expect(screen.getByRole("textbox", { name: "Debug console input" })).toBeEnabled(); + const clear = screen.getAllByRole("button", { name: "Clear Console" })[0]!; + fireEvent.click(clear); + for (const log of screen.getAllByRole("log")) expect(within(log).queryByText("shared log")).toBeNull(); + expect(useDebug.getState().console[1]).toEqual([]); +}); + +it("shows an empty state when its source has been cleared", () => { + render(); + expect(screen.getByText("No debug output available")).toBeTruthy(); + expect(screen.queryByRole("log")).toBeNull(); +}); + +it("explains why a remote attached process may have no application logs", () => { + adoptSession(1, { + launch: { + name: "remote", + adapter: "java-debug", + request: "attach", + program: null, + args: [], + cwd: "/p", + env: {}, + extraJson: null, + }, + }); + render(); + expect(screen.getByText("The attached process may not provide standard output to the debugger.")).toBeTruthy(); +}); diff --git a/src/features/debugger/__tests__/toolWindow.test.tsx b/src/features/debugger/__tests__/toolWindow.test.tsx index efaa52ff3..415ccdea2 100644 --- a/src/features/debugger/__tests__/toolWindow.test.tsx +++ b/src/features/debugger/__tests__/toolWindow.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; vi.mock("@/platform/ipc", async (importOriginal) => { const real = await importOriginal(); @@ -42,6 +42,21 @@ const session: DebugSession = { }; describe("debug tool window", () => { + it("shows preparation output, preserves a chosen tab and opens variables on pause", () => { + useDebug.setState({ sessions: { 1: { ...session, status: "starting" } }, active: 1 }); + render(); + const console = screen.getByRole("tab", { name: "Console" }); + const variables = screen.getByRole("tab", { name: "Threads & Variables" }); + expect(console.getAttribute("aria-selected")).toBe("true"); + fireEvent.click(variables); + expect(variables.getAttribute("aria-selected")).toBe("true"); + fireEvent.click(console); + act(() => useDebug.setState({ sessions: { 1: { ...session, status: "running" } } })); + expect(console.getAttribute("aria-selected")).toBe("true"); + act(() => useDebug.setState({ sessions: { 1: session } })); + expect(variables.getAttribute("aria-selected")).toBe("true"); + }); + it("renders session tabs, views, frames and typed variables", async () => { useDebug.setState({ sessions: { 1: session }, active: 1 }); render( diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 3d016a740..78a08fe4e 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -30,6 +30,7 @@ import { sessionSupports, showExecutionPoint, startSession, + startPreparedSession, stopSession, useDebug, type DebugSession, @@ -218,46 +219,24 @@ async function startRemote(config: RunConfig, root: string) { if (launch) await startSession(launch, { runConfig: config }); } -/** Java preparation has no backend session id yet; expose Stop like remote JVM listening. */ +/** Preparation belongs to a session from the moment the action begins. */ async function startJavaConfig(config: RunConfig, root: string, origin: RunConfig) { - const request = nextRequestId(); - const controller = new AbortController(); - const key = `debug.prepare.${request}`; - let preparing = true; - notifications().notify({ - group: "run", - level: "info", - key, - sticky: true, - title: tx`Starting debugger for ${config.name}…`, - actions: [ - { - label: "Stop", - run: () => { - if (!preparing || controller.signal.aborted) return; - controller.abort(); - notifications().expire?.(key); - void commands.cancelRequest(request).catch(() => undefined); - }, - }, - ], + await startPreparedSession(origin, async (signal, report) => { + const request = nextRequestId(); + const cancel = () => { + void commands.cancelRequest(request).catch(() => undefined); + }; + signal.addEventListener("abort", cancel, { once: true }); + try { + await maybeEditorService() + ?.saveAll() + .catch(() => undefined); + signal.throwIfAborted(); + return await javaLaunchConfig(config, root, { request, signal, report }); + } finally { + signal.removeEventListener("abort", cancel); + } }); - let launch; - try { - await maybeEditorService() - ?.saveAll() - .catch(() => undefined); - controller.signal.throwIfAborted(); - launch = await javaLaunchConfig(config, root, { request, signal: controller.signal }); - controller.signal.throwIfAborted(); - } catch (e) { - if (controller.signal.aborted || isCancelled(e)) return; - throw e; - } finally { - preparing = false; - notifications().expire?.(key); - } - await startSession(launch, { runConfig: origin }); } async function startConfig(arg: unknown) { @@ -417,10 +396,10 @@ export function registerDebugActions(): () => void { title: "Pause Program", category: "Debug", icon: "pause", - when: "debugSessionActive && !debugSessionPaused", + when: "debugSessionRunning", run: async () => { const s = activeSession(); - if (!s || !isLive(s)) return; + if (!s || s.status !== "running") return; let thread = s.selectedThread ?? s.threads[0]?.id; if (thread == null) { const threads = await call(commands.debugThreads(s.id)).catch(() => []); diff --git a/src/features/debugger/index.ts b/src/features/debugger/index.ts index 18ade51d0..ed0bb12cc 100644 --- a/src/features/debugger/index.ts +++ b/src/features/debugger/index.ts @@ -13,6 +13,7 @@ import { registerOverlay, registerRunConfigType, registerToolWindow, + useDebugRunSessions, } from "@/workbench/contributions"; import { registerDebugActions } from "./actions"; import { loadBreakpoints } from "./breakpoints/store"; @@ -28,12 +29,16 @@ import { DebuggerOverlays } from "./ui/Overlays"; import { REMOTE_TYPES } from "./remote"; export function activate(): () => void { + useDebugRunSessions.setState({ + Console: lazyComponent(() => import("./ui/DebugConsole").then((m) => m.DebugServiceConsole)), + }); loadWatches(); publishContext(); loadBreakpoints(useProject.getState().project?.root ?? null); loadExceptionBreakpoints(useProject.getState().project?.root ?? null); loadSpecialBreakpoints(useProject.getState().project?.root ?? null); const disposers = [ + () => useDebugRunSessions.setState({ sessions: [], serviceSessions: [], Console: null }), registerDebugActions(), // Remote JVM Debug, Go Remote, …: Run/Debug Configurations › Add New Configuration. ...REMOTE_TYPES.map(registerRunConfigType), diff --git a/src/features/debugger/java.ts b/src/features/debugger/java.ts index 3ee175f8c..33cd3d644 100644 --- a/src/features/debugger/java.ts +++ b/src/features/debugger/java.ts @@ -5,22 +5,14 @@ * JVMs started by Maven / Gradle with a JDWP agent are attached to. */ import { executeAction } from "@/platform/actions"; -import { - call, - commands, - IpcError, - nextRequestId, - type LaunchConfig, - type RequestId, - type RunConfig, -} from "@/platform/ipc"; +import { call, commands, IpcError, nextRequestId, type LaunchConfig, type RunConfig } from "@/platform/ipc"; import { formatCompileErrors, isJvmApplication, joinJavaArgs, parseJavaArgs, - resolveJavaCommand, - resolveJavaModule, + prepareJavaLaunch, + type JavaPreparation, } from "@/platform/javaLaunch"; import { notifications } from "@/workbench/contributions"; @@ -84,19 +76,20 @@ async function debugServer(module: string, signal?: AbortSignal): Promise { const cwd = rc.cwd ? join(root, rc.cwd) : root; const parsed = parseJavaArgs(rc.args); if (!parsed.mainClass) throw new Error(`No main class in '${rc.name}'`); preparation?.signal.throwIfAborted(); - const module = await resolveJavaModule(rc); - preparation?.signal.throwIfAborted(); - const info = await call( - commands.javaResolveLaunch(module, parsed.classpath === "test", true, preparation?.request ?? nextRequestId()), + const { module, info, resolved } = await prepareJavaLaunch( + rc, + preparation ?? { + request: nextRequestId(), + signal: new AbortController().signal, + }, ); - preparation?.signal.throwIfAborted(); - if (info.errorCount > 0) + if (info.errorCount > 0 && !preparation?.report) notifications().notify({ group: "run", level: "warning", @@ -104,7 +97,6 @@ export async function javaLaunchConfig( title: `'${rc.name}' has compile errors; debugging the last compiled classes`, body: formatCompileErrors(info).trimEnd(), }); - const resolved = resolveJavaCommand(rc, info); const kotlin = rc.debugAdapter === KOTLIN_ADAPTER || (await moduleHasKotlinSources(join(root, module))); preparation?.signal.throwIfAborted(); const classPaths = parsed.explicitClasspath diff --git a/src/features/debugger/launch.ts b/src/features/debugger/launch.ts index 36477017b..8f593baad 100644 --- a/src/features/debugger/launch.ts +++ b/src/features/debugger/launch.ts @@ -1,3 +1,4 @@ +import { parseArgs, formatArgs } from "@/platform/commandLine"; /** Run configuration → DAP launch configuration, per adapter. */ import { workflowSettings } from "@/platform/workflowSettings"; import type { LaunchConfig, RunConfig } from "@/platform/ipc"; @@ -169,34 +170,98 @@ function jsDebugLaunch(rc: RunConfig, cwd: string): LaunchConfig { return out; } -function delveLaunch(rc: RunConfig, cwd: string): LaunchConfig { +function delveLaunch(rc: RunConfig, cwd: string, root: string): LaunchConfig { const out = base(rc, "delve", cwd); - const [sub, ...rest] = rc.args; - const [goArgs, programArgs] = splitAtDashDash(rest); - if (sub === "test") { - // `go test` selection flags become the test binary's (`-run ^TestX$` → `-test.run ^TestX$`). - const testFlags: string[] = []; - let pkg: string | undefined; - for (let i = 0; i < goArgs.length; i++) { - const a = goArgs[i]!; - const flag = /^--?(run|skip)(?:=(.*))?$/.exec(a); - if (flag) { - const value = flag[2] ?? goArgs[++i]; - if (value !== undefined) testFlags.push(`-test.${flag[1]}`, value); - } else if (!a.startsWith("-")) pkg ??= a; - } - pkg ??= "."; - out.program = pkg.endsWith("/...") ? cwd : join(cwd, pkg); - out.extraJson = JSON.stringify({ mode: "test" }); - out.args = [...testFlags, ...programArgs]; - } else { - const flags = goArgs.filter((a) => a.startsWith("-")); - const positional = goArgs.filter((a) => !a.startsWith("-")); - const target = positional[0] ?? "."; - out.program = join(cwd, target); - out.args = [...positional.slice(1), ...programArgs]; - out.extraJson = JSON.stringify(flags.length ? { mode: "debug", buildFlags: flags.join(" ") } : { mode: "debug" }); + const p = rc.params; + if (p?.goKind) { + const module = join(root, p.module || "."); + const target = p.target || "."; + out.program = + p.scope === "package" && !target.startsWith(".") && !isAbsolute(target) + ? target + : join(module, target.split("|")[0]!.trim()); + const test = p.goKind === "test"; + const parsed = delveLaunch( + { + ...rc, + params: undefined, + args: [ + test ? "test" : "build", + ...parseArgs(p.toolArgs ?? ""), + ...(test && p.pattern ? ["-run", p.pattern] : []), + target, + ...(test ? ["-args", ...parseArgs(p.programArgs ?? "")] : []), + ], + }, + module, + root, + ); + const toolArgs = parseArgs(JSON.parse(parsed.extraJson!).buildFlags ?? ""); + out.args = test ? parsed.args : parseArgs(p.programArgs ?? ""); + out.extraJson = JSON.stringify({ + mode: p.goKind === "test" ? "test" : "debug", + buildFlags: toolArgs, + dlvCwd: module, + ...(p.scope === "file" ? { __goFiles: target.split("|").map((f) => join(module, f.trim())) } : {}), + __goToolArgs: toolArgs, + __goUseTags: p.useTags === "true", + ...(p.outputDir ? { output: join(root, `${p.outputDir}/go-debug`) } : {}), + }); + return out; } + const [sub, ...rest] = rc.args; + const test = sub === "test"; + const flags: string[] = []; + const testFlags: string[] = []; + const targets: string[] = []; + let programArgs: string[] = []; + const boolFlags = new Set([ + "-a", + "-n", + "-race", + "-msan", + "-asan", + "-cover", + "-linkshared", + "-modcacherw", + "-buildvcs", + "-trimpath", + "-v", + "-work", + "-x", + "-json", + "-c", + ]); + for (let i = 0; i < rest.length; i++) { + const a = rest[i]!; + if (a === "--" || (test && a === "-args")) { + programArgs = rest.slice(i + 1); + break; + } + const selection = + test && + /^-(run|skip|bench|benchtime|count|timeout|parallel|cpu|list|fuzz|fuzztime|fuzzminimizetime)(?:=(.*))?$/.exec(a); + if (test && /^-(short|failfast|benchmem|fullpath|v)(?:=|$)/.test(a)) { + testFlags.push(a.replace(/^-/, "-test.")); + } else if (selection) { + const value = selection[2] ?? rest[++i]; + if (value !== undefined) testFlags.push(`-test.${selection[1]}`, value); + } else if (a.startsWith("-")) { + if (test && (a === "-json" || a === "-c")) continue; + flags.push(a); + if (!a.includes("=") && !boolFlags.has(a) && rest[i + 1] !== undefined) flags.push(rest[++i]!); + } else if (!test && targets.length) { + programArgs = rest.slice(i); + break; + } else targets.push(a); + } + const target = targets[0] ?? "."; + out.program = target.endsWith("/...") ? cwd : join(cwd, target); + out.args = [...testFlags, ...programArgs]; + out.extraJson = JSON.stringify({ + mode: test ? "test" : "debug", + ...(flags.length ? { buildFlags: formatArgs(flags) } : {}), + }); return out; } @@ -222,8 +287,14 @@ function godotLaunch(rc: RunConfig, cwd: string): LaunchConfig { function nativeLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig { const out: LaunchConfig = { ...base(rc, adapter, cwd), program: resolveCommand(rc.command, cwd), args: [...rc.args] }; // lldb-dap takes the environment as `KEY=VALUE` strings. - if (adapter === "lldb-dap") - out.extraJson = JSON.stringify({ env: Object.entries(rc.env).map(([k, v]) => `${k}=${v}`) }); + if (adapter === "lldb-dap") { + const from = rc.params?.cmakeSourceRoot; + const to = rc.params?.cmakeCanonicalSourceRoot; + out.extraJson = JSON.stringify({ + env: Object.entries(rc.env).map(([k, v]) => `${k}=${v}`), + ...(from && to && from !== to ? { sourceMap: [[from, to]] } : {}), + }); + } return out; } @@ -240,7 +311,7 @@ export function launchConfigFor(rc: RunConfig, root: string): LaunchConfig | nul } if (adapter === "debugpy") return debugpyLaunch(rc, cwd); if (adapter === "js-debug") return jsDebugLaunch(rc, cwd); - if (adapter === "delve") return delveLaunch(rc, cwd); + if (adapter === "delve") return delveLaunch(rc, cwd, root); if (adapter === "godot") return godotLaunch(rc, cwd); // Adapters without a template (hidden in Edit Configurations): generic arguments. return { diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index 6d5f0afc2..d983c6289 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -2,6 +2,7 @@ * Debug sessions as the UI sees them, fed by `IdeEvent::Debug` and by pulls * (threads, frames, locals) after each stop. */ +import { tx } from "@/platform/i18n"; import { workflowSettings } from "@/platform/workflowSettings"; import { executeAction } from "@/platform/actions"; import { create } from "zustand"; @@ -11,6 +12,7 @@ import { call, commands, isTauri, + isCancelled, type DebugCapabilities, type DebugEvent, type DebugSessionId, @@ -108,7 +110,14 @@ interface PendingOutput { raw: string; } const pendingOutput: PendingOutput[] = []; -let outputFlushHandle: number | null = null; +let outputFlushHandle: { raf: number; timeout: number } | null = null; + +function cancelOutputFlush() { + if (!outputFlushHandle) return; + cancelAnimationFrame(outputFlushHandle.raf); + clearTimeout(outputFlushHandle.timeout); + outputFlushHandle = null; +} function ansiStream(session: DebugSessionId, category: string): AnsiStream { let streams = ansiStreams.get(session); @@ -226,7 +235,29 @@ function settleStart(id: DebugSessionId | null) { export function publishContext() { const s = activeSession(); + const retained = Object.values(useDebug.getState().sessions); useDebugRunSessions.setState({ + serviceSessions: retained.flatMap((session) => { + if (session.parent != null || !session.runConfig) return []; + const paused = + isLive(session) && retained.some((child) => child.status === "paused" && rootOf(child).id === session.id); + return [ + { + id: session.id, + config: session.runConfig, + startedAt: session.startedAt ?? 0, + status: paused ? ("paused" as const) : session.status, + processId: session.process, + rerun: session.runConfig || session.launch ? () => void rerunSession(session.id) : null, + stop: (options?: { stopProcess?: boolean }) => stopSession(session.id, options), + show: () => { + if (!useDebug.getState().sessions[session.id]) return; + selectSession(session.id); + toolWindowService().show("debug", true); + }, + }, + ]; + }), sessions: Object.values(useDebug.getState().sessions) .filter((session) => session.parent == null && isLive(session)) .map((session) => { @@ -250,6 +281,7 @@ export function publishContext() { }); const anyLive = Object.values(useDebug.getState().sessions).some(isLive); setContext("debugSessionActive", anyLive); + setContext("debugSessionRunning", s?.status === "running"); setContext("debugSessionPaused", s?.status === "paused"); setContext("debugCanRerun", !!s && !!(rootOf(s).runConfig || rootOf(s).launch)); setContext( @@ -277,7 +309,7 @@ export function appendConsole(session: DebugSessionId, category: string, raw: st /** * Queues streamed debuggee output: ANSI parsing and the console store update happen - * once per animation frame with all pending chunks merged per + * once per animation frame (or timer while unfocused), with pending chunks merged per * (session, category), so a flood of `output` events can't saturate React. */ export function appendConsoleQueued(session: DebugSessionId, category: string, raw: string) { @@ -289,14 +321,15 @@ export function appendConsoleQueued(session: DebugSessionId, category: string, r pendingOutput.push({ session, category, raw }); } if (outputFlushHandle === null) { - outputFlushHandle = requestAnimationFrame(() => { - outputFlushHandle = null; - flushConsoleOutput(); - }); + outputFlushHandle = { + raf: requestAnimationFrame(flushConsoleOutput), + timeout: window.setTimeout(flushConsoleOutput, 100), + }; } } function flushConsoleOutput() { + cancelOutputFlush(); if (pendingOutput.length === 0) return; const chunks = pendingOutput.splice(0); useDebug.setState((s) => { @@ -362,16 +395,61 @@ function trimConsole(lines: ConsoleLine[]) { } export function clearConsole(session: DebugSessionId) { + flushConsoleOutput(); ansiStreams.delete(session); useDebug.setState((s) => ({ console: { ...s.console, [session]: [] } })); } // --- lifecycle --------------------------------------------------------------- +let preparationSeq = 0; +const preparations = new Map(); + +/** A visible session owns preparation, including its console and Stop, before DAP has an id. */ +export async function startPreparedSession( + config: RunConfig, + prepare: (signal: AbortSignal, report: (text: string, kind?: "system" | "stderr") => void) => Promise, +): Promise { + const id = --preparationSeq as DebugSessionId; + const controller = new AbortController(); + preparations.set(id, controller); + useDebug.setState((s) => ({ + sessions: { + ...s.sessions, + [id]: blank(id, { name: config.name, adapter: config.debugAdapter ?? "", runConfig: config }), + }, + console: { ...s.console, [id]: [] }, + active: id, + })); + publishContext(); + try { + if (workflowSettings().run.showOnStart) toolWindowService().show("debug"); + } catch { + /* no workbench (tests) */ + } + const report = (text: string, kind = "system") => { + if (!controller.signal.aborted && useDebug.getState().sessions[id]) appendConsole(id, kind, text); + }; + try { + report(tx`Starting debugger for ${config.name}…` + "\n"); + const launch = await prepare(controller.signal, report); + controller.signal.throwIfAborted(); + return await startSession(launch, { runConfig: config, preparation: { id, signal: controller.signal } }); + } catch (e) { + if (controller.signal.aborted || isCancelled(e)) return null; + report((e instanceof Error ? e.message : String(e)) + "\n", "stderr"); + throw e; + } finally { + preparations.delete(id); + if (useDebug.getState().sessions[id]) markTerminated(id); + } +} + export async function startSession( launch: LaunchConfig, options: { runConfig?: RunConfig; + preparation?: { id: DebugSessionId; signal: AbortSignal }; /** A process started for this session (e.g. Maven with a JDWP agent): Stop ends it too. */ process?: RunId | null; /** Rerun repeats the launch (false for attaching to a process that is gone afterwards). */ @@ -389,6 +467,7 @@ export async function startSession( for (const id of ansiStreams.keys()) if (!sessions[id]) ansiStreams.delete(id); return { sessions, console: consoleLines, active: s.active != null && sessions[s.active] ? s.active : null }; }); + publishContext(); try { if (workflowSettings().run.showOnStart) toolWindowService().show("debug"); } catch { @@ -398,18 +477,38 @@ export async function startSession( pendingStarts++; try { await flushBreakpoints(); + options.preparation?.signal.throwIfAborted(); + const adapterStarted = performance.now(); const id = await call(commands.debugStart(launch)); + console.info("Debug adapter started", { name: launch.name, ms: performance.now() - adapterStarted }); owned.add(id); + if (options.preparation?.signal.aborted) { + await call(commands.debugStop(id)).catch(() => undefined); + settleStart(id); + if (useDebug.getState().sessions[id]) markTerminated(id); + return null; + } + flushConsoleOutput(); + const preparation = options.preparation && useDebug.getState().sessions[options.preparation.id]; useDebug.setState((s) => { const cur = s.sessions[id] ?? blank(id, { status: "running" }); + const sessions = { ...s.sessions }; + const console = { ...s.console }; + if (preparation) { + console[id] = [...(console[preparation.id] ?? []), ...(console[id] ?? [])]; + delete console[preparation.id]; + delete sessions[preparation.id]; + ansiStreams.delete(preparation.id); + } return { + console, sessions: { - ...s.sessions, + ...sessions, [id]: { ...cur, name: launch.name, runConfig: options.runConfig, - startedAt, + startedAt: preparation?.startedAt ?? startedAt, adapter: launch.adapter, launch: options.rerunnable === false ? null : launch, process: options.process ?? null, @@ -428,6 +527,9 @@ export async function startSession( return id; } catch (e) { settleStart(null); + if (options.preparation?.signal.aborted || isCancelled(e)) return null; + if (options.preparation) + appendConsole(options.preparation.id, "stderr", (e instanceof Error ? e.message : String(e)) + "\n"); const missing = missingAdapter(launch.adapter, e); if (missing) { notifications().notify({ @@ -457,10 +559,17 @@ export async function startSession( } } -export async function stopSession(id: DebugSessionId) { +export async function stopSession(id: DebugSessionId, options: { stopProcess?: boolean } = {}) { const s = useDebug.getState().sessions[id]; if (!s) return; const root = rootOf(s); + const preparation = preparations.get(root.id); + if (preparation) { + preparation.abort(); + appendConsole(root.id, "system", tx("Launch cancelled.") + "\n"); + markTerminated(root.id); + return; + } try { await call(commands.debugStop(root.id)); } catch (e) { @@ -469,7 +578,8 @@ export async function stopSession(id: DebugSessionId) { markTerminated(root.id); } // Detaching leaves an attached JVM running: stop the process it belongs to, like IDEA. - if (root.process != null) await call(commands.runStop(root.process)).catch(() => undefined); + if (options.stopProcess !== false && root.process != null) + await call(commands.runStop(root.process)).catch(() => undefined); } export async function rerunSession(id: DebugSessionId) { @@ -799,10 +909,7 @@ export function resetSessions() { const { sessions } = useDebug.getState(); for (const s of Object.values(sessions)) if (s.parent == null && isLive(s)) void stopSession(s.id); pendingOutput.length = 0; - if (outputFlushHandle !== null) { - cancelAnimationFrame(outputFlushHandle); - outputFlushHandle = null; - } + cancelOutputFlush(); ansiStreams.clear(); useDebug.setState({ sessions: {}, active: null, console: {}, stopSeq: 0 }); useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} }); diff --git a/src/features/debugger/ui/DebugConsole.tsx b/src/features/debugger/ui/DebugConsole.tsx new file mode 100644 index 000000000..18a32b9ff --- /dev/null +++ b/src/features/debugger/ui/DebugConsole.tsx @@ -0,0 +1,173 @@ +import { useLayoutEffect, useRef, useState, type KeyboardEvent } from "react"; +import clsx from "clsx"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { copySelectionOnKeyDown } from "@/ui/selectableText"; +import { EmptyState, IconButton, showContextMenu, T, type MenuItem } from "@/ui"; +import { tx } from "@/platform/i18n"; +import { spanStyle } from "@/ui/ansi"; +import ansi from "@/ui/ansi.module.css"; +import { appendConsole, clearConsole, evaluate, isLive, useDebug, type DebugSession } from "../state"; +import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory"; +import styles from "./DebugToolWindow.module.css"; + +export function DebugServiceConsole({ sessionId }: { sessionId: number }) { + const session = useDebug((s) => s.sessions[sessionId]); + return session ? ( + + ) : ( + + ); +} + +export function DebugConsole({ session, interactive = true }: { session: DebugSession; interactive?: boolean }) { + const lines = useDebug((s) => s.console[session.id]); + const scroller = useRef(null); + const stick = useRef(true); + const programmatic = useRef(false); + const [input, setInput] = useState(""); + const history = useRef(readHistory("console")); + const historyPos = useRef(-1); + const inputRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: lines?.length ?? 0, + getScrollElement: () => scroller.current, + estimateSize: () => 18, + overscan: 30, + getItemKey: (i) => lines?.[i]?.id ?? i, + }); + + useLayoutEffect(() => { + const el = scroller.current; + if (!el || !stick.current || !lines?.length) return; + programmatic.current = true; + virtualizer.scrollToIndex(lines.length - 1, { align: "end" }); + el.scrollTop = el.scrollHeight; + requestAnimationFrame(() => (programmatic.current = false)); + }, [lines, virtualizer]); + + const submit = async () => { + const e = input.trim(); + if (!e) return; + pushHistory("console", e); + history.current = readHistory("console"); + historyPos.current = -1; + setInput(""); + appendConsole(session.id, "input", `> ${e}\n`); + try { + const v = await evaluate(e, "repl", session); + appendConsole(session.id, "result", `${v.value}\n`); + } catch (err) { + appendConsole(session.id, "stderr", `${err instanceof Error ? err.message : String(err)}\n`); + } + }; + + /** IDEA's Browse History popup: the expressions typed before, most recent first. */ + const browseHistory = () => { + const el = inputRef.current; + const r = el?.getBoundingClientRect(); + const items: MenuItem[] = history.current.length + ? history.current.slice(0, 30).map((h, i) => ({ + id: String(i), + label: h.length > 120 ? `${h.slice(0, 120)}…` : h, + run: () => { + setInput(h); + historyPos.current = i; + el?.focus(); + }, + })) + : [{ id: "none", label: "No history", disabled: true }]; + showContextMenu({ x: r?.left ?? 120, y: (r?.top ?? 200) - 4 }, items); + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + void submit(); + } else if (e.key === "ArrowUp" || e.key === "ArrowDown") { + const h = history.current; + if (!h.length) return; + e.preventDefault(); + const pos = Math.max(-1, Math.min(h.length - 1, historyPos.current + (e.key === "ArrowUp" ? 1 : -1))); + historyPos.current = pos; + setInput(pos < 0 ? "" : h[pos]!); + } + }; + + return ( +
+
{ + if (programmatic.current) return; + const el = e.currentTarget; + stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24; + }} + onContextMenu={(e) => { + e.preventDefault(); + showContextMenu(e, [{ label: "Clear All", icon: "trash", run: () => clearConsole(session.id) }]); + }} + role="log" + onKeyDown={copySelectionOnKeyDown} + aria-label={tx("Debug console")} + tabIndex={0} + > + {!lines?.length && session.launch?.request === "attach" && ( + The attached process may not provide standard output to the debugger. + )} +
+ {virtualizer.getVirtualItems().map((item) => { + const l = lines?.[item.index]; + if (!l) return null; + return ( + + {l.spans.map((sp, i) => ( + + {sp.text} + + ))} + + ); + })} +
+
+
+ {interactive && ( + <> + + › + + setConsoleHistoryBrowser(browseHistory)} + onBlur={() => setConsoleHistoryBrowser(null)} + value={input} + onChange={(e) => setInput(e.target.value)} + onKeyDown={onKeyDown} + placeholder={isLive(session) ? "Evaluate in the debug console" : "Session finished"} + disabled={!isLive(session)} + aria-label={tx("Debug console input")} + spellCheck={false} + autoComplete="off" + /> + + )} + clearConsole(session.id)} + /> +
+
+ ); +} diff --git a/src/features/debugger/ui/DebugToolWindow.tsx b/src/features/debugger/ui/DebugToolWindow.tsx index 3746a54b4..7a9af34ed 100644 --- a/src/features/debugger/ui/DebugToolWindow.tsx +++ b/src/features/debugger/ui/DebugToolWindow.tsx @@ -1,8 +1,6 @@ /** Debug tool window: toolbar, Threads & Variables (frames + variables), Console. */ -import { copySelectionOnKeyDown } from "@/ui/selectableText"; -import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; -import { useVirtualizer } from "@tanstack/react-virtual"; import { executeAction, getAction } from "@/platform/actions"; import { evaluateWhen, useContextKeys } from "@/platform/context"; import { basename, useProject } from "@/platform/project"; @@ -28,24 +26,19 @@ import { frameLabel, groupFrames } from "../frames"; import { blurDebugView, focusDebugView } from "./focusedView"; import { addWatch, - clearConsole, evaluate, - isLive, selectFrame, selectSession, selectThread, - appendConsole, revealFrame, sessionSupports, useDebug, type DebugSession, } from "../state"; +import { DebugConsole } from "./DebugConsole"; import { VariablesView, type VarRoot } from "./VariablesView"; -import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory"; import { sortVariables } from "../values"; import { workflowSettings } from "@/platform/workflowSettings"; -import { spanStyle } from "@/ui/ansi"; -import ansi from "@/ui/ansi.module.css"; import styles from "./DebugToolWindow.module.css"; import { tx } from "@/platform/i18n"; @@ -144,7 +137,18 @@ export function DebugTabs() { export function DebugToolWindow() { const active = useDebug((s) => (s.active != null ? s.sessions[s.active] : undefined)); const muted = useBreakpoints((s) => s.muted); - const [tab, setTab] = useState("variables"); + const [view, setView] = useState<{ + session: number | undefined; + status: DebugSession["status"] | undefined; + tab: Tab; + }>({ session: undefined, status: undefined, tab: "variables" }); + if (active && (view.session !== active.startedAt || view.status !== active.status)) + setView({ + session: active.startedAt, + status: active.status, + tab: active.status === "starting" ? "console" : active.status === "paused" ? "variables" : view.tab, + }); + const tab = view.tab; if (!active) { return ( @@ -175,7 +179,7 @@ export function DebugToolWindow() { aria-selected={tab === id} aria-controls={`debug-panel-${id}`} className={clsx(styles.tab, tab === id && styles.tabActive)} - onClick={() => setTab(id)} + onClick={() => setView({ session: active.startedAt, status: active.status, tab: id })} > {label} @@ -211,7 +215,11 @@ export function DebugToolWindow() {
- {tab === "variables" ? : } + {tab === "variables" ? ( + + ) : ( + + )}
); @@ -544,151 +552,3 @@ function VariablesPane({ session }: { session: DebugSession }) { ); } - -// --- console ------------------------------------------------------------------- - -function ConsoleView({ session }: { session: DebugSession }) { - const lines = useDebug((s) => s.console[session.id]); - const scroller = useRef(null); - const stick = useRef(true); - const programmatic = useRef(false); - const [input, setInput] = useState(""); - const history = useRef(readHistory("console")); - const historyPos = useRef(-1); - const inputRef = useRef(null); - - const virtualizer = useVirtualizer({ - count: lines?.length ?? 0, - getScrollElement: () => scroller.current, - estimateSize: () => 18, - overscan: 30, - getItemKey: (i) => lines?.[i]?.id ?? i, - }); - - useLayoutEffect(() => { - const el = scroller.current; - if (!el || !stick.current || !lines?.length) return; - programmatic.current = true; - virtualizer.scrollToIndex(lines.length - 1, { align: "end" }); - el.scrollTop = el.scrollHeight; - requestAnimationFrame(() => (programmatic.current = false)); - }, [lines, virtualizer]); - - const submit = async () => { - const e = input.trim(); - if (!e) return; - pushHistory("console", e); - history.current = readHistory("console"); - historyPos.current = -1; - setInput(""); - appendConsole(session.id, "input", `> ${e}\n`); - try { - const v = await evaluate(e, "repl", session); - appendConsole(session.id, "result", `${v.value}\n`); - } catch (err) { - appendConsole(session.id, "stderr", `${err instanceof Error ? err.message : String(err)}\n`); - } - }; - - /** IDEA's Browse History popup: the expressions typed before, most recent first. */ - const browseHistory = () => { - const el = inputRef.current; - const r = el?.getBoundingClientRect(); - const items: MenuItem[] = history.current.length - ? history.current.slice(0, 30).map((h, i) => ({ - id: String(i), - label: h.length > 120 ? `${h.slice(0, 120)}…` : h, - run: () => { - setInput(h); - historyPos.current = i; - el?.focus(); - }, - })) - : [{ id: "none", label: "No history", disabled: true }]; - showContextMenu({ x: r?.left ?? 120, y: (r?.top ?? 200) - 4 }, items); - }; - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - void submit(); - } else if (e.key === "ArrowUp" || e.key === "ArrowDown") { - const h = history.current; - if (!h.length) return; - e.preventDefault(); - const pos = Math.max(-1, Math.min(h.length - 1, historyPos.current + (e.key === "ArrowUp" ? 1 : -1))); - historyPos.current = pos; - setInput(pos < 0 ? "" : h[pos]!); - } - }; - - return ( -
-
{ - if (programmatic.current) return; - const el = e.currentTarget; - stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24; - }} - onContextMenu={(e) => { - e.preventDefault(); - showContextMenu(e, [{ label: "Clear All", icon: "trash", run: () => clearConsole(session.id) }]); - }} - role="log" - onKeyDown={copySelectionOnKeyDown} - aria-label={tx("Debug console")} - tabIndex={0} - > -
- {virtualizer.getVirtualItems().map((item) => { - const l = lines?.[item.index]; - if (!l) return null; - return ( - - {l.spans.map((sp, i) => ( - - {sp.text} - - ))} - - ); - })} -
-
- -
- ); -} diff --git a/src/features/deployment/Dialogs.test.tsx b/src/features/deployment/Dialogs.test.tsx index 197893c4a..15ce23198 100644 --- a/src/features/deployment/Dialogs.test.tsx +++ b/src/features/deployment/Dialogs.test.tsx @@ -18,7 +18,8 @@ function openSync(entries: SyncEntry[]) { sync: { server: "s1", serverName: "Staging", - path: "/work/app", + paths: ["/work/app"], + skipped: [], request: null, entries, error: null, diff --git a/src/features/deployment/Dialogs.tsx b/src/features/deployment/Dialogs.tsx index 3fea1e47c..982a1cda4 100644 --- a/src/features/deployment/Dialogs.tsx +++ b/src/features/deployment/Dialogs.tsx @@ -78,7 +78,7 @@ function SyncDialog() { const shown = entries?.filter((e) => !sync.hideSame || e.status !== "same") ?? []; const steps = syncSteps(sync.choices); const same = entries?.filter((e) => e.status === "same").length ?? 0; - const where = root ? relativePath(root, sync.path) || "/" : sync.path; + const where = sync.paths.map((path) => (root ? relativePath(root, path) || "/" : path)).join(", "); return ( {where} {entries && <> · {txt(`${steps.length} of ${entries.length} files will change`)}} + {!!sync.skipped?.length && ( +
+ {txt(`Skipped: ${sync.skipped.length}`)}:{" "} + {sync.skipped.map((s) => (root ? relativePath(root, s.path) : s.path)).join(", ")} +
+ )} {sync.error &&
{sync.error}
} {!entries && !sync.error && (
diff --git a/src/features/deployment/actions.test.tsx b/src/features/deployment/actions.test.tsx new file mode 100644 index 000000000..f3a1ae487 --- /dev/null +++ b/src/features/deployment/actions.test.tsx @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DeploymentServerValid } from "@/platform/remoteSettings"; + +const mocks = vi.hoisted(() => ({ + actions: [] as { id: string; run: (context: { arg: string[] }) => unknown }[], + servers: [] as DeploymentServerValid[], + coverage: [] as { server: string; paths: string[]; skipped: { path: string; message: string }[] }[], + choose: vi.fn(), + notify: vi.fn(), + upload: vi.fn(), + compare: vi.fn(), +})); +vi.mock("@/platform/actions", () => ({ + executeAction: vi.fn(), + registerActions: (actions: typeof mocks.actions) => { + mocks.actions = actions; + return () => {}; + }, +})); +vi.mock("@/workbench/messages", () => ({ confirm: vi.fn() })); +vi.mock("@/platform/openUrl", () => ({ openExternalUrl: vi.fn() })); +vi.mock("@/platform/context", () => ({ getContext: () => ({}) })); +vi.mock("@/platform/project", () => ({ + relativePath: (_: string, path: string) => path, + useProject: { getState: () => ({ project: { root: "/project" } }) }, +})); +vi.mock("@/platform/workflowSettings", () => ({ + workflowSettings: () => ({ deployment: { servers: mocks.servers, defaultServer: "one" } }), +})); +vi.mock("@/workbench/contributions", () => ({ + notifications: () => ({ notify: mocks.notify }), + projectViewSelection: () => [], + toolWindowService: vi.fn(), +})); +vi.mock("@/workbench/inputDialogs", () => ({ chooseItem: mocks.choose })); +vi.mock("./ssh", () => ({ withSsh: (_: unknown, run: () => unknown) => run() })); +vi.mock("@/platform/ipc", () => ({ + call: (value: unknown) => value, + nextRequestId: () => 1, + isCancelled: () => false, + commands: { + deployCoverage: async () => mocks.coverage, + deployUpload: mocks.upload, + deployCompare: mocks.compare, + }, +})); +import { registerDeploymentActions } from "./actions"; +import { useDeployment } from "./store"; + +async function run(id: string, paths = ["/project/a", "/project/b"]) { + mocks.actions.find((action) => action.id === id)!.run({ arg: paths }); + await vi.waitFor(() => + expect( + mocks.choose.mock.calls.length + mocks.notify.mock.calls.length + (useDeployment.getState().sync ? 1 : 0), + ).toBeGreaterThan(0), + ); +} +beforeEach(() => { + vi.clearAllMocks(); + mocks.servers = ["one", "two"].map((id) => ({ id, name: id, kind: "local" }) as DeploymentServerValid); + mocks.coverage = [ + { + server: "one", + paths: ["/project/a"], + skipped: [{ path: "/project/b", message: "Not covered by this server's mappings" }], + }, + { server: "two", paths: [], skipped: [] }, + ]; + mocks.choose.mockResolvedValue(null); + mocks.upload.mockResolvedValue({ files: 1, skipped: 1, errors: [] }); + mocks.compare.mockResolvedValue([]); + useDeployment.setState({ sync: null }); + registerDeploymentActions(); +}); +describe("deployment eligibility", () => { + it.each(["deployment.uploadTo", "deployment.downloadFrom", "deployment.syncWith"])( + "filters %s and keeps a single option visible", + async (id) => { + await run(id); + expect(mocks.choose).toHaveBeenCalledWith(expect.objectContaining({ items: ["one"] })); + }, + ); + it("explains an empty match and links settings", async () => { + mocks.coverage[0]!.paths = []; + await run("deployment.uploadTo"); + expect(mocks.choose).not.toHaveBeenCalled(); + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ + title: "No deployment server covers the selected paths", + actions: [expect.objectContaining({ label: "Configure…" })], + }), + ); + }); + it("explains why the default cannot deploy the named file", async () => { + mocks.coverage[0]!.paths = []; + await run("deployment.uploadToDefault"); + expect(mocks.upload).not.toHaveBeenCalled(); + expect(mocks.notify.mock.calls[0]![0].body.props.report.errors[0].path).toBe("/project/b"); + }); + it("compares every covered selection and retains skipped paths", async () => { + mocks.coverage[0]!.paths.push("/project/c"); + mocks.choose.mockResolvedValue("one"); + await run("deployment.syncWith"); + await vi.waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(2)); + expect(useDeployment.getState().sync?.skipped?.[0]?.path).toBe("/project/b"); + }); +}); diff --git a/src/features/deployment/actions.tsx b/src/features/deployment/actions.tsx index ceb52d845..4e6163fa9 100644 --- a/src/features/deployment/actions.tsx +++ b/src/features/deployment/actions.tsx @@ -17,7 +17,7 @@ import styles from "./Deployment.module.css"; const GROUP = "tools"; const CATEGORY = "Deployment"; -const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)); +const errorText = (e: unknown) => txTemplate(e instanceof Error ? e.message : String(e)); const projectRoot = () => useProject.getState().project?.root ?? null; const deployment = () => workflowSettings().deployment; @@ -51,22 +51,54 @@ function notifyNoTargets() { }); } -/** Asks which server to use (the only one without asking). */ -async function pickServer(title: string): Promise { - const servers = deployment().servers; - if (!servers.length) { +async function serverCoverage(paths: string[]) { + try { + return await call(commands.deployCoverage(paths)); + } catch (e) { + notifications().notify({ + level: "error", + title: "Cannot check deployment mappings", + body: errorText(e), + group: GROUP, + }); + return null; + } +} + +function notifyNoCoverage(server?: DeploymentServerValid, skipped?: TransferReport["errors"]) { + notifications().notify({ + level: "warning", + title: server + ? txTemplate(`No selected paths can be deployed to ${serverLabel(server)}`) + : "No deployment server covers the selected paths", + body: skipped?.length ? : undefined, + group: GROUP, + key: "deployment.noCoverage", + actions: [{ label: "Configure…", run: () => void executeAction("deployment.configure") }], + }); +} + +/** Keep the chooser visible even when only one server covers the selection. */ +async function pickServer(title: string, paths: string[]): Promise { + const configured = deployment().servers; + if (!configured.length) { notifyNoServers(); return null; } - if (servers.length === 1) return servers[0]!; + const coverage = await serverCoverage(paths); + if (!coverage) return null; + const servers = configured.filter((server) => coverage.some((c) => c.server === server.id && c.paths.length)); + if (!servers.length) { + notifyNoCoverage(); + return null; + } const labels = servers.map(serverLabel); const picked = await chooseItem({ title, items: labels }); return picked === null ? null : (servers[labels.indexOf(picked)] ?? null); } -/** The default server; asks when none is marked default and there are several. */ -async function defaultServer(title: string): Promise { - return resolveServer(deployment(), null) ?? pickServer(title); +async function defaultServer(title: string, paths: string[]): Promise { + return resolveServer(deployment(), null) ?? pickServer(title, paths); } function ReportBody({ report }: { report: TransferReport }) { @@ -80,7 +112,7 @@ function ReportBody({ report }: { report: TransferReport }) {
    {shown.map((e, i) => (
  • - {root ? relativePath(root, e.path) : e.path}: {e.message} + {root ? relativePath(root, e.path) : e.path}: {txt(e.message)}
  • ))}
@@ -118,9 +150,12 @@ function showFailure(title: string, server: DeploymentServerValid, e: unknown) { async function transfer(direction: "upload" | "download", server: DeploymentServerValid | null, paths: string[]) { if (!server) return; + const coverage = (await serverCoverage(paths))?.find((c) => c.server === server.id); + if (!coverage) return; + if (!coverage.paths.length) return notifyNoCoverage(server, coverage.skipped); const request = nextRequestId(); try { - const report = await withSsh(server.sshConfig, () => + const report = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => call( direction === "upload" ? commands.deployUpload(paths, server.id, request) @@ -139,8 +174,11 @@ export function downloadPaths(server: DeploymentServerValid, paths: string[]) { } async function deleteRemote(paths: string[]) { - const server = await defaultServer("Delete from Server"); + const server = await defaultServer("Delete from Server", paths); if (!server) return; + const coverage = (await serverCoverage(paths))?.find((c) => c.server === server.id); + if (!coverage) return; + if (!coverage.paths.length) return notifyNoCoverage(server, coverage.skipped); const root = projectRoot(); const names = paths.map((p) => (root ? relativePath(root, p) || "/" : p)); const ok = await confirm({ @@ -155,21 +193,27 @@ async function deleteRemote(paths: string[]) { }); if (!ok) return; try { - const report = await withSsh(server.sshConfig, () => call(commands.deployDeleteRemote(paths, server.id))); + const report = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.deployDeleteRemote(paths, server.id)), + ); showReport(report, "delete", server); } catch (e) { showFailure("Cannot delete from {server}", server, e); } } -/** Opens the Sync dialog for `path` and compares it with `server`. */ -export async function openSync(server: DeploymentServerValid, path: string) { +/** Compares every covered selection with `server`. */ +export async function openSync(server: DeploymentServerValid, paths: string[]) { + const coverage = (await serverCoverage(paths))?.find((c) => c.server === server.id); + if (!coverage) return; + if (!coverage.paths.length) return notifyNoCoverage(server, coverage.skipped); const request = nextRequestId(); useDeployment.setState({ sync: { server: server.id, serverName: serverLabel(server), - path, + paths, + skipped: coverage.skipped, request, entries: null, error: null, @@ -179,7 +223,15 @@ export async function openSync(server: DeploymentServerValid, path: string) { }, }); try { - const entries = await withSsh(server.sshConfig, () => call(commands.deployCompare(path, server.id, request))); + const groups = await withSsh(server.kind === "sftp" ? server.sshConfig : "", async () => { + const entries = []; + for (const path of coverage.paths) { + if (useDeployment.getState().sync?.request !== request) break; + entries.push(...(await call(commands.deployCompare(path, server.id, request)))); + } + return entries; + }); + const entries = [...new Map(groups.map((entry) => [entry.path, entry])).values()]; if (useDeployment.getState().sync?.request !== request) return; patchSync({ entries, choices: initialSyncChoices(entries), request: null }); } catch (e) { @@ -203,8 +255,11 @@ export async function runSync(steps: { path: string; action: SyncAction }[]) { const request = nextRequestId(); patchSync({ running: true, request }); try { - const report = await withSsh(server.sshConfig, () => call(commands.deploySync(steps, server.id, request))); + const report = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.deploySync(steps, server.id, request)), + ); useDeployment.setState({ sync: null }); + report.skipped += sync.skipped.length; showReport(report, "sync", server); } catch (e) { if (isCancelled(e)) patchSync({ running: false, request: null }); @@ -269,7 +324,7 @@ export function registerDeploymentActions(): () => void { icon: "export", when, keywords: ["deploy", "sftp", "upload", "remote"], - run: withTargets(async (paths) => transfer("upload", await defaultServer("Upload to"), paths)), + run: withTargets(async (paths) => transfer("upload", await defaultServer("Upload to", paths), paths)), }, { id: "deployment.uploadTo", @@ -278,7 +333,7 @@ export function registerDeploymentActions(): () => void { category: CATEGORY, when, keywords: ["deploy", "sftp", "upload", "remote"], - run: withTargets(async (paths) => transfer("upload", await pickServer("Upload to"), paths)), + run: withTargets(async (paths) => transfer("upload", await pickServer("Upload to", paths), paths)), }, { id: "deployment.downloadFromDefault", @@ -287,7 +342,7 @@ export function registerDeploymentActions(): () => void { category: CATEGORY, when, keywords: ["deploy", "sftp", "download", "remote"], - run: withTargets(async (paths) => transfer("download", await defaultServer("Download from"), paths)), + run: withTargets(async (paths) => transfer("download", await defaultServer("Download from", paths), paths)), }, { id: "deployment.downloadFrom", @@ -296,7 +351,7 @@ export function registerDeploymentActions(): () => void { category: CATEGORY, when, keywords: ["deploy", "sftp", "download", "remote"], - run: withTargets(async (paths) => transfer("download", await pickServer("Download from"), paths)), + run: withTargets(async (paths) => transfer("download", await pickServer("Download from", paths), paths)), }, { id: "deployment.syncWith", @@ -309,7 +364,8 @@ export function registerDeploymentActions(): () => void { const paths = actionTargets(arg); const target = paths[0] ?? projectRoot(); if (!target) return; - void pickServer("Sync with Deployed to").then((server) => server && openSync(server, target)); + const targets = paths.length ? paths : [target]; + void pickServer("Sync with Deployed to", targets).then((server) => server && openSync(server, targets)); }, }, { diff --git a/src/features/deployment/remoteHost.test.ts b/src/features/deployment/remoteHost.test.ts new file mode 100644 index 000000000..7ef2cfa46 --- /dev/null +++ b/src/features/deployment/remoteHost.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { DEFAULT_DEPLOYMENT_SERVER } from "@/platform/remoteSettings"; +import { ensureRoot } from "./remoteHost"; +import { useDeployment } from "./store"; + +const ipc = vi.hoisted(() => ({ root: vi.fn(), list: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original()), + commands: { deployServerRoot: ipc.root, remoteList: ipc.list }, +})); + +beforeEach(() => { + useDeployment.setState({ trees: {} }); + vi.clearAllMocks(); + ipc.root.mockResolvedValue({ status: "ok", data: "/srv" }); + ipc.list.mockResolvedValue({ status: "ok", data: [] }); +}); + +it("reloads a server's tree when changing its filesystem type or root", async () => { + const server = { ...DEFAULT_DEPLOYMENT_SERVER, id: "existing", sshConfig: "ssh", rootPath: "/srv" }; + await ensureRoot(server); + expect(ipc.list).toHaveBeenLastCalledWith({ kind: "sftp", ssh: "ssh" }, "/srv"); + await ensureRoot(server); + expect(ipc.root).toHaveBeenCalledTimes(1); + const local = { ...server, kind: "local" as const }; + await ensureRoot(local); + expect(ipc.list).toHaveBeenLastCalledWith({ kind: "local" }, "/srv"); + ipc.root.mockResolvedValue({ status: "ok", data: "/mounted" }); + await ensureRoot({ ...local, rootPath: "/mounted" }); + expect(ipc.list).toHaveBeenLastCalledWith({ kind: "local" }, "/mounted"); + expect(useDeployment.getState().trees.existing?.root).toBe("/mounted"); +}); diff --git a/src/features/deployment/remoteHost.ts b/src/features/deployment/remoteHost.ts index 8069f87b1..34a90091b 100644 --- a/src/features/deployment/remoteHost.ts +++ b/src/features/deployment/remoteHost.ts @@ -2,7 +2,7 @@ import { txTemplate } from "@/platform/i18n"; import { call, commands, type RemoteEntry } from "@/platform/ipc"; import { useProject } from "@/platform/project"; -import type { DeploymentServerValid } from "@/platform/remoteSettings"; +import { deploymentConnection, type DeploymentServerValid } from "@/platform/remoteSettings"; import { copyText } from "@/ui"; import { editorService, notifications } from "@/workbench/contributions"; import { promptInput } from "@/workbench/inputDialogs"; @@ -12,7 +12,9 @@ import { joinRemote, localPathForRemote, remoteName, remoteParent } from "./mode import { withSsh } from "./ssh"; import { EMPTY_TREE, patchTree, useDeployment } from "./store"; -const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)); +const errorText = (e: unknown) => txTemplate(e instanceof Error ? e.message : String(e)); + +const sourceKey = (server: DeploymentServerValid) => JSON.stringify([deploymentConnection(server), server.rootPath]); const tree = (server: string) => useDeployment.getState().trees[server] ?? EMPTY_TREE; @@ -25,14 +27,19 @@ function fail(title: string, e: unknown) { } export async function loadFolder(server: DeploymentServerValid, path: string) { + const source = sourceKey(server); try { - const entries = await withSsh(server.sshConfig, () => call(commands.remoteList(server.sshConfig, path))); + const entries = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.remoteList(deploymentConnection(server), path)), + ); + if (tree(server.id).source !== source) return; patchTree(server.id, (t) => { const folderErrors = { ...t.folderErrors }; delete folderErrors[path]; return { listings: { ...t.listings, [path]: sortEntries(entries) }, folderErrors }; }); } catch (e) { + if (tree(server.id).source !== source) return; patchTree(server.id, (t) => path === t.root ? { error: errorText(e) } : { folderErrors: { ...t.folderErrors, [path]: errorText(e) } }, ); @@ -41,6 +48,8 @@ export async function loadFolder(server: DeploymentServerValid, path: string) { /** Resolves the server root once per session and lists it. */ export async function ensureRoot(server: DeploymentServerValid, force = false) { + const source = sourceKey(server); + if (tree(server.id).source !== source) patchTree(server.id, { ...EMPTY_TREE, source }); const current = tree(server.id); if (current.root && !force && current.listings[current.root]) return; patchTree(server.id, { error: null }); @@ -48,10 +57,14 @@ export async function ensureRoot(server: DeploymentServerValid, force = false) { const root = current.root && !force ? current.root - : await withSsh(server.sshConfig, () => call(commands.deployServerRoot(server.id))); + : await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.deployServerRoot(server.id)), + ); + if (tree(server.id).source !== source) return; patchTree(server.id, { root }); await loadFolder(server, root); } catch (e) { + if (tree(server.id).source !== source) return; patchTree(server.id, { error: errorText(e) }); } } @@ -59,7 +72,7 @@ export async function ensureRoot(server: DeploymentServerValid, force = false) { /** Reloads the root and the expanded folders. */ export async function refreshAll(server: DeploymentServerValid) { const t = tree(server.id); - if (!t.root) return ensureRoot(server, true); + if (!t.root || t.source !== sourceKey(server)) return ensureRoot(server, true); patchTree(server.id, { listings: {}, folderErrors: {}, error: null }); await loadFolder(server, t.root); await Promise.all([...t.expanded].map((p) => loadFolder(server, p))); @@ -67,7 +80,9 @@ export async function refreshAll(server: DeploymentServerValid) { export async function openRemote(server: DeploymentServerValid, entry: RemoteEntry) { try { - const local = await withSsh(server.sshConfig, () => call(commands.remoteOpen(server.sshConfig, entry.path))); + const local = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.remoteOpen(deploymentConnection(server), entry.path)), + ); await editorService().openFile(local, { focus: true }); } catch (e) { fail(`Cannot open ${entry.name}`, e); @@ -82,7 +97,9 @@ export async function newFolder(server: DeploymentServerValid, parent: string) { }); if (!name) return; try { - await withSsh(server.sshConfig, () => call(commands.remoteMkdir(server.sshConfig, joinRemote(parent, name)))); + await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.remoteMkdir(deploymentConnection(server), joinRemote(parent, name))), + ); patchTree(server.id, (t) => ({ expanded: new Set([...t.expanded, parent]) })); await loadFolder(server, parent); } catch (e) { @@ -100,8 +117,8 @@ export async function renameEntry(server: DeploymentServerValid, entry: RemoteEn if (!name || name === entry.name) return; const parent = remoteParent(entry.path); try { - await withSsh(server.sshConfig, () => - call(commands.remoteRename(server.sshConfig, entry.path, joinRemote(parent, name))), + await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.remoteRename(deploymentConnection(server), entry.path, joinRemote(parent, name))), ); await loadFolder(server, parent); } catch (e) { @@ -127,7 +144,9 @@ export async function deleteEntries(server: DeploymentServerValid, entries: Remo const parents = new Set(); for (const entry of entries) { try { - await withSsh(server.sshConfig, () => call(commands.remoteDelete(server.sshConfig, entry.path))); + await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.remoteDelete(deploymentConnection(server), entry.path)), + ); parents.add(remoteParent(entry.path)); } catch (e) { fail(`Cannot delete ${entry.name}`, e); diff --git a/src/features/deployment/store.ts b/src/features/deployment/store.ts index 279b6f152..7d7dce02c 100644 --- a/src/features/deployment/store.ts +++ b/src/features/deployment/store.ts @@ -1,6 +1,6 @@ /** Session state of the deployment feature: the Sync dialog, the SSH secret prompt and the Remote Host tree. */ import { create } from "zustand"; -import type { RemoteEntry, RequestId, SyncEntry } from "@/platform/ipc"; +import type { TransferReport, RemoteEntry, RequestId, SyncEntry } from "@/platform/ipc"; import type { FocusedPart, SyncChoice } from "./model"; export const REMOTE_HOST_WINDOW = "remoteHost"; @@ -8,8 +8,9 @@ export const REMOTE_HOST_WINDOW = "remoteHost"; export interface SyncDialogState { server: string; serverName: string; - /** Absolute local path compared (file or folder). */ - path: string; + /** Absolute local paths selected for comparison. */ + paths: string[]; + skipped: TransferReport["errors"]; request: RequestId | null; entries: SyncEntry[] | null; error: string | null; @@ -26,6 +27,7 @@ export interface SecretPrompt { /** Remote Host tool window, per server: resolved root, loaded folders, expanded folders. */ export interface RemoteTreeState { + source: string | null; root: string | null; error: string | null; listings: Record; @@ -52,6 +54,7 @@ export const useDeployment = create(() => ({ })); export const EMPTY_TREE: RemoteTreeState = { + source: null, root: null, error: null, listings: {}, diff --git a/src/features/libraries/index.ts b/src/features/libraries/index.ts index cee6b3623..acc0f6257 100644 --- a/src/features/libraries/index.ts +++ b/src/features/libraries/index.ts @@ -7,6 +7,7 @@ * are browsed through the backend's `!/` paths. */ import { commands, type LibraryGroup } from "@/bindings"; +import { registerActions } from "@/platform/actions"; import { setLibraryRoots } from "@/editor/api"; import { call, isTauri, onIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; @@ -96,6 +97,7 @@ const sdkKey = (s: ReturnType) => { l?.rust?.stdlib, l?.go?.goroot, l?.go?.gopath, + l?.go?.modules, ]); }; @@ -133,6 +135,9 @@ export function activate(): () => void { .catch((e: unknown) => console.warn("external libraries: cannot list SDK roots", e)); }; refresh(); + const disposeActions = registerActions([ + { id: "libraries.reload", title: "Reload External Libraries", category: "Go", hidden: true, run: refresh }, + ]); const unsub = useProject.subscribe((s, prev) => { if (sdkKey(s) !== sdkKey(prev)) refresh(); }); @@ -147,6 +152,7 @@ export function activate(): () => void { clearTimeout(timer); unsub(); unsubFs(); + disposeActions(); clear(); }; } diff --git a/src/features/project-tree/ProjectView.module.css b/src/features/project-tree/ProjectView.module.css index e947b4ec8..c23ebaf46 100644 --- a/src/features/project-tree/ProjectView.module.css +++ b/src/features/project-tree/ProjectView.module.css @@ -125,3 +125,16 @@ padding: 8px 12px; color: var(--fg-subtle); } + +.iconStack { + position: relative; + display: inline-flex; + flex: none; + width: 16px; + height: 16px; +} +.runnable { + position: absolute; + inset: 0; + pointer-events: none; +} diff --git a/src/features/project-tree/ProjectView.test.tsx b/src/features/project-tree/ProjectView.test.tsx index 610dd2e21..47ad70519 100644 --- a/src/features/project-tree/ProjectView.test.tsx +++ b/src/features/project-tree/ProjectView.test.tsx @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { emitLocalIdeEvent } from "@/platform/ipc"; import { getContext } from "@/platform/context"; -import { provideEditorService, type EditorService } from "@/workbench/contributions"; +import { provideEditorService, registerProjectViewRoot, type EditorService } from "@/workbench/contributions"; import { activate } from "./index"; import { setOsClipboardForTests } from "./clipboard"; import { MemoryFs, setProjectFsForTests } from "./fs"; @@ -164,6 +164,47 @@ describe("ProjectView", () => { expect(getContext().projectViewFocus).toBe(false); }); + it("publishes the selected folder or file parent only while the tree has focus", async () => { + const { tree, row, rows } = setup(); + await waitFor(() => expect(rows()).toContain("README.md")); + fireEvent.mouseDown(row("src")); + tree.focus(); + expect(getContext().focusedDirectory).toBe(`${ROOT}/src`); + fireEvent.keyDown(tree, { key: "ArrowRight" }); + await waitFor(() => expect(rows()).toContain("App.kt")); + fireEvent.mouseDown(row("App.kt")); + expect(getContext().focusedDirectory).toBe(`${ROOT}/src`); + fireEvent.mouseDown(row("README.md")); + expect(getContext().focusedDirectory).toBe(ROOT); + fireEvent.blur(tree); + expect(getContext().focusedDirectory).toBeUndefined(); + }); + + it("publishes Scratches paths through the same Project tree", async () => { + fs.nodes.set("/scratches", { kind: "dir" }); + fs.nodes.set("/scratches/note.txt", { kind: "file", size: 0 }); + const remove = registerProjectViewRoot({ + id: "scratch-test", + path: "/scratches", + label: "Scratches", + icon: "scratches", + scope: "scratches", + }); + try { + const { tree, row, rows } = setup(); + await waitFor(() => expect(rows()).toContain("Scratches")); + fireEvent.mouseDown(row("Scratches")); + tree.focus(); + expect(getContext().focusedDirectory).toBe("/scratches"); + fireEvent.keyDown(tree, { key: "ArrowRight" }); + await waitFor(() => expect(rows()).toContain("note.txt")); + fireEvent.mouseDown(row("note.txt")); + expect(getContext().focusedDirectory).toBe("/scratches"); + } finally { + act(() => remove()); + } + }); + it("renames in IDEA's Rename dialog with Shift+F6, validating conflicts, and undoes with ⌘Z", async () => { const { tree, row, rows } = setup(); await waitFor(() => expect(rows()).toContain("README.md")); diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index d9e2e8a84..af80a06bc 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -24,7 +24,7 @@ import { setContext } from "@/platform/context"; import { tx, useTxTemplate } from "@/platform/i18n"; import { isTauri } from "@/platform/ipc"; import type { RootFolder } from "@/platform/languageSettings"; -import { useEffectiveRoots } from "@/platform/projectRoots"; +import { useDetectedRoots, useEffectiveRoots } from "@/platform/projectRoots"; import { macStroke, strokeOf } from "@/platform/keymap"; import { OWNS_FILE_DROP, prepareMenuGroups, useFileDecoration } from "@/workbench/contributions"; import { @@ -44,6 +44,9 @@ import { } from "@/ui"; import { contextMenuItems, menuGroupContext, newMenuItems } from "./contextMenu"; import { openNewPopup } from "./newPopupModel"; +import { presentationIcon, useJvmPresentation } from "@/platform/filePresentation"; +import { moduleFolderIcon, useProjectPresentation, type BuildPresentation } from "@/platform/projectPresentation"; +import { relativePath } from "@/platform/project"; import { folderKind } from "./folderKind"; import { useHomeDir } from "./home"; import { keyLike, nodeKeyFor, visibleInfo, type ProjectNode } from "./model"; @@ -109,11 +112,23 @@ const getLabel = (n: ProjectNode) => n.label; const isExpandable = (n: ProjectNode) => n.dir && !n.placeholder; const loadChildren = (n: ProjectNode) => loadDir(n.path); -function iconFor(node: ProjectNode, roots: readonly RootFolder[], root: string | null): string { +function iconFor( + node: ProjectNode, + roots: readonly RootFolder[], + root: string | null, + build: BuildPresentation, + detected: readonly RootFolder[], +): string { if (node.placeholder) return node.placeholder === "dir" ? "folder" : "file"; if (node.icon) return node.icon; - if (node.root) return "module"; - if (node.dir) return folderIconName(folderKind(node, roots, root)); + if (node.dir) { + const kind = folderKind(node, roots, root, build, detected); + if (kind !== "plain") return folderIconName(kind); + const rel = root ? (node.path === root ? "" : relativePath(root, node.path)) : null; + const module = rel === null ? null : moduleFolderIcon(rel, node.root, build.modules); + if (module) return module; + return node.root ? "module" : "folder"; + } return fileIconName(node.label); } @@ -246,7 +261,13 @@ const Row = memo(function Row({ const deco = useFileDecoration(node.path); const roots = useEffectiveRoots(); const root = useProjectTree((s) => s.root); - const icon = iconFor(node, roots, root); + const detected = useDetectedRoots(); + const build = useProjectPresentation(); + const file = useJvmPresentation(node.dir || node.placeholder ? "" : node.path, node.entry?.mtimeMs); + const icon = + node.dir || node.placeholder || node.icon + ? iconFor(node, roots, root, build, detected) + : presentationIcon(node.path, file, roots, root, build.spring); const isEditing = !!editing && (node.placeholder ? editing.kind === "new" : editing.kind === "rename" && editing.key === node.key); const faded = node.ignored || deco.faded; @@ -263,11 +284,16 @@ const Row = memo(function Row({ onDoubleClick={(e) => onDoubleClick(node, e)} onPointerDown={(e) => onPointerDown(node, e)} > - + + + {!!file?.runnable && icon !== "spring-boot" && icon !== "jvm-test" && ( + + )} + {isEditing && editing ? ( (node.group || node.placeholder ? null : targetFolder(node))} getChildren={getChildren} isExpandable={isExpandable} loadChildren={loadChildren} diff --git a/src/features/project-tree/folderKind.test.ts b/src/features/project-tree/folderKind.test.ts new file mode 100644 index 000000000..81810a3cb --- /dev/null +++ b/src/features/project-tree/folderKind.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { folderKind, outputFolderKind } from "./folderKind"; +import type { RootFolder } from "@/platform/languageSettings"; +const build = { + modules: new Map([ + ["", "maven"], + ["app", "gradle"], + ] as const), + spring: false, +}; +const roots: RootFolder[] = [ + { path: "src/main/java", kind: "source", detectedFrom: "pom.xml" }, + { path: "src/test/java", kind: "test", detectedFrom: "pom.xml" }, + { path: "src/main/resources", kind: "resources", detectedFrom: "pom.xml" }, + { path: "src/test/resources", kind: "testResources", detectedFrom: "pom.xml" }, +]; +const kind = (rel: string, excluded = false, rs = roots) => + folderKind({ path: `/p/${rel}`, excluded }, rs, "/p", build); +describe("JVM folder presentation", () => { + it.each([ + ["src/main/java", "source"], + ["src/test/java", "test"], + ["src/main/resources", "resources"], + ["src/test/resources", "testResources"], + ["src/main/java/com/example", "package"], + ["src/test/java/com", "package"], + ["src/main/resources/templates", "plain"], + ["plain", "plain"], + ])("%s: %s", (path, expected) => expect(kind(path)).toBe(expected)); + it.each([ + ["target/generated-sources", "generatedSource"], + ["target/generated-test-sources", "generatedTest"], + ["app/build/generated", "generatedSource"], + ["app/build/classes/kotlin", "compiled"], + ["target/classes", "compiled"], + ["target/site/apidocs", "javadoc"], + ["app/build/docs/javadoc", "javadoc"], + ])("%s: %s", (path, expected) => { + expect(kind(path)).toBe(expected); + expect(kind(path, true)).toBe("excluded"); + expect(outputFolderKind(path, new Map())).toBeNull(); + }); + it("shows packages below custom marked JVM source roots", () => { + expect(kind("custom/com/example", false, [{ path: "custom", kind: "source", detectedFrom: null }])).toBe("package"); + }); + it("keeps marks and generated source roots ahead of output and exclusion", () => { + expect( + kind("target/generated-sources", true, [ + { path: "target/generated-sources", kind: "source", detectedFrom: "pom.xml" }, + ]), + ).toBe("source"); + expect(kind("target/classes", false, [{ path: "target/classes", kind: "test", detectedFrom: null }])).toBe("test"); + expect( + kind("src/main/java/com", false, [...roots, { path: "src/main/java/com", kind: "excluded", detectedFrom: null }]), + ).toBe("excluded"); + }); + it.each(["generated-sources", "src/main/java/classes", "other/build/generated", "build/generated"])( + "does not guess output roles for %s", + (path) => expect(outputFolderKind(path, build.modules)).toBeNull(), + ); +}); diff --git a/src/features/project-tree/folderKind.ts b/src/features/project-tree/folderKind.ts index 194238ee0..b87994170 100644 --- a/src/features/project-tree/folderKind.ts +++ b/src/features/project-tree/folderKind.ts @@ -1,8 +1,10 @@ /** Folder icon kind in the project view, from the effective roots. */ -import { rootRole, type FolderRole, type RootFolder } from "@/platform/languageSettings"; +import { rootFor, rootRole, type FolderRole, type RootFolder } from "@/platform/languageSettings"; import { basename, relativePath } from "@/platform/project"; import type { FolderKind } from "@/ui"; import type { ProjectNode } from "./model"; +import { packageKindAt } from "./packages"; +import type { BuildPresentation } from "@/platform/projectPresentation"; const ROLE_KIND: Record = { source: "source", @@ -20,9 +22,39 @@ export function folderKind( node: Pick, roots: readonly RootFolder[], root: string | null, + build?: BuildPresentation, + detected: readonly RootFolder[] = [], ): FolderKind { - if (node.excluded) return "excluded"; - const role = root ? rootRole(roots, relativePath(root, node.path)) : null; + const rel = root ? (node.path === root ? "" : relativePath(root, node.path)) : null; + const role = rel === null ? null : rootRole(roots, rel); + const exact = roots.find((r) => r.path === rel); + const output = rel === null ? null : outputFolderKind(rel, build?.modules ?? new Map()); + if (role && (exact?.detectedFrom === null || output === "generatedSource" || output === "generatedTest")) + return ROLE_KIND[role]; + const containing = rel === null ? null : rootFor(roots, rel); + if (node.excluded || containing?.kind === "excluded") return "excluded"; if (role) return ROLE_KIND[role]; + if (output) return output; + if (rel !== null && (containing?.kind === "source" || containing?.kind === "test")) { + const inModule = [...(build?.modules.keys() ?? [])].some((dir) => !dir || rel.startsWith(`${dir}/`)); + if (inModule || packageKindAt(rel, roots, detected) === "jvm") return "package"; + } return basename(node.path) === ".github" ? "github" : "plain"; } + +/** Output roles are relative to detected JVM modules, never arbitrary package names. */ +export function outputFolderKind(rel: string, modules: BuildPresentation["modules"]): FolderKind | null { + const parts = rel.split("/"); + for (let i = 0; i < parts.length - 1; i++) { + const output = parts[i]; + if (output !== "target" && output !== "build") continue; + const tool = modules.get(parts.slice(0, i).join("/")); + if (tool !== (output === "target" ? "maven" : "gradle")) continue; + const path = parts.slice(i + 1).join("/"); + if (/^generated-test-sources(\/|$)/.test(path) || /^generated\/.*\btest\b/.test(path)) return "generatedTest"; + if (/^(generated-sources|generated)(\/|$)/.test(path)) return "generatedSource"; + if (/^(classes|test-classes)(\/|$)/.test(path)) return "compiled"; + if (/^(site\/apidocs|apidocs|docs\/javadoc|javadoc)(\/|$)/.test(path)) return "javadoc"; + } + return null; +} diff --git a/src/features/remote-dev/ConnectDialog.tsx b/src/features/remote-dev/ConnectDialog.tsx index ef8426fd0..7e8eab8e8 100644 --- a/src/features/remote-dev/ConnectDialog.tsx +++ b/src/features/remote-dev/ConnectDialog.tsx @@ -38,7 +38,7 @@ function ConnectDialog() { setError(null); setBusy(true); try { - const entries = await call(commands.remoteList(ssh, dir)); + const entries = await call(commands.remoteBootstrapList(ssh, dir)); const shown = listedDirectory(dir, entries); setBrowse({ dir: shown, folders: browsableFolders(entries) }); setPath(shown); diff --git a/src/features/run/ConsoleView.tsx b/src/features/run/ConsoleView.tsx index 752f1ed5b..8997d4196 100644 --- a/src/features/run/ConsoleView.tsx +++ b/src/features/run/ConsoleView.tsx @@ -19,17 +19,31 @@ import ansi from "@/ui/ansi.module.css"; import type { ConsoleLine, ConsoleModel } from "./console"; import { findOutputLinks, type OutputLink } from "./links"; import { openTarget, openUrl } from "./navigate"; -import { segmentLine } from "./segments"; +import { segmentLine, type SegmentMark } from "./segments"; import styles from "./Run.module.css"; -import { hostBinding, formatBinding } from "@/platform/keymap"; +import { hostBinding, formatBinding, hasPrimaryModifier, registerKeyClaim } from "@/platform/keymap"; import { tx } from "@/platform/i18n"; +import { ConsoleFindBar } from "./FindBar"; +import { DEFAULT_FIND_OPTIONS, findInLines, type FindOptions } from "./find"; const ROW = 20; +const MARK_COLOR = "var(--bg-search-match)"; +const CURRENT_MARK_COLOR = "var(--bg-search-match-current)"; + const Line = memo( - function Line({ line, cwd }: { line: ConsoleLine; length: number; cwd: string }) { + function Line({ + line, + marks, + cwd, + }: { + line: ConsoleLine; + marks?: readonly SegmentMark[]; + length: number; + cwd: string; + }) { // Not memoised: the open (last) line mutates in place; `memo` below re-renders on length changes only. - const segments = segmentLine(line.spans, line.kind === "system" ? [] : findOutputLinks(line.text)); + const segments = segmentLine(line.spans, line.kind === "system" ? [] : findOutputLinks(line.text), marks); const activate = (e: MouseEvent, link: OutputLink) => { e.preventDefault(); if (window.getSelection()?.toString()) return; @@ -39,29 +53,43 @@ const Line = memo( if (segments.length === 0) return <>{"​"}; return ( <> - {segments.map((seg, i) => - seg.link ? ( + {segments.map((seg, i) => { + // A match keeps the span's colours but takes the search background (as IDEA does). + const style = seg.mark + ? { + ...spanStyle(seg.style), + backgroundColor: seg.current ? CURRENT_MARK_COLOR : MARK_COLOR, + color: "var(--fg-search-match)", + } + : spanStyle(seg.style); + const marked = seg.mark ? { "data-find-match": "", "data-find-current": seg.current ? "" : undefined } : {}; + return seg.link ? ( activate(e, seg.link!)} role="link" tabIndex={-1} + {...marked} > {seg.text} + ) : seg.mark ? ( + + {seg.text} + ) : ( - + {seg.text} - ), - )} + ); + })} ); }, - (a, b) => a.line === b.line && a.length === b.length && a.cwd === b.cwd, + (a, b) => a.line === b.line && a.length === b.length && a.cwd === b.cwd && a.marks === b.marks, ); /** A run of folded lines (Settings › Console › Fold lines that contain). */ @@ -130,6 +158,9 @@ export interface ConsoleViewProps { onClear?: () => void; /** Set while the process accepts input. */ input?: ConsoleInput; + /** Controlled find bar (the Run toolbar button); without it ⌘F opens it locally. */ + findOpen?: boolean; + onFindOpenChange?: (open: boolean) => void; "aria-label"?: string; } @@ -142,12 +173,27 @@ export function ConsoleView({ cwd, onClear, input, + findOpen: controlledFindOpen, + onFindOpenChange, ...aria }: ConsoleViewProps) { const scrollRef = useRef(null); const lines = model.lines; const patterns = useWorkflowSettings((s) => s.console.foldPatterns); const [expanded, setExpanded] = useState>(() => new Set()); + const [internalFindOpen, setInternalFindOpen] = useState(false); + const findOpen = controlledFindOpen ?? internalFindOpen; + const setFindOpen = (open: boolean) => { + onFindOpenChange?.(open); + if (controlledFindOpen === undefined) setInternalFindOpen(open); + }; + const [query, setQuery] = useState(""); + const [findOptions, setFindOptions] = useState(DEFAULT_FIND_OPTIONS); + const [active, setActive] = useState(0); + const findInput = useRef(null); + /** Line the next search starts from (the top of the viewport when the query changed). */ + const findAnchor = useRef(0); + const scrolledMatch = useRef(null); // eslint-disable-next-line react-hooks/exhaustive-deps -- `version` marks in-place appends to `lines` const folded = useMemo(() => foldRows(lines, patterns, expanded), [lines, version, patterns, expanded]); // Pending input continues the open line (a prompt), or starts a row of its own. @@ -198,6 +244,135 @@ export function ConsoleView({ if (atBottom !== follow) onFollowChange(atBottom); }; + // Find (⌘F): search the whole buffer, not just the rendered rows. + const found = useMemo( + () => (findOpen ? findInLines(lines, query, findOptions) : { matches: [], overflow: false, invalid: false }), + // eslint-disable-next-line react-hooks/exhaustive-deps -- `version` marks in-place appends to `lines` + [findOpen, lines, version, query, findOptions], + ); + const activeIndex = found.matches.length ? Math.min(Math.max(active, 0), found.matches.length - 1) : -1; + const activeMatch = activeIndex >= 0 ? found.matches[activeIndex] : undefined; + const marksByLine = useMemo(() => { + const byLine = new Map(); + found.matches.forEach((match, i) => { + const mark: SegmentMark = { start: match.start, end: match.end, current: i === activeIndex }; + const marks = byLine.get(match.line); + if (marks) marks.push(mark); + else byLine.set(match.line, [mark]); + }); + return byLine; + }, [found, activeIndex]); + const rowOfLine = useMemo(() => { + const rowsByLine = new Map(); + rows.forEach((row, i) => { + if (row.kind === "line") rowsByLine.set(row.index, i); + }); + return rowsByLine; + }, [rows]); + + /** The line at the top of the viewport: typing searches from there, as IDEA does. */ + const topLine = () => { + const offset = scrollRef.current?.scrollTop ?? 0; + const items = virtualizer.getVirtualItems(); + // The virtualizer also renders overscan rows above the viewport. + const first = items.find((item) => item.start + item.size > offset) ?? items[0]; + const row = first ? rows[first.index] : undefined; + if (!row) return 0; + return row.kind === "line" ? row.index : row.fold.start; + }; + const selectedText = () => { + const el = scrollRef.current; + const selection = window.getSelection(); + if (!el || !selection || !selection.anchorNode || !el.contains(selection.anchorNode)) return ""; + const text = selection.toString(); + return text && !text.includes("\n") ? text.trim() : ""; + }; + const openFind = () => { + findAnchor.current = topLine(); + // Opening with selected output searches for it (a single line, like the terminal). + if (!findOpen) { + const selection = selectedText(); + if (selection) setQuery(selection); + } + scrolledMatch.current = null; + setFindOpen(true); + onFollowChange(false); + findInput.current?.focus(); + findInput.current?.select(); + }; + const closeFind = () => { + scrolledMatch.current = null; + setFindOpen(false); + scrollRef.current?.focus(); + }; + const changeQuery = (value: string) => { + findAnchor.current = topLine(); + setQuery(value); + }; + const changeOptions = (next: FindOptions) => { + findAnchor.current = topLine(); + setFindOptions(next); + }; + const goToMatch = (direction: 1 | -1) => { + const total = found.matches.length; + if (!total) return; + setActive((current) => { + const from = current >= 0 && current < total ? current : activeIndex; + return (from + direction + total) % total; + }); + }; + + const findWasOpen = useRef(false); + useEffect(() => { + if (findOpen) { + findWasOpen.current = true; + findInput.current?.focus(); + findInput.current?.select(); + } else if (findWasOpen.current) { + // Closed from the toolbar rather than the bar: put the caret back in the console. + findWasOpen.current = false; + scrollRef.current?.focus(); + } + }, [findOpen]); + + // The bar owns Escape / Enter / F3 / ⌘G while it is focused; keep the IDE keymap out. + useEffect(() => { + if (!findOpen) return; + return registerKeyClaim((e) => !!(e.target as Element | null)?.closest?.("[data-run-find]")); + }, [findOpen]); + + // A new query (or option) starts from the viewport, not from the top of the output. + useEffect(() => { + if (!findOpen) return; + const next = found.matches.findIndex((match) => match.line >= findAnchor.current); + setActive(next >= 0 ? next : 0); + // eslint-disable-next-line react-hooks/exhaustive-deps -- new output keeps the current match + }, [findOpen, query, findOptions]); + + // Bring the current match into view; expand a collapsed fold that hides it. + useEffect(() => { + if (!findOpen || !activeMatch) return; + const key = `${activeMatch.lineId}:${activeMatch.start}`; + if (scrolledMatch.current === key) return; + const rowIndex = rowOfLine.get(activeMatch.line); + if (rowIndex === undefined) { + for (const row of rows) { + const fold = row.fold; + if (fold && activeMatch.line >= fold.start && activeMatch.line < fold.start + fold.count) { + setExpanded((s) => { + const next = new Set(s); + next.add(fold.id); + return next; + }); + break; + } + } + return; + } + virtualizer.scrollToIndex(rowIndex, { align: "center" }); + scrolledMatch.current = key; + }, [findOpen, activeMatch, rowOfLine, rows, virtualizer]); + const onContextMenu = (e: MouseEvent) => { e.preventDefault(); const selection = window.getSelection()?.toString() ?? ""; @@ -210,6 +385,12 @@ export function ConsoleView({ run: () => void navigator.clipboard?.writeText(selection).catch(() => undefined), }, { label: "Copy All", run: () => void navigator.clipboard?.writeText(model.text()).catch(() => undefined) }, + { + label: "Find…", + icon: "search", + shortcut: formatBinding(hostBinding("Meta+F")), + run: openFind, + }, { kind: "separator" }, { label: "Clear All", icon: "trash", disabled: !onClear, run: () => onClear?.() }, ...(input @@ -219,8 +400,14 @@ export function ConsoleView({ }; const onKeyDown = (e: KeyboardEvent) => { + if (e.nativeEvent.isComposing) return; + if (hasPrimaryModifier(e) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "f") { + e.preventDefault(); + openFind(); + return; + } if (copySelectionOnKeyDown(e)) return; - if (!input || e.nativeEvent.isComposing) return; + if (!input) return; if (e.key === "d" && e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); input.onEof(); @@ -278,64 +465,82 @@ export function ConsoleView({ const items = virtualizer.getVirtualItems(); return ( -
-
- {items.map((item) => { - const row = rows[item.index]; - if (!row) { +
+ {findOpen && ( + goToMatch(1)} + onPrevious={() => goToMatch(-1)} + onClose={closeFind} + /> + )} +
+
+ {items.map((item) => { + const row = rows[item.index]; + if (!row) { + return ( +
+ {pendingInput} +
+ ); + } + if (row.kind === "fold") { + return ( +
+ {foldButton(row.fold, false)} +
+ ); + } + const line = lines[row.index]; + if (!line) return null; return (
- {pendingInput} + + {row.fold && foldButton(row.fold, true)} + {inputInline && item.index === rows.length - 1 && pendingInput}
); - } - if (row.kind === "fold") { - return ( -
- {foldButton(row.fold, false)} -
- ); - } - const line = lines[row.index]; - if (!line) return null; - return ( -
- - {row.fold && foldButton(row.fold, true)} - {inputInline && item.index === rows.length - 1 && pendingInput} -
- ); - })} + })} +
); diff --git a/src/features/run/FindBar.tsx b/src/features/run/FindBar.tsx new file mode 100644 index 000000000..81af6d837 --- /dev/null +++ b/src/features/run/FindBar.tsx @@ -0,0 +1,145 @@ +/** ⌘F in the Run console: the IDEA find bar (Match Case / Words / Regex, n/m, previous/next). */ +import { type KeyboardEvent, type RefObject } from "react"; +import clsx from "clsx"; +import { IconButton, SearchField } from "@/ui"; +import { hasPrimaryModifier } from "@/platform/keymap"; +import { tx } from "@/platform/i18n"; +import type { FindOptions } from "./find"; +import styles from "./Run.module.css"; + +export interface ConsoleFindBarProps { + query: string; + onChange: (query: string) => void; + options: FindOptions; + onOptionsChange: (options: FindOptions) => void; + /** Number of matches. */ + count: number; + /** Zero-based current match; -1 when there is none. */ + active: number; + /** More matches exist than were collected. */ + overflow: boolean; + /** The query is an invalid regular expression. */ + invalid: boolean; + inputRef: RefObject; + onNext: () => void; + onPrevious: () => void; + onClose: () => void; +} + +export function ConsoleFindBar({ + query, + onChange, + options, + onOptionsChange, + count, + active, + overflow, + invalid, + inputRef, + onNext, + onPrevious, + onClose, +}: ConsoleFindBarProps) { + const none = !!query && !invalid && count === 0; + const counter = invalid + ? "Bad regex" + : !query + ? "" + : count === 0 + ? "0 results" + : `${active + 1}/${count}${overflow ? "+" : ""}`; + const toggle = (key: keyof FindOptions) => onOptionsChange({ ...options, [key]: !options[key] }); + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } else if (e.key === "Enter") { + e.preventDefault(); + if (e.shiftKey) onPrevious(); + else onNext(); + } else if (e.key === "F3" || (hasPrimaryModifier(e) && e.key.toLowerCase() === "g")) { + e.preventDefault(); + if (e.shiftKey) onPrevious(); + else onNext(); + } else if (hasPrimaryModifier(e) && e.key.toLowerCase() === "f") { + e.preventDefault(); + inputRef.current?.focus(); + inputRef.current?.select(); + } else if (e.altKey && !e.metaKey && !e.ctrlKey) { + const key = + e.code === "KeyC" ? "caseSensitive" : e.code === "KeyW" ? "wholeWord" : e.code === "KeyX" ? "regex" : null; + if (key) { + e.preventDefault(); + toggle(key); + } + } + }; + + return ( +
+ toggle("caseSensitive"), + }, + { + id: "word", + icon: "word", + title: "Words", + shortcut: "⌥W", + pressed: options.wholeWord, + onToggle: () => toggle("wholeWord"), + }, + { + id: "regex", + icon: "regex", + title: "Regex", + shortcut: "⌥X", + pressed: options.regex, + onToggle: () => toggle("regex"), + }, + ]} + /> + + {counter} + + + + + +
+ ); +} diff --git a/src/features/run/Run.module.css b/src/features/run/Run.module.css index 3e6a93444..64b8da79a 100644 --- a/src/features/run/Run.module.css +++ b/src/features/run/Run.module.css @@ -81,6 +81,14 @@ /* Console */ +.consoleWrap { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + .console { flex: 1; min-height: 0; @@ -107,6 +115,10 @@ min-width: 0; } +.match { + border-radius: 2px; +} + .line { position: absolute; top: 0; @@ -748,6 +760,36 @@ vertical-align: text-bottom; background: transparent; } + +/* Find bar (⌘F) */ +.find { + flex: none; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: var(--bg-elev); + border-bottom: 1px solid var(--border); +} +.findField { + flex: 0 1 360px; + min-width: 160px; +} +.findCount { + font-family: var(--font-mono); + font-size: var(--fs-small); + color: var(--fg-subtle); + padding: 0 6px; + white-space: nowrap; + min-width: 56px; +} +.findCountNone { + color: var(--danger); +} +.findSpacer { + flex: 1; +} + .console:focus .inputCaret { background: var(--fg); animation: caretBlink 1s steps(1) infinite; diff --git a/src/features/run/RunServices.module.css b/src/features/run/RunServices.module.css index b75407269..288bb3362 100644 --- a/src/features/run/RunServices.module.css +++ b/src/features/run/RunServices.module.css @@ -29,3 +29,9 @@ gap: 6px; padding: 0 12px 12px; } + +.debugConsole { + display: flex; + height: 100%; + min-height: 0; +} diff --git a/src/features/run/RunView.tsx b/src/features/run/RunView.tsx index 9170bca96..7c9b5a38f 100644 --- a/src/features/run/RunView.tsx +++ b/src/features/run/RunView.tsx @@ -3,7 +3,7 @@ import type { RunTargetValid } from "@/platform/remoteSettings"; import { useWorkflowSettings } from "@/platform/workflowSettings"; import { EmptyState, IconButton, SplitGroup, SplitHandle, SplitPanel, Tabs, type TabItem, T } from "@/ui"; import { executeAction } from "@/platform/actions"; -import { shortcutLabel } from "@/platform/keymap"; +import { formatBinding, hostBinding, shortcutLabel } from "@/platform/keymap"; import { ConsoleModel } from "./console"; import { ConsoleView } from "./ConsoleView"; import { TerminalConsole } from "./TerminalConsole"; @@ -43,7 +43,15 @@ function sessionIcon(s: RunSession): string { return s.config.isTest ? "check" : "run"; } -export function SessionPanel({ session }: { session: RunSession }) { +export function SessionPanel({ + session, + onRerun, + onStop, +}: { + session: RunSession; + onRerun?: (() => void) | null; + onStop?: () => void; +}) { const version = useRun((s) => s.versions[session.id] ?? 0); const rt = runtime(session.id); const [follow, setFollow] = useState(true); @@ -52,6 +60,7 @@ export function SessionPanel({ session }: { session: RunSession }) { const [wrapOverride, setWrapOverride] = useState(null); const softWrap = wrapOverride ?? defaultWrap; const [selectedTest, setSelectedTest] = useState(null); + const [findOpen, setFindOpen] = useState(false); const running = session.status === "running" || session.status === "starting"; const cwd = runDirectory(session.config.cwd); @@ -94,6 +103,8 @@ export function SessionPanel({ session }: { session: RunSession }) { softWrap={softWrap} follow={testModel ? false : follow} onFollowChange={setFollow} + findOpen={findOpen} + onFindOpenChange={setFindOpen} cwd={cwd} onClear={() => clearConsole(session.id)} input={ @@ -120,7 +131,8 @@ export function SessionPanel({ session }: { session: RunSession }) { iconSize={14} title="Rerun" shortcut={shortcutLabel("run.rerun")} - onClick={() => rerunSession(session.id)} + disabled={onRerun === null} + onClick={onRerun ?? (() => rerunSession(session.id))} /> {rt.tests && ( void stopSession(session.id)} + tone={running || onStop ? "danger" : "default"} + disabled={!running && !onStop} + onClick={onStop ?? (() => void stopSession(session.id))} /> {session.config.kind === "tomcat" && ( {!rt.terminal && ( <> + setFindOpen((v) => !v)} + /> = { python: "Python", pytest: "pytest", make: "Make", + cmake: "CMake", just: "Just", shell: "Shell Script", program: "Program", @@ -237,49 +239,7 @@ export function validateConfig(config: RunConfig, all: readonly RunConfig[] = [] export const hasErrors = (e: ConfigErrors) => Object.keys(e).length > 0; -/** - * Splits a command line into arguments: whitespace, '…' and "…" quoting, backslash escapes. - * `windowsPaths`: a backslash only escapes a quote, so `C:\dir\app.jar` stays as typed. - */ -export function parseArgs(input: string, windowsPaths = false): string[] { - const out: string[] = []; - let cur = ""; - let has = false; - let quote: "'" | '"' | null = null; - for (let i = 0; i < input.length; i++) { - const ch = input[i]!; - if (quote) { - if (ch === quote) quote = null; - else if ( - ch === "\\" && - quote === '"' && - i + 1 < input.length && - (windowsPaths ? '"' : '"\\$`').includes(input[i + 1]!) - ) - cur += input[++i]; - else cur += ch; - } else if (ch === "'" || ch === '"') { - quote = ch; - has = true; - } else if (ch === "\\" && i + 1 < input.length && (!windowsPaths || "\"'".includes(input[i + 1]!))) { - cur += input[++i]; - has = true; - } else if (/\s/.test(ch)) { - if (has || cur) out.push(cur); - cur = ""; - has = false; - } else { - cur += ch; - } - } - if (has || cur) out.push(cur); - return out; -} - -/** Inverse of `parseArgs` (quotes only when needed). */ -export function formatArgs(args: readonly string[]): string { - return args.map((a) => (a === "" ? "''" : /[\s'"\\$`]/.test(a) ? `'${a.replace(/'/g, `'\\''`)}'` : a)).join(" "); -} +export { parseArgs, formatArgs } from "@/platform/commandLine"; /** Display command line, e.g. `pnpm run dev`. */ export function commandLine(config: Pick): string { diff --git a/src/features/run/context.ts b/src/features/run/context.ts index 82f849cc2..acab57def 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -4,6 +4,7 @@ * direct interpreter invocation for main files. Best effort, IDEA-style. */ import type { RunConfig } from "@/platform/ipc"; +import { goTestParts } from "./goArguments"; import { isWindows } from "./jvmTypes"; export type FileRole = "test" | "main" | "script" | null; @@ -44,7 +45,9 @@ function testKinds(relPath: string): string[] { /** Configs whose working directory most specifically contains `relPath` (ties in their original order). */ function allClosest(configs: RunConfig[], relPath: string): RunConfig[] { const score = (c: RunConfig) => { - const cwd = (c.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, ""); + const cwd = (c.kind === "go" ? (c.params?.module ?? c.cwd ?? "") : (c.cwd ?? "")) + .replace(/^\.\/?/, "") + .replace(/\/+$/, ""); if (!cwd) return 0; return relPath === cwd || relPath.startsWith(cwd + "/") ? cwd.length : -1; }; @@ -59,7 +62,9 @@ function closest(configs: RunConfig[], relPath: string): RunConfig | undefined { } function relToCwd(relPath: string, config: RunConfig): string { - const cwd = (config.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, ""); + const cwd = (config.kind === "go" ? (config.params?.module ?? config.cwd ?? "") : (config.cwd ?? "")) + .replace(/^\.\/?/, "") + .replace(/\/+$/, ""); return cwd && relPath.startsWith(cwd + "/") ? relPath.slice(cwd.length + 1) : relPath; } @@ -123,7 +128,8 @@ function implicitPytest(relPath: string, configs: RunConfig[]): RunConfig | null */ export function testBasesFor(relPath: string, configs: RunConfig[]): RunConfig[] { const kinds = testKinds(relPath); - const candidates = configs.filter((c) => c.isTest && kinds.includes(c.kind)); + // Configurations produced from a test or file are results of narrowing, never bases for it. + const candidates = configs.filter((c) => c.isTest && kinds.includes(c.kind) && !c.id.startsWith("context:")); const best = allClosest(candidates, relPath); if (best.length) return best; const fallback = candidates[0] ?? implicitPytest(relPath, configs); @@ -152,8 +158,14 @@ export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunC return derived(base, relPath, { args: [...base.args, file] }); case "go": { const dir = dirOf(file); - const args = base.args.map((a) => (a === "./..." ? (dir ? `./${dir}` : ".") : a)); - return derived(base, relPath, { args, name: `go test ${dir ? `./${dir}` : "."}` }); + const target = dir ? `./${dir}` : "."; + const parts = goTestParts(base.args); + const args = ["test", ...parts.flags, target, ...parts.program]; + return derived(base, relPath, { + args, + name: `go test ${target}`, + ...(base.params?.goKind ? { params: { ...base.params, scope: "directory", target } } : {}), + }); } case "cargo": { const m = /(?:^|\/)tests\/([^/]+)\.rs$/.exec(relPath); diff --git a/src/features/run/find.test.ts b/src/features/run/find.test.ts new file mode 100644 index 000000000..1b8eb116e --- /dev/null +++ b/src/features/run/find.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_FIND_OPTIONS, findInLines, MAX_MATCHES, type FindOptions } from "./find"; + +const lines = (...texts: string[]) => texts.map((text, i) => ({ id: i + 1, text })); +const opts = (extra: Partial = {}): FindOptions => ({ ...DEFAULT_FIND_OPTIONS, ...extra }); + +describe("findInLines", () => { + it("finds every occurrence on every line, ignoring case by default", () => { + expect(findInLines(lines("alpha beta", "gamma Beta", "BETA again"), "beta", opts()).matches).toEqual([ + { line: 0, lineId: 1, start: 6, end: 10 }, + { line: 1, lineId: 2, start: 6, end: 10 }, + { line: 2, lineId: 3, start: 0, end: 4 }, + ]); + }); + + it("matches case only when asked", () => { + expect(findInLines(lines("beta Beta BETA"), "Beta", opts({ caseSensitive: true })).matches).toEqual([ + { line: 0, lineId: 1, start: 5, end: 9 }, + ]); + }); + + it("keeps whole words only", () => { + expect(findInLines(lines("cat concatenate cat."), "cat", opts({ wholeWord: true })).matches).toEqual([ + { line: 0, lineId: 1, start: 0, end: 3 }, + { line: 0, lineId: 1, start: 16, end: 19 }, + ]); + }); + + it("treats a literal query literally", () => { + const matches = findInLines(lines("a.b axb a.b"), "a.b", opts()).matches; + expect(matches.map((m) => m.start)).toEqual([0, 8]); + }); + + it("uses a regular expression when asked", () => { + expect(findInLines(lines("beta betta b3ta"), "b.ta", opts({ regex: true })).matches).toHaveLength(2); + }); + + it("applies whole words to regular expression matches", () => { + expect(findInLines(lines("cat concatenate cat"), "c.t", opts({ regex: true, wholeWord: true })).matches).toEqual([ + { line: 0, lineId: 1, start: 0, end: 3 }, + { line: 0, lineId: 1, start: 16, end: 19 }, + ]); + }); + + it("reports an invalid regular expression instead of throwing", () => { + expect(findInLines(lines("anything"), "(", opts({ regex: true }))).toEqual({ + matches: [], + overflow: false, + invalid: true, + }); + }); + + it("skips empty regular expression matches", () => { + expect(findInLines(lines("abc"), "x*", opts({ regex: true })).matches).toEqual([]); + }); + + it("caps the matches and reports the overflow", () => { + const result = findInLines(lines("x x x x"), "x", opts(), 2); + expect(result.matches).toHaveLength(2); + expect(result.overflow).toBe(true); + }); + + it("has no overflow when the cap is exact", () => { + expect(findInLines(lines("x x"), "x", opts(), 2)).toMatchObject({ overflow: false }); + expect(findInLines(lines("x x"), "x", opts(), MAX_MATCHES).matches).toHaveLength(2); + }); + + it("returns nothing for an empty query", () => { + expect(findInLines(lines("a b"), " ", opts({ regex: false })).matches).toHaveLength(1); + expect(findInLines(lines("text"), "", opts()).matches).toEqual([]); + }); +}); diff --git a/src/features/run/find.ts b/src/features/run/find.ts new file mode 100644 index 000000000..57eff195c --- /dev/null +++ b/src/features/run/find.ts @@ -0,0 +1,81 @@ +/** + * Find in the Run console output: every occurrence in every line, with IDEA's + * Match Case / Words / Regex options. Matches carry the line's stable id so the + * bar can keep the current match while output keeps arriving. + */ + +export interface FindOptions { + caseSensitive: boolean; + wholeWord: boolean; + regex: boolean; +} + +export const DEFAULT_FIND_OPTIONS: FindOptions = { + caseSensitive: false, + wholeWord: false, + regex: false, +}; + +export interface ConsoleMatch { + /** Index into the model's current lines. */ + line: number; + /** Stable line id (survives trimming of older lines). */ + lineId: number; + /** UTF-16 offsets into the line's text. */ + start: number; + end: number; +} + +export interface FindResult { + matches: ConsoleMatch[]; + /** More matches exist than were collected. */ + overflow: boolean; + /** The query is an invalid regular expression. */ + invalid: boolean; +} + +/** Matches collected per search; the counter shows `N+` beyond this. */ +export const MAX_MATCHES = 2000; + +const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g; +const WORD_CHAR = /[\p{L}\p{N}_$]/u; + +function wholeWord(text: string, start: number, end: number): boolean { + return !WORD_CHAR.test(text[start - 1] ?? "") && !WORD_CHAR.test(text[end] ?? ""); +} + +/** All matches of `query` in `lines`, in document order. */ +export function findInLines( + lines: readonly { id: number; text: string }[], + query: string, + options: FindOptions, + limit = MAX_MATCHES, +): FindResult { + if (!query) return { matches: [], overflow: false, invalid: false }; + const source = options.regex ? query : query.replace(REGEX_SPECIALS, "\\$&"); + let re: RegExp; + try { + re = new RegExp(source, options.caseSensitive ? "gu" : "giu"); + } catch { + return { matches: [], overflow: false, invalid: true }; + } + const matches: ConsoleMatch[] = []; + for (let line = 0; line < lines.length; line++) { + const { id, text } = lines[line]!; + re.lastIndex = 0; + let found: RegExpExecArray | null; + while ((found = re.exec(text))) { + const start = found.index; + const end = start + found[0].length; + // Empty matches (e.g. `x*`) would otherwise loop forever. + if (end === start) { + re.lastIndex = start + 1; + continue; + } + if (options.wholeWord && !wholeWord(text, start, end)) continue; + if (matches.length >= limit) return { matches, overflow: true, invalid: false }; + matches.push({ line, lineId: id, start, end }); + } + } + return { matches, overflow: false, invalid: false }; +} diff --git a/src/features/run/findBar.test.tsx b/src/features/run/findBar.test.tsx new file mode 100644 index 000000000..9aa44c4d8 --- /dev/null +++ b/src/features/run/findBar.test.tsx @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { usePlatform } from "@/platform/platformInfo"; +import { ConsoleModel } from "./console"; +import { ConsoleView } from "./ConsoleView"; + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getTotalSize: () => count * 20, + getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, start: index * 20, key: index })), + scrollToIndex: () => {}, + measureElement: () => {}, + measure: () => {}, + }), +})); + +beforeEach(() => usePlatform.setState({ os: "macos" })); + +function setup(text = "alpha beta\ngamma Beta\nbeta again\n") { + const model = new ConsoleModel(); + model.append({ kind: "stdout", text }); + const view = render( + {}} cwd="/" />, + ); + const log = screen.getByRole("log"); + return { model, log, view }; +} + +function openFind(log: HTMLElement) { + fireEvent.keyDown(log, { key: "f", code: "KeyF", metaKey: true }); + return screen.getByRole("search"); +} + +function input() { + return screen.getByRole("textbox", { name: "Find in console" }); +} + +function type(query: string) { + fireEvent.change(input(), { target: { value: query } }); +} + +function markCount(container: HTMLElement) { + return container.querySelectorAll("[data-find-match]").length; +} + +describe("Run console find", () => { + it("opens on ⌘F, searches as you type and highlights every match", () => { + const { log, view } = setup(); + const bar = openFind(log); + expect(bar).toBeInTheDocument(); + expect(input()).toHaveFocus(); + + type("beta"); + expect(screen.getByText("1/3")).toBeInTheDocument(); + expect(markCount(view.container)).toBe(3); + expect(view.container.querySelectorAll("[data-find-current]")).toHaveLength(1); + }); + + it("moves through matches with Enter / Shift+Enter and wraps around", () => { + const { log } = setup(); + openFind(log); + type("beta"); + const bar = screen.getByRole("search"); + expect(screen.getByText("1/3")).toBeInTheDocument(); + + fireEvent.keyDown(bar, { key: "Enter" }); + expect(screen.getByText("2/3")).toBeInTheDocument(); + fireEvent.keyDown(bar, { key: "Enter", shiftKey: true }); + expect(screen.getByText("1/3")).toBeInTheDocument(); + fireEvent.keyDown(bar, { key: "Enter", shiftKey: true }); + expect(screen.getByText("3/3")).toBeInTheDocument(); + }); + + it("supports match case, words and regex", () => { + const { log, view } = setup(); + openFind(log); + type("beta"); + expect(screen.getByText("1/3")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Match Case" })); + expect(screen.getByText("1/2")).toBeInTheDocument(); + expect(markCount(view.container)).toBe(2); + + fireEvent.change(input(), { target: { value: "b.ta" } }); + expect(screen.getByText("0 results")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Regex" })); + expect(screen.getByText("1/2")).toBeInTheDocument(); + }); + + it("reports an invalid regular expression", () => { + const { log } = setup(); + openFind(log); + fireEvent.click(screen.getByRole("button", { name: "Regex" })); + type("("); + expect(screen.getByText("Bad regex")).toBeInTheDocument(); + }); + + it("closes on Escape, clears the highlights and returns focus to the console", () => { + const { log, view } = setup(); + openFind(log); + type("beta"); + expect(markCount(view.container)).toBe(3); + + fireEvent.keyDown(input(), { key: "Escape" }); + expect(screen.queryByRole("search")).not.toBeInTheDocument(); + expect(markCount(view.container)).toBe(0); + expect(log).toHaveFocus(); + }); + + it("seeds the field with the selected console text", () => { + const { log } = setup(); + const text = screen.getByText("gamma Beta"); + const range = document.createRange(); + range.setStart(text.firstChild!, 0); + range.setEnd(text.firstChild!, 5); + window.getSelection()!.removeAllRanges(); + window.getSelection()!.addRange(range); + + openFind(log); + expect(input()).toHaveValue("gamma"); + }); + + it("shows no matches when nothing matches", () => { + const { log, view } = setup(); + openFind(log); + type("nothing"); + expect(screen.getByText("0 results")).toBeInTheDocument(); + expect(markCount(view.container)).toBe(0); + }); + + it("runs the bar in a controlled open state", () => { + const onOpenChange = vi.fn(); + const model = new ConsoleModel(); + model.append({ kind: "stdout", text: "one two" }); + render( + {}} + cwd="/" + findOpen + onFindOpenChange={onOpenChange} + />, + ); + expect(screen.getByRole("search")).toBeInTheDocument(); + fireEvent.keyDown(input(), { key: "Escape" }); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/features/run/goActions.test.ts b/src/features/run/goActions.test.ts new file mode 100644 index 000000000..f914ef0e2 --- /dev/null +++ b/src/features/run/goActions.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { goModuleCommand, goModuleMenu } from "./goActions"; + +describe("Go module commands", () => { + it("runs module operations from the selected manifest directory", () => { + for (const operation of ["tidy", "download"] as const) { + expect(goModuleCommand(operation, "/work/app/go.mod")).toMatchObject({ + kind: "go", + command: "go", + args: ["mod", operation], + cwd: "/work/app", + isTest: false, + }); + } + expect(goModuleCommand("sync", "/work/go.work")?.args).toEqual(["work", "sync"]); + expect(goModuleCommand("use", "/work/go.work", "./module with spaces")?.args).toEqual([ + "work", + "use", + "--", + "./module with spaces", + ]); + }); + it("does not reinterpret invalid manifests, missing directories or NULs", () => { + expect(goModuleCommand("tidy", "/work/go.work")).toBeNull(); + expect(goModuleCommand("sync", "/work/go.mod")).toBeNull(); + expect(goModuleCommand("use", "/work/go.work")).toBeNull(); + expect(goModuleCommand("use", "/work/go.work", "x\0y")).toBeNull(); + }); + it("contributes stable action ids only to applicable manifest menus", () => { + expect(goModuleMenu({ paths: ["/p/go.mod"] }).map((m) => m.action)).toEqual(["go.mod.tidy", "go.mod.download"]); + expect(goModuleMenu({ paths: ["/p/go.work"] }).map((m) => m.action)).toEqual(["go.work.sync", "go.work.use"]); + expect(goModuleMenu({ paths: ["/p/go.work", "/p/go.mod"] })).toEqual([]); + expect(goModuleMenu({ paths: ["/p/main.go"] })).toEqual([]); + }); +}); diff --git a/src/features/run/goActions.ts b/src/features/run/goActions.ts new file mode 100644 index 000000000..950bfb40d --- /dev/null +++ b/src/features/run/goActions.ts @@ -0,0 +1,112 @@ +import { executeAction, getAction, registerActions } from "@/platform/actions"; +import { useProject } from "@/platform/project"; +import type { RunConfig } from "@/platform/ipc"; +import { registerMenuGroup, maybeEditorService, type MenuGroupContext } from "@/workbench/contributions"; +import { promptInput } from "@/workbench/inputDialogs"; +import { goGenerateConfig, goGenerateMenu } from "./goGenerate"; +import { invalidateGoModules, loadGoModules } from "./goModules"; +import { loadConfigs, startRun, useRun } from "./store"; + +export type GoModuleOperation = "tidy" | "download" | "sync" | "use"; +const ACTIONS = { + tidy: { id: "go.mod.tidy", title: "Go Mod Tidy" }, + download: { id: "go.mod.download", title: "Download Go Modules" }, + sync: { id: "go.work.sync", title: "Sync Go Workspace" }, + use: { id: "go.work.use", title: "Add Directory to Go Workspace…" }, +} as const; + +export function goModuleCommand(operation: GoModuleOperation, path: string, directory?: string): RunConfig | null { + const module = operation === "tidy" || operation === "download"; + if (!path.endsWith(module ? "/go.mod" : "/go.work") || path.includes("\0")) return null; + if (operation === "use" && (!directory?.trim() || directory.includes("\0"))) return null; + const args = [module ? "mod" : "work", operation, ...(operation === "use" ? ["--", directory!] : [])]; + return { + id: `go:${operation}:${path}`, + name: `go ${args.slice(0, 2).join(" ")}`, + kind: "go", + command: "go", + args, + cwd: path.slice(0, path.lastIndexOf("/")), + env: {}, + detected: true, + isTest: false, + debugAdapter: null, + }; +} + +export function goModuleMenu(ctx: MenuGroupContext) { + const path = ctx.paths.length === 1 ? ctx.paths[0]! : ""; + const operations: GoModuleOperation[] = path.endsWith("/go.mod") + ? ["tidy", "download"] + : path.endsWith("/go.work") + ? ["sync", "use"] + : []; + return operations.map((op) => ({ kind: "action" as const, action: ACTIONS[op].id, arg: { path } })); +} + +export function registerGoActions(): () => void { + const actions = registerActions([ + ...Object.entries(ACTIONS).map(([operation, spec]) => ({ + ...spec, + category: "Go", + when: "projectOpen", + run: async ({ arg }: { arg?: unknown }) => { + const params = arg && typeof arg === "object" ? (arg as { path?: string; directory?: string }) : {}; + const path = + typeof arg === "string" + ? arg + : typeof params.path === "string" + ? params.path + : (maybeEditorService()?.activePath() ?? ""); + let directory = typeof params.directory === "string" ? params.directory : undefined; + if (operation === "use" && !directory) { + directory = + (await promptInput({ + title: "Add Directory to Go Workspace", + label: "Module directory", + initial: "./", + hint: "Path relative to go.work, or an absolute directory containing go.mod", + })) ?? undefined; + if (!directory) return; + } + const config = goModuleCommand(operation as GoModuleOperation, path, directory); + if (!config) return; + const root = useProject.getState().project?.root; + await startRun(config, undefined, undefined, { + onExit: () => { + if (useProject.getState().project?.root !== root) return; + invalidateGoModules(); + void loadGoModules(root ?? null, useRun.getState().configs); + void loadConfigs(); + if (getAction("libraries.reload")) void executeAction("libraries.reload"); + }, + }); + }, + })), + ...(["file", "package"] as const).map((scope) => ({ + id: `go.generate.${scope}`, + title: scope === "file" ? "Go Generate File" : "Go Generate Package", + category: "Go", + when: "projectOpen", + run: async ({ arg }: { arg?: unknown }) => { + const path = + typeof arg === "string" + ? arg + : arg && typeof arg === "object" && "path" in arg && typeof arg.path === "string" + ? arg.path + : (maybeEditorService()?.activePath() ?? ""); + const config = goGenerateConfig(path, scope); + if (config) await startRun(config); + }, + })), + ]); + const menu = registerMenuGroup({ + id: "go.modules", + menus: ["projectView", "editorTab", "editor"], + items: (_menu, ctx) => [...goModuleMenu(ctx), ...goGenerateMenu(ctx)], + }); + return () => { + menu(); + actions(); + }; +} diff --git a/src/features/run/goArguments.ts b/src/features/run/goArguments.ts new file mode 100644 index 000000000..3d1c82d79 --- /dev/null +++ b/src/features/run/goArguments.ts @@ -0,0 +1,54 @@ +/** Go test flags can occur before or after package targets; binary arguments start at -args. */ +const BOOLEAN_FLAGS = new Set([ + "-a", + "-n", + "-race", + "-msan", + "-asan", + "-cover", + "-linkshared", + "-modcacherw", + "-trimpath", + "-v", + "-work", + "-x", + "-json", + "-c", + "-i", + "-short", + "-failfast", + "-benchmem", + "-fullpath", + "-buildvcs", +]); + +export function goTestParts(args: readonly string[]) { + const flags: string[] = []; + const targets: string[] = []; + let program: string[] = []; + for (let i = 1; i < args.length; i++) { + const arg = args[i]!; + if (arg === "-args" || arg === "--") { + program = args.slice(i); + break; + } + if (arg.startsWith("-")) { + flags.push(arg); + if (!arg.includes("=") && !BOOLEAN_FLAGS.has(arg) && args[i + 1] !== undefined) flags.push(args[++i]!); + } else targets.push(arg); + } + return { flags, targets, program }; +} + +export function withoutGoSelection(flags: readonly string[]): string[] { + const out: string[] = []; + for (let i = 0; i < flags.length; i++) { + if (["-run", "-skip", "-bench", "-fuzz"].includes(flags[i]!)) { + i++; + continue; + } + if (/^-(run|skip|bench|fuzz)=/.test(flags[i]!)) continue; + out.push(flags[i]!); + } + return out; +} diff --git a/src/features/run/goGenerate.test.ts b/src/features/run/goGenerate.test.ts new file mode 100644 index 000000000..a83fa25c6 --- /dev/null +++ b/src/features/run/goGenerate.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { goGenerateConfig, goGenerateMenu } from "./goGenerate"; +import { detectRunTargets } from "./targets"; +import { OPTIMISTIC, produceContext, produceContextChoices } from "./producers"; +import { formParams } from "./types"; +import { goBuildType } from "./goTypes"; + +const path = "/work/app/tools/generate.go"; + +describe("Go generate", () => { + it("runs files and packages in their own directory without shell interpretation", () => { + const file = goGenerateConfig(path, "file")!; + expect(file).toMatchObject({ + command: "go", + args: ["generate", "generate.go"], + cwd: "/work/app/tools", + isTest: false, + debugAdapter: null, + }); + expect(goGenerateConfig(path, "package")?.args).toEqual(["generate", "."]); + expect(goGenerateConfig("/work/app/-name.go", "file")?.args).toEqual(["generate", "./-name.go"]); + expect(formParams(file, goBuildType)).toBeNull(); + expect(goGenerateConfig("/work/app/go.mod", "file")).toBeNull(); + }); + it("recognizes real directives in non-main packages and narrows the command to their literal line", () => { + const content = + "package tools\n//go:generate go run gen.go -out=$GOFILE\nfunc helper() {}\n// go:generate ignored\n//go:generate \n"; + const targets = detectRunTargets(content, "tools/generate.go"); + expect(targets).toHaveLength(1); + expect(targets[0]).toMatchObject({ kind: "generate", line: 1 }); + const input = { path, root: "/work/app", configs: [], content, line: 1 }; + const result = produceContext(input, OPTIMISTIC)!; + expect(result.config?.args).toEqual([ + "generate", + "-run", + "^//go:generate go run gen\\.go -out=\\$GOFILE$", + "generate.go", + ]); + expect(result.config?.isTest).toBe(false); + expect(produceContext({ ...input, line: 2 }, OPTIMISTIC)).toBeNull(); + const testBase = { ...goGenerateConfig(path, "file")!, id: "go:test", isTest: true, args: ["test", "./..."] }; + expect( + produceContextChoices({ ...input, configs: [testBase, { ...testBase, id: "go:other" }] }, OPTIMISTIC), + ).toHaveLength(1); + }); + it("adds file and package actions only for a single Go source file", () => { + expect(goGenerateMenu({ paths: [path] }).map((m) => m.action)).toEqual(["go.generate.file", "go.generate.package"]); + expect(goGenerateMenu({ paths: [path, "/work/other.go"] })).toEqual([]); + expect(goGenerateMenu({ paths: ["/work/app/go.mod"] })).toEqual([]); + }); +}); diff --git a/src/features/run/goGenerate.ts b/src/features/run/goGenerate.ts new file mode 100644 index 000000000..5fe735bb5 --- /dev/null +++ b/src/features/run/goGenerate.ts @@ -0,0 +1,32 @@ +import type { RunConfig } from "@/platform/ipc"; +import type { MenuGroupContext } from "@/workbench/contributions"; + +export function goGenerateConfig(path: string, scope: "file" | "package", directive?: string): RunConfig | null { + if (!path.endsWith(".go") || path.includes("\0")) return null; + const slash = path.lastIndexOf("/"); + const file = path.slice(slash + 1); + const pattern = directive ? `^${directive.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$` : null; + const target = scope === "package" ? "." : file.startsWith("-") ? `./${file}` : file; + return { + id: `go:generate:${scope}:${path}:${pattern ?? ""}`, + name: `go generate ${target}`, + kind: "go", + command: "go", + args: ["generate", ...(pattern ? ["-run", pattern] : []), target], + cwd: slash < 0 ? null : path.slice(0, slash) || "/", + env: {}, + detected: true, + isTest: false, + debugAdapter: null, + params: { useTags: "true" }, + }; +} + +export function goGenerateMenu(ctx: MenuGroupContext) { + const path = ctx.paths.length === 1 ? ctx.paths[0]! : ""; + if (!path.endsWith(".go") || ctx.dirs?.[0]) return []; + return [ + { kind: "action" as const, action: "go.generate.file", arg: { path } }, + { kind: "action" as const, action: "go.generate.package", arg: { path } }, + ]; +} diff --git a/src/features/run/goModules.ts b/src/features/run/goModules.ts index 5b55a99f1..d316e439d 100644 --- a/src/features/run/goModules.ts +++ b/src/features/run/goModules.ts @@ -8,6 +8,10 @@ import { goModuleDir } from "./producers"; const known = new Map(); +export function invalidateGoModules() { + known.clear(); +} + export function parseGoModule(gomod: string): string | null { const m = /^\s*module\s+("?)([^\s"]+)\1/m.exec(gomod); return m?.[2] ?? null; @@ -21,7 +25,7 @@ export function goModulePath(dir: string): string | null | undefined { /** Reads the `go.mod` of every Go configuration's module not read yet. */ export async function loadGoModules(root: string | null, configs: readonly RunConfig[]): Promise { if (!root || !isTauri()) return; - const dirs = new Set(configs.filter((c) => c.kind === "go").map((c) => goModuleDir(root, c.cwd))); + const dirs = new Set(configs.filter((c) => c.kind === "go").map((c) => goModuleDir(root, c.params?.module ?? c.cwd))); await Promise.all( [...dirs] .filter((d) => !known.has(d)) diff --git a/src/features/run/goTypes.test.ts b/src/features/run/goTypes.test.ts new file mode 100644 index 000000000..243849330 --- /dev/null +++ b/src/features/run/goTypes.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { goBuildType, goTestType } from "./goTypes"; +import { formParams, newConfigOfType, withParams } from "./types"; + +describe("Go configurations", () => { + it("keeps build arguments and program arguments separate and persists the argv", () => { + const c = newConfigOfType(goBuildType, []); + const edited = withParams( + c, + { + ...c.params, + target: "./cmd/app", + toolArgs: '-tags "demo other" -race', + programArgs: 'hello "two words"', + module: "app", + }, + goBuildType, + ); + expect(edited.args).toEqual(["build", "-tags", "demo other", "-race", "./cmd/app"]); + expect(JSON.parse(edited.params!.programArgv!)).toEqual(["hello", "two words"]); + expect(edited.isTest).toBe(false); + }); + it("test configs use the existing Go kind and put test-binary flags after -args", () => { + const c = newConfigOfType(goTestType, []); + const test = withParams( + c, + { ...c.params, pattern: "^TestOne$", toolArgs: "-race", programArgs: "-test.short" }, + goBuildType, + ); + expect(test.kind).toBe("go"); + expect(test.isTest).toBe(true); + expect(test.args).toEqual(["test", "-race", "-run", "^TestOne$", ".", "-args", "-test.short"]); + }); + it("offers existing detected configs the form without rewriting old saved command lines", () => { + const c = { ...newConfigOfType(goBuildType, []), params: {}, args: ["run", "."] }; + expect(formParams(c, goBuildType)).toMatchObject({ goKind: "build", runAfterBuild: "true", target: "." }); + expect(c.args).toEqual(["run", "."]); + expect(formParams({ ...c, args: ["run", "-tags", "old", "."] }, goBuildType)).toBeNull(); + }); +}); diff --git a/src/features/run/goTypes.ts b/src/features/run/goTypes.ts new file mode 100644 index 000000000..5f035f8da --- /dev/null +++ b/src/features/run/goTypes.ts @@ -0,0 +1,99 @@ +import type { RunConfig } from "@/platform/ipc"; +import type { RunConfigTypeContribution } from "@/workbench/contributions"; +import { parseArgs } from "./configs"; + +const scopes = ["Directory", "File", "Package"].map((label) => ({ value: label.toLowerCase(), label })); +const fields: RunConfigTypeContribution["fields"] = [ + { + key: "goKind", + label: "Go configuration", + control: "select", + options: [ + { value: "build", label: "Go Build" }, + { value: "test", label: "Go Test" }, + ], + }, + { key: "scope", label: "Run kind", control: "select", options: scopes }, + { + key: "target", + label: "Target", + mono: true, + required: true, + validate: (value, p) => + p.goKind === "test" && p.scope === "file" && value.includes("|") ? "Select one Go test file" : null, + }, + { key: "outputDir", label: "Output directory", mono: true, visible: (p) => p.goKind !== "test" }, + { key: "runAfterBuild", label: "Run after build", control: "checkbox", visible: (p) => p.goKind !== "test" }, + { key: "pattern", label: "Pattern", mono: true, visible: (p) => p.goKind === "test" }, + { key: "toolArgs", label: "Go tool arguments", mono: true }, + { key: "useTags", label: "Use all custom build tags", control: "checkbox" }, + { key: "programArgs", label: "Program arguments", mono: true }, + { + key: "module", + label: "Module", + mono: true, + placeholder: "Project root", + suggestions: (configs) => [ + ...new Set(configs.filter((c) => c.kind === "go").map((c) => c.params?.module ?? c.cwd ?? "")), + ], + }, +]; + +function commandLine(p: Record) { + const test = p.goKind === "test"; + const tool = parseArgs(p.toolArgs ?? ""); + const target = p.target?.trim() || "."; + const targets = + p.scope === "file" + ? target + .split("|") + .map((f) => f.trim()) + .filter(Boolean) + : [target]; + return { + command: p.executable || "go", + args: test + ? [ + "test", + ...tool, + ...(p.pattern ? ["-run", p.pattern] : []), + ...targets, + ...(p.programArgs?.trim() ? ["-args", ...parseArgs(p.programArgs)] : []), + ] + : ["build", ...tool, ...targets], + }; +} + +function paramsFrom(c: RunConfig): Record | null { + const [op, ...args] = c.args; + // Old arbitrary command lines remain available in the shared command form. + if (!["run", "build", "test"].includes(op ?? "") || args.length !== 1 || args[0]!.startsWith("-")) return null; + return { + goKind: op === "test" ? "test" : "build", + scope: "package", + target: args[0]!, + runAfterBuild: op === "run" ? "true" : "false", + module: c.cwd ?? "", + executable: c.command, + }; +} + +export const goBuildType: RunConfigTypeContribution = { + id: "go", + label: "Go Build", + icon: "file-go", + description: "Build and run a Go application", + defaults: { goKind: "build", scope: "package", target: ".", runAfterBuild: "true", useTags: "true" }, + debugAdapter: "delve", + terminal: false, + fields, + commandLine, + paramsFrom, +}; +export const goTestType: RunConfigTypeContribution = { + ...goBuildType, + id: "go-test", + label: "Go Test", + description: "Run Go tests", + defaults: { goKind: "test", scope: "package", target: ".", useTags: "true" }, +}; diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 1a5cd1ac6..90b0249e4 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -35,9 +35,11 @@ import { debugWithAgent, jdwpLaunch } from "./javaDebug"; import { RunDialogsOverlay } from "./dialogs"; import { registerExternalTools } from "./externalTools"; import { EditConfigsOverlay } from "./EditConfigsDialog"; +import { registerGoActions } from "./goActions"; +import { invalidateGoModules } from "./goModules"; import { RunWidget } from "./RunWidget"; import { runConfigsRunAnything } from "./runAnything"; -import { runServiceProvider } from "./services"; +import { recordRun, runServiceProvider } from "./services"; import { runBeforeLaunch } from "./beforeLaunch"; import { compoundChildren } from "./configs"; import { remoteDebugBlocked } from "./remote"; @@ -104,6 +106,7 @@ async function debugConfig(config: RunConfig, chain: readonly string[] = []) { } if (remoteDebugBlocked(config)) return; if (!(await prepareLaunch(config, chain))) return; + recordRun(config.id); // Remote JVM Debug, Go Remote…: the debugger attaches, nothing is launched here. if (isDebugOnly(config) && getAction("debug.startConfig")) { await executeAction("debug.startConfig", config); @@ -245,7 +248,7 @@ function useRunningCount(): number | null { } const CONFIG_FILES = - /(?:^|\/)(?:\.openidea\/run\.toml|package\.json|deno\.jsonc?|Cargo\.toml|build\.gradle(?:\.kts)?|pom\.xml|go\.mod|pyproject\.toml|setup\.cfg|pytest\.ini|manage\.py|Makefile|justfile)$/; + /(?:^|\/)(?:\.openidea\/run\.toml|package\.json|deno\.jsonc?|Cargo\.toml|build\.gradle(?:\.kts)?|pom\.xml|go\.(?:mod|work)|pyproject\.toml|setup\.cfg|pytest\.ini|manage\.py|Makefile|justfile|CMakeLists\.txt|CMakeCache\.txt|index-[^/]+\.json)$/; let reloadTimer: ReturnType | undefined; function scheduleReload() { @@ -269,6 +272,7 @@ export function activate(): () => void { registerTitleItem({ id: "run.widget", title: "Run Widget", align: "right", priority: 0, component: RunWidget }), registerRunAnythingProvider(runConfigsRunAnything), registerBuiltinTypes(), + registerGoActions(), registerOverlay({ id: "run.chooser", component: ConfigChooserOverlay }), registerOverlay({ id: "run.editConfigs", component: EditConfigsOverlay }), registerOverlay({ id: "run.dialogs", component: RunDialogsOverlay }), @@ -527,8 +531,12 @@ export function activate(): () => void { disposers.push( onIdeEvent("fsChanged", (e) => { const paths = [...e.created, ...e.modified, ...e.removed]; + if (paths.some((p) => /(?:^|\/)go\.(?:mod|work)$/.test(p))) invalidateGoModules(); if (paths.some((p) => CONFIG_FILES.test(p))) scheduleReload(); }), + onIdeEvent("runExited", (e) => { + if (useRun.getState().sessions.some((s) => s.runId === e.run && s.config.kind === "cmake")) scheduleReload(); + }), // Detected configs carry the selected toolchains (interpreter, JDK, Maven home and options). onIdeEvent("settingsChanged", (e) => { if (e.key === "languages" || e.key.startsWith("languages.")) scheduleReload(); diff --git a/src/features/run/producers.test.ts b/src/features/run/producers.test.ts index d9ee794d1..64a102519 100644 --- a/src/features/run/producers.test.ts +++ b/src/features/run/producers.test.ts @@ -385,7 +385,8 @@ describe("single-file producers (scratches)", () => { ); const cpp = produceContext(scratch("a.cpp", "int main() {}"), env({ "g++": null }))!; expect(cpp.config!.command).toMatch(/\/bin\/g\+\+ -g -std=c\+\+17 -o/); - expect(cpp.debug!.config.debugAdapter).toBe("codelldb"); + expect(cpp.debug!.config.debugAdapter).toBe("lldb-dap"); + expect(c.debug!.config.debugAdapter).toBe("lldb-dap"); expect(produceContext(scratch("lib.c", "int add(int a, int b);"), ALL)).toBeNull(); }); @@ -455,6 +456,20 @@ describe("project producers", () => { expect(at(7)).toMatchObject({ name: "m.test.ts", scope: "file" }); }); + it("does not offer configurations produced by an earlier run as choices", () => { + const earlier = cfg({ + id: "context:target:npm:test:src/m.test.ts:other", + name: "other", + kind: "npm", + command: "npm", + args: ["run", "test", "--", "src/m.test.ts"], + isTest: true, + }); + const src = "describe('math', () => {\n it('adds', () => {\n });\n});\n"; + const input = { path: `${ROOT}/src/m.test.ts`, root: ROOT, configs: [...configs, earlier], content: src, line: 1 }; + expect(produceContextChoices(input, ALL).map((c) => c.name)).toEqual(["math > adds"]); + }); + it("several equally close test configurations: one choice each, the default first", () => { const withUnit = [ ...configs, @@ -747,3 +762,44 @@ describe("Go test names (GoLand)", () => { expect(p.config!.args).toEqual(["test", ".", "-run", "^TestAdd$"]); }); }); + +describe("CMake file context", () => { + const configure = cfg({ + id: "cmake:build:configure", + kind: "cmake", + command: "cmake", + args: ["-S", ".", "-B", "build"], + }); + const native = cfg({ + id: "cmake:build:Debug:app", + name: "app", + kind: "native", + command: "/p/build/app", + debugAdapter: "lldb-dap", + params: { cmakeSources: "main.cpp\nmath.cpp" }, + beforeLaunch: [{ kind: "run", target: "cmake:build:Debug:app:build" }], + }); + const input = { path: "/p/main.cpp", root: "/p", configs: [configure, native], content: "int main() {}", line: 0 }; + it("runs the owning executable and keeps its build task", () => { + expect(produceContext(input, OPTIMISTIC)?.config).toEqual(native); + }); + it("matches absolute source paths from a linked workspace member", () => { + const target = { ...native, params: { cmakeSources: "/workspace/native/main.cpp" } }; + const p = produceContext( + { ...input, path: "/workspace/native/main.cpp", root: "/workspace", configs: [configure, target] }, + OPTIMISTIC, + ); + expect(p?.config).toEqual(target); + }); + it("offers all executable targets sharing the source", () => { + const second = { ...native, id: "other", name: "other" }; + expect( + produceContextChoices({ ...input, configs: [configure, native, second] }, OPTIMISTIC).map((p) => p.config?.id), + ).toEqual([native.id, second.id]); + }); + it("never compiles a CMake source as a standalone file when the target is unknown", () => { + const p = produceContext({ ...input, configs: [configure] }, OPTIMISTIC); + expect(p?.config).toBeUndefined(); + expect(p?.missing?.body).toContain("CMake Configure"); + }); +}); diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index 184cd6729..6a784cad4 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -14,6 +14,8 @@ import { isTestPath } from "@/platform/languageSettings"; import { currentRoots } from "@/platform/projectRoots"; import { applicationConfig, jvmModuleDir } from "./application"; import { formatArgs } from "./configs"; +import { goGenerateConfig } from "./goGenerate"; +import { goTestParts } from "./goArguments"; import { contextConfig, fileRole, projectPython, testBasesFor, testConfigFor } from "./context"; import { isWindows } from "./jvmTypes"; import { detectRunTargets, targetConfig, targetLanguage, type RunTarget } from "./targets"; @@ -136,11 +138,12 @@ export function goTestNamed( test?: string, ): RunConfig { if (config.kind !== "go" || config.args[0] !== "test") return config; - const pkgArg = - config.args.find((a, i) => i > 0 && !a.startsWith("-") && !/^-(run|skip|bench)$/.test(config.args[i - 1]!)) ?? "."; + const pkgArg = goTestParts(config.args).targets[0] ?? "."; const recursive = /(^|\/)\.\.\.$/.test(pkgArg); const dir = pkgArg.replace(/\/?\.\.\.$/, "").replace(/^\.(?:\/|$)/, ""); - const module = input.root ? input.goModule?.(goModuleDir(input.root, config.cwd)) : undefined; + const module = input.root + ? input.goModule?.(goModuleDir(input.root, config.params?.module ?? config.cwd)) + : undefined; const clause = /^\s*package\s+(\w+)/m.exec(input.content ?? "")?.[1]; const base = module ? (dir ? `${module}/${dir}` : module) : (clause ?? (dir || ".")); const pkg = recursive ? `${base}/...` : base; @@ -479,7 +482,7 @@ function compiled( const run = lang === "kotlin" ? [java!.command, "-jar", out] : [out, ...runArgs]; const kind = lang === "rust" ? "rustc" : lang === "kotlin" ? "kotlinc" : "cc"; const idPath = test ? `${s.idPath}#${test.name}` : s.idPath; - const debugAdapter = lang === "kotlin" ? null : "codelldb"; + const debugAdapter = lang === "kotlin" ? null : lang === "rust" ? "codelldb" : "lldb-dap"; const config: RunConfig = { ...fileConfig(s, kind, `${build} && ${shellLine(run, win)}`, [], debugAdapter), id: `context:file:${idPath}`, @@ -572,6 +575,19 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null const rel = s.idPath; if (s.inProject) { + if (lang === "c" || lang === "cpp") { + const target = cmakeTargets(input)[0]; + if (target) return { name: target.name, scope: "file", config: target }; + if (configs.some((c) => c.kind === "cmake")) + return { + name: s.file, + scope: "file", + missing: { + title: "No CMake executable target for this file", + body: "Run CMake Configure from the Run selector, then choose the executable target. Files in a CMake project are built with their target.", + }, + }; + } if (/\.t?scn$/i.test(rel)) return godotScene(rel, configs); // Project-aware: test files through the project's test runner, Cargo / Go / Gradle / Maven mains. const role = fileRole(rel, input.content); @@ -769,7 +785,7 @@ export function innermostTarget( let best: RunTarget | null = null; for (const t of targets) { if (t.line > line) continue; - if (line > targetEnd(lines, t, python)) continue; + if (t.kind === "generate" ? line !== t.line : line > targetEnd(lines, t, python)) continue; if (!best || t.line >= best.line) best = t; } return best; @@ -813,6 +829,10 @@ export function fileTargets(input: Pick 1) return cmake.map((config) => ({ name: config.name, scope: produced.scope, config })); const bases = testBasesFor(rel, [...input.configs]); if (bases.length < 2) return [produced]; let element: RunTarget | null = null; if (produced.scope === "element") { const targets = fileTargets(input, (input.content ?? "").slice(0, 400_000)); element = innermostTarget(input.content ?? "", targets, input.line ?? 0, /\.py$/.test(input.path)); - if (!element || element.kind === "main") return [produced]; + if (!element || element.kind === "main" || element.kind === "generate") return [produced]; } else if (!produced.config.isTest) { return [produced]; } @@ -883,6 +905,14 @@ export function produceContextChoices(input: ContextInput, env: ToolEnv): Produc return out.length > 1 ? out : [produced]; } +function cmakeTargets(input: ContextInput): RunConfig[] { + if (!inProject(input.path, input.root)) return []; + const lang = scriptLanguage(input.path, input.content); + if (lang !== "c" && lang !== "cpp") return []; + const rel = relTo(input.path, input.root); + return input.configs.filter((c) => c.params?.cmakeSources?.split("\n").some((p) => p === rel || p === input.path)); +} + // --------------------------------------------------------------------------- // Directories (project view): "Run 'Tests in 'dir''", "Run 'All Tests'" diff --git a/src/features/run/remote.test.tsx b/src/features/run/remote.test.tsx index 270841f6d..d18cd2b3f 100644 --- a/src/features/run/remote.test.tsx +++ b/src/features/run/remote.test.tsx @@ -50,6 +50,7 @@ import { activate } from "./index"; import { remoteDebugBlocked, remoteLabel, startRemedy, startWithRemedies, withTarget, type RemedyDeps } from "./remote"; import { resetRuns, saveUserConfigs, startRun, useRun } from "./store"; import { newConfigOfType } from "./types"; +import { readRan } from "./services"; const cfg = (id: string, extra: Partial = {}): RunConfig => ({ id, @@ -175,6 +176,14 @@ describe("remote debugging", () => { await executeAction("run.debugConfig", local.id); expect(startConfig).toHaveBeenCalledWith(local); }); + + it("remembers a non-Spring-Boot configuration accepted for debugging", async () => { + useProject.setState({ project: { root: "/remember-debug" } as never }); + const local = cfg("python:debug", { debugAdapter: "debugpy" }); + useRun.setState({ configs: [local], loaded: true }); + await executeAction("run.debugConfig", local.id); + expect(readRan("/remember-debug")).toContain(local.id); + }); }); describe("SSH remedies when starting", () => { diff --git a/src/features/run/resolveCancellation.test.ts b/src/features/run/resolveCancellation.test.ts index 51702fabf..d023a1b23 100644 --- a/src/features/run/resolveCancellation.test.ts +++ b/src/features/run/resolveCancellation.test.ts @@ -72,6 +72,7 @@ describe("Stop during Java launch resolution", () => { expect(request).toEqual(expect.any(Number)); await stopSession(session.id); expect(ipc.cancelRequest).toHaveBeenCalledExactlyOnceWith(request); + expect(useRun.getState().sessions[0]?.status).toBe("finished"); resolve( response === "cancelled" ? { status: "error", error: { kind: "cancelled", message: "request cancelled" } } diff --git a/src/features/run/runGutter.ts b/src/features/run/runGutter.ts index db23f0997..63836c683 100644 --- a/src/features/run/runGutter.ts +++ b/src/features/run/runGutter.ts @@ -81,6 +81,7 @@ async function launch(target: RunTarget, path: string, debug: boolean, content: } export function targetTitle(t: RunTarget): string { + if (t.kind === "generate") return tx`Run Go Generate`; if (t.kind === "main") return "Run"; if (t.kind === "suite") return "Run tests"; return "Run test"; @@ -133,7 +134,7 @@ const fileRootedResults = (path: string) => /\.(py|[cm]?[jt]sx?)$/.test(path); /** Last test result of a gutter target (IDEA: green / red run icons after a test run). */ export function gutterTestState(target: RunTarget, path: string): TestState | null { - if (target.kind === "main") return null; + if (target.kind === "main" || target.kind === "generate") return null; return testState({ path: target.path, file: fileRootedResults(path) ? path : undefined }); } diff --git a/src/features/run/segments.test.ts b/src/features/run/segments.test.ts new file mode 100644 index 000000000..1061924c0 --- /dev/null +++ b/src/features/run/segments.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { segmentLine } from "./segments"; + +describe("segmentLine marks", () => { + it("splits a line around matches", () => { + expect( + segmentLine( + [{ text: "hello world" }], + [], + [ + { start: 0, end: 5 }, + { start: 6, end: 11 }, + ], + ), + ).toEqual([ + { text: "hello", style: {}, mark: true, current: false }, + { text: " ", style: {} }, + { text: "world", style: {}, mark: true, current: false }, + ]); + }); + + it("marks the current match and keeps its span style", () => { + expect(segmentLine([{ text: "red", fg: "#f00" }], [], [{ start: 0, end: 3, current: true }])).toEqual([ + { text: "red", style: { fg: "#f00" }, mark: true, current: true }, + ]); + }); + + it("splits matches inside links", () => { + const link = { kind: "url" as const, url: "http://x", text: "http://x", start: 4, end: 12 }; + expect(segmentLine([{ text: "see http://x" }], [link], [{ start: 8, end: 12 }])).toEqual([ + { text: "see ", style: {} }, + { text: "http", style: {}, link }, + { text: "://x", style: {}, link, mark: true, current: false }, + ]); + }); +}); diff --git a/src/features/run/segments.ts b/src/features/run/segments.ts index 2dea20951..f4f59f46d 100644 --- a/src/features/run/segments.ts +++ b/src/features/run/segments.ts @@ -1,36 +1,74 @@ -/** Splits a console line's styled spans at link boundaries for rendering. */ +/** Splits a console line's styled spans at link and find-match boundaries for rendering. */ import type { Span, SpanStyle } from "./console"; import type { OutputLink } from "./links"; +/** A find match on one line, in UTF-16 offsets. */ +export interface SegmentMark { + start: number; + end: number; + current?: boolean; +} + export interface Segment { text: string; style: SpanStyle; link?: OutputLink; + mark?: boolean; + current?: boolean; } -export function segmentLine(spans: readonly Span[], links: readonly OutputLink[]): Segment[] { +export function segmentLine( + spans: readonly Span[], + links: readonly OutputLink[], + marks: readonly SegmentMark[] = [], +): Segment[] { const out: Segment[] = []; let pos = 0; let li = 0; + let mi = 0; for (const span of spans) { const { text, ...style } = span; let offset = 0; while (offset < text.length) { const abs = pos + offset; while (li < links.length && links[li]!.end <= abs) li++; + while (mi < marks.length && marks[mi]!.end <= abs) mi++; const link = links[li]; - let take: number; - let current: OutputLink | undefined; + const mark = marks[mi]; + let take = text.length - offset; + let currentLink: OutputLink | undefined; + let currentMark: SegmentMark | undefined; if (link && link.start <= abs) { - current = link; - take = Math.min(text.length - offset, link.end - abs); - } else { - take = Math.min(text.length - offset, (link ? link.start : Infinity) - abs); + currentLink = link; + take = Math.min(take, link.end - abs); + } else if (link) { + take = Math.min(take, link.start - abs); + } + if (mark && mark.start <= abs) { + currentMark = mark; + take = Math.min(take, mark.end - abs); + } else if (mark) { + take = Math.min(take, mark.start - abs); } const piece = text.slice(offset, offset + take); const prev = out[out.length - 1]; - if (prev && prev.link === current && sameStyle(prev.style, style)) prev.text += piece; - else out.push(current ? { text: piece, style, link: current } : { text: piece, style }); + const current = !!currentMark?.current; + if ( + prev && + prev.link === currentLink && + !!prev.mark === !!currentMark && + !!prev.current === current && + sameStyle(prev.style, style) + ) { + prev.text += piece; + } else { + out.push({ + text: piece, + style, + link: currentLink, + ...(currentMark ? { mark: true, current } : {}), + }); + } offset += take; } pos += text.length; diff --git a/src/features/run/serviceSessions.test.ts b/src/features/run/serviceSessions.test.ts new file mode 100644 index 000000000..4bac8b315 --- /dev/null +++ b/src/features/run/serviceSessions.test.ts @@ -0,0 +1,174 @@ +import { expect, it, vi } from "vitest"; +import type { RunConfig } from "@/platform/ipc"; +import type { DebugServiceSession } from "@/workbench/contributions"; +import type { RunSession } from "./store"; +import { currentServiceSession, serviceDebugger, serviceSessions, stopServiceSessions } from "./serviceSessions"; + +const { stopRun, waitForExit, getSession } = vi.hoisted(() => ({ + stopRun: vi.fn(), + waitForExit: vi.fn(), + getSession: vi.fn(), +})); +vi.mock("./store", () => ({ + stopSession: stopRun, + waitForExit, + useRun: { getState: () => ({ sessions: [getSession()].filter(Boolean) }) }, +})); +const config = (id = "app"): RunConfig => ({ + id, + name: "same name", + kind: "spring-boot", + command: "java", + args: [], + cwd: null, + env: {}, + detected: true, + isTest: false, + debugAdapter: "java-debug", +}); +const run = (id: string, startedAt: number, extra: Partial = {}): RunSession => ({ + id, + config: config(), + startedAt, + runId: 7, + status: "running", + endedAt: null, + exitCode: null, + stdinClosed: false, + ...extra, +}); +const debug = (id: number, startedAt: number, extra: Partial = {}): DebugServiceSession => ({ + id, + config: config(), + startedAt, + status: "running", + processId: null, + stop: vi.fn(async () => {}), + rerun: vi.fn(), + show: vi.fn(), + ...extra, +}); + +it("prefers a live debug launch to stale Run output and isolates equal names by config id", () => { + const d = debug(1, 2); + const sessions = serviceSessions( + [run("old", 1, { status: "finished" })], + [d, debug(2, 3, { config: config("other") })], + ); + expect(currentServiceSession("app", sessions)?.debug).toEqual([d]); + expect(currentServiceSession("other", sessions)?.debug[0]?.id).toBe(2); +}); + +it("keeps an older live instance ahead of a newer finished one and uses stable tie breaking", () => { + const active = debug(1, 1); + const sessions = serviceSessions([run("done", 10, { status: "finished" })], [active]); + expect(currentServiceSession("app", sessions)?.debug).toEqual([active]); + const tied = serviceSessions([run("a", 1)], [active]); + expect(currentServiceSession("app", tied)?.debug).toEqual([active]); + expect(currentServiceSession("app", [...tied].reverse())?.debug).toEqual([active]); +}); + +it("pairs all debugger roots with the same Run process without guessing by config id alone", () => { + const r = run("maven", 1); + const d = [debug(1, 2, { processId: 7 }), debug(2, 3, { processId: 7 })]; + const sessions = serviceSessions([r], [...d, debug(3, 4, { processId: 8 })]); + expect(sessions).toHaveLength(2); + expect(sessions[0]).toMatchObject({ run: r, debug: d, startedAt: 1 }); + expect(serviceDebugger(sessions[0])).toBe(d[1]); + expect(serviceSessions([r], [debug(4, 5, { config: config("other"), processId: 7 })])).toHaveLength(2); +}); + +it("falls back to Run controls when its attached debugger has terminated", () => { + const sessions = serviceSessions([run("maven", 1)], [debug(1, 2, { processId: 7, status: "terminated" })]); + expect(serviceDebugger(sessions[0])).toBeUndefined(); + expect(currentServiceSession("app", sessions)?.run?.id).toBe("maven"); +}); + +it("stops the displayed execution without stopping another parallel instance", async () => { + const first = debug(1, 1); + const latest = debug(2, 2); + const selected = currentServiceSession("app", serviceSessions([], [first, latest]))!; + await stopServiceSessions([selected]); + expect(latest.stop).toHaveBeenCalledOnce(); + expect(first.stop).not.toHaveBeenCalled(); +}); + +it("awaits shared debugger stops without a retained Run and terminates their process only once", async () => { + stopRun.mockClear(); + const calls: string[] = []; + const first = debug(1, 1, { + processId: 7, + stop: vi.fn(async (options) => { + await Promise.resolve(); + calls.push(options?.stopProcess === false ? "detach first" : "stop process first"); + }), + }); + const last = debug(2, 2, { + processId: 7, + stop: vi.fn(async (options) => { + calls.push(options?.stopProcess === false ? "detach last" : "stop process last"); + }), + }); + const targets = serviceSessions([], [first, last]); + await stopServiceSessions([...targets, ...targets]); + expect(calls).toEqual(["detach first", "stop process last"]); + expect(stopRun).not.toHaveBeenCalled(); +}); + +it("stops the owning Run before detaching shared roots so JDWP cannot trigger another attachment", async () => { + stopRun.mockClear(); + let processAlive = true; + let reattachments = 0; + const calls: string[] = []; + stopRun.mockImplementationOnce(async () => { + calls.push("stop run"); + }); + let exit!: () => void; + waitForExit.mockImplementationOnce( + () => + new Promise((resolve) => { + exit = () => { + processAlive = false; + resolve(); + }; + }), + ); + getSession.mockImplementationOnce(() => run("maven", 0, { status: "finished" })); + const roots = [1, 2].map((id) => + debug(id, id, { + processId: 7, + stop: vi.fn(async () => { + await Promise.resolve(); + if (processAlive) reattachments++; + calls.push(`detach ${id}`); + }), + }), + ); + const targets = serviceSessions([run("maven", 0)], roots); + const stopping = stopServiceSessions([...targets, ...targets]); + await vi.waitFor(() => expect(stopRun).toHaveBeenCalledOnce()); + for (const root of roots) expect(root.stop).not.toHaveBeenCalled(); + exit(); + await stopping; + expect(reattachments).toBe(0); + expect(calls).toEqual(["stop run", "detach 1", "detach 2"]); + expect(stopRun).toHaveBeenCalledExactlyOnceWith("maven"); + for (const root of roots) expect(root.stop).toHaveBeenCalledExactlyOnceWith({ stopProcess: false }); +}); + +it("stops all group executions including unpaired Run and ignores finished roots", async () => { + stopRun.mockClear(); + const done = debug(3, 1, { status: "terminated" }); + const running = debug(4, 2); + await stopServiceSessions(serviceSessions([run("plain", 1)], [done, running])); + expect(stopRun).toHaveBeenCalledWith("plain"); + expect(running.stop).toHaveBeenCalledOnce(); + expect(done.stop).not.toHaveBeenCalled(); +}); + +it("does not detach a JVM still running after the exit wait expires", async () => { + getSession.mockReturnValueOnce(run("maven", 0)); + const root = debug(1, 1, { processId: 7 }); + await stopServiceSessions(serviceSessions([run("maven", 0)], [root])); + expect(root.stop).not.toHaveBeenCalled(); +}); diff --git a/src/features/run/serviceSessions.ts b/src/features/run/serviceSessions.ts new file mode 100644 index 000000000..49f217b7c --- /dev/null +++ b/src/features/run/serviceSessions.ts @@ -0,0 +1,97 @@ +import type { RunConfig } from "@/platform/ipc"; +import type { DebugServiceSession } from "@/workbench/contributions"; +import { stopSession, useRun, waitForExit, type RunSession } from "./store"; + +/** One execution: a Run process and its attached debuggers, or a direct debug launch. */ +export interface ServiceSession { + config: RunConfig; + startedAt: number; + run?: RunSession; + debug: DebugServiceSession[]; +} + +const running = (s: RunSession) => s.status === "running" || s.status === "starting"; +const debugging = (s: DebugServiceSession) => s.status !== "terminated"; + +export function serviceSessions(runs: readonly RunSession[], debug: readonly DebugServiceSession[]): ServiceSession[] { + const out = runs.map((run): ServiceSession => ({ config: run.config, startedAt: run.startedAt, run, debug: [] })); + const processes = new Map(out.filter((s) => s.run!.runId !== null).map((s) => [s.run!.runId, s])); + for (const session of debug) { + const owner = session.processId === null ? undefined : processes.get(session.processId); + if (owner && owner.config.id === session.config.id) owner.debug.push(session); + else out.push({ config: session.config, startedAt: session.startedAt, debug: [session] }); + } + return out; +} + +export function liveServiceSession(session: ServiceSession): boolean { + return !!(session.run && running(session.run)) || session.debug.some(debugging); +} + +/** Only a live debugger owns controls; retained output alone cannot claim a live Run. */ +export function serviceDebugger(session: ServiceSession | undefined): DebugServiceSession | undefined { + return session?.debug.filter(debugging).sort((a, b) => b.startedAt - a.startedAt || b.id - a.id)[0]; +} + +function compareSessions(a: ServiceSession, b: ServiceSession): number { + const key = (s: ServiceSession) => s.run?.id ?? `debug:${s.debug[0]!.id}`; + return ( + Number(liveServiceSession(b)) - Number(liveServiceSession(a)) || + b.startedAt - a.startedAt || + Number(b.debug.length > 0) - Number(a.debug.length > 0) || + key(a).localeCompare(key(b)) + ); +} + +export function currentServiceSessions(sessions: readonly ServiceSession[]): Map { + const out = new Map(); + for (const session of sessions) { + const previous = out.get(session.config.id); + if (!previous || compareSessions(session, previous) < 0) out.set(session.config.id, session); + } + return out; +} + +export function currentServiceSession( + configId: string, + sessions: readonly ServiceSession[], +): ServiceSession | undefined { + return currentServiceSessions(sessions).get(configId); +} + +/** A build process may expose several JDWP ports, each with its own debugger root. */ +export async function stopServiceSessions(sessions: readonly ServiceSession[]): Promise { + const roots = new Map(); + const runs = new Map(); + for (const s of sessions) { + for (const d of s.debug) if (debugging(d)) roots.set(d.id, d); + if (s.run && running(s.run)) runs.set(s.run.id, s.run); + } + const groups = new Map(); + for (const d of roots.values()) { + if (d.processId === null) await d.stop(); + else { + const group = groups.get(d.processId) ?? []; + group.push(d); + groups.set(d.processId, group); + } + } + for (const [processId, group] of groups) { + const run = [...runs.values()].find((s) => s.runId === processId); + if (run) { + // Detaching a live JVM republishes its JDWP port and can trigger another attachment. + await stopSession(run.id); + await waitForExit([run.id]); + if (useRun.getState().sessions.some((s) => s.id === run.id && running(s))) { + runs.delete(run.id); + continue; + } + for (const d of group) await d.stop({ stopProcess: false }); + } else { + for (const d of group.slice(0, -1)) await d.stop({ stopProcess: false }); + await group.at(-1)!.stop(); + } + for (const [id, run] of runs) if (run.runId === processId) runs.delete(id); + } + for (const run of runs.values()) await stopSession(run.id); +} diff --git a/src/features/run/services.test.ts b/src/features/run/services.test.ts index ae2106045..8a5ceb93e 100644 --- a/src/features/run/services.test.ts +++ b/src/features/run/services.test.ts @@ -3,6 +3,7 @@ import type { RunConfig } from "@/platform/ipc"; import { DEFAULT_TYPES, readRan, readTypes, runServiceItems, sessionDetail, sessionStatus } from "./services"; import { startedPort } from "./serverPort"; import type { RunSession } from "./store"; +import type { DebugServiceSession } from "@/workbench/contributions"; const config = (id: string, kind: string): RunConfig => ({ id, @@ -41,6 +42,56 @@ describe("run configurations in Services", () => { const dev = config("dev", "npm"); const scratch = config("context:file", "python"); + it.each(["spring-boot", "node", "python", "go", "cargo"])( + "shows configured %s debug roots without a Run session", + (kind) => { + const c = config("debug-app", kind); + const d: DebugServiceSession = { + id: 1, + config: c, + status: "running", + processId: null, + startedAt: 2, + stop: async () => {}, + rerun: null, + show: () => {}, + }; + const build = (status: DebugServiceSession["status"]) => runServiceItems([c], [], [], [], {}, [{ ...d, status }]); + expect(build("running")[0]?.children?.[0]).toMatchObject({ + key: "run:config:debug-app", + detail: "Debugging", + icon: "debug", + dim: false, + }); + expect(build("paused")[0]?.children?.[0]?.detail).toBe("Suspended"); + expect(build("terminated")[0]?.children?.[0]).toMatchObject({ detail: "Finished", dim: true }); + }, + ); + + it("does not reuse an old Run port for a new direct debug launch or double-count an attachment", () => { + const c = config("app", "spring-boot"); + const r = session("run", c, "finished", 1, { runId: 7, httpPort: 8080 }); + const d: DebugServiceSession = { + id: 1, + config: c, + status: "running", + processId: null, + startedAt: 2, + stop: async () => {}, + rerun: null, + show: () => {}, + }; + const direct = runServiceItems([c], [r], DEFAULT_TYPES, [], {}, [d]); + expect(direct[0]?.detail).toBe("1 running"); + expect(direct[0]?.children?.[0]?.detail).toBe("Debugging"); + const attached = runServiceItems([c], [{ ...r, status: "running" }], DEFAULT_TYPES, [], {}, [ + { ...d, processId: 7 }, + ]); + expect(attached[0]?.detail).toBe("1 running"); + expect(attached[0]?.children).toHaveLength(1); + expect(attached[0]?.children?.[0]?.detail).toBe(":8080"); + }); + it("applies display order to detected and saved configurations of every language", () => { const configs = [ config("java-a", "spring-boot"), diff --git a/src/features/run/services.tsx b/src/features/run/services.tsx index d11d69937..7f13a708f 100644 --- a/src/features/run/services.tsx +++ b/src/features/run/services.tsx @@ -19,13 +19,24 @@ import { toolWindowService, notifications, useRunConfigTypes, + useDebugRunSessions, + type DebugServiceSession, type ServiceItemAction, type ServiceProviderContribution, type ServiceTreeItem, } from "@/workbench/contributions"; import { commandLine, configIcon, KIND_OPTIONS, kindLabel } from "./configs"; import { SessionPanel } from "./RunView"; -import { startRun, stopSession, useRun, type RunSession } from "./store"; +import { startRun, useRun, type RunSession } from "./store"; +import { + currentServiceSession, + currentServiceSessions, + liveServiceSession, + serviceDebugger, + serviceSessions, + stopServiceSessions, + type ServiceSession, +} from "./serviceSessions"; import { isDebugOnly } from "./types"; import { currentServiceOrder, @@ -129,16 +140,6 @@ export function recordRun(configId: string) { const busy = (s: RunSession) => s.status === "running" || s.status === "starting"; -/** The newest session of each configuration id. */ -function latestSessions(sessions: readonly RunSession[]): Map { - const out = new Map(); - for (const s of sessions) { - const prev = out.get(s.config.id); - if (!prev || s.startedAt >= prev.startedAt) out.set(s.config.id, s); - } - return out; -} - export function sessionStatus(s: RunSession | undefined): string | undefined { if (!s) return undefined; if (busy(s)) return s.status === "starting" ? "Starting" : "Running"; @@ -152,6 +153,19 @@ export function sessionDetail(s: RunSession | undefined): string | undefined { return s && busy(s) && s.httpPort ? `:${s.httpPort}` : sessionStatus(s); } +function executionDetail(s: ServiceSession | undefined): string | undefined { + if (!s) return undefined; + const debug = serviceDebugger(s); + if (debug?.status === "paused") return "Suspended"; + if (debug) + return s.run && busy(s.run) && s.run.httpPort + ? `:${s.run.httpPort}` + : debug.status === "starting" + ? "Starting" + : "Debugging"; + return s.run ? sessionDetail(s.run) : "Finished"; +} + /** * Groups by configuration type: added types list all their configurations, other * types only those that ran. A default type (Spring Boot) without configurations is hidden. @@ -162,10 +176,12 @@ export function runServiceItems( added: readonly string[], ran: readonly string[] = [], order: ServiceOrder = {}, + debug: readonly DebugServiceSession[] = [], ): ServiceTreeItem[] { - const latest = latestSessions(sessions); + const executions = serviceSessions(sessions, debug); + const latest = currentServiceSessions(executions); const kinds = [...added]; - for (const s of sessions) if (!kinds.includes(s.config.kind)) kinds.push(s.config.kind); + for (const s of executions) if (!kinds.includes(s.config.kind)) kinds.push(s.config.kind); for (const id of ran) { const kind = configs.find((c) => c.id === id)?.kind; if (kind && !kinds.includes(kind)) kinds.push(kind); @@ -182,14 +198,14 @@ export function runServiceItems( return { key: CONFIG_PREFIX + c.id, label: c.name, - icon: s && busy(s) ? "run" : configIcon(c), - detail: sessionDetail(s), - dim: !s || !busy(s), + icon: serviceDebugger(s) ? "debug" : s && liveServiceSession(s) ? "run" : configIcon(c), + detail: executionDetail(s), + dim: !s || !liveServiceSession(s), }; }); const running = entries.filter((c) => { const s = latest.get(c.id); - return !!s && busy(s); + return !!s && liveServiceSession(s); }).length; return [ { @@ -210,6 +226,7 @@ function useRoots(): ServiceTreeItem[] { const order = useServiceOrder(projectLocation(root)); const configs = useRun((s) => s.configs); const sessions = useRun((s) => s.sessions); + const debug = useDebugRunSessions((s) => s.serviceSessions); const types = useAddedTypes((s) => s.types); const typesRoot = useAddedTypes((s) => s.root); const ran = useRanConfigs((s) => s.ids); @@ -219,28 +236,29 @@ function useRoots(): ServiceTreeItem[] { if (ranRoot !== root) useRanConfigs.setState({ root, ids: root ? readRan(root) : [] }); }, [root, typesRoot, ranRoot]); return useMemo( - () => runServiceItems(configs, sessions, typesRoot === root ? types : [], ranRoot === root ? ran : [], order), - [configs, sessions, types, typesRoot, ran, ranRoot, root, order], + () => + runServiceItems(configs, sessions, typesRoot === root ? types : [], ranRoot === root ? ran : [], order, debug), + [configs, sessions, types, typesRoot, ran, ranRoot, root, order, debug], ); } interface Resolved { kind: string | null; config: RunConfig | null; - session: RunSession | undefined; + session: ServiceSession | undefined; /** Sessions of a group's configurations. */ - sessions: RunSession[]; + sessions: ServiceSession[]; } function resolve(item: ServiceTreeItem): Resolved { const { configs, sessions } = useRun.getState(); - const latest = latestSessions(sessions); + const executions = serviceSessions(sessions, useDebugRunSessions.getState().serviceSessions); if (item.key.startsWith(TYPE_PREFIX)) { const kind = item.key.slice(TYPE_PREFIX.length); - return { kind, config: null, session: undefined, sessions: sessions.filter((s) => s.config.kind === kind) }; + return { kind, config: null, session: undefined, sessions: executions.filter((s) => s.config.kind === kind) }; } const id = item.key.slice(CONFIG_PREFIX.length); - const session = latest.get(id); + const session = currentServiceSession(id, executions); const config = configs.find((c) => c.id === id) ?? session?.config ?? null; return { kind: config?.kind ?? null, config, session, sessions: session ? [session] : [] }; } @@ -252,36 +270,42 @@ function showOutput(s: RunSession) { /** Runs (or reruns, in its tab) a configuration; the output stays in Services instead of opening Run. */ function run(r: Resolved) { - if (r.session && r.session.config.id === r.config?.id) - void startRun(r.session.config, r.session.id, r.session.baseConfig, { show: false }); - else if (r.config) void startRun(r.config, undefined, undefined, { show: false }); + const debug = serviceDebugger(r.session); + if (debug) debug.rerun?.(); + else if (r.session?.run) { + const s = r.session.run; + void startRun(s.config, s.id, s.baseConfig, { show: false }); + } else if (r.config) void startRun(r.config, undefined, undefined, { show: false }); } function debug(r: Resolved) { - if (r.config) void executeAction("run.debugConfig", r.config); + const session = serviceDebugger(r.session); + if (session) session.rerun?.(); + else if (r.config) void executeAction("run.debugConfig", r.config); } function actions(item: ServiceTreeItem): ServiceItemAction[] { const r = resolve(item); - const running = r.sessions.filter(busy); + const running = r.sessions.filter(liveServiceSession); const stop = { id: "stop", title: "Stop", icon: "stop", disabled: !running.length, - run: () => running.forEach((s) => void stopSession(s.id)), + run: () => void stopServiceSessions(running), }; if (!r.config) return [stop]; - const live = !!r.session && busy(r.session); + const live = !!r.session && liveServiceSession(r.session); + const debugging = serviceDebugger(r.session); return [ { id: "run", title: live ? "Rerun" : "Run", icon: live ? "rerun" : "run", - disabled: isDebugOnly(r.config), + disabled: debugging ? !debugging.rerun : isDebugOnly(r.config), run: () => run(r), }, - { id: "debug", title: "Debug", icon: "debug", run: () => debug(r) }, + { id: "debug", title: "Debug", icon: "debug", disabled: !!debugging && !debugging.rerun, run: () => debug(r) }, stop, ]; } @@ -301,7 +325,8 @@ function menu(item: ServiceTreeItem): MenuItem[] { } if (r.config) { const config = r.config; - const session = r.session; + const session = r.session?.run; + const debuggerSession = serviceDebugger(r.session) ?? r.session?.debug.at(-1); items.push( { kind: "separator" }, { @@ -310,6 +335,12 @@ function menu(item: ServiceTreeItem): MenuItem[] { disabled: !session, run: () => session && showOutput(session), }, + { + label: "Show in Debug Tool Window", + icon: "toolwindow-debug", + disabled: !debuggerSession, + run: () => debuggerSession?.show(), + }, { label: "Edit Configuration…", icon: "edit", run: () => void executeAction("run.editConfigs", config.id) }, ); } @@ -319,6 +350,8 @@ function menu(item: ServiceTreeItem): MenuItem[] { function RunDetail({ item }: { item: ServiceTreeItem }) { // Re-render on session changes. useRun((s) => s.sessions); + const debugViews = useDebugRunSessions(); + const Console = debugViews.Console; const t = useT(); const txTemplate = useTxTemplate(); const r = resolve(item); @@ -333,7 +366,25 @@ function RunDetail({ item }: { item: ServiceTreeItem }) { ); // The configuration ran: its console, as in the Run tool window. - if (r.session) return ; + if (r.session?.run) { + const execution = r.session; + const d = serviceDebugger(execution); + return ( + void stopServiceSessions([execution]) : undefined} + /> + ); + } + const d = r.session?.debug[0]; + if (d && Console) + return ( +
+ +
+ ); return (
@@ -400,7 +451,7 @@ export const runServiceProvider: ServiceProviderContribution = { menu, activate: (item) => { const r = resolve(item); - if (r.config && !(r.session && busy(r.session)) && !isDebugOnly(r.config)) run(r); + if (r.config && !(r.session && liveServiceSession(r.session)) && !isDebugOnly(r.config)) run(r); }, detail: RunDetail, addMenu, @@ -425,6 +476,7 @@ export const runServiceProvider: ServiceProviderContribution = { currentTypes(), ranIds(useProject.getState().project?.root ?? null), order, + useDebugRunSessions.getState().serviceSessions, ).find((g) => g.key === TYPE_PREFIX + kind); const ids = group?.children?.map((c) => c.key.slice(CONFIG_PREFIX.length)) ?? []; const from = source.key.slice(CONFIG_PREFIX.length), diff --git a/src/features/run/store.ts b/src/features/run/store.ts index ea4011ca9..092e1e0f4 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -17,14 +17,7 @@ import { type RunId, type TestCaseReport, } from "@/platform/ipc"; -import { - findJavaModule, - formatCompileErrors, - isJvmApplication, - parseJavaArgs, - resolveJavaCommand, - resolveJavaModule, -} from "@/platform/javaLaunch"; +import { findJavaModule, isJvmApplication, parseJavaArgs, prepareJavaLaunch } from "@/platform/javaLaunch"; import { downloadJdkActions } from "@/platform/jdk"; import { useProject } from "@/platform/project"; import { executeAction } from "@/platform/actions"; @@ -41,6 +34,7 @@ import { type TestEvent, type TestOutputParser, } from "./testing"; +import { GoTestOutput } from "./testing/go"; import { recordTestStates } from "./testing/states"; import { failedTestPaths, leafTestPaths, rerunFailedConfig, testsConfig } from "./testing/rerun"; import { compoundChildren } from "./configs"; @@ -78,10 +72,11 @@ export interface RunSession { } interface SessionRuntime { - resolveRequest?: { id: RequestId; cancelled: boolean }; + resolveRequest?: { id: RequestId; controller: AbortController }; console: ConsoleModel; tests: TestResults | null; parser: TestOutputParser | null; + goOutput: GoTestOutput | null; queue: OutputChunk[]; /** rAF id while the page is visible, timeout id while hidden (rAF pauses in background tabs). */ flushHandle: { raf: number } | { timeout: number } | null; @@ -361,7 +356,10 @@ function flush(sessionId: string) { cancelFlush(rt); if (rt.queue.length === 0) return; const chunks = rt.queue.splice(0); - for (const c of chunks) rt.console.append(c); + for (const c of chunks) { + const text = c.kind === "stdout" && rt.goOutput ? rt.goOutput.feed(c.text) : c.text; + if (text) rt.console.append({ ...c, text }); + } bump(sessionId); } @@ -426,13 +424,18 @@ export function applyConsoleSettings() { function newRuntime(config: RunConfig): SessionRuntime { const tests = config.isTest ? new TestResults() : null; const parser = config.isTest ? createTestParser(config) : null; + const goOutput = + tests && parser && frameworkFor(config) === "go" + ? new GoTestOutput((line) => tests.apply(parser.feed(line))) + : null; const runtime: SessionRuntime = { + goOutput, tests, parser, console: new ConsoleModel({ maxChars: consoleCharLimit(), onLine: (text, kind) => { - if (parser && tests && kind !== "system") tests.apply(parser.feed(text)); + if (parser && tests && kind !== "system" && !(kind === "stdout" && goOutput)) tests.apply(parser.feed(text)); if (kind !== "system") for (const l of runtime.lineListeners) l(text); }, }), @@ -655,7 +658,9 @@ export async function startRun( if (launch.emulateTerminal) rt.terminal = new TerminalOutput(); patchSession(sessionId, { terminal: !!launch.emulateTerminal }); const started = launch.isTest ? withTestReporting(launch) : launch; + const processStarted = performance.now(); const info = await startWithRemedies(started, () => call(commands.runStart(started, channel))); + console.info("Run process started", { name: config.name, ms: performance.now() - processStarted }); if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null; if (info.pty && rt.terminalSize) void commands.runResize(info.id, rt.terminalSize.cols, rt.terminalSize.rows); // The process may already have exited (fast commands) before we got its id. @@ -706,20 +711,15 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom return null; } if (!parsed.classpath) return config; - say(`Building and resolving the classpath of ${parsed.mainClass}…\n`); const rt = runtime(sessionId)!; - const request = { id: nextRequestId(), cancelled: false }; + const request = { id: nextRequestId(), controller: new AbortController() }; rt.resolveRequest = request; try { - const module = await resolveJavaModule(config); - if (request.cancelled) return null; - const info = await call(commands.javaResolveLaunch(module, parsed.classpath === "test", true, request.id)); - if (request.cancelled) return null; - if (info.build === "withErrors") - say("Build completed with errors; launching with the last compiled classes.\n", "stderr"); - if (info.build === "failed") say("Build failed; launching with the last compiled classes.\n", "stderr"); - const compileReport = formatCompileErrors(info); - if (compileReport) say(compileReport, "stderr"); + const { info, resolved } = await prepareJavaLaunch(config, { + request: request.id, + signal: request.controller.signal, + report: say, + }); // No installed JDK is new enough for the module: offer to download one. const jdkHint = info.hints.find((h) => downloadJdkActions(h).length); if (jdkHint) @@ -731,14 +731,10 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom body: jdkHint, actions: downloadJdkActions(jdkHint), }); - if (!info.classPaths.length) { - say("The classpath is empty: is the module imported by the Java language server?\n", "stderr"); - return null; - } - return resolveJavaCommand(config, info); + return resolved; } catch (e) { const message = e instanceof Error ? e.message : String(e); - if (request.cancelled || isCancelled(e)) { + if (request.controller.signal.aborted || isCancelled(e)) { if (runtimes.get(sessionId) === rt && rt.resolveRequest === request) patchSession(sessionId, { stopped: true }); return null; } @@ -770,6 +766,10 @@ function finishSession(sessionId: string, code: number | null) { void (async () => { if (runtimes.get(sessionId) !== rt) return; flush(sessionId); + if (rt.goOutput) { + const text = rt.goOutput.end(); + if (text) rt.console.append({ kind: "stdout", text }); + } rt.console.flush(); if (rt.tests && rt.parser) { rt.tests.apply(rt.parser.end?.() ?? []); @@ -867,8 +867,8 @@ export async function stopSession(sessionId: string): Promise { if (!session) return; const request = runtimes.get(sessionId)?.resolveRequest; if (session.status === "starting" && request) { - request.cancelled = true; - patchSession(sessionId, { stopped: true }); + request.controller.abort(); + patchSession(sessionId, { stopped: true, status: "finished", endedAt: Date.now() }); await commands.cancelRequest(request.id); return; } diff --git a/src/features/run/targets.test.ts b/src/features/run/targets.test.ts index 5685a96b1..e0053b2e2 100644 --- a/src/features/run/targets.test.ts +++ b/src/features/run/targets.test.ts @@ -179,3 +179,61 @@ describe("targetConfig", () => { expect(c.id).toBe("context:target:cargo:test:src/lib.rs:tests/adds"); }); }); + +describe("Go test targets", () => { + it("detects benchmark, fuzz and example functions and runs a benchmark without tests", () => { + const text = + "package main\nfunc main() {}\nfunc BenchmarkWork(b *testing.B) {}\nfunc FuzzInput(f *testing.F) {}\nfunc Example() {}\n// func TestComment(t *testing.T) {}"; + const targets = detectRunTargets(text, "work_test.go"); + expect(targets.map((t) => t.name)).toEqual(["main", "BenchmarkWork", "FuzzInput", "Example"]); + const base = cfg({ id: "go:test", kind: "go", args: ["test", "-tags", "demo", "./...", "-args", "-test.short"] }); + const config = targetConfig(targets[1]!, "work_test.go", [base], text)!; + expect(config.args).toEqual([ + "test", + "-tags", + "demo", + ".", + "-run", + "^$", + "-bench", + "^BenchmarkWork$", + "-args", + "-test.short", + ]); + }); + it("uses the module and overrides a saved filter before binary arguments", () => { + const base = cfg({ + id: "go:typed", + kind: "go", + cwd: "runtime", + args: ["test", "-tags", "demo", "-run", "Old", ".", "-args", "-test.short"], + params: { + goKind: "test", + module: "app", + scope: "package", + target: ".", + pattern: "Old", + programArgs: "-test.short", + }, + }); + const text = 'package calc\nfunc TestNew(t *testing.T) {\n t.Run("child case", func(t *testing.T) {})\n}'; + const targets = detectRunTargets(text, "app/calc_test.go"); + const config = targetConfig(targets[1]!, "app/calc_test.go", [base], text, base)!; + expect(config.args).toEqual([ + "test", + "-tags", + "demo", + ".", + "-run", + "^TestNew$/^child_case$", + "-args", + "-test.short", + ]); + expect(config.params).toMatchObject({ + module: "app", + scope: "directory", + target: ".", + pattern: "^TestNew$/^child_case$", + }); + }); +}); diff --git a/src/features/run/targets.ts b/src/features/run/targets.ts index a951c6d41..2d4efff4f 100644 --- a/src/features/run/targets.ts +++ b/src/features/run/targets.ts @@ -5,13 +5,15 @@ * Line-based regex detection (cheap, no parser), plus the run configuration for * one target derived from the project's detected test configurations. */ +import { formatArgs } from "./configs"; import type { RunConfig } from "@/platform/ipc"; import { applicationConfig, jvmTestConfig } from "./application"; +import { goTestParts, withoutGoSelection } from "./goArguments"; import { contextConfig, testConfigFor } from "./context"; import { scanJvmTargets, simpleClassName } from "./jvmTargets"; import { escapeRegExp } from "./testing/rerun"; -export type TargetKind = "main" | "test" | "suite"; +export type TargetKind = "main" | "test" | "suite" | "generate"; export interface RunTarget { /** 0-based line of the declaration. */ @@ -173,10 +175,19 @@ function goTargets(lines: string[]): RunTarget[] { const isMain = lines.some((l) => /^package\s+main\b/.test(l)); for (let i = 0; i < lines.length; i++) { const line = lines[i]!; - const t = /^func\s+(Test\w*|Example\w*|Fuzz\w*)\s*\(\s*(?:\w+\s+\*testing\.[TF])?\s*\)?/.exec(line); - if (t && (/^Example/.test(t[1]!) || /\*testing\.[TF]/.test(line))) { + if (/^\/\/go:generate[ \t]+\S/.test(line)) { + out.push({ + line: i, + kind: "generate", + name: line.replace(/^\/\/go:generate[ \t]+/, "").trim(), + path: [String(i)], + }); + continue; + } + const t = /^func\s+(Test\w*|Benchmark\w*|Example\w*|Fuzz\w*)\s*\(\s*(?:\w+\s+\*testing\.[TFB])?\s*\)?/.exec(line); + if (t && (/^Example/.test(t[1]!) || /\*testing\.[TFB]/.test(line))) { out.push({ line: i, kind: "test", name: t[1]!, path: [t[1]!] }); - if (/\*testing\.T\b/.test(line)) out.push(...goSubtests(lines, i, t[1]!)); + if (/\*testing\.[TB]\b/.test(line)) out.push(...goSubtests(lines, i, t[1]!)); continue; } if (isMain && /^func\s+main\s*\(\s*\)/.test(line)) out.push({ line: i, kind: "main", name: "main", path: [] }); @@ -301,7 +312,7 @@ export function detectRunTargets(text: string, relPath: string): RunTarget[] { (t) => t.kind === "main" || /(^|\/)(test_[^/]*|[^/]*_test)\.py$|(^|\/)tests?\//.test(relPath), ); case "go": - return goTargets(lines).filter((t) => t.kind === "main" || /_test\.go$/.test(relPath)); + return goTargets(lines).filter((t) => t.kind === "main" || t.kind === "generate" || /_test\.go$/.test(relPath)); case "jvm": return /\.(java|kts?)$/.test(relPath) ? javaKotlinTargets(text, relPath) : jvmTargets(lines, relPath); default: @@ -363,7 +374,23 @@ export function targetConfig( return named(withLast(file.args, (f) => `${f}::${target.path.join("::")}`)); case "go": { const pattern = `^${target.path.map(escapeRegExp).join("$/^")}$`; - return named([...file.args, "-run", pattern]); + const benchmark = target.path[0]?.startsWith("Benchmark"); + const parts = goTestParts(file.args); + const selection = benchmark ? ["-run", "^$", "-bench", pattern] : ["-run", pattern]; + const result = named([ + "test", + ...withoutGoSelection(parts.flags), + ...parts.targets, + ...selection, + ...parts.program, + ]); + if (file.params?.goKind) + result.params = { + ...file.params, + pattern: benchmark ? "^$" : pattern, + toolArgs: formatArgs([...withoutGoSelection(parts.flags), ...(benchmark ? ["-bench", pattern] : [])]), + }; + return result; } case "cargo": { const path = target.path.join("::"); diff --git a/src/features/run/testing/go.test.ts b/src/features/run/testing/go.test.ts new file mode 100644 index 000000000..d5d36157e --- /dev/null +++ b/src/features/run/testing/go.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { GoTestOutput, goJsonEvent, goParser } from "./go"; +import { TestResults } from "./results"; +import { withTestReporting } from "./index"; +import { findOutputLinks } from "../links"; + +const record = (Package: string, Action: string, Test?: string, rest = {}) => + JSON.stringify({ Package, Action, ...(Test ? { Test } : {}), ...rest }); + +describe("Go JSON tests", () => { + it("streams interleaved packages and subtests with exact counts and locations", () => { + const parser = goParser(); + const results = new TestResults(); + const lines = [ + record("one", "run", "TestPass"), + record("two", "run", "TestFail"), + record("one", "run", "TestPass/child"), + record("one", "pass", "TestPass/child", { Elapsed: 0.002 }), + record("two", "output", "TestFail", { Output: " nested/main_test.go:12:7: fixture failure\n" }), + record("two", "fail", "TestFail", { Elapsed: 0.01 }), + record("two", "fail"), + record("one", "pass", "TestPass"), + record("one", "skip", "TestSkip"), + record("one", "pass"), + ]; + lines.forEach((line) => results.apply(parser.feed(line))); + results.finish(1); + expect(results.counts()).toMatchObject({ passed: 1, failed: 1, skipped: 1, running: 0, total: 3 }); + expect(results.roots.map((n) => n.name)).toEqual(["one", "two"]); + expect(results.get("two\0TestFail")?.location).toEqual({ + path: "nested/main_test.go", + line: 12, + col: 7, + resolve: "name", + }); + expect(results.get("one\0TestPass\0child")?.durationMs).toBe(2); + expect(parser.feed(record("two", "fail", "TestFail"))).toEqual([]); + }); + + it("reports compiler failures and packages without tests", () => { + const parser = goParser(); + const results = new TestResults(); + results.apply( + parser.feed( + JSON.stringify({ ImportPath: "broken", Action: "build-output", Output: "./main.go:3:1: undefined: missing\n" }), + ), + ); + results.apply(parser.feed(JSON.stringify({ ImportPath: "broken", Action: "build-fail" }))); + results.apply(parser.feed(record("empty", "skip"))); + expect(results.counts()).toMatchObject({ failed: 1, skipped: 1 }); + expect(results.roots[0]?.output).toContain("./main.go:3:1: undefined: missing"); + }); + + it("ignores malformed metadata and negative durations without throwing", () => { + for (const line of [ + "{", + "null", + "[]", + '{"Action":"pass","Package":5}', + '{"Action":"output","Package":"p","Output":{}}', + '{"Action":"unknown","Package":"p"}', + ]) { + expect(goJsonEvent(line)).toBeNull(); + expect(goParser().feed(line)).toEqual([]); + } + expect(goParser().feed(record("p", "pass", "TestOne", { Elapsed: -1 }))[0]).toMatchObject({ + durationMs: undefined, + }); + }); + + it("decodes chunked and long JSON output into clickable console text", () => { + const parser = goParser(); + const results = new TestResults(); + const output = new GoTestOutput((line) => results.apply(parser.feed(line))); + const line = record("p", "output", "TestFail", { + Output: " main_test.go:6: failure " + "x".repeat(9000) + "\n", + }); + expect(output.feed(line.slice(0, 22))).toBe(""); + const text = output.feed(line.slice(22) + "\n" + record("p", "fail", "TestFail")); + expect(text).toContain("main_test.go:6: failure"); + expect(text).not.toContain('"Action"'); + expect(findOutputLinks(text.slice(0, 100)).some((link) => link.kind === "file")).toBe(true); + expect(output.end()).toBe(""); + expect(results.counts().failed).toBe(1); + }); + + it("forces JSON before program arguments and keeps the binary arguments untouched", () => { + const config = { + kind: "go", + command: "go", + args: ["test", "-json=false", "-tags", "demo", ".", "-args", "-json=false"], + }; + expect(withTestReporting(config).args).toEqual(["test", "-json", "-tags", "demo", ".", "-args", "-json=false"]); + }); +}); diff --git a/src/features/run/testing/go.ts b/src/features/run/testing/go.ts index 88cf6f674..acf8117d1 100644 --- a/src/features/run/testing/go.ts +++ b/src/features/run/testing/go.ts @@ -10,7 +10,7 @@ const RESULT_RE = /^\s*--- (PASS|FAIL|SKIP): (\S+) \(([\d.]+s)\)/; const PKG_RE = /^(ok|FAIL|---)\s+(\S+)\s+(?:\(cached\)|([\d.]+s)|\[[^\]]+\])/; const LOCATION_RE = /^\s+([\w.-]+\.go):(\d+):/; -export function goParser(): TestOutputParser { +function plainGoParser(): TestOutputParser { let buffer: TestEvent[] = []; let current: string[] | null = null; let totals = { passed: 0, failed: 0, skipped: 0 }; @@ -76,3 +76,112 @@ export function goParser(): TestOutputParser { }, }; } + +interface GoEvent { + Action: string; + Package: string; + Test?: string; + Output?: string; + Elapsed?: number; +} + +export function goJsonEvent(line: string): GoEvent | null { + if (!line.startsWith("{")) return null; + try { + const event = JSON.parse(line); + if (!event || typeof event !== "object" || typeof event.Action !== "string") return null; + const pkg = event.Package ?? event.ImportPath; + if ( + typeof pkg !== "string" || + !pkg || + !["start", "run", "pause", "cont", "output", "pass", "fail", "skip", "build-output", "build-fail"].includes( + event.Action, + ) + ) + return null; + if (event.Test !== undefined && typeof event.Test !== "string") return null; + if (event.Output !== undefined && typeof event.Output !== "string") return null; + return { ...event, Package: pkg }; + } catch { + return null; + } +} + +export function goParser(): TestOutputParser { + const plain = plainGoParser(); + const located = new Set(); + const finished = new Set(); + const packages = new Map(); + const totals = { passed: 0, failed: 0, skipped: 0 }; + return { + feed(line) { + const e = goJsonEvent(line); + if (!e) return plain.feed(line); + const pkg = packages.get(e.Package) ?? { tests: false, failed: false }; + packages.set(e.Package, pkg); + const path = [e.Package, ...(e.Test ? e.Test.split("/") : [])]; + if (e.Test) pkg.tests = true; + if (e.Action === "run") return [{ type: "start", path }]; + if (e.Action === "output" || e.Action === "build-output") { + if (!e.Output) return []; + const lines = e.Output.replace(/\n$/, "").split("\n"); + const events: TestEvent[] = lines.map((line) => ({ type: "output", path, line })); + const key = path.join("\0"); + const loc = lines.map((line) => /^\s*(.+\.go):(\d+)(?::(\d+))?:/.exec(line)).find(Boolean); + if (loc && !located.has(key)) { + located.add(key); + events.push({ + type: "location", + path, + location: { + path: loc[1]!, + line: Number(loc[2]), + ...(loc[3] ? { col: Number(loc[3]) } : {}), + resolve: loc[1]!.startsWith("/") ? "path" : "name", + }, + }); + } + return events; + } + if (!["pass", "fail", "skip", "build-fail"].includes(e.Action)) return []; + const key = path.join("\0"); + if (finished.has(key)) return []; + finished.add(key); + const status = e.Action === "pass" ? "passed" : e.Action === "skip" ? "skipped" : "failed"; + if (e.Test && path.length === 2) { + totals[status]++; + if (status === "failed") pkg.failed = true; + } else if (!e.Test && (!pkg.tests || (status === "failed" && !pkg.failed))) totals[status]++; + const durationMs = + typeof e.Elapsed === "number" && Number.isFinite(e.Elapsed) && e.Elapsed >= 0 ? e.Elapsed * 1000 : undefined; + return [ + { type: "result", path, status, durationMs }, + { type: "summary", ...totals }, + ]; + }, + end: () => plain.end?.() ?? [], + }; +} + +/** Decode stdout for the console while feeding complete, unsplit JSON records to the test parser. */ +export class GoTestOutput { + private pending = ""; + constructor(private readonly feedLine: (line: string) => void) {} + feed(text: string): string { + this.pending += text; + const lines = this.pending.split("\n"); + this.pending = lines.pop()!; + return lines.map((line) => this.line(line)).join(""); + } + end(): string { + const pending = this.pending; + this.pending = ""; + return pending ? this.line(pending) : ""; + } + private line(raw: string): string { + const line = raw.replace(/\r$/, ""); + this.feedLine(line); + const event = goJsonEvent(line); + return event ? (event.Output ?? "") : line + "\n"; + } +} diff --git a/src/features/run/testing/index.ts b/src/features/run/testing/index.ts index 0409ea1cd..93579630e 100644 --- a/src/features/run/testing/index.ts +++ b/src/features/run/testing/index.ts @@ -55,6 +55,15 @@ export function frameworkFor(config: Pick>(config: T): T { + if (frameworkFor(config) === "go" && config.args[0] === "test") { + const boundary = config.args.findIndex((a) => a === "-args" || a === "--"); + const tool = boundary < 0 ? config.args : config.args.slice(0, boundary); + const args = tool.filter((a) => a !== "-json" && !a.startsWith("-json=")); + return { + ...config, + args: ["test", "-json", ...args.slice(1), ...(boundary < 0 ? [] : config.args.slice(boundary))], + }; + } if (frameworkFor(config) !== "pytest") return config; const cmd = [config.command, ...config.args]; const pytestAt = cmd.findIndex((a) => /(^|[/\\])pytest(\.exe)?$/.test(a)); diff --git a/src/features/run/testing/rerun.test.ts b/src/features/run/testing/rerun.test.ts index 93a9f85c3..ea426180f 100644 --- a/src/features/run/testing/rerun.test.ts +++ b/src/features/run/testing/rerun.test.ts @@ -157,3 +157,34 @@ describe("withTestReporting", () => { expect(withTestReporting(cfg("cargo", "cargo", ["test"])).args).toEqual(["test"]); }); }); + +describe("Go rerun argument boundaries", () => { + it("replaces import-path targets without losing flag values or binary arguments", () => { + const config = cfg("go", "go", ["test", "-tags", "demo", "-run", "old", "example.com/old", "-args", "-test.short"]); + const rerun = rerunFailedConfig(config, [["example.com/new", "TestChild", "case"]])!; + expect(rerun.args).toEqual([ + "test", + "-tags", + "demo", + "-run", + "^TestChild$/^case$", + "example.com/new", + "-args", + "-test.short", + ]); + }); + it("retains preparation of file-scoped configurations", () => { + const config = { + ...cfg("go", "go", ["test", "main_test.go"]), + params: { goKind: "test", scope: "file", target: "main_test.go", module: "app", pattern: "", useTags: "true" }, + }; + const rerun = rerunFailedConfig(config, [["command-line-arguments", "TestFail"]])!; + expect(rerun.params).toMatchObject({ + goKind: "test", + scope: "file", + target: "main_test.go", + pattern: "^(TestFail)$", + }); + expect(rerun.args).toEqual(["test", "-run", "^(TestFail)$", "main_test.go"]); + }); +}); diff --git a/src/features/run/testing/rerun.ts b/src/features/run/testing/rerun.ts index 93de07035..2d0bc69a4 100644 --- a/src/features/run/testing/rerun.ts +++ b/src/features/run/testing/rerun.ts @@ -3,6 +3,8 @@ * tests of a previous run, using each framework's own filter syntax. */ import type { RunConfig } from "@/platform/ipc"; +import { formatArgs } from "../configs"; +import { goTestParts, withoutGoSelection } from "../goArguments"; import { frameworkFor } from "./index"; import { nodeStatus, type TestNode, type TestResults } from "./results"; @@ -82,16 +84,23 @@ function pytestArgs(base: string[], paths: string[][]): string[] | null { return [...kept, ...ids]; } -const isGoPkgPattern = (a: string) => a === "." || a === "./..." || a.startsWith("./"); - function goArgs(base: string[], paths: string[][]): string[] | null { const pkgs = unique(paths.map((p) => p[0]!).filter(Boolean)); if (!pkgs.length) return null; const tests = paths.filter((p) => p.length > 1); - const args = dropFlag(base, ["-run"]).filter((a) => !isGoPkgPattern(a)); + const parts = goTestParts(base); const top = unique(tests.map((p) => p[1]!)); - const run = top.length ? ["-run", `^(${top.map(escapeRegExp).join("|")})$`] : []; - return [...args, ...run, ...pkgs]; + const pattern = + tests.length === 1 && tests[0]!.length > 2 + ? tests[0]! + .slice(1) + .map((name) => `^${escapeRegExp(name)}$`) + .join("/") + : `^(${top.map(escapeRegExp).join("|")})$`; + const run = top.length ? ["-run", pattern] : []; + // A file-scoped run is reported under this synthetic import path; keep its file targets. + const targets = pkgs.includes("command-line-arguments") ? parts.targets : pkgs; + return ["test", ...withoutGoSelection(parts.flags), ...run, ...targets, ...parts.program]; } function jvmArgs(config: RunConfig, paths: string[][]): string[] | null { @@ -158,5 +167,24 @@ export function testsConfig(config: RunConfig, paths: string[][], name: string): args = null; } if (!args) return null; - return { ...config, args, name, detected: true, env: { ...config.env } }; + return { + ...config, + args, + name, + detected: true, + env: { ...config.env }, + ...(frameworkFor(config) === "go" && config.params?.goKind + ? { + cwd: config.params.module || config.cwd, + params: + config.params.scope === "file" + ? { + ...config.params, + pattern: args[args.indexOf("-run") + 1] ?? "", + toolArgs: formatArgs(withoutGoSelection(goTestParts(args).flags)), + } + : { module: config.params.module ?? "", useTags: config.params.useTags ?? "false" }, + } + : {}), + }; } diff --git a/src/features/run/types.ts b/src/features/run/types.ts index c531a11ea..b0345cd25 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -20,6 +20,7 @@ import { tx } from "@/platform/i18n"; import { useProject } from "@/platform/project"; import { tomcatType } from "./tomcat"; +import { goBuildType, goTestType } from "./goTypes"; export const COMPOUND = "compound"; @@ -395,6 +396,8 @@ export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [ maven, godot, pytest, + goBuildType, + goTestType, applicationType, springBootType, tomcatType, @@ -419,13 +422,15 @@ export const isDebugOnly = (c: Pick, type = typeFor(c.kind)) */ export function formParams(config: RunConfig, type = typeFor(config.kind)): Record | null { if (!type) return null; - if (config.params && Object.keys(config.params).length) return { ...config.params }; + if (config.params && Object.keys(config.params).length && (type.id !== "go" || config.params.goKind)) + return { ...config.params }; if (type.id === COMPOUND) return {}; const derived = type.paramsFrom?.(config); if (!derived) return null; // Only when the form reproduces exactly what runs today. const line = type.commandLine(derived); if (!line) return null; + if (type.id === "go") return derived; const sameCwd = !type.ownsWorkingDirectory || (line.cwd ?? null) === (config.cwd ?? null); return line.command === config.command && formatArgs(line.args) === formatArgs(config.args) && sameCwd ? derived @@ -439,7 +444,9 @@ export function withParams(config: RunConfig, params: Record, ty if (!line) return { ...rest, params, command: "", args: [] }; return { ...rest, - params, + params: params.goKind ? { ...params, programArgv: JSON.stringify(parseArgs(params.programArgs ?? "")) } : params, + ...(config.kind === "go-test" && !config.id.startsWith("template:") ? { kind: "go" } : {}), + ...(params.goKind ? { isTest: params.goKind === "test" } : {}), command: line.command, args: line.args, ...(isJvmApplication(config) && parseJavaArgs(config.args).mainClass !== parseJavaArgs(line.args).mainClass diff --git a/src/features/search/__tests__/editorResults.test.tsx b/src/features/search/__tests__/editorResults.test.tsx new file mode 100644 index 000000000..a0bcdb76f --- /dev/null +++ b/src/features/search/__tests__/editorResults.test.tsx @@ -0,0 +1,45 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { provideEditorService } from "@/workbench/contributions"; +import { FindToolWindow } from "../find/FindToolWindow"; +import { disposeFindWindow, openEditorResults, useFindViewOptions } from "../find/windowStore"; + +afterEach(disposeFindWindow); + +it("shows highlighted result lines and navigates on a single click", async () => { + const openFile = vi.fn(async () => {}); + provideEditorService({ + openFile, + openInput: async () => {}, + activePath: () => null, + activeCursor: () => null, + saveAll: async () => {}, + }); + useFindViewOptions.setState({ preview: false }); + openEditorResults({ + title: "Occurrences of 'foo'", + query: { + pattern: "foo", + isRegex: false, + wholeWord: true, + caseSensitive: true, + include: [], + exclude: [], + scope: { kind: "files", paths: ["/p/a.ts"] }, + maxMatches: 0, + }, + read: () => ({ + path: "/p/a.ts", + relPath: "a.ts", + matches: [{ line: 5, text: " foo()", textOffset: 0, ranges: [[2, 5]] }], + }), + }); + render(); + const match = await screen.findByRole("treeitem", { name: /6.*foo/ }); + expect(match.querySelector("mark")).toHaveTextContent("foo"); + fireEvent.mouseDown(match, { button: 0 }); + expect(openFile).toHaveBeenCalledWith("/p/a.ts", { + focus: false, + selection: { line: 5, col: 2, endLine: 5, endCol: 5 }, + }); +}); diff --git a/src/features/search/__tests__/findWindow.test.ts b/src/features/search/__tests__/findWindow.test.ts index 5bd3e02c1..2b9ca9808 100644 --- a/src/features/search/__tests__/findWindow.test.ts +++ b/src/features/search/__tests__/findWindow.test.ts @@ -1,8 +1,15 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { FileMatches, SearchQuery } from "@/platform/ipc"; import { buildFindTree } from "../find/FindToolWindow"; import type { FindDeps } from "../find/session"; -import { disposeFindWindow, openInFindWindow, pinFindTab, useFindWindow } from "../find/windowStore"; +import { + disposeFindWindow, + openEditorResults, + openInFindWindow, + pinFindTab, + rerunFindTab, + useFindWindow, +} from "../find/windowStore"; const q = (pattern: string): SearchQuery => ({ pattern, @@ -67,3 +74,23 @@ describe("buildFindTree", () => { ]); }); }); + +describe("editor-backed Find results", () => { + it("uses the shared session and recomputes live results on rerun without IPC", async () => { + const current = file("unsaved.ts"); + current.matches[0]!.text = "foo foo"; + current.matches[0]!.ranges.push([4, 7]); + const read = vi.fn(() => current); + const id = openEditorResults({ title: "Occurrences of 'foo'", query: q("foo"), read }); + await vi.waitFor(() => expect(useFindWindow.getState().tabs[0]?.state.status).toBe("done")); + expect(useFindWindow.getState().tabs[0]?.state.files).toEqual([current]); + expect(useFindWindow.getState().tabs[0]?.state.matchCount).toBe(2); + const tree = buildFindTree(useFindWindow.getState().tabs[0]!.state.files); + expect(tree[0]).toMatchObject({ kind: "file", count: 2 }); + read.mockReturnValue({ ...file("unsaved.ts"), matches: [] }); + rerunFindTab(id); + await vi.waitFor(() => expect(useFindWindow.getState().tabs[0]?.state.files).toEqual([])); + expect(read).toHaveBeenCalledTimes(2); + expect(useFindWindow.getState().tabs[0]?.state.summary?.matches).toBe(0); + }); +}); diff --git a/src/features/search/__tests__/pathScopeActions.test.tsx b/src/features/search/__tests__/pathScopeActions.test.tsx new file mode 100644 index 000000000..ceb1d89be --- /dev/null +++ b/src/features/search/__tests__/pathScopeActions.test.tsx @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { executeAction } from "@/platform/actions"; +import { getContext, setContext } from "@/platform/context"; +import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap"; +import { activate } from "../index"; +import { hideFindPopup, useFindPopup } from "../find/store"; +import { BookmarksView } from "../bookmarks/BookmarksView"; +import { useBookmarks } from "../bookmarks/store"; + +let dispose: (() => void) | void; +let stopKeys: () => void; + +beforeEach(() => { + dispose = activate(); + stopKeys = installKeyboardDispatcher(); + setContext("projectOpen", true); + setContext("focusedDirectory", undefined); + useFindPopup.setState({ open: false, scopeTab: "directory", directory: "ideaIssues", pattern: "" }); +}); + +afterEach(() => { + stopKeys(); + dispose?.(); + setContext("focusedDirectory", undefined); + setContext("projectOpen", false); + useKeymap.setState({ preset: "intellij-macos", overrides: {} }); +}); + +describe.each([false, true])("path scope, replace=%s", (replace) => { + const id = replace ? "find.replaceInPath" : "find.inPath"; + + it.each(["intellij-macos", "intellij-windows"])("takes the focused directory through %s shortcuts", (preset) => { + useKeymap.setState({ preset, overrides: {} }); + setContext("focusedDirectory", "E:/documents/temp/documents"); + fireEvent.keyDown(window, { + key: replace ? "r" : "f", + code: replace ? "KeyR" : "KeyF", + metaKey: preset === "intellij-macos", + ctrlKey: preset === "intellij-windows", + shiftKey: true, + }); + expect(useFindPopup.getState()).toMatchObject({ + open: true, + replace, + scopeTab: "directory", + directory: "E:/documents/temp/documents", + }); + }); + + it("keeps explicit directory/path precedence and a query-only argument", async () => { + setContext("focusedDirectory", "/selected"); + await executeAction(id, { directory: "/explicit", path: "/legacy", query: "needle" }); + expect(useFindPopup.getState()).toMatchObject({ directory: "/explicit", pattern: "needle" }); + await executeAction(id, { path: "/legacy" }); + expect(useFindPopup.getState().directory).toBe("/legacy"); + await executeAction(id, { query: "other" }); + expect(useFindPopup.getState()).toMatchObject({ directory: "/selected", pattern: "other" }); + }); + + it("keeps the previous scope when no path tree owns focus", async () => { + await executeAction(id); + expect(useFindPopup.getState()).toMatchObject({ scopeTab: "directory", directory: "ideaIssues" }); + hideFindPopup(); + useFindPopup.setState({ scopeTab: "project" }); + await executeAction(id); + expect(useFindPopup.getState().scopeTab).toBe("project"); + }); + + it("uses bookmark folders, files and line bookmarks, but not lists or editor focus", async () => { + useBookmarks.setState({ + lists: [{ id: "default", name: "Bookmarks" }], + defaultList: "default", + paths: [ + { path: "/p/documents", dir: true, list: "default", created: 1 }, + { path: "/p/src/main.ts", dir: false, list: "default", created: 2 }, + ], + items: [{ path: "/p/src/main.ts", line: 0, text: "hello", list: "default", created: 3 }], + }); + render( + <> + + + , + ); + const tree = screen.getByRole("tree", { name: "Bookmarks" }); + tree.focus(); + for (const [key, directory] of [ + ["Home", undefined], + ["ArrowDown", "/p/documents"], + ["ArrowDown", "/p/src"], + ["ArrowDown", "/p/src"], + ] as const) { + fireEvent.keyDown(tree, { key }); + expect(getContext().focusedDirectory).toBe(directory); + await act(() => executeAction(id)); + expect(useFindPopup.getState().directory).toBe(directory ?? "ideaIssues"); + act(() => hideFindPopup()); + } + screen.getByLabelText("Editor").focus(); + expect(getContext().focusedDirectory).toBeUndefined(); + }); +}); diff --git a/src/features/search/bookmarks/BookmarksView.tsx b/src/features/search/bookmarks/BookmarksView.tsx index 74a7dfa02..03f2bd2fa 100644 --- a/src/features/search/bookmarks/BookmarksView.tsx +++ b/src/features/search/bookmarks/BookmarksView.tsx @@ -1,6 +1,7 @@ import { useMemo, useRef, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; import { executeAction } from "@/platform/actions"; +import { dirname } from "@/platform/project"; import { shortcutLabel } from "@/platform/keymap"; import { confirm } from "@/workbench/messages"; import { tx, useT, useTx } from "@/platform/i18n"; @@ -237,6 +238,13 @@ export function BookmarksView() { aria-label="Bookmarks" roots={roots} getKey={(n) => n.key} + getDirectory={(n) => + n.kind === "list" + ? null + : n.kind === "folder" + ? n.path + : dirname(n.kind === "mark" ? n.bookmark.path : n.path) + } getLabel={(n) => (n.kind === "mark" ? bookmarkLabel(n.bookmark) : n.kind === "list" ? n.list.name : n.name)} getChildren={(n) => (n.kind === "list" || n.kind === "file" ? n.children : undefined)} isExpandable={(n) => n.kind === "list" || (n.kind === "file" && n.children.length > 0)} diff --git a/src/features/search/everywhere/SearchEverywhere.tsx b/src/features/search/everywhere/SearchEverywhere.tsx index 0ddb04ed9..86b116fca 100644 --- a/src/features/search/everywhere/SearchEverywhere.tsx +++ b/src/features/search/everywhere/SearchEverywhere.tsx @@ -1,3 +1,4 @@ +import { useFilePresentation } from "@/platform/filePresentation"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; import clsx from "clsx"; import { actionToggled, executeAction, getAction } from "@/platform/actions"; @@ -16,7 +17,6 @@ import { SymbolBadge, Select, Toggle, - fileIconName, listNavigate, type MenuItem, T, @@ -830,9 +830,10 @@ function FileRow({ line: number | null; }) { const deco = useFileDecoration(path); + const { icon } = useFilePresentation(path); return ( <> - + diff --git a/src/features/search/find/FindToolWindow.tsx b/src/features/search/find/FindToolWindow.tsx index 9dae9f0d5..db1ae34c1 100644 --- a/src/features/search/find/FindToolWindow.tsx +++ b/src/features/search/find/FindToolWindow.tsx @@ -24,6 +24,7 @@ import { import { notifications, useOccurrenceNavigator } from "@/workbench/contributions"; import { confirm } from "@/workbench/messages"; import { basename, openLocation, relDir } from "../common/open"; +import { countFileMatches } from "./session"; import { CodePreview, MatchText, type PreviewHighlight } from "./parts"; import { NO_MARKS, @@ -104,18 +105,19 @@ export function buildFindTree(files: readonly FileMatches[], groupByDirectory = starts: m.ranges.map(([s]) => ({ line: m.line, col: s + m.textOffset })), }; }); + const count = countFileMatches(f); const file: Node = { kind: "file", key: `f:${f.path}`, path: f.path, name: basename(f.relPath), dir, - count: f.matches.length, + count, children, }; if (d) { d.children.push(file); - d.count += f.matches.length; + d.count += count; } else flat.push(file); } if (!groupByDirectory) { @@ -434,6 +436,12 @@ function FindTabView({ tab }: { tab: FindTab }) { onSelectionChange={(next, lead) => { setSelection(next); setLeadNode(lead); + if (tab.editorResults && lead?.kind === "match") + void openLocation( + lead.path, + { line: lead.line, col: lead.col, endLine: lead.line, endCol: lead.endCol }, + { focus: false }, + ); }} multiSelect onActivate={(n) => { diff --git a/src/features/search/find/session.ts b/src/features/search/find/session.ts index 51b40e2d7..5cc0e9970 100644 --- a/src/features/search/find/session.ts +++ b/src/features/search/find/session.ts @@ -31,6 +31,9 @@ export interface FindDeps { nextRequestId(): RequestId; } +export const countFileMatches = (file: FileMatches): number => + file.matches.reduce((count, line) => count + line.ranges.length, 0); + export const IDLE: FindState = { status: "idle", query: null, files: [], matchCount: 0, summary: null, error: null }; function errorMessage(e: unknown): string { @@ -86,7 +89,7 @@ export class FindSession { const summary = await this.deps.start(request, query, (f) => { if (seq !== this.seq) return; files.push(f); - matchCount += f.matches.length; + matchCount += countFileMatches(f); this.update({ ...this.state, files: [...files], matchCount }); }); if (seq !== this.seq) return; diff --git a/src/features/search/find/store.ts b/src/features/search/find/store.ts index a2883b214..0f39ea451 100644 --- a/src/features/search/find/store.ts +++ b/src/features/search/find/store.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import type { ContextKeys } from "@/platform/context"; import type { ProjectFileSet, SearchContext, SearchQuery, SearchScope } from "@/platform/ipc"; import { SEARCH_CONTEXTS, customScopes, findCustomScope, rememberSearch, searchHistory } from "@/platform/search"; import { loadJson, saveJson } from "../common/open"; @@ -103,11 +104,17 @@ export interface FindArg { query?: string; } -export function parseFindArg(arg: unknown): FindArg { - if (!arg || typeof arg !== "object") return {}; - const a = arg as Record; +export function parseFindArg(arg: unknown, ctx: ContextKeys = {}): FindArg { + const a = (arg && typeof arg === "object" ? arg : {}) as Record; return { - directory: typeof a.directory === "string" ? a.directory : typeof a.path === "string" ? a.path : undefined, + directory: + typeof a.directory === "string" + ? a.directory + : typeof a.path === "string" + ? a.path + : typeof ctx.focusedDirectory === "string" + ? ctx.focusedDirectory + : undefined, query: typeof a.query === "string" ? a.query : undefined, }; } diff --git a/src/features/search/find/windowStore.ts b/src/features/search/find/windowStore.ts index c92bb6645..de8aee1d7 100644 --- a/src/features/search/find/windowStore.ts +++ b/src/features/search/find/windowStore.ts @@ -4,11 +4,12 @@ */ import { create } from "zustand"; import { call, commands, nextRequestId, type SearchQuery } from "@/platform/ipc"; +import type { FindResultsSource } from "@/workbench/contributions"; import type { MenuItem } from "@/ui"; import { loadJson, saveJson } from "../common/open"; import { maybeToolWindowService } from "../common/services"; import { cancelRequest, streamSearch } from "../common/textSearch"; -import { FindSession, IDLE, type FindDeps, type FindState } from "./session"; +import { FindSession, IDLE, countFileMatches, type FindDeps, type FindState } from "./session"; export interface FindTab { id: number; @@ -20,6 +21,8 @@ export interface FindTab { /** Kept when a new search runs (Pin Tab). */ pinned?: boolean; state: FindState; + /** In-file results reveal their line on selection while keeping focus in the tree. */ + editorResults?: boolean; } interface FindWindowState { @@ -77,6 +80,23 @@ export function openInFindWindow( return id; } +/** Use the normal Find session/tab lifecycle for live editor results. */ +export function openEditorResults(source: FindResultsSource) { + const id = openInFindWindow(source.query, source.title, null, { + validate: async () => {}, + nextRequestId, + cancel: () => {}, + start: async (_request, _query, onFile) => { + const file = source.read(); + const matches = countFileMatches(file); + if (matches) onFile(file); + return { files: matches ? 1 : 0, matches, limitHit: false, cancelled: false, elapsedMs: 0 }; + }, + }); + patchTab(id, { editorResults: true }); + return id; +} + export function pinFindTab(id: number, pinned: boolean) { patchTab(id, { pinned }); } diff --git a/src/features/search/index.ts b/src/features/search/index.ts index 759e5c80b..a5cbcd4dc 100644 --- a/src/features/search/index.ts +++ b/src/features/search/index.ts @@ -11,7 +11,7 @@ import { onIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { EDIT_SCOPES_ACTION } from "@/platform/search"; import { isVirtualUri } from "@/platform/virtualDocs"; -import { registerMenuGroup, registerOverlay, registerToolWindow } from "@/workbench/contributions"; +import { provideFindResults, registerMenuGroup, registerOverlay, registerToolWindow } from "@/workbench/contributions"; import { BookmarkPopupsOverlay } from "./bookmarks/BookmarkPopups"; import { bookmarkGutter } from "./bookmarks/gutter"; import { @@ -38,7 +38,7 @@ import { hideSearchEverywhere, showSearchEverywhere } from "./everywhere/store"; import { FindInFilesOverlay } from "./find/FindInFiles"; import { ScopesDialogOverlay, showScopesDialog } from "./find/ScopesDialog"; import { hideFindPopup, parseFindArg, runFindPopupCommand, showFindPopup, useFindPopup } from "./find/store"; -import { disposeFindWindow, rerunActiveFindTab, useFindHeaderMenu } from "./find/windowStore"; +import { disposeFindWindow, openEditorResults, rerunActiveFindTab, useFindHeaderMenu } from "./find/windowStore"; import { RecentPopupsOverlay } from "./recent/RecentPopups"; import { cycleSwitcher, hideRecentPopup, showRecentPopup } from "./recent/store"; import { FileStructureOverlay, useFileStructurePopup } from "./structure/StructureView"; @@ -123,6 +123,7 @@ export function activate(): void | (() => void) { const disposers: Array<() => void> = [ installTracking(), + provideFindResults(openEditorResults), registerOverlay({ id: "search.everywhere", component: SearchEverywhereOverlay }), registerOverlay({ id: "search.recent", component: RecentPopupsOverlay }), @@ -326,9 +327,9 @@ export function activate(): void | (() => void) { icon: "search", keywords: ["grep", "search in path"], when: "projectOpen", - run: ({ arg }) => { + run: ({ arg, ctx }) => { closeAllPopups("find"); - showFindPopup(false, { ...parseFindArg(arg), selection: editorSelectionText() }); + showFindPopup(false, { ...parseFindArg(arg, ctx), selection: editorSelectionText() }); }, }, { @@ -354,9 +355,9 @@ export function activate(): void | (() => void) { category: "Find", icon: "replace", when: "projectOpen", - run: ({ arg }) => { + run: ({ arg, ctx }) => { closeAllPopups("find"); - showFindPopup(true, { ...parseFindArg(arg), selection: editorSelectionText() }); + showFindPopup(true, { ...parseFindArg(arg, ctx), selection: editorSelectionText() }); }, }, // Keys of the Find in Files popup that are global shortcuts elsewhere (bound after them). diff --git a/src/features/search/recent/RecentPopups.tsx b/src/features/search/recent/RecentPopups.tsx index 20220c24d..efab57b66 100644 --- a/src/features/search/recent/RecentPopups.tsx +++ b/src/features/search/recent/RecentPopups.tsx @@ -1,3 +1,4 @@ +import { useFilePresentation } from "@/platform/filePresentation"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; import clsx from "clsx"; import { editorsFor, getNavigationHistory } from "@/editor/api"; @@ -353,9 +354,10 @@ function ToolRow({ tw, indices }: { tw: ToolWindowContribution; indices: number[ function RecentFileRow({ item, current }: { item: RecentFileItem; current: boolean }) { const deco = useFileDecoration(item.editor ? "" : item.path); + const { icon } = useFilePresentation(item.editor ? "" : item.path); return ( <> - + ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getTotalSize: () => count * 18, + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ index, key: index, start: index * 18, size: 18 })), + measureElement: () => {}, + measure: () => {}, + scrollToIndex: () => {}, + }), +})); + +const config: RunConfig = { + id: "gateway", + name: "Gateway", + kind: "spring-boot", + command: "java", + args: [], + cwd: null, + env: {}, + detected: true, + isTest: false, + debugAdapter: "java-debug", +}; +const summary = (id: number, extra: Partial = {}): DebugServiceSession => ({ + id, + config, + startedAt: id, + status: "running", + processId: null, + stop: vi.fn(async () => {}), + rerun: vi.fn(), + show: vi.fn(), + ...extra, +}); +let dispose: () => void; +beforeEach(() => { + localStorage.clear(); + useProject.setState({ + project: { root: "/services-debug", name: "app", isGit: false, settings: validateSettings({}) }, + }); + useRun.setState({ configs: [config], sessions: [], loaded: true }); + useDebug.setState({ sessions: {}, console: {}, active: null }); + useDebugRunSessions.setState({ sessions: [], serviceSessions: [], Console: DebugServiceConsole }); + useServicesTree.setState({ + roots: {}, + selectedKey: null, + selectedKeys: new Set(), + expanded: new Set(["run:type:spring-boot"]), + }); + dispose = registerServiceProvider(runServiceProvider); +}); +afterEach(() => { + vi.restoreAllMocks(); + dispose(); + useDebugRunSessions.setState({ sessions: [], serviceSessions: [], Console: null }); +}); +const select = () => + fireEvent.mouseDown( + within(screen.getByRole("tree", { name: "Services" })) + .getByText("Gateway") + .closest('[role="treeitem"]')!, + ); +const toolbar = () => within(screen.getByRole("toolbar", { name: "Services actions" })); + +it("shows debug output/state, pins actions, and does not rebuild the tree for stdout", async () => { + adoptSession(1, { runConfig: config }); + adoptSession(2, { runConfig: { ...config, id: "other" } }); + appendConsole(1, "stdout", "gateway log\n"); + appendConsole(2, "stdout", "other log\n"); + const d = summary(1); + useDebugRunSessions.setState({ serviceSessions: [d] }); + useDebug.setState({ active: 2 }); + render(); + select(); + expect(screen.getByRole("log")).toHaveTextContent("gateway log"); + expect(screen.getByRole("log")).not.toHaveTextContent("other log"); + expect(toolbar().getByRole("button", { name: "Stop" })).toBeEnabled(); + fireEvent.click(toolbar().getByRole("button", { name: "Rerun" })); + expect(d.rerun).toHaveBeenCalledOnce(); + expect(useDebug.getState().active).toBe(2); + const roots = useServicesTree.getState().roots; + act(() => appendConsole(1, "stdout", "next log\n")); + expect(screen.getByRole("log")).toHaveTextContent("next log"); + expect(useServicesTree.getState().roots).toBe(roots); + act(() => useDebugRunSessions.setState({ serviceSessions: [{ ...d, status: "paused" }] })); + expect(within(screen.getByRole("tree")).getByText("Suspended")).toBeTruthy(); + fireEvent.click(toolbar().getByRole("button", { name: "Stop" })); + expect(d.stop).toHaveBeenCalledOnce(); +}); + +it("keeps the other parallel instance alive and then displays it", async () => { + adoptSession(1); + adoptSession(2); + appendConsole(1, "stdout", "first instance\n"); + appendConsole(2, "stdout", "second instance\n"); + const first = summary(1), + second = summary(2); + useDebugRunSessions.setState({ serviceSessions: [first, second] }); + render(); + select(); + expect(screen.getByRole("log")).toHaveTextContent("second instance"); + fireEvent.click(toolbar().getByRole("button", { name: "Stop" })); + expect(second.stop).toHaveBeenCalledOnce(); + expect(first.stop).not.toHaveBeenCalled(); + act(() => useDebugRunSessions.setState({ serviceSessions: [first, { ...second, status: "terminated" }] })); + expect(screen.getByRole("log")).toHaveTextContent("first instance"); +}); + +it("shows the owning Run output for attached debugging and overrides both sets of controls", async () => { + const stopRun = vi.spyOn(commands, "runStop").mockImplementation(async (id) => { + queueMicrotask(() => onRunExited(id, 130)); + return { status: "ok", data: null }; + }); + const c = { ...config, kind: "shell", command: "echo", debugAdapter: null }; + const s = await startRun(c); + expect(s).not.toBeNull(); + const active = { ...s!, config, status: "running" as const, runId: 7 }; + runtime(s!.id)!.console.append({ kind: "stdout", text: "Maven application log\n" }); + useRun.setState({ configs: [config], sessions: [active] }); + const d = summary(1, { processId: 7 }); + useDebugRunSessions.setState({ serviceSessions: [d] }); + render(); + select(); + expect(screen.getByRole("log")).toHaveTextContent("Maven application log"); + const actions = within(screen.getByRole("toolbar", { name: "Run actions" })); + fireEvent.click(actions.getByRole("button", { name: "Rerun" })); + expect(d.rerun).toHaveBeenCalledOnce(); + await act(async () => fireEvent.click(actions.getByRole("button", { name: "Stop" }))); + expect(stopRun).toHaveBeenCalledExactlyOnceWith(7); + await vi.waitFor(() => expect(d.stop).toHaveBeenCalledExactlyOnceWith({ stopProcess: false })); + act(() => useRun.setState({ sessions: [{ ...active, status: "finished" }] })); + expect(actions.getByRole("button", { name: "Stop" })).toBeEnabled(); +}); + +it("group Stop stops all roots but terminates a shared Maven process only once", async () => { + const first = summary(1, { processId: 7 }), + second = summary(2, { processId: 7 }); + useDebugRunSessions.setState({ serviceSessions: [first, second] }); + render(); + fireEvent.mouseDown(within(screen.getByRole("tree")).getByText("Spring Boot").closest('[role="treeitem"]')!); + fireEvent.click(toolbar().getByRole("button", { name: "Stop" })); + await vi.waitFor(() => expect(second.stop).toHaveBeenCalledOnce()); + expect(first.stop).toHaveBeenCalledExactlyOnceWith({ stopProcess: false }); + expect(second.stop).toHaveBeenCalledExactlyOnceWith(); +}); diff --git a/src/features/settings/__tests__/colorScheme.test.tsx b/src/features/settings/__tests__/colorScheme.test.tsx new file mode 100644 index 000000000..4d9a40c4d --- /dev/null +++ b/src/features/settings/__tests__/colorScheme.test.tsx @@ -0,0 +1,116 @@ +import { readFileSync } from "node:fs"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { executeAction } from "@/platform/actions"; +import { useProject } from "@/platform/project"; +import { DEFAULT_SETTINGS } from "../model/schema"; +import { openSettings, stage, stagedValue, useSettingsDialog } from "../model/staging"; +import { PageContext } from "../components/context"; +import { COLOR_SCHEME_PAGE } from "../pages/editor/colorScheme"; + +const mocks = vi.hoisted(() => ({ + open: vi.fn(), + save: vi.fn(), + read: vi.fn(), + write: vi.fn(), + ask: vi.fn(), + confirm: vi.fn(), + error: vi.fn(), +})); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open, save: mocks.save })); +vi.mock("@/workbench/messages", () => ({ ask: mocks.ask, confirm: mocks.confirm, showError: mocks.error })); +vi.mock("@/platform/ipc", async (original) => { + const actual = await original(); + return { ...actual, commands: { ...actual.commands, fsReadText: mocks.read, fsCreateFileBytes: mocks.write } }; +}); +const value = (key: string) => stagedValue(useSettingsDialog.getState(), "global", `editor.${key}`); +const run = (name: string) => + act(async () => { + await executeAction(`editor.colorScheme.${name}`); + }); + +beforeEach(() => { + vi.resetAllMocks(); + mocks.open.mockResolvedValue("/schemes/forest.icls"); + mocks.save.mockResolvedValue("/schemes/export.icls"); + mocks.read.mockResolvedValue({ + status: "ok", + data: readFileSync("src/platform/__fixtures__/colorSchemes/forest.icls", "utf8"), + }); + mocks.write.mockResolvedValue({ status: "ok", data: null }); + useProject.setState({ settings: structuredClone(DEFAULT_SETTINGS), project: null }); + openSettings("editor.colorScheme"); + const Page = COLOR_SCHEME_PAGE.component!; + render( + + + , + ); +}); + +describe("scheme actions", () => { + it("imports and selects in global scope, exports and confirms deletion", async () => { + await run("import"); + const id = value("colorScheme") as string; + expect(id).toMatch(/^custom-/); + expect(screen.getByRole("status")).toHaveTextContent("Applied 16 values; skipped 2"); + expect(Object.values(useSettingsDialog.getState().staged).every((s) => s.scope === "global")).toBe(true); + await run("export"); + expect(mocks.write).toHaveBeenCalledWith("/schemes/export.icls", expect.any(Array)); + expect(new TextDecoder().decode(new Uint8Array(mocks.write.mock.calls[0]![1]))).toContain( + 'name="Forest & Moss"', + ); + mocks.confirm.mockResolvedValueOnce(false); + await run("delete"); + expect(value("colorScheme")).toBe(id); + mocks.confirm.mockResolvedValueOnce(true); + await run("delete"); + expect(value("colorScheme")).toBe("theme-default"); + expect(value("customSchemes")).toEqual({}); + }); + + it("accepts file paths from action callers and keeps the same import/export flow", async () => { + await act(async () => { + await executeAction("editor.colorScheme.import", "/chosen/custom.icls"); + }); + expect(mocks.open).not.toHaveBeenCalled(); + expect(mocks.read).toHaveBeenCalledWith("/chosen/custom.icls"); + expect(value("colorScheme")).toMatch(/^custom-/); + await act(async () => { + await executeAction("editor.colorScheme.export", "/chosen/custom-export.icls"); + }); + expect(mocks.save).not.toHaveBeenCalled(); + expect(mocks.write).toHaveBeenCalledWith("/chosen/custom-export.icls", expect.any(Array)); + }); + + it("supports cancel, keep both and overwrite collisions", async () => { + await run("import"); + const first = value("colorScheme"); + mocks.ask.mockResolvedValueOnce({ button: "cancel" }); + await run("import"); + expect(value("colorScheme")).toBe(first); + mocks.ask.mockResolvedValueOnce({ button: "both" }); + await run("import"); + expect(Object.values(value("customSchemes") as Record).map((s) => s.name)).toEqual([ + "Forest & Moss", + "Forest & Moss (2)", + ]); + act(() => + stage("editor.colorScheme", "global", "editor.colorOverrides", { + [first as string]: { keyword: { foreground: "#ff0000" } }, + }), + ); + mocks.ask.mockResolvedValueOnce({ button: "overwrite" }); + await run("import"); + expect(value("colorScheme")).toBe(first); + expect(value("colorOverrides")).toEqual({}); + expect(Object.keys(value("customSchemes") as object)).toHaveLength(2); + }); + + it("shows a malformed-file error without staging any changes", async () => { + mocks.read.mockResolvedValueOnce({ status: "ok", data: '' }); + await run("import"); + await waitFor(() => expect(mocks.error).toHaveBeenCalledWith("Color Scheme", "Invalid .icls color scheme")); + expect(useSettingsDialog.getState().staged).toEqual({}); + }); +}); diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index 0ab46c953..bf60202ce 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -65,6 +65,24 @@ async function openDialog(page?: string) { } describe("Settings dialog", () => { + it("stages properties conversion through its action and applies the checkbox", async () => { + const dialog = await openDialog("editor.fileEncodings"); + const checkbox = within(dialog).getByRole("checkbox", { name: "Transparent native-to-ascii conversion" }); + expect(checkbox).not.toBeChecked(); + await act(async () => fireEvent.click(checkbox)); + expect(checkbox).toBeChecked(); + expect(settingsSet).not.toHaveBeenCalled(); + await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" }))); + expect(settingsSet).toHaveBeenCalledWith("global", "editor.encodings.nativeToAscii", "true"); + }); + + it("toggles properties conversion through its stable action", async () => { + await executeAction("editor.encodings.toggleNativeToAscii"); + expect(settingsSet).toHaveBeenCalledWith("global", "editor.encodings.nativeToAscii", "true"); + await executeAction("editor.encodings.toggleNativeToAscii"); + expect(settingsSet).toHaveBeenLastCalledWith("global", "editor.encodings.nativeToAscii", "false"); + }); + it("finds Gradle JVM and applies project Gradle settings", async () => { useProject.setState({ project: { root: "/project" } as never }); const dialog = await openDialog("build.gradle"); diff --git a/src/features/settings/__tests__/goBuildTags.test.ts b/src/features/settings/__tests__/goBuildTags.test.ts new file mode 100644 index 000000000..8741f9f8e --- /dev/null +++ b/src/features/settings/__tests__/goBuildTags.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_LANGUAGES, validateLanguages } from "@/platform/languageSettings"; +import { LANGUAGE_PAGES } from "../pages/languages"; + +describe("Go build tags", () => { + it("keeps old project settings unchanged and ignores malformed values", () => { + expect(validateLanguages({ go: { goroot: "/go" } }).go).toEqual({ ...DEFAULT_LANGUAGES.go, goroot: "/go" }); + expect(validateLanguages({ go: { buildTags: { os: false, arch: [], custom: 42 } } }).go.buildTags).toEqual( + DEFAULT_LANGUAGES.go.buildTags, + ); + }); + it("preserves independent project constraints", () => { + expect( + validateLanguages({ + go: { + buildTags: { + os: " linux ", + arch: "arm64", + cgo: "0", + version: "go1.22", + compiler: "gc", + custom: "integration debug", + experiments: "loopvar", + }, + }, + }).go.buildTags, + ).toEqual({ + os: "linux", + arch: "arm64", + cgo: "0", + version: "go1.22", + compiler: "gc", + custom: "integration debug", + experiments: "loopvar", + }); + const page = LANGUAGE_PAGES.find((p) => p.id === "languages.go.buildTags")!; + expect(page.defaultScope).toBe("project"); + expect(page.ownScopeSwitch).toBe(true); + }); +}); diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index 9468486b0..0fd9ac555 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -77,3 +77,32 @@ describe("language pages", () => { expect(tildify("C:/Program Files/Java/jdk-21")).toBe("C:/Program Files/Java/jdk-21"); }); }); + +describe("Go module settings", () => { + it("keeps automatic vendoring by default and normalizes malformed environment entries", () => { + expect(validateLanguages({ go: {} }).go.modules).toEqual({ env: {}, autoVendor: true }); + const modules = validateLanguages({ + go: { + modules: { + autoVendor: false, + env: { + GOPROXY: "https://proxy.example", + GOPRIVATE: "example.com/private", + GOFLAGS: "-race", + INVALID: 3, + "BAD KEY": "value", + NULL: "x\0y", + }, + }, + }, + }).go.modules; + expect(modules).toEqual({ + autoVendor: false, + env: { GOPROXY: "https://proxy.example", GOPRIVATE: "example.com/private", GOFLAGS: "-race" }, + }); + expect(validateLanguages({ go: { modules: { autoVendor: "no", env: [] } } }).go.modules).toEqual({ + env: {}, + autoVendor: true, + }); + }); +}); diff --git a/src/features/settings/__tests__/staging.test.ts b/src/features/settings/__tests__/staging.test.ts index 67542ccd4..d47ca8399 100644 --- a/src/features/settings/__tests__/staging.test.ts +++ b/src/features/settings/__tests__/staging.test.ts @@ -147,6 +147,11 @@ describe("settings staging", () => { }); describe("validateSettings", () => { + it("defaults focus synchronization on and preserves an explicit opt-out", () => { + expect(validateSettings({ files: {} }).files.syncOnFocus).toBe(true); + expect(validateSettings({ files: { syncOnFocus: false } }).files.syncOnFocus).toBe(false); + }); + it("fills defaults for missing or malformed values", () => { expect(validateSettings(undefined)).toEqual(DEFAULT_SETTINGS); expect(validateSettings("garbage")).toEqual(DEFAULT_SETTINGS); diff --git a/src/features/settings/components/Form.tsx b/src/features/settings/components/Form.tsx index e8ac1c825..5bc75c2f7 100644 --- a/src/features/settings/components/Form.tsx +++ b/src/features/settings/components/Form.tsx @@ -475,7 +475,7 @@ export function SettingText({ set(e.target.value)} /> diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts index 3be8e8f04..8391dad44 100644 --- a/src/features/settings/index.ts +++ b/src/features/settings/index.ts @@ -36,6 +36,7 @@ import { installActionProxies, installSettingsHost } from "./window/bridge"; import { AssignShortcutOverlay, showAssignShortcut } from "./keymap/AssignShortcut"; import { settingsSearchEntries } from "./searchIndex"; import { activatePythonStatus } from "./languages/PythonStatus"; +import { nativeToAsciiAction } from "./nativeToAscii"; import { isImeKey } from "@/platform/ime"; export { settingsWindowParent } from "./window/bridge"; @@ -49,6 +50,7 @@ const NEW_PROJECTS_PAGE = "languages.java"; function settingsActions(open: (page?: string, newProjects?: boolean) => void): Action[] { return [ + nativeToAsciiAction(), { id: "file.settings", title: "Settings…", diff --git a/src/features/settings/languages/GoBuildTags.tsx b/src/features/settings/languages/GoBuildTags.tsx new file mode 100644 index 000000000..f9072c01d --- /dev/null +++ b/src/features/settings/languages/GoBuildTags.tsx @@ -0,0 +1,88 @@ +import { useProject } from "@/platform/project"; +import { T } from "@/ui"; +import { useSettingsDialog } from "../model/staging"; +import { PageContext } from "../components/context"; +import { OptionsPage } from "../pages/OptionsPage"; +import type { SectionSpec } from "../pages/spec"; + +const choices = (values: string[]) => [ + { value: "", label: "Automatic" }, + ...values.map((value) => ({ value, label: value })), +]; +const key = (name: string) => `languages.go.buildTags.${name}`; +export const GO_BUILD_TAG_SECTIONS: SectionSpec[] = [ + { + options: [ + { + kind: "select", + key: key("os"), + label: "Operating System", + options: choices([ + "aix", + "android", + "darwin", + "dragonfly", + "freebsd", + "illumos", + "ios", + "js", + "linux", + "netbsd", + "openbsd", + "plan9", + "solaris", + "wasip1", + "windows", + ]), + }, + { + kind: "select", + key: key("arch"), + label: "Architecture", + options: choices([ + "386", + "amd64", + "arm", + "arm64", + "loong64", + "mips", + "mipsle", + "mips64", + "mips64le", + "ppc64", + "ppc64le", + "riscv64", + "s390x", + "wasm", + ]), + }, + { kind: "text", key: key("version"), label: "Go Version", placeholder: "go1.22" }, + { kind: "select", key: key("compiler"), label: "Compiler", options: choices(["gc", "gccgo"]) }, + { + kind: "select", + key: key("cgo"), + label: "CGO", + options: [ + { value: "", label: "Automatic" }, + { value: "1", label: "Enabled" }, + { value: "0", label: "Disabled" }, + ], + }, + { kind: "text", key: key("custom"), label: "Custom Tags", placeholder: "integration debug" }, + { kind: "text", key: key("experiments"), label: "Experiments", placeholder: "loopvar,rangefunc" }, + ], + }, +]; + +export function GoBuildTagsPage() { + const hasProject = useProject((s) => !!s.project); + const newProjects = useSettingsDialog((s) => s.newProjects); + if (!hasProject || newProjects) return Open a project to configure Go build tags.; + return ( + + + + ); +} diff --git a/src/features/settings/languages/GoModules.tsx b/src/features/settings/languages/GoModules.tsx new file mode 100644 index 000000000..38093ec38 --- /dev/null +++ b/src/features/settings/languages/GoModules.tsx @@ -0,0 +1,41 @@ +import { useProject } from "@/platform/project"; +import { T } from "@/ui"; +import { useSettingsDialog } from "../model/staging"; +import { PageContext } from "../components/context"; +import { OptionsPage } from "../pages/OptionsPage"; +import type { SectionSpec } from "../pages/spec"; + +export const GO_MODULE_SECTIONS: SectionSpec[] = [ + { + options: [ + { kind: "toggle", key: "languages.go.modules.autoVendor", label: "Enable vendoring support automatically" }, + ...[ + "GOPROXY", + "GOPRIVATE", + "GOFLAGS", + "GONOPROXY", + "GONOSUMDB", + "GOSUMDB", + "GOMODCACHE", + "GOTOOLCHAIN", + "GOWORK", + ].map((name) => ({ + kind: "text" as const, + key: `languages.go.modules.env.${name}`, + label: name, + placeholder: "Inherit from environment", + })), + ], + }, +]; + +export function GoModulesPage() { + const hasProject = useProject((s) => !!s.project); + const newProjects = useSettingsDialog((s) => s.newProjects); + if (!hasProject || newProjects) return Open a project to configure Go modules.; + return ( + + + + ); +} diff --git a/src/features/settings/model/schema.ts b/src/features/settings/model/schema.ts index 9a5bc5681..8bf6e185e 100644 --- a/src/features/settings/model/schema.ts +++ b/src/features/settings/model/schema.ts @@ -62,6 +62,7 @@ export interface ValidSettings extends WorkflowSettings { keymap: string; files: { autoSave: boolean; + syncOnFocus: boolean; exclude: string[]; ignoredNames: string[]; trimTrailingWhitespace: boolean; @@ -134,6 +135,7 @@ export const DEFAULT_SETTINGS: ValidSettings = { keymap: "intellij-macos", files: { autoSave: true, + syncOnFocus: true, exclude: ["**/.git", "**/node_modules", "**/target"], ignoredNames: [...DEFAULT_IGNORED_NAMES], trimTrailingWhitespace: true, @@ -233,6 +235,7 @@ export function validateSettings(raw: Settings | unknown): ValidSettings { keymap: str(root.keymap, d.keymap), files: { autoSave: bool(f.autoSave, d.files.autoSave), + syncOnFocus: bool(f.syncOnFocus, d.files.syncOnFocus), exclude: Array.isArray(f.exclude) ? f.exclude.filter((x): x is string => typeof x === "string" && !!x.trim()) : [...d.files.exclude], diff --git a/src/features/settings/nativeToAscii.ts b/src/features/settings/nativeToAscii.ts new file mode 100644 index 000000000..dd978efa6 --- /dev/null +++ b/src/features/settings/nativeToAscii.ts @@ -0,0 +1,32 @@ +import type { Action } from "@/platform/actions"; +import { call, commands } from "@/platform/ipc"; +import { loadSettings, useProject } from "@/platform/project"; +import { stage, type Scope } from "./model/staging"; + +export const NATIVE_TO_ASCII_ACTION = "editor.encodings.toggleNativeToAscii"; +const KEY = "editor.encodings.nativeToAscii"; + +export function nativeToAsciiAction(): Action { + return { + id: NATIVE_TO_ASCII_ACTION, + title: "Transparent native-to-ascii conversion", + category: "Editor", + keywords: ["properties", "encoding", "Unicode"], + run: async ({ arg }) => { + const staged = arg as { pageId?: string; scope?: Scope; value?: boolean } | undefined; + if (staged?.pageId && staged.scope && typeof staged.value === "boolean") { + stage(staged.pageId, staged.scope, KEY, staged.value); + return; + } + const { settings, project } = useProject.getState(); + await call( + commands.settingsSet( + project ? "project" : "global", + KEY, + JSON.stringify(!settings?.editor?.encodings?.nativeToAscii), + ), + ); + await loadSettings(); + }, + }; +} diff --git a/src/features/settings/pages/Deployment.module.css b/src/features/settings/pages/Deployment.module.css index 8f30054e0..43cdb3fea 100644 --- a/src/features/settings/pages/Deployment.module.css +++ b/src/features/settings/pages/Deployment.module.css @@ -54,12 +54,22 @@ } .wide { width: 320px; + max-width: 100%; } .field { display: flex; + flex: 1; + min-width: 0; align-items: center; gap: 6px; } +.field > input { + min-width: 0; + flex: 1; +} +.field > button { + flex-shrink: 0; +} .note { color: var(--fg-subtle); font-size: var(--fs-small); diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index 5b0a5fb66..ecd39bfbc 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -10,7 +10,7 @@ import { useId, useRef, useState, type ReactNode } from "react"; import clsx from "clsx"; import { shortcutLabel } from "@/platform/keymap"; import { isTauri } from "@/platform/ipc"; -import { SCHEMES as EDITOR_SCHEMES } from "@/platform/colorScheme"; +import { SCHEMES as EDITOR_SCHEMES, listSchemes, customSchemes } from "@/platform/colorScheme"; import { formatTimestamp } from "@/platform/dateFormat"; import { Checkbox, IconButton, Input, Popup, type SelectOption, T } from "@/ui"; import { NOTIFICATION_GROUPS } from "@/workbench/notificationStore"; @@ -117,7 +117,8 @@ const SCHEMES: SelectOption[] = [ function ColorSchemeRow() { const [scheme, setScheme] = useSetting("editor.colorScheme"); - const options = SCHEMES.some((s) => s.value === scheme) ? SCHEMES : [...SCHEMES, { value: scheme, label: scheme }]; + const [customs] = useSetting("editor.customSchemes", "global"); + const options = [SCHEMES[0]!, ...listSchemes(customSchemes(customs)).map((s) => ({ value: s.id, label: s.name }))]; return ( @@ -587,6 +588,11 @@ export const BEHAVIOR_PAGES: PageSpec[] = [ key: "files.autoSave", label: "Save files when switching to a different application or a built-in terminal", }, + { + kind: "toggle", + key: "files.syncOnFocus", + label: "Synchronize external changes when switching to the IDE window", + }, ], }, { diff --git a/src/features/settings/pages/deployment.tsx b/src/features/settings/pages/deployment.tsx index 024180060..53378a7ee 100644 --- a/src/features/settings/pages/deployment.tsx +++ b/src/features/settings/pages/deployment.tsx @@ -5,10 +5,10 @@ */ import { useEffect, useId, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; -import { call, commands, IpcError, type RemoteEntry } from "@/platform/ipc"; -import { useTx, tx } from "@/platform/i18n"; -import type { DeploymentServerValid } from "@/platform/remoteSettings"; -import { Badge, Button, Checkbox, Dialog, Icon, IconButton, Input, Spinner, T, Tabs } from "@/ui"; +import { call, commands, IpcError, type FsConnection, type RemoteEntry } from "@/platform/ipc"; +import { useTx, tx, txTemplate } from "@/platform/i18n"; +import { deploymentConnection, type DeploymentServerValid } from "@/platform/remoteSettings"; +import { Badge, Button, Checkbox, Dialog, Icon, IconButton, Input, Select, Spinner, T, Tabs } from "@/ui"; import { GlobListEditor, Row } from "../components/Form"; import { SshConfigSelect, useSshConfigs } from "../components/SshConfigSelect"; import { useSetting } from "../components/context"; @@ -29,7 +29,7 @@ import { PageLink } from "./workflowControls"; import workflow from "./Workflow.module.css"; import styles from "./Deployment.module.css"; -const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)); +const errorText = (e: unknown) => txTemplate(e instanceof Error ? e.message : String(e)); // --------------------------------------------------------------------------- // Connection problems: trust an unknown host key or enter the password. @@ -96,12 +96,12 @@ function ConnectionProblem({ ssh, error, onRetry }: { ssh: string; error: unknow // Remote folder chooser (Root path › Browse…) function RemoteFolderDialog({ - ssh, + connection, initial, onChoose, onClose, }: { - ssh: string; + connection: FsConnection; initial: string; onChoose: (path: string) => void; onClose: () => void; @@ -111,6 +111,7 @@ function RemoteFolderDialog({ const [attempt, setAttempt] = useState(0); const [result, setResult] = useState<{ key: string; folders?: RemoteEntry[]; error?: unknown } | null>(null); const [selected, setSelected] = useState(null); + const connectionKey = JSON.stringify(connection); const key = `${path}\n${attempt}`; const current = result?.key === key ? result : null; const folders = current ? (current.folders ?? []) : null; @@ -118,7 +119,7 @@ function RemoteFolderDialog({ useEffect(() => { let live = true; - call(commands.remoteList(ssh, path)).then( + call(commands.remoteList(JSON.parse(connectionKey) as FsConnection, path)).then( (entries) => { if (!live) return; // `~` lists the login folder: name it by the absolute path the entries report. @@ -136,7 +137,7 @@ function RemoteFolderDialog({ return () => { live = false; }; - }, [ssh, path, key]); + }, [connectionKey, path, key]); const go = (next: string) => { setDraft(next); @@ -166,7 +167,7 @@ function RemoteFolderDialog({ return ( {error ? ( - + ) : (
    (null); - const test = result?.ssh === server.sshConfig ? result : null; + const connection = deploymentConnection(server); + const testKey = `${server.kind}:${server.sshConfig}:${server.rootPath}`; + const test = result?.ssh === testKey ? result : null; const rootId = useId(); const runTest = async () => { - const ssh = server.sshConfig; + const ssh = testKey; setTest({ ssh, kind: "busy" }); try { - const entries = await call(commands.remoteList(ssh, server.rootPath.trim() || "~")); - setTest({ ssh, kind: "ok", count: entries.length }); + await call(commands.deployTestConnection(connection, server.rootPath.trim() || "~")); + setTest({ ssh, kind: "ok", count: 0 }); } catch (e) { setTest({ ssh, kind: "error", error: e }); } @@ -294,12 +297,25 @@ function ConnectionTab({ return (
    update({ name })} /> - - update({ sshConfig })} /> - - Configure… - + + update({ rootPath: e.target.value })} /> - @@ -322,7 +338,11 @@ function ConnectionTab({ placeholder="http://example.com" />
    - {test?.kind === "busy" && } @@ -332,12 +352,15 @@ function ConnectionTab({ )}
    - {test?.kind === "error" && ( - void runTest()} /> - )} + {test?.kind === "error" && + (server.kind === "sftp" ? ( + void runTest()} /> + ) : ( + {errorText(test.error)} + ))} {browsing && ( setBrowsing(false)} onChoose={(rootPath) => { @@ -608,7 +631,18 @@ export const DEPLOYMENT_PAGES: PageSpec[] = [ order: 365, projectScope: true, defaultScope: "project", - keywords: ["sftp", "ssh", "server", "upload", "remote host", "mappings", "web server", "deploy"], + keywords: [ + "local", + "mounted", + "sftp", + "ssh", + "server", + "upload", + "remote host", + "mappings", + "web server", + "deploy", + ], sections: [ { options: [ diff --git a/src/features/settings/pages/deploymentModel.test.ts b/src/features/settings/pages/deploymentModel.test.ts index 56fddec25..de0525904 100644 --- a/src/features/settings/pages/deploymentModel.test.ts +++ b/src/features/settings/pages/deploymentModel.test.ts @@ -54,6 +54,7 @@ describe("server list", () => { expect(nextServerName([a])).toBe("Server 2"); expect(nextServerName([{ ...a, name: "Server 2" }])).toBe("Server 3"); const fresh = newServer([a]); + expect(fresh.kind).toBe("sftp"); expect(fresh.name).toBe("Server 2"); expect(fresh.id).not.toBe(""); expect(fresh.mappings).toEqual([{ local: "", deployment: "", web: "" }]); @@ -63,6 +64,14 @@ describe("server list", () => { expect(copy.id).not.toBe("a"); }); + it("copies a local server without changing its type or mappings", () => { + const local = { ...a, kind: "local" as const, rootPath: "/mounted/www" }; + const copy = newServer([local], local); + expect(copy).toMatchObject({ kind: "local", rootPath: "/mounted/www", sshConfig: "" }); + expect(copy.mappings).toEqual(local.mappings); + expect(copy.mappings).not.toBe(local.mappings); + }); + it("keeps the default server valid", () => { expect(fixDefaultServer([a], "a")).toBe("a"); expect(fixDefaultServer([a], "gone")).toBe("a"); diff --git a/src/features/settings/pages/editor/Editor.module.css b/src/features/settings/pages/editor/Editor.module.css index a2dfc3292..2f76363b0 100644 --- a/src/features/settings/pages/editor/Editor.module.css +++ b/src/features/settings/pages/editor/Editor.module.css @@ -148,6 +148,7 @@ /* Color Scheme page */ .schemeRow { display: flex; + flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 10px; diff --git a/src/features/settings/pages/editor/codeStyle.tsx b/src/features/settings/pages/editor/codeStyle.tsx index 548b74a9a..3138f68e1 100644 --- a/src/features/settings/pages/editor/codeStyle.tsx +++ b/src/features/settings/pages/editor/codeStyle.tsx @@ -11,6 +11,7 @@ import { useId, useState } from "react"; import { Button, Checkbox, Input, type SelectOption, T } from "@/ui"; import { commands, isTauri, type LanguageInfo_Serialize as LanguageInfo } from "@/platform/ipc"; import { ENCODING_CHOICES, lineCommentDefaults, type LanguageCodeStyle } from "@/platform/editorSettings"; +import { NATIVE_TO_ASCII_ACTION } from "../../nativeToAscii"; import { NumberInput, Row } from "../../components/Form"; import { useSetting } from "../../components/context"; import type { PageSpec } from "../spec"; @@ -272,7 +273,7 @@ const ENCODINGS_PAGE = editorPage({ title: "File Encodings", order: 27, projectScope: true, - keywords: ["encoding", "charset", "utf-8", "bom"], + keywords: ["encoding", "charset", "utf-8", "bom", "native-to-ascii", "properties"], sections: [ { items: [ @@ -301,6 +302,12 @@ const ENCODINGS_PAGE = editorPage({ label: "Default encoding for properties files", options: ENCODINGS, }, + { + t: "check", + key: "editor.encodings.nativeToAscii", + action: NATIVE_TO_ASCII_ACTION, + label: "Transparent native-to-ascii conversion", + }, ], }, { diff --git a/src/features/settings/pages/editor/colorScheme.tsx b/src/features/settings/pages/editor/colorScheme.tsx index 6b0557aee..75aa54df1 100644 --- a/src/features/settings/pages/editor/colorScheme.tsx +++ b/src/features/settings/pages/editor/colorScheme.tsx @@ -4,32 +4,32 @@ * italic / effects editor, and a live preview. Edits are staged in * `editor.colorOverrides[scheme][attribute]`; `editor/core` injects them. */ -import { useMemo, useState, type CSSProperties } from "react"; +import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { Button, Checkbox, Input, Tree, type SelectOption, T } from "@/ui"; -import { fontWeightVars, type TextAttributesOverride } from "@/platform/editorSettings"; +import { fontWeightVars, type CustomColorSchemes, type TextAttributesOverride } from "@/platform/editorSettings"; import { ATTRIBUTES, SCHEMES, - boldWeight, + overrideDeclarations, effectiveScheme, - schemeTokens, + resolvedSchemeTokens, + listSchemes, + customSchemes, type AttributeDef, - type SchemeId, } from "@/platform/colorScheme"; import { HEX_COLOR } from "../../model/schema"; import { Row, Select } from "../../components/Form"; import { useSetting } from "../../components/context"; import { editorPage } from "./kit"; import styles from "./Editor.module.css"; +import { executeAction, registerActions } from "@/platform/actions"; +import { call, commands } from "@/platform/ipc"; +import { ask, confirm, showError } from "@/workbench/messages"; +import { parseIcls, importScheme, deleteScheme, exportIcls } from "@/platform/icls"; import { tx } from "@/platform/i18n"; type Overrides = Record>; -const SCHEME_OPTIONS: SelectOption[] = [ - { value: "theme-default", label: "Theme default" }, - ...SCHEMES.map((s) => ({ value: s.id, label: s.name })), -]; - interface Node { key: string; label: string; @@ -56,9 +56,14 @@ function buildTree(): Node[] { const TREE = buildTree(); /** CSS custom properties of a scheme plus pending overrides (for the preview). */ -function previewVars(id: SchemeId, o: Record | undefined): CSSProperties { +function previewVars( + id: string, + o: Record | undefined, + customs: CustomColorSchemes, +): CSSProperties { const vars: Record = {}; - for (const [k, v] of schemeTokens(id)) + const tokens = resolvedSchemeTokens(id, customs); + for (const [k, v] of tokens) if ( k.startsWith("--syn-") || k.startsWith("--fg-") || @@ -67,21 +72,8 @@ function previewVars(id: SchemeId, o: Record | u k === "--caret" ) vars[k] = v; - vars["--bg-editor"] = schemeTokens(id).get("--bg-editor") ?? schemeTokens(id).get("--bg-island") ?? ""; - for (const attr of ATTRIBUTES) { - const ov = o?.[attr.id]; - if (!ov) continue; - if (ov.foreground && attr.fg) vars[attr.fg] = ov.foreground; - if (ov.background && attr.bg) vars[attr.bg] = ov.background; - if (attr.token) { - if (ov.bold !== undefined) vars[`--syn-${attr.token}-bold`] = boldWeight(ov.bold); - if (ov.italic !== undefined) vars[`--syn-${attr.token}-italic`] = ov.italic ? "italic" : "normal"; - if (ov.background) vars[`--syn-${attr.token}-bg`] = ov.background; - if (ov.effect && ov.effect !== "none") - vars[`--syn-${attr.token}-effect`] = - `${ov.effect === "strikeout" ? "line-through" : ov.effect === "wave" ? "underline wavy" : "underline"} ${ov.effectColor ?? "currentColor"}`; - } - } + vars["--bg-editor"] = tokens.get("--bg-editor") ?? tokens.get("--bg-island") ?? ""; + for (const [key, value] of overrideDeclarations(o)) vars[key] = value; return vars as CSSProperties; } @@ -270,17 +262,21 @@ function ColorRow({ } function ColorSchemePage() { - const [scheme, setScheme] = useSetting("editor.colorScheme"); - const [overrides, setOverrides] = useSetting("editor.colorOverrides"); + const [scheme, setScheme] = useSetting("editor.colorScheme", "global"); + const [overrides, setOverrides] = useSetting("editor.colorOverrides", "global"); const [weight] = useSetting("editor.fontWeight"); const [boldFontWeight] = useSetting("editor.boldFontWeight"); + const [rawCustoms, setCustoms] = useSetting("editor.customSchemes", "global"); + const customs = useMemo(() => customSchemes(rawCustoms), [rawCustoms]); + const [report, setReport] = useState(""); + const [busy, setBusy] = useState(false); const [selected, setSelected] = useState("keyword"); - const id = effectiveScheme(scheme); + const id = effectiveScheme(scheme, undefined, customs); const mine = overrides?.[id]; const attr = ATTRIBUTES.find((a) => a.id === selected); const vars = useMemo( - () => ({ ...previewVars(id, mine), ...fontWeightVars(weight, boldFontWeight) }) as CSSProperties, - [id, mine, weight, boldFontWeight], + () => ({ ...previewVars(id, mine, customs), ...fontWeightVars(weight, boldFontWeight) }) as CSSProperties, + [id, mine, customs, weight, boldFontWeight], ); const update = (attrId: string, v: TextAttributesOverride | undefined) => { const next: Overrides = { ...(overrides ?? {}) }; @@ -291,13 +287,128 @@ function ColorSchemePage() { else delete next[id]; setOverrides(next); }; - const schemeName = SCHEMES.find((s) => s.id === id)?.name ?? id; + const schemes = listSchemes(customs); + const schemeName = schemes.find((s) => s.id === id)?.name ?? id; + const options: SelectOption[] = [ + { value: "theme-default", label: "Theme default" }, + ...schemes.map((s) => ({ value: s.id, label: s.name })), + ]; + const handlers = useRef Promise>>({}); + useEffect(() => { + handlers.current = { + import: async (file) => { + const { open } = await import("@tauri-apps/plugin-dialog"); + const path = + file ?? + (await open({ + multiple: false, + filters: [{ name: tx("IntelliJ Color Scheme"), extensions: ["icls"] }], + })); + if (!path) return; + const parsed = parseIcls(await call(commands.fsReadText(path))); + let overwrite = false; + if (schemes.some((s) => s.name === parsed.scheme.name)) { + const canOverwrite = Object.values(customs).some((s) => s.name === parsed.scheme.name); + const { button } = await ask({ + title: tx("Import Scheme"), + body: tx`A scheme named '${parsed.scheme.name}' already exists.`, + buttons: [ + ...(canOverwrite ? [{ id: "overwrite", label: tx("Overwrite") }] : []), + { id: "both", label: tx("Keep Both"), primary: true }, + ], + }); + if (button !== "overwrite" && button !== "both") return; + overwrite = button === "overwrite"; + } + const next = importScheme(customs, parsed.scheme, overwrite); + setCustoms(next.customs); + if (overwrite) + setOverrides(Object.fromEntries(Object.entries(overrides ?? {}).filter(([key]) => key !== next.id))); + setScheme(next.id); + setReport(tx`Applied ${parsed.applied} values; skipped ${parsed.skipped} unsupported or invalid values.`); + }, + export: async (file) => { + const { save } = await import("@tauri-apps/plugin-dialog"); + const path = + file ?? + (await save({ + defaultPath: `${schemeName.replace(/[/\\:*?"<>|]/g, "_")}.icls`, + filters: [{ name: tx("IntelliJ Color Scheme"), extensions: ["icls"] }], + })); + if (!path) return; + const bytes = Array.from(new TextEncoder().encode(exportIcls(id, customs, mine))); + // The save dialog already confirmed replacing an existing file. + await call(commands.fsDelete(path)).catch(() => {}); + await call(commands.fsCreateFileBytes(path, bytes)); + setReport(tx("Scheme exported.")); + }, + delete: async () => { + if ( + !customs[id] || + !(await confirm({ + title: tx("Delete Scheme"), + message: tx`Delete color scheme '${schemeName}'?`, + ok: tx("Delete"), + })) + ) + return; + const next = deleteScheme(customs, overrides ?? {}, scheme, id); + setCustoms(next.customs); + setOverrides(next.overrides); + setScheme(next.selected); + setReport(""); + }, + }; + }); + const pending = useRef(false); + useEffect( + () => + registerActions( + (["import", "export", "delete"] as const).map((name) => ({ + id: `editor.colorScheme.${name}`, + title: name === "import" ? "Import Scheme" : name === "export" ? "Export Scheme" : "Delete Scheme", + category: "Editor", + run: async ({ arg }) => { + if (pending.current) return; + pending.current = true; + setBusy(true); + try { + await handlers.current[name]!(typeof arg === "string" ? arg : undefined); + } catch (error) { + await showError( + tx("Color Scheme"), + error instanceof Error && error.message === "Invalid .icls color scheme" + ? tx("Invalid .icls color scheme") + : String(error), + ); + } finally { + pending.current = false; + setBusy(false); + } + }, + })), + ), + [], + ); return (
    - + + +
    + {report && ( +
    + {report} +
    + )} {scheme === "theme-default" && (
    {tx`Follows the IDE theme: currently ${schemeName}.`}
    )} @@ -364,6 +480,7 @@ export const COLOR_SCHEME_PAGE = editorPage({ id: "editor.colorScheme", title: "Color Scheme", order: 23, + projectScope: false, keywords: [ "colors", "syntax highlighting", diff --git a/src/features/settings/pages/editor/kit.tsx b/src/features/settings/pages/editor/kit.tsx index 0d09798c9..74edf3683 100644 --- a/src/features/settings/pages/editor/kit.tsx +++ b/src/features/settings/pages/editor/kit.tsx @@ -8,9 +8,10 @@ import { createContext, useContext, useId, type ComponentType, type ReactNode } from "react"; import clsx from "clsx"; import { Checkbox, Input, type SelectOption } from "@/ui"; +import { executeAction } from "@/platform/actions"; import { tx } from "@/platform/i18n"; import { NumberInput, Row, SearchLabel, Section, Select } from "../../components/Form"; -import { useLabelMatch, useSetting } from "../../components/context"; +import { useLabelMatch, usePageContext, useSetting } from "../../components/context"; import type { PageSpec } from "../spec"; import styles from "./Editor.module.css"; @@ -22,6 +23,7 @@ export type Item = | { t: "check"; key: string; + action?: string; label: string; hint?: ReactNode; /** Checked when the stored boolean is false (e.g. "Disable …"). */ @@ -60,6 +62,7 @@ const Disabled = createContext(false); function CheckItem({ item }: { item: Extract }) { const parentDisabled = useContext(Disabled); + const context = usePageContext(); const [raw, set] = useSetting(item.key); const checked = item.invert ? !raw : !!raw; const matched = useLabelMatch(item.label); @@ -70,7 +73,11 @@ function CheckItem({ item }: { item: Extract }) { set(item.invert ? !v : v)} + onChange={(v) => { + const value = item.invert ? !v : v; + if (item.action) void executeAction(item.action, { ...context, value }); + else set(value); + }} label={} /> {item.inline && } diff --git a/src/features/settings/pages/languages.tsx b/src/features/settings/pages/languages.tsx index 2f2ff3e10..b8c320c46 100644 --- a/src/features/settings/pages/languages.tsx +++ b/src/features/settings/pages/languages.tsx @@ -6,6 +6,8 @@ import { GoPage, JavaPage, NodePage, RustPage, TypeScriptPage } from "../languages/LanguagePages"; import { ProjectStructurePage } from "../languages/ProjectStructurePage"; import { PythonInterpreterPage } from "../languages/PythonInterpreterPage"; +import { GoBuildTagsPage, GO_BUILD_TAG_SECTIONS } from "../languages/GoBuildTags"; +import { GoModulesPage, GO_MODULE_SECTIONS } from "../languages/GoModules"; import type { PageSpec } from "./spec"; export const LANGUAGES_GROUP = "Languages & Frameworks"; @@ -51,6 +53,28 @@ export const LANGUAGE_PAGES: PageSpec[] = [ keywords: ["goroot", "gopath", "go sdk", "gopls"], component: GoPage, }, + { + id: "languages.go.buildTags", + title: "Build Tags", + group: LANGUAGES_GROUP, + order: 391, + projectScope: true, + defaultScope: "project", + ownScopeSwitch: true, + sections: GO_BUILD_TAG_SECTIONS, + component: GoBuildTagsPage, + }, + { + id: "languages.go.modules", + title: "Go Modules", + group: LANGUAGES_GROUP, + order: 392, + projectScope: true, + defaultScope: "project", + ownScopeSwitch: true, + sections: GO_MODULE_SECTIONS, + component: GoModulesPage, + }, { id: "languages.java", projectScope: true, diff --git a/src/features/settings/pages/runTargets.tsx b/src/features/settings/pages/runTargets.tsx index b0e9aa492..88e24d532 100644 --- a/src/features/settings/pages/runTargets.tsx +++ b/src/features/settings/pages/runTargets.tsx @@ -52,7 +52,7 @@ function RemoteFolderDialog({ useEffect(() => { let live = true; - call(commands.remoteList(sshConfig, request.path)).then( + call(commands.remoteList({ kind: "sftp", ssh: sshConfig }, request.path)).then( (list) => { if (!live) return; setEntries(list.filter((e) => e.isDir).sort((a, b) => a.name.localeCompare(b.name))); diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index 0112ddaab..f9019a0a3 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, typ import { tx } from "@/platform/i18n"; import clsx from "clsx"; import { executeAction } from "@/platform/actions"; +import { dirname } from "@/platform/project"; import { Button, Checkbox, @@ -481,6 +482,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { roots={roots} getKey={(n) => n.key} getLabel={nodeText} + getDirectory={(n) => (n.path && n.type !== "group" ? (n.type === "dir" ? n.path : dirname(n.path)) : null)} getChildren={(n) => n.children} isExpandable={(n) => n.type !== "file"} expanded={expanded} diff --git a/src/features/vcs/__tests__/changesDirectory.test.tsx b/src/features/vcs/__tests__/changesDirectory.test.tsx new file mode 100644 index 000000000..4ab66d58a --- /dev/null +++ b/src/features/vcs/__tests__/changesDirectory.test.tsx @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { getContext } from "@/platform/context"; +import { ChangesTree } from "../CommitView"; +import { useCommit } from "../commitStore"; +import { useVcs } from "../store"; + +beforeEach(() => { + useVcs.setState({ + root: "/p", + roots: ["/p"], + status: [ + { + path: "/p/documents/note.txt", + relPath: "documents/note.txt", + staged: null, + unstaged: "modified", + renamedFrom: null, + }, + ], + }); + useCommit.setState({ groupByDir: true, checks: new Map(), selection: [] }); +}); + +describe.each(["commit", "local", "dialog"] as const)("%s change tree directory", (mode) => { + it("scopes folders and file parents, and clears on groups and message focus", () => { + render( + <> + +