diff --git a/Cargo.lock b/Cargo.lock index c3ad7874..ee5c7b81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4250,6 +4250,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "windows-sys 0.61.2", ] [[package]] @@ -4683,6 +4684,7 @@ dependencies = [ "ide-text", "ide-vfs", "parking_lot", + "semver", "serde", "serde_json", "specta", @@ -6207,6 +6209,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/ide-core/src/output_encoding.rs b/crates/ide-core/src/output_encoding.rs index f5e85f3d..de53b540 100644 --- a/crates/ide-core/src/output_encoding.rs +++ b/crates/ide-core/src/output_encoding.rs @@ -215,13 +215,21 @@ fn fix_kind( } /// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding` -/// (its launch attribute, UTF-8 when unset) and sets `file.encoding` itself. -pub fn java_debug_vm_args(vm_args: &str, encoding: Option<&str>, windows: bool) -> Option { +/// (its launch attribute, UTF-8 when unset). java-debug sets `file.encoding` only +/// when that attribute is nonblank; use the same defaults and user overrides as [`fix`]. +pub fn java_debug_vm_args( + vm_args: &str, + vars: &BTreeMap, + inherited: impl Fn(&str) -> Option, + encoding: Option<&str>, + windows: bool, +) -> Option { let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8"); - if (!windows && is_utf8(encoding)) || sets(vm_args, STD_PROPERTIES) { + let added = fix_kind(Kind::Java, Some(&[vm_args.to_owned()]), vars, inherited, encoding, windows).java_args; + if added.is_empty() { return None; } - let added = options(STD_PROPERTIES, encoding).join(" "); + let added = added.join(" "); Some(match vm_args.trim() { "" => added, own => format!("{own} {added}"), @@ -446,12 +454,53 @@ mod tests { #[test] fn java_debug_launches_write_what_java_debug_decodes() { - assert_eq!(java_debug_vm_args("", None, true).as_deref(), Some(TOOL_UTF8)); - assert_eq!(java_debug_vm_args("-Xmx1g", Some(""), true), Some(format!("-Xmx1g {TOOL_UTF8}"))); - assert_eq!(java_debug_vm_args("", Some("GBK"), true).unwrap().split(' ').next(), Some("-Dstdout.encoding=GBK")); - assert_eq!(java_debug_vm_args("-Dstdout.encoding=GBK", None, true), None); - assert_eq!(java_debug_vm_args("-Xmx1g", None, false), None); - assert!(java_debug_vm_args("", Some("GBK"), false).is_some()); + let debug = + |args: &str, encoding, windows| java_debug_vm_args(args, &BTreeMap::new(), |_| None, encoding, windows); + for encoding in [None, Some(""), Some(" "), Some("UTF-8"), Some("utf8")] { + let streams = encoding.filter(|e| !e.trim().is_empty()).unwrap_or("UTF-8"); + let utf8 = format!("{} -Dfile.encoding=UTF-8", options(STD_PROPERTIES, streams).join(" ")); + assert_eq!(debug("", encoding, true), Some(utf8.clone())); + assert_eq!(debug("-Xmx1g", encoding, true), Some(format!("-Xmx1g {utf8}"))); + assert_eq!(debug("-Xmx1g", encoding, false), None); + } + for windows in [true, false] { + assert_eq!(debug("", Some("GBK"), windows), Some(options(STD_PROPERTIES, "GBK").join(" "))); + } + } + + #[test] + fn java_debug_preserves_user_options_independently() { + let debug = |args: &str| java_debug_vm_args(args, &BTreeMap::new(), |_| None, None, true); + assert_eq!(debug("-Dfile.encoding=GBK"), Some(format!("-Dfile.encoding=GBK {TOOL_UTF8}"))); + for property in STD_PROPERTIES { + let args = format!("-D{property}=GBK"); + assert_eq!(debug(&args), Some(format!("{args} -Dfile.encoding=UTF-8"))); + assert_eq!(debug(&format!("{args} -Dfile.encoding=GBK")), None); + } + for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] { + for (own, expected) in [ + ("-Dfile.encoding=GBK", Some(TOOL_UTF8.to_owned())), + ("-Dstdout.encoding=GBK", Some("-Dfile.encoding=UTF-8".to_owned())), + ("-Dstdout.encoding=GBK -Dfile.encoding=GBK", None), + ] { + assert_eq!(java_debug_vm_args("", &vars(&[(key, own)]), |_| None, None, true), expected); + assert_eq!( + java_debug_vm_args("", &BTreeMap::new(), |k| (k == key).then(|| own.to_owned()), None, true), + expected + ); + } + // A launch environment value overrides the inherited one, as in Run. + assert_eq!( + java_debug_vm_args( + "", + &vars(&[(key, "")]), + |k| (k == key).then(|| "-Dfile.encoding=GBK".into()), + None, + true + ), + Some(format!("{TOOL_UTF8} -Dfile.encoding=UTF-8")) + ); + } } fn filtered(chunks: &[&str]) -> String { diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 7f360b1f..7c2c17a3 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -58,6 +58,43 @@ pub struct Settings { pub deployment: DeploymentSettings, /// Build, Execution, Deployment › Run Targets. pub run_targets: Vec, + pub cmake: CMakeSettings, +} + +/// Absent profiles retain automatic discovery for existing projects. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct CMakeSettings { + #[serde(skip_serializing_if = "Option::is_none")] + #[specta(optional)] + pub profiles: Option>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct CMakeProfile { + pub name: String, + pub build_type: String, + pub generator: String, + pub cmake_args: Vec, + /// Empty uses cmake-build-. + pub build_dir: String, + pub build_args: Vec, + pub env: BTreeMap, +} + +impl Default for CMakeProfile { + fn default() -> Self { + Self { + name: "Debug".into(), + build_type: "Debug".into(), + generator: String::new(), + cmake_args: Vec::new(), + build_dir: String::new(), + build_args: Vec::new(), + env: BTreeMap::new(), + } + } } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -154,6 +191,7 @@ impl Default for Settings { ssh: SshSettings::default(), deployment: DeploymentSettings::default(), run_targets: Vec::new(), + cmake: CMakeSettings::default(), } } } @@ -765,6 +803,24 @@ impl Settings { mod tests { use super::*; + #[test] + fn cmake_profile_argument_arrays_round_trip() { + let mut settings = Settings::default(); + assert!(settings.cmake.profiles.is_none()); + assert!(CMakeProfile::default().cmake_args.is_empty()); + assert!(CMakeProfile::default().build_args.is_empty()); + let profile = CMakeProfile { + cmake_args: ["-DMESSAGE=two words", "-DQUOTE=\"hello\"", ""].map(String::from).into(), + build_args: ["-j", "8", "--", "LABEL=it's a build"].map(String::from).into(), + ..Default::default() + }; + settings.cmake.profiles = Some(vec![profile.clone()]); + let value = serde_json::to_value(&settings).unwrap(); + assert_eq!(value["cmake"]["profiles"][0]["cmakeArgs"], serde_json::json!(profile.cmake_args)); + assert_eq!(value["cmake"]["profiles"][0]["buildArgs"], serde_json::json!(profile.build_args)); + assert_eq!(serde_json::from_value::(value).unwrap(), settings); + } + #[test] fn ui_font_fallback_defaults_empty_and_round_trips() { let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap(); diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index c6330434..39922852 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -52,3 +52,6 @@ tempfile.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } diff --git a/crates/ide-host/src/bin/server.rs b/crates/ide-host/src/bin/server.rs index 6f405fcb..ada210d8 100644 --- a/crates/ide-host/src/bin/server.rs +++ b/crates/ide-host/src/bin/server.rs @@ -55,6 +55,7 @@ async fn run(base: PathBuf) -> i32 { let sink_peer = peer.clone(); let host = Arc::new(Host::new(HostConfig { config_dir, + local_data_dir: data_dir.clone(), data_dir, languages, syntax: Arc::default(), diff --git a/crates/ide-host/src/data_dirs.rs b/crates/ide-host/src/data_dirs.rs new file mode 100644 index 00000000..9c96c415 --- /dev/null +++ b/crates/ide-host/src/data_dirs.rs @@ -0,0 +1,474 @@ +//! Storage policy and best-effort migration of rebuildable desktop data. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// Unknown entries and recovery/user content deliberately stay in roaming data. +pub fn is_local(entry: &Path) -> bool { + ["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"] + .iter() + .any(|root| entry.starts_with(root)) + || entry.starts_with("agents/tools") + || entry == Path::new("agents/registry.json") + || (entry.components().count() == 1 + && entry.file_name().is_some_and(|name| name.to_string_lossy().starts_with("java-usages-")) + && entry.extension().is_some_and(|ext| ext == "jar" || ext == "tmp")) +} + +pub fn data_path(data: &Path, local: &Path, entry: &Path) -> PathBuf { + if is_local(entry) { local.join(entry) } else { data.join(entry) } +} + +#[derive(Debug, PartialEq, Eq)] +enum Action { + Skip, + Rename, +} + +fn action(same_root: bool, source_exists: bool, target_exists: bool) -> Action { + if same_root || !source_exists || target_exists { Action::Skip } else { Action::Rename } +} + +#[derive(Debug, PartialEq, Eq)] +enum RenameFailure { + Copy, + KeepSource, +} + +fn rename_failure(error: &io::Error) -> RenameFailure { + if error.kind() == io::ErrorKind::CrossesDevices { RenameFailure::Copy } else { RenameFailure::KeepSource } +} + +/// Only Windows splits these paths. Renames are cheap; cross-volume copies run in +/// the background. Consumers always use Local, even when migration fails. +pub fn migrate(data: &Path, local: &Path) { + if cfg!(target_os = "windows") && data != local { + migrate_entries(data, local); + } +} + +fn migrate_entries(data: &Path, local: &Path) { + let mut entries: Vec = [ + "language-server-runtimes", + "index", + "cache", + "lsp", + "notebook-runtime", + "server", + "java-usages-1.1.1.jar", + "agents/registry.json", + ] + .into_iter() + .map(PathBuf::from) + .collect(); + // Virtual environments contain absolute interpreter paths. Keep the old copy + // intact; these tools are installed afresh in Local when requested. Registry + // agents also have absolute commands in user config, so leave legacy installs. + match fs::read_dir(data.join("tools")) { + Ok(tools) => { + for entry in tools { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + tracing::warn!(%error, "cannot enumerate legacy tool; keeping old data, using Local"); + continue; + } + }; + let name = entry.file_name(); + let venv = ide_tools::registry::TOOLS.iter().any(|tool| { + name == tool.name && matches!(tool.method, ide_tools::registry::Method::PipVenv { .. }) + }); + if venv { + tracing::info!(?name, "leaving non-relocatable legacy tool; reinstall into Local when needed"); + } else { + entries.push(Path::new("tools").join(name)); + } + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"), + } + let mut copies = Vec::new(); + for entry in entries { + let source = data.join(&entry); + let target = data_path(data, local, &entry); + match try_rename(&source, &target) { + Ok(true) => copies.push((source, target)), + Ok(false) => {} + Err(error) => { + tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local") + } + } + } + if !copies.is_empty() { + let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || { + for (source, target) in copies { + tracing::info!(?source, ?target, "copying legacy data in background"); + if let Err(error) = copy_then_remove(&source, &target) { + tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local"); + } + } + }); + if let Err(error) = result { + tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local"); + } + } +} + +/// Returns true when a cross-volume copy is needed. +fn try_rename(source: &Path, target: &Path) -> io::Result { + if action(source == target, source.try_exists()?, target.try_exists()?) == Action::Skip { + return Ok(false); + } + if fs::symlink_metadata(source)?.file_type().is_symlink() { + return Err(io::Error::other("refusing to migrate a linked data entry")); + } + fs::create_dir_all(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?; + // Relative wrappers work at the old location too, so even a failed rename + // leaves the old installation usable and needs no rollback. + relocate_shims(source, source)?; + match rename_missing(source, target) { + Ok(()) => { + tracing::info!(?source, ?target, "moved legacy data to Local"); + Ok(false) + } + Err(error) if rename_failure(&error) == RenameFailure::Copy => Ok(true), + Err(error) => Err(error), + } +} + +fn copy_then_remove(source: &Path, target: &Path) -> io::Result<()> { + if target.try_exists()? { + return Ok(()); + } + let parent = target.parent().ok_or_else(|| io::Error::other("missing data parent"))?; + fs::create_dir_all(parent)?; + let staging = parent.join(format!(".migration-{}", uuid::Uuid::new_v4())); + let result = (|| { + copy_tree(source, &staging)?; + relocate_shims(&staging, source)?; + // A tool may have been installed or a cache rebuilt while copying. Never + // merge into or overwrite it, and retain the original for recovery. + if target.try_exists()? { + tracing::info!(?source, ?target, "Local data created during migration; retaining legacy copy"); + return Ok(()); + } + rename_missing(&staging, target)?; + remove_entry(source)?; + tracing::info!(?source, ?target, "copied legacy data to Local"); + Ok(()) + })(); + if staging.exists() { + let _ = remove_entry(&staging); + } + result +} + +// std::fs::rename may replace even an empty directory on modern Windows. +#[cfg(windows)] +fn rename_missing(source: &Path, target: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::MoveFileExW; + + // canonicalize supplies extended-length paths, including redirected UNC roots. + let source = fs::canonicalize(source)?; + let target = fs::canonicalize(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)? + .join(target.file_name().ok_or_else(|| io::Error::other("missing data name"))?); + let wide = |path: &Path| -> io::Result> { + let mut value: Vec = path.as_os_str().encode_wide().collect(); + if value.contains(&0) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "NUL in data path")); + } + value.push(0); + Ok(value) + }; + let source = wide(&source)?; + let target = wide(&target)?; + // SAFETY: both paths are valid NUL-terminated UTF-16 buffers. No flags means + // neither replacement nor implicit cross-volume copying is permitted. + if unsafe { MoveFileExW(source.as_ptr(), target.as_ptr(), 0) } == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +// Filesystem tests run on Unix too; production migration is Windows-only. +#[cfg(not(windows))] +fn rename_missing(source: &Path, target: &Path) -> io::Result<()> { + if target.try_exists()? { + return Err(io::Error::from(io::ErrorKind::AlreadyExists)); + } + fs::rename(source, target) +} + +fn remove_entry(path: &Path) -> io::Result<()> { + if fs::symlink_metadata(path)?.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) } +} + +fn copy_tree(source: &Path, target: &Path) -> io::Result<()> { + let metadata = fs::symlink_metadata(source)?; + if metadata.file_type().is_symlink() { + return Err(io::Error::other("refusing to follow a link during data migration")); + } + if metadata.is_dir() { + fs::create_dir(target)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + copy_tree(&entry.path(), &target.join(entry.file_name()))?; + } + } else if metadata.is_file() { + fs::copy(source, target)?; + } else { + return Err(io::Error::other("unsupported data entry")); + } + Ok(()) +} + +/// Make our absolute .cmd wrappers relative to their bin directory. They remain +/// valid in both locations if the move must be rolled back. npm shims already use +/// relative paths; payloads and user configuration are never rewritten. +fn relocate_shims(root: &Path, source: &Path) -> io::Result<()> { + if source.parent().and_then(Path::file_name).is_none_or(|name| name != "tools") { + return Ok(()); + } + let bin = root.join("bin"); + if !bin.is_dir() { + return Ok(()); + } + for entry in fs::read_dir(bin)? { + let path = entry?.path(); + if path.extension().is_none_or(|ext| ext != "cmd") { + continue; + } + let text = fs::read_to_string(&path)?; + if text.starts_with("@echo off\r\n") { + let updated = relative_wrapper(&text, source); + if updated != text { + fs::write(path, updated)?; + } + } + } + Ok(()) +} + +fn relative_wrapper(text: &str, source: &Path) -> String { + // PathBuf can contain both separators (notably roots joined with "tools/demo"). + // Compare normalized paths, but retain every other byte of the managed wrapper. + let normalized = text.replace('\\', "/"); + let prefix = source.to_string_lossy().replace('\\', "/"); + #[cfg(windows)] + let (normalized, prefix) = (normalized.to_ascii_lowercase(), prefix.to_ascii_lowercase()); + let mut updated = text.to_owned(); + let offsets: Vec<_> = normalized.match_indices(&prefix).map(|(offset, _)| offset).collect(); + for offset in offsets.into_iter().rev() { + let end = offset + prefix.len(); + if normalized.as_bytes().get(end) == Some(&b'/') { + updated.replace_range(offset..end, "%~dp0.."); + } + } + updated +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() { + let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n"; + assert_eq!( + relative_wrapper(text, Path::new("C:/old/tools/demo")), + "@echo off\r\n\"%~dp0..\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n" + ); + } + + #[test] + fn storage_policy_is_conservative_and_preserves_unix_layout() { + for entry in [ + "tools/jdks", + "language-server-runtimes", + "lsp/jdtls", + "index/x", + "cache/x", + "notebook-runtime", + "server", + "logs", + "agents/tools", + "agents/registry.json", + "java-usages-1.1.1.jar", + ] { + assert!(is_local(Path::new(entry)), "{entry}"); + assert_eq!( + data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)), + Path::new("local").join(entry) + ); + assert_eq!( + data_path(Path::new("data"), Path::new("data"), Path::new(entry)), + Path::new("data").join(entry) + ); + } + for entry in [ + "git-credentials.json", + "history", + "remote", + "scratches", + "consoles", + "plugins", + "workspaces", + "agents/models.json", + "agents/project", + "unknown", + ] { + assert!(!is_local(Path::new(entry)), "{entry}"); + assert_eq!( + data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)), + Path::new("roaming").join(entry) + ); + } + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn unix_startup_does_not_migrate_even_if_given_distinct_roots() { + assert_eq!(dirs::data_dir(), dirs::data_local_dir()); + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("new"); + fs::create_dir_all(old.join("index")).unwrap(); + migrate(&old, &new); + assert!(old.join("index").exists()); + assert!(!new.exists()); + } + + #[test] + fn decisions_cover_missing_existing_and_failed_renames() { + assert_eq!(action(true, true, false), Action::Skip); + assert_eq!(action(false, false, false), Action::Skip); + assert_eq!(action(false, true, true), Action::Skip); + assert_eq!(action(false, true, false), Action::Rename); + assert_eq!(rename_failure(&io::Error::from(io::ErrorKind::CrossesDevices)), RenameFailure::Copy); + for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::AlreadyExists, io::ErrorKind::NotFound] { + assert_eq!(rename_failure(&io::Error::from(kind)), RenameFailure::KeepSource); + } + } + + #[test] + fn same_volume_moves_and_second_start_does_not_overwrite() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("new"); + fs::create_dir_all(old.join("tools/demo/bin")).unwrap(); + let wrapper = format!("@echo off\r\n\"{}/payload.exe\" %*\r\n", old.join("tools/demo").display()); + fs::write(old.join("tools/demo/bin/demo.cmd"), wrapper).unwrap(); + fs::create_dir_all(old.join("tools/ty/venv")).unwrap(); + fs::write(old.join("git-credentials.json"), "secret").unwrap(); + fs::create_dir_all(old.join("index")).unwrap(); + migrate_entries(&old, &new); + assert!(!old.join("tools/demo").exists()); + assert!(old.join("tools/ty/venv").exists()); + assert!(old.join("git-credentials.json").exists()); + assert!(fs::read_to_string(new.join("tools/demo/bin/demo.cmd")).unwrap().contains("%~dp0..")); + fs::create_dir_all(old.join("index")).unwrap(); + fs::write(old.join("index/legacy"), "old").unwrap(); + migrate_entries(&old, &new); + assert!(old.join("index/legacy").exists()); + assert!(!new.join("index/legacy").exists()); + } + + #[cfg(unix)] + #[test] + fn copy_failure_does_not_publish_or_remove_source() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("new"); + fs::create_dir(&old).unwrap(); + fs::write(old.join("payload"), "old").unwrap(); + std::os::unix::fs::symlink(old.join("payload"), old.join("link")).unwrap(); + assert!(copy_then_remove(&old, &new).is_err()); + assert!(old.join("payload").exists()); + assert!(!new.exists()); + assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1); + } + + #[test] + fn staged_copy_publishes_complete_data_and_preserves_existing_target() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("new"); + fs::create_dir_all(old.join("nested")).unwrap(); + fs::write(old.join("nested/tool.exe"), "payload").unwrap(); + copy_then_remove(&old, &new).unwrap(); + assert!(!old.exists()); + assert_eq!(fs::read_to_string(new.join("nested/tool.exe")).unwrap(), "payload"); + fs::create_dir(&old).unwrap(); + fs::write(old.join("legacy"), "old").unwrap(); + copy_then_remove(&old, &new).unwrap(); + assert!(old.join("legacy").exists()); + assert!(!new.join("legacy").exists()); + let old_file = tmp.path().join("registry-old.json"); + let new_file = tmp.path().join("registry-new.json"); + fs::write(&old_file, "cached registry").unwrap(); + copy_then_remove(&old_file, &new_file).unwrap(); + assert!(!old_file.exists()); + assert_eq!(fs::read_to_string(new_file).unwrap(), "cached registry"); + } + + #[test] + fn publication_refuses_existing_files_and_empty_directories() { + let tmp = tempfile::tempdir().unwrap(); + for directory in [false, true] { + let old = tmp.path().join(if directory { "old-dir" } else { "old-file" }); + let new = tmp.path().join(if directory { "new-dir" } else { "new-file" }); + if directory { + fs::create_dir(&old).unwrap(); + fs::create_dir(&new).unwrap(); + fs::write(old.join("payload"), "old").unwrap(); + } else { + fs::write(&old, "old").unwrap(); + fs::write(&new, "new").unwrap(); + } + assert!(rename_missing(&old, &new).is_err()); + assert!(old.exists() && new.exists()); + if directory { + assert!(!new.join("payload").exists()); + } else { + assert_eq!(fs::read_to_string(new).unwrap(), "new"); + } + } + } + + #[test] + fn wrapper_failure_keeps_the_old_installation() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old/tools/demo"); + let new = tmp.path().join("new/tools/demo"); + fs::create_dir_all(old.join("bin")).unwrap(); + fs::write(old.join("bin/demo.cmd"), [0xff, 0xfe]).unwrap(); + assert!(try_rename(&old, &new).is_err()); + assert!(old.join("bin/demo.cmd").exists()); + assert!(!new.exists()); + } + + #[test] + fn failures_keep_source_without_publishing_partial_data() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("old"); + let blocker = tmp.path().join("blocker"); + fs::create_dir(&old).unwrap(); + fs::write(old.join("payload"), "old").unwrap(); + fs::write(&blocker, "file").unwrap(); + assert!(try_rename(&old, &blocker.join("target")).is_err()); + assert!(copy_then_remove(&old, &blocker.join("target")).is_err()); + assert!(old.join("payload").exists()); + assert!(copy_then_remove(&tmp.path().join("missing"), &tmp.path().join("new")).is_err()); + assert!(!tmp.path().join("new").exists()); + assert!( + !fs::read_dir(tmp.path()) + .unwrap() + .flatten() + .any(|e| e.file_name().to_string_lossy().starts_with(".migration-")) + ); + } +} diff --git a/crates/ide-host/src/formatter.rs b/crates/ide-host/src/formatter.rs index 2da08e36..7bc9af9d 100644 --- a/crates/ide-host/src/formatter.rs +++ b/crates/ide-host/src/formatter.rs @@ -45,7 +45,7 @@ pub async fn format( } let config = config.unwrap().clone(); let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path); - let tools = Tools::new(&host.data_dir); + let tools = Tools::new(&host.local_data_dir); let whole_selection = range.is_some() && !config.range; let edits: IdeResult> = tokio::task::spawn_blocking(move || { let text = ide_text::Document::new(&snap.text); diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 5937b972..66e21c9b 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -3,6 +3,7 @@ //! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server` //! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]). +pub mod data_dirs; pub mod environment; mod formatter; mod java_runtime; @@ -33,7 +34,10 @@ pub type SinkFactory = Box SharedSink + Send + Sync>; pub struct HostConfig { pub config_dir: PathBuf, + /// Persistent user state, including credentials and recovery history. pub data_dir: PathBuf, + /// Machine-local tools and rebuildable caches. + pub local_data_dir: PathBuf, pub languages: Arc, /// Grammars, possibly still being built on another thread. pub syntax: Arc>>, @@ -49,10 +53,14 @@ pub struct HostConfig { pub struct Host { pub trust: trust::TrustStore, pub config_dir: PathBuf, + /// Persistent user state, including credentials and recovery history. pub data_dir: PathBuf, + /// Machine-local tools and rebuildable caches. + pub local_data_dir: PathBuf, pub languages: Arc, syntax: Arc>>, pub global_settings: SettingsStore, + pub dependency_versions: Arc, pub terminals: TerminalManager, pub processes: ProcessManager, /// Debug sessions (adapters run on this machine, next to the project). @@ -76,8 +84,17 @@ pub struct Host { impl Host { pub fn new(config: HostConfig) -> Self { - let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime, project_environment } = - config; + let HostConfig { + config_dir, + data_dir, + local_data_dir, + languages, + syntax, + sink_for, + owner, + runtime, + project_environment, + } = config; let global_settings = SettingsStore::load(&config_dir, None); let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json"))); ssh.set_configs(global_settings.effective().ssh.configs); @@ -86,8 +103,11 @@ impl Host { proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")), remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))), ssh, - environment: environment::Environment::new(project_environment, data_dir.clone()), + environment: environment::Environment::new(project_environment, local_data_dir.clone()), global_settings, + dependency_versions: Arc::new(ide_workspace::dependencies::VersionCache::new( + data_dir.join("cache/dependency-versions"), + )), // Sessions, runs and terminals report to the window that started them (`window_sink`). terminals: TerminalManager::new(), processes: ProcessManager::new(), @@ -96,6 +116,7 @@ impl Host { syntax, config_dir, data_dir, + local_data_dir, sink_for, owner, runtime, diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index 47f089f9..f72d603c 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -25,7 +25,7 @@ pub fn providers(host: &Host, window: &str) -> IdeResult> { /// Where ACP Registry agents are saved (`/agents`, like the user's own) and installed. pub(crate) fn registry_paths(host: &Host) -> RegistryPaths { - RegistryPaths::new(&host.config_dir, &host.data_dir) + RegistryPaths::new(&host.config_dir, &host.local_data_dir) } fn join_err(e: impl std::fmt::Display) -> IdeError { diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 492ce842..2af1eedd 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -39,7 +39,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide .await?; } if config.adapter == ide_dap::kotlin::ADAPTER { - let data_dir = host.data_dir.clone(); + let data_dir = host.local_data_dir.clone(); let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default(); let runtime_settings = settings.clone(); let runtime = host @@ -96,7 +96,13 @@ fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchC }; let vm_args = extra.get("vmArgs").and_then(|v| v.as_str()).unwrap_or_default(); let encoding = extra.get("encoding").and_then(|v| v.as_str()); - if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(vm_args, encoding, windows) { + if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args( + vm_args, + &config.env, + |key| std::env::var(key).ok(), + encoding, + windows, + ) { extra.insert("vmArgs".into(), vm_args.into()); config.extra_json = Some(serde_json::Value::Object(extra).to_string()); } @@ -452,7 +458,7 @@ mod tests { program: None, args: vec![], cwd: None, - env: Default::default(), + env: [("JDK_JAVA_OPTIONS".into(), "".into()), ("JAVA_TOOL_OPTIONS".into(), "".into())].into(), extra_json: Some(extra.to_string()), } } @@ -468,7 +474,7 @@ mod tests { let fixed = match_java_debug_encoding(launch("java-debug", "launch", extra.clone()), true); assert_eq!( vm_args(&fixed), - "-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8" + "-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -Dfile.encoding=UTF-8" ); assert_eq!( serde_json::from_str::(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"], @@ -481,4 +487,13 @@ mod tests { assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g"); } } + + #[test] + fn java_debug_launches_respect_environment_encoding() { + for key in ["JDK_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS"] { + let mut config = launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Dstdout.encoding=GBK"})); + config.env.insert(key.into(), "-Dfile.encoding=GBK".into()); + assert_eq!(vm_args(&match_java_debug_encoding(config, true)), "-Dstdout.encoding=GBK"); + } + } } diff --git a/crates/ide-host/src/ops/dependencies.rs b/crates/ide-host/src/ops/dependencies.rs new file mode 100644 index 00000000..96dfad9b --- /dev/null +++ b/crates/ide-host/src/ops/dependencies.rs @@ -0,0 +1,123 @@ +//! Repository version lookups for manifest inspections and completion. +use crate::{Host, rpc::Dispatcher}; +use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId}; +use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo}; + +struct CratesIo; +impl DependencyVersionSource for CratesIo { + fn id(&self) -> &'static str { + "crates-io" + } + + fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult> { + let path = cargo::index_path(package).ok_or_else(|| IdeError::internal("invalid crate name"))?; + let search_path = std::env::var_os("PATH").unwrap_or_default(); + let text = ide_tools::Http::new(&search_path)?.https_only().get_text_limited( + &format!("https://index.crates.io/{path}"), + Some(16 * 1024 * 1024), + cancel, + )?; + cargo::parse_index(&text).ok_or_else(|| IdeError::internal("invalid sparse index")) + } +} + +/// `offset` limits a completion request to its version string; inspections omit it. +pub async fn dependency_versions( + host: &Host, + window: &str, + doc: DocId, + offset: Option, + request: RequestId, +) -> IdeResult> { + let project = host.project(window)?; + let cache = host.dependency_versions.clone(); + let cancel = host.begin_request(request); + let result = host + .blocking(move || { + let snap = project.documents.snapshot(doc)?; + if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + for dependency in cargo::dependencies(&snap.text) { + if offset.is_some_and(|pos| pos < dependency.from || pos > dependency.to) { + continue; + } + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let versions = cache.versions(&CratesIo, &dependency.package, &cancel)?; + let update = cargo::update(&dependency.requirement, &versions); + out.push(DependencyVersions { dependency, versions, update }); + } + Ok(out) + }) + .await; + host.end_request(request); + result +} + +pub(crate) fn register(d: &mut Dispatcher) { + d.add("dependency_versions", |c| async move { + dependency_versions(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?, c.arg("request")?).await + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::HostConfig; + use std::sync::Arc; + + struct Source; + impl DependencyVersionSource for Source { + fn id(&self) -> &'static str { + "crates-io" + } + fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult> { + Ok(vec!["1.10.0".into(), "0.2.1".into()]) + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn host_uses_synced_document_cache_and_cursor_filter_without_network() { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + std::fs::create_dir(&root).unwrap(); + let text = "# 中文 😀\n[dependencies]\ntokio = { version = \"0.2\", features = [\"full\"] }\nserde = \"1.0.100\"\nlocal = { path = \"../x\", version = \"0.2\" }\n"; + let path = root.join("Cargo.toml"); + std::fs::write(&path, text).unwrap(); + 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(|_| Arc::new(|_| {})), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + let project = host.open_project("main", &root).unwrap(); + let info = project.documents.open(&path).unwrap(); + for package in ["tokio", "serde"] { + host.dependency_versions.versions(&Source, package, &CancellationToken::new()).unwrap(); + } + let all = dependency_versions(&host, "main", info.id, None, RequestId(410)).await.unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].update.as_ref().unwrap().replacement, "1.10"); + assert!(all[1].update.is_none()); + let offset = all[1].dependency.from; + let at_cursor = dependency_versions(&host, "main", info.id, Some(offset), RequestId(411)).await.unwrap(); + assert_eq!(at_cursor.len(), 1); + assert_eq!(at_cursor[0].dependency.package, "serde"); + assert!(dependency_versions(&host, "main", info.id, Some(0), RequestId(412)).await.unwrap().is_empty()); + host.cancel_request(RequestId(413)); + assert!(dependency_versions(&host, "main", info.id, None, RequestId(413)).await.is_err()); + let other = root.join("other.toml"); + std::fs::write(&other, text).unwrap(); + let info = project.documents.open(&other).unwrap(); + assert!(dependency_versions(&host, "main", info.id, None, RequestId(414)).await.unwrap().is_empty()); + host.close_project("main"); + } +} diff --git a/crates/ide-host/src/ops/doc/tests.rs b/crates/ide-host/src/ops/doc/tests.rs index 2f8d8bda..6c649bed 100644 --- a/crates/ide-host/src/ops/doc/tests.rs +++ b/crates/ide-host/src/ops/doc/tests.rs @@ -30,6 +30,7 @@ impl Fixture { let host = Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(move |_| { diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index 47beb38d..d1cc0a61 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -61,7 +61,7 @@ async fn resolve_launch( let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None }; let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?; let lsp_ms = started.elapsed().as_millis(); - let data_dir = host.data_dir.clone(); + let data_dir = host.local_data_dir.clone(); let cancel = cancel.clone(); tokio::task::spawn_blocking(move || { let _external_build = external_build; diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs index 228d02fc..93a0b28f 100644 --- a/crates/ide-host/src/ops/mod.rs +++ b/crates/ide-host/src/ops/mod.rs @@ -8,6 +8,7 @@ pub mod build; pub mod bytes; pub mod coverage; pub mod debug; +pub mod dependencies; pub mod deploy; pub mod doc; pub mod env; @@ -69,6 +70,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) { analysis::register(d); syntax::register(d); spell::register(d); + dependencies::register(d); history::register(d); coverage::register(d); build::register(d); diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index 21f21e0a..8798e52a 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -50,10 +50,11 @@ fn detected_and_saved( suppressed: &[String], ) -> Vec { let excluded_poms = crate::project::unlinked_maven_poms(root, settings); - let mut all: Vec = ide_run::detect_excluding(root, &excluded_poms) - .into_iter() - .filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id)) - .collect(); + let mut all: Vec = + ide_run::detect_with_profiles(root, &excluded_poms, settings.cmake.profiles.as_deref()) + .into_iter() + .filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id)) + .collect(); all.extend(saved.iter().cloned()); for config in all.iter_mut().filter(|c| c.kind != "gradle") { ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings); diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs index f8f05b1f..b92399c2 100644 --- a/crates/ide-host/src/ops/tools.rs +++ b/crates/ide-host/src/ops/tools.rs @@ -12,7 +12,7 @@ use std::path::PathBuf; /// The installer, reporting the executables chosen in the window's settings. fn tools(host: &Host, window: &str) -> IdeResult { host.require_trust(window)?; - let data = host.data_dir.clone(); + let data = host.local_data_dir.clone(); let (settings, root) = match host.project(window) { Ok(project) => (project.settings.effective(), project.root.clone()), Err(_) => (host.global_settings.effective(), Default::default()), @@ -104,7 +104,7 @@ pub async fn jdk_download_releases( vendor: String, request: RequestId, ) -> IdeResult { - let tools = Tools::new(&host.data_dir); + let tools = Tools::new(&host.local_data_dir); let cancel = host.begin_request(request); let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err); host.end_request(request); @@ -113,7 +113,7 @@ pub async fn jdk_download_releases( /// Where Download JDK puts JDKs unless told otherwise. pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult { - Ok(ide_tools::jdk::jdks_dir(&host.data_dir).into()) + Ok(ide_tools::jdk::jdks_dir(&host.local_data_dir).into()) } /// Downloads the latest `vendor` JDK of Java `version` into `location` (default: @@ -130,7 +130,7 @@ pub async fn jdk_download( request: RequestId, on_event: Channel, ) -> IdeResult { - let tools = Tools::new(&host.data_dir); + let tools = Tools::new(&host.local_data_dir); let location = location.filter(|p| !p.as_os_str().is_empty()); let cancel = host.begin_request(request); let result = tokio::task::spawn_blocking(move || { diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index baf7e10e..faf28db0 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -112,7 +112,7 @@ impl Host { let project_key = project_key(&root); let index = ProjectIndex::open( &root, - &self.data_dir.join("index").join(&project_key), + &self.local_data_dir.join("index").join(&project_key), index_roots(&root, extra_roots.clone()), excludes.clone(), languages.clone(), @@ -127,7 +127,7 @@ impl Host { { let settings = settings.clone(); let root = root.clone(); - let data_dir = self.data_dir.clone(); + let data_dir = self.local_data_dir.clone(); let config_dir = self.config_dir.clone(); let sink = sink.clone(); let formatter_problem = std::sync::Mutex::new(None); @@ -214,7 +214,7 @@ impl Host { { let settings = settings.clone(); let root = root.clone(); - let data_dir = self.data_dir.clone(); + let data_dir = self.local_data_dir.clone(); let sink = sink.clone(); lsp.set_prepare_hook(Box::new(move |cfg, _, cancel| { let effective = settings.effective(); @@ -255,8 +255,8 @@ impl Host { let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new( root.clone(), - self.data_dir.join("cache").join(&project_key).join("build"), - &self.data_dir, + self.local_data_dir.join("cache").join(&project_key).join("build"), + &self.local_data_dir, settings.clone(), lsp.clone(), sink.clone(), @@ -385,7 +385,7 @@ impl Host { let (db, http_history, agents) = self.project_services(&root, &project_key); let notebooks = crate::notebook::NotebookService::new( - self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()), + self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()), sink.clone(), ); ide_agent::registry::maintain(crate::ops::agent::registry_paths(self)); @@ -404,9 +404,9 @@ impl Host { structure: Arc::new(StructureService::new(syntax.clone())), syntax, detected_roots, - cache_dir: self.data_dir.join("cache").join(&project_key), + cache_dir: self.local_data_dir.join("cache").join(&project_key), java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new( - self.data_dir.join("cache").join(&project_key).join("build"), + self.local_data_dir.join("cache").join(&project_key).join("build"), )), db, http_history, @@ -509,7 +509,7 @@ impl Host { let project_key = project_key(&dir); let (db, http_history, agents) = self.project_services(&dir, &project_key); let notebooks = crate::notebook::NotebookService::new( - self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()), + self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()), sink.clone(), ); let project = Arc::new(Project { @@ -526,9 +526,9 @@ impl Host { structure: Arc::new(StructureService::new(syntax.clone())), syntax, detected_roots: Arc::default(), - cache_dir: self.data_dir.join("cache").join(&project_key), + cache_dir: self.local_data_dir.join("cache").join(&project_key), java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new( - self.data_dir.join("cache").join(&project_key).join("build"), + self.local_data_dir.join("cache").join(&project_key).join("build"), )), db, http_history, diff --git a/crates/ide-host/tests/data_dirs.rs b/crates/ide-host/tests/data_dirs.rs new file mode 100644 index 00000000..557a2b7d --- /dev/null +++ b/crates/ide-host/tests/data_dirs.rs @@ -0,0 +1,39 @@ +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops}; +use std::sync::Arc; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn split_storage_keeps_user_history_and_uses_local_tools_and_caches() { + let tmp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(tmp.path()).unwrap(); + let data = base.join("roaming"); + let local = base.join("local"); + let root = base.join("project"); + std::fs::create_dir(&root).unwrap(); + std::fs::write(root.join("notes.txt"), "hello\n").unwrap(); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: data.clone(), + local_data_dir: local.clone(), + 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, + }); + assert_eq!( + std::path::PathBuf::from(ops::tools::jdk_download_location(&host, "main").unwrap()), + local.join("tools/jdks") + ); + let light = host.open_light("main", &root.join("notes.txt")).unwrap(); + assert!(light.cache_dir.starts_with(&local)); + assert!(data.join("history").exists()); + assert!(!local.join("history").exists()); + let project = host.open_project("main", &root).unwrap(); + assert!(project.cache_dir.starts_with(&local)); + assert!(local.join("index").exists()); + assert!(!data.join("index").exists()); + assert!(!data.join("tools").exists()); + host.shutdown().await; +} diff --git a/crates/ide-host/tests/light_edit.rs b/crates/ide-host/tests/light_edit.rs index d3732431..1eff9e38 100644 --- a/crates/ide-host/tests/light_edit.rs +++ b/crates/ide-host/tests/light_edit.rs @@ -9,6 +9,7 @@ fn host(base: &std::path::Path) -> Arc { Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/properties.rs b/crates/ide-host/tests/properties.rs index dce783dd..2d8e5a1a 100644 --- a/crates/ide-host/tests/properties.rs +++ b/crates/ide-host/tests/properties.rs @@ -20,6 +20,7 @@ fn host(base: &Path) -> Host { Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/proxy.rs b/crates/ide-host/tests/proxy.rs index cef7910d..cf74d5dd 100644 --- a/crates/ide-host/tests/proxy.rs +++ b/crates/ide-host/tests/proxy.rs @@ -10,6 +10,7 @@ fn host(base: &std::path::Path) -> Arc { Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/remote_env.rs b/crates/ide-host/tests/remote_env.rs index 5f1fbf7c..121f3bed 100644 --- a/crates/ide-host/tests/remote_env.rs +++ b/crates/ide-host/tests/remote_env.rs @@ -79,6 +79,7 @@ fn server_environment_from_login_shell_and_devcontainer() { let host = Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(move |_| sink_events.clone() as SharedSink), diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index 58e4ede3..2a101b01 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -56,6 +56,7 @@ async fn serves_projects_files_documents_search_and_runs() { let host = Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))), @@ -261,6 +262,7 @@ async fn local_deployment_runs_on_the_project_host() { let host = Arc::new(Host::new(HostConfig { config_dir: tmp.path().join("config"), data_dir: tmp.path().join("data"), + local_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()))), @@ -328,6 +330,7 @@ async fn properties_conversion_through_remote_dispatch_without_a_remote_host() { let host = Arc::new(Host::new(HostConfig { config_dir: tmp.path().join("config"), data_dir: tmp.path().join("data"), + local_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)), diff --git a/crates/ide-host/tests/run_configs.rs b/crates/ide-host/tests/run_configs.rs index 339121f7..9313c756 100644 --- a/crates/ide-host/tests/run_configs.rs +++ b/crates/ide-host/tests/run_configs.rs @@ -6,6 +6,7 @@ fn host(base: &std::path::Path) -> Arc { Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/terminal_activation.rs b/crates/ide-host/tests/terminal_activation.rs index 9561a23e..3678ffdb 100644 --- a/crates/ide-host/tests/terminal_activation.rs +++ b/crates/ide-host/tests/terminal_activation.rs @@ -24,6 +24,7 @@ fn terminal_activation_uses_the_resolved_default_shell() { let host = Arc::new(Host::new(HostConfig { config_dir: tmp.path().join("config"), data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index 4a1d0250..aa41a315 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -11,6 +11,7 @@ fn host(base: &Path, environment: bool) -> Arc { Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/trust_git_binary.rs b/crates/ide-host/tests/trust_git_binary.rs index 0002b46b..d4f27820 100644 --- a/crates/ide-host/tests/trust_git_binary.rs +++ b/crates/ide-host/tests/trust_git_binary.rs @@ -12,6 +12,7 @@ async fn restricted_status_does_not_select_the_project_git_for_other_windows() { let host = Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/vcs_encoding.rs b/crates/ide-host/tests/vcs_encoding.rs index 71b8e6a4..7da472b8 100644 --- a/crates/ide-host/tests/vcs_encoding.rs +++ b/crates/ide-host/tests/vcs_encoding.rs @@ -28,6 +28,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() { let host = Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/vcs_roots.rs b/crates/ide-host/tests/vcs_roots.rs index d0431a1a..b833e8ed 100644 --- a/crates/ide-host/tests/vcs_roots.rs +++ b/crates/ide-host/tests/vcs_roots.rs @@ -31,6 +31,7 @@ async fn patches_read_and_write_the_requested_repository() { let host = Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-host/tests/workspaces.rs b/crates/ide-host/tests/workspaces.rs index 86a8d9cd..cfa22542 100644 --- a/crates/ide-host/tests/workspaces.rs +++ b/crates/ide-host/tests/workspaces.rs @@ -22,6 +22,7 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di let host = Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), + local_data_dir: base.join("data"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(|_| Arc::new(NullSink)), diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index ca6d0cc4..82f36594 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -17,6 +17,14 @@ pub(crate) fn detect(root: &Path) -> Vec { } pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec { + detect_with_profiles(root, excluded_poms, None) +} + +pub(crate) fn detect_with_profiles( + root: &Path, + excluded_poms: &[PathBuf], + profiles: Option<&[ide_core::settings::CMakeProfile]>, +) -> Vec { let mut out = Vec::new(); detect_node(root, &mut out); detect_deno(root, &mut out); @@ -25,7 +33,7 @@ pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec) { +pub(super) fn detect(root: &Path, profiles: Option<&[CMakeProfile]>, out: &mut Vec) { if !root.join("CMakeLists.txt").is_file() { return; } + if let Some(profiles) = profiles { + for (index, profile) in profiles.iter().enumerate() { + detect_profile(root, profile, index, profiles.len() > 1, out); + } + return; + } let mut builds = Vec::new(); if let Ok(entries) = fs::read_dir(root) { builds.extend(entries.flatten().filter_map(|e| { @@ -79,11 +86,69 @@ pub(super) fn detect(root: &Path, out: &mut Vec) { ); before(&mut test, &build_id); out.push(test); - targets(root, &build, &dir, &prefix, out); + targets(root, &build, &dir, &prefix, None, out); } } -fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec) { +fn detect_profile(root: &Path, profile: &CMakeProfile, index: usize, multiple: bool, out: &mut Vec) { + let dir = if profile.build_dir.trim().is_empty() { + format!("cmake-build-{}", profile.build_type.to_lowercase()) + } else { + profile.build_dir.clone() + }; + let build = root.join(&dir); + let prefix = format!("cmake:profile:{index}"); + let configure_id = format!("{prefix}:configure"); + let build_id = format!("{prefix}:build"); + let start = out.len(); + let mut configure = + config(&configure_id, format!("CMake Configure ({dir})"), "cmake", "cmake", &["-S", ".", "-B", &dir]); + if !profile.generator.trim().is_empty() { + configure.args.extend(["-G".into(), profile.generator.clone()]); + } + configure.args.push(format!("-DCMAKE_BUILD_TYPE={}", profile.build_type)); + configure.args.push("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON".into()); + configure.args.extend_from_slice(&profile.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", &profile.build_type], + ); + all.args.extend_from_slice(&profile.build_args); + 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", &profile.build_type, "--output-on-failure"], + ); + before(&mut test, &build_id); + out.push(test); + targets(root, &build, &dir, &prefix, Some(profile), out); + for config in &mut out[start..] { + config.env = profile.env.clone(); + if multiple { + config.name = format!("{} [{}]", config.name, profile.name); + } + } +} + +fn targets( + root: &Path, + build: &Path, + dir: &str, + prefix: &str, + profile: Option<&CMakeProfile>, + out: &mut Vec, +) { let reply = build.join(".cmake/api/v1/reply"); let Some(index_path) = fs::read_dir(&reply).ok().and_then(|entries| { entries @@ -113,6 +178,9 @@ fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec crate::IdeResult<()> { && 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"); + let build = cwd.join(dir); + let generator = config.args.windows(2).find(|a| a[0] == "-G").map(|a| a[1].as_str()); + if let Some(generator) = generator + && let Ok(cache) = fs::read_to_string(build.join("CMakeCache.txt")) + && let Some(cached) = cache.lines().find_map(|l| l.strip_prefix("CMAKE_GENERATOR:INTERNAL=")) + && cached != generator + { + fs::remove_file(build.join("CMakeCache.txt"))?; + let files = build.join("CMakeFiles"); + if files.is_dir() { + fs::remove_dir_all(files)?; + } + } + let query = build.join(".cmake/api/v1/query/client-openidea"); fs::create_dir_all(&query)?; fs::write(query.join("codemodel-v2"), "")?; } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profiles_select_file_api_configuration_and_apply_target_options() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap(); + let reply = root.path().join("output/.cmake/api/v1/reply"); + fs::create_dir_all(&reply).unwrap(); + let write = |name: &str, value: Value| fs::write(reply.join(name), value.to_string()).unwrap(); + write( + "index-1.json", + serde_json::json!({"objects":[{"kind":"codemodel","version":{"major":2},"jsonFile":"model.json"}]}), + ); + write( + "model.json", + serde_json::json!({"paths":{"source":root.path()},"configurations":[ + {"name":"Debug","targets":[{"jsonFile":"debug.json"}]}, + {"name":"Release","targets":[{"jsonFile":"release.json"}]} + ]}), + ); + for name in ["debug", "release"] { + write( + &format!("{name}.json"), + serde_json::json!({"name":"app","type":"EXECUTABLE","artifacts":[{"path":format!("{name}/app")}]}), + ); + } + let profile = CMakeProfile { + name: "Production".into(), + build_type: "Release".into(), + build_dir: "output".into(), + build_args: ["-j", "8", "--", "verbose=1"].map(String::from).into(), + env: [("FOO".into(), "bar".into())].into(), + ..Default::default() + }; + let mut configs = Vec::new(); + detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configs); + let run = configs.iter().find(|c| c.kind == "native").unwrap(); + assert!(run.command.ends_with("release/app")); + assert_eq!(run.env, profile.env); + assert_eq!(configs.iter().filter(|c| c.kind == "native").count(), 1); + let build = configs.iter().find(|c| c.id == run.extras.before_launch[0].target).unwrap(); + assert_eq!( + build.args, + ["--build", "output", "--target", "app", "--config", "Release", "-j", "8", "--", "verbose=1"] + ); + assert_eq!(build.env, profile.env); + assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure"); + fs::write(root.path().join("output/CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Ninja\n").unwrap(); + let mut configured = Vec::new(); + detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configured); + for id in ["cmake:profile:0:build", build.id.as_str()] { + let build = configured.iter().find(|c| c.id == id).unwrap(); + assert!(build.extras.before_launch.is_empty(), "{id} should use CMake's automatic regeneration"); + } + } + + #[test] + fn generator_change_clears_only_cmake_state_at_launch() { + let root = tempfile::tempdir().unwrap(); + let build = root.path().join("cmake-build-debug"); + fs::create_dir_all(build.join("CMakeFiles")).unwrap(); + fs::write(build.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap(); + fs::write(build.join("keep.txt"), "artifact").unwrap(); + fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap(); + let mut configs = Vec::new(); + detect(root.path(), Some(&[CMakeProfile { generator: "Ninja".into(), ..Default::default() }]), &mut configs); + assert!(build.join("CMakeCache.txt").exists()); + prepare(&configs[0], root.path()).unwrap(); + assert!(!build.join("CMakeCache.txt").exists()); + assert!(!build.join("CMakeFiles").exists()); + assert!(build.join("keep.txt").exists()); + assert!(build.join(".cmake/api/v1/query/client-openidea/codemodel-v2").exists()); + } +} diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 22c1e8cd..f35036b1 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -202,6 +202,15 @@ pub fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec, +) -> Vec { + detect::detect_with_profiles(root, excluded_poms, profiles) +} + /// Reads `/.openidea/run.toml`; a missing file yields no configs. pub fn load_user_configs(root: &Path) -> IdeResult> { config::load(root) diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 601f9af7..57801933 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -652,3 +652,72 @@ fn go_default_build_output_is_ignored_by_version_control() { let ignore = fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap(); assert!(ignore.lines().any(|l| l == "/bin/"), "{ignore}"); } + +#[test] +fn cmake_profiles_control_commands_names_and_environment() { + use ide_core::settings::CMakeProfile; + let dir = project(&[("CMakeLists.txt", "project(app)")]); + for generator in ["", "Ninja", "Unix Makefiles", "Ninja Multi-Config", "Visual Studio 17 2022"] { + let profile = CMakeProfile { + name: "Production".into(), + build_type: "Release".into(), + generator: generator.into(), + cmake_args: ["-DFEATURE=ON", "-DMESSAGE=two words", "-DQUOTE=\"hello world\"", ""].map(String::from).into(), + build_dir: "output dir".into(), + build_args: ["-j", "8", "--", "VERBOSE=1"].map(String::from).into(), + env: [("MY_ENV".into(), "yes".into())].into(), + }; + let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(std::slice::from_ref(&profile))); + let configure = find(&configs, "cmake:profile:0:configure"); + let mut expected = vec!["-S", ".", "-B", "output dir"]; + if !generator.is_empty() { + expected.extend(["-G", generator]); + } + expected.extend([ + "-DCMAKE_BUILD_TYPE=Release", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + "-DFEATURE=ON", + "-DMESSAGE=two words", + "-DQUOTE=\"hello world\"", + "", + ]); + assert_eq!(configure.args, expected); + assert_eq!(configure.name, "CMake Configure (output dir)"); + let build = find(&configs, "cmake:profile:0:build"); + assert_eq!(build.args, ["--build", "output dir", "--config", "Release", "-j", "8", "--", "VERBOSE=1"]); + assert_eq!(build.extras.before_launch[0].target, configure.id); + assert!(configs.iter().all(|c| c.env == profile.env)); + let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(&[CMakeProfile::default(), profile])); + assert!(find(&configs, "cmake:profile:0:build").name.ends_with("[Debug]")); + assert!(find(&configs, "cmake:profile:1:build").name.ends_with("[Production]")); + assert_eq!(find(&configs, "cmake:profile:0:build").args[1], "cmake-build-debug"); + } + assert_eq!(ide_run::detect_with_profiles(dir.path(), &[], None), detect(dir.path())); + assert!(ide_run::detect_with_profiles(dir.path(), &[], Some(&[])).is_empty()); +} + +#[test] +fn cmake_profile_build_configures_only_without_a_cache() { + use ide_core::settings::CMakeProfile; + let root = project(&[("CMakeLists.txt", "project(app)")]); + for dir in ["", "custom output"] { + let profile = CMakeProfile { build_dir: dir.into(), generator: "Ninja".into(), ..Default::default() }; + let build_dir = root.path().join(if dir.is_empty() { "cmake-build-debug" } else { dir }); + for cached in [false, true] { + if cached { + fs::create_dir_all(&build_dir).unwrap(); + // Generator changes are handled by an explicit Configure, not every Build. + fs::write(build_dir.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap(); + } + let configs = ide_run::detect_with_profiles(root.path(), &[], Some(std::slice::from_ref(&profile))); + let build = find(&configs, "cmake:profile:0:build"); + if cached { + assert!(build.extras.before_launch.is_empty()); + } else { + assert_eq!(build.extras.before_launch.len(), 1); + assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure"); + } + assert_eq!(find(&configs, "cmake:profile:0:test").extras.before_launch[0].target, build.id); + } + } +} diff --git a/crates/ide-workspace/Cargo.toml b/crates/ide-workspace/Cargo.toml index cfb75f56..9036108e 100644 --- a/crates/ide-workspace/Cargo.toml +++ b/crates/ide-workspace/Cargo.toml @@ -15,6 +15,7 @@ serde_json.workspace = true specta.workspace = true toml.workspace = true toml_edit.workspace = true +semver = "1.0.28" parking_lot.workspace = true blake3.workspace = true tracing.workspace = true diff --git a/crates/ide-workspace/src/caches.rs b/crates/ide-workspace/src/caches.rs index 65dc1603..b1ff5ca9 100644 --- a/crates/ide-workspace/src/caches.rs +++ b/crates/ide-workspace/src/caches.rs @@ -23,16 +23,26 @@ pub struct InvalidateCachesOptions { const MARKER: &str = "invalidate-caches.json"; /// Data-directory folders removed for `options`. -pub fn cache_dirs(data_dir: &Path, options: &InvalidateCachesOptions) -> Vec { - let mut dirs = vec![data_dir.join("index")]; +pub fn cache_dirs(data_dir: &Path, local_data_dir: &Path, options: &InvalidateCachesOptions) -> Vec { + let mut dirs = vec![local_data_dir.join("index")]; if options.local_history { dirs.push(data_dir.join("history")); } if options.language_servers { - dirs.push(data_dir.join("lsp")); + dirs.push(local_data_dir.join("lsp")); } if options.build_caches { - dirs.push(data_dir.join("cache")); + dirs.push(local_data_dir.join("cache")); + } + // Also invalidate pre-migration caches so a later start cannot revive them. + if data_dir != local_data_dir { + dirs.push(data_dir.join("index")); + if options.language_servers { + dirs.push(data_dir.join("lsp")); + } + if options.build_caches { + dirs.push(data_dir.join("cache")); + } } dirs } @@ -57,11 +67,11 @@ pub fn cancel(data_dir: &Path) { /// Carries out a scheduled invalidation (call at start-up, before projects /// open). Returns the folders removed. -pub fn apply_pending(data_dir: &Path) -> Vec { +pub fn apply_pending(data_dir: &Path, local_data_dir: &Path) -> Vec { let Some(options) = pending(data_dir) else { return Vec::new() }; cancel(data_dir); let mut removed = Vec::new(); - for dir in cache_dirs(data_dir, &options) { + for dir in cache_dirs(data_dir, local_data_dir, &options) { match fs::remove_dir_all(&dir) { Ok(()) => removed.push(dir), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} @@ -75,6 +85,34 @@ pub fn apply_pending(data_dir: &Path) -> Vec { mod tests { use super::*; + #[test] + fn split_roots_invalidate_local_caches_and_roaming_history() { + let tmp = tempfile::tempdir().unwrap(); + let data = tmp.path().join("roaming"); + let local = tmp.path().join("local"); + let options = InvalidateCachesOptions { local_history: true, language_servers: true, build_caches: true }; + assert_eq!( + cache_dirs(&data, &local, &options), + vec![ + local.join("index"), + data.join("history"), + local.join("lsp"), + local.join("cache"), + data.join("index"), + data.join("lsp"), + data.join("cache") + ] + ); + for path in cache_dirs(&data, &local, &options) { + fs::create_dir_all(path).unwrap(); + } + fs::create_dir_all(local.join("tools")).unwrap(); + schedule(&data, &options).unwrap(); + assert_eq!(apply_pending(&data, &local).len(), 7); + assert!(local.join("tools").exists()); + assert_eq!(pending(&data), None); + } + #[test] fn scheduled_invalidation_runs_once() { let tmp = tempfile::tempdir().unwrap(); @@ -82,16 +120,16 @@ mod tests { for d in ["index/p1", "history/p1", "lsp/jdtls/x", "cache/p1/build", "tools/jdk"] { fs::create_dir_all(data.join(d)).unwrap(); } - assert!(apply_pending(data).is_empty()); + assert!(apply_pending(data, data).is_empty()); let opts = InvalidateCachesOptions { local_history: true, language_servers: false, build_caches: true }; schedule(data, &opts).unwrap(); assert_eq!(pending(data), Some(opts)); - let removed = apply_pending(data); + let removed = apply_pending(data, data); assert_eq!(removed, vec![data.join("index"), data.join("history"), data.join("cache")]); assert!(data.join("lsp/jdtls/x").exists() && data.join("tools/jdk").exists()); assert_eq!(pending(data), None); - assert!(apply_pending(data).is_empty()); + assert!(apply_pending(data, data).is_empty()); schedule(data, &InvalidateCachesOptions::default()).unwrap(); cancel(data); diff --git a/crates/ide-workspace/src/dependencies/cache.rs b/crates/ide-workspace/src/dependencies/cache.rs new file mode 100644 index 00000000..7be33fe7 --- /dev/null +++ b/crates/ide-workspace/src/dependencies/cache.rs @@ -0,0 +1,102 @@ +use super::DependencyVersionSource; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const TTL: Duration = Duration::from_secs(3600); +const FAILURE_TTL: Duration = Duration::from_secs(30); +const MAX_DISK_BYTES: u64 = 2 * 1024 * 1024; +type Slot = Arc>>; + +#[derive(Clone, Serialize, Deserialize)] +struct Cached { + fetched: u64, + versions: Vec, + #[serde(skip)] + failed: bool, +} + +/// Same-package lookups share one lock and fetch. Different packages don't block +/// each other; waiting callers can cancel independently of the active fetch. +pub struct VersionCache { + directory: PathBuf, + entries: Mutex>, + ttl: Duration, +} + +impl VersionCache { + pub fn new(directory: PathBuf) -> Self { + Self { directory, entries: Mutex::default(), ttl: TTL } + } + + pub fn versions( + &self, + source: &dyn DependencyVersionSource, + package: &str, + cancel: &CancellationToken, + ) -> IdeResult> { + let safe = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b)); + if !safe(source.id()) || !safe(package) || package.len() > 64 { + return Ok(Vec::new()); + } + let key = format!("{}-{}", source.id(), package.to_ascii_lowercase()); + let slot = self.entries.lock().entry(key.clone()).or_default().clone(); + let mut cached = loop { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + if let Some(guard) = slot.try_lock_for(Duration::from_millis(50)) { + break guard; + } + }; + let checked_at = now(); + let fresh = |c: &Cached| { + c.fetched <= checked_at && checked_at - c.fetched < if c.failed { FAILURE_TTL } else { self.ttl }.as_secs() + }; + if let Some(entry) = cached.as_ref().filter(|c| fresh(c)) { + return Ok(entry.versions.clone()); + } + let path = self.directory.join(format!("{key}.json")); + let disk = std::fs::metadata(&path) + .ok() + .filter(|m| m.len() <= MAX_DISK_BYTES) + .and_then(|_| std::fs::read(&path).ok()) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .filter(fresh); + if let Some(entry) = disk { + let versions = entry.versions.clone(); + *cached = Some(entry); + return Ok(versions); + } + let result = source.fetch(package, cancel); + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let failed = result.is_err(); + let versions = result.unwrap_or_default(); + let entry = Cached { fetched: now(), versions: versions.clone(), failed }; + if !failed + && let Ok(bytes) = serde_json::to_vec(&entry) + && std::fs::create_dir_all(&self.directory).is_ok() + { + let temporary = path.with_extension("tmp"); + if std::fs::write(&temporary, bytes).is_ok() { + let _ = std::fs::rename(&temporary, &path); + let _ = std::fs::remove_file(&temporary); + } + } + *cached = Some(entry); + Ok(versions) + } +} + +fn now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() +} + +#[cfg(test)] +mod tests; diff --git a/crates/ide-workspace/src/dependencies/cache/tests.rs b/crates/ide-workspace/src/dependencies/cache/tests.rs new file mode 100644 index 00000000..c8426510 --- /dev/null +++ b/crates/ide-workspace/src/dependencies/cache/tests.rs @@ -0,0 +1,97 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; + +struct Source { + calls: AtomicUsize, + fail: bool, +} +impl DependencyVersionSource for Source { + fn id(&self) -> &'static str { + "test" + } + fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult> { + self.calls.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(20)); + if self.fail { Err(IdeError::unavailable("offline")) } else { Ok(vec!["2.0.0".into()]) } + } +} +fn source(fail: bool) -> Source { + Source { calls: AtomicUsize::new(0), fail } +} + +#[test] +fn memory_disk_ttl_corruption_and_failed_cache_writes() { + let dir = tempfile::tempdir().unwrap(); + let s = source(false); + let cancel = CancellationToken::new(); + let cache = VersionCache::new(dir.path().into()); + for _ in 0..2 { + assert_eq!(cache.versions(&s, "foo", &cancel).unwrap(), ["2.0.0"]); + } + assert_eq!(s.calls.load(Ordering::SeqCst), 1); + VersionCache::new(dir.path().into()).versions(&s, "foo", &cancel).unwrap(); + assert_eq!(s.calls.load(Ordering::SeqCst), 1, "disk hit after service restart"); + let mut expired = VersionCache::new(dir.path().into()); + expired.ttl = Duration::ZERO; + expired.versions(&s, "foo", &cancel).unwrap(); + assert_eq!(s.calls.load(Ordering::SeqCst), 2); + std::fs::write(dir.path().join("test-foo.json"), b"corrupt").unwrap(); + VersionCache::new(dir.path().into()).versions(&s, "foo", &cancel).unwrap(); + assert_eq!(s.calls.load(Ordering::SeqCst), 3); + let file = dir.path().join("not-a-directory"); + std::fs::write(&file, "x").unwrap(); + assert_eq!(VersionCache::new(file).versions(&s, "foo", &cancel).unwrap(), ["2.0.0"]); +} + +#[test] +fn concurrent_fetches_coalesce_and_failures_are_silent_and_short_cached() { + let dir = tempfile::tempdir().unwrap(); + let cache = VersionCache::new(dir.path().into()); + let s = source(false); + std::thread::scope(|scope| { + for _ in 0..8 { + scope.spawn(|| assert_eq!(cache.versions(&s, "foo", &CancellationToken::new()).unwrap(), ["2.0.0"])); + } + }); + assert_eq!(s.calls.load(Ordering::SeqCst), 1); + let s = source(true); + for _ in 0..2 { + assert!(cache.versions(&s, "offline", &CancellationToken::new()).unwrap().is_empty()); + } + assert_eq!(s.calls.load(Ordering::SeqCst), 1); + assert!(!dir.path().join("test-offline.json").exists(), "failures aren't persisted"); + let slot = cache.entries.lock().get("test-offline").unwrap().clone(); + slot.lock().as_mut().unwrap().fetched -= 31; + cache.versions(&s, "offline", &CancellationToken::new()).unwrap(); + assert_eq!(s.calls.load(Ordering::SeqCst), 2); +} + +#[test] +fn cancellation_and_invalid_cache_keys_do_not_fetch() { + let dir = tempfile::tempdir().unwrap(); + let cache = VersionCache::new(dir.path().into()); + let s = source(false); + let token = CancellationToken::new(); + token.cancel(); + assert!(cache.versions(&s, "foo", &token).is_err()); + assert!(cache.versions(&s, "../foo", &CancellationToken::new()).unwrap().is_empty()); + assert_eq!(s.calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn cancelled_waiter_does_not_cancel_the_shared_fetch() { + let dir = tempfile::tempdir().unwrap(); + let cache = VersionCache::new(dir.path().into()); + let s = source(false); + let slot = Arc::new(Mutex::new(None)); + cache.entries.lock().insert("test-foo".into(), slot.clone()); + let guard = slot.lock(); + let token = CancellationToken::new(); + std::thread::scope(|scope| { + let waiter = scope.spawn(|| cache.versions(&s, "foo", &token)); + token.cancel(); + assert!(waiter.join().unwrap().is_err()); + }); + drop(guard); + assert_eq!(cache.versions(&s, "foo", &CancellationToken::new()).unwrap(), ["2.0.0"]); +} diff --git a/crates/ide-workspace/src/dependencies/cargo.rs b/crates/ide-workspace/src/dependencies/cargo.rs new file mode 100644 index 00000000..a63e701e --- /dev/null +++ b/crates/ide-workspace/src/dependencies/cargo.rs @@ -0,0 +1,150 @@ +use super::{DependencyUpdate, ManifestDependency}; +use semver::{Op, Version, VersionReq}; +use serde::Deserialize; +use toml_edit::{Document, Item, TableLike}; + +/// Cargo's sparse-index path, including the special cases for short names. +pub fn index_path(name: &str) -> Option { + if name.is_empty() + || name.len() > 64 + || !name.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_')) + || !name.as_bytes()[0].is_ascii_alphabetic() + { + return None; + } + let name = name.to_ascii_lowercase(); + Some(match name.len() { + 1 => format!("1/{name}"), + 2 => format!("2/{name}"), + 3 => format!("3/{}/{name}", &name[..1]), + _ => format!("{}/{}/{name}", &name[..2], &name[2..4]), + }) +} + +pub fn parse_index(text: &str) -> Option> { + #[derive(Deserialize)] + struct Entry { + vers: String, + #[serde(default)] + yanked: bool, + } + let mut versions = Vec::new(); + for line in text.lines().filter(|l| !l.trim().is_empty()) { + let entry: Entry = serde_json::from_str(line).ok()?; + if !entry.yanked { + versions.push(Version::parse(&entry.vers).ok()?); + } + } + versions.sort_unstable_by(|a, b| b.cmp(a)); + versions.dedup(); + Some(versions.into_iter().map(|v| v.to_string()).collect()) +} + +/// A complete TOML parser supplies spans, so strings/comments that resemble tables +/// never become dependencies. Broken documents are silent until they parse again. +pub fn dependencies(text: &str) -> Vec { + let Ok(doc) = text.parse::>() else { return Vec::new() }; + let mut out = Vec::new(); + sections(doc.as_table(), text, &mut out); + if let Some(workspace) = doc.get("workspace").and_then(Item::as_table_like) + && let Some(deps) = workspace.get("dependencies").and_then(Item::as_table_like) + { + collect(deps, text, &mut out); + } + if let Some(targets) = doc.get("target").and_then(Item::as_table_like) { + for (_, target) in targets.iter() { + if let Some(target) = target.as_table_like() { + sections(target, text, &mut out); + } + } + } + out.sort_unstable_by_key(|d| d.from); + out +} + +fn sections(table: &dyn TableLike, text: &str, out: &mut Vec) { + for section in ["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(deps) = table.get(section).and_then(Item::as_table_like) { + collect(deps, text, out); + } + } +} + +fn collect(table: &dyn TableLike, text: &str, out: &mut Vec) { + for (name, item) in table.iter() { + let (package, value) = if let Some(dep) = item.as_table_like() { + if ["path", "git", "registry", "registry-index"].iter().any(|k| dep.contains_key(k)) + || dep.get("workspace").and_then(Item::as_bool) == Some(true) + { + continue; + } + let Some(version) = dep.get("version") else { continue }; + (dep.get("package").and_then(Item::as_str).unwrap_or(name), version) + } else { + (name, item) + }; + if index_path(package).is_none() { + continue; + } + let Some(requirement) = value.as_str() else { continue }; + let Some(span) = value.span() else { continue }; + let Some(raw) = text.get(span.clone()) else { continue }; + // Spans cover the encoded TOML string. Replace its entire contents, + // including escapes, while retaining the original quote delimiters. + let delimiter = if raw.starts_with("\"\"\"") || raw.starts_with("'''") { 3 } else { 1 }; + out.push(ManifestDependency { + package: package.to_owned(), + requirement: requirement.to_owned(), + from: text[..span.start + delimiter].encode_utf16().count() as u32, + to: text[..span.end - delimiter].encode_utf16().count() as u32, + }); + } +} + +pub fn update(requirement: &str, versions: &[String]) -> Option { + let req = VersionReq::parse(requirement).ok()?; + let prerelease = req.comparators.iter().any(|c| !c.pre.is_empty()); + let latest = + versions.iter().filter_map(|v| Version::parse(v).ok()).filter(|v| prerelease || v.pre.is_empty()).max()?; + if req.matches(&latest) { + return None; + } + // An unavailable future version isn't an invitation to downgrade. + if req.comparators.iter().any(|c| { + (c.op == Op::Greater + && c.patch.is_none() + && (latest.major < c.major + || (latest.major == c.major && c.minor.is_none_or(|minor| latest.minor <= minor)))) + || !matches!(c.op, Op::Less | Op::LessEq) + && latest + <= Version { + major: c.major, + minor: c.minor.unwrap_or(0), + patch: c.patch.unwrap_or(0), + pre: c.pre.clone(), + build: Default::default(), + } + }) { + return None; + } + let trimmed = requirement.trim(); + let digits = trimmed.trim_start_matches(['^', '~', '=']).trim_start(); + let simple = digits.split('.').all(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())); + let mut replacement = latest.to_string(); + if simple && latest.pre.is_empty() { + let precision = digits.split('.').count(); + let version = match precision { + 1 => latest.major.to_string(), + 2 => format!("{}.{}", latest.major, latest.minor), + _ => latest.to_string(), + }; + let candidate = format!("{}{version}", &trimmed[..trimmed.len() - digits.len()]); + if VersionReq::parse(&candidate).is_ok_and(|r| r.matches(&latest)) { + replacement = candidate; + } + } + Some(DependencyUpdate { latest: latest.to_string(), replacement }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ide-workspace/src/dependencies/cargo/tests.rs b/crates/ide-workspace/src/dependencies/cargo/tests.rs new file mode 100644 index 00000000..7be9927d --- /dev/null +++ b/crates/ide-workspace/src/dependencies/cargo/tests.rs @@ -0,0 +1,143 @@ +use super::*; + +#[test] +fn sparse_paths_and_input_validation() { + for (name, path) in + [("A", "1/a"), ("ab", "2/ab"), ("AbC", "3/a/abc"), ("serde", "se/rd/serde"), ("my_crate", "my/_c/my_crate")] + { + assert_eq!(index_path(name).as_deref(), Some(path)); + } + for name in ["", "../serde", "a/b", "sé", "-foo", "a?b", "1abc"] { + assert!(index_path(name).is_none(), "{name}"); + } +} + +#[test] +fn sparse_index_filters_yanked_sorts_semver_and_keeps_prereleases_for_completion() { + let text = r#"{"vers":"1.9.0","yanked":false} +{"vers":"2.0.0-beta.1","yanked":false} +{"vers":"9.0.0","yanked":true} +{"vers":"1.10.0","yanked":false} +{"vers":"1.10.0"}"#; + assert_eq!(parse_index(text).unwrap(), ["2.0.0-beta.1", "1.10.0", "1.9.0"]); + assert!(parse_index("{broken").is_none()); + assert!(parse_index(r#"{"vers":"bogus"}"#).is_none()); +} + +#[test] +fn cargo_requirements_updates_and_precision() { + for (req, latest, expected) in [ + ("1", "1.9.0", None), + ("1.0.100", "1.0.228", None), + ("^1.2", "1.9.0", None), + ("0.7", "0.7.9", None), + ("0.0.1", "0.0.2", Some("0.0.2")), + ("1", "2.3.4", Some("2")), + ("1.2", "2.3.4", Some("2.3")), + ("1.2.3", "2.3.0", Some("2.3.0")), + ("1.2.3", "1.3.0", None), + ("0.2", "1.3.0", Some("1.3")), + ("=1.2.3", "1.3.0", Some("=1.3.0")), + ("~1.2", "1.3.0", Some("~1.3")), + ("^0.2", "0.3.1", Some("^0.3")), + ("1.*", "1.9.0", None), + ("*", "2.3.4", None), + (">=1, <2", "2.3.4", Some("2.3.4")), + (">=1, <2", "2.0.0", Some("2.0.0")), + ("<1", "1.0.0", Some("1.0.0")), + (">=1, <3", "2.3.4", None), + (">=9", "2.3.4", None), + (">1", "1.9.0", None), + (">1.2", "1.2.9", None), + (">1.2.3", "1.2.3", None), + ("=9.1.2", "2.3.4", None), + ("bad", "2.3.4", None), + ("1.0", "2.0.0-beta.1", None), + ("1.0.0-beta.1", "1.0.0-beta.2", None), + ("=1.0.0-beta.1", "1.0.0-beta.2", Some("1.0.0-beta.2")), + ("1.0.0-beta.1", "2.0.0-beta.1", Some("2.0.0-beta.1")), + ] { + let actual = update(req, &[latest.to_owned()]); + assert_eq!(actual.as_ref().map(|u| u.replacement.as_str()), expected, "{req} -> {latest}"); + if let Some(u) = actual { + assert!(VersionReq::parse(&u.replacement).unwrap().matches(&Version::parse(latest).unwrap())); + } + } + let versions = vec!["3.0.0-beta.1".into(), "2.0.0".into(), "1.0.0".into()]; + assert_eq!(update("1", &versions).unwrap().latest, "2.0.0"); +} + +#[test] +fn locates_all_cargo_sections_and_only_replaces_version_contents() { + let text = r#"# 中文 😀 +[package] +name = "fixture" +version = "1.0.0" +[dependencies] +serde = "1.0.100" # stays +rand = '0.7' +tokio = { version = "0.2", features = ["full"] } +renamed = { package = "real-crate", version = "1.2" } +local = { path = "../local", version = "1" } +git = { git = "https://example.com/repo", version = "1" } +inherited = { workspace = true } +other = { registry = "private", version = "1" } +[dev-dependencies] +check = "1" +[build-dependencies.build] +version = "1.2.3" +[target.'cfg(unix)'.dependencies] +native = "0.1" +[target.'cfg(windows)'.dev-dependencies] +win = { version = "1" } +[target.'cfg(unix)'.build-dependencies] +build-native = "2" +[workspace.dependencies] +shared = { version = "1", features = ["std"] } +[package.metadata.dependencies] +not-a-dependency = "1" +"#; + let deps = dependencies(text); + assert_eq!( + deps.iter().map(|d| d.package.as_str()).collect::>(), + ["serde", "rand", "tokio", "real-crate", "check", "build", "native", "win", "build-native", "shared"] + ); + let utf16: Vec<_> = text.encode_utf16().collect(); + for dep in &deps { + assert_eq!(String::from_utf16(&utf16[dep.from as usize..dep.to as usize]).unwrap(), dep.requirement); + } + let tokio = &deps[2]; + let mut edited = utf16.clone(); + edited.splice(tokio.from as usize..tokio.to as usize, "1.3".encode_utf16()); + assert_eq!(String::from_utf16(&edited).unwrap(), text.replace("version = \"0.2\"", "version = \"1.3\"")); + let rand = &deps[1]; + let mut edited = utf16; + edited.splice(rand.from as usize..rand.to as usize, "0.9".encode_utf16()); + assert_eq!(String::from_utf16(&edited).unwrap(), text.replace("'0.7'", "'0.9'")); +} + +#[test] +fn invalid_toml_and_decoy_tables_are_silent() { + assert!(dependencies("[dependencies]\na = \"").is_empty()); + assert!(dependencies("[package]\ndescription = '''\n[dependencies]\na = \"1\"\n'''\n").is_empty()); + assert!(dependencies("[dependencies]\na = { version = 1 }\nb = { workspace = true, version = \"1\" }\nc = { version = \"1\", registry-index = \"x\" }").is_empty()); + let deps = dependencies("[dependencies]\na = \"\"\n"); + assert_eq!(deps.len(), 1, "empty version strings still offer completion"); + assert_eq!(deps[0].from, deps[0].to); +} + +#[test] +fn replaces_encoded_and_multiline_strings_without_damaging_quotes() { + for raw in [r#""0.\u0032""#, "\"\"\"\n0.2\"\"\"", "'''0.2'''"] { + let text = format!("[dependencies]\ntokio = {{ version = {raw}, features = [\"full\"] }}\n"); + let dep = dependencies(&text).pop().unwrap(); + assert_eq!(dep.requirement, "0.2"); + let mut encoded: Vec<_> = text.encode_utf16().collect(); + encoded.splice(dep.from as usize..dep.to as usize, "1.10".encode_utf16()); + let edited = String::from_utf16(&encoded).unwrap(); + assert!(edited.contains("features = [\"full\"]")); + assert_eq!(dependencies(&edited)[0].requirement, "1.10"); + assert_eq!(edited.matches('"').count(), text.matches('"').count()); + assert_eq!(edited.matches('\'').count(), text.matches('\'').count()); + } +} diff --git a/crates/ide-workspace/src/dependencies/mod.rs b/crates/ide-workspace/src/dependencies/mod.rs new file mode 100644 index 00000000..eef3a69a --- /dev/null +++ b/crates/ide-workspace/src/dependencies/mod.rs @@ -0,0 +1,41 @@ +//! Manifest dependencies and repository versions, independent of editor and transport. +mod cache; +pub mod cargo; + +pub use cache::VersionCache; +use ide_core::{CancellationToken, IdeResult}; +use serde::{Deserialize, Serialize}; +use specta::Type; + +/// A repository adapter; Maven/npm can use the same cache and editor protocol. +pub trait DependencyVersionSource: Send + Sync { + /// A filesystem-safe, stable repository identifier. + fn id(&self) -> &'static str; + /// Non-yanked versions in descending semantic order. + fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult>; +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DependencyUpdate { + pub latest: String, + pub replacement: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ManifestDependency { + pub package: String, + pub requirement: String, + /// UTF-16 offsets of the string's contents (quotes excluded). + pub from: u32, + pub to: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DependencyVersions { + pub dependency: ManifestDependency, + pub versions: Vec, + pub update: Option, +} diff --git a/crates/ide-workspace/src/lib.rs b/crates/ide-workspace/src/lib.rs index 1ab611e6..38ba30ca 100644 --- a/crates/ide-workspace/src/lib.rs +++ b/crates/ide-workspace/src/lib.rs @@ -1,6 +1,7 @@ //! The project model that ties documents, settings and history together. pub mod caches; +pub mod dependencies; pub mod devcontainer; pub mod documents; pub mod editorconfig; diff --git a/perf/compare/apps.toml b/perf/compare/apps.toml index 30c01d25..5ebb48b3 100644 --- a/perf/compare/apps.toml +++ b/perf/compare/apps.toml @@ -22,7 +22,7 @@ install_dir = { windows = "%LOCALAPPDATA%/OpenIDEA", macos = "/Applications/Open logs = { windows = "%LOCALAPPDATA%/ai.l0ng.openidea/logs/openidea*.log", macos = "~/Library/Logs/ai.l0ng.openidea/openidea*.log", linux = "~/.local/share/ai.l0ng.openidea/logs/openidea*.log" } # The index and build caches. jdtls keeps its own workspace under lsp/jdtls, # next to the jdtls install, so a cold Java run still reuses jdtls's build state. -cache_dirs = { windows = ["%APPDATA%/ai.l0ng.openidea/index", "%APPDATA%/ai.l0ng.openidea/cache"], macos = ["~/Library/Application Support/ai.l0ng.openidea/index", "~/Library/Application Support/ai.l0ng.openidea/cache"], linux = ["~/.local/share/ai.l0ng.openidea/index", "~/.local/share/ai.l0ng.openidea/cache"] } +cache_dirs = { windows = ["%LOCALAPPDATA%/ai.l0ng.openidea/index", "%LOCALAPPDATA%/ai.l0ng.openidea/cache"], macos = ["~/Library/Application Support/ai.l0ng.openidea/index", "~/Library/Application Support/ai.l0ng.openidea/cache"], linux = ["~/.local/share/ai.l0ng.openidea/index", "~/.local/share/ai.l0ng.openidea/cache"] } [apps.openidea.markers] project_opened = 'opened .* in window' diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5ebf05c5..ab942c55 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -78,3 +78,11 @@ objc2-core-foundation = { version = "0.3", default-features = false, features = objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] } # Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions. objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } diff --git a/src-tauri/src/commands/dependencies.rs b/src-tauri/src/commands/dependencies.rs new file mode 100644 index 00000000..ae5e9bbb --- /dev/null +++ b/src-tauri/src/commands/dependencies.rs @@ -0,0 +1,17 @@ +//! Typed IPC adapters for manifest dependency versions. +use crate::state::AppState; +use ide_core::{DocId, IdeResult, RequestId}; +use ide_workspace::dependencies::DependencyVersions; +use tauri::{State, Window}; + +#[tauri::command] +#[specta::specta] +pub async fn dependency_versions( + window: Window, + state: State<'_, AppState>, + doc: DocId, + offset: Option, + request: RequestId, +) -> IdeResult> { + ide_host::ops::dependencies::dependency_versions(&state.host, window.label(), doc, offset, request).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 45a35f66..ba282249 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -16,6 +16,7 @@ pub mod clipboard; pub mod coverage; pub mod db; pub mod debug; +pub mod dependencies; pub mod deploy; pub mod doc; pub mod docker; @@ -190,6 +191,7 @@ pub fn builder() -> tauri_specta::Builder { nav::fs_resolve_reference, nav::path_completions, nav::web_unresolved_references, + dependencies::dependency_versions, spell::spell_check, spell::spell_suggest, search::search_start, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aa413b6f..ed795e66 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,8 @@ mod theme_frame; #[cfg(target_os = "macos")] mod titlebar; mod window_groups; +#[cfg(any(windows, test))] +mod window_work_area; mod windows; use parking_lot::Mutex; @@ -53,7 +55,7 @@ pub fn run() { let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier); // Managed LSP/DAP installs; must run before other threads start and before PATH is cached. - if let Some(data) = dirs::data_dir() { + if let Some(data) = dirs::data_local_dir() { ide_tools::extend_process_path(&data.join(&context.config().identifier)); ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data.join(&context.config().identifier))); ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data.join(&context.config().identifier))); @@ -70,6 +72,9 @@ pub fn run() { // Single instance first: a second launch focuses this one and opens its path argument. // Debug builds have a separate identifier per worktree. let app = app.plugin(tauri_plugin_single_instance::init(windows::on_second_instance)); + // Install before window-state restores maximized geometry during window creation. + #[cfg(windows)] + let app = app.plugin(window_work_area::init()); let app = app.plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_dialog::init()); #[cfg(target_os = "linux")] let app = app.plugin(linux_window_state::plugin()); diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index 5bcf0cb5..e45f7eef 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -182,7 +182,7 @@ fn local_server_binary(state: &AppState, os: &str, arch: &str) -> Option, ) -> IdeResult { - let (data, version, os, arch) = (state.data_dir.clone(), version.to_owned(), os.to_owned(), arch.to_owned()); + let (data, version, os, arch) = (state.local_data_dir.clone(), version.to_owned(), os.to_owned(), arch.to_owned()); tokio::task::spawn_blocking(move || { ide_tools::server::fetch(&data, &version, &os, &arch, &ide_core::CancellationToken::new(), &|e| match e { ide_tools::InstallEvent::Line { text } => tracing::info!("openidea-server download: {text}"), @@ -259,7 +259,12 @@ async fn ensure_server(state: &AppState, session: &ide_ssh::Session, progress: A `cargo build --release -p ide-host --bin openidea-server` for that platform and put it at {} \ (or set OPENIDEA_SERVER_BINARY)", e.message, - state.data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server").display() + state + .local_data_dir + .join("server") + .join(format!("{os}-{arch}")) + .join("openidea-server") + .display() )) }); } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 67413569..e3ad4ea3 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -64,21 +64,28 @@ impl AppState { } let config_dir = app.path().app_config_dir()?; let data_dir = app.path().app_data_dir()?; + let local_data_dir = app.path().app_local_data_dir()?; std::fs::create_dir_all(&config_dir)?; std::fs::create_dir_all(&data_dir)?; + if let Err(error) = std::fs::create_dir_all(&local_data_dir) { + tracing::warn!(?local_data_dir, %error, "cannot create local data directory"); + } + tracing::info!(?data_dir, ?local_data_dir, "application data directories"); // Saved git HTTPS credentials (askpass answers them silently). ide_ssh::git_creds::init(data_dir.join("git-credentials.json")); // File › Invalidate Caches… requested at the last run. - let removed = ide_workspace::caches::apply_pending(&data_dir); + let removed = ide_workspace::caches::apply_pending(&data_dir, &local_data_dir); if !removed.is_empty() { tracing::info!("invalidated caches: {removed:?}"); } + ide_host::data_dirs::migrate(&data_dir, &local_data_dir); let global_sink: SharedSink = Arc::new(TauriSink { app: app.clone(), window: None }); let languages = Arc::new(LanguageRegistry::load(Some(&config_dir.join("languages")))?); let sink_app = app.clone(); let host = Arc::new(Host::new(ide_host::HostConfig { config_dir: config_dir.clone(), data_dir: data_dir.clone(), + local_data_dir, languages: languages.clone(), syntax, sink_for: Box::new(move |window| { diff --git a/src-tauri/src/window_work_area.rs b/src-tauri/src/window_work_area.rs new file mode 100644 index 00000000..116a748a --- /dev/null +++ b/src-tauri/src/window_work_area.rs @@ -0,0 +1,208 @@ +//! Native maximize bounds for every Windows frameless window, including state restoration. + +#[cfg(windows)] +pub use native::init; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Rect { + left: i32, + top: i32, + right: i32, + bottom: i32, +} + +/// All coordinates are physical pixels in the selected monitor's coordinate space. +/// Auto-hide needs one exposed pixel, but an already reserved edge needs no extra inset. +fn bounds(monitor: Rect, work: Rect, auto_hide: [bool; 4], fullscreen: bool) -> Rect { + if fullscreen { + return monitor; + } + let [left, top, right, bottom] = auto_hide; + Rect { + left: work.left + i32::from(left && work.left == monitor.left), + top: work.top + i32::from(top && work.top == monitor.top), + right: work.right - i32::from(right && work.right == monitor.right), + bottom: work.bottom - i32::from(bottom && work.bottom == monitor.bottom), + } +} + +fn maximize_geometry(monitor: Rect, area: Rect) -> (i32, i32, i32, i32) { + (area.left - monitor.left, area.top - monitor.top, area.right - area.left, area.bottom - area.top) +} + +#[cfg(windows)] +mod native { + use super::{Rect, bounds, maximize_geometry}; + use windows_sys::Win32::{ + Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM}, + Graphics::Gdi::{ + GetMonitorInfoW, HMONITOR, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromRect, MonitorFromWindow, + }, + UI::{ + Shell::{ + ABE_BOTTOM, ABE_LEFT, ABE_RIGHT, ABE_TOP, ABM_GETAUTOHIDEBAREX, APPBARDATA, DefSubclassProc, + RemoveWindowSubclass, SHAppBarMessage, SetWindowSubclass, + }, + WindowsAndMessaging::{ + GWL_STYLE, GetWindowLongPtrW, IsZoomed, MINMAXINFO, NCCALCSIZE_PARAMS, WM_GETMINMAXINFO, WM_NCCALCSIZE, + WM_NCDESTROY, WS_CAPTION, + }, + }, + }; + + const SUBCLASS: usize = 0x410; + + pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("window-work-area") + .on_window_ready(|window| { + if window.is_decorated().unwrap_or(true) { + return; + } + let install = || -> tauri::Result<()> { + let hwnd = window.hwnd()?.0 as usize; + window.run_on_main_thread(move || { + // Subclasses must be installed and removed on the owning GUI thread. + if unsafe { SetWindowSubclass(hwnd as HWND, Some(window_proc), SUBCLASS, 0) } == 0 { + tracing::warn!( + "cannot install window work area handler: {}", + std::io::Error::last_os_error() + ); + } + }) + }; + if let Err(error) = install() { + tracing::warn!("cannot install window work area handler: {error}"); + } + }) + .build() + } + + fn rect(rect: RECT) -> Rect { + Rect { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom } + } + + fn native_rect(rect: Rect) -> RECT { + RECT { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom } + } + + unsafe fn monitor_bounds(monitor: HMONITOR, fullscreen: bool) -> Option<(Rect, Rect)> { + let mut info = MONITORINFO { cbSize: size_of::() as u32, ..Default::default() }; + if unsafe { GetMonitorInfoW(monitor, &mut info) } == 0 { + return None; + } + let mut auto_hide = [false; 4]; + if !fullscreen { + for (index, edge) in [ABE_LEFT, ABE_TOP, ABE_RIGHT, ABE_BOTTOM].into_iter().enumerate() { + let mut data = APPBARDATA { + cbSize: size_of::() as u32, + uEdge: edge, + rc: info.rcMonitor, + ..Default::default() + }; + auto_hide[index] = unsafe { SHAppBarMessage(ABM_GETAUTOHIDEBAREX, &mut data) } != 0; + } + } + let monitor = rect(info.rcMonitor); + Some((monitor, bounds(monitor, rect(info.rcWork), auto_hide, fullscreen))) + } + + unsafe extern "system" fn window_proc( + hwnd: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, + _id: usize, + _data: usize, + ) -> LRESULT { + // Tao keeps WS_CAPTION on frameless windows and removes it for native fullscreen. + // Read native styles here: a Tauri state query from its window procedure can deadlock. + let fullscreen = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) } as u32 & WS_CAPTION == 0; + match message { + WM_GETMINMAXINFO if !fullscreen => { + // Keep Tao's min/max tracking constraints, then set the outer maximize bounds. + let result = unsafe { DefSubclassProc(hwnd, message, wparam, lparam) }; + let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) }; + if let Some((monitor, area)) = unsafe { monitor_bounds(monitor, false) } { + let (x, y, width, height) = maximize_geometry(monitor, area); + let info = unsafe { &mut *(lparam as *mut MINMAXINFO) }; + info.ptMaxPosition.x = x; + info.ptMaxPosition.y = y; + info.ptMaxSize.x = width; + info.ptMaxSize.y = height; + } + result + } + WM_NCCALCSIZE if wparam != 0 && (fullscreen || unsafe { IsZoomed(hwnd) } != 0) => { + let params = unsafe { &mut *(lparam as *mut NCCALCSIZE_PARAMS) }; + // Use the proposed rectangle: a move/snap/DPI change can target another monitor. + let monitor = unsafe { MonitorFromRect(¶ms.rgrc[0], MONITOR_DEFAULTTONEAREST) }; + if let Some((_, area)) = unsafe { monitor_bounds(monitor, fullscreen) } { + // Replace Tao's client-only clamp and global auto-hide query. Fullscreen + // must retain the entire display, even when entered from a maximized window. + params.rgrc[0] = native_rect(area); + 0 + } else { + unsafe { DefSubclassProc(hwnd, message, wparam, lparam) } + } + } + WM_NCDESTROY => { + unsafe { RemoveWindowSubclass(hwnd, Some(window_proc), SUBCLASS) }; + unsafe { DefSubclassProc(hwnd, message, wparam, lparam) } + } + _ => unsafe { DefSubclassProc(hwnd, message, wparam, lparam) }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(left: i32, top: i32, right: i32, bottom: i32) -> Rect { + Rect { left, top, right, bottom } + } + + #[test] + fn taskbars_on_every_edge_and_secondary_displays() { + for (monitor, work, expected) in [ + // Reported case: maximized window must leave the bottom taskbar visible. + (r(0, 0, 1920, 1080), r(0, 0, 1920, 1040), (0, 0, 1920, 1040)), + (r(0, 0, 1920, 1080), r(48, 0, 1920, 1080), (48, 0, 1872, 1080)), + (r(0, 0, 1920, 1080), r(0, 40, 1920, 1080), (0, 40, 1920, 1040)), + (r(0, 0, 1920, 1080), r(0, 0, 1872, 1080), (0, 0, 1872, 1080)), + // Negative origin, top taskbar, 150% display beside a 100% display. + (r(-2560, -1440, 0, 0), r(-2560, -1380, 0, 0), (0, 60, 2560, 1380)), + // 250% display: physical bounds must not be scaled with the previous monitor's DPI. + (r(1920, 0, 5760, 2160), r(1920, 0, 5760, 2060), (0, 0, 3840, 2060)), + ] { + let area = bounds(monitor, work, [false; 4], false); + assert_eq!(area, work); + assert_eq!(maximize_geometry(monitor, area), expected); + } + } + + #[test] + fn auto_hide_leaves_a_physical_pixel_only_on_its_own_monitor_edge() { + let monitor = r(-1920, 0, 0, 1080); + for (edges, expected) in [ + ([true, false, false, false], r(-1919, 0, 0, 1080)), + ([false, true, false, false], r(-1920, 1, 0, 1080)), + ([false, false, true, false], r(-1920, 0, -1, 1080)), + ([false, false, false, true], r(-1920, 0, 0, 1079)), + ([false; 4], monitor), + ([true; 4], r(-1919, 1, -1, 1079)), + ] { + assert_eq!(bounds(monitor, monitor, edges, false), expected); + } + let reserved = r(-1920, 0, 0, 1040); + assert_eq!(bounds(monitor, reserved, [false, false, false, true], false), reserved); + } + + #[test] + fn fullscreen_covers_taskbars_including_when_entered_from_maximized() { + let monitor = r(1920, -100, 5760, 2060); + let work = r(1980, -100, 5760, 1960); + assert_eq!(bounds(monitor, work, [true; 4], true), monitor); + assert_eq!(bounds(monitor, work, [false; 4], false), work); + } +} diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index f905ed51..1599e060 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -841,9 +841,18 @@ fn fit_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri: /// scale and its position is given in physical pixels. #[cfg(not(target_os = "macos"))] pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> { + // Placement setters leave maximized mode. Fit the restored frame, then reapply the saved state. + // A restored fullscreen window already covers its display and must not be fitted to the work area. + if window.is_fullscreen()? { + return Ok(()); + } let Some(monitor) = parent.current_monitor()?.or(parent.primary_monitor()?) else { return Ok(()); }; + let maximized = window.is_maximized()?; + if maximized { + window.unmaximize()?; + } let scale = monitor.scale_factor(); let area = monitor.work_area(); let origin = area.position.to_logical::(scale); @@ -872,6 +881,9 @@ pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWind )))?; window.set_size(tauri::LogicalSize::new(width, height))?; } + if maximized { + window.maximize()?; + } Ok(()) } diff --git a/src/bindings.ts b/src/bindings.ts index cad8c86e..f4603e36 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -327,6 +327,7 @@ export const commands = { pathCompletions: (from: IpcPath, prefix: string) => typedError(__TAURI_INVOKE("path_completions", { from, prefix })), /** Paths in an HTML document or stylesheet that name no existing file. */ webUnresolvedReferences: (doc: DocId) => typedError(__TAURI_INVOKE("web_unresolved_references", { doc })), + dependencyVersions: (doc: DocId, offset: number | null, request: RequestId) => typedError(__TAURI_INVOKE("dependency_versions", { doc, offset, request })), /** * Misspelt words in the comments, string literals and declared names of * `doc` that `scope` includes (all of its text for prose). Cancel with @@ -1640,6 +1641,30 @@ export type ByteRange = { total: number, }; +export type CMakeProfile = { + name?: string, + buildType?: string, + generator?: string, + cmakeArgs?: string[], + /** Empty uses cmake-build-. */ + buildDir?: string, + buildArgs?: string[], + env?: { [key in string]: string }, +}; + +/** Absent profiles retain automatic discovery for existing projects. */ +export type CMakeSettings = CMakeSettings_Serialize | CMakeSettings_Deserialize; + +/** Absent profiles retain automatic discovery for existing projects. */ +export type CMakeSettings_Deserialize = { + profiles?: CMakeProfile[] | null, +}; + +/** Absent profiles retain automatic discovery for existing projects. */ +export type CMakeSettings_Serialize = { + profiles?: CMakeProfile[] | null, +}; + export type CallHierarchy = { items: HierarchyItem[], incomplete: ReferencesIncomplete | null, @@ -2445,6 +2470,17 @@ export type DeletedBranch = { unmerged: boolean, }; +export type DependencyUpdate = { + latest: string, + replacement: string, +}; + +export type DependencyVersions = { + dependency: ManifestDependency, + versions: string[], + update: DependencyUpdate | null, +}; + /** Where a local file is deployed. */ export type DeployedPath = { server: string, @@ -4266,6 +4302,14 @@ export type MainToolbarSettings = { hidden?: string[], }; +export type ManifestDependency = { + package: string, + requirement: string, + /** UTF-16 offsets of the string's contents (quotes excluded). */ + from: number, + to: number, +}; + export type Markup = { /** `markdown` or `plaintext`. */ kind: string, @@ -5636,6 +5680,7 @@ export type Settings_Deserialize = { deployment?: DeploymentSettings, /** Build, Execution, Deployment › Run Targets. */ runTargets?: RunTarget[], + cmake?: CMakeSettings_Deserialize, }; export type Settings_Serialize = { @@ -5691,6 +5736,7 @@ export type Settings_Serialize = { deployment: DeploymentSettings, /** Build, Execution, Deployment › Run Targets. */ runTargets: RunTarget[], + cmake: CMakeSettings_Serialize, }; export type Severity = "error" | "warning" | "info" | "hint"; diff --git a/src/editor/api.ts b/src/editor/api.ts index 9f2ab9a0..9f0251cb 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -21,6 +21,7 @@ import { Annotation, Compartment, Facet, + StateEffect, type EditorState, type Extension, type RangeSet, @@ -332,14 +333,25 @@ export function contributedAt(state: EditorState, key: string, pos: number): return state.languageDataAt(key, pos)[0]; } -/** A context action a feature offers at the caret, listed by Show Context Actions (⌥↵) before the language server's. */ +/** A context action a feature offers at the caret, listed by Show Context Actions (⌥↵). */ export interface ContributedIntention { id: string; label: string; icon?: string; + quickFix?: boolean; run: () => void | Promise; } +/** A background provider has new context actions for the current document. */ +export const intentionsChanged = StateEffect.define(); + +/** Local fixes of a diagnostic range (hover link and Apply Quick Fix). */ +export const quickFixProviders = Facet.define<(view: EditorView, from: number, to: number) => ContributedIntention[]>(); + +export function contributedQuickFixes(view: EditorView, from: number, to: number): ContributedIntention[] { + return view.state.facet(quickFixProviders).flatMap((provider) => provider(view, from, to)); +} + /** Providers of `ContributedIntention`s for the caret of `view` (cheap and synchronous: also drives the light bulb). */ export const intentionProviders = Facet.define<(view: EditorView) => ContributedIntention[]>(); diff --git a/src/editor/lsp/diagnostics.ts b/src/editor/lsp/diagnostics.ts index f4cc7850..65691292 100644 --- a/src/editor/lsp/diagnostics.ts +++ b/src/editor/lsp/diagnostics.ts @@ -12,7 +12,7 @@ import type { Diagnostic, Severity } from "@/bindings"; import { commands, ideEventsReady, isTauri, onIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { publishLocalDiagnostics } from "@/platform/localDiagnostics"; -import { scrollbarMarks, type ScrollbarMark } from "@/editor/api"; +import { intentionsChanged, scrollbarMarks, type ScrollbarMark } from "@/editor/api"; import { rangeToOffsets } from "./positions"; import { lspDoc } from "./lsp"; import { showHint } from "./hint"; @@ -199,7 +199,10 @@ const diagnosticsSync = ViewPlugin.fromClass( if (!path || !this.view.dom.isConnected) return; const lint = toLintDiagnostics(this.view.state.doc, diagnosticsFor(path)); const tr = setDiagnostics(this.view.state, lint); - this.view.dispatch({ ...tr, effects: [...[tr.effects ?? []].flat(), setMarks.of(diagnosticMarks(lint))] }); + this.view.dispatch({ + ...tr, + effects: [...[tr.effects ?? []].flat(), setMarks.of(diagnosticMarks(lint)), intentionsChanged.of(null)], + }); }); } destroy() { diff --git a/src/editor/lsp/hover.ts b/src/editor/lsp/hover.ts index e6614dc2..4da03f15 100644 --- a/src/editor/lsp/hover.ts +++ b/src/editor/lsp/hover.ts @@ -98,7 +98,7 @@ function renderDiagnostics(view: EditorView, diags: PlacedDiagnostic[]): HTMLEle fix.className = "cm-lsp-link-button"; const keys = shortcutLabel("code.showIntentions"); fix.textContent = keys ? `More actions… ${keys}` : "More actions…"; - if (d.severity === "error" || d.severity === "warning") offerQuickFix(view, meta, fix, from, to); + if (d.severity !== "hint") offerQuickFix(view, meta, fix, from, to); fix.addEventListener("mousedown", (e) => e.preventDefault()); fix.addEventListener("click", () => { view.dispatch({ selection: EditorSelection.cursor(from) }); diff --git a/src/editor/lsp/intentions.test.ts b/src/editor/lsp/intentions.test.ts index bb913d15..a76d9b4a 100644 --- a/src/editor/lsp/intentions.test.ts +++ b/src/editor/lsp/intentions.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; +import { setDiagnostics } from "@codemirror/lint"; import type { CodeAction } from "@/bindings"; const resolved: CodeAction = { @@ -35,6 +36,7 @@ const { intentionPreview, lightsBulb, listedCodeActions, + listedIntentions, withInspectionControls, } = await import("./intentions"); const { setFileDiagnostics } = await import("./diagnostics"); @@ -90,6 +92,27 @@ describe("inspection controls in ⌥↵ (issue #42)", () => { setFileDiagnostics("/p/two.py", "ruff", []); expect(withInspectionControls(view, [])).toEqual([]); }); + + it.each(["info", "warning", "error"] as const)( + "uses the effective %s severity for local and LSP fixes", + (severity) => { + const path = `/p/severity-${severity}.py`; + setFileDiagnostics(path, "ruff", [{ ...unused, severity }]); + const view = editorAt(path); + view.dispatch(setDiagnostics(view.state, [{ from: 7, to: 9, severity, message: unused.message }])); + const items = listedIntentions(view, [ + { id: "refactor", label: "Extract method", icon: "bulb", run: vi.fn() }, + { id: "local", label: "Local fix", quickFix: true, run: vi.fn() }, + { id: "lsp", label: "LSP fix", quickFix: true, emphasis: true, run: vi.fn() }, + ]); + expect(items.map((i) => i.id)).toEqual(["lsp", "local", "refactor"]); + expect(items.slice(0, 2).map((i) => i.icon)).toEqual(Array(2).fill(severity === "error" ? "bulb-fix" : "bulb")); + const controls = withInspectionControls(view, items); + expect(controls.slice(0, 2).every((i) => i.submenu?.length === 2)).toBe(true); + expect(controls[2]!.submenu).toBeUndefined(); + setFileDiagnostics(path, "ruff", []); + }, + ); }); describe("intention preview", () => { diff --git a/src/editor/lsp/intentions.ts b/src/editor/lsp/intentions.ts index 07cb7d6d..7dd58413 100644 --- a/src/editor/lsp/intentions.ts +++ b/src/editor/lsp/intentions.ts @@ -9,7 +9,13 @@ import { forEachDiagnostic } from "@codemirror/lint"; import { indentUnit } from "@codemirror/language"; import { diff } from "@codemirror/merge"; import { commands, isCancelled, type CodeAction } from "@/platform/ipc"; -import { contributedIntentions, intentionProviders, whenSynced } from "@/editor/api"; +import { + contributedIntentions, + contributedQuickFixes, + intentionProviders, + intentionsChanged, + whenSynced, +} from "@/editor/api"; import { shortcutLabel } from "@/platform/keymap"; import { debounce, docOf, isUnavailable, lspRequest, reportLspError } from "./lsp"; import { offsetsToRange } from "./positions"; @@ -32,6 +38,27 @@ const codeActionBackoff = featureBackoff(); const isQuickFix = (a: CodeAction) => !!a.kind && (a.kind === "quickfix" || a.kind.startsWith("quickfix.")); +/** The red bulb is reserved for errors; other inspections use the ordinary bulb. */ +export function quickFixIcon(view: EditorView): "bulb" | "bulb-fix" { + const sel = view.state.selection.main; + const line = view.state.doc.lineAt(sel.head); + let error = false; + forEachDiagnostic(view.state, (d, from, to) => { + if (d.severity === "error" && from <= line.to && to >= line.from) error = true; + }); + return error ? "bulb-fix" : "bulb"; +} + +const isFixItem = (item: ListPopupItem) => item.quickFix ?? item.icon === "bulb-fix"; + +/** Local and language-server fixes share icons and the same stable ordering. */ +export function listedIntentions(view: EditorView, items: ListPopupItem[]): ListPopupItem[] { + const rank = (item: ListPopupItem) => (item.disabled ? 4 : 0) + (isFixItem(item) ? 0 : 2) + (item.emphasis ? 0 : 1); + return items + .map((item) => (isFixItem(item) ? { ...item, quickFix: true, icon: quickFixIcon(view) } : item)) + .sort((a, b) => rank(a) - rank(b)); +} + /** IDEA ordering: quick fixes first, preferred before others, disabled last; stable otherwise. */ export function sortCodeActions(actions: readonly CodeAction[]): CodeAction[] { const rank = (a: CodeAction) => (a.disabledReason ? 4 : 0) + (isQuickFix(a) ? 0 : 2) + (a.isPreferred ? 0 : 1); @@ -104,15 +131,23 @@ async function fetchIntentions(view: EditorView): Promise { return actions; } -/** Enabled quick fixes of the problem at `from..to`, preferred first. */ -export async function quickFixesAt(view: EditorView, from: number, to: number): Promise { +export type EditorQuickFix = CodeAction | { title: string; run: () => void | Promise }; + +/** Enabled quick fixes of the problem at `from..to`, including local inspections. */ +export async function quickFixesAt(view: EditorView, from: number, to: number): Promise { + const local = contributedQuickFixes(view, from, to).map((fix) => ({ title: fix.label, run: fix.run })); const doc = docOf(view); - if (!doc) return []; - await whenSynced(view); - const actions = await lspRequest(() => - commands.lspCodeActions(doc.doc, offsetsToRange(view.state.doc, from, to), ["quickfix"]), - ); - return sortCodeActions(actions.filter((a) => isQuickFix(a) && !a.disabledReason)); + if (!doc) return local; + try { + await whenSynced(view); + const actions = await lspRequest(() => + commands.lspCodeActions(doc.doc, offsetsToRange(view.state.doc, from, to), ["quickfix"]), + ); + return [...local, ...sortCodeActions(actions.filter((a) => isQuickFix(a) && !a.disabledReason))]; + } catch (e) { + if (local.length) return local; + throw e; + } } /** @@ -144,7 +179,8 @@ export async function applyQuickFix(view: EditorView) { showHint(view, "No quick fixes available"); } -export async function applyCodeAction(view: EditorView, action: CodeAction) { +export async function applyCodeAction(view: EditorView, action: EditorQuickFix) { + if ("run" in action) return action.run(); const doc = docOf(view); if (!doc) return; if (action.disabledReason) { @@ -276,9 +312,10 @@ function actionItems( resolved?: Map>, ): ListPopupItem[] { return listedCodeActions(actions).map((a, i) => ({ - id: `${i}`, + id: `lsp:${i}`, label: a.title, - icon: isQuickFix(a) ? "bulb-fix" : "bulb", + icon: isQuickFix(a) ? quickFixIcon(view) : "bulb", + quickFix: isQuickFix(a), emphasis: a.isPreferred, disabled: a.disabledReason, // The edit the preview already resolved is applied as is. @@ -344,7 +381,7 @@ export function withInspectionControls(view: EditorView, items: ListPopupItem[]) (i) => i.id !== SPELLING_INSPECTION, ); if (!inspections.length) return items; - const fixes = items.filter((i) => i.icon === "bulb-fix" && !i.submenu); + const fixes = items.filter((i) => isFixItem(i) && !i.submenu); if (inspections.length === 1 && fixes.length) { const submenu = intentionControls(inspections[0]!); return items.map((i) => (fixes.includes(i) ? { ...i, submenu } : i)); @@ -364,7 +401,7 @@ export function withInspectionControls(view: EditorView, items: ListPopupItem[]) /** ⌥↵ */ export async function showIntentions(view: EditorView) { try { - // Typo fixes and feature-contributed actions come first and need no language server. + // Local inspections need no language server. const local: ListPopupItem[] = [...spellingIntentions(view), ...contributedIntentions(view)]; let actions: CodeAction[] = []; let failure: { error: unknown } | null = null; @@ -376,7 +413,9 @@ export async function showIntentions(view: EditorView) { } const sorted = listedCodeActions(actions); const resolved = new Map>(); - const items = withInspectionControls(view, [...local, ...actionItems(view, sorted, resolved)]); + const serverItems = actionItems(view, sorted, resolved); + const previews = new Map(serverItems.map((item, i) => [item.id, sorted[i]!])); + const items = withInspectionControls(view, listedIntentions(view, [...local, ...serverItems])); if (!items.length) { if (failure) throw failure.error; showHint(view, "No intentions available"); @@ -388,7 +427,7 @@ export async function showIntentions(view: EditorView) { title: "Intentions", items, preview: (item, signal) => { - const action = sorted[items.indexOf(item) - local.length]; + const action = previews.get(item.id); return action ? intentionPreview(view, action, resolved, signal) : Promise.resolve(null); }, }); @@ -500,7 +539,13 @@ const bulbPlugin = ViewPlugin.fromClass( this.lookup(); } update(u: ViewUpdate) { - if (u.docChanged || u.selectionSet || (u.focusChanged && u.view.hasFocus)) this.lookup(); + if ( + u.docChanged || + u.selectionSet || + (u.focusChanged && u.view.hasFocus) || + u.transactions.some((tr) => tr.effects.some((e) => e.is(intentionsChanged))) + ) + this.lookup(); else if (u.focusChanged) { this.lookup.cancel(); // A blur dispatched from inside the update is not allowed. @@ -516,16 +561,16 @@ const bulbPlugin = ViewPlugin.fromClass( if (!doc || doc.readOnly || !this.view.dom.isConnected || !this.view.hasFocus) return; const state = this.view.state; const typoFix = spellingIntentions(this.view).length > 0; - const local = typoFix || contributedIntentions(this.view).length > 0; + const selection = state.selection.main; + const ownFix = contributedQuickFixes(this.view, selection.from, selection.to).length > 0; + const local = typoFix || ownFix || contributedIntentions(this.view).length > 0; const backoff = codeActionBackoff.active(doc.doc); - if (backoff && !local) return; let actions: CodeAction[] = []; if (!backoff) { try { actions = await fetchIntentions(this.view); } catch (e) { if (isUnavailable(e)) codeActionBackoff.mark(doc.doc); - if (!local) return; } } if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection) || !this.view.hasFocus) @@ -533,7 +578,14 @@ const bulbPlugin = ViewPlugin.fromClass( const line = state.doc.lineAt(state.selection.main.head).number; const any = actions.some(lightsBulb) || local; this.view.dispatch({ - effects: setBulb.of(any ? { line, quickFix: typoFix || actions.some(isQuickFix) } : null), + effects: setBulb.of( + any + ? { + line, + quickFix: (typoFix || ownFix || actions.some(isQuickFix)) && quickFixIcon(this.view) === "bulb-fix", + } + : null, + ), }); } destroy() { diff --git a/src/editor/lsp/popupStore.ts b/src/editor/lsp/popupStore.ts index e5276d6c..a855ccd3 100644 --- a/src/editor/lsp/popupStore.ts +++ b/src/editor/lsp/popupStore.ts @@ -18,6 +18,8 @@ export interface ListPopupItem { /** Second line / muted text after the label (code preview). */ preview?: string; icon?: string; + /** Inspection fix, independent of the icon chosen for its severity. */ + quickFix?: boolean; /** Symbol badge kind (`ui/icons` `SymbolBadge`) shown instead of an icon. */ symbol?: string; emphasis?: boolean; diff --git a/src/features/debugger/__tests__/actions.test.ts b/src/features/debugger/__tests__/actions.test.ts index 295b6944..0e89449d 100644 --- a/src/features/debugger/__tests__/actions.test.ts +++ b/src/features/debugger/__tests__/actions.test.ts @@ -6,6 +6,7 @@ const ipc = vi.hoisted(() => { return { ok, debugStep: vi.fn(() => ok(null)), + debugResume: vi.fn(() => ok(null)), debugForceStepOver: vi.fn(() => ok(null)), debugRunTo: vi.fn(() => ok(null)), debugStepInTargets: vi.fn(() => ok([] as Array<{ id: number; label: string }>)), @@ -54,7 +55,7 @@ import type { DebugCapabilities } from "@/platform/ipc"; import { registerDebugActions } from "../actions"; import { breakpointsOf, loadBreakpoints } from "../breakpoints/store"; import { loadSpecialBreakpoints, removeAllSpecial, useSpecialBreakpoints } from "../breakpoints/special"; -import { publishContext, useDebug, type DebugSession } from "../state"; +import { publishContext, updateExecutionPoint, useExecutionPoint, useDebug, type DebugSession } from "../state"; import { focusDebugView, blurDebugView } from "../ui/focusedView"; import { useOverlays } from "../ui/popoverStore"; @@ -97,6 +98,7 @@ function paused(p: Partial = {}) { }; useDebug.setState({ sessions: { 3: s }, active: 3 }); publishContext(); + updateExecutionPoint(); } function caret(path: string, language: string, doc: string, line: number) { @@ -154,15 +156,41 @@ describe("debugger actions (IDEA parity)", () => { expect(getAction("debug.jumpToSource")!.keyWhen).toBe("debugFramesFocus || debugVariablesFocus"); }); + it.each([ + ["debug.resume", "debugResume"], + ["debug.stepOver", "debugStep"], + ["debug.stepInto", "debugStep"], + ["debug.stepOut", "debugStep"], + ["debug.forceStepOver", "debugForceStepOver"], + ["debug.forceStepInto", "debugStep"], + ["debug.smartStepInto", "debugStep"], + ["debug.runToCursor", "debugRunTo"], + ["debug.forceRunToCursor", "debugRunTo"], + ["debug.resetFrame", "debugRestartFrame"], + ] as const)("%s clears the old execution point before its request", async (id, command) => { + paused({ caps: caps({ restartFrame: true }) }); + caret("/p/app.py", "python", "a\nb\nc", 2); + const request = ipc[command].mockImplementationOnce(() => { + expect(useExecutionPoint.getState().path).toBeNull(); + expect(useDebug.getState().sessions[3]?.status).toBe("running"); + return ipc.ok(null); + }); + await executeAction(id); + expect(request).toHaveBeenCalledOnce(); + }); + it("Force Step Over / Force Step Into / Force Run to Cursor", async () => { paused(); caret("/p/app.py", "python", "a\nb\nc\nd", 2); await executeAction("debug.forceStepOver"); expect(ipc.debugForceStepOver).toHaveBeenCalledWith(3, 1); + paused(); await executeAction("debug.forceStepInto"); expect(ipc.debugStep).toHaveBeenCalledWith(3, 1, "into"); + paused(); await executeAction("debug.forceRunToCursor"); expect(ipc.debugRunTo).toHaveBeenCalledWith(3, 1, "/p/app.py", 2, true); + paused(); await executeAction("debug.runToCursor"); expect(ipc.debugRunTo).toHaveBeenLastCalledWith(3, 1, "/p/app.py", 2, false); }); @@ -180,6 +208,7 @@ describe("debugger actions (IDEA parity)", () => { expect(ipc.debugStepInTargets).toHaveBeenCalledWith(3, 71); expect(useOverlays.getState().smartStep?.targets.map((t) => t.label)).toEqual(["str(i)", "parse(str(i))"]); + paused({ caps: caps({ stepInTargets: true }) }); ipc.debugStepInTargets.mockReturnValueOnce(ipc.ok([{ id: 5, label: "parse()" }])); await executeAction("debug.smartStepInto"); await settle(); diff --git a/src/features/debugger/__tests__/editor.test.ts b/src/features/debugger/__tests__/editor.test.ts index b975cca6..c7967b7d 100644 --- a/src/features/debugger/__tests__/editor.test.ts +++ b/src/features/debugger/__tests__/editor.test.ts @@ -166,6 +166,60 @@ describe("breakpoint gutter clicks", () => { }); describe("execution point", () => { + it("keeps the execution line synchronized if inline rendering throws", async () => { + useExecutionPoint.setState({ path: null, values: {} }); + const errors: unknown[] = []; + const view = new EditorView({ + state: EditorState.create({ + doc: "x\nx\nx", + extensions: [executionPoint(ctx), EditorView.exceptionSink.of((e) => errors.push(e))], + }), + parent: document.body, + }); + views.push(view); + const values: Record = Object.defineProperty({}, "x", { + enumerable: true, + get() { + throw new Error("inline rendering failed"); + }, + }); + useExecutionPoint.setState({ path: ctx.path, line: 0, top: true, values }); + await flush(); + expect(errors).toHaveLength(1); + useExecutionPoint.setState({ line: 2, values: {} }); + await flush(); + expect(view.dom.querySelectorAll(".cm-line")[2]!.className).toContain(styles.execLine); + useExecutionPoint.setState({ path: null }); + await flush(); + expect(view.dom.querySelector(`.${styles.execLine}`)).toBeNull(); + }); + + it("keeps refreshing the execution line around Object.prototype names", async () => { + useExecutionPoint.setState({ path: null, values: {} }); + const view = makeView("String text = Integer.toString(40);\nint count = 40;\ncount++;\ncount++;"); + useExecutionPoint.setState({ path: ctx.path, line: 1, top: true, values: { text: "40" } }); + await flush(); + useExecutionPoint.setState({ line: 3, values: { text: "40", count: "42" } }); + await flush(); + expect(view.dom.querySelector(`.${styles.execLine}`)?.textContent).toContain("count++"); + useExecutionPoint.setState({ path: null, values: {} }); + await flush(); + expect(view.dom.querySelector(`.${styles.execLine}`)).toBeNull(); + }); + + it.each([2, null])("refreshes a cached editor state when remounted at %s", async (line) => { + useExecutionPoint.setState({ path: ctx.path, line: 0, top: true, values: {} }); + const view = makeView("first\nsecond\nthird"); + const state = view.state; + view.destroy(); + views.splice(views.indexOf(view), 1); + useExecutionPoint.setState({ path: line == null ? null : ctx.path, line: line ?? 0, values: {} }); + const restored = new EditorView({ state, parent: document.body }); + views.push(restored); + await flush(); + expect(restored.dom.querySelector(`.${styles.execLine}`)?.textContent ?? null).toBe(line == null ? null : "third"); + }); + it("highlights the suspended line and shows inline values", async () => { const view = makeView("x = 1\ny = x + 1\nprint(y)"); useExecutionPoint.setState({ path: ctx.path, line: 1, top: true, values: { x: "1", y: "2" } }); diff --git a/src/features/debugger/__tests__/executionState.test.ts b/src/features/debugger/__tests__/executionState.test.ts new file mode 100644 index 00000000..9cbbde52 --- /dev/null +++ b/src/features/debugger/__tests__/executionState.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { StackFrame, Variable } from "@/platform/ipc"; + +const ipc = vi.hoisted(() => { + const ok = (data: T) => ({ status: "ok" as const, data }); + return { + ok, + debugCapabilities: vi.fn(async () => ok(null)), + debugThreads: vi.fn(async () => + ok([ + { id: 5, name: "http-nio-8179-exec-5" }, + { id: 1, name: "main" }, + ]), + ), + debugStackTrace: vi.fn(async () => ok([] as StackFrame[])), + debugScopes: vi.fn(async () => ok([{ name: "Locals", variablesReference: 10, expensive: false }])), + debugVariables: vi.fn(async () => ok([] as Variable[])), + }; +}); +vi.mock("@/platform/ipc", async (orig) => { + const real = await orig(); + const { ok: _ok, ...commands } = ipc; + return { ...real, commands: { ...real.commands, ...commands } }; +}); +vi.mock("@/workbench/contributions", async (orig) => ({ + ...(await orig()), + maybeEditorService: () => ({ openFile: vi.fn(async () => {}) }), +})); +import { adoptSession, handleDebugEvent, selectThread, resumeExecution, useDebug, useExecutionPoint } from "../state"; + +const path = "/proj/MsMemberUtils.java"; +const frame = (line: number, id = 100): StackFrame => ({ + id, + name: "queryMsTicket", + path, + line, + column: 0, + presentationHint: null, +}); +const stopped = (threadId = 5, allThreadsStopped = false) => + handleDebugEvent(1, { kind: "stopped", threadId, allThreadsStopped, reason: "step", description: null }); +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +beforeEach(() => { + vi.clearAllMocks(); + useDebug.setState({ sessions: {}, active: null, stopSeq: 0 }); + useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} }); + adoptSession(1, { adapter: "java-debug" }); + ipc.debugStackTrace.mockResolvedValue(ipc.ok([frame(6840)])); + ipc.debugVariables.mockResolvedValue(ipc.ok([])); +}); + +describe("execution point state", () => { + it.each([false, true])("selects each stopped thread and its fresh top frame (all=%s)", async (all) => { + stopped(5, all); + await settle(); + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6840 }); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)])); + stopped(5, all); + await settle(); + expect(useDebug.getState().sessions[1]).toMatchObject({ selectedThread: 5, selectedFrame: 100 }); + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 }); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(9)])); + stopped(1, all); + await settle(); + expect(useDebug.getState().sessions[1]?.selectedThread).toBe(1); + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 9 }); + }); + + it("clears the old point while a new stack or selected thread is loading", async () => { + stopped(); + await settle(); + const pending = deferred>>(); + ipc.debugStackTrace.mockReturnValueOnce(pending.promise); + stopped(); + expect(useExecutionPoint.getState().path).toBeNull(); + pending.resolve(ipc.ok([frame(6842)])); + await settle(); + const other = deferred>>(); + ipc.debugStackTrace.mockReturnValueOnce(other.promise); + const selecting = selectThread(1, 1); + expect(useExecutionPoint.getState().path).toBeNull(); + other.resolve(ipc.ok([frame(9)])); + await selecting; + expect(useExecutionPoint.getState().line).toBe(9); + }); + + it("ignores a stack response from an earlier stop on the same thread", async () => { + const old = deferred>>(); + ipc.debugStackTrace.mockReturnValueOnce(old.promise); + stopped(); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)])); + stopped(); + await settle(); + old.resolve(ipc.ok([frame(6840)])); + await settle(); + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 }); + expect(useDebug.getState().sessions[1]?.frames[0]?.line).toBe(6842); + }); + + it("keeps the selected paused thread when only another thread continues", async () => { + stopped(); + await settle(); + handleDebugEvent(1, { kind: "continued", threadId: 1, allThreadsContinued: false }); + expect(useExecutionPoint.getState().path).toBe(path); + expect(useDebug.getState().sessions[1]?.status).toBe("paused"); + handleDebugEvent(1, { kind: "continued", threadId: 5, allThreadsContinued: false }); + expect(useExecutionPoint.getState().path).toBeNull(); + }); + + it.each(["continued", "terminated"] as const)("clears the execution point on %s", async (kind) => { + stopped(); + await settle(); + handleDebugEvent(1, kind === "continued" ? { kind, threadId: 5, allThreadsContinued: true } : { kind }); + expect(useExecutionPoint.getState()).toMatchObject({ path: null, values: {} }); + }); + + it("retains locals whose names are Object.prototype properties", async () => { + const names = Object.getOwnPropertyNames(Object.prototype); + ipc.debugVariables.mockResolvedValueOnce( + ipc.ok( + names.map((name) => ({ name, value: "known", typeName: null, variablesReference: 0, evaluateName: name })), + ), + ); + stopped(); + await settle(); + for (const name of names) expect(Object.hasOwn(useExecutionPoint.getState().values, name), name).toBe(true); + }); + it.each(["step", "continue"])("clears suspended data before a %s request resolves", async () => { + stopped(); + await settle(); + const pending = deferred(); + const request = vi.fn(() => { + expect(useExecutionPoint.getState().path).toBeNull(); + expect(useDebug.getState().sessions[1]).toMatchObject({ status: "running", frames: [], selectedFrame: null }); + return pending.promise; + }); + const result = resumeExecution(1, request); + expect(request).toHaveBeenCalledOnce(); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)])); + stopped(); + await settle(); + pending.resolve(); + await result; + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 }); + }); + + it("restores suspended data if the command fails without a newer event", async () => { + stopped(); + await settle(); + await expect( + resumeExecution(1, async () => { + throw new Error("rejected"); + }), + ).rejects.toThrow("rejected"); + expect(useDebug.getState().sessions[1]?.status).toBe("paused"); + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6840 }); + }); + + it("does not restore an older stop when a failed command has already stopped again", async () => { + stopped(); + await settle(); + let reject!: (e: Error) => void; + const result = resumeExecution( + 1, + () => + new Promise((_, r) => { + reject = r; + }), + ); + const rejected = expect(result).rejects.toThrow("rejected"); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)])); + stopped(); + await settle(); + reject(new Error("rejected")); + await rejected; + expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 }); + }); + + it("discards locals from an earlier stop even when the frame ID is reused", async () => { + const old = deferred>>(); + ipc.debugVariables.mockReturnValueOnce(old.promise); + stopped(); + await settle(); + ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)])); + ipc.debugVariables.mockResolvedValueOnce( + ipc.ok([{ name: "count", value: "42", typeName: null, variablesReference: 0, evaluateName: "count" }]), + ); + stopped(); + await settle(); + old.resolve(ipc.ok([{ name: "count", value: "40", typeName: null, variablesReference: 0, evaluateName: "count" }])); + await settle(); + expect(useExecutionPoint.getState().values.count).toBe("42"); + }); +}); diff --git a/src/features/debugger/__tests__/smartStep.test.ts b/src/features/debugger/__tests__/smartStep.test.ts index 84eb48fd..eeffc599 100644 --- a/src/features/debugger/__tests__/smartStep.test.ts +++ b/src/features/debugger/__tests__/smartStep.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { _addEditor, _removeEditor, type TextEditorHandle } from "@/editor/api"; -import { useExecutionPoint } from "../state"; +import { adoptSession, useDebug, useExecutionPoint } from "../state"; import { useOverlays } from "../ui/popoverStore"; import { calleeName, @@ -92,6 +92,8 @@ describe("picking in the editor", () => { }); function setup() { + useDebug.setState({ sessions: {}, active: null }); + adoptSession(3, { status: "paused", selectedThread: 1, stoppedThread: 1 }); const ctx = { doc: 1, path: "/p/app.py", language: "python", uiMode: null, readOnly: false }; const view = new EditorView({ state: EditorState.create({ doc: "def f():\n x = parse(str(i))\n", extensions: [smartStepTargets(ctx)] }), diff --git a/src/features/debugger/__tests__/values.test.ts b/src/features/debugger/__tests__/values.test.ts index 38a5bcfc..15ee72a7 100644 --- a/src/features/debugger/__tests__/values.test.ts +++ b/src/features/debugger/__tests__/values.test.ts @@ -52,6 +52,12 @@ describe("value classification", () => { ); expect(inlineValuesFor("return 1", values)).toBeNull(); }); + + it.each(Object.getOwnPropertyNames(Object.prototype))("only displays an own local named %s", (name) => { + expect(inlineValuesFor(name, { count: "40" })).toBeNull(); + expect(inlineValuesFor(name, Object.fromEntries([[name, "known"]]))).toBe(`${name}: known`); + expect(inlineValuesFor(name, Object.create({ [name]: "inherited" }))).toBeNull(); + }); }); const frame = (id: number, path: string | null, hint: string | null = null, line = 9): StackFrame => ({ diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 98cbde4b..2db2c3e6 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -24,6 +24,7 @@ import { debugUnsupportedReason, launchConfigFor } from "./launch"; import { isRemoteConfig, REMOTE_JVM, remoteAttachConfig, remoteJvmTarget } from "./remote"; import { activeSession, + resumeExecution, evaluate, isLive, rerunSession, @@ -89,7 +90,10 @@ function breakpointPathAllowed(path: string): boolean { export function runToLine(path: string, line: number) { const t = pausedTarget(); if (t && breakpointPathAllowed(path)) - void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, path, line, false))); + void report( + "Run to Cursor failed", + resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, path, line, false))), + ); } function notSupported(what: string, s: DebugSession) { @@ -115,15 +119,17 @@ async function smartStepInto() { if (!t) return; const frame = selectedFrame(t.s); if (!frame || !sessionSupports("stepInTargets", t.s)) { - await call(commands.debugStep(t.s.id, t.thread, "into")); + await resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, "into"))); return; } const targets = pausedLineTargets(await call(commands.debugStepInTargets(t.s.id, frame.id))); if (targets.length <= 1) { - await call( - targets[0] - ? commands.debugStepInTarget(t.s.id, t.thread, targets[0].id) - : commands.debugStep(t.s.id, t.thread, "into"), + await resumeExecution(t.s.id, () => + call( + targets[0] + ? commands.debugStepInTarget(t.s.id, t.thread, targets[0].id) + : commands.debugStep(t.s.id, t.thread, "into"), + ), ); return; } @@ -319,7 +325,11 @@ export function registerDebugActions(): () => void { when: paused, run: () => { const t = pausedTarget(); - if (t) void report(title, call(commands.debugStep(t.s.id, t.thread, kind))); + if (t) + void report( + title, + resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, kind))), + ); }, }); @@ -402,7 +412,11 @@ export function registerDebugActions(): () => void { when: paused, run: () => { const t = pausedTarget(); - if (t) void report("Resume failed", call(commands.debugResume(t.s.id, t.thread))); + if (t) + void report( + "Resume failed", + resumeExecution(t.s.id, () => call(commands.debugResume(t.s.id, t.thread))), + ); }, }, { @@ -434,7 +448,11 @@ export function registerDebugActions(): () => void { when: paused, run: () => { const t = pausedTarget(); - if (t) void report("Force Step Over failed", call(commands.debugForceStepOver(t.s.id, t.thread))); + if (t) + void report( + "Force Step Over failed", + resumeExecution(t.s.id, () => call(commands.debugForceStepOver(t.s.id, t.thread))), + ); }, }, { @@ -447,7 +465,11 @@ export function registerDebugActions(): () => void { when: paused, run: () => { const t = pausedTarget(); - if (t) void report("Force Step Into failed", call(commands.debugStep(t.s.id, t.thread, "into"))); + if (t) + void report( + "Force Step Into failed", + resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, "into"))), + ); }, }, { @@ -486,7 +508,10 @@ export function registerDebugActions(): () => void { notSupported("Reset Frame", t.s); return; } - void report("Reset Frame failed", call(commands.debugRestartFrame(t.s.id, frame.id))); + void report( + "Reset Frame failed", + resumeExecution(t.s.id, () => call(commands.debugRestartFrame(t.s.id, frame.id))), + ); }, }, { @@ -533,7 +558,10 @@ export function registerDebugActions(): () => void { const t = pausedTarget(); const c = cursor(); if (t && c && breakpointPathAllowed(c.path)) - void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false))); + void report( + "Run to Cursor failed", + resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false))), + ); }, }, { @@ -547,7 +575,10 @@ export function registerDebugActions(): () => void { const t = pausedTarget(); const c = cursor(); if (t && c && breakpointPathAllowed(c.path)) - void report("Force Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true))); + void report( + "Force Run to Cursor failed", + resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true))), + ); }, }, { diff --git a/src/features/debugger/editor/execution.ts b/src/features/debugger/editor/execution.ts index f466e459..749bce22 100644 --- a/src/features/debugger/editor/execution.ts +++ b/src/features/debugger/editor/execution.ts @@ -49,6 +49,7 @@ function currentExec(path: string): Exec | null { export function inlineValuesFor(text: string, values: Record): string | null { const parts: string[] = []; for (const id of identifiersIn(text)) { + if (!Object.hasOwn(values, id)) continue; const v = values[id]; if (v === undefined) continue; parts.push(`${id}: ${inlineValue(v, 40)}`); @@ -80,33 +81,48 @@ export function executionPoint(ctx: EditorDocContext): Extension { provide: (f) => EditorView.decorations.compute([f], (state) => lineDecorations(state, state.field(f))), }); + // Keep the execution line alive even if an inline-value renderer fails. + const sync = ViewPlugin.fromClass( + class { + private unsub: () => void; + private pending = false; + private destroyed = false; + constructor(readonly view: EditorView) { + this.unsub = useExecutionPoint.subscribe(() => this.refresh()); + // Hidden tabs retain EditorState while their views are unmounted. + this.refresh(); + } + refresh() { + if (this.pending) return; + this.pending = true; + queueMicrotask(() => { + this.pending = false; + if (this.destroyed) return; + const next = currentExec(path); + const cur = this.view.state.field(execField); + const moved = cur?.line !== next?.line || cur?.top !== next?.top || !!cur !== !!next; + // Locals can change without moving the execution point. + this.view.dispatch(moved ? { effects: setExec.of(next) } : {}); + }); + } + destroy() { + this.destroyed = true; + this.unsub(); + } + }, + ); + const inline = ViewPlugin.fromClass( class { decorations: DecorationSet = Decoration.none; - private unsub: () => void; - private pending = false; private destroyed = false; private unsubSettings: () => void; constructor(readonly view: EditorView) { this.decorations = this.build(); - // Settings › Debugger › Data Views › Show values inline. this.unsubSettings = onWorkflowSettingsChange( (s) => s.debugger.inlineValues, () => queueMicrotask(() => !this.destroyed && this.view.dispatch({})), ); - this.unsub = useExecutionPoint.subscribe(() => { - if (this.pending) return; - this.pending = true; - queueMicrotask(() => { - this.pending = false; - if (this.destroyed) return; - const next = currentExec(path); - const cur = this.view.state.field(execField); - const moved = cur?.line !== next?.line || cur?.top !== next?.top || !!cur !== !!next; - // An empty transaction still rebuilds the inline values (locals arrive after the frame). - this.view.dispatch(moved ? { effects: setExec.of(next) } : {}); - }); - }); } update(u: ViewUpdate) { @@ -136,12 +152,11 @@ export function executionPoint(ctx: EditorDocContext): Extension { destroy() { this.destroyed = true; - this.unsub(); this.unsubSettings(); } }, { decorations: (v) => v.decorations }, ); - return [execField, inline]; + return [execField, sync, inline]; } diff --git a/src/features/debugger/editor/smartStep.ts b/src/features/debugger/editor/smartStep.ts index 6eec156e..c5617ab4 100644 --- a/src/features/debugger/editor/smartStep.ts +++ b/src/features/debugger/editor/smartStep.ts @@ -10,7 +10,7 @@ import { editorsFor, activeEditor, type EditorDocContext, type TextEditorHandle import { call, commands, type DebugSessionId, type StepInTarget } from "@/platform/ipc"; import { suspendKeyboardDispatch } from "@/platform/keymap"; import { notifications } from "@/workbench/contributions"; -import { useExecutionPoint } from "../state"; +import { useExecutionPoint, resumeExecution } from "../state"; import { useOverlays, type InlineStepTarget } from "../ui/popoverStore"; import styles from "./editor.module.css"; @@ -139,7 +139,7 @@ export function chooseStepTarget(id?: number) { const target = id ?? s.inline?.ranges[s.inline.index]?.id; cancelSmartStep(); if (target == null) return; - void call(commands.debugStepInTarget(s.session, s.thread, target)).catch((e) => + void resumeExecution(s.session, () => call(commands.debugStepInTarget(s.session, s.thread, target))).catch((e) => notifications().notify({ group: "run", level: "error", diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index d983c628..e910ff0c 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -644,6 +644,7 @@ export function selectSession(id: DebugSessionId) { export async function selectThread(id: DebugSessionId, threadId: number) { patch(id, { selectedThread: threadId, frames: [], selectedFrame: null }); + updateExecutionPoint(); await loadFrames(id, threadId, true); } @@ -652,7 +653,7 @@ export function selectFrame(id: DebugSessionId, frameId: number, reveal = true) updateExecutionPoint(); const s = useDebug.getState().sessions[id]; const frame = s?.frames.find((f) => f.id === frameId); - if (reveal && frame?.path) { + if (reveal && useDebug.getState().active === id && frame?.path) { void maybeEditorService()?.openFile(frame.path, { selection: { line: frame.line, col: frame.column }, focus: false, @@ -662,6 +663,7 @@ export function selectFrame(id: DebugSessionId, frameId: number, reveal = true) } async function loadFrames(id: DebugSessionId, threadId: number, reveal: boolean) { + const before = useDebug.getState().sessions[id]; let frames: StackFrame[] = []; try { frames = await call(commands.debugStackTrace(id, threadId)); @@ -669,7 +671,7 @@ async function loadFrames(id: DebugSessionId, threadId: number, reveal: boolean) console.warn("stackTrace failed", e); } const s = useDebug.getState().sessions[id]; - if (!s || s.status !== "paused" || s.selectedThread !== threadId) return; + if (!s || s.status !== "paused" || s.selectedThread !== threadId || s.frames !== before?.frames) return; patch(id, { frames }); const top = frames.find((f) => f.path && f.presentationHint !== "label") ?? frames[0]; if (top?.path && (s.stopReason === "breakpoint" || s.stopReason === "function breakpoint")) { @@ -707,19 +709,21 @@ async function loadThreads(id: DebugSessionId) { /** Locals of `frameId` (first non-expensive scope) for inline values. */ async function loadLocals(id: DebugSessionId, frameId: number) { - const values: Record = {}; + const frames = useDebug.getState().sessions[id]?.frames; + const values: Record = Object.create(null); try { const scopes = await call(commands.debugScopes(id, frameId)); const scope = scopes.find((sc) => !sc.expensive); if (scope) { const vars = await call(commands.debugVariables(id, scope.variablesReference)); - for (const v of vars) if (v.name && !(v.name in values)) values[v.name] = v.value; + for (const v of vars) if (v.name && !Object.hasOwn(values, v.name)) values[v.name] = v.value; } } catch { // No inline values. } const s = useDebug.getState().sessions[id]; - if (useDebug.getState().active !== id || s?.selectedFrame !== frameId) return; + if (useDebug.getState().active !== id || s?.status !== "paused" || s.selectedFrame !== frameId || s.frames !== frames) + return; useExecutionPoint.setState({ values }); } @@ -729,6 +733,7 @@ export async function showExecutionPoint(id: DebugSessionId) { if (!s || s.status !== "paused" || s.stoppedThread == null) return; if (s.selectedThread !== s.stoppedThread) { patch(id, { selectedThread: s.stoppedThread, frames: [], selectedFrame: null }); + updateExecutionPoint(); await loadFrames(id, s.stoppedThread, false); } const cur = useDebug.getState().sessions[id]; @@ -757,6 +762,32 @@ export function updateExecutionPoint() { useExecutionPoint.setState({ path: frame.path, line: frame.line, top, values: {} }); } +/** Invalidate suspended data before sending a command that resumes execution. */ +export async function resumeExecution(id: DebugSessionId, request: () => Promise) { + const before = useDebug.getState().sessions[id]; + if (!before || before.status !== "paused") return; + const frames: StackFrame[] = []; + patch(id, { status: "running", frames, selectedFrame: null, stoppedThread: null }); + updateExecutionPoint(); + try { + await request(); + } catch (e) { + const current = useDebug.getState().sessions[id]; + // A stop/continue/termination received during the request owns the newer state. + if (current?.status === "running" && current.frames === frames) { + patch(id, { + status: before.status, + frames: before.frames, + selectedFrame: before.selectedFrame, + stoppedThread: before.stoppedThread, + }); + updateExecutionPoint(); + if (before.selectedFrame != null) void loadLocals(id, before.selectedFrame); + } + throw e; + } +} + // --- watches ------------------------------------------------------------------- export function addWatch(expression: string) { @@ -862,6 +893,7 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) { }); useDebug.setState((st) => ({ active: id, stopSeq: st.stopSeq + 1 })); publishContext(); + updateExecutionPoint(); void loadThreads(id); void loadFrames(id, event.threadId, true); const dbg = workflowSettings().debugger; @@ -876,6 +908,7 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) { case "continued": { const cur = useDebug.getState().sessions[id]!; if (cur.status === "terminated") break; + if (!event.allThreadsContinued && event.threadId !== (cur.selectedThread ?? cur.stoppedThread)) break; patch(id, { status: "running", frames: [], diff --git a/src/features/debugger/ui/Overlays.tsx b/src/features/debugger/ui/Overlays.tsx index 14806793..1f4ae51e 100644 --- a/src/features/debugger/ui/Overlays.tsx +++ b/src/features/debugger/ui/Overlays.tsx @@ -13,7 +13,7 @@ import { updateBreakpoint, useBreakpoints, } from "../breakpoints/store"; -import { activeSession, addWatch, evaluate, isLive, startSession, useDebug } from "../state"; +import { activeSession, addWatch, evaluate, isLive, startSession, useDebug, resumeExecution } from "../state"; import { addMethodBreakpoint, removeAllSpecial, @@ -978,7 +978,9 @@ function SmartStepChooser() { const pick = (targetId: number) => { if (!state) return; close(); - void call(commands.debugStepInTarget(state.session, state.thread, targetId)).catch((e) => + void resumeExecution(state.session, () => + call(commands.debugStepInTarget(state.session, state.thread, targetId)), + ).catch((e) => notifications().notify({ group: "run", level: "error", title: "Smart Step Into failed", body: errorText(e) }), ); }; diff --git a/src/features/dependencies/dependencies.test.ts b/src/features/dependencies/dependencies.test.ts new file mode 100644 index 00000000..e3322c59 --- /dev/null +++ b/src/features/dependencies/dependencies.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { CompletionContext } from "@codemirror/autocomplete"; +import type { DependencyVersions } from "@/bindings"; + +const dependencyVersions = vi.fn(); +const cancelRequest = vi.fn(async () => {}); +const lspCodeActions = vi.fn(async () => ({ status: "error", error: { kind: "unavailable", message: "No server" } })); +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { + ...actual, + isTauri: () => true, + commands: { ...actual.commands, dependencyVersions, cancelRequest, lspCodeActions }, + }; +}); +const { activate, versionCompletions } = await import("./index"); +const { editorContributions, contributedIntentions } = await import("@/editor/api"); +const { diagnosticsFor } = await import("@/editor/lsp/diagnostics"); +const { lspDoc } = await import("@/editor/lsp/lsp"); +const { quickFixesAt, applyCodeAction, showIntentions } = await import("@/editor/lsp/intentions"); +const { useLspPopup, closePopup } = await import("@/editor/lsp/popupStore"); +const { useProject } = await import("@/platform/project"); +const { DEPENDENCY_INSPECTION } = await import("@/platform/inspections"); +const text = '# 中文 😀\n[dependencies]\ntokio = { version = "0.2", features = ["full"] }\n'; +const from = text.indexOf('"0.2"') + 1; +const item: DependencyVersions = { + dependency: { package: "tokio", requirement: "0.2", from, to: from + 3 }, + versions: ["2.0.0-beta.1", "1.10.0", "1.9.0", "0.2.1"], + update: { latest: "1.10.0", replacement: "1.10" }, +}; +let deactivate: () => void; +const views: EditorView[] = []; +beforeEach(() => { + vi.useFakeTimers(); + dependencyVersions.mockResolvedValue({ status: "ok", data: [item] }); + useProject.setState({ project: { root: "/p" } as never, settings: {} as never }); + deactivate = activate(); +}); +afterEach(() => { + views.splice(0).forEach((view) => view.destroy()); + deactivate(); + closePopup(); + useProject.setState({ project: null, settings: null }); + vi.clearAllMocks(); + vi.useRealTimers(); +}); +function editor(path = "/p/Cargo.toml", readOnly = false) { + const ctx = { doc: 7, path, language: "toml", uiMode: "TOML", readOnly }; + const contribution = editorContributions().find((c) => c.id === "dependency-versions")!; + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc: text, + selection: { anchor: from }, + extensions: [lspDoc.of(ctx), contribution.extensions(ctx)], + }), + }); + views.push(view); + return view; +} +async function settle() { + await vi.advanceTimersByTimeAsync(550); +} + +describe("Cargo dependency versions", () => { + it("publishes a weak warning and fixes only the version through context actions", async () => { + const view = editor(); + await settle(); + expect(dependencyVersions).toHaveBeenCalledWith(7, null, expect.any(Number)); + expect(diagnosticsFor("/p/Cargo.toml")).toMatchObject([ + { + severity: "info", + message: "A newer version is available: 1.10.0", + range: { start: { line: 2, col: 21 }, end: { line: 2, col: 24 } }, + }, + ]); + const [fix] = contributedIntentions(view); + expect(fix?.label).toBe("Update to 1.10"); + await fix!.run(); + expect(view.state.doc.toString()).toBe(text.replace('"0.2"', '"1.10"')); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + }); + it("provides direct and hover quick fixes even without a language server", async () => { + const view = editor(); + await settle(); + const [fix] = await quickFixesAt(view, from, from + 3); + expect(fix?.title).toBe("Update to 1.10"); + await applyCodeAction(view, fix!); + expect(view.state.doc.toString()).toContain('version = "1.10", features = ["full"]'); + }); + it.each(["0.10", "^0.10", "=0.10.3"])( + "shows the exact replacement %s with weak-warning inspection controls", + async (replacement) => { + dependencyVersions.mockResolvedValue({ + status: "ok", + data: [{ ...item, update: { latest: "0.10.3", replacement } }], + }); + const view = editor(); + await settle(); + await showIntentions(view); + const popup = useLspPopup.getState().popup; + expect(popup?.kind).toBe("list"); + if (popup?.kind !== "list") throw new Error("Missing intentions"); + expect(popup.title).toBe("Intentions"); + expect(popup.items).toHaveLength(1); + expect(popup.items[0]).toMatchObject({ label: `Update to ${replacement}`, icon: "bulb", quickFix: true }); + expect(popup.items[0]!.submenu).toHaveLength(2); + await popup.items[0]!.run(); + expect(view.state.doc.toString()).toBe(text.replace('"0.2"', `"${replacement}"`)); + }, + ); + it("keeps diagnostics when one of two split editors closes", async () => { + editor(); + const split = editor(); + await settle(); + split.destroy(); + views.pop(); + expect(diagnosticsFor("/p/Cargo.toml")).toHaveLength(1); + }); + it("invalidates fixes immediately after edits and rejects a stale fix callback", async () => { + const view = editor(); + await settle(); + const [fix] = contributedIntentions(view); + view.dispatch({ changes: { from: 0, insert: "# edit\n" } }); + expect(contributedIntentions(view)).toEqual([]); + await fix!.run(); + expect(view.state.doc.toString()).toBe("# edit\n" + text); + }); + it("discards delayed responses after an edit, project switch or editor close", async () => { + let resolve!: (value: unknown) => void; + dependencyVersions.mockImplementation( + () => + new Promise((r) => { + resolve = r; + }), + ); + const view = editor(); + await settle(); + view.dispatch({ changes: { from: 0, insert: "# edit\n" } }); + resolve({ status: "ok", data: [item] }); + await vi.advanceTimersByTimeAsync(0); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + await settle(); + useProject.setState({ project: { root: "/other" } as never }); + resolve({ status: "ok", data: [item] }); + await settle(); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + expect(cancelRequest).toHaveBeenCalled(); + const other = editor("/other/Cargo.toml"); + await settle(); + other.destroy(); + views.pop(); + resolve({ status: "ok", data: [item] }); + await settle(); + expect(diagnosticsFor("/other/Cargo.toml")).toEqual([]); + }); + it("stays silent offline, with compatible versions and for non-Cargo or readonly files", async () => { + dependencyVersions.mockRejectedValue(new Error("offline")); + editor(); + await settle(); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + dependencyVersions.mockResolvedValue({ status: "ok", data: [{ ...item, update: null }] }); + editor("/p/sub/Cargo.toml"); + await settle(); + expect(diagnosticsFor("/p/sub/Cargo.toml")).toEqual([]); + dependencyVersions.mockClear(); + editor("/p/other.toml"); + editor("/p/readonly/Cargo.toml", true); + await settle(); + expect(dependencyVersions).not.toHaveBeenCalled(); + }); + it("honours the inspection profile and cleans up on close", async () => { + const view = editor(); + await settle(); + useProject.setState({ + settings: { inspections: { default: { [DEPENDENCY_INSPECTION]: { enabled: false } } } } as never, + }); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + expect(contributedIntentions(view)).toEqual([]); + view.destroy(); + views.pop(); + await settle(); + expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]); + }); + it("completes only at a parsed dependency version, preserving descending source order", async () => { + const view = editor(); + const source = view.state.languageDataAt<(ctx: CompletionContext) => Promise>( + "openideaCompletion", + from, + )[0]!; + const result = await source(new CompletionContext(view.state, from + 3, true, view)); + expect(result).toMatchObject({ + from, + to: from + 3, + filter: false, + options: item.versions.map((label) => ({ label })), + }); + expect(dependencyVersions).toHaveBeenLastCalledWith(7, from + 3, expect.any(Number)); + dependencyVersions.mockResolvedValue({ status: "ok", data: [] }); + expect(await source(new CompletionContext(view.state, 0, true, view))).toBeNull(); + }); + it("replaces the whole version and retains a simple requirement operator", () => { + const state = EditorState.create({ doc: "^1.2" }); + const context = new CompletionContext(state, 4, true); + const result = versionCompletions({ ...item, dependency: { ...item.dependency, from: 0, to: 4 } }, context); + expect(result.options[0]).toMatchObject({ label: "2.0.0-beta.1", apply: "^2.0.0-beta.1" }); + }); +}); diff --git a/src/features/dependencies/index.ts b/src/features/dependencies/index.ts new file mode 100644 index 00000000..3a47f969 --- /dev/null +++ b/src/features/dependencies/index.ts @@ -0,0 +1,208 @@ +import { EditorState, StateEffect, StateField, type Extension } from "@codemirror/state"; +import { ViewPlugin, type EditorView } from "@codemirror/view"; +import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete"; +import { + intentionProviders, + intentionsChanged, + quickFixProviders, + registerEditorContribution, + whenSynced, + type ContributedIntention, + type EditorDocContext, +} from "@/editor/api"; +import { setFileDiagnostics } from "@/editor/lsp/diagnostics"; +import { offsetsToRange } from "@/editor/lsp/positions"; +import { call, commands, isTauri, nextRequestId, type DependencyVersions, type RequestId } from "@/platform/ipc"; +import { + DEPENDENCY_INSPECTION, + DEPENDENCY_PRODUCER, + inspectionProfile, + subscribeInspectionProfile, +} from "@/platform/inspections"; +import { useProject } from "@/platform/project"; +import { tx } from "@/platform/i18n"; + +const setVersions = StateEffect.define(); +const versionsField = StateField.define({ + create: () => [], + update(value, tr) { + if (tr.docChanged) return []; + for (const effect of tr.effects) if (effect.is(setVersions)) return effect.value; + return value; + }, +}); + +const viewers = new Map(); + +const enabled = () => inspectionProfile()[DEPENDENCY_INSPECTION]?.enabled !== false; + +export function dependencyFixes(view: EditorView, from: number, to: number): ContributedIntention[] { + if (!enabled() || view.state.readOnly) return []; + const text = view.state.doc; + return (view.state.field(versionsField, false) ?? []).flatMap(({ dependency, update }) => { + if (!update || from > dependency.to || to < dependency.from) return []; + const { replacement } = update; + return [ + { + id: `dependency.update:${dependency.from}`, + label: tx`Update to ${replacement}`, + quickFix: true, + run: () => { + if (view.state.doc !== text) return; + view.dispatch({ + changes: { from: dependency.from, to: dependency.to, insert: replacement }, + userEvent: "input.dependency", + }); + }, + }, + ]; + }); +} + +export function versionCompletions(item: DependencyVersions, ctx: CompletionContext): CompletionResult { + const { dependency, versions } = item; + const typed = ctx.state.sliceDoc(dependency.from, ctx.pos); + const prefix = /^[\s^~<>=]*/.exec(typed)?.[0] ?? ""; + const filter = typed.slice(prefix.length); + return { + from: dependency.from, + to: dependency.to, + filter: false, + options: versions + .filter((v) => ctx.explicit || v.startsWith(filter)) + .map((version) => ({ + label: version, + type: "constant", + detail: dependency.package, + apply: prefix + version, + })), + }; +} + +function extension(doc: EditorDocContext): Extension { + const plugin = ViewPlugin.define((view) => { + if (!isTauri()) return {}; + viewers.set(doc.path, (viewers.get(doc.path) ?? 0) + 1); + let timer: ReturnType | undefined; + let request: RequestId | null = null; + let sequence = 0; + let disposed = false; + const clear = () => setFileDiagnostics(doc.path, DEPENDENCY_PRODUCER, []); + const cancel = () => { + sequence++; + if (request !== null) void commands.cancelRequest(request); + request = null; + }; + const run = async () => { + if (disposed || !enabled()) return; + const mine = sequence; + const text = view.state.doc; + const root = useProject.getState().project?.root; + const id = nextRequestId(); + request = id; + try { + await whenSynced(view); + if (mine !== sequence || disposed) return; + const items = await call(commands.dependencyVersions(doc.doc, null, id)); + if (mine !== sequence || disposed || view.state.doc !== text || useProject.getState().project?.root !== root) + return; + view.dispatch({ effects: [setVersions.of(items), intentionsChanged.of(null)] }); + setFileDiagnostics( + doc.path, + DEPENDENCY_PRODUCER, + items.flatMap(({ dependency, update }) => { + if (!update) return []; + const { latest } = update; + return [ + { + range: offsetsToRange(text, dependency.from, dependency.to), + severity: "info", + message: tx`A newer version is available: ${latest}`, + source: "Cargo", + code: null, + }, + ]; + }), + ); + } catch { + // Offline registries and cancelled checks never interrupt editing. + } finally { + if (request === id) request = null; + } + }; + const schedule = () => { + cancel(); + clear(); + clearTimeout(timer); + timer = setTimeout(() => void run(), 500); + }; + schedule(); + const offProfile = subscribeInspectionProfile(() => { + schedule(); + view.dispatch({ effects: intentionsChanged.of(null) }); + }); + const offProject = useProject.subscribe((state, previous) => { + if (state.project?.root !== previous.project?.root) { + disposed = true; + cancel(); + clearTimeout(timer); + clear(); + } + }); + return { + update: (u) => { + if (u.docChanged) schedule(); + }, + destroy: () => { + disposed = true; + cancel(); + clearTimeout(timer); + offProfile(); + offProject(); + const remaining = (viewers.get(doc.path) ?? 1) - 1; + if (remaining) viewers.set(doc.path, remaining); + else { + viewers.delete(doc.path); + clear(); + } + }, + }; + }); + const completion = async (ctx: CompletionContext): Promise => { + if (!isTauri()) return null; + const id = nextRequestId(); + const text = ctx.state.doc; + const root = useProject.getState().project?.root; + ctx.addEventListener("abort", () => void commands.cancelRequest(id)); + try { + // The completion context is immutable; only use it while its document is current. + const view = ctx.view; + if (!view) return null; + await whenSynced(view); + if (ctx.aborted || view.state.doc !== text) return null; + const items = await call(commands.dependencyVersions(doc.doc, ctx.pos, id)); + if (ctx.aborted || view.state.doc !== text || useProject.getState().project?.root !== root) return null; + const item = items.find(({ dependency }) => dependency.from <= ctx.pos && ctx.pos <= dependency.to); + return item ? versionCompletions(item, ctx) : null; + } catch { + return null; + } + }; + return [ + versionsField, + plugin, + intentionProviders.of((view) => { + const selection = view.state.selection.main; + return dependencyFixes(view, selection.from, selection.to); + }), + quickFixProviders.of(dependencyFixes), + EditorState.languageData.of(() => [{ openideaCompletion: completion }]), + ]; +} + +export function activate(): () => void { + return registerEditorContribution({ + id: "dependency-versions", + extensions: (doc) => (!doc.virtual && !doc.readOnly && /(?:^|\/)Cargo\.toml$/.test(doc.path) ? extension(doc) : []), + }); +} diff --git a/src/features/index.ts b/src/features/index.ts index 2678ade5..6286ef08 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -10,6 +10,7 @@ import { activate as editorLsp } from "@/editor/lsp"; import { activate as welcome } from "./welcome"; import { activate as projectTree } from "./project-tree"; import { activate as search } from "./search"; +import { activate as dependencies } from "./dependencies"; import { activate as codeInsight } from "./code-insight"; import { activate as vcs, activateSettingsPages as vcsPages } from "./vcs"; import { activate as terminal } from "./terminal"; @@ -55,6 +56,7 @@ const features: Array<[string, Activate]> = [ ["project-tree", projectTree], ["search", search], ["code-insight", codeInsight], + ["dependencies", dependencies], ["vcs", vcs], ["terminal", terminal], ["run", run], diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 5cac309c..a07662b1 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -538,7 +538,14 @@ export function activate(): () => void { }), // Detected configs carry the selected toolchains (interpreter, JDK, Maven home and options). onIdeEvent("settingsChanged", (e) => { - if (e.key === "languages" || e.key.startsWith("languages.")) scheduleReload(); + if ( + !e.key || + e.key === "languages" || + e.key.startsWith("languages.") || + e.key === "cmake" || + e.key.startsWith("cmake.") + ) + scheduleReload(); }), ); diff --git a/src/features/settings/__tests__/cmake.test.ts b/src/features/settings/__tests__/cmake.test.ts new file mode 100644 index 00000000..bf5da3a7 --- /dev/null +++ b/src/features/settings/__tests__/cmake.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { validateSettings } from "../model/schema"; +import { DEFAULT_CMAKE_PROFILE } from "@/platform/cmakeSettings"; + +describe("CMake settings", () => { + it("preserves absent and explicitly empty profiles", () => { + expect(validateSettings({}).cmake).toEqual({}); + expect(validateSettings({ cmake: { profiles: [] } }).cmake.profiles).toEqual([]); + }); + it("preserves argument arrays and drops malformed argument values", () => { + const cmakeArgs = ["-DMESSAGE=two words", '-DQUOTE="hello"', ""]; + const buildArgs = ["-j", "8", "--", "LABEL=it's a build"]; + const profiles = validateSettings({ cmake: { profiles: [{ cmakeArgs, buildArgs }] } }).cmake.profiles; + expect(profiles?.[0]).toMatchObject({ cmakeArgs, buildArgs }); + expect( + validateSettings({ cmake: { profiles: [{ cmakeArgs: ["-DFOO=bar", 42, null] }] } }).cmake.profiles?.[0] + ?.cmakeArgs, + ).toEqual(["-DFOO=bar"]); + }); + it("retains custom types and generators while normalizing malformed fields", () => { + expect( + validateSettings({ + cmake: { + profiles: [ + { + name: "Custom", + buildType: "Coverage", + generator: "Custom generator", + buildArgs: 5, + env: { KEEP: "yes", DROP: 5 }, + }, + null, + ], + }, + }).cmake.profiles, + ).toEqual([ + { + ...DEFAULT_CMAKE_PROFILE, + name: "Custom", + buildType: "Coverage", + generator: "Custom generator", + env: { KEEP: "yes" }, + }, + ]); + }); +}); diff --git a/src/features/settings/__tests__/cmakeEditor.test.tsx b/src/features/settings/__tests__/cmakeEditor.test.tsx new file mode 100644 index 00000000..2d11a991 --- /dev/null +++ b/src/features/settings/__tests__/cmakeEditor.test.tsx @@ -0,0 +1,72 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it } from "vitest"; +import { DEFAULT_CMAKE_PROFILE } from "@/platform/cmakeSettings"; +import { parseArgs } from "@/platform/commandLine"; +import { PageContext } from "../components/context"; +import { validateSettings } from "../model/schema"; +import { useSettingsDialog } from "../model/staging"; +import { CMakeProfiles } from "../pages/cmake"; + +const savedDialog = useSettingsDialog.getState(); +afterEach(() => { + cleanup(); + useSettingsDialog.setState(savedDialog, true); +}); + +it.each([ + ["CMake arguments", "cmakeArgs"], + ["Build arguments", "buildArgs"], +] as const)("round-trips spaces, quotes and empty values in %s", async (label, key) => { + const args = ["two words", 'a "quote"', "it's", "", String.raw`C:\some path\file`]; + useSettingsDialog.setState({ + base: validateSettings({ cmake: { profiles: [{ ...DEFAULT_CMAKE_PROFILE, [key]: args }] } }), + staged: {}, + }); + const mount = () => + render( + + + , + ); + const view = mount(); + const input = screen.getByLabelText(label); + expect(parseArgs(input.value)).toEqual(args); + await userEvent.clear(input); + const text = String.raw`-DNAME="two words" "-DQUOTE=it's" ""`; + await userEvent.type(input, text); + expect(input).toHaveValue(text); + fireEvent.blur(input); + const expected = ["-DNAME=two words", "-DQUOTE=it's", ""]; + expect(parseArgs(input.value)).toEqual(expected); + expect(useSettingsDialog.getState().staged["project|cmake.profiles"]?.value).toEqual([ + { ...DEFAULT_CMAKE_PROFILE, [key]: expected }, + ]); + view.unmount(); + mount(); + expect(parseArgs(screen.getByLabelText(label).value)).toEqual(expected); +}); + +it("displays each profile's arguments when switching profiles", () => { + useSettingsDialog.setState({ + base: validateSettings({ + cmake: { + profiles: [ + { ...DEFAULT_CMAKE_PROFILE, name: "Debug" }, + { ...DEFAULT_CMAKE_PROFILE, name: "Release", cmakeArgs: ["-DMESSAGE=release build"] }, + ], + }, + }), + staged: {}, + }); + render( + + + , + ); + fireEvent.change(screen.getByLabelText("CMake arguments"), { target: { value: '"unfinished ' } }); + fireEvent.click(screen.getByRole("option", { name: "Release" })); + expect(parseArgs(screen.getByLabelText("CMake arguments").value)).toEqual([ + "-DMESSAGE=release build", + ]); +}); diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index dbdb5f02..a72f4752 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -84,6 +84,29 @@ describe("Settings dialog", () => { expect(settingsSet).toHaveBeenLastCalledWith("global", "editor.encodings.nativeToAscii", "false"); }); + it("edits CMake profiles in project settings and persists them on Apply", async () => { + useProject.setState({ project: { root: "/project" } as never }); + const dialog = await openDialog("build.cmake"); + expect(within(dialog).getByRole("option", { name: "Debug" })).toBeInTheDocument(); + fireEvent.click( + within(within(dialog).getByRole("toolbar", { name: "CMake profile actions" })).getByRole("button", { + name: "Add", + }), + ); + fireEvent.change(within(dialog).getByLabelText("Name"), { target: { value: "Release" } }); + fireEvent.change(within(dialog).getByLabelText("Build type"), { target: { value: "Release" } }); + fireEvent.change(within(dialog).getByLabelText("Generator"), { target: { value: "Ninja" } }); + fireEvent.change(within(dialog).getByLabelText("Build arguments"), { target: { value: "-j 8" } }); + expect(settingsSet).not.toHaveBeenCalled(); + await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" }))); + const write = settingsSet.mock.calls.find((c) => c[1] === "cmake.profiles")!; + expect(write[0]).toBe("project"); + expect(JSON.parse(write[2])).toEqual([ + expect.objectContaining({ name: "Debug", buildType: "Debug" }), + expect.objectContaining({ name: "Release", buildType: "Release", generator: "Ninja", buildArgs: ["-j", "8"] }), + ]); + }); + 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/model/schema.ts b/src/features/settings/model/schema.ts index 8bf6e185..d92540d3 100644 --- a/src/features/settings/model/schema.ts +++ b/src/features/settings/model/schema.ts @@ -4,6 +4,7 @@ * so `validateSettings` never throws — anything unknown or malformed falls * back to the default (mirrors `ide-core::settings` defaults). */ +import { validateCMake } from "@/platform/cmakeSettings"; import { DEFAULT_IGNORED_NAMES } from "@/platform/ignoredNames"; import { validateFileTypeOverrides, validateFileTypes, type FileTypeAssociations } from "@/platform/fileTypes"; import type { LanguageServerSettings, Settings } from "@/platform/ipc"; @@ -82,6 +83,7 @@ export interface ValidSettings extends WorkflowSettings { /** System Settings › HTTP Proxy (global). */ proxy: ProxySettingsValid; languages: LanguagesValid; + cmake: ReturnType; projectStructure: ProjectStructureValid; inspections: InspectionSettingsValid; /** Editor › Natural Languages › Spelling. */ @@ -150,6 +152,7 @@ export const DEFAULT_SETTINGS: ValidSettings = { notifications: DEFAULT_NOTIFICATIONS, proxy: DEFAULT_PROXY, languages: DEFAULT_LANGUAGES, + cmake: {}, projectStructure: DEFAULT_PROJECT_STRUCTURE, inspections: DEFAULT_INSPECTIONS, spelling: { ideWords: [], projectWords: [], customDictionaries: [] }, @@ -254,6 +257,7 @@ export function validateSettings(raw: Settings | unknown): ValidSettings { notifications: validateNotifications(root.notifications), proxy: validateProxy(root.proxy), languages: validateLanguages(root.languages), + cmake: validateCMake(root.cmake), projectStructure: validateProjectStructure(root.projectStructure), inspections: validateInspections(root.inspections), spelling: validateSpelling(root.spelling), diff --git a/src/features/settings/pages/CMake.module.css b/src/features/settings/pages/CMake.module.css new file mode 100644 index 00000000..1196d4e8 --- /dev/null +++ b/src/features/settings/pages/CMake.module.css @@ -0,0 +1,4 @@ +.profile { + width: 100%; + text-align: left; +} diff --git a/src/features/settings/pages/cmake.tsx b/src/features/settings/pages/cmake.tsx new file mode 100644 index 00000000..6bdf008b --- /dev/null +++ b/src/features/settings/pages/cmake.tsx @@ -0,0 +1,151 @@ +import { useId, useState } from "react"; +import { DEFAULT_CMAKE_PROFILE, type CMakeProfileValid } from "@/platform/cmakeSettings"; +import { formatArgs, parseArgs } from "@/platform/commandLine"; +import { tx } from "@/platform/i18n"; +import { IconButton, Input, T } from "@/ui"; +import { Row, Section } from "../components/Form"; +import { useSetting } from "../components/context"; +import { TableEditor } from "./workflowControls"; +import styles from "./Workflow.module.css"; +import own from "./CMake.module.css"; + +function ArgumentsInput({ id, value, onChange }: { id: string; value: string[]; onChange: (args: string[]) => void }) { + const [text, setText] = useState(); + return ( + { + setText(e.target.value); + onChange(parseArgs(e.target.value)); + }} + onBlur={() => setText(undefined)} + /> + ); +} + +export function CMakeProfiles() { + const [saved, setProfiles] = useSetting("cmake.profiles"); + const profiles = saved ?? [DEFAULT_CMAKE_PROFILE]; + const [selected, setSelected] = useState(0); + const index = Math.min(selected, Math.max(0, profiles.length - 1)); + const current = profiles[index]; + const id = useId(); + const update = (patch: Partial) => + setProfiles(profiles.map((p, i) => (i === index ? { ...p, ...patch } : p))); + const fields = [ + ["name", "Name"], + ["buildType", "Build type"], + ["generator", "Generator"], + ["cmakeArgs", "CMake arguments"], + ["buildDir", "Build directory"], + ["buildArgs", "Build arguments"], + ] as const; + return ( +
+
+
+
+ { + setProfiles([ + ...profiles, + { ...DEFAULT_CMAKE_PROFILE, name: tx`CMake profile ${profiles.length + 1}`, env: {} }, + ]); + setSelected(profiles.length); + }} + /> + { + setProfiles(profiles.filter((_, i) => i !== index)); + setSelected(Math.max(0, index - 1)); + }} + /> +
+
+ {profiles.map((p, i) => ( + + ))} + {profiles.length === 0 && No CMake profiles. Click + to add one.} +
+
+
+ {current && ( +
+ {fields.map(([key, label]) => ( + + {key === "cmakeArgs" || key === "buildArgs" ? ( + update({ [key]: args })} + /> + ) : ( + update({ [key]: e.target.value })} + /> + )} + + ))} + + {["Debug", "Release", "RelWithDebInfo", "MinSizeRel"].map((v) => ( + + + {["Ninja", "Unix Makefiles", "Ninja Multi-Config", "Visual Studio 17 2022", "Xcode"].map((v) => ( + + + ({ name, value }))} + columns={[ + { key: "name", title: "Name", placeholder: "NAME" }, + { key: "value", title: "Value", placeholder: "value" }, + ]} + onChange={(rows) => update({ env: Object.fromEntries(rows.map((r) => [r.name ?? "", r.value ?? ""])) })} + empty="No variables" + aria-label="Environment variables" + /> + +
+ + Quote arguments containing spaces. Build arguments go to cmake --build; use -- before native build tool + options. + +
+
+ )} +
+ ); +} diff --git a/src/features/settings/pages/execution.tsx b/src/features/settings/pages/execution.tsx index 77ce59a0..3498655c 100644 --- a/src/features/settings/pages/execution.tsx +++ b/src/features/settings/pages/execution.tsx @@ -3,6 +3,7 @@ * Stepping), Python Debugger and Run/Debug. Consumed by the run and debugger * features (and `ide-run` for encoding / termination timeout). */ +import { CMakeProfiles } from "./cmake"; import { WORKFLOW_LIMITS } from "@/platform/workflowSettings"; import type { SelectOption } from "@/ui"; import { GlobListEditor, NumberInput, Row } from "../components/Form"; @@ -90,6 +91,25 @@ function TooltipDelayRow() { } export const EXECUTION_PAGES: PageSpec[] = [ + { + id: "build.cmake", + title: "CMake", + group: BUILD_GROUP, + order: 357, + projectScope: true, + defaultScope: "project", + keywords: [ + "profile", + "generator", + "Ninja", + "Build type", + "CMake arguments", + "Build directory", + "Build arguments", + "Environment variables", + ], + component: CMakeProfiles, + }, { id: "build.gradle", projectScope: true, diff --git a/src/platform/cmakeSettings.ts b/src/platform/cmakeSettings.ts new file mode 100644 index 00000000..6c4cd5ed --- /dev/null +++ b/src/platform/cmakeSettings.ts @@ -0,0 +1,38 @@ +import type { CMakeProfile } from "@/bindings"; + +export type CMakeProfileValid = Required; + +export const DEFAULT_CMAKE_PROFILE: CMakeProfileValid = { + name: "Debug", + buildType: "Debug", + generator: "", + cmakeArgs: [], + buildDir: "", + buildArgs: [], + env: {}, +}; + +export function validateCMake(raw: unknown): { profiles?: CMakeProfileValid[] } { + const profiles = (raw as { profiles?: unknown } | undefined)?.profiles; + if (!Array.isArray(profiles)) return {}; + const string = (value: unknown, fallback = "") => (typeof value === "string" ? value : fallback); + const args = (value: unknown): string[] => + Array.isArray(value) ? value.filter((arg): arg is string => typeof arg === "string") : []; + return { + profiles: profiles + .filter((p) => p && typeof p === "object") + .map((p) => ({ + name: string(p.name, "Debug"), + buildType: string(p.buildType, "Debug"), + generator: string(p.generator), + cmakeArgs: args(p.cmakeArgs), + buildDir: string(p.buildDir), + buildArgs: args(p.buildArgs), + env: Object.fromEntries( + Object.entries(p.env && typeof p.env === "object" ? p.env : {}).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ), + })), + }; +} diff --git a/src/platform/inspections.ts b/src/platform/inspections.ts index 88fcfb51..8a0ed22e 100644 --- a/src/platform/inspections.ts +++ b/src/platform/inspections.ts @@ -222,6 +222,9 @@ export interface BuiltinInspection { export const SPELLING_PRODUCER = "spelling"; export const SPELLING_INSPECTION = "builtin:spelling"; +export const DEPENDENCY_PRODUCER = "dependency-versions"; +export const DEPENDENCY_INSPECTION = "builtin:dependency-versions"; + /** IDEA's Typo options: which parts of source files are checked (plain text always is). */ export const SPELLING_OPTIONS = { code: "processCode", @@ -231,6 +234,14 @@ export const SPELLING_OPTIONS = { /** Checks the IDE runs itself (not a language server). */ export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [ + { + id: DEPENDENCY_INSPECTION, + producer: DEPENDENCY_PRODUCER, + name: "Newer dependency version available", + group: "Cargo", + description: "Reports newer crate versions that Cargo.toml requirements do not already allow.", + defaultLevel: "weakWarning", + }, { id: "builtin:file-references", producer: "file-references", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 39c7b78f..43edfc7f 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,8 @@ { + "Newer dependency version available": "有更新的依赖版本可用", + "Reports newer crate versions that Cargo.toml requirements do not already allow.": "报告 Cargo.toml 版本要求尚未允许的较新 crate 版本。", + "Update to {latest}": "更新至 {latest}", + "A newer version is available: {latest}": "有新版本可用:{latest}", "Kotlin bridge requires a Maven build root": "Kotlin 桥接需要 Maven 构建根目录", "Kotlin bridge bundle is unavailable": "Kotlin 桥接扩展不可用", "Kotlin bridge command did not acknowledge its library snapshot": "Kotlin 桥接命令未确认类库更新", @@ -5455,6 +5459,18 @@ "Java formatter configuration unavailable": "Java 格式化配置不可用", "Choose an Eclipse formatter XML file.": "请选择 Eclipse 格式化 XML 文件。", "Not a formatter XML file: {path}": "不是格式化 XML 文件:{path}", + "CMake profiles": "CMake 配置", + "CMake profile actions": "CMake 配置操作", + "No CMake profiles. Click + to add one.": "没有 CMake 配置。点击 + 添加。", + "Profile settings": "配置设置", + "Build type": "构建类型", + "Generator": "生成器", + "CMake arguments": "CMake 参数", + "Build directory": "构建目录", + "Build arguments": "构建参数", + "CMake default": "CMake 默认", + "Quote arguments containing spaces. Build arguments go to cmake --build; use -- before native build tool options.": "包含空格的参数请加引号。构建参数传给 cmake --build;原生构建工具参数前请加 --。", + "CMake profile {0}": "CMake 配置 {0}", ".NET Project": ".NET 项目", ".NET project": ".NET 项目", "Run a .NET project with a launch profile": "使用启动配置运行 .NET 项目", diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 90df1b52..827d795f 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -82,6 +82,8 @@ const UNTRANSLATED = new Set([ "dotnet test", "netcoredbg (.NET)", // Product names + "Cargo", + "CMake", "Prettier", "IntelliJ IDEA", // Go environment variables and placeholder values