Compare commits

..
79 changed files with 116 additions and 3036 deletions

No files matched your search

Generated
-2
View File
@@ -4250,7 +4250,6 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"windows-sys 0.61.2",
]
[[package]]
@@ -4684,7 +4683,6 @@ dependencies = [
"ide-text",
"ide-vfs",
"parking_lot",
"semver",
"serde",
"serde_json",
"specta",
-56
View File
@@ -58,43 +58,6 @@ pub struct Settings {
pub deployment: DeploymentSettings,
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
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<Vec<CMakeProfile>>,
}
#[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<String>,
/// Empty uses cmake-build-<lowercase build type>.
pub build_dir: String,
pub build_args: Vec<String>,
pub env: BTreeMap<String, String>,
}
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)]
@@ -191,7 +154,6 @@ impl Default for Settings {
ssh: SshSettings::default(),
deployment: DeploymentSettings::default(),
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
}
}
}
@@ -803,24 +765,6 @@ 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::<Settings>(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();
-3
View File
@@ -52,6 +52,3 @@ 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"] }
-1
View File
@@ -55,7 +55,6 @@ 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(),
-474
View File
@@ -1,474 +0,0 @@
//! 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<PathBuf> = [
"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<bool> {
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<Vec<u16>> {
let mut value: Vec<u16> = 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-"))
);
}
}
+1 -1
View File
@@ -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.local_data_dir);
let tools = Tools::new(&host.data_dir);
let whole_selection = range.is_some() && !config.range;
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
let text = ide_text::Document::new(&snap.text);
+3 -24
View File
@@ -3,7 +3,6 @@
//! 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;
@@ -34,10 +33,7 @@ pub type SinkFactory = Box<dyn Fn(&str) -> 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<LanguageRegistry>,
/// Grammars, possibly still being built on another thread.
pub syntax: Arc<OnceLock<Arc<Syntax>>>,
@@ -53,14 +49,10 @@ 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<LanguageRegistry>,
syntax: Arc<OnceLock<Arc<Syntax>>>,
pub global_settings: SettingsStore,
pub dependency_versions: Arc<ide_workspace::dependencies::VersionCache>,
pub terminals: TerminalManager,
pub processes: ProcessManager,
/// Debug sessions (adapters run on this machine, next to the project).
@@ -84,17 +76,8 @@ pub struct Host {
impl Host {
pub fn new(config: HostConfig) -> Self {
let HostConfig {
config_dir,
data_dir,
local_data_dir,
languages,
syntax,
sink_for,
owner,
runtime,
project_environment,
} = config;
let HostConfig { config_dir, 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);
@@ -103,11 +86,8 @@ 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, local_data_dir.clone()),
environment: environment::Environment::new(project_environment, 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(),
@@ -116,7 +96,6 @@ impl Host {
syntax,
config_dir,
data_dir,
local_data_dir,
sink_for,
owner,
runtime,
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
RegistryPaths::new(&host.config_dir, &host.data_dir)
}
fn join_err(e: impl std::fmt::Display) -> IdeError {
+1 -1
View File
@@ -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.local_data_dir.clone();
let data_dir = host.data_dir.clone();
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
let runtime_settings = settings.clone();
let runtime = host
-123
View File
@@ -1,123 +0,0 @@
//! 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<Vec<String>> {
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<u32>,
request: RequestId,
) -> IdeResult<Vec<DependencyVersions>> {
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<Vec<String>> {
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");
}
}
-1
View File
@@ -30,7 +30,6 @@ 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 |_| {
+1 -1
View File
@@ -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.local_data_dir.clone();
let data_dir = host.data_dir.clone();
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
let _external_build = external_build;
-2
View File
@@ -8,7 +8,6 @@ pub mod build;
pub mod bytes;
pub mod coverage;
pub mod debug;
pub mod dependencies;
pub mod deploy;
pub mod doc;
pub mod env;
@@ -70,7 +69,6 @@ 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);
+4 -5
View File
@@ -50,11 +50,10 @@ fn detected_and_saved(
suppressed: &[String],
) -> Vec<RunConfig> {
let excluded_poms = crate::project::unlinked_maven_poms(root, settings);
let mut all: Vec<RunConfig> =
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();
let mut all: Vec<RunConfig> = ide_run::detect_excluding(root, &excluded_poms)
.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);
+4 -4
View File
@@ -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<Tools> {
host.require_trust(window)?;
let data = host.local_data_dir.clone();
let data = host.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<JdkReleases> {
let tools = Tools::new(&host.local_data_dir);
let tools = Tools::new(&host.data_dir);
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err);
host.end_request(request);
@@ -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<ide_core::IpcPath> {
Ok(ide_tools::jdk::jdks_dir(&host.local_data_dir).into())
Ok(ide_tools::jdk::jdks_dir(&host.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<InstallEvent>,
) -> IdeResult<Sdk> {
let tools = Tools::new(&host.local_data_dir);
let tools = Tools::new(&host.data_dir);
let location = location.filter(|p| !p.as_os_str().is_empty());
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
+11 -11
View File
@@ -112,7 +112,7 @@ impl Host {
let project_key = project_key(&root);
let index = ProjectIndex::open(
&root,
&self.local_data_dir.join("index").join(&project_key),
&self.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.local_data_dir.clone();
let data_dir = self.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.local_data_dir.clone();
let data_dir = self.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.local_data_dir.join("cache").join(&project_key).join("build"),
&self.local_data_dir,
self.data_dir.join("cache").join(&project_key).join("build"),
&self.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.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
self.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.local_data_dir.join("cache").join(&project_key),
cache_dir: self.data_dir.join("cache").join(&project_key),
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.local_data_dir.join("cache").join(&project_key).join("build"),
self.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.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
self.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.local_data_dir.join("cache").join(&project_key),
cache_dir: self.data_dir.join("cache").join(&project_key),
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.local_data_dir.join("cache").join(&project_key).join("build"),
self.data_dir.join("cache").join(&project_key).join("build"),
)),
db,
http_history,
-39
View File
@@ -1,39 +0,0 @@
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;
}
-1
View File
@@ -9,7 +9,6 @@ fn host(base: &std::path::Path) -> Arc<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)),
-1
View File
@@ -20,7 +20,6 @@ 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)),
-1
View File
@@ -10,7 +10,6 @@ fn host(base: &std::path::Path) -> Arc<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)),
-1
View File
@@ -79,7 +79,6 @@ 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),
-3
View File
@@ -56,7 +56,6 @@ 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()))),
@@ -262,7 +261,6 @@ 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()))),
@@ -330,7 +328,6 @@ 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)),
-1
View File
@@ -6,7 +6,6 @@ fn host(base: &std::path::Path) -> Arc<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)),
@@ -24,7 +24,6 @@ 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)),
-1
View File
@@ -11,7 +11,6 @@ fn host(base: &Path, environment: bool) -> Arc<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)),
@@ -12,7 +12,6 @@ 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)),
-1
View File
@@ -28,7 +28,6 @@ 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)),
-1
View File
@@ -31,7 +31,6 @@ 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)),
-1
View File
@@ -22,7 +22,6 @@ 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)),
+1 -9
View File
@@ -16,14 +16,6 @@ pub(crate) fn detect(root: &Path) -> Vec<RunConfig> {
}
pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<RunConfig> {
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<RunConfig> {
let mut out = Vec::new();
detect_node(root, &mut out);
detect_deno(root, &mut out);
@@ -32,7 +24,7 @@ pub(crate) fn detect_with_profiles(
detect_maven(root, excluded_poms, &mut out);
detect_go(root, &mut out);
detect_python(root, &mut out);
cmake::detect(root, profiles, &mut out);
cmake::detect(root, &mut out);
detect_make(root, &mut out);
detect_just(root, &mut out);
detect_godot(root, &mut out);
+5 -172
View File
@@ -2,7 +2,6 @@
use super::{ConfigExt, config};
use crate::{BeforeLaunchTask, RunConfig};
use ide_core::settings::CMakeProfile;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
@@ -20,16 +19,10 @@ fn before(config: &mut RunConfig, id: &str) {
config.extras.before_launch.push(BeforeLaunchTask { kind: "run".into(), target: id.into() });
}
pub(super) fn detect(root: &Path, profiles: Option<&[CMakeProfile]>, out: &mut Vec<RunConfig>) {
pub(super) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
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| {
@@ -86,69 +79,11 @@ pub(super) fn detect(root: &Path, profiles: Option<&[CMakeProfile]>, out: &mut V
);
before(&mut test, &build_id);
out.push(test);
targets(root, &build, &dir, &prefix, None, out);
targets(root, &build, &dir, &prefix, out);
}
}
fn detect_profile(root: &Path, profile: &CMakeProfile, index: usize, multiple: bool, out: &mut Vec<RunConfig>) {
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<RunConfig>,
) {
fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec<RunConfig>) {
let reply = build.join(".cmake/api/v1/reply");
let Some(index_path) = fs::read_dir(&reply).ok().and_then(|entries| {
entries
@@ -178,9 +113,6 @@ fn targets(
let source_root = dunce::canonicalize(&source_root).unwrap_or(source_root);
for configuration in configurations {
let name = configuration["name"].as_str().unwrap_or("");
if profile.is_some_and(|p| !name.is_empty() && name != p.build_type) {
continue;
}
let Some(targets) = configuration["targets"].as_array() else { continue };
for target in targets {
let Some(target) = target["jsonFile"].as_str().and_then(|n| reply_file(&reply, n)) else { continue };
@@ -198,17 +130,8 @@ fn targets(
if !name.is_empty() {
args.extend(["--config", name]);
}
let mut build_config =
let build_config =
config(&target_build_id, format!("Build {target_name} ({dir} {name})"), "cmake", "cmake", &args);
if let Some(profile) = profile {
if name.is_empty() {
build_config.args.extend(["--config".into(), profile.build_type.clone()]);
}
build_config.args.extend_from_slice(&profile.build_args);
if !build.join("CMakeCache.txt").is_file() {
before(&mut build_config, &format!("{prefix}:configure"));
}
}
out.push(build_config);
let binary = build.join(artifact);
let mut run =
@@ -242,99 +165,9 @@ pub(crate) fn prepare(config: &RunConfig, cwd: &Path) -> crate::IdeResult<()> {
&& config.args.first().is_some_and(|a| a == "-S")
&& let Some(dir) = config.extras.params.get("buildDir")
{
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");
let query = cwd.join(dir).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());
}
}
-9
View File
@@ -201,15 +201,6 @@ pub fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<RunConfig
detect::detect_excluding(root, excluded_poms)
}
/// Detects configurations with explicit CMake profiles, or legacy discovery when absent.
pub fn detect_with_profiles(
root: &Path,
excluded_poms: &[PathBuf],
profiles: Option<&[ide_core::settings::CMakeProfile]>,
) -> Vec<RunConfig> {
detect::detect_with_profiles(root, excluded_poms, profiles)
}
/// Reads `<root>/.openidea/run.toml`; a missing file yields no configs.
pub fn load_user_configs(root: &Path) -> IdeResult<Vec<RunConfig>> {
config::load(root)
-69
View File
@@ -652,72 +652,3 @@ 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);
}
}
}
-1
View File
@@ -15,7 +15,6 @@ 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
+9 -47
View File
@@ -23,26 +23,16 @@ pub struct InvalidateCachesOptions {
const MARKER: &str = "invalidate-caches.json";
/// Data-directory folders removed for `options`.
pub fn cache_dirs(data_dir: &Path, local_data_dir: &Path, options: &InvalidateCachesOptions) -> Vec<PathBuf> {
let mut dirs = vec![local_data_dir.join("index")];
pub fn cache_dirs(data_dir: &Path, options: &InvalidateCachesOptions) -> Vec<PathBuf> {
let mut dirs = vec![data_dir.join("index")];
if options.local_history {
dirs.push(data_dir.join("history"));
}
if options.language_servers {
dirs.push(local_data_dir.join("lsp"));
dirs.push(data_dir.join("lsp"));
}
if options.build_caches {
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.push(data_dir.join("cache"));
}
dirs
}
@@ -67,11 +57,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, local_data_dir: &Path) -> Vec<PathBuf> {
pub fn apply_pending(data_dir: &Path) -> Vec<PathBuf> {
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, local_data_dir, &options) {
for dir in cache_dirs(data_dir, &options) {
match fs::remove_dir_all(&dir) {
Ok(()) => removed.push(dir),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
@@ -85,34 +75,6 @@ pub fn apply_pending(data_dir: &Path, local_data_dir: &Path) -> Vec<PathBuf> {
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();
@@ -120,16 +82,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, data).is_empty());
assert!(apply_pending(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, data);
let removed = apply_pending(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, data).is_empty());
assert!(apply_pending(data).is_empty());
schedule(data, &InvalidateCachesOptions::default()).unwrap();
cancel(data);
@@ -1,102 +0,0 @@
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<Mutex<Option<Cached>>>;
#[derive(Clone, Serialize, Deserialize)]
struct Cached {
fetched: u64,
versions: Vec<String>,
#[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<HashMap<String, Slot>>,
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<Vec<String>> {
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::<Cached>(&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;
-97
View File
@@ -1,97 +0,0 @@
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<Vec<String>> {
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"]);
}
@@ -1,150 +0,0 @@
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<String> {
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<Vec<String>> {
#[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<ManifestDependency> {
let Ok(doc) = text.parse::<Document<String>>() 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<ManifestDependency>) {
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<ManifestDependency>) {
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<DependencyUpdate> {
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;
@@ -1,143 +0,0 @@
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::<Vec<_>>(),
["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());
}
}
@@ -1,41 +0,0 @@
//! 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<Vec<String>>;
}
#[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<String>,
pub update: Option<DependencyUpdate>,
}
-1
View File
@@ -1,7 +1,6 @@
//! 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;
+1 -1
View File
@@ -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 = ["%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"] }
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"] }
[apps.openidea.markers]
project_opened = 'opened .* in window'
-17
View File
@@ -1,17 +0,0 @@
//! 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<u32>,
request: RequestId,
) -> IdeResult<Vec<DependencyVersions>> {
ide_host::ops::dependencies::dependency_versions(&state.host, window.label(), doc, offset, request).await
}
-2
View File
@@ -16,7 +16,6 @@ pub mod clipboard;
pub mod coverage;
pub mod db;
pub mod debug;
pub mod dependencies;
pub mod deploy;
pub mod doc;
pub mod docker;
@@ -191,7 +190,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
nav::fs_resolve_reference,
nav::path_completions,
nav::web_unresolved_references,
dependencies::dependency_versions,
spell::spell_check,
spell::spell_suggest,
search::search_start,
+1 -1
View File
@@ -53,7 +53,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_local_dir() {
if let Some(data) = dirs::data_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)));
+3 -8
View File
@@ -182,7 +182,7 @@ fn local_server_binary(state: &AppState, os: &str, arch: &str) -> Option<std::pa
if let Some(p) = std::env::var_os("OPENIDEA_SERVER_BINARY").map(std::path::PathBuf::from).filter(|p| p.is_file()) {
return Some(p);
}
let bundled = state.local_data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server");
let bundled = state.data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server");
if bundled.is_file() {
return Some(bundled);
}
@@ -205,7 +205,7 @@ async fn download_server(
arch: &str,
progress: Arc<Progress>,
) -> IdeResult<std::path::PathBuf> {
let (data, version, os, arch) = (state.local_data_dir.clone(), version.to_owned(), os.to_owned(), arch.to_owned());
let (data, version, os, arch) = (state.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,12 +259,7 @@ 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
.local_data_dir
.join("server")
.join(format!("{os}-{arch}"))
.join("openidea-server")
.display()
state.data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server").display()
))
});
}
+1 -8
View File
@@ -64,28 +64,21 @@ 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, &local_data_dir);
let removed = ide_workspace::caches::apply_pending(&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| {
-46
View File
@@ -327,7 +327,6 @@ export const commands = {
pathCompletions: (from: IpcPath, prefix: string) => typedError<PathCompletion[], IdeError>(__TAURI_INVOKE("path_completions", { from, prefix })),
/** Paths in an HTML document or stylesheet that name no existing file. */
webUnresolvedReferences: (doc: DocId) => typedError<FileReference[], IdeError>(__TAURI_INVOKE("web_unresolved_references", { doc })),
dependencyVersions: (doc: DocId, offset: number | null, request: RequestId) => typedError<DependencyVersions[], IdeError>(__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,30 +1639,6 @@ export type ByteRange = {
total: number,
};
export type CMakeProfile = {
name?: string,
buildType?: string,
generator?: string,
cmakeArgs?: string[],
/** Empty uses cmake-build-<lowercase build type>. */
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,
@@ -2469,17 +2444,6 @@ 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,
@@ -4301,14 +4265,6 @@ 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,
@@ -5679,7 +5635,6 @@ export type Settings_Deserialize = {
deployment?: DeploymentSettings,
/** Build, Execution, Deployment › Run Targets. */
runTargets?: RunTarget[],
cmake?: CMakeSettings_Deserialize,
};
export type Settings_Serialize = {
@@ -5735,7 +5690,6 @@ export type Settings_Serialize = {
deployment: DeploymentSettings,
/** Build, Execution, Deployment › Run Targets. */
runTargets: RunTarget[],
cmake: CMakeSettings_Serialize,
};
export type Severity = "error" | "warning" | "info" | "hint";
+1 -13
View File
@@ -21,7 +21,6 @@ import {
Annotation,
Compartment,
Facet,
StateEffect,
type EditorState,
type Extension,
type RangeSet,
@@ -333,25 +332,14 @@ export function contributedAt<T>(state: EditorState, key: string, pos: number):
return state.languageDataAt<T>(key, pos)[0];
}
/** A context action a feature offers at the caret, listed by Show Context Actions (⌥↵). */
/** A context action a feature offers at the caret, listed by Show Context Actions (⌥↵) before the language server's. */
export interface ContributedIntention {
id: string;
label: string;
icon?: string;
quickFix?: boolean;
run: () => void | Promise<void>;
}
/** A background provider has new context actions for the current document. */
export const intentionsChanged = StateEffect.define<null>();
/** 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[]>();
+2 -5
View File
@@ -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 { intentionsChanged, scrollbarMarks, type ScrollbarMark } from "@/editor/api";
import { scrollbarMarks, type ScrollbarMark } from "@/editor/api";
import { rangeToOffsets } from "./positions";
import { lspDoc } from "./lsp";
import { showHint } from "./hint";
@@ -199,10 +199,7 @@ 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)), intentionsChanged.of(null)],
});
this.view.dispatch({ ...tr, effects: [...[tr.effects ?? []].flat(), setMarks.of(diagnosticMarks(lint))] });
});
}
destroy() {
+1 -1
View File
@@ -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 !== "hint") offerQuickFix(view, meta, fix, from, to);
if (d.severity === "error" || d.severity === "warning") offerQuickFix(view, meta, fix, from, to);
fix.addEventListener("mousedown", (e) => e.preventDefault());
fix.addEventListener("click", () => {
view.dispatch({ selection: EditorSelection.cursor(from) });
-23
View File
@@ -1,7 +1,6 @@
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 = {
@@ -36,7 +35,6 @@ const {
intentionPreview,
lightsBulb,
listedCodeActions,
listedIntentions,
withInspectionControls,
} = await import("./intentions");
const { setFileDiagnostics } = await import("./diagnostics");
@@ -92,27 +90,6 @@ 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", () => {
+21 -73
View File
@@ -9,13 +9,7 @@ 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,
contributedQuickFixes,
intentionProviders,
intentionsChanged,
whenSynced,
} from "@/editor/api";
import { contributedIntentions, intentionProviders, whenSynced } from "@/editor/api";
import { shortcutLabel } from "@/platform/keymap";
import { debounce, docOf, isUnavailable, lspRequest, reportLspError } from "./lsp";
import { offsetsToRange } from "./positions";
@@ -38,27 +32,6 @@ 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);
@@ -131,23 +104,15 @@ async function fetchIntentions(view: EditorView): Promise<CodeAction[]> {
return actions;
}
export type EditorQuickFix = CodeAction | { title: string; run: () => void | Promise<void> };
/** Enabled quick fixes of the problem at `from..to`, including local inspections. */
export async function quickFixesAt(view: EditorView, from: number, to: number): Promise<EditorQuickFix[]> {
const local = contributedQuickFixes(view, from, to).map((fix) => ({ title: fix.label, run: fix.run }));
/** Enabled quick fixes of the problem at `from..to`, preferred first. */
export async function quickFixesAt(view: EditorView, from: number, to: number): Promise<CodeAction[]> {
const doc = docOf(view);
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;
}
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));
}
/**
@@ -179,8 +144,7 @@ export async function applyQuickFix(view: EditorView) {
showHint(view, "No quick fixes available");
}
export async function applyCodeAction(view: EditorView, action: EditorQuickFix) {
if ("run" in action) return action.run();
export async function applyCodeAction(view: EditorView, action: CodeAction) {
const doc = docOf(view);
if (!doc) return;
if (action.disabledReason) {
@@ -312,10 +276,9 @@ function actionItems(
resolved?: Map<CodeAction, Promise<CodeAction>>,
): ListPopupItem[] {
return listedCodeActions(actions).map((a, i) => ({
id: `lsp:${i}`,
id: `${i}`,
label: a.title,
icon: isQuickFix(a) ? quickFixIcon(view) : "bulb",
quickFix: isQuickFix(a),
icon: isQuickFix(a) ? "bulb-fix" : "bulb",
emphasis: a.isPreferred,
disabled: a.disabledReason,
// The edit the preview already resolved is applied as is.
@@ -381,7 +344,7 @@ export function withInspectionControls(view: EditorView, items: ListPopupItem[])
(i) => i.id !== SPELLING_INSPECTION,
);
if (!inspections.length) return items;
const fixes = items.filter((i) => isFixItem(i) && !i.submenu);
const fixes = items.filter((i) => i.icon === "bulb-fix" && !i.submenu);
if (inspections.length === 1 && fixes.length) {
const submenu = intentionControls(inspections[0]!);
return items.map((i) => (fixes.includes(i) ? { ...i, submenu } : i));
@@ -401,7 +364,7 @@ export function withInspectionControls(view: EditorView, items: ListPopupItem[])
/** ⌥↵ */
export async function showIntentions(view: EditorView) {
try {
// Local inspections need no language server.
// Typo fixes and feature-contributed actions come first and need no language server.
const local: ListPopupItem[] = [...spellingIntentions(view), ...contributedIntentions(view)];
let actions: CodeAction[] = [];
let failure: { error: unknown } | null = null;
@@ -413,9 +376,7 @@ export async function showIntentions(view: EditorView) {
}
const sorted = listedCodeActions(actions);
const resolved = new Map<CodeAction, Promise<CodeAction>>();
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]));
const items = withInspectionControls(view, [...local, ...actionItems(view, sorted, resolved)]);
if (!items.length) {
if (failure) throw failure.error;
showHint(view, "No intentions available");
@@ -427,7 +388,7 @@ export async function showIntentions(view: EditorView) {
title: "Intentions",
items,
preview: (item, signal) => {
const action = previews.get(item.id);
const action = sorted[items.indexOf(item) - local.length];
return action ? intentionPreview(view, action, resolved, signal) : Promise.resolve(null);
},
});
@@ -539,13 +500,7 @@ const bulbPlugin = ViewPlugin.fromClass(
this.lookup();
}
update(u: ViewUpdate) {
if (
u.docChanged ||
u.selectionSet ||
(u.focusChanged && u.view.hasFocus) ||
u.transactions.some((tr) => tr.effects.some((e) => e.is(intentionsChanged)))
)
this.lookup();
if (u.docChanged || u.selectionSet || (u.focusChanged && u.view.hasFocus)) this.lookup();
else if (u.focusChanged) {
this.lookup.cancel();
// A blur dispatched from inside the update is not allowed.
@@ -561,16 +516,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 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 local = typoFix || 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)
@@ -578,14 +533,7 @@ 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 || ownFix || actions.some(isQuickFix)) && quickFixIcon(this.view) === "bulb-fix",
}
: null,
),
effects: setBulb.of(any ? { line, quickFix: typoFix || actions.some(isQuickFix) } : null),
});
}
destroy() {
-2
View File
@@ -18,8 +18,6 @@ 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;
@@ -6,7 +6,6 @@ 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 }>)),
@@ -55,7 +54,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, updateExecutionPoint, useExecutionPoint, useDebug, type DebugSession } from "../state";
import { publishContext, useDebug, type DebugSession } from "../state";
import { focusDebugView, blurDebugView } from "../ui/focusedView";
import { useOverlays } from "../ui/popoverStore";
@@ -98,7 +97,6 @@ function paused(p: Partial<DebugSession> = {}) {
};
useDebug.setState({ sessions: { 3: s }, active: 3 });
publishContext();
updateExecutionPoint();
}
function caret(path: string, language: string, doc: string, line: number) {
@@ -156,41 +154,15 @@ 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);
});
@@ -208,7 +180,6 @@ 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();
@@ -166,60 +166,6 @@ 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<string, string> = 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" } });
@@ -1,202 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StackFrame, Variable } from "@/platform/ipc";
const ipc = vi.hoisted(() => {
const ok = <T>(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<typeof import("@/platform/ipc")>();
const { ok: _ok, ...commands } = ipc;
return { ...real, commands: { ...real.commands, ...commands } };
});
vi.mock("@/workbench/contributions", async (orig) => ({
...(await orig<typeof import("@/workbench/contributions")>()),
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<T>() {
let resolve!: (v: T) => void;
const promise = new Promise<T>((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<ReturnType<typeof ipc.ok<StackFrame[]>>>();
ipc.debugStackTrace.mockReturnValueOnce(pending.promise);
stopped();
expect(useExecutionPoint.getState().path).toBeNull();
pending.resolve(ipc.ok([frame(6842)]));
await settle();
const other = deferred<ReturnType<typeof ipc.ok<StackFrame[]>>>();
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<ReturnType<typeof ipc.ok<StackFrame[]>>>();
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<void>();
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<ReturnType<typeof ipc.ok<Variable[]>>>();
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");
});
});
@@ -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 { adoptSession, useDebug, useExecutionPoint } from "../state";
import { useExecutionPoint } from "../state";
import { useOverlays } from "../ui/popoverStore";
import {
calleeName,
@@ -92,8 +92,6 @@ 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)] }),
@@ -52,12 +52,6 @@ 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 => ({
+13 -44
View File
@@ -24,7 +24,6 @@ import { debugUnsupportedReason, launchConfigFor } from "./launch";
import { isRemoteConfig, REMOTE_JVM, remoteAttachConfig, remoteJvmTarget } from "./remote";
import {
activeSession,
resumeExecution,
evaluate,
isLive,
rerunSession,
@@ -90,10 +89,7 @@ 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",
resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, path, line, false))),
);
void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, path, line, false)));
}
function notSupported(what: string, s: DebugSession) {
@@ -119,17 +115,15 @@ async function smartStepInto() {
if (!t) return;
const frame = selectedFrame(t.s);
if (!frame || !sessionSupports("stepInTargets", t.s)) {
await resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, "into")));
await 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 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"),
),
await call(
targets[0]
? commands.debugStepInTarget(t.s.id, t.thread, targets[0].id)
: commands.debugStep(t.s.id, t.thread, "into"),
);
return;
}
@@ -323,11 +317,7 @@ export function registerDebugActions(): () => void {
when: paused,
run: () => {
const t = pausedTarget();
if (t)
void report(
title,
resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, kind))),
);
if (t) void report(title, call(commands.debugStep(t.s.id, t.thread, kind)));
},
});
@@ -410,11 +400,7 @@ export function registerDebugActions(): () => void {
when: paused,
run: () => {
const t = pausedTarget();
if (t)
void report(
"Resume failed",
resumeExecution(t.s.id, () => call(commands.debugResume(t.s.id, t.thread))),
);
if (t) void report("Resume failed", call(commands.debugResume(t.s.id, t.thread)));
},
},
{
@@ -446,11 +432,7 @@ export function registerDebugActions(): () => void {
when: paused,
run: () => {
const t = pausedTarget();
if (t)
void report(
"Force Step Over failed",
resumeExecution(t.s.id, () => call(commands.debugForceStepOver(t.s.id, t.thread))),
);
if (t) void report("Force Step Over failed", call(commands.debugForceStepOver(t.s.id, t.thread)));
},
},
{
@@ -463,11 +445,7 @@ export function registerDebugActions(): () => void {
when: paused,
run: () => {
const t = pausedTarget();
if (t)
void report(
"Force Step Into failed",
resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, "into"))),
);
if (t) void report("Force Step Into failed", call(commands.debugStep(t.s.id, t.thread, "into")));
},
},
{
@@ -506,10 +484,7 @@ export function registerDebugActions(): () => void {
notSupported("Reset Frame", t.s);
return;
}
void report(
"Reset Frame failed",
resumeExecution(t.s.id, () => call(commands.debugRestartFrame(t.s.id, frame.id))),
);
void report("Reset Frame failed", call(commands.debugRestartFrame(t.s.id, frame.id)));
},
},
{
@@ -556,10 +531,7 @@ export function registerDebugActions(): () => void {
const t = pausedTarget();
const c = cursor();
if (t && c && breakpointPathAllowed(c.path))
void report(
"Run to Cursor failed",
resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false))),
);
void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false)));
},
},
{
@@ -573,10 +545,7 @@ export function registerDebugActions(): () => void {
const t = pausedTarget();
const c = cursor();
if (t && c && breakpointPathAllowed(c.path))
void report(
"Force Run to Cursor failed",
resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true))),
);
void report("Force Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true)));
},
},
{
+18 -33
View File
@@ -49,7 +49,6 @@ function currentExec(path: string): Exec | null {
export function inlineValuesFor(text: string, values: Record<string, string>): 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)}`);
@@ -81,48 +80,33 @@ 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) {
@@ -152,11 +136,12 @@ export function executionPoint(ctx: EditorDocContext): Extension {
destroy() {
this.destroyed = true;
this.unsub();
this.unsubSettings();
}
},
{ decorations: (v) => v.decorations },
);
return [execField, sync, inline];
return [execField, inline];
}
+2 -2
View File
@@ -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, resumeExecution } from "../state";
import { useExecutionPoint } 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 resumeExecution(s.session, () => call(commands.debugStepInTarget(s.session, s.thread, target))).catch((e) =>
void call(commands.debugStepInTarget(s.session, s.thread, target)).catch((e) =>
notifications().notify({
group: "run",
level: "error",
+5 -38
View File
@@ -644,7 +644,6 @@ 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);
}
@@ -653,7 +652,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 && useDebug.getState().active === id && frame?.path) {
if (reveal && frame?.path) {
void maybeEditorService()?.openFile(frame.path, {
selection: { line: frame.line, col: frame.column },
focus: false,
@@ -663,7 +662,6 @@ 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));
@@ -671,7 +669,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 || s.frames !== before?.frames) return;
if (!s || s.status !== "paused" || s.selectedThread !== threadId) 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")) {
@@ -709,21 +707,19 @@ async function loadThreads(id: DebugSessionId) {
/** Locals of `frameId` (first non-expensive scope) for inline values. */
async function loadLocals(id: DebugSessionId, frameId: number) {
const frames = useDebug.getState().sessions[id]?.frames;
const values: Record<string, string> = Object.create(null);
const values: Record<string, string> = {};
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 && !Object.hasOwn(values, v.name)) values[v.name] = v.value;
for (const v of vars) if (v.name && !(v.name in values)) values[v.name] = v.value;
}
} catch {
// No inline values.
}
const s = useDebug.getState().sessions[id];
if (useDebug.getState().active !== id || s?.status !== "paused" || s.selectedFrame !== frameId || s.frames !== frames)
return;
if (useDebug.getState().active !== id || s?.selectedFrame !== frameId) return;
useExecutionPoint.setState({ values });
}
@@ -733,7 +729,6 @@ 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];
@@ -762,32 +757,6 @@ 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<unknown>) {
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) {
@@ -893,7 +862,6 @@ 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;
@@ -908,7 +876,6 @@ 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: [],
+2 -4
View File
@@ -13,7 +13,7 @@ import {
updateBreakpoint,
useBreakpoints,
} from "../breakpoints/store";
import { activeSession, addWatch, evaluate, isLive, startSession, useDebug, resumeExecution } from "../state";
import { activeSession, addWatch, evaluate, isLive, startSession, useDebug } from "../state";
import {
addMethodBreakpoint,
removeAllSpecial,
@@ -978,9 +978,7 @@ function SmartStepChooser() {
const pick = (targetId: number) => {
if (!state) return;
close();
void resumeExecution(state.session, () =>
call(commands.debugStepInTarget(state.session, state.thread, targetId)),
).catch((e) =>
void call(commands.debugStepInTarget(state.session, state.thread, targetId)).catch((e) =>
notifications().notify({ group: "run", level: "error", title: "Smart Step Into failed", body: errorText(e) }),
);
};
@@ -1,210 +0,0 @@
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<typeof import("@/platform/ipc")>();
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<unknown>>(
"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" });
});
});
-208
View File
@@ -1,208 +0,0 @@
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<DependencyVersions[]>();
const versionsField = StateField.define<DependencyVersions[]>({
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<string, number>();
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<typeof setTimeout> | 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<CompletionResult | null> => {
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) : []),
});
}
-2
View File
@@ -10,7 +10,6 @@ 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";
@@ -56,7 +55,6 @@ const features: Array<[string, Activate]> = [
["project-tree", projectTree],
["search", search],
["code-insight", codeInsight],
["dependencies", dependencies],
["vcs", vcs],
["terminal", terminal],
["run", run],
+1 -8
View File
@@ -538,14 +538,7 @@ export function activate(): () => void {
}),
// Detected configs carry the selected toolchains (interpreter, JDK, Maven home and options).
onIdeEvent("settingsChanged", (e) => {
if (
!e.key ||
e.key === "languages" ||
e.key.startsWith("languages.") ||
e.key === "cmake" ||
e.key.startsWith("cmake.")
)
scheduleReload();
if (e.key === "languages" || e.key.startsWith("languages.")) scheduleReload();
}),
);
@@ -1,46 +0,0 @@
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" },
},
]);
});
});
@@ -1,72 +0,0 @@
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(
<PageContext.Provider value={{ pageId: "build.cmake", scope: "project" }}>
<CMakeProfiles />
</PageContext.Provider>,
);
const view = mount();
const input = screen.getByLabelText<HTMLInputElement>(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<HTMLInputElement>(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(
<PageContext.Provider value={{ pageId: "build.cmake", scope: "project" }}>
<CMakeProfiles />
</PageContext.Provider>,
);
fireEvent.change(screen.getByLabelText("CMake arguments"), { target: { value: '"unfinished ' } });
fireEvent.click(screen.getByRole("option", { name: "Release" }));
expect(parseArgs(screen.getByLabelText<HTMLInputElement>("CMake arguments").value)).toEqual([
"-DMESSAGE=release build",
]);
});
@@ -84,29 +84,6 @@ 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");
-4
View File
@@ -4,7 +4,6 @@
* 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";
@@ -83,7 +82,6 @@ export interface ValidSettings extends WorkflowSettings {
/** System Settings › HTTP Proxy (global). */
proxy: ProxySettingsValid;
languages: LanguagesValid;
cmake: ReturnType<typeof validateCMake>;
projectStructure: ProjectStructureValid;
inspections: InspectionSettingsValid;
/** Editor › Natural Languages › Spelling. */
@@ -152,7 +150,6 @@ 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: [] },
@@ -257,7 +254,6 @@ 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),
@@ -1,4 +0,0 @@
.profile {
width: 100%;
text-align: left;
}
-151
View File
@@ -1,151 +0,0 @@
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<string>();
return (
<Input
id={id}
className={styles.wide}
value={text ?? formatArgs(value)}
onChange={(e) => {
setText(e.target.value);
onChange(parseArgs(e.target.value));
}}
onBlur={() => setText(undefined)}
/>
);
}
export function CMakeProfiles() {
const [saved, setProfiles] = useSetting<CMakeProfileValid[] | undefined>("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<CMakeProfileValid>) =>
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 (
<div className={styles.toolsLayout}>
<Section title="CMake profiles">
<div className={styles.tableBox}>
<div className={styles.toolbar} role="toolbar" aria-label={tx("CMake profile actions")}>
<IconButton
icon="plus"
title="Add"
size="small"
onClick={() => {
setProfiles([
...profiles,
{ ...DEFAULT_CMAKE_PROFILE, name: tx`CMake profile ${profiles.length + 1}`, env: {} },
]);
setSelected(profiles.length);
}}
/>
<IconButton
icon="minus"
title="Remove"
size="small"
disabled={!current}
onClick={() => {
setProfiles(profiles.filter((_, i) => i !== index));
setSelected(Math.max(0, index - 1));
}}
/>
</div>
<div className={styles.toolList} role="listbox" aria-label={tx("CMake profiles")}>
{profiles.map((p, i) => (
<button
type="button"
key={i}
role="option"
aria-selected={index === i}
className={`${own.profile} ${styles.toolRow} ${index === i ? styles.toolRowSelected : ""}`}
onClick={() => setSelected(i)}
>
{p.name}
</button>
))}
{profiles.length === 0 && <T>No CMake profiles. Click + to add one.</T>}
</div>
</div>
</Section>
{current && (
<Section title="Profile settings">
{fields.map(([key, label]) => (
<Row key={key} label={label} htmlFor={`${id}-${key}`} className={styles.alignedRow}>
{key === "cmakeArgs" || key === "buildArgs" ? (
<ArgumentsInput
key={index}
id={`${id}-${key}`}
value={current[key]}
onChange={(args) => update({ [key]: args })}
/>
) : (
<Input
id={`${id}-${key}`}
className={styles.wide}
value={current[key]}
list={key === "buildType" || key === "generator" ? `${id}-${key}-options` : undefined}
placeholder={
key === "buildDir"
? "cmake-build-" + current.buildType.toLowerCase()
: key === "generator"
? tx("CMake default")
: undefined
}
onChange={(e) => update({ [key]: e.target.value })}
/>
)}
</Row>
))}
<datalist id={`${id}-buildType-options`}>
{["Debug", "Release", "RelWithDebInfo", "MinSizeRel"].map((v) => (
<option key={v} value={v} />
))}
</datalist>
<datalist id={`${id}-generator-options`}>
{["Ninja", "Unix Makefiles", "Ninja Multi-Config", "Visual Studio 17 2022", "Xcode"].map((v) => (
<option key={v} value={v} />
))}
</datalist>
<Row label="Environment variables" stacked>
<TableEditor
rows={Object.entries(current.env).map(([name, value]) => ({ 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"
/>
</Row>
<div className={styles.note}>
<T>
Quote arguments containing spaces. Build arguments go to cmake --build; use -- before native build tool
options.
</T>
</div>
</Section>
)}
</div>
);
}
-20
View File
@@ -3,7 +3,6 @@
* 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";
@@ -91,25 +90,6 @@ 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,
-38
View File
@@ -1,38 +0,0 @@
import type { CMakeProfile } from "@/bindings";
export type CMakeProfileValid = Required<CMakeProfile>;
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",
),
),
})),
};
}
-11
View File
@@ -222,9 +222,6 @@ 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",
@@ -234,14 +231,6 @@ 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",
+1 -17
View File
@@ -1,8 +1,4 @@
{
"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 桥接命令未确认类库更新",
@@ -5458,17 +5454,5 @@
"Cannot read Java formatter XML {path}: {error}": "无法读取 Java 格式化 XML {path}:{error}",
"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}"
"Not a formatter XML file: {path}": "不是格式化 XML 文件:{path}"
}
-2
View File
@@ -80,8 +80,6 @@ const BUILT_AT_RUNTIME = new Set<string>([]);
/** Text that stays as written in every locale: names, code, formats, shortcuts. */
const UNTRANSLATED = new Set<string>([
// Product names
"Cargo",
"CMake",
"Prettier",
"IntelliJ IDEA",
// Go environment variables and placeholder values