diff --git a/Cargo.lock b/Cargo.lock index 6f1b98b05..e3d45f110 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,6 +60,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -616,6 +630,12 @@ dependencies = [ "cipher", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "borsh" version = "1.8.1" @@ -690,6 +710,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.2" @@ -2030,6 +2056,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "embed-resource" version = "3.0.12" @@ -2187,6 +2222,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "faster-hex" version = "0.10.1" @@ -2287,6 +2333,17 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "flume" version = "0.12.0" @@ -2346,6 +2403,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "fs-set-times" version = "0.20.3" @@ -4256,7 +4323,9 @@ name = "ide-db" version = "0.1.39" dependencies = [ "bb8", + "blake3", "dirs 6.0.0", + "dunce", "futures", "ide-core", "ide-http", @@ -4274,6 +4343,7 @@ dependencies = [ "tokio", "tokio-util", "toml 0.9.12+spec-1.1.0", + "toml_edit 0.25.17+spec-1.1.0", "tracing", "uuid", ] @@ -4380,6 +4450,7 @@ dependencies = [ "ide-vfs", "ide-workspace", "ignore", + "jsonschema", "parking_lot", "quick-xml 0.42.0", "regex-lite", @@ -4587,6 +4658,7 @@ name = "ide-plugin" version = "0.1.39" dependencies = [ "blake3", + "dunce", "ide-core", "ide-extension", "parking_lot", @@ -4598,6 +4670,7 @@ dependencies = [ "thiserror 2.0.21", "tokio", "tracing", + "url", "wasmtime", "wasmtime-wasi", "wat", @@ -4620,6 +4693,7 @@ dependencies = [ name = "ide-remote" version = "0.1.39" dependencies = [ + "dunce", "globset", "ide-core", "ide-run", @@ -5287,6 +5361,32 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonschema" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46662859bc5f60a145b75f4632fbadc84e829e45df6c5de74cfc8e05acb96b5" +dependencies = [ + "ahash", + "base64 0.22.1", + "bytecount", + "email_address", + "fancy-regex", + "fraction", + "idna", + "itoa", + "num-cmp", + "num-traits", + "once_cell", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "uuid-simd", +] + [[package]] name = "jupyter-protocol" version = "2.0.3" @@ -5520,7 +5620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" dependencies = [ "bitflags 1.3.2", - "fluent-uri", + "fluent-uri 0.1.4", "serde", "serde_json", "serde_repr", @@ -5862,6 +5962,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + [[package]] name = "num-complex" version = "0.4.6" @@ -6539,6 +6645,12 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.14.0" @@ -7306,6 +7418,20 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "referencing" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9c261f7ce75418b3beadfb3f0eb1299fe8eb9640deba45ffa2cb783098697d" +dependencies = [ + "ahash", + "fluent-uri 0.3.2", + "once_cell", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regalloc2" version = "0.15.2" @@ -10496,6 +10622,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "uuid", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -10520,6 +10657,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vswhom" version = "0.1.0" diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index c543c0e39..3e786e418 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -20,6 +20,8 @@ pub enum McpUiRequest { #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] pub enum IdeEvent { + /// Project data source settings changed; reload the Database tree. + DbSourcesChanged, McpRequest { id: u32, request: McpUiRequest, diff --git a/crates/ide-core/src/remote_settings.rs b/crates/ide-core/src/remote_settings.rs index 62442a36f..3d1fc5276 100644 --- a/crates/ide-core/src/remote_settings.rs +++ b/crates/ide-core/src/remote_settings.rs @@ -191,6 +191,19 @@ pub struct RunTarget { pub image_user: bool, } +/// A plugin-owned argv wrapper for project runs (API 1.4). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PluginRunTarget { + pub id: String, + pub name: String, + pub program: String, + pub args: Vec, + pub workdir_args: Vec, + pub container_root: Option, + pub commands: Vec<(String, Vec)>, +} + #[cfg(test)] mod deployment_tests { use super::*; diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 8feb6a512..b249c3453 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -629,6 +629,8 @@ impl Default for FileSettings { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct LanguagesSettings { + /// Explicit plugin formatter, by language id. Empty entries use the normal formatter policy. + pub plugin_formatters: BTreeMap, pub python: PythonSettings, pub node: NodeSettings, pub php: PhpSettings, @@ -646,6 +648,7 @@ pub struct LanguagesSettings { impl Default for LanguagesSettings { fn default() -> Self { Self { + plugin_formatters: BTreeMap::new(), python: PythonSettings::default(), node: NodeSettings::default(), php: PhpSettings::default(), @@ -767,6 +770,8 @@ impl Default for PythonSettings { pub struct PhpSettings { /// `php` CLI executable; empty = the one on `PATH`. pub interpreter: String, + /// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local. + pub run_target: String, /// PHP version the language server checks the code against (`7.4`); empty = from /// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows). pub language_level: String, @@ -794,6 +799,8 @@ pub struct RubySettings { pub struct NodeSettings { /// `node` executable; empty = the one on `PATH`. pub interpreter: String, + /// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local. + pub run_target: String, /// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`. pub package_manager: String, } diff --git a/crates/ide-core/src/workflow_settings.rs b/crates/ide-core/src/workflow_settings.rs index 5549be1af..293775a55 100644 --- a/crates/ide-core/src/workflow_settings.rs +++ b/crates/ide-core/src/workflow_settings.rs @@ -272,6 +272,8 @@ impl Default for PythonDebuggerSettings { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct RunSettings { + /// Target for configurations without an explicit target. Empty = local. + pub default_target: String, pub confirm_rerun: bool, /// Ask before closing a Run tab whose process is still running. pub confirm_terminate: bool, @@ -282,7 +284,13 @@ pub struct RunSettings { impl Default for RunSettings { fn default() -> Self { - Self { confirm_rerun: true, confirm_terminate: true, show_on_start: true, termination_timeout_ms: 3000 } + Self { + default_target: String::new(), + confirm_rerun: true, + confirm_terminate: true, + show_on_start: true, + termination_timeout_ms: 3000, + } } } diff --git a/crates/ide-db/Cargo.toml b/crates/ide-db/Cargo.toml index 709cacc15..0a19ef0af 100644 --- a/crates/ide-db/Cargo.toml +++ b/crates/ide-db/Cargo.toml @@ -7,10 +7,13 @@ license.workspace = true [dependencies] ide-core.workspace = true +dunce.workspace = true serde.workspace = true serde_json = { workspace = true, features = ["raw_value"] } specta.workspace = true toml.workspace = true +toml_edit.workspace = true +blake3.workspace = true tokio.workspace = true tracing.workspace = true parking_lot.workspace = true diff --git a/crates/ide-db/src/lib.rs b/crates/ide-db/src/lib.rs index 79913f3dc..bab76ac77 100644 --- a/crates/ide-db/src/lib.rs +++ b/crates/ide-db/src/lib.rs @@ -7,6 +7,7 @@ pub mod conn; pub mod history; mod oracle; pub mod plan; +pub mod plugin; pub mod secrets; pub mod source; pub mod split; @@ -381,7 +382,9 @@ impl DbManager { /// Where the saved passwords of data source `id` live: app-wide for a global one. fn saved_for(&self, id: &str) -> &SecretStore { match &self.global { - Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store, + Some((_, store)) if self.sources().is_ok_and(|sources| sources.iter().any(|s| s.id == id && s.global)) => { + store + } _ => &self.saved_passwords, } } @@ -412,19 +415,28 @@ impl DbManager { /// Saves the data source list; connections of removed or changed sources are closed. /// Global ones (`global`) go to the app-wide list, the others to the project's. pub async fn save_sources(&self, sources: Vec) -> IdeResult<()> { - let old = self.sources().unwrap_or_default(); - let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global); - match &self.global { - // Rewritten only when it changes: other projects' windows edit the same file. - Some((dir, _)) => { - if global != old.iter().filter(|s| s.global).cloned().collect::>() { - source::save_global(dir, &global)?; - } - } - None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")), - None => {} + if sources.iter().any(|s| s.global && s.plugin_owner.is_some()) { + return Err(IdeError::invalid("plugin-owned data sources must stay project-scoped")); } - source::save(&self.root, &project)?; + let old = { + let _guard = plugin::EDIT.lock(); + let old = self.sources().unwrap_or_default(); + let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global); + match &self.global { + // Rewritten only when it changes: other projects' windows edit the same file. + Some((dir, _)) => { + if global != old.iter().filter(|s| s.global).cloned().collect::>() { + source::save_global(dir, &global)?; + } + } + None if !global.is_empty() => { + return Err(IdeError::invalid("global data sources are not available here")); + } + None => {} + } + source::save(&self.root, &project)?; + old + }; for o in old { match sources.iter().find(|s| s.id == o.id) { Some(n) if *n == o => {} diff --git a/crates/ide-db/src/plugin.rs b/crates/ide-db/src/plugin.rs new file mode 100644 index 000000000..8de3888da --- /dev/null +++ b/crates/ide-db/src/plugin.rs @@ -0,0 +1,271 @@ +//! Ownership checks and surgical updates of plugin-created project sources. +use crate::{DataSource, DbManager, source}; +use ide_core::{IdeError, IdeResult, project_dir}; +use std::path::Path; + +// All DbManagers of the same root share this file. Never hold this across an await. +pub(crate) static EDIT: parking_lot::Mutex<()> = parking_lot::Mutex::new(()); + +/// Host-owned connection id, unambiguous and safe for console paths. +pub fn source_id(plugin: &str, id: &str) -> String { + format!("plugin-{}", blake3::hash(format!("{plugin}\0{id}").as_bytes()).to_hex()) +} + +fn owned(source: &DataSource, plugin: &str, id: &str) -> bool { + source.id == source_id(plugin, id) + && source.plugin_owner.as_deref() == Some(plugin) + && source.plugin_source_id.as_deref() == Some(id) + && !source.global +} + +/// Edits just matching array tables, retaining unrelated entries, comments and formatting. +fn update(root: &Path, remove: impl Fn(&DataSource) -> bool, add: Option<&DataSource>) -> IdeResult> { + let path = project_dir::path(root, "data-sources.toml"); + if let Some(ancestor) = path.ancestors().find(|p| p.exists()) + && root.exists() + && !dunce::canonicalize(ancestor)?.starts_with(dunce::canonicalize(root)?) + { + return Err(IdeError::invalid("data sources file is outside the project")); + } + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => return Err(e.into()), + }; + let old = source::load(root)?; + let doc = toml_edit::Document::parse(text.as_str()).map_err(|e| IdeError::invalid(format!("data sources: {e}")))?; + let removed: Vec<_> = old.iter().filter(|s| remove(s)).cloned().collect(); + fn line_span(text: &str, span: std::ops::Range, out: &mut Vec>) { + let start = text[..span.start].rfind('\n').map_or(0, |n| n + 1); + let mut end = text[span.end..].find('\n').map_or(text.len(), |n| span.end + n + 1); + while end < text.len() { + let next = text[end..].find('\n').map_or(text.len(), |n| end + n + 1); + if !text[end..next].trim().is_empty() { + break; + } + end = next; + } + out.push(start..end); + } + fn spans(table: &toml_edit::Table, text: &str, out: &mut Vec>) { + if !table.is_implicit() + && let Some(span) = table.span() + { + line_span(text, span, out); + } + for (_, item) in table.iter() { + if let Some(span) = item.as_value().and_then(toml_edit::Value::span) { + line_span(text, span, out); + } + if let Some(table) = item.as_table() { + spans(table, text, out); + } + if let Some(tables) = item.as_array_of_tables() { + for table in tables.iter() { + spans(table, text, out); + } + } + } + } + if doc.get("data-source").is_some_and(|i| i.as_array_of_tables().is_none()) { + return Err(IdeError::invalid("plugin sources require [[data-source]] tables")); + } + let mut ranges = vec![]; + if let Some(tables) = doc.get("data-source").and_then(toml_edit::Item::as_array_of_tables) { + for (table, source) in tables.iter().zip(&old) { + if remove(source) { + spans(table, &text, &mut ranges); + } + } + } + ranges.sort_by_key(|r| r.start); + let mut merged: Vec> = vec![]; + for range in ranges { + if let Some(last) = merged.last_mut() + && range.start <= last.end + { + last.end = last.end.max(range.end); + continue; + } + merged.push(range); + } + let mut output = text.clone(); + for range in merged.into_iter().rev() { + output.replace_range(range, ""); + } + if let Some(add) = add { + let mut value = toml::Value::try_from(add).map_err(|e| IdeError::internal(e.to_string()))?; + value.as_table_mut().unwrap().remove("global"); + let value = + toml::Value::Table(toml::Table::from_iter([("data-source".into(), toml::Value::Array(vec![value]))])); + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + output.push_str(&toml::to_string_pretty(&value).map_err(|e| IdeError::internal(e.to_string()))?); + } + if !removed.is_empty() || add.is_some() { + let dir = path.parent().unwrap(); + std::fs::create_dir_all(dir)?; + let tmp = dir.join(format!(".data-sources-{}.tmp", uuid::Uuid::new_v4())); + std::fs::write(&tmp, output)?; + if let Err(e) = std::fs::rename(&tmp, &path) { + let _ = std::fs::remove_file(&tmp); + return Err(e.into()); + } + } + Ok(removed) +} + +/// Removes only this plugin's sources, including when their project is closed at uninstall. +pub fn remove_all(root: &Path, plugin: &str) -> IdeResult> { + let _guard = EDIT.lock(); + update(root, |s| s.plugin_owner.as_deref() == Some(plugin), None) +} + +impl DbManager { + /// Lists private source ids without exposing any connection settings. + pub fn plugin_sources(&self, plugin: &str) -> IdeResult> { + Ok(source::load(&self.root)? + .into_iter() + .filter(|s| s.plugin_owner.as_deref() == Some(plugin)) + .filter_map(|s| s.plugin_source_id) + .collect()) + } + /// Checks the stored owner, even if a user-created source uses the host's id format. + pub fn plugin_source(&self, plugin: &str, id: &str) -> IdeResult { + source::load(&self.root)? + .into_iter() + .find(|s| owned(s, plugin, id)) + .ok_or_else(|| IdeError::not_found("no owned data source with this id")) + } + /// Replaces only an owned source and disconnects its old pools. + pub async fn upsert_plugin_source(&self, plugin: &str, id: &str, mut source: DataSource) -> IdeResult<()> { + source.id = source_id(plugin, id); + source.plugin_owner = Some(plugin.into()); + source.plugin_source_id = Some(id.into()); + source.global = false; + { + let _guard = EDIT.lock(); + let all = self.sources()?; + if all.iter().any(|s| s.id == source.id && !owned(s, plugin, id)) { + return Err(IdeError::not_found("no owned data source with this id")); + } + if !all.iter().any(|s| owned(s, plugin, id)) && self.plugin_sources(plugin)?.len() >= 64 { + return Err(IdeError::invalid("at most 64 sources per plugin and project")); + } + update(&self.root, |s| owned(s, plugin, id), Some(&source))?; + } + self.disconnect_source(&source.id).await; + Ok(()) + } + /// Deletes an owned source and all its saved/session credentials. + pub async fn remove_plugin_source(&self, plugin: &str, id: &str) -> IdeResult<()> { + let source = { + let _guard = EDIT.lock(); + let source = self.plugin_source(plugin, id)?; + update(&self.root, |s| owned(s, plugin, id), None)?; + source + }; + self.forget_plugin_source(&source.id).await; + Ok(()) + } + /// Completes cleanup after removal from the file. + pub async fn forget_plugin_source(&self, id: &str) { + for key in [id.to_owned(), crate::secrets::ssh_key(id)] { + self.session_passwords.lock().remove(&key); + self.saved_passwords.delete(&key); + } + self.disconnect_source(id).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{DbDriver, secrets::SecretStore}; + #[tokio::test] + async fn ownership_preserves_unrelated_bytes_and_cleans_credentials() { + ide_ssh::secrets::testing::use_memory_store(); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + std::fs::create_dir_all(root.join(".openidea")).unwrap(); + let original = "# user comment\n[[data-source]] # keep\nid = 'user'\nname='Mine'\ndriver='postgres'\n\n[data-source.options]\ntimeZone = 'UTC' # retain\n"; + let file = root.join(".openidea/data-sources.toml"); + std::fs::write(&file, original).unwrap(); + let secrets = tmp.path().join("passwords.json"); + let db = DbManager::new( + root.clone(), + secrets.clone(), + tmp.path().join("history.json"), + std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))), + ); + let settings = + DataSource { name: "Plugin".into(), driver: DbDriver::Postgres, save_password: true, ..Default::default() }; + db.upsert_plugin_source("a", "local", settings.clone()).await.unwrap(); + assert!(std::fs::read_to_string(&file).unwrap().starts_with(original)); + db.upsert_plugin_source("b", "local", settings.clone()).await.unwrap(); + assert_eq!(db.plugin_sources("a").unwrap(), ["local"]); + assert!(db.plugin_source("a", "user").is_err()); + assert!(db.remove_plugin_source("a", "user").await.is_err()); + let source = db.plugin_source("a", "local").unwrap(); + db.set_password(&source.id, Some("top-secret".into()), true).await.unwrap(); + let store = SecretStore::new(secrets, &root); + assert_eq!(store.get(&source.id).as_deref(), Some("top-secret")); + db.upsert_plugin_source("a", "local", DataSource { name: "Updated".into(), ..settings }).await.unwrap(); + assert_eq!(store.get(&source.id).as_deref(), Some("top-secret")); + db.remove_plugin_source("a", "local").await.unwrap(); + assert!(store.get(&source.id).is_none()); + assert_eq!(db.plugin_sources("b").unwrap(), ["local"]); + remove_all(&root, "b").unwrap(); + assert_eq!(std::fs::read_to_string(&file).unwrap(), original); + } + #[tokio::test] + async fn project_owned_credentials_do_not_replace_a_shadowed_global_sources_password() { + ide_ssh::secrets::testing::use_memory_store(); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + let global = tmp.path().join("global"); + let passwords = tmp.path().join("passwords.json"); + let db = DbManager::new( + root.clone(), + passwords.clone(), + tmp.path().join("history.json"), + std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))), + ) + .with_global_sources(global.clone()); + db.upsert_plugin_source( + "plugin", + "local", + DataSource { name: "Owned".into(), save_password: true, ..Default::default() }, + ) + .await + .unwrap(); + let owned = db.plugin_source("plugin", "local").unwrap(); + source::save_global( + &global, + &[DataSource { id: owned.id.clone(), name: "Global user source".into(), ..Default::default() }], + ) + .unwrap(); + let global_store = SecretStore::new(passwords.clone(), &global); + global_store.set(&owned.id, "user-secret").unwrap(); + db.set_password(&owned.id, Some("plugin-secret".into()), true).await.unwrap(); + assert_eq!(SecretStore::new(passwords, &root).get(&owned.id).as_deref(), Some("plugin-secret")); + assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret")); + db.remove_plugin_source("plugin", "local").await.unwrap(); + assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret")); + } + #[tokio::test] + async fn a_user_source_cannot_be_claimed_by_predicting_its_id() { + let tmp = tempfile::tempdir().unwrap(); + let db = DbManager::new( + tmp.path().join("project"), + tmp.path().join("passwords.json"), + tmp.path().join("history.json"), + std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))), + ); + let source = DataSource { id: source_id("plugin", "x"), name: "User".into(), ..Default::default() }; + db.save_sources(vec![source.clone()]).await.unwrap(); + assert!(db.upsert_plugin_source("plugin", "x", source.clone()).await.is_err()); + assert_eq!(db.sources().unwrap(), [source]); + } +} diff --git a/crates/ide-db/src/source.rs b/crates/ide-db/src/source.rs index da8ddec2f..6294602e9 100644 --- a/crates/ide-db/src/source.rs +++ b/crates/ide-db/src/source.rs @@ -190,6 +190,12 @@ pub struct DataSource { /// Stored app-wide and listed in every project. Implied by the file it is read from, /// so not written to it. pub global: bool, + /// Plugin id owning this source; absent for user-managed sources. + #[serde(skip_serializing_if = "Option::is_none")] + pub plugin_owner: Option, + /// Id private to the owner, never used as a connection target. + #[serde(skip_serializing_if = "Option::is_none")] + pub plugin_source_id: Option, } impl Default for DataSource { @@ -219,6 +225,8 @@ impl Default for DataSource { introspection: DbIntrospection::default(), properties: Vec::new(), global: false, + plugin_owner: None, + plugin_source_id: None, } } } diff --git a/crates/ide-extension/src/capability.rs b/crates/ide-extension/src/capability.rs index 9b4848611..00a79d417 100644 --- a/crates/ide-extension/src/capability.rs +++ b/crates/ide-extension/src/capability.rs @@ -20,6 +20,10 @@ pub enum Capability { EditorWrite, /// Create and replace files inside the project root. ProjectWrite, + /// Run arbitrary IDE actions and other plugins' commands. + IdeCommands, + /// Manage only its own project database sources and write their credentials. + DatabaseSources, VcsRead, VcsWrite, /// The plugin's own key-value store and data directory. @@ -32,8 +36,12 @@ pub enum Capability { InputEvents, /// The system clipboard, from the plugin's web views. Clipboard, - /// HTTP(S) to one host (`*.example.com` matches subdomains). + /// HTTPS (and loopback HTTP) to one host (`*.example.com` matches subdomains). Net(String), + /// Unencrypted HTTP to one host. + NetHttp(String), + /// A local socket or named pipe on the project machine. + Ipc(String), /// Run one external program. Full code execution: never fine-grained. Process(String), /// Read files outside the project under one path. @@ -60,12 +68,14 @@ impl Capability { Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low, Self::EditorWrite | Self::ProjectWrite + | Self::DatabaseSources | Self::VcsWrite | Self::Net(_) + | Self::NetHttp(_) | Self::Secrets | Self::InputEvents | Self::Clipboard => Risk::Medium, - Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High, + Self::IdeCommands | Self::Ipc(_) | Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High, } } @@ -82,6 +92,8 @@ impl Capability { Self::EditorRead => "Read the focused editor's file and selection".into(), Self::EditorWrite => "Change text in the editor".into(), Self::ProjectWrite => "Create and change files in the open project".into(), + Self::DatabaseSources => "Manage its own database sources and store their passwords in trusted projects".into(), + Self::IdeCommands => "Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing".into(), Self::VcsRead => "Read version control state".into(), Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(), Self::Storage => "Store its own data".into(), @@ -92,6 +104,12 @@ impl Capability { "Copy to the clipboard from its commands, and read and write it from its web views".into() } Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"), + Self::NetHttp(host) => { + format!("Connect to {host} over unencrypted HTTP (from the machine the project is on)") + } + Self::Ipc(path) => { + format!("Connect to local socket {path} — equivalent to running arbitrary code on the project machine") + } Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"), Self::FsRead(path) => format!("Read files under {path}, outside the project"), Self::FsWrite(path) => format!("Change files under {path}, outside the project"), @@ -100,8 +118,14 @@ impl Capability { /// Whether `self`, granted, also covers `wanted` (`net:*.x.com` covers `net:a.x.com`). pub fn covers(&self, wanted: &Capability) -> bool { + if self == wanted { + return true; + } match (self, wanted) { - (Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host), + (Self::Ipc(pattern), Self::Ipc(path)) => ipc_matches(pattern, path), + (Self::Net(pattern), Self::Net(host)) | (Self::NetHttp(pattern), Self::NetHttp(host)) => { + host_matches(pattern, host) + } (Self::FsRead(base), Self::FsRead(path)) | (Self::FsWrite(base), Self::FsWrite(path)) => { std::path::Path::new(path).starts_with(std::path::Path::new(base)) } @@ -120,6 +144,75 @@ pub fn host_matches(pattern: &str, host: &str) -> bool { } } +/// Validates a socket path; a grant may use `*` in its last segment only. +pub fn validate_ipc_name(name: &str, pattern: bool) -> Result<(), String> { + let pipe = name.starts_with(r"\\.\pipe\"); + let unix = name.starts_with('/') + || name.starts_with("~/") + || name.starts_with("$TMPDIR/") + || name.starts_with("$XDG_RUNTIME_DIR/"); + let parts: Vec<_> = name.strip_prefix(r"\\.\pipe\").unwrap_or(name).split(if pipe { '\\' } else { '/' }).collect(); + if !(pipe || unix) + || name.contains(['\0', '?']) + || name.len() > 4096 + || parts.last().is_none_or(|p| p.is_empty()) + || parts.iter().any(|p| matches!(*p, "." | "..")) + || parts[..parts.len().saturating_sub(1)].iter().any(|p| p.contains('*')) + || (!pattern && name.contains('*')) + { + return Err( + "ipc: use an absolute socket path or \\\\.\\pipe\\name; only the last grant segment may contain '*'".into(), + ); + } + Ok(()) +} + +/// Expands the three supported host-local prefixes; no shell is involved. +pub fn expand_ipc_name(name: &str) -> Result { + validate_ipc_name(name, true)?; + for (prefix, key) in [("~/", "HOME"), ("$TMPDIR/", "TMPDIR"), ("$XDG_RUNTIME_DIR/", "XDG_RUNTIME_DIR")] { + if let Some(rest) = name.strip_prefix(prefix) { + let base = std::env::var(key).map_err(|_| format!("{key} is not set"))?; + let expanded = format!("{}/{rest}", base.trim_end_matches('/')); + validate_ipc_name(&expanded, true)?; + return Ok(expanded); + } + } + Ok(name.into()) +} + +fn ipc_matches(pattern: &str, name: &str) -> bool { + let (Ok(pattern), Ok(name)) = (expand_ipc_name(pattern), expand_ipc_name(name)) else { return false }; + if validate_ipc_name(&name, false).is_err() { + return false; + } + let separator = if pattern.starts_with(r"\\.\pipe\") { '\\' } else { '/' }; + let (Some((parent, pattern)), Some((other, name))) = (pattern.rsplit_once(separator), name.rsplit_once(separator)) + else { + return false; + }; + if parent != other { + return false; + } + let mut rest = name; + let mut pieces = pattern.split('*'); + let first = pieces.next().unwrap_or_default(); + let Some(tail) = rest.strip_prefix(first) else { return false }; + rest = tail; + let pieces: Vec<_> = pieces.collect(); + if pieces.is_empty() { + return rest.is_empty(); + } + for (i, piece) in pieces.iter().enumerate() { + if i + 1 == pieces.len() { + return rest.ends_with(piece); + } + let Some(at) = rest.find(piece) else { return false }; + rest = &rest[at + piece.len()..]; + } + true +} + impl fmt::Display for Capability { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -127,6 +220,8 @@ impl fmt::Display for Capability { Self::EditorRead => f.write_str("editor.read"), Self::EditorWrite => f.write_str("editor.write"), Self::ProjectWrite => f.write_str("project.write"), + Self::IdeCommands => f.write_str("ide.commands"), + Self::DatabaseSources => f.write_str("database.sources"), Self::VcsRead => f.write_str("vcs.read"), Self::VcsWrite => f.write_str("vcs.write"), Self::Storage => f.write_str("storage"), @@ -135,6 +230,8 @@ impl fmt::Display for Capability { Self::InputEvents => f.write_str("events.input"), Self::Clipboard => f.write_str("clipboard"), Self::Net(h) => write!(f, "net:{h}"), + Self::NetHttp(h) => write!(f, "net.http:{h}"), + Self::Ipc(p) => write!(f, "ipc:{p}"), Self::Process(b) => write!(f, "process:{b}"), Self::FsRead(p) => write!(f, "fs.read:{p}"), Self::FsWrite(p) => write!(f, "fs.write:{p}"), @@ -153,7 +250,7 @@ impl FromStr for Capability { return Err(format!("capability {s:?} needs a value after ':'")); } return match kind { - "net" => { + "net" | "net.http" => { let host = arg.strip_prefix("*.").unwrap_or(arg); let valid = !host.is_empty() && host.len() <= 253 @@ -162,7 +259,19 @@ impl FromStr for Capability { && label.len() <= 63 && label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') }); - if valid { Ok(Self::Net(arg.to_ascii_lowercase())) } else { Err(format!("invalid host in {s:?}")) } + if valid { + Ok(if kind == "net" { + Self::Net(arg.to_ascii_lowercase()) + } else { + Self::NetHttp(arg.to_ascii_lowercase()) + }) + } else { + Err(format!("invalid host in {s:?}")) + } + } + "ipc" => { + validate_ipc_name(arg, true)?; + Ok(Self::Ipc(arg.to_owned())) } "process" => { if arg.contains(['/', '\\']) && !std::path::Path::new(arg).is_absolute() { @@ -184,6 +293,8 @@ impl FromStr for Capability { "editor.read" | "editor-read" => Self::EditorRead, "editor.write" | "editor-write" => Self::EditorWrite, "project.write" => Self::ProjectWrite, + "ide.commands" => Self::IdeCommands, + "database.sources" => Self::DatabaseSources, "vcs.read" => Self::VcsRead, "vcs.write" => Self::VcsWrite, "storage" => Self::Storage, @@ -292,3 +403,71 @@ mod tests { assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into()))); } } + +#[cfg(test)] +mod api14_tests { + use super::*; + #[test] + fn http_grants_do_not_imply_https_and_socket_patterns_stay_in_one_directory() { + let http: Capability = "net.http:*.example.com".parse().unwrap(); + assert_eq!(http.to_string(), "net.http:*.example.com"); + assert_eq!(http.risk(), Risk::Medium); + assert!(http.describe().contains("unencrypted")); + assert!(http.covers(&"net.http:a.example.com".parse().unwrap())); + assert!(!http.covers(&"net:a.example.com".parse().unwrap())); + assert!(!"net:*.example.com".parse::().unwrap().covers(&"net.http:a.example.com".parse().unwrap())); + let socket: Capability = "ipc:/tmp/discord-ipc-*-socket".parse().unwrap(); + assert_eq!(socket.risk(), Risk::High); + assert!(socket.covers(&Capability::Ipc("/tmp/discord-ipc-42-socket".into()))); + assert!(!socket.covers(&Capability::Ipc("/tmp/discord-ipc-dir/evil-socket".into()))); + assert!(!socket.covers(&Capability::Ipc("/tmp/../tmp/discord-ipc-42-socket".into()))); + assert_eq!("ipc:$TMPDIR/discord-ipc-*".parse::().unwrap().to_string(), "ipc:$TMPDIR/discord-ipc-*"); + assert!( + r"ipc:\\.\pipe\discord-*" + .parse::() + .unwrap() + .covers(&Capability::Ipc(r"\\.\pipe\discord-0".into())) + ); + for invalid in + ["ipc:relative", "ipc:/tmp/*/socket", "ipc:/tmp/../socket", "ipc:/tmp/", "ipc:/tmp/socket?", "net.http:a b"] + { + assert!(invalid.parse::().is_err(), "{invalid}"); + } + } +} + +#[cfg(test)] +mod command_tests { + use super::*; + #[test] + fn ide_commands_is_explicit_and_high_risk() { + let cap: Capability = "ide.commands".parse().unwrap(); + assert_eq!(cap, Capability::IdeCommands); + assert_eq!(cap.to_string(), "ide.commands"); + assert_eq!(cap.risk(), Risk::High); + assert!(!cap.auto_migrates()); + assert!(!Capability::VcsWrite.covers(&cap)); + assert!(!Capability::Process("git".into()).covers(&cap)); + } +} + +#[cfg(test)] +mod database_tests { + use super::*; + #[test] + fn database_sources_is_a_medium_risk_exact_grant_requiring_api4() { + let cap: Capability = "database.sources".parse().unwrap(); + assert_eq!(cap, Capability::DatabaseSources); + assert_eq!(cap.to_string(), "database.sources"); + assert_eq!(cap.risk(), Risk::Medium); + assert!(!cap.auto_migrates()); + assert!(!Capability::ProjectRead.covers(&cap)); + assert!(!Capability::ProjectWrite.covers(&cap)); + for (api, valid) in [("^1.3", false), ("^1.4", true)] { + let text = format!( + "id='db-test'\nname='DB'\nversion='1.0.0'\nengines.openidea='{api}'\n[capabilities]\nrequired=['database.sources']" + ); + assert_eq!(crate::Manifest::parse(&text).is_ok(), valid); + } + } +} diff --git a/crates/ide-extension/src/ledger.rs b/crates/ide-extension/src/ledger.rs index 89a9f9f6c..95c17cd50 100644 --- a/crates/ide-extension/src/ledger.rs +++ b/crates/ide-extension/src/ledger.rs @@ -59,6 +59,12 @@ pub struct Grant { /// `process:` → the program confirmed for it. #[serde(default)] pub binaries: BTreeMap, + /// Project root → process grant → the executable approved for that project. + #[serde(default)] + pub project_binaries: BTreeMap>, + /// Explicit global executable choices, retained for the next re-approval. + #[serde(default)] + pub program_choices: BTreeMap, /// Migrated from 0.1: capabilities not granted yet are asked for on first /// use instead of blocking the plugin from loading. #[serde(default)] @@ -248,6 +254,9 @@ impl Ledger { } else { if let Capability::Process(_) = cap { grant.binaries.remove(&s); + for binaries in grant.project_binaries.values_mut() { + binaries.remove(&s); + } } grant.granted.remove(&s) } @@ -260,6 +269,58 @@ impl Ledger { } } + /// The user's explicit global executable choice, if any. + pub fn program_choice(&self, subject: &Subject, cap: &Capability) -> Option<&Path> { + self.grants.get(subject)?.program_choices.get(&cap.to_string()).map(PathBuf::as_path) + } + /// Remembers or resets an explicit global executable choice after confirmation. + pub fn set_program_choice(&mut self, subject: &Subject, cap: &Capability, path: Option<&Path>) { + if let Some(grant) = self.grants.get_mut(subject) { + if let Some(path) = path { + grant.program_choices.insert(cap.to_string(), path.into()); + } else { + grant.program_choices.remove(&cap.to_string()); + } + } + } + + /// Updates a project-specific program choice after user confirmation. + pub fn set_project_binary( + &mut self, + subject: &Subject, + cap: &Capability, + root: &Path, + binary: Option, + ) { + if let Some(grant) = self.grants.get_mut(subject) { + if let Some(binary) = binary { + grant.project_binaries.entry(root.into()).or_default().insert(cap.to_string(), binary); + } else if let Some(binaries) = grant.project_binaries.get_mut(root) { + binaries.remove(&cap.to_string()); + } + } + } + /// The project override when present, otherwise the global approved program. + pub fn check_binary_in_project( + &self, + subject: &Subject, + cap: &Capability, + root: Option<&Path>, + ) -> Result { + let grant = self.grants.get(subject).ok_or_else(|| format!("{cap} was not granted"))?; + if !grant.has(cap) { + return Err(format!("{cap} was not granted")); + } + if let Some(bin) = root.and_then(|root| grant.project_binaries.get(root)).and_then(|b| b.get(&cap.to_string())) + { + return match file_hash(&bin.path) { + Ok(hash) if hash == bin.hash => Ok(bin.path.clone()), + _ => Err(format!("{} changed or is missing; confirm {cap} again", bin.path.display())), + }; + } + self.check_binary(subject, cap) + } + /// Checks a `process:` grant at call time: the program confirmed for it, /// unchanged since. Errors name what to do. pub fn check_binary(&self, subject: &Subject, cap: &Capability) -> Result { @@ -306,6 +367,16 @@ pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec)> { groups.into_iter().map(|(r, v)| (r.0, v)).collect() } +/// Pins an explicitly selected executable, retaining a multicall shim's filename. +pub fn approve_program(path: &Path) -> Result { + if !path.is_absolute() || !is_executable(path) { + return Err("choose an absolute executable file".into()); + } + let path = canonical_dir(path); + let hash = file_hash(&path).map_err(|e| e.to_string())?; + Ok(BinaryGrant { path, hash }) +} + /// Resolves a `process:` program to an absolute path once, at grant time: /// absolute names are taken as is, others are looked up in `path_var`. /// The directory is canonicalized but the file name kept as found: `cargo`, diff --git a/crates/ide-extension/src/lib.rs b/crates/ide-extension/src/lib.rs index 7ff7738db..c466abda2 100644 --- a/crates/ide-extension/src/lib.rs +++ b/crates/ide-extension/src/lib.rs @@ -24,7 +24,7 @@ mod registry; mod ui; mod when; -pub use capability::{Capability, CapabilityInfo, Risk, host_matches}; +pub use capability::{Capability, CapabilityInfo, Risk, expand_ipc_name, host_matches, validate_ipc_name}; pub use index::ContributionIndex; pub use manifest::{ ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES, diff --git a/crates/ide-extension/src/manifest.rs b/crates/ide-extension/src/manifest.rs index 4f07c6e7c..17d3c6992 100644 --- a/crates/ide-extension/src/manifest.rs +++ b/crates/ide-extension/src/manifest.rs @@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf}; pub const MANIFEST_FILE: &str = "plugin.toml"; /// The plugin API (engine) version this host implements. -pub const ENGINE_VERSION: &str = "1.3.0"; +pub const ENGINE_VERSION: &str = "1.4.0"; /// The 0.1 API, still accepted for contribution-only packages and 0.1 components. pub const LEGACY_API: &str = "0.1"; /// Marketplace categories, in the order they are shown. @@ -144,8 +144,20 @@ impl ActivationEvent { /// Workbench event kinds (`on-event:`, WIT `events.event-kind`). /// `scheduled` comes from `on-schedule` instead. -pub const EVENT_KINDS: &[&str] = - &["file-opened", "file-saved", "files-changed", "editor-focused", "action-performed", "vcs-changed"]; +pub const EVENT_KINDS: &[&str] = &[ + "run-exited", + "terminal-exited", + "content-tab-action", + "file-closed", + "settings-changed", + "input-mode-changed", + "file-opened", + "file-saved", + "files-changed", + "editor-focused", + "action-performed", + "vcs-changed", +]; impl std::fmt::Display for ActivationEvent { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -317,6 +329,12 @@ impl Manifest { check_relative(icon, "icon")?; } for cap in self.capabilities.required.iter().chain(&self.capabilities.optional) { + if matches!( + cap, + Capability::IdeCommands | Capability::DatabaseSources | Capability::NetHttp(_) | Capability::Ipc(_) + ) { + self.needs_api(&format!("capability `{cap}`"), 4)?; + } if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) { self.needs_api(&format!("capability `{cap}`"), 3)?; } @@ -332,6 +350,10 @@ impl Manifest { } let mut seen = BTreeSet::new(); for event in &self.activation { + if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action")) + { + self.needs_api(&format!("`{event}`"), 4)?; + } if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") { self.needs_api("`on-event:files-changed`", 3)?; } @@ -655,3 +677,44 @@ mod tests { assert!(!engine_compatible("0.10", true)); } } + +#[cfg(test)] +mod api14_tests { + use super::*; + #[test] + fn new_capabilities_and_activations_require_api_1_4() { + for declaration in [ + "[capabilities]\nrequired = [\"net.http:example.com\"]", + "[capabilities]\noptional = [\"ipc:$TMPDIR/test-*\"]", + "activation = [\"on-event:file-closed\"]", + "activation = [\"on-event:settings-changed\"]", + "activation = [\"on-event:input-mode-changed\"]", + "activation = [\"on-event:run-exited\"]", + "activation = [\"on-event:terminal-exited\"]", + "activation = [\"on-event:content-tab-action\"]", + ] { + for engine in ["^1.0", "^1.3", "^1.4"] { + let manifest = format!( + "id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n" + ); + let parsed = Manifest::parse(&manifest); + if engine == "^1.4" { + parsed.unwrap(); + } else { + assert!(parsed.unwrap_err().contains("new in plugin API 1.4")); + } + } + } + } +} + +#[cfg(test)] +mod commands_api_tests { + use super::*; + #[test] + fn ide_commands_requires_api_1_4() { + let manifest = "id = \"test\"\nname = \"Test\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.3\"\n[capabilities]\nrequired = [\"ide.commands\"]\n"; + assert!(Manifest::parse(manifest).unwrap_err().contains("1.4")); + assert!(Manifest::parse(&manifest.replace("^1.3", "^1.4")).is_ok()); + } +} diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index af8eec2f1..730e8f766 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -52,6 +52,7 @@ ide-tools = { path = "../ide-tools" } ide-docker = { path = "../ide-docker" } semver = "1" serde.workspace = true +jsonschema = { version = "0.33", default-features = false } serde_json = { workspace = true, features = ["raw_value"] } toml.workspace = true specta.workspace = true diff --git a/crates/ide-host/src/formatter.rs b/crates/ide-host/src/formatter.rs index fea37d053..ad3416ac5 100644 --- a/crates/ide-host/src/formatter.rs +++ b/crates/ide-host/src/formatter.rs @@ -130,6 +130,38 @@ pub async fn format( let snap = project.documents.snapshot(doc)?; let settings = project.settings.effective(); let language = project.languages.get(&snap.language); + if options.tool.is_none() + && let Some(id) = settings.languages.plugin_formatters.get(snap.language.as_str()).filter(|s| !s.is_empty()) + { + if options.on_save && !settings.editor.format_on_save { + return Ok(Vec::new()); + } + if snap.read_only { + return Err(IdeError::invalid("The file is read-only")); + } + let plugins = crate::plugins::editor_plugin_host() + .ok_or_else(|| IdeError::not_found("The selected plugin formatter is unavailable"))?; + let d = ide_plugin::ProviderDocument { + path: snap.path.0, + language: snap.language.0, + version: u64::from(snap.version), + text: snap.text, + }; + let id = id.clone(); + return tokio::task::spawn_blocking(move || { + plugins.language_format( + &id, + &d, + range, + ide_plugin::language_features::FormatOptions { + tab_size: options.tab_size, + insert_spaces: options.insert_spaces, + }, + ) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))?; + } // A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files. if options.tool.is_none() && snap.path.extension().is_some_and(|e| e == "php") diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index b3ab936d2..5606766e7 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -55,6 +55,8 @@ pub struct HostConfig { } pub struct Host { + pub(crate) mcp_executable: RwLock>, + plugin_host: RwLock>, pub mcp_reports: mcp::Reports, pub mcp_requests: mcp::Requests, pub trust: trust::TrustStore, @@ -99,6 +101,14 @@ pub struct Host { const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10); impl Host { + /// Windows whose current project has this root. + pub(crate) fn windows_for_root(&self, root: &std::path::Path) -> Vec { + let mut windows: Vec<_> = + self.projects.read().iter().filter(|(_, p)| p.root == root).map(|(w, _)| w.clone()).collect(); + windows.sort(); + windows + } + pub fn new(config: HostConfig) -> Self { let HostConfig { config_dir, @@ -115,6 +125,8 @@ impl Host { let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json"))); ssh.set_configs(global_settings.effective().ssh.configs); let host = Self { + plugin_host: RwLock::default(), + mcp_executable: RwLock::default(), mcp_reports: Default::default(), mcp_requests: Default::default(), trust: trust::TrustStore::load(&config_dir, &data_dir), @@ -166,6 +178,19 @@ impl Host { } } + /// Associates the plugin runtime that executes this host's project operations. + /// Publishes only the executable of the running built-in MCP service. + pub fn set_mcp_executable(&self, executable: Option) { + *self.mcp_executable.write() = executable; + } + + pub fn set_plugin_host(&self, plugins: &Arc) { + *self.plugin_host.write() = Arc::downgrade(plugins); + } + pub(crate) fn plugin_host(&self) -> Option> { + self.plugin_host.read().upgrade() + } + /// Every open project (all windows). pub fn projects(&self) -> Vec> { self.projects.read().values().cloned().collect() @@ -231,6 +256,11 @@ impl Host { self.cancel_request(request); } let project = self.projects.write().remove(window)?; + if self.windows_for_root(&project.root).is_empty() + && let Some(plugins) = self.plugin_host() + { + plugins.withdraw_project_run_targets(&project.root); + } project.files_events_active.store(false, std::sync::atomic::Ordering::Release); project.plugin_diagnostics.close(); if let Some(plugins) = plugins::editor_plugin_host() { diff --git a/crates/ide-host/src/mcp/mod.rs b/crates/ide-host/src/mcp/mod.rs index ac87c20fd..9c4e7c26d 100644 --- a/crates/ide-host/src/mcp/mod.rs +++ b/crates/ide-host/src/mcp/mod.rs @@ -75,7 +75,11 @@ impl ToolRegistry for Registry { } fn tools(&self, root: &Path) -> Result, String> { let (_, project) = self.target(root)?; - Ok(tools().into_iter().map(|t| t.metadata).filter(|t| allowed(t, project.is_trusted())).collect()) + let mut all: Vec<_> = tools().into_iter().map(|t| t.metadata).collect(); + if let Some(plugins) = self.0.plugin_host() { + all.extend(plugins.mcp_tools(root).into_iter().map(|(_, _, tool)| tool)); + } + Ok(all.into_iter().filter(|t| allowed(t, project.is_trusted())).collect()) } fn call( &self, @@ -95,6 +99,12 @@ impl ToolRegistry for Registry { client: &str, ) -> Result { let (window, project) = self.target(root).map_err(ToolCallError::Failed)?; + if let Some(plugins) = self.0.plugin_host() + && let Some((plugin, tool, metadata)) = + plugins.mcp_tools(root).into_iter().find(|(_, _, t)| t.name == name && allowed(t, project.is_trusted())) + { + return self.call_plugin(window, project, plugins, plugin, tool, metadata, arguments, cancel, client); + } let tool = tools() .into_iter() .find(|t| t.metadata.name == name && allowed(&t.metadata, project.is_trusted())) @@ -129,6 +139,51 @@ impl Host { } } +impl Registry { + #[allow(clippy::too_many_arguments)] + fn call_plugin( + &self, + window: String, + project: Arc, + plugins: Arc, + plugin: String, + tool: String, + metadata: ide_mcp::Tool, + arguments: &str, + cancel: CancellationToken, + client: &str, + ) -> Result { + let run = || -> ide_core::IdeResult { + let args = serde_json::from_str(arguments).map_err(|e| IdeError::invalid(e.to_string()))?; + let call = tool::Call { + host: self.0.clone(), + project: project.clone(), + window: window.clone(), + args, + cancel: cancel.clone(), + client: client.into(), + tool: metadata.name, + }; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + if metadata.destructive { + self.0.runtime.block_on(call.confirm(&metadata.description, None))?; + } + if !metadata.read_only { + project.require_trust()?; + } + let invocation = ide_plugin::Invocation { + client: Some(window), + workspace_root: Some(project.root.clone()), + ..Default::default() + }; + plugins.call_mcp_tool(&plugin, &tool, arguments, invocation, &cancel) + }; + run().map_err(|e| ToolCallError::Failed(e.message)) + } +} + #[cfg(test)] mod tests { #[test] diff --git a/crates/ide-host/src/mcp/reports.rs b/crates/ide-host/src/mcp/reports.rs index 00b360e23..343ddba77 100644 --- a/crates/ide-host/src/mcp/reports.rs +++ b/crates/ide-host/src/mcp/reports.rs @@ -91,7 +91,7 @@ impl Reports { pub(super) fn focused_window(&self) -> Option { self.0.read().focused.clone() } - pub(super) fn editors(&self, windows: &[String]) -> Vec { + pub(crate) fn editors(&self, windows: &[String]) -> Vec { let state = self.0.read(); let mut paths = Vec::new(); for window in windows.iter().filter_map(|w| state.windows.get(w)) { @@ -103,10 +103,10 @@ impl Reports { } paths } - pub(super) fn focused(&self, window: &str) -> Option { + pub(crate) fn focused(&self, window: &str) -> Option { self.0.read().windows.get(window).and_then(|w| w.focused.clone()) } - pub(super) fn problems(&self, windows: &[String]) -> Vec { + pub(crate) fn problems(&self, windows: &[String]) -> Vec { let state = self.0.read(); let mut files: BTreeMap> = BTreeMap::new(); for window in windows.iter().filter_map(|w| state.windows.get(w)) { diff --git a/crates/ide-host/src/ops/db.rs b/crates/ide-host/src/ops/db.rs index 5ab776d8a..176583b40 100644 --- a/crates/ide-host/src/ops/db.rs +++ b/crates/ide-host/src/ops/db.rs @@ -169,7 +169,12 @@ pub fn sources(host: &Host, window: &str) -> IdeResult> { /// Replaces the project's data sources with `sources`. pub async fn save_sources(host: &Host, window: &str, sources: Vec) -> IdeResult<()> { let db = host.project(window)?.db.clone(); - db.save_sources(sources).await + db.save_sources(sources).await?; + let root = host.project(window)?.root.clone(); + for project in host.projects().into_iter().filter(|p| p.root == root) { + project.sink.emit(ide_core::IdeEvent::DbSourcesChanged); + } + Ok(()) } /// Remembers the password of data source `id` for the session, and in the OS diff --git a/crates/ide-host/src/ops/doc/commands.rs b/crates/ide-host/src/ops/doc/commands.rs index ddb9c8a6f..ad1019036 100644 --- a/crates/ide-host/src/ops/doc/commands.rs +++ b/crates/ide-host/src/ops/doc/commands.rs @@ -52,6 +52,20 @@ pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: L pub async fn doc_outline(host: &Host, window: &str, doc: DocId) -> IdeResult> { let project = host.project(window)?; let lsp = project.lsp.document_symbols(doc).await.ok().flatten(); + let lsp = if lsp.as_ref().is_none_or(|v| v.is_empty()) { + if let Some((plugins, d)) = + crate::ops::lsp::plugin_providers(host, window, doc, crate::plugins::ProviderKind::DocumentSymbols) + { + let items = + crate::ops::lsp::plugin_call(move || plugins.language_document_symbols(&d)).await.unwrap_or_default(); + if items.is_empty() { lsp } else { Some(crate::plugins::language_features::outline(items)) } + } else { + lsp + } + } else { + lsp + }; + let lsp = lsp.filter(|v| !v.is_empty()); blocking(move || { let snap = match project.documents.text_snapshot(doc) { Ok(snap) => snap, @@ -80,7 +94,27 @@ pub async fn doc_folding(host: &Host, window: &str, doc: DocId) -> IdeResult ide_language::FoldKind::Comment, + "imports" => ide_language::FoldKind::Imports, + _ => ide_language::FoldKind::Region, + }, + })); + folds.sort_by_key(|f| (f.start_line, f.end_line)); + folds.dedup_by_key(|f| (f.start_line, f.end_line)); + } + Ok(folds) }) .await } @@ -105,6 +139,21 @@ pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset: let project = host.project(window)?; blocking(move || { let snap = project.documents.text_snapshot(doc)?; + if !snap.large + && let Some(plugins) = crate::plugins::editor_providers_for(crate::plugins::ProviderKind::SelectionRanges) + { + let at = ide_text::Document::new(&snap.text).offset_to_pos(offset); + let d = ide_plugin::ProviderDocument { + path: project.documents.identity(doc)?.0, + language: snap.language.0.clone(), + version: u64::from(snap.version), + text: snap.text.clone(), + }; + let ranges = plugins.language_selection_ranges(&d, at); + if !ranges.is_empty() { + return Ok(ranges); + } + } let language = if snap.large { ide_core::LanguageId::plain_text() } else { snap.language }; Ok(project.syntax.selection_ranges(doc, &language, snap.version, &snap.text, offset)) }) diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index 5ed1de270..9533fc464 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -56,7 +56,7 @@ pub async fn lsp_completion( } /// The plugin host and the document, when some plugin provides `kind`. -fn plugin_providers( +pub(crate) fn plugin_providers( host: &Host, window: &str, doc: DocId, @@ -74,10 +74,48 @@ fn plugin_providers( } /// Plugin calls block (the plugin's actor thread does the work). -async fn plugin_call(f: impl FnOnce() -> T + Send + 'static) -> Option { +pub(crate) async fn plugin_call(f: impl FnOnce() -> T + Send + 'static) -> Option { tokio::task::spawn_blocking(f).await.ok() } +fn provider_server(server: IdeResult) -> IdeResult { + match server { + Ok(v) => Ok(v), + Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => Ok(T::default()), + Err(e) => Err(e), + } +} + +fn merge_language_locations(mut server: Vec, plugin: Vec) -> Vec { + let mut seen: std::collections::HashSet<_> = server.iter().cloned().collect(); + for l in plugin { + let loc = Location::file(l.path, l.range); + if seen.insert(loc.clone()) { + server.push(loc); + } + } + server +} +fn merge_language_highlights( + mut server: Vec, + plugin: Vec, +) -> Vec { + let mut seen: std::collections::HashSet<_> = server.iter().map(|h| h.range).collect(); + for h in plugin { + if seen.insert(h.range) { + server.push(Highlight { + range: h.range, + kind: match h.kind.as_str() { + "read" => HighlightKind::Read, + "write" => HighlightKind::Write, + _ => HighlightKind::Text, + }, + }); + } + } + server +} + /// Items from plugins carry no server: commands don't apply, and a code /// action is resolved by the plugin named in its `data` ([`PluginActionRef`]). const PLUGIN_SERVER: ServerId = ServerId(0); @@ -222,7 +260,21 @@ pub async fn lsp_hover( } pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult> { - host.project(window)?.lsp.signature_help(doc, pos).await + let server = host.project(window)?.lsp.signature_help(doc, pos).await; + if matches!(&server,Ok(Some(h)) if !h.signatures.is_empty()) { + return server; + } + let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::SignatureHelp) else { + return server; + }; + let help = plugin_call(move || plugins.language_signature_help(&d, pos)).await.flatten(); + match help { + Some(h) => { + let _ = provider_server(server)?; + Ok(Some(crate::plugins::language_features::signature_help(h))) + } + None => server, + } } pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult> { @@ -238,6 +290,23 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got } } let result = host.project(window)?.lsp.goto(doc, pos, kind).await; + if matches!(kind, GotoKind::Implementation | GotoKind::TypeDefinition) { + let k = + if kind == GotoKind::Implementation { ProviderKind::Implementation } else { ProviderKind::TypeDefinition }; + let Some((plugins, d)) = plugin_providers(host, window, doc, k) else { + return result; + }; + let locations = plugin_call(move || { + if kind == GotoKind::Implementation { + plugins.language_implementation(&d, pos) + } else { + plugins.language_type_definition(&d, pos) + } + }) + .await + .unwrap_or_default(); + return Ok(merge_language_locations(provider_server(result)?, locations)); + } if kind != GotoKind::Definition { return result; } @@ -358,6 +427,20 @@ pub async fn lsp_implementations( let cancel = host.begin_request(request); let result = lsp.implementations(doc, &positions, cancel).await; host.end_request(request); + if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) { + let count = positions.len(); + let found = plugin_call(move || { + positions.into_iter().map(|pos| plugins.language_implementation(&d, pos)).collect::>() + }) + .await + .unwrap_or_default(); + let mut out = provider_server(result)?; + out.resize_with(count, Vec::new); + for (items, found) in out.iter_mut().zip(found) { + *items = merge_language_locations(std::mem::take(items), found); + } + return Ok(out); + } result } @@ -380,6 +463,10 @@ pub async fn lsp_goto_implementation( }) .await; host.end_request(request); + if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) { + let found = plugin_call(move || plugins.language_implementation(&d, pos)).await.unwrap_or_default(); + return Ok(merge_language_locations(provider_server(result)?, found)); + } result } @@ -505,10 +592,11 @@ fn validate_indexed_symbol( let stale = || { ide_core::IdeError::new(ErrorKind::OutOfSync, "indexed document changed; wait for indexing and try again") }; - if ide_plugin::resolve_in_root(&project.root, &file.path.to_string_lossy()).ok().as_ref() != Some(&file.path) { + let path = dunce::simplified(&file.path); + if ide_plugin::resolve_in_root(&project.root, &path.to_string_lossy()).ok().as_deref() != Some(path) { return Err(stale()); } - let text = if let Some(doc) = project.documents.by_path(&file.path) { + let text = if let Some(doc) = project.documents.by_path(path) { let snapshot = project.documents.text_snapshot(doc)?; if snapshot.large || file.version.is_some_and(|v| v != snapshot.version) { return Err(stale()); @@ -517,7 +605,7 @@ fn validate_indexed_symbol( } else { let loaded = project .documents - .load_file_bounded(&file.path, crate::plugins::navigation::MAX_DOCUMENT_BYTES) + .load_file_bounded(path, crate::plugins::navigation::MAX_DOCUMENT_BYTES) .map_err(|_| stale())?; if loaded.binary || loaded.large { return Err(stale()); @@ -555,7 +643,12 @@ fn merge_indexed_references(mut server: References, symbol: &crate::plugins::nav } pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult> { - host.project(window)?.lsp.document_highlights(doc, pos).await + let server = host.project(window)?.lsp.document_highlights(doc, pos).await; + let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentHighlights) else { + return server; + }; + let items = plugin_call(move || plugins.language_document_highlights(&d, pos)).await.unwrap_or_default(); + Ok(merge_language_highlights(provider_server(server)?, items)) } pub async fn lsp_workspace_symbols( @@ -597,7 +690,7 @@ pub async fn lsp_code_actions( let actions = if plugin_actions.is_empty() { result? } else { - let mut out = result.unwrap_or_default(); + let mut out = provider_server(result)?; out.extend(plugin_actions); out }; @@ -1105,12 +1198,52 @@ pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeRe } pub async fn lsp_code_lens(host: &Host, window: &str, doc: DocId) -> IdeResult> { - host.project(window)?.lsp.code_lens(doc).await + let server = host.project(window)?.lsp.code_lens(doc).await; + let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::CodeLenses) else { + return server; + }; + let items = plugin_call(move || plugins.language_code_lenses(&d)).await.unwrap_or_default(); + let mut out = provider_server(server)?; + out.extend(items.into_iter().map(|(plugin, i)| CodeLens { + range: i.range, + command: Some(Command { + title: i.title, + command: format!("openidea.plugin-language:{plugin}:{}", i.command), + arguments: vec![serde_json::json!(serde_json::to_string(&i.argument).unwrap_or_default())], + }), + })); + Ok(out) } /// Links in a document (`href`s, Markdown links, `#include`s…) from its language servers. pub async fn lsp_document_links(host: &Host, window: &str, doc: DocId) -> IdeResult> { - host.project(window)?.lsp.document_links(doc).await + let server = host.project(window)?.lsp.document_links(doc).await; + let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentLinks) else { + return server; + }; + let root = host.project(window)?.root.clone(); + let items = plugin_call(move || plugins.language_document_links(&d)).await.unwrap_or_default(); + let mut out = provider_server(server)?; + out.extend(items.into_iter().filter_map(|i| { + let url = i.target.starts_with("https://") || i.target.starts_with("http://"); + if !url && ide_core::is_virtual_uri(&i.target) { + return None; + } + Some(DocumentLink { + range: i.range, + path: (!url).then(|| { + { + let path = std::path::PathBuf::from(&i.target); + if path.is_absolute() { path } else { root.join(path) } + } + .into() + }), + url: url.then_some(i.target), + fragment: None, + tooltip: None, + }) + })); + Ok(out) } pub async fn lsp_call_hierarchy( @@ -1200,6 +1333,34 @@ pub async fn redo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResul /// Runs a server command (`workspace/executeCommand`), e.g. a completion item's /// follow-up command. Edits the server requests arrive via `workspace/applyEdit`. pub async fn lsp_execute_command(host: &Host, window: &str, doc: DocId, command: Command) -> IdeResult<()> { + if let Some(rest) = command.command.strip_prefix("openidea.plugin-language:") { + let (plugin, command_id) = + rest.split_once(':').ok_or_else(|| ide_core::IdeError::invalid("Invalid plugin command"))?; + let plugins = crate::plugins::editor_plugin_host() + .ok_or_else(|| ide_core::IdeError::not_found("Plugin host unavailable"))?; + let argument = command + .arguments + .first() + .and_then(|a| a.as_str()) + .and_then(|s| serde_json::from_str::>(s).ok()) + .flatten(); + let project = host.project(window)?; + let snap = project.documents.snapshot(doc)?; + let invocation = ide_plugin::Invocation { + client: Some(window.into()), + workspace_root: Some(project.root.clone()), + editor: Some(ide_plugin::EditorContext { + path: snap.path.0.clone(), + language: snap.language.0, + selections: vec![], + }), + selected_paths: vec![snap.path.0], + ..Default::default() + }; + let (plugin, command_id) = (plugin.to_owned(), command_id.to_owned()); + return blocking(move || plugins.run_language_command(&plugin, &command_id, argument.as_deref(), invocation)) + .await; + } host.project(window)?.lsp.execute_command(doc, command).await } @@ -1262,7 +1423,74 @@ pub async fn lsp_execute_code_action( } /// Commands of this module the remote server answers. +/// Versioned plugin visuals, refreshed after the editor synchronizes its buffer. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct PluginLanguageVisuals { + pub version: u64, + pub injections: Vec, + pub colors: Vec<(String, ide_plugin::language_features::ColorInfo)>, + pub gutter_actions: Vec<(String, ide_plugin::language_features::GutterAction)>, +} +pub async fn plugin_language_visuals(host: &Host, window: &str, doc: DocId) -> IdeResult { + let snap = host.project(window)?.documents.snapshot(doc)?; + blocking(move || { + let d = ProviderDocument { + path: snap.path.0, + language: snap.language.0, + version: u64::from(snap.version), + text: snap.text, + }; + let mut out = + PluginLanguageVisuals { version: d.version, injections: vec![], colors: vec![], gutter_actions: vec![] }; + if d.text.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES + && let Some(plugins) = crate::plugins::editor_plugin_host() + { + out.injections = plugins.provide_injections(&d); + out.colors = plugins.language_colors(&d); + out.gutter_actions = plugins.language_gutter_actions(&d); + } + Ok(out) + }) + .await +} +pub async fn plugin_color_presentations( + host: &Host, + window: &str, + doc: DocId, + plugin: String, + color: ide_plugin::language_features::ColorInfo, +) -> IdeResult> { + let snap = host.project(window)?.documents.snapshot(doc)?; + blocking(move || { + let d = ProviderDocument { + path: snap.path.0, + language: snap.language.0, + version: u64::from(snap.version), + text: snap.text, + }; + if !crate::plugins::language_features::valid_range(&d, color.range) { + return Err(ide_core::IdeError::invalid("Invalid color range")); + } + Ok(crate::plugins::editor_plugin_host() + .map(|p| p.language_color_presentations(&plugin, &d, color)) + .unwrap_or_default()) + }) + .await +} +pub fn plugin_formatters() -> Vec { + crate::plugins::editor_plugin_host().map(|p| p.formatter_choices()).unwrap_or_default() +} + pub(crate) fn register(d: &mut Dispatcher) { + d.add( + "plugin_language_visuals", + |c| async move { plugin_language_visuals(&c.host, &c.window, c.arg("doc")?).await }, + ); + d.add("plugin_color_presentations", |c| async move { + plugin_color_presentations(&c.host, &c.window, c.arg("doc")?, c.arg("plugin")?, c.arg("color")?).await + }); + d.add("plugin_formatters", |_c| async move { Ok(plugin_formatters()) }); d.add("lsp_completion", |c| async move { lsp_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("trigger")?).await }); @@ -1408,6 +1636,32 @@ pub(crate) fn register(d: &mut Dispatcher) { #[cfg(test)] mod tests { + #[test] + fn language_features_merge_without_overwriting_lsp_ranges_or_masking_errors() { + use super::*; + let range = Range::new(Pos::default(), Pos::new(0, 1)); + let existing = Location::file("/p/a".into(), range); + let merged = merge_language_locations( + vec![existing.clone()], + vec![ + ide_plugin::Location { path: "/p/a".into(), range }, + ide_plugin::Location { path: "/p/b".into(), range }, + ], + ); + assert_eq!(merged.len(), 2); + assert_eq!(merged[0], existing); + let merged = merge_language_highlights( + vec![Highlight { range, kind: HighlightKind::Read }], + vec![ide_plugin::language_features::Highlight { range, kind: "write".into() }], + ); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].kind, HighlightKind::Read); + assert!( + provider_server::>(Err(ide_core::IdeError::unavailable("no server"))).unwrap().is_empty() + ); + assert!(provider_server::>(Err(ide_core::IdeError::cancelled())).is_err()); + } + use super::*; #[test] @@ -1430,11 +1684,13 @@ mod tests { use crate::plugins::navigation::{IndexedAnchors, IndexedSymbol}; use ide_core::{FileEdit, LanguageId}; let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().canonicalize().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); let shared = root.join("shared.idx"); let native = root.join("native.txt"); std::fs::write(&shared, "old old").unwrap(); std::fs::write(&native, "old").unwrap(); + // Exercise legacy verbatim index paths alongside ordinary native URI paths. + let shared = std::fs::canonicalize(shared).unwrap(); let range = Range::new(Pos::new(0, 0), Pos::new(0, 3)); let other = Range::new(Pos::new(0, 4), Pos::new(0, 7)); let symbol = IndexedSymbol { @@ -1446,8 +1702,6 @@ mod tests { ranges: vec![range, other], }], }; - // Native file URIs have ordinary Windows paths; index snapshots retain - // canonical verbatim paths. Both identify the same file and edit batch. let server_shared = dunce::simplified(&shared).to_path_buf(); let server_native = dunce::simplified(&native).to_path_buf(); #[cfg(windows)] @@ -1771,7 +2025,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join("proj"); std::fs::create_dir_all(&root).unwrap(); - let root = root.canonicalize().unwrap(); + let root = dunce::canonicalize(&root).unwrap(); let action = |operations: Vec| ide_plugin::CodeAction { title: "Create view".into(), kind: Some("quickfix".into()), diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index 558ce392b..e04d3c0b7 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -9,6 +9,9 @@ use std::time::Duration; pub fn configs(host: &Host, window: &str) -> IdeResult> { host.require_trust(window)?; let project = host.project(window)?; + if let Some(plugins) = host.plugin_host() { + plugins.refresh_run_configurations(&project.root); + } project_configs(&project.root, &project.settings.effective(), &host.config_dir) } @@ -199,8 +202,48 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec) -> Ide } pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit) -> IdeResult { + let operation = config.extras.params.get("plugin.operation").cloned(); + let result = start_inner(host, window, config, on_output).await; + if let (Ok(info), Some(operation), Some(plugins)) = (&result, operation, host.plugin_host()) { + plugins.run_operation_started(window, &operation, info.id); + } + result +} + +async fn start_inner( + host: &Host, + window: &str, + mut config: RunConfig, + on_output: Emit, +) -> IdeResult { host.require_trust(window)?; let project = host.project(window)?; + if config.extras.target.is_none() { + config.extras.target = ide_sdk::apply::run_target(&config, &project.settings.effective()).map(str::to_owned); + } + if config.detected + && config.id.starts_with(ide_run::EXTERNAL_PREFIX) + && let Some(plugins) = host.plugin_host() + { + let provider = plugins.run_detection(); + let binary = + provider.launch(&config).map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))?; + config.command = if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) { + provider + .detect(&project.root) + .into_iter() + .find(|c| c.id == config.id) + .ok_or_else(|| IdeError::not_found("plugin configuration no longer available"))? + .command + } else { + binary.to_string_lossy().into_owned() + }; + config.detected = false; + } + let mut sink = project.sink.clone(); + if let Some(plugins) = host.plugin_host() { + sink = plugins.run_operation_sink(window, &mut config, sink)?; + } if config.command.is_empty() { return Err(IdeError::invalid("run configuration has no command")); } @@ -210,6 +253,25 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit } if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) { let settings = project.settings.effective(); + if id.starts_with("plugin-target:") { + let plugins = + host.plugin_host().ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?; + let root = project.root.clone(); + let id = id.to_owned(); + let config = host.blocking(move || plugins.prepare_run_target(&root, &id, &config)).await?; + project.require_trust()?; + return host.processes.start( + &project.root, + &config, + &StartOptions { + encoding: Some(settings.console.encoding).filter(|e| !e.trim().is_empty()), + python_paths: Vec::new(), + ruby_reporter: None, + }, + sink, + Box::new(move |c| on_output(c)), + ); + } let target = ide_remote::service::run_target(&settings, id)?; if target.kind != RunTargetKind::Ssh { // A local run of the docker CLI: the command is resolved in the container, and @@ -227,15 +289,12 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit &project.root, &config, &options, - project.sink.clone(), + sink.clone(), Box::new(move |c| on_output(c)), ); } let out = on_output.clone(); - return host - .remote - .run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c))) - .await; + return host.remote.run(&settings, &project.root, &config, sink.clone(), Box::new(move |c| out(c))).await; } let mut config = config; let (root, settings) = settings_for(host, &project, &mut config)?; @@ -286,7 +345,6 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit ruby_reporter: ruby_reporter_dir(host, &config), }; project.require_trust()?; - let mut sink = project.sink.clone(); if config.kind == "go" && matches!(config.args.first().map(String::as_str), Some("mod" | "work")) { let previous = sink; let root = project.root.clone(); @@ -317,6 +375,9 @@ fn ruby_reporter_dir(host: &Host, config: &RunConfig) -> Option IdeResult<()> { + if let Some(plugins) = host.plugin_host() { + plugins.run_operation_stopped(id); + } let settings = match host.project(window) { Ok(p) => p.settings.effective(), Err(_) => host.global_settings.effective(), diff --git a/crates/ide-host/src/ops/search.rs b/crates/ide-host/src/ops/search.rs index ac2de1168..61d90c345 100644 --- a/crates/ide-host/src/ops/search.rs +++ b/crates/ide-host/src/ops/search.rs @@ -34,67 +34,12 @@ pub async fn start( // Excluded roots (Project Structure marks and detected `target`, `node_modules`, …) and // ignored names are never searched. query.exclude.extend(project.search_excludes()); - let roots = project.roots(); - let set = match query.scope { - ide_search::SearchScope::FileSet { set } => Some(set), - _ => None, - }; let cancel = host.begin_request(request); - let documents = project.documents.clone(); - let root = project.root.clone(); - let workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some(); - // The whole project also covers the Project Structure content roots. - let content_roots = match query.scope { - ide_search::SearchScope::Project - | ide_search::SearchScope::FileSet { .. } - | ide_search::SearchScope::Pattern { .. } => project.content_roots(), - _ => Vec::new(), - }; - let include_root = !workspace - || matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. }); - let languages = project.languages.clone(); - let syntax = host.syntax(); let events = on_event.clone(); + let syntax = host.syntax(); let summary = host .blocking(move || { - let filter = |rel: &str| match set { - Some(ProjectFileSet::Production) => !roots.is_test(rel), - Some(ProjectFileSet::Test) => roots.is_test(rel), - Some(ProjectFileSet::All) | None => true, - }; - // Comments and string literals for the search context filter. - let literals = |path: &Path, text: &str| { - let language = languages.detect(path, text.lines().next()); - let found = syntax.literals(&language, text)?; - Some(found.into_iter().map(literal_range).collect()) - }; - let mut total = SearchSummary::default(); - for dir in std::iter::once(&root).filter(|_| include_root).chain(&content_roots) { - let s = ide_search::search_in_context( - dir, - &query, - Some(&filter), - Some(&literals), - &|p| documents.search_text_of(p), - &cancel, - &|mut file| { - if dir != &root { - let name = dir.file_name().unwrap_or_default().to_string_lossy(); - file.rel_path = format!("{name}/{}", file.rel_path); - } - events(SearchEvent::File { file }); - }, - )?; - total.files += s.files; - total.matches += s.matches; - total.elapsed_ms += s.elapsed_ms; - total.limit_hit |= s.limit_hit; - total.cancelled |= s.cancelled; - if total.limit_hit || total.cancelled { - break; - } - } - Ok(total) + search_project(&project, &syntax, &query, &cancel, &|file| events(SearchEvent::File { file })) }) .await; host.end_request(request); @@ -102,5 +47,69 @@ pub async fn start( Ok(()) } +/// Shared Find in Files engine: project excludes are added by the caller. +pub(crate) fn search_project( + project: &crate::project::Project, + syntax: &ide_language::Syntax, + query: &SearchQuery, + cancel: &ide_core::CancellationToken, + emit: &(dyn Fn(FileMatches) + Sync), +) -> IdeResult { + let root = &project.root; + let roots = project.roots(); + let set = match query.scope { + ide_search::SearchScope::FileSet { set } => Some(set), + _ => None, + }; + let workspace = ide_workspace::workspace::Workspace::load(root)?.is_some(); + let include_root = !workspace + || matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. }); + let content_roots = match query.scope { + ide_search::SearchScope::Project + | ide_search::SearchScope::FileSet { .. } + | ide_search::SearchScope::Pattern { .. } => project.content_roots(), + _ => Vec::new(), + }; + let filter = |rel: &str| match set { + Some(ProjectFileSet::Production) => !roots.is_test(rel), + Some(ProjectFileSet::Test) => roots.is_test(rel), + _ => true, + }; + let literals = |path: &Path, text: &str| { + let language = project.languages.detect(path, text.lines().next()); + Some(syntax.literals(&language, text)?.into_iter().map(literal_range).collect()) + }; + let mut total = SearchSummary::default(); + for dir in std::iter::once(root).filter(|_| include_root).chain(&content_roots) { + let mut remaining = query.clone(); + let max = if query.max_matches == 0 { 10_000 } else { query.max_matches }; + remaining.max_matches = max.saturating_sub(total.matches).max(1); + let summary = ide_search::search_in_context( + dir, + &remaining, + Some(&filter), + Some(&literals), + &|p| project.documents.search_text_of(p), + cancel, + &|mut file| { + if dir != root { + let name = dir.file_name().unwrap_or_default().to_string_lossy(); + file.rel_path = format!("{name}/{}", file.rel_path); + } + emit(file); + }, + )?; + total.files += summary.files; + total.matches += summary.matches; + total.elapsed_ms += summary.elapsed_ms; + total.limit_hit |= summary.limit_hit || total.matches >= max; + total.cancelled |= summary.cancelled; + if total.limit_hit || total.cancelled { + break; + } + } + Ok(total) +} + mod commands; pub use commands::*; diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 0b2824730..70892f2de 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -4,6 +4,7 @@ use super::{CommitAnswer, CommitRequest, CommitSelection, PluginContextKey, PluginEvent, PluginEventSink}; use ide_core::ipc_path::to_ipc; use ide_core::{CancellationToken, TextEdit}; +use ide_extension::Capability; use ide_extension::Package; use ide_language::ProgramPin; use ide_plugin::{ @@ -37,10 +38,48 @@ type ContextKey = (Option, String, String); /// What plugins see of the open projects; provided by the app /// ([`super::PluginHost::set_workspace`]), over [`crate::Host`] on the desktop. pub trait PluginWorkspace: Send + Sync { + /// Execution host of the Database subsystem; absent on unsupported hosts. + fn database_host(&self) -> Option> { + None + } + + /// A freshly resolved configuration owned by this plugin in the invoking project. + fn run_configuration(&self, _client: &str, _plugin: &str, _config: &str) -> Result { + Err(HostError::NotFound("run configuration unavailable".into())) + } + + /// Window-owned reports shared with the MCP tools. + fn open_editors(&self, _client: &str) -> Result, HostError> { + Ok(Vec::new()) + } + /// Shared Find in Files service, preferring the invoking window's document store. + fn search_text( + &self, + _client: Option<&str>, + _root: &Path, + _query: ide_plugin::TextQuery, + _limit: u32, + _cancel: &CancellationToken, + ) -> Result { + Err(HostError::Failed("search unavailable".into())) + } + /// Problems view reports for this window's project. + fn problems(&self, _client: &str, _path: Option<&Path>) -> Result, HostError> { + Ok(Vec::new()) + } + /// Roots of the open projects. fn projects(&self) -> Vec; /// Unsaved text of the open document at `path`. fn buffer_text(&self, path: &Path) -> Option; + /// A window's unsaved text, with the normal project overlay as fallback. + fn buffer_text_in(&self, _client: Option<&str>, path: &Path) -> Option { + self.buffer_text(path) + } + /// An editor owned by this window. + fn document_in(&self, _client: &str, path: &Path) -> Option<(u64, String)> { + self.document(path) + } /// Whether the editor holds changes for `path` that are not on disk yet; /// writing such a file would lose them. fn buffer_dirty(&self, _path: &Path) -> bool { @@ -157,6 +196,34 @@ pub(super) fn net_runtime() -> &'static tokio::runtime::Runtime { }) } +#[derive(Debug)] +struct RedirectDenied(String); +impl std::fmt::Display for RedirectDenied { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} is not granted for this redirect", self.0) + } +} +impl std::error::Error for RedirectDenied {} + +fn network_error(error: reqwest::Error) -> HostError { + let mut source: Option<&(dyn std::error::Error + 'static)> = Some(&error); + while let Some(e) = source { + if let Some(denied) = e.downcast_ref::() { + return HostError::Denied(denied.0.clone()); + } + source = e.source(); + } + HostError::Failed(error.to_string()) +} + +fn redirect_capability(from: &reqwest::Url, to: &reqwest::Url) -> Result { + if from.scheme() == "https" && to.scheme() == "http" { + ide_plugin::net_host(to.as_str()).map(Capability::NetHttp) + } else { + ide_plugin::net_capability(to.as_str()) + } +} + /// Replaces `{{secret:}}` in `value` with the plugin's secrets. fn fill_secrets(value: &str, lookup: impl Fn(&str) -> Option) -> Result { let mut out = String::with_capacity(value.len()); @@ -182,15 +249,16 @@ struct TaskEntry { } /// A question a plugin's call waits on: the user's (a prompt) or a window's. -struct PromptEntry { - plugin: String, - client: String, - answer: Waiter, +pub(super) struct PromptEntry { + pub(super) plugin: String, + pub(super) client: String, + pub(super) answer: Waiter, } -enum Waiter { +pub(super) enum Waiter { Prompt(std::sync::mpsc::SyncSender>>), Commit(std::sync::mpsc::SyncSender), + Command(std::sync::mpsc::SyncSender), } /// A process a plugin started and has not released (API 1.3 `process.start`). @@ -405,6 +473,13 @@ fn append_process_env(command: &mut StdCommand, env: &[(String, String)]) -> Res Ok(()) } +/// Approved project-specific programs. +pub(super) type ProjectPins = Arc>>; + +type ContentRequests = BTreeMap<(String, String, String), (String, Instant)>; + +type SettingSnapshots = BTreeMap<(Option, String), BTreeMap>; + /// State shared by the host and every plugin's [`PluginApi`]. pub(super) struct Shared { pub sink: Arc, @@ -416,16 +491,28 @@ pub(super) struct Shared { /// (plugin, handle id) → the processes it started and has not released. processes: Mutex>>, next_process: AtomicU64, + connections: super::ipc::Connections, + pub loopbacks: RwLock>, process_shutdowns: Mutex>>, - prompts: Mutex>, + pub(super) prompts: Mutex>, /// Plugins being stopped: their calls may not open new prompts (taken before `prompts`). - prompts_blocked: Mutex>, - next_prompt: AtomicU64, + pub(super) prompts_blocked: Mutex>, + pub(super) next_prompt: AtomicU64, + pub(super) run_configurations: super::run_detectors::DynamicConfigurations, + pub(super) run_targets: Mutex, + pub(super) operations: Mutex>, + pub(super) content_tabs: Mutex>, + pub(super) content_requests: Mutex, + /// (plugin, item id) → item, for windows opened later. + output: Mutex>, + decorations: Mutex>>, status: Mutex>, + setting_snapshots: Mutex, + window_status: Mutex>, /// (scope, plugin, key) → JSON value. contexts: Mutex>, - closed_clients: Mutex>, + pub(super) closed_clients: Mutex>, /// plugin → kinds subscribed at run time (kept across idle reclaim). pub subscriptions: Mutex, /// plugin → files it shows problems for, withdrawn when it stops. @@ -433,6 +520,8 @@ pub(super) struct Shared { pub secrets: RwLock>, /// (plugin, `process:` capability) → the approved program. pub pins: Arc>>, + pub project_pins: ProjectPins, + pub event_editors: RwLock>, /// Validated UI declarations (settings schemas, declared views). pub ui: Arc, /// The UI language the windows report (BCP 47). @@ -463,16 +552,30 @@ impl Shared { workspace: RwLock::new(None), secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })), pins, + project_pins: Arc::default(), + event_editors: RwLock::default(), data_dir, tasks: Mutex::default(), next_task: AtomicU64::new(1), processes: Mutex::default(), next_process: AtomicU64::new(1), + connections: super::ipc::Connections::default(), + loopbacks: RwLock::new(Arc::new(super::auth::Loopbacks::default())), process_shutdowns: Mutex::default(), prompts: Mutex::default(), prompts_blocked: Mutex::default(), next_prompt: AtomicU64::new(1), + run_configurations: Arc::default(), + run_targets: Mutex::default(), + operations: Mutex::default(), + content_tabs: Mutex::default(), + content_requests: Mutex::default(), + status: Mutex::default(), + output: Mutex::default(), + decorations: Mutex::default(), + window_status: Mutex::default(), + setting_snapshots: Mutex::default(), contexts: Mutex::default(), closed_clients: Mutex::default(), subscriptions: Mutex::default(), @@ -518,6 +621,45 @@ impl Shared { self.status.lock().iter().map(|((p, id), item)| (p.clone(), id.clone(), item.clone())).collect() } + pub fn changed_settings(self: &Arc, pkg: &Package, client: Option<&str>) -> Vec { + let root = client.and_then(|c| self.project_of(c)); + let api = PluginApi { pkg: pkg.clone(), shared: self.clone() }; + let mut current = BTreeMap::new(); + for item in self.ui.items(super::ui_points::SETTINGS, Some(pkg.id())) { + if let Some(properties) = item.decl.get("properties").and_then(|p| p.as_array()) { + for property in properties { + if let Some(key) = property.get("key").and_then(|k| k.as_str()) + && let Some(value) = api.setting(root.as_deref(), key) + { + current.insert(key.into(), value); + } + } + } + } + let previous = + self.setting_snapshots.lock().insert((client.map(str::to_owned), pkg.id().into()), current.clone()); + previous + .map(|old| current.into_iter().filter(|(k, v)| old.get(k) != Some(v)).map(|(k, _)| k).collect()) + .unwrap_or_default() + } + + pub fn status_snapshot(&self, client: &str) -> Vec { + let mut items: Vec<_> = self + .status_items() + .into_iter() + .map(|(plugin_id, id, item)| super::PluginStatusItem { plugin_id, id, item, window: false }) + .collect(); + items.extend(self.window_status.lock().iter().filter(|((w, ..), _)| w == client).map( + |((_, plugin_id, id), item)| super::PluginStatusItem { + plugin_id: plugin_id.clone(), + id: id.clone(), + item: item.clone(), + window: true, + }, + )); + items + } + pub fn context_keys(&self, client: Option<&str>) -> Vec { let contexts = self.contexts.lock(); let mut keys: BTreeMap<(&str, &str), &str> = BTreeMap::new(); @@ -542,6 +684,13 @@ impl Shared { self.prompts.lock().retain(|_, entry| entry.client != client); drop(closed); self.contexts.lock().retain(|(scope, ..), _| scope.as_deref() != Some(client)); + self.loopbacks.read().forget_window(client); + self.window_status.lock().retain(|(window, ..), _| window != client); + self.operations.lock().retain(|_, op| op.client != client); + self.content_tabs.lock().retain(|(_, window, _), _| window != client); + self.content_requests.lock().retain(|(_, window, _), _| window != client); + self.event_editors.write().remove(client); + self.setting_snapshots.lock().retain(|(window, _), _| window.as_deref() != Some(client)); let mine: Vec<_> = { let mut processes = self.processes.lock(); let keys: Vec<_> = processes @@ -597,7 +746,28 @@ impl Shared { /// Withdraws a plugin's run-time state (it stopped or failed). Its /// subscriptions go only with `subscriptions`, as they survive restarts. pub fn clear_plugin(&self, plugin: &str, subscriptions: bool) { + self.withdraw_run_targets(Some(plugin), None); + self.run_configurations.write().retain(|(p, _), _| p != plugin); + self.operations.lock().retain(|_, op| op.plugin != plugin); + self.content_tabs.lock().retain(|(p, _, _), _| p != plugin); + self.content_requests.lock().retain(|(p, _, _), _| p != plugin); + self.kill_processes(plugin); + self.connections.clear(plugin); + self.setting_snapshots.lock().retain(|(_, p), _| p != plugin); + let channels: Vec<_> = { + let mut output = self.output.lock(); + let keys: Vec<_> = output.keys().filter(|(p, _)| p == plugin).cloned().collect(); + for key in &keys { + output.remove(key); + } + keys + }; + for (_, channel) in channels { + self.sink.emit(None, PluginEvent::OutputChanged { plugin_id: plugin.into(), channel, text: None }); + } + + self.loopbacks.read().clear(plugin); let removed: Vec = { let mut status = self.status.lock(); let ids: Vec = status.keys().filter(|(p, _)| p == plugin).map(|(_, id)| id.clone()).collect(); @@ -609,6 +779,21 @@ impl Shared { for id in removed { self.sink.emit(None, PluginEvent::StatusItemChanged { plugin_id: plugin.to_owned(), id, item: None }); } + let removed: Vec<_> = { + let mut items = self.window_status.lock(); + let keys: Vec<_> = items.keys().filter(|(_, p, _)| p == plugin).cloned().collect(); + for key in &keys { + items.remove(key); + } + keys + }; + for (window, _, id) in removed { + self.sink + .emit(Some(&window), PluginEvent::WindowStatusItemChanged { plugin_id: plugin.into(), id, item: None }); + } + if self.decorations.lock().remove(plugin).is_some() { + self.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: plugin.into(), sets: Vec::new() }); + } self.withdraw_prompts(plugin); let removed: Vec<(Option, String)> = { let mut contexts = self.contexts.lock(); @@ -699,6 +884,16 @@ impl Shared { } /// A window answered a [`CommitRequest`]. + pub fn answer_command(&self, client: &str, id: u64, answer: super::CommandAnswer) -> bool { + let mut prompts = self.prompts.lock(); + if !matches!(prompts.get(&id), Some(PromptEntry { client: window, answer: Waiter::Command(_), .. }) if window == client) + { + return false; + } + let Some(PromptEntry { answer: Waiter::Command(tx), .. }) = prompts.remove(&id) else { return false }; + tx.try_send(answer).is_ok() + } + pub fn answer_commit(&self, id: u64, answer: CommitAnswer) -> bool { let mut prompts = self.prompts.lock(); if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Commit(_), .. })) { @@ -802,7 +997,17 @@ impl PluginApi { /// The program approved for the (already checked) `process:` grant, /// verified right before it starts. + #[cfg(test)] fn approved_program(&self, program: &str) -> Result { + self.approved_program_in_project(program, None) + } + + pub(super) fn approved_program_in_project(&self, program: &str, root: Option<&Path>) -> Result { + if let Some(pin) = root.and_then(|root| { + self.shared.project_pins.read().get(&(self.id().into(), format!("process:{program}"), root.into())).cloned() + }) { + return pin.verify().map(Path::to_path_buf).map_err(|e| HostError::Denied(e.to_string())); + } let cap = format!("process:{program}"); // Granted without a pinned file: the program wasn't found when it was // allowed, and installing it later approves nothing by itself. @@ -828,7 +1033,7 @@ impl PluginApi { cancel: &CancellationToken, on_line: &dyn Fn(super::PluginOutputLine), ) -> Result, HostError> { - let binary = self.approved_program(program)?; + let binary = self.approved_program_in_project(program, cwd)?; let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id())); let _ = std::fs::create_dir_all(&cwd); let mut cmd = std::process::Command::new(&binary); @@ -1051,6 +1256,500 @@ fn failed(e: impl std::fmt::Display) -> HostError { } impl HostApi for PluginApi { + fn database_upsert(&self, root: &Path, source: ide_plugin::PluginDataSource) -> Result<(), HostError> { + let (host, db) = self.database_host(root)?; + let id = source.id.clone(); + let source = super::database::settings(source)?; + self.remember_database_project(root, &id)?; + host.runtime.block_on(db.upsert_plugin_source(self.pkg.id(), &id, source)).map_err(super::database::error)?; + let internal = ide_db::plugin::source_id(self.pkg.id(), &id); + for project in host.projects().into_iter().filter(|p| p.root == root) { + host.runtime.block_on(project.db.disconnect_source(&internal)); + } + super::database::changed(&host, root); + Ok(()) + } + fn database_remove(&self, root: &Path, id: &str) -> Result<(), HostError> { + let (host, db) = self.database_host(root)?; + host.runtime.block_on(db.remove_plugin_source(self.pkg.id(), id)).map_err(super::database::error)?; + let internal = ide_db::plugin::source_id(self.pkg.id(), id); + for project in host.projects().into_iter().filter(|p| p.root == root) { + host.runtime.block_on(project.db.forget_plugin_source(&internal)); + } + super::database::changed(&host, root); + Ok(()) + } + fn database_sources(&self, root: &Path) -> Result, HostError> { + let (_, db) = self.database_host(root)?; + db.plugin_sources(self.pkg.id()).map_err(super::database::error) + } + fn database_credential(&self, root: &Path, id: &str, password: String) -> Result<(), HostError> { + let (host, db) = self.database_host(root)?; + let source = db.plugin_source(self.pkg.id(), id).map_err(super::database::error)?; + self.remember_database_project(root, id)?; + for project in host.projects().into_iter().filter(|p| p.root == root) { + host.runtime.block_on(project.db.set_password(&source.id, Some(password.clone()), true)).map_err(failed)?; + } + Ok(()) + } + fn database_connect( + &self, + root: &Path, + client: &str, + id: &str, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result<(), HostError> { + self.connect_database(root, client, id, cancel, timeout) + } + fn mcp_stdio_command(&self, client: &str) -> Option> { + let host = self.shared.workspace()?.database_host()?; + let root = host.project(client).ok()?.root.clone(); + host.mcp_executable.read().as_ref().map(|exe| ide_mcp::stdio_command(exe, &root)) + } + + fn register_run_target(&self, root: &Path, target: ide_plugin::PluginRunTarget) -> Result<(), HostError> { + self.register_target(root, target) + } + fn unregister_run_target(&self, root: &Path, id: &str) { + self.unregister_target(root, id); + } + + fn start_run( + &self, + client: &str, + config: &str, + args: Vec, + debug: bool, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result { + let _ = timeout; + if cancel.is_cancelled() { + return Err(HostError::Cancelled); + } + let mut run = + self.shared.workspace().ok_or(HostError::Cancelled)?.run_configuration(client, self.id(), config)?; + let binary = self.approved_program_in_project(&run.command, self.shared.project_of(client).as_deref())?; + if debug && run.debug_adapter.is_none() { + return Err(HostError::Invalid("this configuration has no debugger".into())); + } + let program = run.command.clone(); + if debug && run.extras.target.as_ref().is_some_and(|t| !t.is_empty()) { + return Err(HostError::Invalid("debugging this target is not supported".into())); + } + if debug { + run.command = binary.to_string_lossy().into_owned(); + } + run.args.extend(args); + let id = self.new_operation( + client, + super::execution::OperationKind::Run { config: config.into(), configuration: run.id.clone(), program }, + )?; + run.extras.params.insert(super::execution::RUN_OPERATION.into(), id.clone()); + let request = super::WindowRequest::Run { operation: id.clone(), config: Box::new(run), debug }; + self.shared.sink.emit( + Some(client), + PluginEvent::WindowRequest { plugin_id: self.id().into(), request_id: String::new(), request }, + ); + Ok(id) + } + fn open_terminal_session( + &self, + client: &str, + key: String, + title: String, + cmd: ProcessCommand, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result { + super::execution::label(&key, 128)?; + super::execution::label(&title, 256)?; + let root = + self.shared.project_of(client).ok_or_else(|| HostError::Invalid("no project in this window".into()))?; + if !self.shared.workspace().is_some_and(|w| w.trusted(&root)) { + return Err(HostError::Denied("project is not trusted".into())); + } + let binary = self.approved_program_in_project(&cmd.program, Some(&root))?; + let mut command = StdCommand::new(&binary); + append_process_env(&mut command, &cmd.env)?; + if cmd.args.iter().any(|a| a.contains('\0')) { + return Err(HostError::Invalid("NUL in terminal argument".into())); + } + let existing = self + .shared + .operations + .lock() + .iter() + .find(|(_, op)| { + op.plugin == self.id() + && op.client == client + && matches!(&op.kind, super::execution::OperationKind::Terminal { key: k, .. } if k == &key) + }) + .map(|(id, _)| id.clone()); + let id = match existing { + Some(id) => id, + None => self.new_operation( + client, + super::execution::OperationKind::Terminal { key: key.clone(), command: cmd.clone() }, + )?, + }; + let request = super::WindowRequest::Terminal { + operation: id.clone(), + key, + title, + program: binary.into(), + args: cmd.args, + env: cmd.env.into_iter().collect(), + cwd: root.into(), + }; + if let Err(error) = self.window_request(client, request, cancel, timeout) { + self.shared.operations.lock().remove(&id); + return Err(error); + } + Ok(id) + } + fn write_terminal( + &self, + client: &str, + key: &str, + text: String, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result<(), HostError> { + let root = self.shared.project_of(client).ok_or(HostError::Cancelled)?; + if !self.shared.workspace().is_some_and(|w| w.trusted(&root)) { + return Err(HostError::Denied("project is not trusted".into())); + } + let _ = timeout; + if cancel.is_cancelled() { + return Err(HostError::Cancelled); + } + let terminal = self + .shared + .operations + .lock() + .iter() + .find(|(_, op)| { + op.plugin == self.id() + && op.client == client + && op.root.as_ref() == Some(&root) + && matches!(&op.kind, super::execution::OperationKind::Terminal { key: k, .. } if k == key) + }) + .and_then(|(_, op)| op.terminal.clone()) + .ok_or_else(|| HostError::NotFound("no running terminal with this key".into()))?; + let (host, id) = terminal; + host.upgrade().ok_or(HostError::Cancelled)?.terminals.write(id, &text).map_err(failed) + } + + fn open_content_tab( + &self, + client: &str, + tab: ide_plugin::ContentTab, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result<(), HostError> { + super::execution::validate_tab(&tab)?; + let mut tabs = self.shared.content_tabs.lock(); + let key = (self.id().into(), client.into(), tab.key.clone()); + if !tabs.contains_key(&key) && tabs.keys().filter(|(p, w, _)| p == self.id() && w == client).count() >= 32 { + return Err(HostError::Invalid("at most 32 content tabs".into())); + } + let previous = tabs.insert(key.clone(), tab.clone()); + drop(tabs); + if let Err(error) = self.window_request(client, super::WindowRequest::ContentTab { tab }, cancel, timeout) { + let mut tabs = self.shared.content_tabs.lock(); + if let Some(previous) = previous { + tabs.insert(key, previous); + } else { + tabs.remove(&key); + } + return Err(error); + } + Ok(()) + } + fn reply_content_tab( + &self, + client: &str, + request: &str, + reply: ide_plugin::ContentTabReply, + ) -> Result<(), HostError> { + super::execution::validate_reply(&reply)?; + if self.shared.content_requests.lock().remove(&(self.id().into(), client.into(), request.into())).is_none() { + return Err(HostError::NotFound("unknown content action request".into())); + } + self.shared.sink.emit( + Some(client), + PluginEvent::ContentTabReply { plugin_id: self.id().into(), request_id: request.into(), reply }, + ); + Ok(()) + } + + fn owns_command(&self, id: &str) -> bool { + let commands = self.shared.ui.items(super::COMMANDS, None); + let mut matches = commands.iter().filter(|c| id == format!("plugin.{}.{}", c.plugin_id, c.key)).peekable(); + matches.peek().is_some() && matches.all(|c| c.plugin_id == self.id()) + } + + fn open_editors(&self, client: &str) -> Result, HostError> { + self.shared.workspace().ok_or_else(|| HostError::NotFound("window project".into()))?.open_editors(client) + } + fn editor_open(&self, client: &str, path: &Path, selection: Option) -> Result<(), HostError> { + self.shared.sink.emit(Some(client), PluginEvent::EditorOpen { path: path.into(), selection }); + Ok(()) + } + fn editor_ranges( + &self, + client: &str, + path: &Path, + ranges: Vec, + reveal: bool, + ) -> Result<(), HostError> { + let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("window project".into()))?; + if !workspace.open_editors(client)?.iter().any(|e| Path::new(&e.path) == path) { + return Err(HostError::Invalid("editor does not belong to the invoking window".into())); + } + let (_, text) = + workspace.document_in(client, path).ok_or_else(|| HostError::NotFound("editor is closed".into()))?; + let document = ide_text::Document::new(&text); + for r in &ranges { + if !valid_range(&document, *r) { + return Err(HostError::Invalid("range outside document".into())); + } + } + self.shared.sink.emit(Some(client), PluginEvent::EditorRanges { path: path.into(), ranges, reveal }); + Ok(()) + } + fn search_text( + &self, + client: Option<&str>, + root: &Path, + query: ide_plugin::TextQuery, + limit: u32, + cancel: &CancellationToken, + deadline: Instant, + ) -> Result { + let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("project closed".into()))?; + if query.pattern.len() > 16_384 || query.include.len() + query.exclude.len() > 256 { + return Err(HostError::Invalid("search query too large".into())); + } + let token = cancel.child_token(); + let timed = token.clone(); + let (done, wait) = std::sync::mpsc::channel(); + let timer = std::thread::spawn(move || { + if wait.recv_timeout(deadline.saturating_duration_since(Instant::now())).is_err() { + timed.cancel(); + } + }); + let result = workspace.search_text(client, root, query, limit.min(10_000), &token); + let _ = done.send(()); + let _ = timer.join(); + if cancel.is_cancelled() { + Err(HostError::Cancelled) + } else if Instant::now() >= deadline { + Err(HostError::Failed("text search timed out".into())) + } else { + result + } + } + fn problems(&self, client: &str, path: Option<&Path>) -> Result, HostError> { + self.shared.workspace().ok_or_else(|| HostError::NotFound("window project".into()))?.problems(client, path) + } + fn decorations(&self, path: Option<&Path>, set: &str, items: Vec) -> Result<(), HostError> { + self.set_decorations(path, set, items, None, false) + } + fn file_decorations( + &self, + path: Option<&Path>, + set: &str, + item: Option, + ) -> Result<(), HostError> { + self.set_decorations(path, set, Vec::new(), item, true) + } + fn vcs_log( + &self, + root: &Path, + path: Option<&str>, + limit: u32, + cancel: &CancellationToken, + ) -> Result, HostError> { + let repo = self.read_repo(root)?; + let path = path + .map(|p| ide_plugin::resolve_new_in_root(root, p).map(ide_core::IpcPath::from).map_err(HostError::Invalid)) + .transpose()?; + let Some(repo) = repo else { + return Ok(Vec::new()); + }; + if limit == 0 { + return Ok(Vec::new()); + } + let query = ide_vcs::LogQuery { path, limit: limit.min(1000), ..Default::default() }; + repo.log_cancellable(&query, cancel) + .map(|cs| { + cs.into_iter() + .map(|c| ide_plugin::CommitInfo { + id: c.id, + short_id: c.short_id, + author: c.author, + author_email: c.email, + date: (c.time_ms / 1000.0) as i64, + subject: c.summary, + body: c + .message + .split_once('\n') + .map_or(String::new(), |(_, body)| body.trim_start_matches('\n').into()), + parents: c.parents, + }) + .collect() + }) + .map_err(failed) + } + fn vcs_blame( + &self, + client: Option<&str>, + root: &Path, + path: &str, + ) -> Result, HostError> { + let repo = self.read_repo(root)?; + let path = ide_plugin::resolve_new_in_root(root, path).map_err(HostError::Invalid)?; + let Some(repo) = repo else { + return Ok(Vec::new()); + }; + let workspace = self.shared.workspace().unwrap(); + let text = workspace.buffer_text_in(client, &path); + repo.blame(&path, text.as_deref(), None, workspace.vcs_encoding(&path)) + .map(|lines| { + lines + .into_iter() + .enumerate() + .map(|(i, l)| ide_plugin::BlameLine { + line: i as u32, + commit: l.commit, + author: l.author, + date: (l.time_ms / 1000.0) as i64, + summary: l.summary, + }) + .collect() + }) + .map_err(failed) + } + fn vcs_diff( + &self, + root: &Path, + path: Option<&str>, + against: Option<&str>, + cancel: &CancellationToken, + ) -> Result, HostError> { + let repo = self.read_repo(root)?; + let path = path.map(|p| ide_plugin::resolve_new_in_root(root, p).map_err(HostError::Invalid)).transpose()?; + let Some(repo) = repo else { + return Ok(None); + }; + repo.working_tree_patch(path.as_deref(), against, 4 << 20, cancel).map(Some).map_err(failed) + } + + fn execute_command( + &self, + invocation: &ide_plugin::Invocation, + id: &str, + arg: Option, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result, HostError> { + let client = from_window(invocation.client.as_deref(), "execute commands")?; + let mut chain = invocation.command_chain.clone(); + chain.push(self.id().into()); + if chain.len() > 8 { + return Err(HostError::Invalid("command nesting limit (8)".into())); + } + for command in self.shared.ui.items(super::COMMANDS, None) { + if id == format!("plugin.{}.{}", command.plugin_id, command.key) && chain.contains(&command.plugin_id) { + return Err(HostError::Invalid("recursive plugin command".into())); + } + } + let request = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + { + let closed = self.shared.closed_clients.lock(); + let blocked = self.shared.prompts_blocked.lock(); + if closed.contains(client) || blocked.contains(self.id()) { + return Err(HostError::Cancelled); + } + self.shared.prompts.lock().insert( + request, + PromptEntry { plugin: self.id().into(), client: client.into(), answer: Waiter::Command(tx) }, + ); + } + self.shared.sink.emit( + Some(client), + PluginEvent::ExecuteCommand { request_id: request.to_string(), id: id.into(), arg, chain }, + ); + let deadline = Instant::now() + timeout; + loop { + match rx.recv_timeout(Duration::from_millis(20)) { + Ok(super::CommandAnswer::Value(value)) => return Ok(value), + Ok(super::CommandAnswer::NotFound) => return Err(HostError::NotFound(id.into())), + Ok(super::CommandAnswer::Failed(error)) => return Err(HostError::Failed(error)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + if !cancel.is_cancelled() && Instant::now() < deadline => {} + Err(_) => { + self.shared.prompts.lock().remove(&request); + return Err(HostError::Cancelled); + } + } + } + } + fn output_write(&self, channel: &str, text: Option<&str>) -> Result<(), HostError> { + if channel.is_empty() || channel.len() > 128 || channel.chars().any(char::is_control) { + return Err(HostError::Invalid("output channel must be 1-128 bytes without control characters".into())); + } + let key = (self.id().to_owned(), channel.to_owned()); + let mut channels = self.shared.output.lock(); + if !channels.contains_key(&key) && channels.keys().filter(|(p, _)| p == self.id()).count() >= 32 { + return Err(HostError::Invalid("at most 32 output channels".into())); + } + let buffer = channels.entry(key).or_default(); + if let Some(text) = text { + let mut start = text.len().saturating_sub(1 << 20); + while !text.is_char_boundary(start) { + start += 1; + } + buffer.push_str(&text[start..]); + } else { + buffer.clear(); + } + let mut remove = buffer.len().saturating_sub(1 << 20); + while !buffer.is_char_boundary(remove) { + remove += 1; + } + buffer.drain(..remove); + let text = buffer.clone(); + drop(channels); + self.shared.sink.emit( + None, + PluginEvent::OutputChanged { plugin_id: self.id().into(), channel: channel.into(), text: Some(text) }, + ); + Ok(()) + } + fn output_reveal(&self, client: &str, channel: &str) -> Result<(), HostError> { + let key = (self.id().to_owned(), channel.to_owned()); + if !self.shared.output.lock().contains_key(&key) { + self.output_write(channel, None)?; + } + if self.shared.client_closed(client) { + return Err(HostError::Cancelled); + } + let text = self.shared.output.lock().get(&key).cloned().unwrap_or_default(); + self.shared.sink.emit( + Some(client), + PluginEvent::RevealOutput { plugin_id: self.id().into(), channel: channel.into(), text }, + ); + Ok(()) + } + fn require_api(&self, what: &str, minor: u64) -> Result<(), HostError> { + self.pkg.manifest.needs_api(what, minor).map_err(HostError::Invalid) + } + fn notify( &self, client: Option<&str>, @@ -1202,6 +1901,32 @@ impl HostApi for PluginApi { .emit(None, PluginEvent::StatusItemChanged { plugin_id: self.id().to_owned(), id: id.to_owned(), item }); } + fn window_status_item(&self, client: &str, id: &str, item: Option) -> Result<(), HostError> { + let closed = self.shared.closed_clients.lock(); + let blocked = self.shared.prompts_blocked.lock(); + if closed.contains(client) || blocked.contains(self.id()) { + return Err(HostError::Cancelled); + } + let key = (client.to_owned(), self.id().to_owned(), id.to_owned()); + let mut items = self.shared.window_status.lock(); + if let Some(item) = &item { + if !items.contains_key(&key) + && items.keys().filter(|(w, p, _)| w == client && p == self.id()).count() >= MAX_STATUS_ITEMS + { + return Err(HostError::Invalid("too many window status items".into())); + } + items.insert(key, item.clone()); + } else { + items.remove(&key); + } + drop(items); + self.shared.sink.emit( + Some(client), + PluginEvent::WindowStatusItemChanged { plugin_id: self.id().into(), id: id.into(), item }, + ); + Ok(()) + } + fn set_context(&self, client: Option<&str>, key: &str, value: Option) -> Result<(), HostError> { let map_key = (client.map(str::to_owned), self.id().to_owned(), key.to_owned()); { @@ -1419,25 +2144,32 @@ impl HostApi for PluginApi { } let method = reqwest::Method::from_bytes(request.method.to_ascii_uppercase().as_bytes()) .map_err(|_| HostError::Invalid(format!("method {:?}", request.method)))?; - let client = ide_http::client::shared().map_err(|e| HostError::Failed(e.message))?; - // A redirect to another host is refused after the fact (the shared - // client follows redirects). + let grants = request.grants.clone(); + let policy = reqwest::redirect::Policy::custom(move |attempt| { + if attempt.previous().len() >= 10 { + return attempt.error("too many redirects"); + } + let wanted = attempt.previous().last().map_or_else( + || ide_plugin::net_capability(attempt.url().as_str()), + |from| redirect_capability(from, attempt.url()), + ); + match wanted { + Ok(wanted) if grants.iter().any(|g| g.covers(&wanted)) => attempt.follow(), + Ok(wanted) => attempt.error(RedirectDenied(wanted.to_string())), + Err(error) => attempt.error(error), + } + }); + let client = ide_http::client::build_with_redirect(&ide_core::proxy_settings::proxy(), policy) + .map_err(|e| HostError::Failed(e.message))?; let builder = client.request(method, &request.url).headers(headers).timeout(request.timeout); let builder = match request.body { Some(body) => builder.body(body), None => builder, }; let cancel = cancel.clone(); - let host = ide_plugin::net_host(&request.url).map_err(HostError::Invalid)?; net_runtime().block_on(async move { let send = async { - let response = builder.send().await.map_err(|e| HostError::Failed(e.to_string()))?; - if ide_plugin::net_host(response.url().as_str()).ok().as_deref() != Some(host.as_str()) { - return Err(HostError::Denied(format!( - "net:{} (redirected)", - response.url().host_str().unwrap_or("?") - ))); - } + let response = builder.send().await.map_err(network_error)?; let status = response.status().as_u16(); let headers = response .headers() @@ -1465,6 +2197,55 @@ impl HostApi for PluginApi { self.shared.secrets.read().get(self.id(), name).is_some() } + fn secret_set(&self, name: &str, value: Option<&str>) -> Result<(), HostError> { + if !self.shared.ui.items(super::providers::SECRETS_POINT, Some(self.id())).iter().any(|s| s.key == name) { + return Err(HostError::NotFound(format!("undeclared secret {name:?}"))); + } + self.shared.secrets.read().set(self.id(), name, value).map_err(HostError::Failed) + } + fn begin_loopback(&self, client: &str, path: &str) -> Result<(u64, String), HostError> { + let closed = self.shared.closed_clients.lock(); + let blocked = self.shared.prompts_blocked.lock(); + if closed.contains(client) || blocked.contains(self.id()) { + return Err(HostError::Cancelled); + } + self.shared.loopbacks.read().begin(self.id(), client, path) + } + fn wait_loopback( + &self, + id: u64, + timeout: Duration, + cancel: &CancellationToken, + ) -> Result, HostError> { + self.shared.loopbacks.read().clone().wait(self.id(), id, timeout, cancel) + } + fn close_loopback(&self, id: u64) { + self.shared.loopbacks.read().close(self.id(), id); + } + fn ipc_connect(&self, name: &str, cancel: &CancellationToken, timeout: Duration) -> Result { + self.shared.connections.connect(self.id(), name, cancel, timeout) + } + fn ipc_write( + &self, + id: u64, + bytes: Vec, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result<(), HostError> { + self.shared.connections.write(self.id(), id, bytes, cancel, timeout) + } + fn ipc_read( + &self, + id: u64, + max: usize, + timeout: Duration, + cancel: &CancellationToken, + ) -> Result, HostError> { + self.shared.connections.read(self.id(), id, max, timeout, cancel) + } + fn ipc_close(&self, id: u64) { + self.shared.connections.close(self.id(), id); + } fn run_process( &self, cmd: ProcessCommand, @@ -1473,7 +2254,7 @@ impl HostApi for PluginApi { cancel: &CancellationToken, timeout: Duration, ) -> Result { - let binary = self.approved_program(&cmd.program)?; + let binary = self.approved_program_in_project(&cmd.program, cwd.or(cmd.approval_root.as_deref()))?; let cwd = self.spawn_dir(cwd); let mut command = StdCommand::new(&binary); ide_core::process::prepare(&mut command); @@ -1554,7 +2335,7 @@ impl HostApi for PluginApi { } } fn start_process(&self, cmd: ProcessCommand, cwd: Option<&Path>, client: Option<&str>) -> Result { - let binary = self.approved_program(&cmd.program)?; + let binary = self.approved_program_in_project(&cmd.program, cwd.or(cmd.approval_root.as_deref()))?; let cwd = self.spawn_dir(cwd); if client.is_some_and(|c| self.shared.closed_clients.lock().contains(c)) { return Err(HostError::Cancelled); @@ -1777,7 +2558,7 @@ impl HostApi for PluginApi { args: Vec, cwd: Option<&Path>, ) -> Result<(), HostError> { - let binary = self.approved_program(program)?; + let binary = self.approved_program_in_project(program, cwd)?; let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id())); let _ = std::fs::create_dir_all(&cwd); let event = PluginEvent::OpenTerminal { @@ -1810,7 +2591,7 @@ impl HostApi for PluginApi { log: ide_plugin::ServiceLog, ) -> Result<(), HostError> { // Fails now, rather than in the tab, when the approved file is gone or changed. - self.approved_program(&log.program)?; + self.approved_program_in_project(&log.program, self.shared.project_of(client).as_deref())?; { let mut all = self.shared.log_tabs.lock(); let tabs = all.entry((self.id().to_owned(), client.to_owned())).or_default(); @@ -1849,7 +2630,7 @@ impl HostApi for PluginApi { ide_vfs::create_private_dir(&data).map_err(failed)?; let files = data.join("files"); ide_vfs::create_private_dir(&files).map_err(failed)?; - files.canonicalize().map_err(failed) + dunce::canonicalize(files).map_err(failed) } fn read_text_file(&self, real: &Path, max: usize) -> Result { @@ -1941,6 +2722,13 @@ fn not_writable(path: &Path, e: ide_core::error::IdeError) -> HostError { pub struct HostWorkspace(pub std::sync::Weak); impl HostWorkspace { + fn project_for(&self, client: Option<&str>, root: &Path) -> Option> { + let host = self.0.upgrade()?; + client + .and_then(|w| host.project(w).ok()) + .filter(|p| p.root == root) + .or_else(|| host.windows_for_root(root).first().and_then(|w| host.project(w).ok())) + } fn projects(&self) -> Vec> { self.0.upgrade().map(|h| h.projects()).unwrap_or_default() } @@ -1952,6 +2740,183 @@ impl HostWorkspace { } impl PluginWorkspace for HostWorkspace { + fn database_host(&self) -> Option> { + self.0.upgrade() + } + fn run_configuration(&self, client: &str, plugin: &str, config: &str) -> Result { + let host = self.0.upgrade().ok_or(HostError::Cancelled)?; + let prefix = format!("{}{}:", ide_run::EXTERNAL_PREFIX, plugin); + let id = if config.starts_with(&prefix) { config.to_owned() } else { format!("{prefix}{config}") }; + let project = host.project(client).map_err(failed)?; + project.require_trust().map_err(failed)?; + let plugins = host.plugin_host().ok_or(HostError::Cancelled)?; + let detected = plugins + .run_detection() + .detect(&project.root) + .into_iter() + .find(|c| c.id == id) + .ok_or_else(|| HostError::NotFound("no owned run configuration with this id".into()))?; + let mut config = ide_run::load_user_configs(&project.root) + .map_err(failed)? + .into_iter() + .find(|c| c.id == id) + .unwrap_or(detected); + ide_sdk::apply::apply_to_run_configs( + std::slice::from_mut(&mut config), + &project.root, + &project.settings.effective(), + ); + if config.debug_adapter.is_none() { + let cwd = config.cwd.as_ref().map(|p| p.as_ref()).unwrap_or(&project.root); + config.debug_adapter = + config.args.iter().map(|arg| cwd.join(arg)).filter(|path| path.is_file()).find_map(|path| { + let language = project.languages.detect(&path, None); + project.languages.get(&language)?.debugger.map(|d| d.adapter) + }); + } + Ok(config) + } + + fn buffer_text_in(&self, client: Option<&str>, path: &Path) -> Option { + client + .and_then(|w| self.0.upgrade()?.project(w).ok()?.documents.text_of(path)) + .or_else(|| self.buffer_text(path)) + } + fn document_in(&self, client: &str, path: &Path) -> Option<(u64, String)> { + let project = self.0.upgrade()?.project(client).ok()?; + let doc = project.documents.by_path(path)?; + let snapshot = project.documents.snapshot(doc).ok()?; + Some((u64::from(snapshot.version), snapshot.text)) + } + fn open_editors(&self, client: &str) -> Result, HostError> { + let host = self.0.upgrade().ok_or(HostError::Cancelled)?; + let project = host.project(client).map_err(failed)?; + let active = host.mcp_reports.focused(client).map(|e| e.path.into_path_buf()); + Ok(host + .mcp_reports + .editors(&[client.into()]) + .into_iter() + .map(|path| { + let language = project + .languages + .detect(&path, project.documents.text_of(&path).as_deref().and_then(|t| t.lines().next())); + ide_plugin::OpenEditor { + active: active.as_ref() == Some(&path), + dirty: project + .documents + .by_path(&path) + .is_some_and(|doc| project.documents.is_dirty(doc).unwrap_or(false)), + language: language.to_string(), + path: to_ipc(&path), + } + }) + .collect()) + } + fn search_text( + &self, + client: Option<&str>, + root: &Path, + query: ide_plugin::TextQuery, + limit: u32, + cancel: &CancellationToken, + ) -> Result { + let host = self.0.upgrade().ok_or(HostError::Cancelled)?; + let project = self.project_for(client, root).ok_or_else(|| HostError::NotFound("project closed".into()))?; + if limit == 0 { + return Ok(ide_plugin::TextSearch::default()); + } + let mut query = ide_search::SearchQuery { + pattern: query.pattern, + is_regex: query.regex, + case_sensitive: query.case_sensitive, + whole_word: query.whole_word, + include: query.include, + exclude: query.exclude, + scope: ide_search::SearchScope::Project, + max_matches: limit.min(10_000).saturating_add(1), + context: Default::default(), + }; + query.exclude.extend(project.search_excludes()); + let files = Mutex::new(Vec::new()); + let summary = crate::ops::search::search_project(&project, &host.syntax(), &query, cancel, &|file| { + files.lock().push(file) + }) + .map_err(failed)?; + if summary.cancelled || cancel.is_cancelled() { + return Err(HostError::Cancelled); + } + let mut out = ide_plugin::TextSearch { matches: Vec::new(), limit_hit: summary.limit_hit }; + let mut bytes = 0usize; + for file in files.into_inner() { + if cancel.is_cancelled() { + return Err(HostError::Cancelled); + } + // Restrict project.read to the project root, including symlink targets. + let Ok(path) = ide_plugin::resolve_in_root(root, &to_ipc(&file.path.0)) else { + continue; + }; + let text = project.documents.text_of(&path).or_else(|| { + let loaded = project.documents.load_file_bounded(&path, ide_plugin::MAX_READ_BYTES as usize).ok()?; + (!loaded.binary && !loaded.too_large) + .then(|| ide_text::normalize_line_endings(&loaded.text).into_owned()) + }); + let Some(text) = text else { + out.limit_hit = true; + continue; + }; + let doc = ide_text::Document::new(&text); + for (start, end) in ide_search::match_ranges(&text, &query).map_err(failed)? { + if out.matches.len() >= limit as usize { + out.limit_hit = true; + return Ok(out); + } + let range = ide_core::Range::new(doc.byte_to_pos(start), doc.byte_to_pos(end)); + let line = text[..start].rfind('\n').map_or(0, |i| i + 1); + let end_line = text[start..].find('\n').map_or(text.len(), |i| start + i); + let preview = text[line..end_line].trim_end_matches('\r'); + let mut cut = preview.len().min(4096); + while !preview.is_char_boundary(cut) { + cut -= 1; + } + let preview = &preview[..cut]; + bytes += preview.len() + path.as_os_str().len() + 64; + if bytes > 4 << 20 { + out.limit_hit = true; + return Ok(out); + } + out.matches.push(ide_plugin::TextMatch { + location: ide_plugin::Location { path: path.clone(), range }, + line: preview.into(), + }); + } + } + Ok(out) + } + fn problems(&self, client: &str, path: Option<&Path>) -> Result, HostError> { + let host = self.0.upgrade().ok_or(HostError::Cancelled)?; + let root = host.project(client).map_err(failed)?.root.clone(); + let windows = host.windows_for_root(&root); + let mut out = Vec::new(); + for file in host.mcp_reports.problems(&windows) { + if path.is_some_and(|p| p != file.path.0) { + continue; + } + if ide_plugin::resolve_new_in_root(&root, &to_ipc(&file.path.0)).is_err() { + continue; + } + let mut sources: BTreeMap> = BTreeMap::new(); + for d in file.items { + sources.entry(d.server.clone().or_else(|| d.source.clone()).unwrap_or_default()).or_default().push(d); + } + out.extend(sources.into_iter().map(|(source, diagnostics)| ide_plugin::FileProblems { + path: to_ipc(&file.path.0), + source, + diagnostics, + })); + } + Ok(out) + } + fn projects(&self) -> Vec { let mut roots: Vec = HostWorkspace::projects(self).iter().map(|p| p.root.clone()).collect(); roots.sort(); @@ -2104,6 +3069,22 @@ mod tests { PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared } } + #[cfg(windows)] + #[test] + fn private_directory_matches_resolved_write_paths() { + let tmp = tempfile::tempdir().unwrap(); + let api = api(tmp.path(), Arc::default()); + let dir = api.plugin_dir().unwrap(); + let verbatim = std::fs::canonicalize(&dir).unwrap(); + for root in [&dir, &verbatim] { + let path = ide_plugin::resolve_new_in_root(root, "state.txt").unwrap(); + assert!(path.starts_with(&dir), "private writes must use the private-directory branch"); + api.write_text_file(&path, "text").unwrap(); + api.write_bytes_file(&path, b"bytes").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"bytes"); + } + } + #[test] fn storage_moves_an_older_storage_json_to_one_file_per_key() { let tmp = tempfile::tempdir().unwrap(); @@ -2180,7 +3161,7 @@ mod tests { fn repository_and_search_paths_match_ipc_events() { let tmp = tempfile::tempdir().unwrap(); assert!(std::process::Command::new("git").args(["init", "-q"]).arg(tmp.path()).status().unwrap().success()); - let root = tmp.path().canonicalize().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); std::fs::create_dir(root.join("src")).unwrap(); let file = root.join("src/Example.java"); std::fs::write(&file, "class Example {}\n").unwrap(); @@ -2223,7 +3204,7 @@ mod tests { #[test] fn find_files_uses_the_project_index_when_there_is_one() { let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().canonicalize().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); let api = api(&root, Arc::default()); *api.shared.workspace.write() = Some(Arc::new(Indexed( root.clone(), @@ -2338,7 +3319,12 @@ mod tests { #[cfg(unix)] fn shell(script: &str) -> ProcessCommand { - ProcessCommand { program: "sh".into(), args: vec!["-c".into(), script.into()], env: Vec::new() } + ProcessCommand { + program: "sh".into(), + args: vec!["-c".into(), script.into()], + env: Vec::new(), + approval_root: None, + } } #[cfg(unix)] @@ -2451,6 +3437,58 @@ mod tests { matches!(api.approved_program("lint"), Err(HostError::Denied(e)) if e.contains("changed since it was approved")) ); } + #[test] + fn redirects_check_grants_before_connecting_and_downgrades_require_http() { + assert_eq!( + redirect_capability(&"https://localhost/start".parse().unwrap(), &"http://localhost/end".parse().unwrap()) + .unwrap(), + Capability::NetHttp("localhost".into()) + ); + let source = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let target = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + target.set_nonblocking(true).unwrap(); + let source_port = source.local_addr().unwrap().port(); + let target_port = target.local_addr().unwrap().port(); + let worker = std::thread::spawn(move || { + use std::io::{Read, Write}; + let (mut stream, _) = source.accept().unwrap(); + let mut buf = [0; 4096]; + let _ = stream.read(&mut buf); + write!(stream, "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{target_port}/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").unwrap(); + }); + let dir = tempfile::tempdir().unwrap(); + let api = api(dir.path(), Arc::default()); + let result = api.fetch( + HttpRequest { + method: "GET".into(), + url: format!("http://localhost:{source_port}/"), + headers: Vec::new(), + body: None, + timeout: Duration::from_secs(2), + grants: vec![Capability::Net("localhost".into())], + }, + &CancellationToken::new(), + ); + assert!(matches!(result, Err(HostError::Denied(cap)) if cap == "net:127.0.0.1")); + assert!(target.accept().is_err(), "ungranted target must never receive a connection"); + worker.join().unwrap(); + } + + #[test] + fn output_ring_keeps_utf8_and_is_withdrawn_on_stop() { + let dir = tempfile::tempdir().unwrap(); + let api = api(dir.path(), Arc::default()); + api.output_write("build", Some(&"😀".repeat(300_000))).unwrap(); + let buffer = api.shared.output.lock().get(&(api.id().into(), "build".into())).cloned().unwrap(); + assert_eq!(buffer.len(), 1 << 20); + api.output_write("build", Some("x")).unwrap(); + let buffer = api.shared.output.lock().get(&(api.id().into(), "build".into())).cloned().unwrap(); + assert!(buffer.len() <= 1 << 20 && buffer.ends_with('x')); + api.output_write("build", None).unwrap(); + assert!(api.shared.output.lock().get(&(api.id().into(), "build".into())).unwrap().is_empty()); + api.shared.clear_plugin(api.id(), true); + assert!(api.shared.output.lock().is_empty()); + } } /// The window a user action invoked the plugin from: browser tabs and clipboard writes @@ -2491,3 +3529,309 @@ mod credential_tests { assert_eq!(from_window(Some("main"), "open a URL").unwrap(), "main"); } } + +fn valid_range(document: &ide_text::Document, range: ide_core::Range) -> bool { + range.start <= range.end + && [range.start, range.end].into_iter().all(|pos| document.byte_to_pos(document.pos_to_byte(pos)) == pos) +} + +impl PluginApi { + fn read_repo(&self, root: &Path) -> Result, HostError> { + if !self.shared.workspace().is_some_and(|w| w.trusted(root)) { + return Err(HostError::Failed("the project is in Restricted Mode: git does not run there".into())); + } + let repo = ide_vcs::Repo::discover(root).map_err(failed)?; + if let Some(repo) = &repo { + repo.set_execution_allowed(false); + } + Ok(repo) + } + fn set_decorations( + &self, + path: Option<&Path>, + set: &str, + mut items: Vec, + file: Option, + tree: bool, + ) -> Result<(), HostError> { + if set.is_empty() || set.len() > 128 { + return Err(HostError::Invalid("set id must be 1-128 bytes".into())); + } + if items.len() > 10_000 || file.as_ref().and_then(|f| f.badge.as_ref()).is_some_and(|b| b.chars().count() > 2) { + return Err(HostError::Invalid("decoration limit exceeded".into())); + } + items.retain(|d| decoration_style(&d.style)); + if items.iter().any(|d| d.range.start > d.range.end) { + return Err(HostError::Invalid("reversed decoration range".into())); + } + let replacement = if let Some(path) = path { + let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project open".into()))?; + let (root, real) = workspace + .projects() + .into_iter() + .filter_map(|root| ide_plugin::resolve_new_in_root(&root, &to_ipc(path)).ok().map(|real| (root, real))) + .max_by_key(|(root, _)| root.components().count()) + .ok_or_else(|| HostError::Invalid("path outside open projects".into()))?; + Some(super::DecorationSet { + root: root.into(), + path: real.into(), + set_id: set.into(), + tree, + items, + file: file.filter(|f| decoration_style(&f.style)), + }) + } else { + None + }; + let mut all = self.shared.decorations.lock(); + let mut sets = all.get(self.id()).cloned().unwrap_or_default(); + sets.retain(|s| !(s.tree == tree && s.set_id == set && replacement.as_ref().is_none_or(|r| r.path == s.path))); + if let Some(replacement) = replacement { + sets.push(replacement); + } + let names: BTreeSet<_> = sets.iter().filter(|s| s.tree == tree).map(|s| &s.set_id).collect(); + if names.len() > 50 || serde_json::to_vec(&sets).map_err(failed)?.len() > 4 << 20 { + return Err(HostError::Invalid("decoration sets exceed plugin limits".into())); + } + // A plugin may spread one file's annotations across sets, but the combined limit is fixed. + let mut counts = BTreeMap::new(); + for s in &sets { + *counts.entry(&s.path).or_insert(0usize) += s.items.len(); + } + if counts.values().any(|n| *n > 10_000) { + return Err(HostError::Invalid("at most 10000 decorations per file".into())); + } + all.insert(self.id().into(), sets.clone()); + // Emit while holding the state lock so replacement snapshots cannot arrive out of order. + self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets }); + Ok(()) + } +} +fn decoration_style(style: &str) -> bool { + matches!( + style, + "highlight" + | "search" + | "added" + | "modified" + | "removed" + | "warning" + | "error" + | "info" + | "underline" + | "strikethrough" + | "muted" + ) +} +impl Shared { + pub(super) fn decoration_sets(&self) -> BTreeMap> { + self.decorations.lock().clone() + } +} + +#[cfg(test)] +mod workbench_tests { + use super::*; + struct Workspace { + root: PathBuf, + trusted: bool, + text: Option, + } + impl PluginWorkspace for Workspace { + fn projects(&self) -> Vec { + vec![self.root.clone()] + } + fn buffer_text(&self, _: &Path) -> Option { + self.text.clone() + } + fn document(&self, _: &Path) -> Option<(u64, String)> { + self.text.clone().map(|s| (1, s)) + } + fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit]) -> Result { + Err(HostError::Stale) + } + fn trusted(&self, _: &Path) -> bool { + self.trusted + } + fn project_of(&self, _: &str) -> Option { + Some(self.root.clone()) + } + fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec) -> Result<(), HostError> { + Ok(()) + } + fn open_editors(&self, client: &str) -> Result, HostError> { + Ok(if client == "main" { + vec![ide_plugin::OpenEditor { + path: to_ipc(&self.root.join("a.txt")), + language: "text".into(), + active: true, + dirty: true, + }] + } else { + Vec::new() + }) + } + } + fn api(root: &Path, trusted: bool, text: Option<&str>) -> PluginApi { + let manifest = ide_extension::Manifest::parse( + "id = \"test\"\nname = \"Test\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.4\"\n", + ) + .unwrap(); + let shared = Shared::new( + Arc::new(|_: Option<&str>, _: PluginEvent| {}), + root.join(".data"), + Arc::default(), + ide_extension::UiStore::new(), + ); + *shared.workspace.write() = + Some(Arc::new(Workspace { root: root.into(), trusted, text: text.map(str::to_owned) })); + PluginApi { pkg: Package::new(manifest, root.into()), shared } + } + #[test] + fn command_exemption_requires_a_declared_unambiguous_owner() { + let dir = tempfile::tempdir().unwrap(); + let api = api(dir.path(), true, None); + let own = api.shared.ui.insert(ide_extension::UiContribution { + plugin_id: "test".into(), + point: super::super::COMMANDS.into(), + key: "other.run".into(), + decl: serde_json::json!({}), + }); + assert!(api.owns_command("plugin.test.other.run")); + assert!(!api.owns_command("plugin.test.unknown")); + let other = api.shared.ui.insert(ide_extension::UiContribution { + plugin_id: "test.other".into(), + point: super::super::COMMANDS.into(), + key: "run".into(), + decl: serde_json::json!({}), + }); + assert!(!api.owns_command("plugin.test.other.run")); + drop(other); + assert!(api.owns_command("plugin.test.other.run")); + drop(own); + } + #[test] + fn selections_validate_window_and_utf16_without_requiring_a_grant() { + let dir = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(dir.path()).unwrap(); + let path = root.join("a.txt"); + let api = api(&root, true, Some("😀abc")); + let range = |col| ide_core::Range::point(ide_core::Pos::new(0, col)); + assert!(api.editor_ranges("main", &path, vec![range(2)], false).is_ok()); + assert!(api.editor_ranges("main", &path, vec![range(1)], true).is_err()); + assert!(api.editor_ranges("other", &path, vec![range(2)], false).is_err()); + } + #[test] + fn decoration_sets_validate_paths_limits_and_cleanup() { + let dir = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(dir.path()).unwrap(); + let path = root.join("a.txt"); + std::fs::write(&path, "text").unwrap(); + let api = api(&root, true, None); + let item = |style: &str| ide_plugin::Decoration { + range: ide_core::Range::default(), + style: style.into(), + hover: None, + after: Some("after".into()), + }; + api.decorations(Some(&path), "set", vec![item("highlight"), item("unknown")]).unwrap(); + assert_eq!(api.shared.decoration_sets()["test"][0].items.len(), 1); + assert!(api.decorations(Some(&root.join("../outside.txt")), "set", vec![]).is_err()); + api.decorations(None, "set", vec![]).unwrap(); + for i in 0..50 { + api.decorations(Some(&path), &format!("s{i}"), vec![]).unwrap(); + } + assert!(api.decorations(Some(&path), "overflow", vec![]).is_err()); + api.decorations(Some(&path), "s0", vec![item("info"); 10_000]).unwrap(); + assert!(api.decorations(Some(&path), "s1", vec![item("info")]).is_err()); + assert!( + api.file_decorations( + Some(&path), + "tree", + Some(ide_plugin::FileDecoration { badge: Some("abc".into()), style: "info".into(), tooltip: None }) + ) + .is_err() + ); + api.shared.clear_plugin("test", true); + assert!(api.shared.decoration_sets().is_empty()); + } + fn git(root: &Path, args: &[&str]) { + let out = StdCommand::new("git").current_dir(root).args(args).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + } + #[test] + fn vcs_reads_reuse_history_and_dirty_blame_and_require_trust() { + let dir = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(dir.path()).unwrap(); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.name", "Test"]); + git(&root, &["config", "user.email", "test@example.com"]); + let path = root.join("a.txt"); + std::fs::write(&path, "committed\nsecond\n").unwrap(); + git(&root, &["add", "."]); + git(&root, &["commit", "-qm", "Subject\n\nBody"]); + std::fs::write(&path, "disk\nsecond\n").unwrap(); + let api = api(&root, true, Some("unsaved\nsecond\nnew\n")); + let cancel = CancellationToken::new(); + let history = api.vcs_log(&root, Some("a.txt"), 10, &cancel).unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].subject, "Subject"); + assert_eq!(history[0].body.trim(), "Body"); + assert!(history[0].date > 0); + let lines = api.vcs_blame(None, &root, "a.txt").unwrap(); + assert_eq!(lines.len(), 3); + assert!(lines[0].commit.bytes().all(|b| b == b'0')); + assert_eq!(lines[1].commit, history[0].id); + assert_eq!(lines[2].line, 2); + let patch = api.vcs_diff(&root, Some("a.txt"), None, &cancel).unwrap().unwrap(); + assert!(patch.contains("+disk")); + assert!(!patch.contains("+unsaved")); + assert!(api.vcs_diff(&root, None, Some("--output=outside"), &cancel).is_err()); + assert!(api.vcs_log(&root, Some("../outside"), 1, &cancel).is_err()); + std::fs::remove_file(&path).unwrap(); + assert_eq!(api.vcs_log(&root, Some("a.txt"), 10, &cancel).unwrap().len(), 1); + assert!(api.vcs_diff(&root, Some("a.txt"), None, &cancel).unwrap().unwrap().contains("-committed")); + let restricted = super::workbench_tests::api(&root, false, None); + assert!(restricted.vcs_log(&root, None, 1, &cancel).is_err()); + assert!(restricted.vcs_blame(None, &root, "a.txt").is_err()); + assert!(restricted.vcs_diff(&root, None, None, &cancel).is_err()); + std::fs::write(&path, "x".repeat(5 << 20)).unwrap(); + assert!(api.vcs_diff(&root, None, None, &cancel).is_err()); + } + #[cfg(unix)] + #[test] + fn vcs_reads_never_run_repository_configured_helpers() { + let dir = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(dir.path()).unwrap(); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.name", "Test"]); + git(&root, &["config", "user.email", "test@example.com"]); + std::fs::write(root.join("a.txt"), "base\n").unwrap(); + git(&root, &["add", "."]); + git(&root, &["commit", "-qm", "base"]); + let marker = root.join("HELPER-RAN"); + let helper = format!("touch '{}'", marker.display()); + for key in [ + "core.fsmonitor", + "diff.external", + "diff.test.command", + "diff.test.textconv", + "filter.test.clean", + "filter.test.process", + ] { + git(&root, &["config", key, &helper]); + } + git(&root, &["config", "filter.test.required", "true"]); + assert!(!marker.exists(), "setup ran helper"); + std::fs::write(root.join(".gitattributes"), "*.txt diff=test filter=test\n").unwrap(); + std::fs::write(root.join("a.txt"), "changed\n").unwrap(); + let api = api(&root, true, Some("unsaved\n")); + let cancel = CancellationToken::new(); + api.vcs_log(&root, None, 10, &cancel).unwrap(); + assert!(!marker.exists(), "log ran helper"); + api.vcs_blame(None, &root, "a.txt").unwrap(); + assert!(!marker.exists(), "blame ran helper"); + api.vcs_diff(&root, None, None, &cancel).unwrap(); + assert!(!marker.exists()); + } +} diff --git a/crates/ide-host/src/plugins/auth.rs b/crates/ide-host/src/plugins/auth.rs new file mode 100644 index 000000000..e0a955ba4 --- /dev/null +++ b/crates/ide-host/src/plugins/auth.rs @@ -0,0 +1,205 @@ +//! One-shot OAuth redirects, always bound on the desktop's IPv4 loopback. +use ide_core::CancellationToken; +use ide_plugin::HostError; +use parking_lot::Mutex; +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, TcpListener, TcpStream}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +pub(super) trait Service: Send + Sync { + fn begin(&self, plugin: &str, window: &str, path: &str) -> Result<(u64, String), HostError>; + fn wait( + &self, + plugin: &str, + id: u64, + timeout: Duration, + cancel: &CancellationToken, + ) -> Result, HostError>; + fn close(&self, plugin: &str, id: u64); + fn clear(&self, plugin: &str); + fn forget_window(&self, window: &str); +} +struct Entry { + window: String, + path: String, + listener: Mutex>, + cancel: CancellationToken, +} +#[derive(Default)] +pub(super) struct Loopbacks { + next: AtomicU64, + entries: Mutex>>, +} +fn failed(e: impl std::fmt::Display) -> HostError { + HostError::Failed(e.to_string()) +} +fn respond(stream: &mut TcpStream, ok: bool) { + let body = if ok { "You can close this tab." } else { "Not found." }; + let status = if ok { "200 OK" } else { "404 Not Found" }; + let _ = write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n{body}", + body.len() + ); +} +impl Loopbacks { + fn remove_where(&self, predicate: impl Fn(&str, &Entry) -> bool) { + self.entries.lock().retain(|(p, _), entry| { + if !predicate(p, entry) { + return true; + } + entry.cancel.cancel(); + entry.listener.lock().take(); + false + }); + } +} +impl Service for Loopbacks { + fn begin(&self, plugin: &str, window: &str, path: &str) -> Result<(u64, String), HostError> { + let path = path.trim_start_matches('/'); + if path.len() > 1024 + || path.bytes().any(|b| !(b.is_ascii_alphanumeric() || b"/-._~".contains(&b))) + || path.split('/').any(|s| matches!(s, "." | "..")) + { + return Err(HostError::Invalid("loopback path must be a URL path without query or fragment".into())); + } + let mut entries = self.entries.lock(); + if entries.keys().filter(|(p, _)| p == plugin).count() >= 2 { + return Err(HostError::Invalid("at most two open loopbacks per plugin".into())); + } + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(failed)?; + listener.set_nonblocking(true).map_err(failed)?; + let uri = format!("http://127.0.0.1:{}/{path}", listener.local_addr().map_err(failed)?.port()); + let id = self.next.fetch_add(1, Ordering::Relaxed); + entries.insert( + (plugin.into(), id), + Arc::new(Entry { + window: window.into(), + path: format!("/{path}"), + listener: Mutex::new(Some(listener)), + cancel: CancellationToken::new(), + }), + ); + Ok((id, uri)) + } + fn wait( + &self, + plugin: &str, + id: u64, + timeout: Duration, + cancel: &CancellationToken, + ) -> Result, HostError> { + let entry = self + .entries + .lock() + .get(&(plugin.into(), id)) + .cloned() + .ok_or_else(|| HostError::NotFound("loopback".into()))?; + let listener = + entry.listener.lock().take().ok_or_else(|| HostError::Invalid("loopback already awaited".into()))?; + let deadline = Instant::now() + timeout; + let result = (|| loop { + if cancel.is_cancelled() || entry.cancel.is_cancelled() { + return Err(HostError::Cancelled); + } + match listener.accept() { + Ok((mut stream, address)) => { + if !address.ip().is_loopback() { + continue; + } + stream.set_nonblocking(true).map_err(failed)?; + let mut head = Vec::new(); + loop { + if cancel.is_cancelled() || entry.cancel.is_cancelled() { + return Err(HostError::Cancelled); + } + if Instant::now() >= deadline { + return Ok(None); + } + let mut buf = [0; 1024]; + match stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + head.extend_from_slice(&buf[..n]); + if head.windows(4).any(|w| w == b"\r\n\r\n") || head.len() > 8192 { + break; + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)) + } + Err(e) => return Err(failed(e)), + } + } + let line = std::str::from_utf8(&head).unwrap_or_default().lines().next().unwrap_or_default(); + let target = line.strip_prefix("GET ").and_then(|s| s.split_once(' ')).map(|(target, _)| target); + let query = target.and_then(|target| { + let (path, query) = target.split_once('?').unwrap_or((target, "")); + (path == entry.path && head.len() <= 8192).then(|| query.to_owned()) + }); + stream.set_nonblocking(false).map_err(failed)?; + stream.set_write_timeout(Some(Duration::from_millis(100))).map_err(failed)?; + respond(&mut stream, query.is_some()); + if query.is_some() { + return Ok(query); + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(e) => return Err(failed(e)), + } + if Instant::now() >= deadline { + return Ok(None); + } + std::thread::sleep(Duration::from_millis(5)); + })(); + self.close(plugin, id); + result + } + fn close(&self, plugin: &str, id: u64) { + let entry = self.entries.lock().remove(&(plugin.into(), id)); + if let Some(entry) = entry { + entry.cancel.cancel(); + entry.listener.lock().take(); + } + } + fn clear(&self, plugin: &str) { + self.remove_where(|p, _| p == plugin || p.starts_with("remote:") && p.ends_with(&format!(":{plugin}"))); + } + fn forget_window(&self, window: &str) { + self.remove_where(|_, entry| entry.window == window); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn one_shot_raw_query_limits_timeout_and_cleanup() { + let service = Arc::new(Loopbacks::default()); + let (id, uri) = service.begin("a", "main", "oauth/callback").unwrap(); + assert!(uri.starts_with("http://127.0.0.1:")); + let (other, _) = service.begin("a", "main", "").unwrap(); + assert!(service.begin("a", "main", "third").is_err()); + assert_eq!(service.wait("a", other, Duration::ZERO, &CancellationToken::new()).unwrap(), None); + let host = uri.strip_prefix("http://").unwrap().split('/').next().unwrap().to_owned(); + let worker = std::thread::spawn(move || { + let mut stream = TcpStream::connect(host).unwrap(); + stream.write_all(b"GET /oauth/callback?code=a%2Bb&state=x HTTP/1.1\r\nHost: localhost\r\n\r\n").unwrap(); + let mut answer = String::new(); + stream.read_to_string(&mut answer).unwrap(); + assert!(answer.contains("You can close this tab")); + }); + assert_eq!( + service.wait("a", id, Duration::from_secs(2), &CancellationToken::new()).unwrap(), + Some("code=a%2Bb&state=x".into()) + ); + worker.join().unwrap(); + assert!(service.wait("a", id, Duration::ZERO, &CancellationToken::new()).is_err()); + let (id, _) = service.begin("a", "main", "callback").unwrap(); + service.forget_window("main"); + assert!(service.wait("a", id, Duration::ZERO, &CancellationToken::new()).is_err()); + } +} diff --git a/crates/ide-host/src/plugins/database.rs b/crates/ide-host/src/plugins/database.rs new file mode 100644 index 000000000..5fb19ee11 --- /dev/null +++ b/crates/ide-host/src/plugins/database.rs @@ -0,0 +1,225 @@ +//! Database ownership stays on the project host; credentials never pass back to guests. +use super::api::PluginApi; +use super::{PluginHost, WindowRequest}; +use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult}; +use ide_db::{DataSource, DbDriver, DbProperty}; +use ide_plugin::{HostError, PluginDataSource}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +#[derive(serde::Serialize, serde::Deserialize)] +struct OwnedProject { + root: PathBuf, + sources: std::collections::BTreeSet, +} + +pub(super) fn settings(source: PluginDataSource) -> Result { + let bad = || HostError::Invalid("invalid non-secret database source settings".into()); + if source.id.is_empty() + || source.id.len() > 128 + || source.name.trim().is_empty() + || source.name.len() > 256 + || source.id.chars().chain(source.name.chars()).any(char::is_control) + || source.options.len() > 128 + || [&source.host, &source.database, &source.user, &source.url] + .into_iter() + .flatten() + .any(|v| v.len() > 16_384 || v.contains('\0')) + { + return Err(bad()); + } + let driver = match source.driver.as_str() { + "sqlite" => DbDriver::Sqlite, + "postgres" => DbDriver::Postgres, + "mysql" => DbDriver::Mysql, + "sqlServer" => DbDriver::SqlServer, + "clickhouse" => DbDriver::Clickhouse, + "oracle" => DbDriver::Oracle, + _ => return Err(bad()), + }; + let secret = |key: &str| { + let key = key.to_ascii_lowercase(); + ["password", "passwd", "pwd", "token", "secret", "credential"].iter().any(|s| key.contains(s)) + }; + if source.options.iter().any(|(k, v)| { + k.is_empty() || k.len() > 256 || v.len() > 16_384 || k.contains('\0') || v.contains('\0') || secret(k) + }) { + return Err(bad()); + } + let mut out = DataSource { + name: source.name, + driver, + host: source.host.unwrap_or_else(|| "localhost".into()), + port: source.port, + database: source.database.unwrap_or_default(), + user: source.user.unwrap_or_default(), + save_password: true, + properties: source.options.into_iter().map(|(name, value)| DbProperty { name, value }).collect(), + ..Default::default() + }; + if driver == DbDriver::Sqlite { + out.file = Some(out.database.clone().into()); + } + if let Some(url) = source.url.filter(|u| !u.is_empty()) { + if url.contains(';') { + return Err(bad()); + } + let parsed = ide_db::source::parse_url(&url).map_err(|_| bad())?; + if parsed.password.is_some() || parsed.params.iter().any(|(k, _)| secret(k)) { + return Err(bad()); + } + // Case-insensitive URL credential keys must not bypass the parser's lowercase password key. + if let Some((_, query)) = url.split_once('?') { + for part in query.split('&') { + let key = part.split('=').next().unwrap_or_default(); + // Parsing a synthetic URL decodes percent-encoded parameter names as well. + let probe = ide_db::source::parse_url(&format!("postgres://host/db?{key}=x")).map_err(|_| bad())?; + if probe.password.is_some() || probe.params.iter().any(|(k, _)| secret(k)) { + return Err(bad()); + } + } + } + out.url_only = true; + out.url = url; + out.resolved().map_err(|_| bad())?; + } + Ok(out) +} + +impl PluginApi { + pub(super) fn database_host(&self, root: &Path) -> Result<(Arc, Arc), HostError> { + let workspace = self.shared.workspace().ok_or(HostError::Cancelled)?; + if !workspace.projects().iter().any(|r| r == root) { + return Err(HostError::NotFound("project is not open".into())); + } + if !workspace.trusted(root) { + return Err(HostError::Denied("project is not trusted".into())); + } + ide_plugin::resolve_new_in_root(root, ".openidea/data-sources.toml").map_err(HostError::Invalid)?; + let host = workspace + .database_host() + .ok_or_else(|| HostError::Invalid("Database subsystem is unavailable on this host".into()))?; + let db = host.projects().into_iter().find(|p| p.root == root).ok_or(HostError::Cancelled)?.db.clone(); + Ok((host, db)) + } + pub(super) fn remember_database_project(&self, root: &Path, id: &str) -> Result<(), HostError> { + let file = self.shared.data_dir.join(".database-projects").join(format!("{}.json", self.pkg.id())); + let mut projects: Vec = match std::fs::read(&file) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(e.to_string()))?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => vec![], + Err(e) => return Err(HostError::Failed(e.to_string())), + }; + if !projects.iter().any(|p| p.root == root) { + projects.push(OwnedProject { root: root.into(), sources: Default::default() }); + } + let project = projects.iter_mut().find(|p| p.root == root).unwrap(); + if project.sources.insert(id.into()) { + std::fs::create_dir_all(file.parent().unwrap()).map_err(|e| HostError::Failed(e.to_string()))?; + ide_vfs::write_atomic(&file, &serde_json::to_vec(&projects).unwrap()).map_err(error)?; + } + Ok(()) + } + pub(super) fn connect_database( + &self, + root: &Path, + client: &str, + id: &str, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result<(), HostError> { + let (_, db) = self.database_host(root)?; + if self.shared.project_of(client).as_deref() != Some(root) { + return Err(HostError::Invalid("requires this project's invoking window".into())); + } + let source = db.plugin_source(self.pkg.id(), id).map_err(error)?; + self.window_request(client, WindowRequest::DatabaseConnect { id: source.id }, cancel, timeout).map(|_| ()) + } +} + +pub(super) fn changed(host: &crate::Host, root: &Path) { + for project in host.projects().into_iter().filter(|p| p.root == root) { + project.sink.emit(IdeEvent::DbSourcesChanged); + } +} +impl PluginHost { + pub(super) fn remove_database_sources(&self, plugin: &str) -> IdeResult<()> { + let file = self.shared.data_dir.join(".database-projects").join(format!("{plugin}.json")); + let projects: Vec = match std::fs::read(&file) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string()))?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.into()), + }; + let host = self + .shared + .workspace() + .and_then(|w| w.database_host()) + .ok_or_else(|| IdeError::unavailable("Database subsystem is unavailable on this host"))?; + for project in projects { + let root = project.root; + let removed = ide_db::plugin::remove_all(&root, plugin)?; + let store = ide_db::secrets::SecretStore::new(host.data_dir.join("db-passwords.json"), &root); + let ids: std::collections::BTreeSet<_> = project + .sources + .iter() + .map(|id| ide_db::plugin::source_id(plugin, id)) + .chain(removed.into_iter().map(|source| source.id)) + .collect(); + for id in ids { + store.delete(&id); + store.delete(&ide_db::secrets::ssh_key(&id)); + for project in host.projects().into_iter().filter(|p| p.root == root) { + host.runtime.block_on(project.db.forget_plugin_source(&id)); + } + } + changed(&host, &root); + } + std::fs::remove_file(file)?; + Ok(()) + } +} + +pub(super) fn error(e: IdeError) -> HostError { + use ide_core::error::ErrorKind; + match e.kind { + ErrorKind::NotFound => HostError::NotFound(e.message), + ErrorKind::InvalidInput => HostError::Invalid(e.message), + ErrorKind::PermissionDenied => HostError::Denied(e.message), + _ => HostError::Failed(e.message), + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn source() -> PluginDataSource { + PluginDataSource { id: "local".into(), name: "Local".into(), driver: "postgres".into(), ..Default::default() } + } + #[test] + fn rejects_secrets_without_echoing_them_and_documents_every_driver_id() { + for driver in ["sqlite", "postgres", "mysql", "sqlServer", "clickhouse", "oracle"] { + assert!(settings(PluginDataSource { driver: driver.into(), ..source() }).is_ok(), "{driver}"); + } + for url in [ + "postgres://user:top-secret@host/db", + "postgres://host/db?password=top-secret", + "postgres://host/db?PASSWORD=top-secret", + "postgres://host/db?to%6ben=top-secret", + ] { + let error = settings(PluginDataSource { url: Some(url.into()), ..source() }).unwrap_err(); + assert!(!error.to_string().contains("top-secret")); + } + for key in ["password", "accessToken", "client_secret", "PWD"] { + assert!( + settings(PluginDataSource { options: vec![(key.into(), "top-secret".into())], ..source() }).is_err() + ); + } + let source = settings(PluginDataSource { + url: Some("postgres://reader@host:5433/db?sslmode=require".into()), + options: vec![("application_name".into(), "Example".into())], + ..source() + }) + .unwrap(); + assert_eq!(source.resolved().unwrap().host, "host"); + } +} diff --git a/crates/ide-host/src/plugins/diagnostics.rs b/crates/ide-host/src/plugins/diagnostics.rs index 14470f56a..86e44ccd8 100644 --- a/crates/ide-host/src/plugins/diagnostics.rs +++ b/crates/ide-host/src/plugins/diagnostics.rs @@ -79,7 +79,9 @@ impl Diagnostics { doc.cancel.cancel(); doc.cancel = CancellationToken::new(); let Some(host) = editor_plugin_host() else { return }; - if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty() { + if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty() + && !host.providers.any(ProviderKind::Injections) + { return; } // Register only once per project incarnation; no project is retained by the host. @@ -113,6 +115,7 @@ impl Diagnostics { snapshot.text = current.text; drop(documents); + let injections = host.providers.injection_generations(&snapshot.language); let generations = host.providers.diagnostic_generations(&snapshot.language); let results = if snapshot.text.len() <= super::navigation::MAX_DOCUMENT_BYTES { host.provide_project_diagnostics(Some(&root), &snapshot, &cancel) @@ -124,6 +127,11 @@ impl Diagnostics { if state.closed || cancel.is_cancelled() { return; } + if injections != host.providers.injection_generations(&snapshot.language) + || injections.keys().any(|id| lifecycle_generations.get(id) != state.generations.get(id)) + { + return; + } let matching: HashSet<_> = host .diagnostic_plugins(Some(&root), &snapshot.language) .into_iter() @@ -224,6 +232,13 @@ pub(super) type ProjectState = (Weak>, SharedSink); impl PluginHost { pub(super) fn clear_provider_diagnostics(&self, plugin: &str) { // Clone before taking a project's state lock (edits register in the other order). + let injector = self.get(plugin).is_ok_and(|p| { + p.manifest().declarations("providers").iter().any(|d| { + d.get("kinds") + .and_then(|v| v.as_array()) + .is_some_and(|v| v.iter().any(|k| k.as_str() == Some("injections"))) + }) + }); let projects = self.diagnostic_projects.lock().clone(); for (state, sink) in projects { if let Some(state) = state.upgrade() { @@ -231,7 +246,15 @@ impl PluginHost { // A completed answer can still be waiting for another provider. *state.generations.entry(plugin.to_owned()).or_default() += 1; for doc in state.documents.values_mut() { - if doc.sources.remove(plugin) { + if injector { + for source in doc.sources.drain() { + sink.emit(IdeEvent::Diagnostics { + path: doc.snapshot.path.clone().into(), + source: format!("plugin-provider:{source}"), + items: vec![], + }); + } + } else if doc.sources.remove(plugin) { sink.emit(IdeEvent::Diagnostics { path: doc.snapshot.path.clone().into(), source: format!("plugin-provider:{plugin}"), diff --git a/crates/ide-host/src/plugins/endpoints.rs b/crates/ide-host/src/plugins/endpoints.rs index 204768328..358ad4918 100644 --- a/crates/ide-host/src/plugins/endpoints.rs +++ b/crates/ide-host/src/plugins/endpoints.rs @@ -281,7 +281,7 @@ mod tests { #[test] fn validates_entire_rooted_batch_with_utf16_positions() { let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().canonicalize().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); let file = root.join("routes.rb"); std::fs::write(&file, "😀x\n").unwrap(); let good = endpoint(file.clone()); diff --git a/crates/ide-host/src/plugins/execution.rs b/crates/ide-host/src/plugins/execution.rs new file mode 100644 index 000000000..95328befe --- /dev/null +++ b/crates/ide-host/src/plugins/execution.rs @@ -0,0 +1,534 @@ +//! Window-owned plugin operations. Instance reclamation does not end them. +use super::api::{PluginApi, PromptEntry, Waiter}; +use super::{CommandAnswer, EventQueue, PluginEvent, PluginHost, WindowRequest}; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use ide_plugin::{ContentTab, ContentTabReply, HostError, WorkbenchEvent}; +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::time::{Duration, Instant}; + +pub(super) struct Operation { + pub plugin: String, + pub client: String, + pub kind: OperationKind, + pub root: Option, + pub started: bool, + pub closed: bool, + pub run: Option, + pub terminal: Option<(std::sync::Weak, ide_core::TerminalId)>, +} + +pub(super) enum OperationKind { + Run { config: String, configuration: String, program: String }, + Terminal { key: String, command: ide_plugin::ProcessCommand }, +} + +pub(super) fn label(value: &str, max: usize) -> Result<(), HostError> { + if value.is_empty() || value.encode_utf16().count() > max || value.chars().any(char::is_control) { + return Err(HostError::Invalid(format!("expected 1-{max} characters without controls"))); + } + Ok(()) +} +fn body(value: &str) -> Result<(), HostError> { + if value.encode_utf16().count() > 1 << 20 || value.contains('\0') { + return Err(HostError::Invalid("content too large or contains NUL".into())); + } + Ok(()) +} +pub(super) fn validate_tab(tab: &ContentTab) -> Result<(), HostError> { + label(&tab.key, 128)?; + label(&tab.title, 256)?; + body(&tab.content)?; + if let Some(name) = &tab.file_name { + label(name, 256)?; + if name.contains(['/', '\\']) { + return Err(HostError::Invalid("file-name cannot contain separators".into())); + } + } + if let Some(language) = &tab.language { + label(language, 64)?; + } + if tab.actions.len() > 16 || tab.actions.iter().filter(|a| a.save).count() > 1 { + return Err(HostError::Invalid("at most 16 actions and one Save action".into())); + } + let mut ids = std::collections::BTreeSet::new(); + for action in &tab.actions { + label(&action.id, 64)?; + label(&action.label, 128)?; + if !ids.insert(&action.id) { + return Err(HostError::Invalid("duplicate action id".into())); + } + if let Some(icon) = &action.icon { + label(icon, 128)?; + } + } + Ok(()) +} +pub(super) fn validate_reply(reply: &ContentTabReply) -> Result<(), HostError> { + if let Some(content) = &reply.content { + body(content)?; + } + if reply.status.as_ref().is_some_and(|s| s.len() > 512) || reply.error.as_ref().is_some_and(|s| s.len() > 1024) { + return Err(HostError::Invalid("content status or error too long".into())); + } + if let Some(diff) = &reply.diff { + body(&diff.left)?; + body(&diff.right)?; + for value in [&diff.left_label, &diff.right_label].into_iter().flatten() { + label(value, 128)?; + } + if let Some(title) = &diff.title { + label(title, 256)?; + } + } + Ok(()) +} + +impl PluginApi { + pub(super) fn new_operation(&self, client: &str, kind: OperationKind) -> Result { + let closed = self.shared.closed_clients.lock(); + let blocked = self.shared.prompts_blocked.lock(); + if closed.contains(client) || blocked.contains(self.pkg.id()) { + return Err(HostError::Cancelled); + } + let mut operations = self.shared.operations.lock(); + if operations.values().filter(|o| o.plugin == self.pkg.id()).count() >= 32 { + return Err(HostError::Invalid("at most 32 active operations".into())); + } + let id = format!("plugin-op-{}", self.shared.next_prompt.fetch_add(1, Ordering::Relaxed)); + operations.insert( + id.clone(), + Operation { + plugin: self.pkg.id().into(), + client: client.into(), + kind, + root: self.shared.project_of(client), + started: false, + closed: false, + terminal: None, + run: None, + }, + ); + Ok(id) + } + pub(super) fn window_request( + &self, + client: &str, + request: WindowRequest, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result, HostError> { + let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + { + let closed = self.shared.closed_clients.lock(); + let blocked = self.shared.prompts_blocked.lock(); + if closed.contains(client) || blocked.contains(self.pkg.id()) { + return Err(HostError::Cancelled); + } + self.shared.prompts.lock().insert( + id, + PromptEntry { plugin: self.pkg.id().into(), client: client.into(), answer: Waiter::Command(tx) }, + ); + } + self.shared.sink.emit( + Some(client), + PluginEvent::WindowRequest { plugin_id: self.pkg.id().into(), request_id: id.to_string(), request }, + ); + let deadline = Instant::now() + timeout; + loop { + match rx.recv_timeout(Duration::from_millis(20)) { + Ok(CommandAnswer::Value(value)) => return Ok(value), + Ok(CommandAnswer::NotFound) => return Err(HostError::NotFound("window service unavailable".into())), + Ok(CommandAnswer::Failed(error)) => return Err(HostError::Failed(error)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + if !cancel.is_cancelled() && Instant::now() < deadline => {} + Err(_) => { + self.shared.prompts.lock().remove(&id); + return Err(HostError::Cancelled); + } + } + } + } +} + +impl PluginHost { + /// The launcher alone may finish its operation; duplicate and foreign reports are ignored. + pub fn finish_operation(&self, client: &str, id: &str, status: Option, stopped: bool) -> bool { + let operation = { + let mut operations = self.shared.operations.lock(); + if !operations.get(id).is_some_and(|op| op.client == client) { + return false; + } + operations.remove(id).unwrap() + }; + let event = match operation.kind { + OperationKind::Run { config, .. } => { + WorkbenchEvent::RunExited(ide_plugin::RunExited { id: id.into(), config, status, killed: stopped }) + } + OperationKind::Terminal { key, .. } => WorkbenchEvent::TerminalExited(ide_plugin::TerminalExited { + id: id.into(), + key, + status, + closed: stopped, + }), + }; + self.post_owned_event(&operation.plugin, client, event); + true + } + /// Content actions bypass subscriptions, just like completions of owned operations. + pub fn content_tab_action( + &self, + client: &str, + plugin: &str, + action: ide_plugin::ContentTabAction, + ) -> IdeResult<()> { + let tabs = self.shared.content_tabs.lock(); + let tab = tabs + .get(&(plugin.into(), client.into(), action.key.clone())) + .ok_or_else(|| IdeError::not_found("unknown content tab"))?; + if !tab.actions.iter().any(|a| a.id == action.action) { + return Err(IdeError::invalid("unknown content action")); + } + body(&action.content).map_err(|e| IdeError::invalid(e.to_string()))?; + label(&action.request_id, 128).map_err(|e| IdeError::invalid(e.to_string()))?; + drop(tabs); + let mut pending = self.shared.content_requests.lock(); + pending.retain(|_, (_, created)| created.elapsed() < Duration::from_secs(15)); + if pending.keys().filter(|(p, w, _)| p == plugin && w == client).count() >= 32 { + return Err(IdeError::invalid("too many pending content actions")); + } + pending.insert((plugin.into(), client.into(), action.request_id.clone()), (action.key.clone(), Instant::now())); + drop(pending); + self.post_owned_event(plugin, client, WorkbenchEvent::ContentTabAction(action)); + Ok(()) + } + fn post_owned_event(&self, plugin: &str, client: &str, event: WorkbenchEvent) { + if self.shared.client_closed(client) { + return; + } + self.queues + .lock() + .entry((plugin.into(), Some(client.into()))) + .or_insert_with(|| EventQueue { since: Instant::now(), events: Vec::new(), files_source: None }) + .events + .push(event); + } +} + +/// Dynamic configurations opt in with marker files, just like static detectors. +pub(super) fn configurations_declaration( + pkg: &ide_extension::Package, + value: toml::Value, +) -> Result { + #[derive(serde::Deserialize, serde::Serialize)] + #[serde(deny_unknown_fields)] + struct Decl { + files: Vec, + } + pkg.manifest.needs_api("contributes.runConfigurations", 4)?; + let decl: Decl = ide_extension::decode("runConfigurations", value)?; + if pkg.manifest.wasm.is_none() || decl.files.is_empty() || decl.files.len() > 16 { + return Err("runConfigurations needs code and 1-16 marker files".into()); + } + for file in &decl.files { + ide_extension::check_relative(std::path::Path::new(file), "files")?; + } + Ok(ide_extension::UiDecl::new( + serde_json::to_string(&decl.files).unwrap(), + "Run configurations", + serde_json::to_value(decl).unwrap(), + )) +} + +impl PluginHost { + /// Refreshes configuration exports on the same listing path as static detectors. + pub fn refresh_run_configurations(&self, root: &std::path::Path) { + if !self.shared.workspace().is_some_and(|w| w.trusted(root)) { + return; + } + let owners: std::collections::BTreeSet<_> = self + .ui + .items("runConfigurations", None) + .into_iter() + .filter(|d| { + d.decl["files"] + .as_array() + .is_some_and(|files| files.iter().filter_map(|f| f.as_str()).any(|f| root.join(f).is_file())) + }) + .map(|d| d.plugin_id) + .collect(); + self.shared.run_configurations.write().retain(|(p, r), _| r != root || owners.contains(p)); + for plugin in owners { + let project = root.to_path_buf(); + let invocation = ide_plugin::Invocation { workspace_root: Some(project.clone()), ..Default::default() }; + let result = self.code_call(&plugin, invocation, "run-configurations", |rt, ctx| { + rt.run_configurations( + ctx.workspace_root.unwrap().to_string_lossy().into_owned(), + Duration::from_secs(2), + ) + }); + let Ok(items) = result else { continue }; + if let Err(error) = validate_configurations(root, &items) { + tracing::warn!(%plugin, %error, "invalid run configurations"); + continue; + } + self.shared.run_configurations.write().insert((plugin, project), items); + } + } + /// Parses a bounded batch under the editor-provider soft/hard budgets. + pub fn parse_test_results( + &self, + client: &str, + config: &str, + chunk: String, + ) -> IdeResult>> { + if chunk.len() > 64 << 10 { + return Err(IdeError::invalid("test output batch too large")); + } + let root = self.shared.project_of(client).ok_or_else(|| IdeError::not_found("no project"))?; + if !self.shared.workspace().is_some_and(|w| w.trusted(&root)) { + return Err(IdeError::invalid("project is not trusted")); + } + let Some(rest) = config.strip_prefix(ide_run::EXTERNAL_PREFIX) else { return Ok(None) }; + let Some((owner, id)) = rest.split_once(':') else { return Ok(None) }; + let configuration = self + .shared + .workspace() + .ok_or_else(IdeError::cancelled)? + .run_configuration(client, owner, id) + .map_err(|e| IdeError::invalid(e.to_string()))?; + if !configuration.is_test || configuration.extras.params.get("framework").map(String::as_str) != Some("plugin") + { + return Ok(None); + } + let plugin = self.get(owner)?; + let (actor, activated) = self.ensure_active(&plugin, None)?; + if activated { + self.changed(); + } + let invocation = ide_plugin::Invocation { workspace_root: Some(root.clone()), ..Default::default() }; + let (id, hard) = (id.to_owned(), self.limits.edit_hard); + let started = Instant::now(); + let result = + actor.call(Some(self.limits.edit_soft), move |rt| rt.parse_test_results(id, chunk, invocation, hard)); + let mut events = self.finish_provider_call(&plugin, &actor, started, result); + if let Some(events) = &mut events { + if serde_json::to_vec(&*events).is_ok_and(|b| b.len() > 4 << 20) { + return Ok(None); + } + for event in events { + let location = match event { + ide_plugin::PluginTestEvent::SuiteStarted(v) | ide_plugin::PluginTestEvent::TestStarted(v) => { + v.location.as_mut() + } + ide_plugin::PluginTestEvent::Failed(v) => v.location.as_mut(), + _ => None, + }; + if let Some(location) = location { + let Ok(path) = ide_plugin::resolve_new_in_root(&root, &location.path.to_string_lossy()) else { + return Ok(None); + }; + if location.range.end < location.range.start { + return Ok(None); + } + location.path = path; + } + } + } + Ok(events) + } +} + +fn validate_configurations( + root: &std::path::Path, + items: &[ide_plugin::PluginRunConfiguration], +) -> Result<(), HostError> { + let mut ids = std::collections::BTreeSet::new(); + if items.len() > 1000 || serde_json::to_vec(items).is_ok_and(|b| b.len() > 4 << 20) { + return Err(HostError::Invalid("too many run configurations".into())); + } + for c in items { + ide_extension::validate_item_id(&c.id, "run configuration id").map_err(HostError::Invalid)?; + label(&c.name, 128)?; + if !ids.insert(&c.id) + || c.program.is_empty() + || c.program.len() > 256 + || c.program.contains(char::is_whitespace) + || c.args.len() > 256 + || c.args.iter().any(|a| a.len() > 4096 || a.contains('\0')) + { + return Err(HostError::Invalid("invalid run configuration".into())); + } + if let Some(cwd) = &c.cwd { + ide_plugin::resolve_new_in_root(root, cwd).map_err(HostError::Invalid)?; + } + } + Ok(()) +} + +impl PluginHost { + /// Resolves and rechecks the pin immediately before starting a keyed terminal's PTY. + pub fn spawn_terminal_session( + self: &std::sync::Arc, + host: &std::sync::Arc, + client: &str, + operation: &str, + cols: u16, + rows: u16, + output: crate::ops::Emit, + ) -> IdeResult { + let (plugin, command) = { + let mut operations = self.shared.operations.lock(); + let op = operations + .get_mut(operation) + .filter(|op| op.client == client) + .ok_or_else(|| IdeError::not_found("unknown terminal operation"))?; + if op.started || op.closed || op.root != self.shared.project_of(client) { + return Err(IdeError::invalid("terminal operation already started or project changed")); + } + let OperationKind::Terminal { command, .. } = &op.kind else { + return Err(IdeError::invalid("not a terminal operation")); + }; + op.started = true; + (op.plugin.clone(), command.clone()) + }; + let options = super::web::WebPtyCommand { + program: command.program, + args: command.args, + env: command.env.into_iter().collect(), + cols, + rows, + }; + let weak = Arc::downgrade(self); + let (id, window) = (operation.to_owned(), client.to_owned()); + let completion = Arc::new(move |status| { + if let Some(plugins) = weak.upgrade() { + let closed = plugins.shared.operations.lock().get(&id).is_some_and(|op| op.closed); + plugins.finish_operation(&window, &id, status, closed); + } + }); + let result = self.spawn_plugin_pty(host, &plugin, client, output, Some(completion), || { + self.pty_options(&plugin, client, options) + }); + if let Ok(info) = &result { + let mut operations = self.shared.operations.lock(); + if let Some(op) = operations.get_mut(operation) { + op.terminal = Some((Arc::downgrade(host), info.id)); + if op.closed { + let _ = host.terminals.kill(info.id); + } + } + } + if result.is_err() + && let Some(op) = self.shared.operations.lock().get_mut(operation) + { + op.started = false; + } + result + } +} + +impl PluginHost { + /// Closing a tab marks the operation before killing its PTY. The real exit emits the event. + pub fn close_terminal_session(&self, client: &str, id: &str) -> IdeResult<()> { + let terminal = { + let mut operations = self.shared.operations.lock(); + let Some(op) = operations + .get_mut(id) + .filter(|op| op.client == client && matches!(op.kind, OperationKind::Terminal { .. })) + else { + return Ok(()); + }; + op.closed = true; + op.terminal.clone() + }; + if let Some((host, id)) = terminal + && let Some(host) = host.upgrade() + { + host.terminals.kill(id)?; + } + Ok(()) + } +} + +impl PluginHost { + /// Rechecks the owned configuration's program before Debug and every IDE rerun. + pub fn prepare_debug_run(&self, client: &str, mut config: ide_run::RunConfig) -> IdeResult { + let Some((plugin, id)) = config.id.strip_prefix(ide_run::EXTERNAL_PREFIX).and_then(|s| s.split_once(':')) + else { + return Err(IdeError::invalid("not a plugin configuration")); + }; + let workspace = self.shared.workspace().ok_or_else(IdeError::cancelled)?; + let owned = workspace.run_configuration(client, plugin, id).map_err(|e| IdeError::invalid(e.to_string()))?; + if owned.debug_adapter.is_none() { + return Err(IdeError::invalid("this configuration has no debugger")); + } + if config.extras.target.is_none() { + config.extras.target = owned.extras.target.clone(); + } + if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) { + return Err(IdeError::invalid("debugging this target is not supported")); + } + config.command = self + .program_for_project(plugin, &owned.command, self.shared.project_of(client).as_deref())? + .to_string_lossy() + .into_owned(); + config.debug_adapter = owned.debug_adapter; + Ok(config) + } +} + +/// Internal launch metadata; it is ignored after the original operation finishes. +pub(super) const RUN_OPERATION: &str = "plugin.operation"; +impl PluginHost { + pub(crate) fn run_operation_sink( + self: &Arc, + client: &str, + config: &mut ide_run::RunConfig, + previous: Arc, + ) -> IdeResult> { + let Some(id) = config.extras.params.get(RUN_OPERATION).cloned() else { return Ok(previous) }; + let origin = self.shared.operations.lock().get(&id).and_then(|op| { + if op.client != client { + return None; + } + let OperationKind::Run { configuration, program, .. } = &op.kind else { return None }; + (configuration == &config.id).then(|| (op.plugin.clone(), op.root.clone(), program.clone())) + }); + let Some((plugin, root, program)) = origin else { return Ok(previous) }; + let binary = self.program_for_project(&plugin, &program, root.as_deref())?; + config.command = if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) { + program + } else { + binary.to_string_lossy().into_owned() + }; + config.detected = false; + let (weak, client) = (Arc::downgrade(self), client.to_owned()); + Ok(Arc::new(move |event: ide_core::IdeEvent| { + if let ide_core::IdeEvent::RunExited { code, .. } = &event + && let Some(plugins) = weak.upgrade() + { + let stopped = plugins.shared.operations.lock().get(&id).is_some_and(|op| op.closed); + plugins.finish_operation(&client, &id, *code, stopped); + } + previous.emit(event); + })) + } + pub(crate) fn run_operation_started(&self, client: &str, operation: &str, run: ide_core::RunId) { + if let Some(op) = self + .shared + .operations + .lock() + .get_mut(operation) + .filter(|op| op.client == client && matches!(op.kind, OperationKind::Run { .. })) + { + op.run = Some(run); + } + } + pub(crate) fn run_operation_stopped(&self, run: ide_core::RunId) { + for op in self.shared.operations.lock().values_mut().filter(|op| op.run == Some(run)) { + op.closed = true; + } + } +} diff --git a/crates/ide-host/src/plugins/index_ops.rs b/crates/ide-host/src/plugins/index_ops.rs index ef11aac62..60f09fd6a 100644 --- a/crates/ide-host/src/plugins/index_ops.rs +++ b/crates/ide-host/src/plugins/index_ops.rs @@ -113,6 +113,7 @@ pub async fn plugin_navigation_index( &project.root, crate::plugins::navigation::Snapshot { path: &path, version, text: &text, symbols: &symbols, scope }, Invocation { + command_chain: Vec::new(), client: Some(client.clone()), workspace_root: Some(project.root.clone()), editor: None, diff --git a/crates/ide-host/src/plugins/injections.rs b/crates/ide-host/src/plugins/injections.rs new file mode 100644 index 000000000..79e132845 --- /dev/null +++ b/crates/ide-host/src/plugins/injections.rs @@ -0,0 +1,126 @@ +//! Embedded plugin-provider documents preserve the host's UTF-16 coordinates. +use super::{PluginHost, ProviderKind}; +use ide_core::{Pos, Range}; +use ide_plugin::{ProviderDocument, language_features::Injection}; +use std::sync::Arc; + +/// Allowed output ranges for one virtual document; the host document is unrestricted. +pub(super) struct Bounds(Option>); +impl Bounds { + pub fn unrestricted() -> Self { + Self(None) + } + pub fn contains(&self, range: Range) -> bool { + self.0.as_ref().is_none_or(|rs| rs.iter().any(|r| r.start <= range.start && range.end <= r.end)) + } + pub fn lines(&self, start: u32, end: u32) -> bool { + self.0.as_ref().is_none_or(|rs| rs.iter().any(|r| r.start.line <= start && end <= r.end.line)) + } +} + +impl PluginHost { + pub fn provide_injections(&self, doc: &ProviderDocument) -> Vec { + if !self.providers.any(ProviderKind::Injections) { + return vec![]; + } + if doc.text.len() > super::navigation::MAX_DOCUMENT_BYTES { + return Vec::new(); + } + let text = ide_text::Document::new(&doc.text); + let d = Arc::new(doc.clone()); + let mut items: Vec<_> = self + .document_calls(ProviderKind::Injections, doc, move |rt, t| rt.provide_injections(&d, t)) + .into_iter() + .flat_map(|(_, v)| v) + .filter(|i| { + !i.range.is_empty() + && super::language_features::valid_range_in(&text, i.range) + && i.language != doc.language + && !i.language.is_empty() + && i.language.len() <= 64 + && i.language.chars().all(|c| c.is_ascii_alphanumeric() || "-_.+#".contains(c)) + }) + .collect(); + items.sort_by_key(|i| i.range); + let mut end = Pos::default(); + items.retain(|i| { + if i.range.start < end { + return false; + } + end = i.range.end; + true + }); + items.truncate(1000); + items + } + + pub(super) fn feature_documents(&self, doc: &ProviderDocument, at: Option) -> Vec<(ProviderDocument, Bounds)> { + let injections = self.provide_injections(doc); + let mut languages = Vec::new(); + for i in &injections { + if at.is_some_and(|p| p < i.range.start || p >= i.range.end) || languages.contains(&i.language) { + continue; + } + languages.push(i.language.clone()); + if languages.len() == 32 { + break; + } + } + let mut docs = Vec::new(); + for language in languages { + let ranges = injections.iter().filter(|i| i.language == language).map(|i| i.range).collect(); + let text = virtual_text(doc, &injections, &language); + docs.push((ProviderDocument { language, text, ..doc.clone() }, Bounds(Some(ranges)))); + } + docs.push((doc.clone(), Bounds(None))); + docs + } +} + +pub(crate) fn virtual_text(doc: &ProviderDocument, ranges: &[Injection], language: &str) -> String { + let ranges: Vec<_> = ranges.iter().filter(|i| i.language == language).map(|i| i.range).collect(); + let mut index = 0; + let mut at = Pos::default(); + let mut text = String::with_capacity(doc.text.len()); + for c in doc.text.chars() { + while index < ranges.len() && ranges[index].end <= at { + index += 1; + } + let inside = ranges.get(index).is_some_and(|r| r.start <= at && at < r.end); + if inside || c == '\n' || c == '\r' { + text.push(c); + } else { + for _ in 0..c.len_utf16() { + text.push(' '); + } + } + if c == '\n' { + at.line += 1; + at.col = 0; + } else { + at.col += c.len_utf16() as u32; + } + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn virtual_documents_preserve_utf16_columns_and_newlines() { + let d = ProviderDocument { + path: "/p/a".into(), + language: "host".into(), + version: 7, + text: "😀 sql\n outside\n sql".into(), + }; + let ranges = vec![ + Injection { range: Range::new(Pos::new(0, 3), Pos::new(0, 6)), language: "sql".into() }, + Injection { range: Range::new(Pos::new(2, 1), Pos::new(2, 4)), language: "sql".into() }, + ]; + let text = virtual_text(&d, &ranges, "sql"); + assert_eq!(text, " sql\n \n sql"); + assert_eq!(text.encode_utf16().count(), d.text.encode_utf16().count()); + } +} diff --git a/crates/ide-host/src/plugins/ipc.rs b/crates/ide-host/src/plugins/ipc.rs new file mode 100644 index 000000000..50612562d --- /dev/null +++ b/crates/ide-host/src/plugins/ipc.rs @@ -0,0 +1,174 @@ +//! Plugin-owned local byte streams, closed on resource drop and plugin shutdown. +use super::api::net_runtime; +use ide_core::CancellationToken; +use ide_plugin::HostError; +use parking_lot::Mutex; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +trait Stream: AsyncRead + AsyncWrite + Unpin + Send {} +impl Stream for T {} +struct Connection { + stream: tokio::sync::Mutex>, + closed: CancellationToken, +} +impl Drop for Connection { + fn drop(&mut self) { + self.closed.cancel(); + } +} + +#[derive(Default)] +pub(super) struct Connections { + next: AtomicU64, + entries: Mutex>>, +} +fn failed(e: impl std::fmt::Display) -> HostError { + HostError::Failed(e.to_string()) +} +impl Connections { + pub fn connect( + &self, + plugin: &str, + name: &str, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result { + let check = |entries: &HashMap<(String, u64), Arc>| { + if entries.keys().filter(|(p, _)| p == plugin).count() >= 8 { + Err(HostError::Invalid("at most eight IPC connections per plugin".into())) + } else { + Ok(()) + } + }; + check(&self.entries.lock())?; + let stream: Box = net_runtime().block_on(async { + let connect = async { + #[cfg(unix)] + { Ok::, HostError>(Box::new(tokio::net::UnixStream::connect(name).await.map_err(failed)?)) } + #[cfg(windows)] + { Ok::, HostError>(Box::new(tokio::net::windows::named_pipe::ClientOptions::new().open(name).map_err(failed)?)) } + #[cfg(not(any(unix, windows)))] + { let _ = name; Err(HostError::Failed("local IPC unavailable".into())) } + }; + tokio::select! { + r = tokio::time::timeout(timeout, connect) => r.map_err(|_| HostError::Failed("IPC connection timed out".into()))?, + () = cancel.cancelled() => Err(HostError::Cancelled), + } + })?; + let mut entries = self.entries.lock(); + check(&entries)?; + if cancel.is_cancelled() { + return Err(HostError::Cancelled); + } + let id = self.next.fetch_add(1, Ordering::Relaxed); + entries.insert( + (plugin.into(), id), + Arc::new(Connection { stream: tokio::sync::Mutex::new(stream), closed: CancellationToken::new() }), + ); + Ok(id) + } + fn get(&self, plugin: &str, id: u64) -> Result, HostError> { + self.entries + .lock() + .get(&(plugin.into(), id)) + .cloned() + .ok_or_else(|| HostError::NotFound("IPC connection".into())) + } + pub fn write( + &self, + plugin: &str, + id: u64, + bytes: Vec, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result<(), HostError> { + let entry = self.get(plugin, id)?; + net_runtime().block_on(async { + tokio::select! { + r = tokio::time::timeout(timeout, async { entry.stream.lock().await.write_all(&bytes).await.map_err(failed) }) => r.map_err(|_| HostError::Failed("IPC write timed out".into()))?, + () = cancel.cancelled() => Err(HostError::Cancelled), + () = entry.closed.cancelled() => Err(HostError::Cancelled), + } + }) + } + pub fn read( + &self, + plugin: &str, + id: u64, + max: usize, + timeout: Duration, + cancel: &CancellationToken, + ) -> Result, HostError> { + let entry = self.get(plugin, id)?; + net_runtime().block_on(async { + let read = async { + let mut bytes = vec![0; max.min(1 << 20)]; + let count = entry.stream.lock().await.read(&mut bytes).await.map_err(failed)?; + bytes.truncate(count); + Ok(bytes) + }; + tokio::select! { + biased; + () = cancel.cancelled() => Err(HostError::Cancelled), + () = entry.closed.cancelled() => Err(HostError::Cancelled), + r = read => r, + () = tokio::time::sleep(timeout) => Ok(Vec::new()), + } + }) + } + pub fn close(&self, plugin: &str, id: u64) { + if let Some(entry) = self.entries.lock().remove(&(plugin.into(), id)) { + entry.closed.cancel(); + } + } + pub fn clear(&self, plugin: &str) { + self.entries.lock().retain(|(p, _), entry| { + if p != plugin { + return true; + } + entry.closed.cancel(); + false + }); + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::os::unix::net::UnixListener; + #[test] + fn echo_limit_and_shutdown() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("echo.sock"); + let listener = UnixListener::bind(&path).unwrap(); + let worker = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut bytes = [0; 4]; + stream.read_exact(&mut bytes).unwrap(); + stream.write_all(&bytes).unwrap(); + // Accept the remaining seven connections before shutdown. + for _ in 1..8 { + listener.accept().unwrap(); + } + }); + let pool = Connections::default(); + let token = CancellationToken::new(); + let timeout = Duration::from_secs(2); + let id = pool.connect("a", path.to_str().unwrap(), &token, timeout).unwrap(); + pool.write("a", id, b"echo".to_vec(), &token, timeout).unwrap(); + assert_eq!(pool.read("a", id, 4, timeout, &token).unwrap(), b"echo"); + for _ in 1..8 { + pool.connect("a", path.to_str().unwrap(), &token, timeout).unwrap(); + } + assert!(matches!(pool.connect("a", path.to_str().unwrap(), &token, timeout), Err(HostError::Invalid(_)))); + assert!(pool.read("b", id, 4, timeout, &token).is_err()); + pool.clear("a"); + assert!(pool.read("a", id, 4, timeout, &token).is_err()); + worker.join().unwrap(); + } +} diff --git a/crates/ide-host/src/plugins/language_features.rs b/crates/ide-host/src/plugins/language_features.rs new file mode 100644 index 000000000..b7852690a --- /dev/null +++ b/crates/ide-host/src/plugins/language_features.rs @@ -0,0 +1,462 @@ +//! API 1.4 language features. Results are bounded and checked against the snapshot. +use super::{PluginHost, ProviderKind}; +use ide_core::{IdeResult, Pos, Range}; +use ide_plugin::{ProviderDocument, language_features::*}; +use std::sync::Arc; + +pub(crate) fn valid_range(doc: &ProviderDocument, range: Range) -> bool { + valid_range_in(&ide_text::Document::new(&doc.text), range) +} +pub(super) fn valid_range_in(text: &ide_text::Document, range: Range) -> bool { + if range.start > range.end { + return false; + } + [range.start, range.end].iter().all(|p| text.offset_to_pos(text.pos_to_offset(*p)) == *p) +} + +impl PluginHost { + pub fn language_format( + &self, + id: &str, + doc: &ProviderDocument, + range: Option, + options: FormatOptions, + ) -> IdeResult> { + let candidates = if let Some(r) = range { + self.feature_documents(doc, Some(r.start)) + } else { + vec![(doc.clone(), super::injections::Bounds::unrestricted())] + }; + let (target, bounds) = candidates + .into_iter() + .find(|(d, b)| { + range.is_none_or(|r| b.contains(r)) + && self + .provider_plugins(ProviderKind::Formatting, &d.language) + .iter() + .any(|p| p.id() == id && p.dev_root.as_ref().is_none_or(|r| d.path.starts_with(r))) + }) + .ok_or_else(|| ide_core::IdeError::not_found("The selected plugin formatter is unavailable"))?; + let text = ide_text::Document::new(&target.text); + let d = Arc::new(target); + let edits = self.code_call(id, ide_plugin::Invocation::default(), "formatting", move |rt, _| { + rt.language_format(&d, range, options.clone(), std::time::Duration::from_secs(5)) + })?; + if edits.iter().any(|e| !valid_range_in(&text, e.range) || !bounds.contains(e.range)) { + return Err(ide_core::IdeError::invalid("Plugin formatter returned an invalid range")); + } + let mut ranges: Vec<_> = edits.iter().map(|e| e.range).collect(); + ranges.sort(); + if ranges.windows(2).any(|p| p[0].end > p[1].start) { + return Err(ide_core::IdeError::invalid("Plugin formatter returned overlapping edits")); + } + Ok(edits) + } + pub fn language_signature_help(&self, doc: &ProviderDocument, at: Pos) -> Option { + for (d, _bounds) in self.feature_documents(doc, Some(at)) { + let snapshot = d.clone(); + let d = Arc::new(d); + let results = self.document_calls(ProviderKind::SignatureHelp, &snapshot, move |rt, t| { + rt.language_signature_help(&d, at, t) + }); + for (_, v) in results { + if let Some(v) = v.filter(|h| !h.signatures.is_empty()) { + return Some(v); + } + } + } + None + } + pub fn language_document_highlights(&self, doc: &ProviderDocument, at: Pos) -> Vec { + let mut out = Vec::new(); + for (d, bounds) in self.feature_documents(doc, Some(at)) { + let snapshot = d.clone(); + let text = ide_text::Document::new(&snapshot.text); + let d = Arc::new(d); + let results = self.document_calls(ProviderKind::DocumentHighlights, &snapshot, move |rt, t| { + rt.language_document_highlights(&d, at, t) + }); + for (p, items) in results { + for i in items { + let _ = &p; + if valid_range_in(&text, i.range) && bounds.contains(i.range) { + out.push(i); + } + } + } + } + out + } + pub fn language_document_links(&self, doc: &ProviderDocument) -> Vec { + let mut out = Vec::new(); + for (d, bounds) in self.feature_documents(doc, None) { + let snapshot = d.clone(); + let text = ide_text::Document::new(&snapshot.text); + let d = Arc::new(d); + let results = self + .document_calls(ProviderKind::DocumentLinks, &snapshot, move |rt, t| rt.language_document_links(&d, t)); + for (p, items) in results { + for i in items { + let _ = &p; + if valid_range_in(&text, i.range) && bounds.contains(i.range) { + out.push(i); + } + } + } + } + out + } + pub fn language_code_lenses(&self, doc: &ProviderDocument) -> Vec<(String, CodeLens)> { + let mut out = Vec::new(); + for (d, bounds) in self.feature_documents(doc, None) { + let snapshot = d.clone(); + let text = ide_text::Document::new(&snapshot.text); + let d = Arc::new(d); + let results = + self.document_calls(ProviderKind::CodeLenses, &snapshot, move |rt, t| rt.language_code_lenses(&d, t)); + for (p, items) in results { + let commands: std::collections::HashSet<_> = + self.ui.items(super::COMMANDS, Some(p.id())).into_iter().map(|c| c.key).collect(); + for i in items { + if valid_range_in(&text, i.range) && bounds.contains(i.range) && commands.contains(&i.command) { + out.push((p.id().to_owned(), i)); + } + } + } + } + out + } + pub fn language_selection_ranges(&self, doc: &ProviderDocument, at: Pos) -> Vec { + let mut out = Vec::new(); + for (d, bounds) in self.feature_documents(doc, Some(at)) { + let snapshot = d.clone(); + let text = ide_text::Document::new(&snapshot.text); + let d = Arc::new(d); + let results = self.document_calls(ProviderKind::SelectionRanges, &snapshot, move |rt, t| { + rt.language_selection_ranges(&d, at, t) + }); + for (p, items) in results { + for i in items { + let _ = &p; + if valid_range_in(&text, i) && i.start <= at && at <= i.end && bounds.contains(i) { + out.push(i); + } + } + } + } + out.sort_by(|a, b| b.start.cmp(&a.start).then(a.end.cmp(&b.end))); + out.dedup(); + let mut end = None; + out.retain(|r| { + let ok = end.is_none_or(|prev: Range| r.start <= prev.start && prev.end <= r.end); + if ok { + end = Some(*r); + } + ok + }); + out + } + pub fn language_folding(&self, doc: &ProviderDocument) -> Vec { + let mut out = Vec::new(); + for (d, bounds) in self.feature_documents(doc, None) { + let lines = d.text.split('\n').count() as u32; + let snapshot = d.clone(); + let d = Arc::new(d); + let results = + self.document_calls(ProviderKind::Folding, &snapshot, move |rt, t| rt.language_folding(&d, t)); + for (p, items) in results { + for i in items { + let _ = &p; + if i.start_line < i.end_line && i.end_line < lines && bounds.lines(i.start_line, i.end_line) { + out.push(i); + } + } + } + } + out + } + pub fn language_colors(&self, doc: &ProviderDocument) -> Vec<(String, ColorInfo)> { + let mut out = Vec::new(); + for (d, bounds) in self.feature_documents(doc, None) { + let snapshot = d.clone(); + let text = ide_text::Document::new(&snapshot.text); + let d = Arc::new(d); + let results = self.document_calls(ProviderKind::Colors, &snapshot, move |rt, t| rt.language_colors(&d, t)); + for (p, items) in results { + for i in items { + if valid_range_in(&text, i.range) + && bounds.contains(i.range) + && [i.red, i.green, i.blue, i.alpha].iter().all(|c| c.is_finite() && (0.0..=1.0).contains(c)) + { + out.push((p.id().to_owned(), i)); + } + } + } + } + out + } + pub fn language_color_presentations(&self, id: &str, doc: &ProviderDocument, color: ColorInfo) -> Vec { + for (d, bounds) in self.feature_documents(doc, Some(color.range.start)) { + if !bounds.contains(color.range) + || !self.provider_plugins(ProviderKind::Colors, &d.language).iter().any(|p| p.id() == id) + { + continue; + } + let snapshot = d.clone(); + let d = Arc::new(d); + let color = color.clone(); + let result = self.document_calls_for(ProviderKind::Colors, &snapshot, Some(id), move |rt, t| { + rt.language_color_presentations(&d, color.clone(), t) + }); + if let Some((_, items)) = result.into_iter().find(|(p, _)| p.id() == id) { + return items.into_iter().take(100).collect(); + } + } + Vec::new() + } + pub fn language_document_symbols(&self, doc: &ProviderDocument) -> Vec { + let mut out = Vec::new(); + for (d, bounds) in self.feature_documents(doc, None) { + let snapshot = d.clone(); + let text = ide_text::Document::new(&snapshot.text); + let d = Arc::new(d); + let results = self.document_calls(ProviderKind::DocumentSymbols, &snapshot, move |rt, t| { + rt.language_document_symbols(&d, t) + }); + for (_, items) in results { + let mut remap = std::collections::HashMap::new(); + for (index, mut i) in items.into_iter().enumerate() { + if !valid_range_in(&text, i.range) + || !valid_range_in(&text, i.selection_range) + || i.selection_range.start < i.range.start + || i.selection_range.end > i.range.end + || !bounds.contains(i.range) + { + continue; + } + i.parent = i.parent.and_then(|p| remap.get(&(p as usize)).copied()); + remap.insert(index, out.len() as u32); + out.push(i); + } + } + } + out + } + pub fn language_implementation(&self, doc: &ProviderDocument, at: Pos) -> Vec { + let mut out = Vec::new(); + for (d, _bounds) in self.feature_documents(doc, Some(at)) { + let snapshot = d.clone(); + let d = Arc::new(d); + let results = self.document_calls(ProviderKind::Implementation, &snapshot, move |rt, t| { + rt.language_implementation(&d, at, t) + }); + for (p, items) in results { + for i in items { + let _ = &p; + out.push(i); + } + } + } + out + } + pub fn language_type_definition(&self, doc: &ProviderDocument, at: Pos) -> Vec { + let mut out = Vec::new(); + for (d, _bounds) in self.feature_documents(doc, Some(at)) { + let snapshot = d.clone(); + let d = Arc::new(d); + let results = self.document_calls(ProviderKind::TypeDefinition, &snapshot, move |rt, t| { + rt.language_type_definition(&d, at, t) + }); + for (p, items) in results { + for i in items { + let _ = &p; + out.push(i); + } + } + } + out + } + pub fn language_gutter_actions(&self, doc: &ProviderDocument) -> Vec<(String, GutterAction)> { + let mut out = Vec::new(); + for (d, bounds) in self.feature_documents(doc, None) { + let lines = d.text.split('\n').count() as u32; + let snapshot = d.clone(); + let d = Arc::new(d); + let results = self + .document_calls(ProviderKind::GutterActions, &snapshot, move |rt, t| rt.language_gutter_actions(&d, t)); + for (p, items) in results { + let commands: std::collections::HashSet<_> = + self.ui.items(super::COMMANDS, Some(p.id())).into_iter().map(|c| c.key).collect(); + for i in items { + if i.line < lines && bounds.lines(i.line, i.line) && commands.contains(&i.command) { + out.push((p.id().to_owned(), i)); + } + } + } + } + out + } +} + +impl PluginHost { + pub(crate) fn run_language_command( + &self, + plugin: &str, + command: &str, + argument: Option<&str>, + invocation: ide_plugin::Invocation, + ) -> IdeResult<()> { + if !self.ui.items(super::COMMANDS, Some(plugin)).iter().any(|c| c.key == command) { + return Err(ide_core::IdeError::not_found("The plugin did not declare this command")); + } + self.run_command(plugin, command, argument, invocation).map(|_| ()) + } +} + +pub(crate) fn signature_help(h: SignatureHelp) -> ide_lsp::types::SignatureHelp { + let signatures: Vec<_> = h + .signatures + .into_iter() + .take(100) + .map(|s| { + let len = s.label.encode_utf16().count() as u32; + let parameters = s + .parameters + .into_iter() + .take(100) + .map(|p| match p.label { + ParameterLabel::Offsets((a, b)) => (a.min(len), b.max(a).min(len)), + ParameterLabel::Text(t) => s + .label + .find(&t) + .map(|a| { + let start = s.label[..a].encode_utf16().count() as u32; + (start, start + t.encode_utf16().count() as u32) + }) + .unwrap_or((0, 0)), + }) + .collect(); + ide_lsp::types::Signature { + label: s.label, + documentation: s.documentation.map(|value| ide_lsp::types::Markup { kind: "markdown".into(), value }), + parameters, + } + }) + .collect(); + let active_signature = h.active_signature.min(signatures.len().saturating_sub(1) as u32); + let active_parameter = + h.active_parameter + .min(signatures.get(active_signature as usize).map(|s| s.parameters.len().saturating_sub(1)).unwrap_or(0) + as u32); + ide_lsp::types::SignatureHelp { signatures, active_signature, active_parameter } +} + +pub(crate) fn outline(items: Vec) -> Vec { + // A parent must precede its child, ruling out cycles and unbounded recursion. + let mut depths = vec![0usize; items.len()]; + let mut parents = vec![None; items.len()]; + for (idx, item) in items.iter().enumerate() { + if let Some(parent) = item.parent.map(|p| p as usize).filter(|p| { + *p < idx + && depths[*p] < 63 + && items[*p].range.start <= item.range.start + && item.range.end <= items[*p].range.end + }) { + parents[idx] = Some(parent); + depths[idx] = depths[parent] + 1; + } + } + let mut nodes: Vec<_> = items + .into_iter() + .map(|i| { + Some(ide_core::OutlineItem { + name: i.name, + detail: Some(i.detail).filter(|s| !s.is_empty()), + kind: serde_json::from_value(serde_json::Value::String(symbol_kind_name(&i.kind))) + .unwrap_or(ide_core::SymbolKind::Unknown), + range: i.range, + selection_range: i.selection_range, + children: vec![], + visibility: None, + }) + }) + .collect(); + let mut roots = Vec::new(); + for idx in (0..nodes.len()).rev() { + let mut node = nodes[idx].take().expect("once"); + node.children.reverse(); + if let Some(parent) = parents[idx] { + nodes[parent].as_mut().expect("parent precedes child").children.push(node); + } else { + roots.push(node); + } + } + roots.reverse(); + roots +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct FormatterChoice { + pub id: String, + pub name: String, + pub languages: Vec, +} +impl PluginHost { + pub fn formatter_choices(&self) -> Vec { + let mut out = std::collections::BTreeMap::::new(); + for (id, languages) in self.providers.formatter_declarations() { + let Ok(p) = self.get(&id) else { + continue; + }; + if !matches!(p.meta.lock().status, super::PluginStatus::Active | super::PluginStatus::Inactive) { + continue; + } + out.entry(id.clone()) + .or_insert_with(|| FormatterChoice { id, name: p.pkg.manifest.name.clone(), languages: vec![] }) + .languages + .extend(languages); + } + out.into_values() + .map(|mut c| { + c.languages.sort(); + c.languages.dedup(); + c + }) + .collect() + } +} + +fn symbol_kind_name(kind: &str) -> String { + let mut parts = kind.split('-'); + let mut out = parts.next().unwrap_or_default().to_owned(); + for part in parts { + let mut chars = part.chars(); + if let Some(c) = chars.next() { + out.extend(c.to_uppercase()); + out.extend(chars); + } + } + out +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn outline_uses_parent_order_containment_and_string_kinds() { + let range = Range::new(Pos::new(0, 0), Pos::new(0, 10)); + let item = |name: &str, parent| DocumentSymbol { + name: name.into(), + detail: String::new(), + kind: "enum-member".into(), + range, + selection_range: range, + parent, + }; + let out = + outline(vec![item("root", None), item("child", Some(0)), item("future", Some(3)), item("other", Some(2))]); + assert_eq!(out[0].children[0].kind, ide_core::SymbolKind::EnumMember); + assert_eq!(out[1].name, "future"); + assert_eq!(out[1].children[0].name, "other"); + } +} diff --git a/crates/ide-host/src/plugins/mcp.rs b/crates/ide-host/src/plugins/mcp.rs new file mode 100644 index 000000000..419f368d5 --- /dev/null +++ b/crates/ide-host/src/plugins/mcp.rs @@ -0,0 +1,176 @@ +//! Validated MCP declarations and dispatch through the ordinary plugin actor. +use super::{PluginHost, PluginStatus}; +use ide_core::{IdeError, IdeResult}; +use ide_extension::{Package, UiDecl, decode}; +use ide_plugin::Invocation; +use serde::Deserialize; + +pub(super) const POINT: &str = "mcpTools"; +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Declaration { + name: String, + description: String, + input_schema: std::path::PathBuf, + read_only: bool, + destructive: bool, +} +fn tool_name(plugin: &str, name: &str) -> String { + let plugin: String = plugin + .chars() + .map(|c| if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' { c } else { '_' }) + .collect(); + format!("{plugin}__{name}") +} +pub(super) fn declaration(pkg: &Package, value: toml::Value) -> Result { + pkg.manifest.needs_api("contributes.mcpTools", 4)?; + if pkg.manifest.wasm.is_none() { + return Err("mcpTools requires wasm".into()); + } + let d: Declaration = decode(POINT, value)?; + if d.name.is_empty() + || d.name.len() > 40 + || !d.name.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_') + || tool_name(pkg.id(), &d.name).len() > 64 + || d.description.trim().is_empty() + || d.description.len() > 4096 + { + return Err( + "mcpTools: name must match [a-z0-9_]{1,40}, namespaced name at most 64 bytes, description 1–4096 bytes" + .into(), + ); + } + let text = pkg.read_text(&d.input_schema)?; + if text.len() > 256 << 10 { + return Err("mcpTools: schema exceeds 256 KiB".into()); + } + let schema: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("mcpTools: {e}"))?; + if schema.get("type").and_then(serde_json::Value::as_str) != Some("object") { + return Err("mcpTools: inputSchema must describe an object".into()); + } + // No HTTP or filesystem resolver features: schemas must be self-contained. + jsonschema::validator_for(&schema).map_err(|e| format!("mcpTools: invalid JSON Schema: {e}"))?; + let mut out = UiDecl::new( + d.name.clone(), + d.description.clone(), + serde_json::json!({ + "name": d.name, "description": d.description, "inputSchema": schema, "readOnly": d.read_only, "destructive": d.destructive, + }), + ); + out.files.push(d.input_schema); + Ok(out) +} + +impl PluginHost { + /// Live declarations only, refreshed on every list/call. Ambiguous normalized names fail closed. + pub fn mcp_tools(&self, root: &std::path::Path) -> Vec<(String, String, ide_mcp::Tool)> { + let mut tools = vec![]; + for item in self.ui.items(POINT, None) { + let Ok(plugin) = self.get(&item.plugin_id) else { continue }; + if !matches!(plugin.status(), PluginStatus::Active | PluginStatus::Inactive) + || plugin.dev_root.as_deref().is_some_and(|r| r != root) + { + continue; + } + let d = &item.decl; + let Some(name) = d["name"].as_str() else { continue }; + tools.push(( + plugin.id().to_owned(), + name.to_owned(), + ide_mcp::Tool { + name: tool_name(plugin.id(), name), + description: format!( + "{} (plugin: {} [{}])", + d["description"].as_str().unwrap_or_default(), + plugin.manifest().name, + plugin.id() + ), + input_schema: d["inputSchema"].clone(), + output_schema: None, + read_only: d["readOnly"].as_bool().unwrap_or(false), + destructive: d["destructive"].as_bool().unwrap_or(true), + }, + )); + } + let mut counts = std::collections::HashMap::new(); + for (_, _, tool) in &tools { + *counts.entry(tool.name.clone()).or_insert(0) += 1; + } + tools.retain(|(_, _, tool)| counts[&tool.name] == 1); + tools + } + pub fn call_mcp_tool( + &self, + plugin: &str, + tool: &str, + arguments: &str, + invocation: Invocation, + cancel: &ide_core::CancellationToken, + ) -> IdeResult { + if arguments.len() > 1 << 20 { + return Err(IdeError::invalid("MCP arguments exceed 1 MiB")); + } + let root = + invocation.workspace_root.as_ref().ok_or_else(|| IdeError::invalid("MCP requires a project window"))?; + let (_, _, declared) = self + .mcp_tools(std::path::Path::new(root)) + .into_iter() + .find(|(p, n, _)| p == plugin && n == tool) + .ok_or_else(|| IdeError::not_found("plugin MCP tool is no longer available"))?; + let args: serde_json::Value = serde_json::from_str(arguments).map_err(|e| IdeError::invalid(e.to_string()))?; + let validator = + jsonschema::validator_for(&declared.input_schema).map_err(|e| IdeError::invalid(e.to_string()))?; + if !validator.is_valid(&args) { + return Err(IdeError::invalid("MCP arguments do not match inputSchema")); + } + let (tool, arguments) = (tool.to_owned(), arguments.to_owned()); + self.code_call_cancellable(plugin, invocation, "calling the MCP tool", Some(cancel), move |rt, inv| { + rt.mcp_call(&tool, &arguments, inv) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn validates_names_schema_files_versions_and_schema_keywords_at_install() { + let tmp = tempfile::tempdir().unwrap(); + let pkg = |api: &str| { + Package::new( + ide_extension::Manifest::parse(&format!( + "id='test.tools'\nname='Tools'\nversion='1.0.0'\nengines.openidea='{api}'\nwasm='plugin.wasm'" + )) + .unwrap(), + tmp.path().into(), + ) + }; + let decl = |name: &str, path: &str| { + toml::from_str::(&format!( + "name='{name}'\ndescription='Tool'\ninputSchema='{path}'\nreadOnly=true\ndestructive=false" + )) + .unwrap() + }; + std::fs::write( + tmp.path().join("args.json"), + r##"{"type":"object","$defs":{"text":{"type":"string"}},"properties":{"text":{"$ref":"#/$defs/text"}}}"##, + ) + .unwrap(); + assert_eq!(declaration(&pkg("^1.4"), decl("echo", "args.json")).unwrap().files.len(), 1); + assert!(declaration(&pkg("^1.3"), decl("echo", "args.json")).is_err()); + for name in ["", "Bad", "bad-name", &"x".repeat(41)] { + assert!(declaration(&pkg("^1.4"), decl(name, "args.json")).is_err()); + } + assert!(declaration(&pkg("^1.4"), decl("echo", "../args.json")).is_err()); + for schema in [ + r#"{"type":"nonsense"}"#, + r#"{"type":"object","required":42}"#, + r#"{"type":"object","properties":{"x":{"$ref":"file:///private/secret"}}}"#, + r#"{"type":"object","properties":{"x":{"$ref":"https://example.invalid/schema"}}}"#, + ] { + std::fs::write(tmp.path().join("args.json"), schema).unwrap(); + assert!(declaration(&pkg("^1.4"), decl("echo", "args.json")).is_err(), "{schema}"); + } + assert_eq!(tool_name("a.b-c", "echo"), "a_b_c__echo"); + } +} diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index be00ee3c1..1b339bbce 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -16,10 +16,13 @@ //! projects) and folders linked with `openidea-plugin link` (`links.json`). mod api; +mod auth; pub mod commit; +mod database; pub(crate) mod diagnostics; pub mod endpoints; mod event_paths; +mod execution; mod files_changed; mod hover; mod icon_extensions; @@ -27,17 +30,23 @@ mod icon_patterns; mod icon_settings; mod icons; pub mod index_ops; +pub(crate) mod injections; mod inlay_hints; mod install; pub mod intellij; +mod ipc; +pub mod language_features; mod market; +mod mcp; pub mod navigation; mod points; mod providers; pub mod remote; pub mod remote_host; mod run_detectors; +pub mod run_targets; mod semantic_tokens; +mod snippets; mod theme; mod types; pub mod ui_points; @@ -410,6 +419,11 @@ struct PendingUpdate { } impl PluginHost { + /// Current decoration snapshots for windows opening after a plugin published them. + pub fn decoration_sets(&self) -> std::collections::BTreeMap> { + self.shared.decoration_sets() + } + /// Creates the host; call [`load_all`](Self::load_all) to load installed plugins. pub fn new(plugins_dir: PathBuf, sink: Arc, limits: Limits) -> IdeResult { std::fs::create_dir_all(&plugins_dir)?; @@ -653,6 +667,7 @@ impl PluginHost { /// Unloads the project plugins of `root` (the project became untrusted or closed). pub fn restrict_project(&self, root: &Path) { + self.shared.withdraw_run_targets(None, Some(root)); let _guard = self.lifecycle.lock(); let ids: Vec<_> = self .plugins @@ -1080,7 +1095,9 @@ impl PluginHost { ledger.set(&subject, &cap, granted); if granted && let Capability::Process(program) = &cap - && let Some(bin) = self.resolve_binary(program) + && let Some(bin) = ledger + .program_choice(&subject, &cap) + .map_or_else(|| self.resolve_binary(program), |path| ledger::approve_program(path).ok()) { ledger.set_binary(&subject, &cap, bin); } @@ -1097,14 +1114,74 @@ impl PluginHost { /// The program a `process:` grant allows, checked against the file /// approved for it. Errors ask the user to confirm again. pub fn program_for(&self, id: &str, program: &str) -> IdeResult { + self.program_for_project(id, program, None) + } + + /// Resolves the approved executable for the invoking project, verifying its pinned content. + pub fn program_for_project(&self, id: &str, program: &str, root: Option<&Path>) -> IdeResult { let plugin = self.get(id)?; - let cap = Capability::Process(program.to_owned()); self.ledger .lock() - .check_binary(&plugin.subject(), &cap) + .check_binary_in_project(&plugin.subject(), &Capability::Process(program.into()), root) .map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e)) } + /// Records a user-approved executable choice; removing it revalidates the fallback or revokes the grant. + pub fn choose_program( + &self, + id: &str, + capability: &str, + root: Option<&Path>, + path: Option<&Path>, + ) -> IdeResult { + let _guard = self.lifecycle.lock(); + let plugin = self.get(id)?; + self.check_not_uninstalling(&plugin)?; + let cap: Capability = capability.parse().map_err(IdeError::invalid)?; + let Capability::Process(program) = &cap else { + return Err(IdeError::invalid("expected a process capability")); + }; + if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) { + return Err(IdeError::invalid("undeclared process capability")); + } + let binary = if let Some(path) = path { + Some(ledger::approve_program(path).map_err(IdeError::invalid)?) + } else if root.is_none() { + self.resolve_binary(program) + } else { + None + }; + { + let mut ledger = self.ledger.lock(); + let subject = plugin.subject(); + if ledger.get(&subject).is_none() { + return Err(IdeError::invalid("confirm this plugin's permissions first")); + } + if let Some(root) = root { + ledger.set_project_binary(&subject, &cap, root, binary); + if path.is_some() { + ledger.set(&subject, &cap, true); + } else if ledger.check_binary(&subject, &cap).is_err() { + ledger.set(&subject, &cap, false); + } + } else if let Some(binary) = binary { + ledger.set_program_choice(&subject, &cap, path); + ledger.set(&subject, &cap, true); + ledger.set_binary(&subject, &cap, binary); + } else { + ledger.set_program_choice(&subject, &cap, None); + ledger.set(&subject, &cap, false); + } + self.save_ledger(&ledger); + } + self.stop(&plugin); + if !self.state.lock().disabled.contains(id) { + self.start(&plugin); + } + self.changed(); + self.info_of(id) + } + fn check_uninstall_pending(&self, id: &str) -> IdeResult<()> { if install::uninstall_pending(&self.plugins_dir, id)? { return Err(IdeError::unavailable(std::io::Error::from(std::io::ErrorKind::ResourceBusy).to_string())); @@ -1151,6 +1228,7 @@ impl PluginHost { } self.stop(&plugin); let cleanup = || -> std::io::Result<()> { + self.remove_database_sources(id).map_err(|e| std::io::Error::other(e.message))?; install::check_cleanup_parent(&self.plugins_dir, &self.shared.data_dir.join(id))?; install::check_cleanup_parent(&self.plugins_dir, plugin.dir())?; install::check_not_symlink(&self.shared.data_dir.join(id))?; @@ -1342,6 +1420,17 @@ impl PluginHost { invocation: Invocation, what: &str, f: impl Fn(&mut Runtime, Invocation) -> Result + Clone + Send + 'static, + ) -> IdeResult { + self.code_call_cancellable(id, invocation, what, None, f) + } + + fn code_call_cancellable( + &self, + id: &str, + invocation: Invocation, + what: &str, + cancel: Option<&ide_core::CancellationToken>, + f: impl Fn(&mut Runtime, Invocation) -> Result + Clone + Send + 'static, ) -> IdeResult { let plugin = self.get(id)?; let client = invocation.client.clone(); @@ -1383,7 +1472,14 @@ impl PluginHost { } let started = Instant::now(); let (call, inv) = (f.clone(), invocation.clone()); - let result = actor.call(None, move |rt| call(rt, inv)); + let result = match cancel { + Some(cancel) => actor.call_cancellable(cancel, Duration::from_millis(50), move |rt| call(rt, inv)), + None => actor.call(None, move |rt| call(rt, inv)), + }; + if matches!(result, Err(ActorError::Cancelled)) { + self.reap(&plugin, &actor, client.as_deref()); + return Err(IdeError::cancelled()); + } if !matches!(result, Err(ActorError::Stopped | ActorError::SoftTimeout | ActorError::Cancelled)) { let crashed = matches!(result, Err(ActorError::Call(CallError::Trap(_)))); // Cancelling or a missing grant is not the plugin failing. @@ -1457,7 +1553,12 @@ impl PluginHost { /// Plugins' `runDetectors`, for [`ide_run::set_external_detection`]. pub fn run_detection(&self) -> Arc { - Arc::new(run_detectors::PluginRunDetection { store: self.run_detectors.clone(), pins: self.pins.clone() }) + Arc::new(run_detectors::PluginRunDetection { + store: self.run_detectors.clone(), + dynamic: self.shared.run_configurations.clone(), + pins: self.pins.clone(), + project_pins: self.shared.project_pins.clone(), + }) } /// The syntax of plugin languages, for [`ide_language::set_external_syntax`]. @@ -1513,6 +1614,10 @@ impl PluginHost { } /// Plugin context keys in effect for window `client`: global keys overlaid with its own. + pub fn status_snapshot(&self, client: &str) -> Vec { + self.shared.status_snapshot(client) + } + pub fn context_keys(&self, client: Option<&str>) -> Vec { self.shared.context_keys(client) } @@ -1543,6 +1648,10 @@ impl PluginHost { /// A window answered a plugin's `CommitRequested`; false when the call no /// longer waits. + pub fn answer_command(&self, client: &str, request: &str, answer: CommandAnswer) -> bool { + request.parse().is_ok_and(|id| self.shared.answer_command(client, id, answer)) + } + pub fn answer_commit(&self, request_id: &str, answer: CommitAnswer) -> bool { request_id.parse().is_ok_and(|id| self.shared.answer_commit(id, answer)) } @@ -1632,6 +1741,30 @@ impl PluginHost { /// Queues workbench events from window `client` for the plugins that want /// them; [`flush_events`](Self::flush_events) delivers them in batches. + pub fn post_window_events(&self, client: &str, events: Vec, editor: Option) { + let mut editors = self.shared.event_editors.write(); + if let Some(editor) = editor { + editors.insert(client.into(), editor.context()); + } else { + editors.remove(client); + } + drop(editors); + self.post_events( + Some(client), + events + .into_iter() + .filter(|e| { + !matches!( + e, + WorkbenchEvent::RunExited(_) + | WorkbenchEvent::TerminalExited(_) + | WorkbenchEvent::ContentTabAction(_) + ) + }) + .collect(), + ); + } + pub fn post_events(&self, client: Option<&str>, events: Vec) { self.post_events_from(client, events, None); } @@ -1661,7 +1794,13 @@ impl PluginHost { .as_ref() .is_none_or(|root| client.and_then(|c| self.shared.project_of(c)).as_ref() == Some(root)) }) - .cloned() + .filter_map(|e| match e { + WorkbenchEvent::SettingsChanged(_) => { + let keys = self.shared.changed_settings(&p.pkg, client); + (!keys.is_empty()).then_some(WorkbenchEvent::SettingsChanged(keys)) + } + _ => Some(e.clone()), + }) .collect(); let wanted = Self::filter_path_events(&self.shared, &p, client, &wanted, &subs); if wanted.is_empty() { @@ -1682,7 +1821,15 @@ impl PluginHost { let excess = queue.events.len().saturating_sub(MAX_QUEUED_EVENTS); let mut remaining = excess; queue.events.retain(|event| { - if remaining > 0 && !matches!(event, WorkbenchEvent::FilesChanged(_)) { + if remaining > 0 + && !matches!( + event, + WorkbenchEvent::FilesChanged(_) + | WorkbenchEvent::RunExited(_) + | WorkbenchEvent::TerminalExited(_) + | WorkbenchEvent::ContentTabAction(_) + ) + { remaining -= 1; false } else { @@ -1809,9 +1956,10 @@ impl PluginHost { let invocation = Invocation { workspace_root: client.as_deref().and_then(|c| self.shared.project_of(c)), client: client.clone(), - editor: None, + editor: client.as_deref().and_then(|c| self.shared.event_editors.read().get(c).cloned()), selected_paths: Vec::new(), global_settings: false, + command_chain: Vec::new(), }; let kinds: BTreeSet = queue.events.iter().map(WorkbenchEvent::kind).collect(); let filesystem = kinds.contains(&EventKind::FilesChanged); @@ -1823,6 +1971,9 @@ impl PluginHost { if batches.running.as_ref() == Some(&kinds) && !kinds.contains(&EventKind::Scheduled) && !kinds.contains(&EventKind::FilesChanged) + && !kinds.iter().any(|k| { + matches!(k, EventKind::RunExited | EventKind::TerminalExited | EventKind::ContentTabAction) + }) { actor.cancel_soft(); } @@ -2599,8 +2750,21 @@ impl PluginHost { /// pick up a change on their next lookup. fn refresh_pins(&self, ledger: &Ledger) { let mut pins = HashMap::new(); + let mut project_pins = HashMap::new(); for plugin in self.plugins.read().values() { let subject = plugin.subject(); + if let Some(grant) = ledger.get(&subject) { + for (root, binaries) in &grant.project_binaries { + for (cap, bin) in binaries { + if grant.granted.contains(cap) { + project_pins.insert( + (plugin.id().to_owned(), cap.clone(), root.clone()), + ProgramPin { path: bin.path.clone(), hash: bin.hash.clone() }, + ); + } + } + } + } let declared = plugin.manifest().capabilities.all().cloned().chain(plugin.meta.lock().declared_extra.clone()); for cap in declared.filter(|c| matches!(c, Capability::Process(_))) { @@ -2612,6 +2776,7 @@ impl PluginHost { } } } + *self.shared.project_pins.write() = project_pins; let mut current = self.pins.write(); if *current != pins { *current = pins; @@ -2665,12 +2830,26 @@ impl PluginHost { fn record_consent(&self, request: &Request<'_>, consent: &Consent) { let accepted: Vec = request.optional.iter().filter(|c| consent.accepts(c)).cloned().collect(); + let choices: BTreeMap<_, _> = { + let ledger = self.ledger.lock(); + request + .required + .iter() + .chain(&accepted) + .filter_map(|cap| { + ledger.program_choice(&request.subject, cap).map(|p| (cap.to_string(), p.to_path_buf())) + }) + .collect() + }; let binaries: BTreeMap = request .required .iter() .chain(&accepted) .filter_map(|c| match c { - Capability::Process(program) => self.resolve_binary(program).map(|b| (c.to_string(), b)), + Capability::Process(program) => choices + .get(&c.to_string()) + .map_or_else(|| self.resolve_binary(program), |path| ledger::approve_program(path).ok()) + .map(|b| (c.to_string(), b)), _ => None, }) .collect(); diff --git a/crates/ide-host/src/plugins/navigation.rs b/crates/ide-host/src/plugins/navigation.rs index 070c1f7d6..9e8449c28 100644 --- a/crates/ide-host/src/plugins/navigation.rs +++ b/crates/ide-host/src/plugins/navigation.rs @@ -197,6 +197,7 @@ impl PluginHost { /// navigation_at. Call off the UI thread, in small cancellable batches. pub fn index_navigation(&self, root: &Path, snapshot: Snapshot<'_>, invocation: Invocation) -> IdeResult<()> { let Snapshot { path, version, text, symbols, scope } = snapshot; + let path = dunce::simplified(path); if !(self.project_policy.read())(root) { return Err(IdeError::unavailable("project is not trusted")); } diff --git a/crates/ide-host/src/plugins/points.rs b/crates/ide-host/src/plugins/points.rs index 13ed6d29f..4575de514 100644 --- a/crates/ide-host/src/plugins/points.rs +++ b/crates/ide-host/src/plugins/points.rs @@ -20,15 +20,23 @@ pub struct CommandDecl { pub id: String, pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub category: Option, /// Enablement, evaluated by the client against context keys (`editorLang == rust`). #[serde(default, skip_serializing_if = "Option::is_none")] pub when: Option, } -fn command(_pkg: &Package, decl: toml::Value) -> Result { +fn command(pkg: &Package, decl: toml::Value) -> Result { let c: CommandDecl = decode(COMMANDS, decl)?; validate_item_id(&c.id, "command id")?; + if let Some(icon) = &c.icon { + pkg.manifest.needs_api("contributes.commands.icon", 4)?; + if icon.is_empty() || icon.len() > 256 { + return Err("command icon: expected 1–256 bytes".into()); + } + } if c.title.trim().is_empty() { return Err(format!("command {:?} has an empty title", c.id)); } @@ -40,7 +48,14 @@ fn command(_pkg: &Package, decl: toml::Value) -> Result { } let c = CommandDecl { category: c.category.filter(|x| !x.trim().is_empty()), ..c }; let json = serde_json::to_value(&c).map_err(|e| e.to_string())?; - Ok(UiDecl::new(c.id.clone(), c.title, json)) + let mut out = UiDecl::new(c.id.clone(), c.title, json); + if let Some(icon) = c.icon.filter(|s| s.ends_with(".svg")) { + let path = std::path::PathBuf::from(icon); + let svg = super::icons::read_icon(pkg, &path, super::icons::MAX_SVG)?; + out.files.push(path); + out.assets.push((format!("commandIcon/{}", c.id), svg)); + } + Ok(out) } /// Registers every first-party extension point. @@ -60,6 +75,13 @@ pub fn register_builtin_points( registry.register(Arc::new(super::providers::ProvidersPoint::new(providers.clone())))?; registry.register(Arc::new(UiPoint::new(super::providers::SECRETS_POINT, ui.clone(), super::providers::secret)))?; registry.register(Arc::new(UiPoint::new(super::endpoints::POINT, ui.clone(), super::endpoints::declaration)))?; + registry.register(Arc::new(UiPoint::new( + "runConfigurations", + ui.clone(), + super::execution::configurations_declaration, + )))?; + registry.register(Arc::new(UiPoint::new("snippets", ui.clone(), super::snippets::declaration)))?; + registry.register(Arc::new(UiPoint::new(super::mcp::POINT, ui.clone(), super::mcp::declaration)))?; use super::ui_points as p; registry.register(Arc::new(UiPoint::new(p::MENUS, ui.clone(), p::menu)))?; registry.register(Arc::new(UiPoint::new(p::KEYMAPS, ui.clone(), p::keymap)))?; diff --git a/crates/ide-host/src/plugins/providers.rs b/crates/ide-host/src/plugins/providers.rs index ad1d590f9..10a8ebf27 100644 --- a/crates/ide-host/src/plugins/providers.rs +++ b/crates/ide-host/src/plugins/providers.rs @@ -42,6 +42,19 @@ pub enum ProviderKind { Hover, InlayHints, Diagnostics, + Formatting, + SignatureHelp, + DocumentHighlights, + DocumentLinks, + CodeLenses, + SelectionRanges, + Folding, + Colors, + DocumentSymbols, + Implementation, + TypeDefinition, + GutterActions, + Injections, } #[derive(Clone, Debug, Deserialize)] @@ -94,9 +107,30 @@ impl Providers { } pub(super) fn diagnostic_generations(&self, language: &str) -> HashMap { - self.matching(ProviderKind::Diagnostics, language) + if self.any(ProviderKind::Injections) { + let mut out = HashMap::new(); + for (seq, plugin, kinds, _) in self.items.read().iter() { + if kinds.contains(&ProviderKind::Diagnostics) { + out.entry(plugin.clone()).or_insert(*seq); + } + } + out + } else { + self.matching(ProviderKind::Diagnostics, language) + } } + pub(super) fn injection_generations(&self, language: &str) -> HashMap { + self.matching(ProviderKind::Injections, language) + } + pub(super) fn formatter_declarations(&self) -> Vec<(String, Vec)> { + self.items + .read() + .iter() + .filter(|(_, _, k, _)| k.contains(&ProviderKind::Formatting)) + .map(|(_, p, _, l)| (p.clone(), l.clone())) + .collect() + } /// Whether any plugin provides `kind` (for any language). pub fn any(&self, kind: ProviderKind) -> bool { self.items.read().iter().any(|(_, _, kinds, _)| kinds.contains(&kind)) @@ -142,6 +176,26 @@ impl ExtensionPoint for ProvidersPoint { if d.kinds.contains(&ProviderKind::InlayHints) { pkg.manifest.needs_api("contributes.providers: inlay-hints", 3)?; } + if d.kinds.iter().any(|k| { + matches!( + k, + ProviderKind::Formatting + | ProviderKind::SignatureHelp + | ProviderKind::DocumentHighlights + | ProviderKind::DocumentLinks + | ProviderKind::CodeLenses + | ProviderKind::SelectionRanges + | ProviderKind::Folding + | ProviderKind::Colors + | ProviderKind::DocumentSymbols + | ProviderKind::Implementation + | ProviderKind::TypeDefinition + | ProviderKind::GutterActions + | ProviderKind::Injections + ) + }) { + pkg.manifest.needs_api("contributes.providers: language features and injections", 4)?; + } for l in &d.languages { if l != "*" && (l.is_empty() @@ -211,7 +265,7 @@ pub fn set_editor_providers(host: Option<&Arc>) { /// The plugin host whose providers take part, when any declares `kind`. pub(crate) fn editor_providers(kind: ProviderKind) -> Option> { let host = editor_plugin_host()?; - host.providers.any(kind).then_some(host) + (host.providers.any(kind) || host.providers.any(ProviderKind::Injections)).then_some(host) } pub(crate) fn editor_plugin_host() -> Option> { @@ -236,15 +290,46 @@ impl PluginHost { /// declares `kind` for `language`, in plugin order, leaving out those that /// failed or were late. Each plugin runs on its own thread, so the calls /// run at the same time: the wait is the slowest plugin's, not the sum. - fn edit_calls( + pub(super) fn edit_calls( &self, kind: ProviderKind, language: &str, f: impl FnOnce(&mut Runtime, Duration) -> Result + Clone + Send + 'static, + ) -> Vec<(Arc, R)> { + self.edit_calls_plugins(self.provider_plugins(kind, language), f) + } + + pub(super) fn document_calls( + &self, + kind: ProviderKind, + doc: &ProviderDocument, + f: impl FnOnce(&mut Runtime, Duration) -> Result + Clone + Send + 'static, + ) -> Vec<(Arc, R)> { + self.document_calls_for(kind, doc, None, f) + } + pub(super) fn document_calls_for( + &self, + kind: ProviderKind, + doc: &ProviderDocument, + owner: Option<&str>, + f: impl FnOnce(&mut Runtime, Duration) -> Result + Clone + Send + 'static, + ) -> Vec<(Arc, R)> { + let plugins = self + .provider_plugins(kind, &doc.language) + .into_iter() + .filter(|p| { + p.dev_root.as_ref().is_none_or(|r| doc.path.starts_with(r)) && owner.is_none_or(|id| p.id() == id) + }) + .collect(); + self.edit_calls_plugins(plugins, f) + } + fn edit_calls_plugins( + &self, + plugins: Vec>, + f: impl FnOnce(&mut Runtime, Duration) -> Result + Clone + Send + 'static, ) -> Vec<(Arc, R)> { let (soft, hard) = (self.limits.edit_soft, self.limits.edit_hard); - let queued: Vec<_> = self - .provider_plugins(kind, language) + let queued: Vec<_> = plugins .into_iter() .filter_map(|plugin| { let (actor, activated) = self.ensure_active(&plugin, None).ok()?; @@ -303,13 +388,24 @@ impl PluginHost { at: ide_core::Pos, trigger: Option<&str>, ) -> Vec<(String, CompletionItem)> { - let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned)); - self.edit_calls(ProviderKind::Completion, &doc.language, move |rt, timeout| { - rt.provide_completion(&d, at, t.as_deref(), timeout) - }) - .into_iter() - .flat_map(|(p, items)| items.into_iter().map(move |i| (p.id().to_owned(), i))) - .collect() + self.feature_documents(doc, Some(at)) + .into_iter() + .flat_map(|(doc, bounds)| { + let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned)); + self.document_calls(ProviderKind::Completion, &doc, move |rt, timeout| { + rt.provide_completion(&d, at, t.as_deref(), timeout) + }) + .into_iter() + .flat_map(|(p, items)| items.into_iter().map(move |i| (p.id().to_owned(), i))) + .filter_map(move |(p, mut i)| { + if i.replace.is_some_and(|r| !bounds.contains(r)) { + return None; + } + i.additional_edits.retain(|e| bounds.contains(e.range)); + Some((p, i)) + }) + }) + .collect() } pub fn provide_code_actions( @@ -391,10 +487,20 @@ impl PluginHost { project: Option<&std::path::Path>, language: &str, ) -> Vec> { - self.provider_plugins(ProviderKind::Diagnostics, language) - .into_iter() - .filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project) - .collect() + let plugins = if self.providers.any(ProviderKind::Injections) { + self.providers + .diagnostic_generations(language) + .keys() + .filter_map(|id| self.get(id).ok()) + .filter(|p| { + let m = p.meta.lock(); + matches!(m.status, PluginStatus::Active | PluginStatus::Inactive) && m.pending.is_empty() + }) + .collect() + } else { + self.provider_plugins(ProviderKind::Diagnostics, language) + }; + plugins.into_iter().filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project).collect() } pub(super) fn provide_project_diagnostics( @@ -402,11 +508,35 @@ impl PluginHost { project: Option<&std::path::Path>, doc: &ProviderDocument, cancel: &CancellationToken, + ) -> Vec<(String, Vec)> { + let mut out = std::collections::BTreeMap::>::new(); + for (d, bounds) in self.feature_documents(doc, None) { + if cancel.is_cancelled() { + break; + } + for (id, items) in self.provide_document_diagnostics(project, &d, cancel) { + out.entry(id).or_default().extend(items.into_iter().filter(|i| bounds.contains(i.range))); + } + } + out.into_iter() + .map(|(id, mut items)| { + items.truncate(5000); + (id, items) + }) + .collect() + } + + fn provide_document_diagnostics( + &self, + project: Option<&std::path::Path>, + doc: &ProviderDocument, + cancel: &CancellationToken, ) -> Vec<(String, Vec)> { std::thread::scope(|scope| { let calls: Vec<_> = self - .diagnostic_plugins(project, &doc.language) + .provider_plugins(ProviderKind::Diagnostics, &doc.language) .into_iter() + .filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project) .map(|p| { scope.spawn(move || { if cancel.is_cancelled() { @@ -432,10 +562,15 @@ impl PluginHost { } pub fn provide_hover(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec { - let d = Arc::new(doc.clone()); - self.edit_calls(ProviderKind::Hover, &doc.language, move |rt, timeout| rt.provide_hover(&d, at, timeout)) + self.feature_documents(doc, Some(at)) .into_iter() - .filter_map(|(_, hover)| hover) + .flat_map(|(doc, bounds)| { + let d = Arc::new(doc.clone()); + self.document_calls(ProviderKind::Hover, &doc, move |rt, timeout| rt.provide_hover(&d, at, timeout)) + .into_iter() + .filter_map(|(_, h)| h) + .filter(move |h| h.range.is_none_or(|r| bounds.contains(r))) + }) .collect() } diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index 74ee904dd..de1f3775c 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -22,9 +22,7 @@ //! keychain and are asked for with a `plugin_secret` request //! ([`SecretRequest`]) when a request header needs one (rule 4). -use super::{ - Consent, HostWorkspace, PluginEditor, PluginEvent, PluginHost, PluginSource, PluginStatusItem, SecretStore, -}; +use super::{Consent, HostWorkspace, PluginEditor, PluginEvent, PluginHost, PluginSource, SecretStore}; use crate::rpc::Dispatcher; use base64::Engine as _; use ide_core::{IdeError, IdeResult}; @@ -45,6 +43,32 @@ pub const SYNC_PACKAGES: &str = "plugin_sync_packages"; pub const EVENT_NOTIFICATION: &str = "plugin"; /// Server → client request ([`SecretRequest`] → `Option`). pub const SECRET_REQUEST: &str = "plugin_secret"; +/// Mutations of declared secrets, kept on the desktop. +pub const SECRET_WRITE: &str = "plugin_secret_write"; +/// OAuth listeners bound on the desktop, never the project server. +pub const AUTH_REQUEST: &str = "plugin_auth"; + +#[derive(Clone, Serialize, Deserialize)] +pub struct SecretWrite { + pub plugin: String, + pub name: String, + pub value: Option, +} + +/// A remote plugin's operation on a desktop OAuth listener. +#[derive(Clone, Serialize, Deserialize)] +pub struct AuthRequest { + pub plugin: String, + pub operation: AuthOperation, +} +#[derive(Clone, Serialize, Deserialize)] +pub enum AuthOperation { + Begin { path: String }, + Wait { id: u64, timeout_ms: u32 }, + Close { id: u64 }, + Clear, +} + /// Under the server's data directory. pub const PLUGINS_DIR: &str = "remote-plugins"; const INCOMING_DIR: &str = "remote-plugins.incoming"; @@ -61,6 +85,13 @@ pub struct RemotePackage { pub digest: String, /// Capabilities the user granted on the desktop. pub granted: Vec, + /// Disabled packages remain installed so their persistent project data survives. + #[serde(default = "enabled_by_default")] + pub enabled: bool, +} + +fn enabled_by_default() -> bool { + true } /// One file of a package (`path` relative to the package, `data` base64). @@ -107,8 +138,18 @@ pub fn package_digest(dir: &Path, files: &[PathBuf]) -> IdeResult { pub fn forwarded(event: &PluginEvent) -> bool { matches!( event, - PluginEvent::Notification { .. } + PluginEvent::RunTargetsChanged + | PluginEvent::WindowRequest { .. } + | PluginEvent::ContentTabReply { .. } + | PluginEvent::DecorationsChanged { .. } + | PluginEvent::EditorOpen { .. } + | PluginEvent::EditorRanges { .. } + | PluginEvent::Notification { .. } | PluginEvent::StatusItemChanged { .. } + | PluginEvent::WindowStatusItemChanged { .. } + | PluginEvent::ExecuteCommand { .. } + | PluginEvent::OutputChanged { .. } + | PluginEvent::RevealOutput { .. } | PluginEvent::ContextChanged { .. } | PluginEvent::TaskStarted { .. } | PluginEvent::TaskProgress { .. } @@ -144,12 +185,16 @@ impl PluginHost { plugins .iter() .filter(|p| p.dev_root.is_none() && !p.invalid && p.manifest().wasm.is_some()) - .filter(|p| matches!(p.status(), super::PluginStatus::Active | super::PluginStatus::Inactive)) .filter_map(|p| { let files = self.package_files(p).ok()?; let digest = package_digest(p.dir(), &files).ok()?; let granted = p.meta.lock().granted.iter().map(ToString::to_string).collect(); - Some(RemotePackage { id: p.id().to_owned(), digest, granted }) + Some(RemotePackage { + id: p.id().to_owned(), + digest, + granted, + enabled: matches!(p.status(), super::PluginStatus::Active | super::PluginStatus::Inactive), + }) }) .collect() } @@ -218,8 +263,19 @@ impl SecretStore for ClientSecrets { } } - fn set(&self, _: &str, _: &str, _: Option<&str>) -> Result<(), String> { - Err("secrets are stored on the desktop".into()) + fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String> { + let peer = self.peer.get().ok_or("desktop disconnected")?.clone(); + let request = SecretWrite { plugin: plugin.into(), name: name.into(), value: value.map(str::to_owned) }; + let ask = async { + tokio::time::timeout(SECRET_TIMEOUT, peer.request::<_, Result<(), String>>(SECRET_WRITE, request, None)) + .await + }; + let answer = if tokio::runtime::Handle::try_current().is_ok() { + tokio::task::block_in_place(|| self.runtime.block_on(ask)) + } else { + self.runtime.block_on(ask) + }; + answer.map_err(|_| "desktop timed out".to_owned())?.map_err(|e| e.to_string())? } } @@ -236,8 +292,10 @@ impl RemotePlugins { } }; let host = Arc::new(PluginHost::new(data_dir.join(PLUGINS_DIR), Arc::new(sink), Limits::default())?); + projects.set_plugin_host(&host); host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(projects))))); - host.set_secret_store(Arc::new(ClientSecrets { peer, runtime: projects.runtime.clone() })); + host.set_secret_store(Arc::new(ClientSecrets { peer: peer.clone(), runtime: projects.runtime.clone() })); + *host.shared.loopbacks.write() = Arc::new(ClientLoopbacks { peer, runtime: projects.runtime.clone() }); let trust = Arc::downgrade(projects); host.set_project_policy(Arc::new(move |root| { trust.upgrade().is_some_and(|h| h.trust.trusted(root).unwrap_or(false)) @@ -285,6 +343,14 @@ impl RemotePlugins { report.failed.push(format!("{}: invalid plugin id", pkg.id)); continue; } + if !pkg.enabled { + if self.host.info_of(&pkg.id).is_ok() + && let Err(e) = self.host.set_enabled(&pkg.id, false) + { + report.failed.push(format!("{}: {e}", pkg.id)); + } + continue; + } // The desktop asked the user, change of signer included. let consent = Consent { optional: pkg.granted.clone(), signer_change: true }; if self.current_digest(&pkg.id).as_deref() == Some(pkg.digest.as_str()) { @@ -320,6 +386,9 @@ impl RemotePlugins { if have != want || info.status == super::PluginStatus::NeedsConsent { self.host.grant(&pkg.id, consent)?; } + if info.status == super::PluginStatus::Disabled { + self.host.set_enabled(&pkg.id, true)?; + } Ok(()) } @@ -380,6 +449,7 @@ impl RemotePlugins { let paths: Option> = c.arg("paths")?; let settings_scope: Option = c.arg("settingsScope")?; let invocation = Invocation { + command_chain: c.arg::>>("commandChain")?.unwrap_or_default(), client: Some(c.window.clone()), workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()), editor: editor.map(PluginEditor::context), @@ -398,10 +468,59 @@ impl RemotePlugins { } }); let h = host.clone(); + d.add("plugin_parse_test_results", move |c| { + let h = h.clone(); + async move { + let host = alive(&h)?; + let config = c.arg::("config")?; + let chunk = c.arg("chunk")?; + blocking(move || host.parse_test_results(&c.window, &config, chunk)).await + } + }); + let h = host.clone(); + d.add("plugin_terminal_session_close", move |c| { + let h = h.clone(); + async move { alive(&h)?.close_terminal_session(&c.window, &c.arg::("operation")?) } + }); + let h = host.clone(); + d.add("plugin_terminal_session_spawn", move |c| { + let h = h.clone(); + async move { + let plugins = alive(&h)?; + let operation: String = c.arg("operation")?; + let cols = c.arg("cols")?; + let rows = c.arg("rows")?; + let output = c.channel("onOutput")?; + blocking(move || plugins.spawn_terminal_session(&c.host, &c.window, &operation, cols, rows, output)) + .await + } + }); + let h = host.clone(); + d.add("plugin_prepare_debug_run", move |c| { + let h = h.clone(); + async move { + let plugins = alive(&h)?; + let config = c.arg("config")?; + blocking(move || plugins.prepare_debug_run(&c.window, config)).await + } + }); + let h = host.clone(); + d.add("plugin_finish_operation", move |c| { + let h = h.clone(); + async move { + Ok(alive(&h)?.finish_operation(&c.window, &c.arg::("id")?, c.arg("status")?, c.arg("stopped")?)) + } + }); + let h = host.clone(); + d.add("plugin_content_tab_action", move |c| { + let h = h.clone(); + async move { alive(&h)?.content_tab_action(&c.window, &c.arg::("pluginId")?, c.arg("action")?) } + }); + let h = host.clone(); d.add("plugin_post_events", move |c| { let h = h.clone(); async move { - alive(&h)?.post_events(Some(&c.window), c.arg("events")?); + alive(&h)?.post_window_events(&c.window, c.arg("events")?, c.arg("editor")?); Ok(()) } }); @@ -411,15 +530,9 @@ impl RemotePlugins { async move { Ok(alive(&h)?.event_kinds().into_iter().collect::>()) } }); let h = host.clone(); - d.add("plugin_status_items", move |_| { + d.add("plugin_status_items", move |c| { let h = h.clone(); - async move { - Ok(alive(&h)? - .status_items() - .into_iter() - .map(|(plugin_id, id, item)| PluginStatusItem { plugin_id, id, item }) - .collect::>()) - } + async move { Ok(alive(&h)?.status_snapshot(&c.window)) } }); let h = host.clone(); d.add("plugin_context_keys", move |c| { @@ -437,6 +550,43 @@ impl RemotePlugins { async move { Ok(alive(&h)?.answer_prompt(&c.arg::("promptId")?, c.arg("answer")?)) } }); let h = host.clone(); + d.add("plugin_choose_program", move |c| { + let h = h.clone(); + async move { + let host = alive(&h)?; + let id: String = c.arg("pluginId")?; + let cap: String = c.arg("capability")?; + let scope: SettingsScope = c.arg("scope")?; + let path: Option = c.arg("path")?; + let root = match scope { + SettingsScope::Global => None, + SettingsScope::Project => Some(c.host.project(&c.window)?.root.clone()), + }; + blocking(move || host.choose_program(&id, &cap, root.as_deref(), path.as_deref())).await + } + }); + let h = host.clone(); + d.add("plugin_program", move |c| { + let h = h.clone(); + async move { + let host = alive(&h)?; + let id: String = c.arg("pluginId")?; + let program: String = c.arg("program")?; + let scope: SettingsScope = c.arg("scope")?; + let root = match scope { + SettingsScope::Global => None, + SettingsScope::Project => Some(c.host.project(&c.window)?.root.clone()), + }; + blocking(move || host.program_for_project(&id, &program, root.as_deref()).map(ide_core::IpcPath::from)) + .await + } + }); + let h = host.clone(); + d.add("plugin_answer_command", move |c| { + let h = h.clone(); + async move { Ok(alive(&h)?.answer_command(&c.window, &c.arg::("requestId")?, c.arg("answer")?)) } + }); + let h = host.clone(); d.add("plugin_answer_commit", move |c| { let h = h.clone(); async move { Ok(alive(&h)?.answer_commit(&c.arg::("requestId")?, c.arg("answer")?)) } @@ -456,6 +606,22 @@ impl RemotePlugins { .await } }); + d.add("mcp_set_open_editors", |c| async move { + let paths: Vec = c.arg("paths")?; + c.host.project(&c.window)?; + c.host.mcp_reports.set_open_editors(&c.window, paths.into_iter().map(PathBuf::from).collect()); + Ok(()) + }); + d.add("mcp_set_focused_editor", |c| async move { + c.host.project(&c.window)?; + c.host.mcp_reports.set_focused_editor(&c.window, c.arg("editor")?, c.arg("focused")?); + Ok(()) + }); + let h = host.clone(); + d.add("plugin_decoration_sets", move |_c| { + let h = h.clone(); + async move { Ok(alive(&h)?.decoration_sets()) } + }); let h = host.clone(); d.add("plugin_set_active_file", move |c| { let h = h.clone(); @@ -577,6 +743,21 @@ impl RemotePlugins { let h = h.clone(); async move { super::index_ops::plugin_endpoints(&c.host, alive(&h)?, &c.window, c.arg("pluginId")?).await } }); + let h = host.clone(); + d.add("plugin_run_targets", move |c| { + let h = h.clone(); + async move { super::run_targets::list(&c.host, alive(&h)?.as_ref(), &c.window) } + }); + let h = host.clone(); + d.add("plugin_answer_run_target", move |c| { + let h = h.clone(); + async move { + let plugins = alive(&h)?; + let id: String = c.arg("id")?; + let use_target = c.arg("useTarget")?; + blocking(move || super::run_targets::answer(&c.host, &plugins, &c.window, &id, use_target)).await + } + }); d.add("plugin_line_markers", |c| async move { crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await }); @@ -621,6 +802,123 @@ fn safe_relative(path: &str) -> IdeResult { Ok(p.to_path_buf()) } +struct ClientLoopbacks { + peer: Arc>, + runtime: tokio::runtime::Handle, +} +impl ClientLoopbacks { + fn request( + &self, + plugin: &str, + operation: AuthOperation, + cancel: Option, + ) -> Result { + let peer = self.peer.get().ok_or(ide_plugin::HostError::Cancelled)?.clone(); + let timeout = match &operation { + AuthOperation::Wait { timeout_ms, .. } => Duration::from_millis(u64::from(*timeout_ms)) + SECRET_TIMEOUT, + _ => SECRET_TIMEOUT, + }; + let request = AuthRequest { plugin: plugin.into(), operation }; + let ask = async { + tokio::time::timeout( + timeout, + peer.request::<_, Result>(AUTH_REQUEST, request, cancel), + ) + .await + .map_err(|_| ide_plugin::HostError::Failed("desktop OAuth request timed out".into()))? + .map_err(|e| ide_plugin::HostError::Failed(e.to_string()))? + }; + if tokio::runtime::Handle::try_current().is_ok() { + tokio::task::block_in_place(|| self.runtime.block_on(ask)) + } else { + self.runtime.block_on(ask) + } + } +} +impl super::auth::Service for ClientLoopbacks { + fn begin(&self, plugin: &str, _window: &str, path: &str) -> Result<(u64, String), ide_plugin::HostError> { + serde_json::from_value(self.request(plugin, AuthOperation::Begin { path: path.into() }, None)?) + .map_err(|e| ide_plugin::HostError::Failed(e.to_string())) + } + fn wait( + &self, + plugin: &str, + id: u64, + timeout: Duration, + cancel: &ide_core::CancellationToken, + ) -> Result, ide_plugin::HostError> { + let result = self.request( + plugin, + AuthOperation::Wait { id, timeout_ms: timeout.as_millis().min(u128::from(u32::MAX)) as u32 }, + Some(cancel.clone()), + ); + if cancel.is_cancelled() { + self.close(plugin, id); + return Err(ide_plugin::HostError::Cancelled); + } + serde_json::from_value(result?).map_err(|e| ide_plugin::HostError::Failed(e.to_string())) + } + fn close(&self, plugin: &str, id: u64) { + let _ = self.request(plugin, AuthOperation::Close { id }, None); + } + fn clear(&self, plugin: &str) { + let _ = self.request(plugin, AuthOperation::Clear, None); + } + fn forget_window(&self, _: &str) {} +} + +impl PluginHost { + /// A remote instance may mutate only declared secrets with a current grant. + pub fn remote_secret_write(&self, request: SecretWrite) -> Result<(), String> { + let plugin = self.get(&request.plugin).map_err(|e| e.message)?; + if !plugin.meta.lock().granted.iter().any(|g| g.covers(&ide_extension::Capability::Secrets)) { + return Err("secrets capability not granted".into()); + } + if !self.ui.items(super::providers::SECRETS_POINT, Some(plugin.id())).iter().any(|s| s.key == request.name) { + return Err("undeclared secret".into()); + } + if request.value.as_ref().is_some_and(|v| v.len() > 64 << 10) { + return Err("secret value too long".into()); + } + self.shared.secrets.read().set(plugin.id(), &request.name, request.value.as_deref()) + } + /// Serves a connected remote instance using this connection's actual window. + pub fn remote_auth(&self, window: &str, request: AuthRequest) -> Result { + if matches!(&request.operation, AuthOperation::Begin { .. } | AuthOperation::Wait { .. }) { + self.get(&request.plugin).map_err(|e| ide_plugin::HostError::NotFound(e.message))?; + } + let owner = format!("remote:{window}:{}", request.plugin); + let service = self.shared.loopbacks.read().clone(); + let result = match request.operation { + AuthOperation::Begin { path } => { + if self.shared.client_closed(window) { + return Err(ide_plugin::HostError::Cancelled); + } + serde_json::to_value(service.begin(&owner, window, &path)?) + } + AuthOperation::Wait { id, timeout_ms } => serde_json::to_value(service.wait( + &owner, + id, + Duration::from_millis(u64::from(timeout_ms)), + &ide_core::CancellationToken::new(), + )?), + AuthOperation::Close { id } => { + service.close(&owner, id); + Ok(serde_json::Value::Null) + } + AuthOperation::Clear => { + service.clear(&owner); + Ok(serde_json::Value::Null) + } + }; + result.map_err(|e| ide_plugin::HostError::Failed(e.to_string())) + } + /// A remote transport closed; abandon its browser listeners. + pub fn forget_remote_loopbacks(&self, window: &str) { + self.shared.loopbacks.read().forget_window(window); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ide-host/src/plugins/run_detectors.rs b/crates/ide-host/src/plugins/run_detectors.rs index c48b001e6..31b89a876 100644 --- a/crates/ide-host/src/plugins/run_detectors.rs +++ b/crates/ide-host/src/plugins/run_detectors.rs @@ -42,6 +42,9 @@ pub struct Detector { pub args: Vec, #[serde(default)] pub test: bool, + /// Optional output parser, including the API 1.4 plugin export. + #[serde(default)] + pub framework: Option, } /// Detectors of enabled plugins. @@ -85,6 +88,12 @@ impl ExtensionPoint for RunDetectorsPoint { fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result { let d: Detector = decode(POINT_ID, decl)?; + if d.framework.is_some() { + pkg.manifest.needs_api("runDetectors.framework", 4)?; + } + if d.framework.as_ref().is_some_and(|v| v.is_empty() || v.len() > 64) { + return Err("invalid test framework".into()); + } validate_item_id(&d.id, "run detector id")?; if d.name.trim().is_empty() || d.name.len() > 128 { return Err(format!("run detector {:?}: `name` must be 1–128 bytes", d.id)); @@ -110,48 +119,106 @@ impl ExtensionPoint for RunDetectorsPoint { } type Pins = Arc>>; +pub(super) type DynamicConfigurations = + Arc>>>; /// [`ExternalDetection`] over the detectors and the approved programs. pub struct PluginRunDetection { pub(crate) store: Arc, pub(crate) pins: Pins, + pub(crate) dynamic: DynamicConfigurations, + pub(crate) project_pins: super::api::ProjectPins, } -fn config_id(plugin: &str, detector: &str) -> String { +pub(super) fn config_id(plugin: &str, detector: &str) -> String { format!("{EXTERNAL_PREFIX}{plugin}:{detector}") } impl PluginRunDetection { - fn pin(&self, plugin: &str, d: &Detector) -> Option { + fn pin(&self, plugin: &str, d: &Detector, root: Option<&Path>) -> Option { let cap = Capability::Process(d.command.trim().to_owned()); - self.pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned() + root.and_then(|root| self.project_pins.read().get(&(plugin.into(), cap.to_string(), root.into())).cloned()) + .or_else(|| self.pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()) } } impl ExternalDetection for PluginRunDetection { fn detect(&self, root: &Path) -> Vec { let items = self.store.items.read().clone(); - items + let mut configs: Vec<_> = items .into_iter() - .filter(|(_, plugin, d)| self.pin(plugin, d).is_some() && d.files.iter().any(|f| root.join(f).exists())) + .filter(|(_, plugin, d)| { + self.pin(plugin, d, Some(root)).is_some() && d.files.iter().any(|f| root.join(f).exists()) + }) .map(|(_, plugin, d)| RunConfig { id: config_id(&plugin, &d.id), name: d.name.clone(), kind: "plugin".into(), command: d.command.trim().to_owned(), args: d.args.clone(), - cwd: None, + cwd: Some(root.to_path_buf().into()), env: Default::default(), detected: true, is_test: d.test, debug_adapter: None, allow_parallel: false, - extras: Default::default(), + extras: ide_run::RunExtras { + params: d.framework.clone().map(|f| [("framework".into(), f)].into()).unwrap_or_default(), + ..Default::default() + }, }) - .collect() + .collect(); + for ((plugin, project), entries) in self.dynamic.read().iter().filter(|((_, project), _)| project == root) { + for c in entries { + let cap = Capability::Process(c.program.clone()); + if !self.project_pins.read().contains_key(&(plugin.clone(), cap.to_string(), project.clone())) + && !self.pins.read().contains_key(&(plugin.clone(), cap.to_string())) + { + continue; + } + let mut config = RunConfig { + id: String::new(), + name: c.name.clone(), + kind: "plugin".into(), + command: c.program.clone(), + args: c.args.clone(), + cwd: Some(root.to_path_buf().into()), + env: Default::default(), + detected: true, + is_test: c.test, + debug_adapter: None, + allow_parallel: false, + extras: Default::default(), + }; + config.id = config_id(plugin, &c.id); + config.cwd = Some(c.cwd.as_ref().map(|p| root.join(p)).unwrap_or_else(|| root.to_path_buf()).into()); + if let Some(framework) = &c.framework { + config.extras.params.insert("framework".into(), framework.clone()); + } + configs.push(config); + } + } + configs } fn launch(&self, config: &RunConfig) -> Result { + let dynamic = self.dynamic.read(); + if let Some(((plugin, root), c)) = + dynamic.iter().flat_map(|(k, entries)| entries.iter().map(move |c| (k, c))).find(|((plugin, root), c)| { + config_id(plugin, &c.id) == config.id && config.cwd.as_ref().is_some_and(|cwd| cwd.starts_with(root)) + }) + { + let cap = Capability::Process(c.program.clone()).to_string(); + let pin = self + .project_pins + .read() + .get(&(plugin.clone(), cap.clone(), root.clone())) + .cloned() + .or_else(|| self.pins.read().get(&(plugin.clone(), cap)).cloned()) + .ok_or_else(|| "run program is no longer granted".to_owned())?; + return pin.verify().map(Path::to_path_buf); + } + drop(dynamic); let items = self.store.items.read(); let (plugin, d) = items .iter() @@ -159,7 +226,7 @@ impl ExternalDetection for PluginRunDetection { .map(|(_, p, d)| (p.clone(), d.clone())) .ok_or_else(|| "the plugin that detected this configuration is no longer enabled".to_owned())?; drop(items); - let pin = self.pin(&plugin, &d).ok_or_else(|| { + let pin = self.pin(&plugin, &d, config.cwd.as_ref().map(|p| p.as_ref())).ok_or_else(|| { format!("allow process:{} for plugin {plugin} in Settings › Plugins to run this configuration", d.command) })?; pin.verify().map(Path::to_path_buf) diff --git a/crates/ide-host/src/plugins/run_targets.rs b/crates/ide-host/src/plugins/run_targets.rs new file mode 100644 index 000000000..8b2b01325 --- /dev/null +++ b/crates/ide-host/src/plugins/run_targets.rs @@ -0,0 +1,150 @@ +//! Project-owned registrations, retained across idle instance reclamation. +use super::api::{PluginApi, Shared}; +use super::{PluginEvent, PluginHost}; +use ide_core::{IdeError, IdeResult}; +use ide_plugin::{HostError, PluginRunTarget}; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct RegisteredRunTarget { + /// Opaque selector id, namespaced by the host. + pub id: String, + pub plugin_id: String, + pub plugin_name: String, + pub target: PluginRunTarget, + pub prompt: bool, +} + +pub(super) type Targets = std::collections::BTreeMap<(PathBuf, String), RegisteredRunTarget>; +fn selector(plugin: &str, id: &str) -> String { + format!("plugin-target:{}:{plugin}{id}", plugin.len()) +} +impl Shared { + fn target_answer_file(&self, root: &Path, id: &str) -> PathBuf { + let key = format!("{}\0{id}", root.display()); + self.data_dir.join(".run-target-answers").join(blake3::hash(key.as_bytes()).to_hex().as_str()) + } + pub(super) fn withdraw_run_targets(&self, plugin: Option<&str>, root: Option<&Path>) { + let mut targets = self.run_targets.lock(); + let before = targets.len(); + targets.retain(|(r, _), t| !(plugin.is_none_or(|p| p == t.plugin_id) && root.is_none_or(|path| path == r))); + if before != targets.len() { + drop(targets); + self.sink.emit(None, PluginEvent::RunTargetsChanged); + } + } +} +impl PluginApi { + pub(super) fn register_target(&self, root: &Path, target: PluginRunTarget) -> Result<(), HostError> { + ide_remote::plugin_target::validate(&target).map_err(|e| HostError::Invalid(e.to_string()))?; + let workspace = self.shared.workspace().ok_or(HostError::Cancelled)?; + if !workspace.projects().iter().any(|r| r == root) { + return Err(HostError::NotFound("project is not open".into())); + } + if !workspace.trusted(root) { + return Err(HostError::Denied("project is not trusted".into())); + } + self.approved_program_in_project(&target.program, Some(root))?; + let blocked = self.shared.prompts_blocked.lock(); + if blocked.contains(self.pkg.id()) { + return Err(HostError::Cancelled); + } + let id = selector(self.pkg.id(), &target.id); + let mut targets = self.shared.run_targets.lock(); + if !targets.contains_key(&(root.into(), id.clone())) + && targets.iter().filter(|((r, _), t)| r == root && t.plugin_id == self.pkg.id()).count() >= 32 + { + return Err(HostError::Invalid("at most 32 targets per plugin and project".into())); + } + let prompt = !self.shared.target_answer_file(root, &id).exists(); + targets.insert( + (root.into(), id.clone()), + RegisteredRunTarget { + id, + plugin_id: self.pkg.id().into(), + plugin_name: self.pkg.manifest.name.clone(), + target, + prompt, + }, + ); + drop(targets); + drop(blocked); + self.shared.sink.emit(None, PluginEvent::RunTargetsChanged); + Ok(()) + } + pub(super) fn unregister_target(&self, root: &Path, id: &str) { + if self.shared.run_targets.lock().remove(&(root.into(), selector(self.pkg.id(), id))).is_some() { + self.shared.sink.emit(None, PluginEvent::RunTargetsChanged); + } + } +} +impl PluginHost { + /// Drops registrations when their project closes or loses trust. + pub fn withdraw_project_run_targets(&self, root: &Path) { + self.shared.withdraw_run_targets(None, Some(root)); + } + pub fn run_targets(&self, root: &Path) -> Vec { + self.shared.run_targets.lock().iter().filter(|((r, _), _)| r == root).map(|(_, t)| t.clone()).collect() + } + pub fn prepare_run_target( + &self, + root: &Path, + id: &str, + config: &ide_run::RunConfig, + ) -> IdeResult { + let target = self + .shared + .run_targets + .lock() + .get(&(root.into(), id.into())) + .cloned() + .ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?; + let program = self.program_for_project(&target.plugin_id, &target.target.program, Some(root))?; + ide_remote::plugin_target::prepare(root, &target.target, &program, config) + } +} + +pub fn list(host: &crate::Host, plugins: &PluginHost, window: &str) -> IdeResult> { + Ok(plugins.run_targets(&host.project(window)?.root)) +} + +/// The first answer wins across the project's windows. Only Use changes settings. +pub fn answer( + host: &crate::Host, + plugins: &PluginHost, + window: &str, + id: &str, + use_target: bool, +) -> IdeResult { + host.require_trust(window)?; + let project = host.project(window)?; + let mut targets = plugins.shared.run_targets.lock(); + let target = targets + .get_mut(&(project.root.clone(), id.into())) + .ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?; + let file = plugins.shared.target_answer_file(&project.root, id); + if file.exists() { + target.prompt = false; + return Ok(project.settings.effective()); + } + if use_target { + plugins.program_for_project(&target.plugin_id, &target.target.program, Some(&project.root))?; + let scope = ide_workspace::SettingsScope::Project; + for key in ["run.defaultTarget", "languages.php.runTarget", "languages.node.runTarget"] { + project.settings.set(scope, key, serde_json::json!(id))?; + for window in host.windows_for_root(&project.root) { + if let Ok(open) = host.project(&window) { + open.settings.reload(); + open.sink.emit(ide_core::IdeEvent::SettingsChanged { key: key.into() }); + } + } + } + } + std::fs::create_dir_all(file.parent().unwrap())?; + std::fs::write(file, if use_target { "use" } else { "not-now" })?; + target.prompt = false; + drop(targets); + plugins.shared.sink.emit(None, PluginEvent::RunTargetsChanged); + Ok(project.settings.effective()) +} diff --git a/crates/ide-host/src/plugins/snippets.rs b/crates/ide-host/src/plugins/snippets.rs new file mode 100644 index 000000000..dac8228fb --- /dev/null +++ b/crates/ide-host/src/plugins/snippets.rs @@ -0,0 +1,106 @@ +//! Named JSON snippets, retained as read-only UI declarations. +use ide_extension::{Package, UiDecl, decode}; +use serde::Deserialize; +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Decl { + language: String, + path: std::path::PathBuf, +} +#[derive(Deserialize)] +#[serde(untagged)] +enum Lines { + One(String), + Many(Vec), +} +impl Lines { + fn values(self) -> Vec { + match self { + Self::One(s) => vec![s], + Self::Many(v) => v, + } + } +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Snippet { + prefix: Lines, + body: Lines, + description: Option, +} + +pub(super) fn declaration(pkg: &Package, decl: toml::Value) -> Result { + pkg.manifest.needs_api("contributes.snippets", 4)?; + let d: Decl = decode("snippets", decl)?; + if d.language.is_empty() + || d.language.len() > 64 + || !d.language.chars().all(|c| c.is_ascii_alphanumeric() || "-_.+#".contains(c)) + { + return Err("snippets: invalid language id".into()); + } + let text = pkg.read_text(&d.path)?; + if text.len() > 1024 * 1024 { + return Err("snippets: file exceeds 1 MiB".into()); + } + let snippets: std::collections::BTreeMap = + serde_json::from_str(&text).map_err(|e| format!("snippets: {e}"))?; + if snippets.len() > 1000 { + return Err("snippets: at most 1000 entries per file".into()); + } + let mut entries = vec![]; + for (name, s) in snippets { + let prefixes = s.prefix.values(); + let body = s.body.values().join("\n"); + if name.is_empty() + || name.len() > 256 + || prefixes.is_empty() + || prefixes.len() > 32 + || prefixes.iter().any(|p| p.is_empty() || p.len() > 256) + || body.len() > 65536 + || s.description.as_ref().is_some_and(|s| s.len() > 4096) + { + return Err(format!("snippets: invalid entry {name:?}")); + } + entries.push(serde_json::json!({"name":name,"prefixes":prefixes,"body":body,"description":s.description.unwrap_or_default()})); + } + let key = d.path.to_string_lossy().into_owned(); + let mut out = UiDecl::new( + key.clone(), + d.language.clone(), + serde_json::json!({"source":key,"language":d.language,"entries":entries}), + ); + out.files.push(d.path); + Ok(out) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn validates_every_snippet_at_install_and_requires_api4() { + let dir = tempfile::tempdir().unwrap(); + let manifest = |api| { + ide_extension::Manifest::parse(&format!( + "id='test'\nname='Test'\nversion='1.0.0'\nengines.openidea='{api}'" + )) + .unwrap() + }; + let decl: toml::Value = toml::from_str("language='sql'\npath='snippets.json'").unwrap(); + std::fs::write( + dir.path().join("snippets.json"), + r#"{"Select":{"prefix":["sel","select"],"body":["SELECT ${1:column}","FROM $1$0"],"description":"Query"}}"#, + ) + .unwrap(); + let pkg = Package::new(manifest("^1.4"), dir.path().into()); + let out = declaration(&pkg, decl.clone()).unwrap(); + assert_eq!(out.json["entries"][0]["body"], "SELECT ${1:column}\nFROM $1$0"); + assert!(declaration(&Package::new(manifest("^1.3"), dir.path().into()), decl.clone()).is_err()); + for text in [ + r#"{"x":{"prefix":[],"body":"x"}}"#, + r#"{"x":{"prefix":"x","body":42}}"#, + r#"{"x":{"prefix":"x","body":"x","extra":1}}"#, + ] { + std::fs::write(dir.path().join("snippets.json"), text).unwrap(); + assert!(declaration(&pkg, decl.clone()).is_err()); + } + } +} diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs index df58404f3..a17a148fe 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -8,6 +8,7 @@ pub use ide_plugin::{ }; use serde::{Deserialize, Serialize}; use specta::Type; +use std::collections::BTreeMap; /// A line a view's `process` body printed. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -164,8 +165,33 @@ pub struct PluginUpdate { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(tag = "type", rename_all = "camelCase")] pub enum PluginEvent { + /// Project target registrations or remembered prompt answers changed. + RunTargetsChanged, + /// A window-owned operation; an empty request id queues an asynchronous run. + #[serde(rename_all = "camelCase")] + WindowRequest { plugin_id: String, request_id: String, request: WindowRequest }, + /// Reply to a content action in this window. + #[serde(rename_all = "camelCase")] + ContentTabReply { plugin_id: String, request_id: String, reply: ide_plugin::ContentTabReply }, + /// Plugins, their status or their contributions changed: re-fetch `plugin_list`. CommandsChanged, + /// Complete replacement of one plugin's project-scoped decorations. + #[serde(rename_all = "camelCase")] + DecorationsChanged { plugin_id: String, sets: Vec }, + /// Open a file in the invoking window. + EditorOpen { path: ide_core::IpcPath, selection: Option }, + /// Change selection or scrolling only in this window's already open editor. + EditorRanges { path: ide_core::IpcPath, ranges: Vec, reveal: bool }, + /// A bounded output channel snapshot, or withdrawal when the plugin stops. + #[serde(rename_all = "camelCase")] + OutputChanged { plugin_id: String, channel: String, text: Option }, + /// Reveal a read-only Output tab in the receiving window. + #[serde(rename_all = "camelCase")] + RevealOutput { plugin_id: String, channel: String, text: String }, + /// Execute an action through the receiving window and answer over IPC. + #[serde(rename_all = "camelCase")] + ExecuteCommand { request_id: String, id: String, arg: Option, chain: Vec }, /// Cached document associations were disposed (reload, disable or failure). DocumentIndexesChanged, /// The routes `plugin_id` reports for the project `root` may have changed: @@ -187,6 +213,9 @@ pub enum PluginEvent { /// A plugin's status bar item was set (`item`) or removed. #[serde(rename_all = "camelCase")] StatusItemChanged { plugin_id: String, id: String, item: Option }, + /// An override in the receiving window; removal exposes the global item. + #[serde(rename_all = "camelCase")] + WindowStatusItemChanged { plugin_id: String, id: String, item: Option }, /// A plugin's context key was set (`value`: JSON text) or removed, in the /// emitted window (every window when the client is `None`). #[serde(rename_all = "camelCase")] @@ -340,6 +369,8 @@ pub struct PackageFile { #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct PluginStatusItem { + /// Whether this item overrides the global item in the requesting window. + pub window: bool, pub plugin_id: String, pub id: String, pub item: StatusItem, @@ -388,3 +419,46 @@ pub struct PluginCommitCheckResult { /// The check could not run (the plugin failed, timed out or lacks `vcs.read`). pub error: Option, } + +/// The invoking window's response to a command request. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(tag = "kind", content = "value", rename_all = "camelCase")] +pub enum CommandAnswer { + Value(Option), + NotFound, + Failed(String), +} + +/// A plugin-owned editor or project-tree decoration set for one file. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DecorationSet { + pub root: ide_core::IpcPath, + pub path: ide_core::IpcPath, + pub set_id: String, + pub tree: bool, + pub items: Vec, + pub file: Option, +} + +/// A request that the invoking window acknowledges through plugin_answer_command. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum WindowRequest { + /// Connect and introspect using the normal Database action in this window. + DatabaseConnect { id: String }, + /// Open and acknowledge validation by the shared content editor. + ContentTab { tab: ide_plugin::ContentTab }, + #[serde(rename_all = "camelCase")] + Run { operation: String, config: Box, debug: bool }, + #[serde(rename_all = "camelCase")] + Terminal { + operation: String, + key: String, + title: String, + program: ide_core::IpcPath, + args: Vec, + env: BTreeMap, + cwd: ide_core::IpcPath, + }, +} diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index 9a1edc4e0..b8ca5f94f 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -76,8 +76,18 @@ pub const COMMIT_CHECKS: &str = "commitChecks"; const VIEW_ITEM_MENU: &str = "viewItem.context"; /// The toolbar above the commit message (Commit tool window and dialog). const COMMIT_MESSAGE_MENU: &str = "commit.message"; -const MENU_IDS: [&str; 6] = - ["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU, COMMIT_MESSAGE_MENU]; +const MENU_IDS: [&str; 10] = [ + "editor.context", + "projectView.context", + "editorTab.context", + "tools", + VIEW_ITEM_MENU, + COMMIT_MESSAGE_MENU, + "editor.title", + "mainToolbar", + "problems.context", + "searchResults.context", +]; const MAX_TITLE: usize = 256; const MAX_SETTINGS: usize = 200; const MAX_PATTERNS: usize = 16; @@ -135,6 +145,9 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result { return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", "))); } declared_command(pkg, MENUS, &d.command)?; + if ["editor.title", "mainToolbar", "problems.context", "searchResults.context"].contains(&d.menu.as_str()) { + pkg.manifest.needs_api("contributes.menus: language workbench menus", 4)?; + } if d.menu == COMMIT_MESSAGE_MENU { pkg.manifest.needs_api(&format!("contributes.menus: {COMMIT_MESSAGE_MENU}"), 3)?; } @@ -965,6 +978,20 @@ pub(super) fn locales(pkg: &Package, _decl: toml::Value) -> Result, ) -> IdeResult { - self.spawn_plugin_pty(host, plugin_id, window, output, || { + self.spawn_plugin_pty(host, plugin_id, window, output, None, || { self.web_pty_options(plugin_id, view, window, command) }) } @@ -154,19 +155,21 @@ impl PluginHost { (cols, rows): (u16, u16), output: crate::ops::Emit, ) -> IdeResult { - self.spawn_plugin_pty(host, plugin_id, window, output, || { + self.spawn_plugin_pty(host, plugin_id, window, output, None, || { self.log_tab_pty_options(plugin_id, key, window, on, (cols, rows)) }) } /// The PTY of a plugin, counted against its sessions and ended with its /// window, project or permission; `options` authorizes it. - fn spawn_plugin_pty( + #[allow(clippy::too_many_arguments)] + pub(super) fn spawn_plugin_pty( &self, host: &std::sync::Arc, plugin_id: &str, window: &str, output: crate::ops::Emit, + completion: Option) + Send + Sync>>, options: impl FnOnce() -> IdeResult, ) -> IdeResult { let _guard = self.lifecycle.lock(); @@ -181,10 +184,19 @@ impl PluginHost { return Err(IdeError::invalid("a plugin may own at most 8 PTYs")); } let cwd = options.cwd.0.clone(); + let previous = host.window_sink(window); + let sink: Arc = Arc::new(move |event: ide_core::IdeEvent| { + if let ide_core::IdeEvent::TerminalExited { code, .. } = &event + && let Some(completion) = &completion + { + completion(*code); + } + previous.emit(event); + }); let info = host.terminals.spawn_program( options, host.owner_window(window).to_owned(), - host.window_sink(window), + sink, Box::new(move |data| output(data)), )?; if !self @@ -258,7 +270,7 @@ impl PluginHost { /// Checks `command` against the plugin's current grant, the limits and /// the window's trusted project. - fn pty_options( + pub(super) fn pty_options( &self, plugin_id: &str, window: &str, @@ -309,7 +321,7 @@ impl PluginHost { if !workspace.trusted(&cwd) { return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted")); } - let program = self.program_for(plugin_id, &command.program)?; + let program = self.program_for_project(plugin_id, &command.program, Some(&cwd))?; Ok(ide_terminal::TerminalOptions { cwd: cwd.into(), shell: Some(program.to_string_lossy().into_owned()), diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index fafe557ab..f8b5e75f1 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -248,7 +248,7 @@ impl Dispatcher { .add("term_is_busy", |c| async move { ops::term::is_busy(&c.host, c.arg("id")?) }) .add("term_default_shell", |_| async move { Ok(ide_terminal::default_shell_path()) }) .add("term_available_shells", |_| async move { Ok(ide_terminal::available_shells()) }) - .add("run_configs", |c| async move { ops::run::configs(&c.host, &c.window) }) + .add("run_configs", |c| async move { ops::doc::blocking(move || ops::run::configs(&c.host, &c.window)).await }) .add("run_save_configs", |c| async move { ops::run::save_configs( &c.host, diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index f6aa475e8..603dbc77d 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -146,6 +146,9 @@ impl crate::Host { phpstan.interrupt(); } if !trusted { + if let Some(plugins) = self.plugin_host() { + plugins.withdraw_project_run_targets(&project.root); + } project.lsp.set_execution_allowed(false); } project.repos(); @@ -396,6 +399,8 @@ pub fn projectless_command_allowed(cmd: &str) -> bool { | "plugin_set_enabled" | "plugin_grant" | "plugin_set_capability" + | "plugin_choose_program" + | "plugin_program" | "plugin_roll_back" | "plugin_reload" ) diff --git a/crates/ide-host/tests/distribution.rs b/crates/ide-host/tests/distribution.rs index 1af8a1068..1c38a8bb1 100644 --- a/crates/ide-host/tests/distribution.rs +++ b/crates/ide-host/tests/distribution.rs @@ -59,6 +59,7 @@ fn fixture() -> Fixture { fn inv() -> Invocation { Invocation { + command_chain: Vec::new(), client: Some("main".into()), workspace_root: None, editor: None, diff --git a/crates/ide-host/tests/plugin_data_tools.rs b/crates/ide-host/tests/plugin_data_tools.rs new file mode 100644 index 000000000..3721cf935 --- /dev/null +++ b/crates/ide-host/tests/plugin_data_tools.rs @@ -0,0 +1,262 @@ +//! Database and MCP calls through a real sandboxed API 1.4 component. +use ide_core::{CancellationToken, IdeEvent, event::McpUiRequest}; +use ide_host::{ + Host, HostConfig, + mcp::Registry, + ops, + plugins::{Consent, HostWorkspace, PluginEvent, PluginHost, WindowRequest}, +}; +use ide_mcp::{ToolCallError, ToolRegistry}; +use ide_plugin::{Invocation, Limits}; +use std::{ + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::Duration, +}; +const ID: &str = "test.data-tools"; +fn fixture() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/data-tools") +} +struct Harness { + host: Arc, + plugins: Arc, + root: PathBuf, + events: Arc>>, + requests: Arc>>, + _tmp: tempfile::TempDir, +} +impl Harness { + fn new() -> Self { + ide_ssh::secrets::testing::use_memory_store(); + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap().join("project"); + std::fs::create_dir(&root).unwrap(); + let events = Arc::new(Mutex::new(vec![])); + let out = events.clone(); + 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("local"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(move |w| { + let (w, out) = (w.to_owned(), out.clone()); + Arc::new(move |e| out.lock().unwrap().push((w.clone(), e))) + }), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + std::fs::write(root.join("db.sqlite"), "").unwrap(); + host.open_project("main", &root).unwrap(); + host.open_project("second", &root).unwrap(); + let requests = Arc::new(Mutex::new(vec![])); + let out = requests.clone(); + let plugins = Arc::new( + PluginHost::new( + tmp.path().join("plugins"), + Arc::new(move |w: Option<&str>, e| out.lock().unwrap().push((w.unwrap_or_default().to_owned(), e))), + Limits { timeout: Duration::from_millis(250), ..Default::default() }, + ) + .unwrap(), + ); + plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host))))); + host.set_plugin_host(&plugins); + plugins.install_from_dir(&fixture(), &Consent::all()).unwrap(); + Self { host, plugins, root, events, requests, _tmp: tmp } + } + fn invocation(&self) -> Invocation { + Invocation { client: Some("main".into()), workspace_root: Some(self.root.clone()), ..Default::default() } + } + fn run(&self, id: &str, arg: Option<&str>) -> ide_core::IdeResult> { + self.plugins.run_command(ID, id, arg, self.invocation()) + } + fn source(&self) -> String { + serde_json::json!({"id":"local","name":"Example","driver":"sqlite","database":self.root.join("db.sqlite").to_string_lossy(),"options":[]}).to_string() + } +} +#[tokio::test(flavor = "multi_thread")] +async fn database_calls_enforce_grants_trust_ownership_and_uninstall_lifetime() { + let f = Harness::new(); + assert!(f.run("sources", None).is_err()); + f.host.set_project_trust(&f.root, true, false).await.unwrap(); + let source = f.source(); + f.run("upsert", Some(&source)).unwrap(); + assert_eq!(f.run("sources", None).unwrap().as_deref(), Some("[\"local\"]")); + f.run("credential", Some("never-on-disk")).unwrap(); + let source = f.host.project("main").unwrap().db.plugin_source(ID, "local").unwrap(); + let store = ide_db::secrets::SecretStore::new(f._tmp.path().join("data/db-passwords.json"), &f.root); + assert_eq!(store.get(&source.id).as_deref(), Some("never-on-disk")); + let file = f.root.join(".openidea/data-sources.toml"); + assert!(!std::fs::read_to_string(&file).unwrap().contains("never-on-disk")); + for command in ["remove", "connect"] { + assert!(f.run(command, Some("foreign")).is_err()); + } + assert!(f.events.lock().unwrap().iter().any(|(w, e)| w == "second" && matches!(e, IdeEvent::DbSourcesChanged))); + f.plugins.set_enabled(ID, false).unwrap(); + assert!(file.exists()); + f.plugins.set_enabled(ID, true).unwrap(); + assert!(f.host.project("main").unwrap().db.plugin_source(ID, "local").is_ok()); + f.host.set_mcp_executable(Some(PathBuf::from("/app/openidea"))); + let command: serde_json::Value = serde_json::from_str(&f.run("stdio", None).unwrap().unwrap()).unwrap(); + assert_eq!(command[1], "--mcp-stdio"); + f.host.set_mcp_executable(None); + assert_eq!(f.run("stdio", None).unwrap().as_deref(), Some("null")); + // Closed project cleanup uses the host-owned journal, not only current windows. + f.host.close_project("main"); + f.host.close_project("second"); + f.plugins.uninstall(ID).unwrap(); + assert!(ide_db::source::load(&f.root).unwrap().is_empty()); + assert!(store.get(&source.id).is_none()); +} +#[tokio::test(flavor = "multi_thread")] +async fn database_connect_asks_only_the_invoking_window_and_uses_normal_connection_flow() { + let f = Harness::new(); + f.host.set_project_trust(&f.root, true, false).await.unwrap(); + f.run("upsert", Some(&f.source())).unwrap(); + let (plugins, invocation) = (f.plugins.clone(), f.invocation()); + let run = tokio::task::spawn_blocking(move || plugins.run_command(ID, "connect", None, invocation)); + let (window, request, source) = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let found = f.requests.lock().unwrap().iter().find_map(|(w, e)| match e { + PluginEvent::WindowRequest { request_id, request: WindowRequest::DatabaseConnect { id }, .. } => { + Some((w.clone(), request_id.clone(), id.clone())) + } + _ => None, + }); + if let Some(v) = found { + break v; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(window, "main"); + assert!(!f.plugins.answer_command("second", &request, ide_host::plugins::CommandAnswer::Value(None))); + tokio::time::sleep(Duration::from_millis(350)).await; // Password entry can outlive the 250 ms command budget. + ops::db::connect(&f.host, "main", &source).await.unwrap(); + f.plugins.answer_command("main", &request, ide_host::plugins::CommandAnswer::Value(None)); + run.await.unwrap().unwrap(); + assert!(!f.host.project("main").unwrap().db.connected().is_empty()); +} +#[tokio::test(flavor = "multi_thread")] +async fn mcp_listing_dispatch_confirmation_and_lifecycle_use_plugin_authority() { + let f = Harness::new(); + let registry = Arc::new(Registry::new(f.host.clone())); + assert!(!registry.tools(&f.root).unwrap().iter().any(|t| t.name == "test_data_tools__write")); + assert!(registry.call(&f.root, "test_data_tools__write", "{}", CancellationToken::new()).is_err()); + f.host.set_project_trust(&f.root, true, false).await.unwrap(); + let tools = registry.tools(&f.root).unwrap(); + let name = "test_data_tools__echo"; + assert!(tools.iter().any(|t| t.name == name && t.description.contains(ID))); + assert!(matches!( + registry.call(&f.root, name, r#"{"text":12}"#, CancellationToken::new()), + Err(ToolCallError::Failed(_)) + )); + let result = registry.call(&f.root, name, r#"{"text":"hello"}"#, CancellationToken::new()).unwrap(); + assert!(result.contains("hello")); + let denied = registry.call(&f.root, "test_data_tools__denied", "{}", CancellationToken::new()).unwrap_err(); + assert!(format!("{denied:?}").contains("process:sh")); + f.host.mcp_requests.set_ready("main", true); + let (r, root) = (registry.clone(), f.root.clone()); + let call = + tokio::task::spawn_blocking(move || r.call(&root, "test_data_tools__erase", "{}", CancellationToken::new())); + let request = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(id) = f.events.lock().unwrap().iter().find_map(|(_, e)| match e { + IdeEvent::McpRequest { id, request: McpUiRequest::Confirm { .. } } => Some(*id), + _ => None, + }) { + break id; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!(f.host.mcp_requests.answer("second", request, true, None).is_err()); + f.host.mcp_requests.answer("main", request, false, None).unwrap(); + assert!(call.await.unwrap().is_err()); + f.plugins.set_enabled(ID, false).unwrap(); + assert!(!registry.tools(&f.root).unwrap().iter().any(|t| t.name == name)); + assert!(registry.call(&f.root, name, "{}", CancellationToken::new()).is_err()); + f.plugins.set_enabled(ID, true).unwrap(); + assert!(registry.tools(&f.root).unwrap().iter().any(|t| t.name == name)); + let (r, root) = (registry.clone(), f.root.clone()); + let spin = + tokio::task::spawn_blocking(move || r.call(&root, "test_data_tools__spin", "{}", CancellationToken::new())); + assert!(spin.await.unwrap().is_err(), "command budget interrupts wasm"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn database_imports_are_denied_without_the_optional_grant() { + let f = Harness::new(); + f.host.set_project_trust(&f.root, true, false).await.unwrap(); + f.plugins.set_capability(ID, "database.sources", false).unwrap(); + for command in ["upsert", "remove", "sources", "credential", "connect"] { + let err = f.run(command, Some(&f.source())).unwrap_err(); + assert!(err.message.contains("database.sources"), "{command}: {err}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn official_api_examples_install_and_render_real_component_contributions() { + let f = Harness::new(); + f.host.set_project_trust(&f.root, true, false).await.unwrap(); + let official = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/official"); + for name in ["blame-annotations", "todo-finder"] { + f.plugins.install_from_dir(&official.join(name), &Consent::all()).unwrap(); + } + std::fs::write(f.root.join("notes.txt"), "TODO example\n").unwrap(); + let doc = ide_plugin::ProviderDocument { + path: f.root.join("notes.txt"), + language: "text".into(), + version: 1, + text: "TODO example\n".into(), + }; + assert!(eventually(|| { + f.plugins + .language_code_lenses(&doc) + .iter() + .any(|(owner, lens)| owner == "openidea.blame-annotations" && lens.command == "annotate") + })); + f.plugins.run_command("openidea.todo-finder", "find", None, f.invocation()).unwrap(); + let view = f.plugins.render_view("openidea.todo-finder", "todos", None, f.invocation()).unwrap(); + let ide_plugin::ViewBody::List(items) = view.body else { panic!("expected TODO list") }; + assert_eq!(items.len(), 1); + assert!(items[0].label.contains("TODO example")); + #[cfg(unix)] + { + let path = std::env::join_paths([official.join("container-runner"), official.join("test-runner")]).unwrap(); + f.plugins.set_program_path(Arc::new(move || Some(path.clone()))); + for name in ["container-runner", "test-runner"] { + f.plugins.install_from_dir(&official.join(name), &Consent::all()).unwrap(); + } + std::fs::write(f.root.join("example.tests"), "test adds\n").unwrap(); + let doc = + ide_plugin::ProviderDocument { path: f.root.join("example.tests"), text: "test adds\n".into(), ..doc }; + assert!(eventually(|| { + f.plugins + .language_gutter_actions(&doc) + .iter() + .any(|(owner, action)| owner == "openidea.test-runner" && action.argument.as_deref() == Some("adds")) + })); + f.plugins.refresh_run_configurations(&f.root); + let configs = f.plugins.run_detection().detect(&f.root); + assert!(configs.iter().any(|c| c.id == "plugin:openidea.test-runner:tests")); + } +} + +/// Editor-path calls drop a result that misses their budget, as the first call into a cold +/// component can on a slow machine: ask again until it arrives. +fn eventually(mut ready: impl FnMut() -> bool) -> bool { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while std::time::Instant::now() < deadline { + if ready() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + false +} diff --git a/crates/ide-host/tests/plugin_indexing.rs b/crates/ide-host/tests/plugin_indexing.rs index 63dd97dad..db938890f 100644 --- a/crates/ide-host/tests/plugin_indexing.rs +++ b/crates/ide-host/tests/plugin_indexing.rs @@ -20,7 +20,7 @@ impl Fixture { } fn with_manifest(edit: impl FnOnce(&Path)) -> Self { let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().canonicalize().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); let host = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); host.set_project_policy(Arc::new(|_| true)); let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer"); @@ -147,6 +147,48 @@ fn permissions_and_paths_are_checked_before_indexing() { ); } +#[cfg(windows)] +#[test] +fn verbatim_snapshots_use_the_hosts_plain_document_identity() { + let f = Fixture::new(); + let source = f.index("source.idx", SOURCE, &[method()], Some(1), "app"); + let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "app"); + let verbatim = std::fs::canonicalize(&source).unwrap(); + assert_ne!(source, verbatim); + f.host + .index_navigation( + &f.root, + Snapshot { path: &verbatim, version: Some(2), text: SOURCE, symbols: &[method()], scope: "app" }, + Invocation { workspace_root: Some(f.root.clone()), ..Default::default() }, + ) + .unwrap(); + let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap(); + assert_eq!(result.targets.len(), 1, "a different prefix must replace the same document snapshot"); + assert_eq!(result.targets[0].path.0, source); + assert!(f.host.indexed_symbol_at(&f.root, &source, Pos::new(0, 4), None).is_some()); +} + +#[cfg(unix)] +#[test] +fn snapshot_paths_still_reject_symlink_aliases_and_escapes() { + let f = Fixture::new(); + let path = f.index("source.idx", SOURCE, &[method()], Some(1), "app"); + let outside = tempfile::NamedTempFile::new().unwrap(); + for (target, name) in [(path.as_path(), "alias.idx"), (outside.path(), "escape.idx")] { + let alias = f.root.join(name); + std::os::unix::fs::symlink(target, &alias).unwrap(); + assert!( + f.host + .index_navigation( + &f.root, + Snapshot { path: &alias, version: None, text: SOURCE, symbols: &[], scope: "app" }, + Invocation::default(), + ) + .is_err() + ); + } +} + #[test] fn indexed_refactorings_drop_graphs_after_permission_revocation_or_lost_trust() { let f = Fixture::new(); @@ -194,6 +236,7 @@ fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() { &f.root, Snapshot { path, version: Some(1), text, symbols: &symbols, scope: "" }, Invocation { + command_chain: Vec::new(), client: Some(client.into()), workspace_root: Some(f.root.clone()), editor: None, diff --git a/crates/ide-host/tests/plugin_language_features.rs b/crates/ide-host/tests/plugin_language_features.rs new file mode 100644 index 000000000..200b44740 --- /dev/null +++ b/crates/ide-host/tests/plugin_language_features.rs @@ -0,0 +1,234 @@ +//! The API 1.4 fixture exercises every kind through the real component runtime and editor ops. +use ide_core::{CancellationToken, Pos, Range, RequestId}; +use ide_host::plugins::{Consent, HostWorkspace, PluginHost, set_editor_providers}; +use ide_host::{Host, HostConfig, ops}; +use ide_plugin::{Invocation, Limits, ProviderDocument}; +use std::{path::Path, sync::Arc, time::Duration}; +const ID: &str = "test.language-features"; +fn fixture() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/language-features") +} +fn plugins(base: &Path) -> Arc { + let p = Arc::new( + PluginHost::new( + base.join("plugins"), + Arc::new(|_: Option<&str>, _| {}), + Limits { edit_soft: Duration::from_secs(3), edit_hard: Duration::from_secs(5), ..Default::default() }, + ) + .unwrap(), + ); + p.install_from_dir(&fixture(), &Consent::all()).unwrap(); + p +} +#[test] +fn injections_route_only_declared_embedded_providers_and_filter_diagnostics() { + let tmp = tempfile::tempdir().unwrap(); + let p = plugins(tmp.path()); + let doc = ProviderDocument { + path: tmp.path().join("a.txt"), + language: "text".into(), + version: 9, + text: "xx[[sql]]zz".into(), + }; + assert_eq!(p.provide_injections(&doc).len(), 1); + let items = p.provide_completion(&doc, Pos::new(0, 5), None); + assert_eq!(items.len(), 1); + assert_eq!(items[0].1.label, "sql: sql "); + assert!(p.provide_completion(&doc, Pos::new(0, 1), None).is_empty()); + assert_eq!(p.provide_hover(&doc, Pos::new(0, 5))[0].markdown, "sql: sql "); + let diagnostics = p.provide_diagnostics(&doc, &CancellationToken::new()); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].1.len(), 1); + assert_eq!(diagnostics[0].1[0].range, Range::new(Pos::new(0, 4), Pos::new(0, 7))); + assert!(p.language_signature_help(&ProviderDocument { language: "rust".into(), ..doc }, Pos::default()).is_none()); +} +#[test] +fn every_new_kind_requires_api4_at_install() { + let tmp = tempfile::tempdir().unwrap(); + let p = plugins(tmp.path()); + let package = tmp.path().join("old"); + std::fs::create_dir_all(package.join("dist")).unwrap(); + std::fs::copy(fixture().join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap(); + let header = "id='old'\nname='Old'\nversion='1.0.0'\nengines.openidea='^1.3'\nwasm='dist/plugin.wasm'\n"; + for kind in [ + "formatting", + "signature-help", + "document-highlights", + "document-links", + "code-lenses", + "selection-ranges", + "folding", + "colors", + "document-symbols", + "implementation", + "type-definition", + "gutter-actions", + "injections", + ] { + std::fs::write( + package.join("plugin.toml"), + format!("{header}\n[[contributes.providers]]\nkinds=['{kind}']\nlanguages=['text']"), + ) + .unwrap(); + assert!(p.install_from_dir(&package, &Consent::all()).is_err(), "{kind}"); + } +} +#[tokio::test(flavor = "multi_thread")] +async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_commands() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap().join("project"); + std::fs::create_dir(&root).unwrap(); + 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("local"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(ide_core::NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + host.open_project("main", &root).unwrap(); + let p = plugins(tmp.path()); + p.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host))))); + set_editor_providers(Some(&p)); + let path = root.join("a.txt"); + std::fs::write(&path, "value\nnext\nend").unwrap(); + let doc = ops::doc::open(&host, "main", path.clone()).await.unwrap(); + assert_eq!( + ops::lsp::lsp_signature_help(&host, "main", doc.id, Pos::default()).await.unwrap().unwrap().signatures[0] + .parameters, + vec![(6, 11)] + ); + assert_eq!(ops::lsp::lsp_document_highlights(&host, "main", doc.id, Pos::default()).await.unwrap().len(), 1); + let links = ops::lsp::lsp_document_links(&host, "main", doc.id).await.unwrap(); + assert_eq!(links.len(), 2); + assert_eq!(links[0].path.as_ref().unwrap().0, root.join("next.txt")); + let lenses = ops::lsp::lsp_code_lens(&host, "main", doc.id).await.unwrap(); + assert_eq!(lenses.len(), 1, "undeclared command was dropped"); + ops::lsp::lsp_execute_command(&host, "main", doc.id, lenses[0].command.clone().unwrap()).await.unwrap(); + assert_eq!(p.run_command(ID, "state", None, Invocation::default()).unwrap().as_deref(), Some("lens")); + let outline = ops::doc::doc_outline(&host, "main", doc.id).await.unwrap(); + assert_eq!(outline[0].name, "Outer"); + assert_eq!(outline[0].children[0].name, "Inner"); + assert_eq!(ops::doc::doc_folding(&host, "main", doc.id).await.unwrap()[0].end_line, 2); + assert_eq!(ops::doc::doc_selection_ranges(&host, "main", doc.id, 0).await.unwrap().len(), 2); + for kind in [ide_lsp::types::GotoKind::Implementation, ide_lsp::types::GotoKind::TypeDefinition] { + assert_eq!(ops::lsp::lsp_goto(&host, "main", doc.id, Pos::default(), kind).await.unwrap().len(), 1); + } + assert_eq!( + ops::lsp::lsp_goto_implementation( + &host, + "main", + RequestId(1), + doc.id, + Pos::default(), + ide_host::ops::Channel::new(|_| {}) + ) + .await + .unwrap() + .len(), + 1 + ); + let visuals = ops::lsp::plugin_language_visuals(&host, "main", doc.id).await.unwrap(); + assert_eq!(visuals.colors.len(), 1); + assert_eq!(visuals.gutter_actions.len(), 1); + assert_eq!( + ops::lsp::plugin_color_presentations(&host, "main", doc.id, ID.into(), visuals.colors[0].1.clone()) + .await + .unwrap(), + ["red-plaintext", "#ff0000"] + ); + let project = host.project("main").unwrap(); + project + .settings + .set( + ide_workspace::settings::SettingsScope::Project, + "languages.pluginFormatters", + serde_json::json!({doc.language.as_str():ID}), + ) + .unwrap(); + let options = ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false, tool: None }; + let formatted = + ops::lsp::lsp_format(&host, "main", doc.id, Some(Range::new(Pos::default(), Pos::new(0, 5))), options) + .await + .unwrap(); + assert_eq!(formatted[0].new_text, "formatted"); + // The same editor ops are exposed by the server dispatcher, with no desktop provider call. + let dispatcher = ide_host::rpc::Dispatcher::standard(); + assert!(dispatcher.commands().contains(&"plugin_language_visuals")); + + let base = tmp.path().join("server"); + let peer_slot = Arc::new(std::sync::OnceLock::new()); + let remote = ide_host::plugins::remote_host::RemotePlugins::start(&base, &host, peer_slot.clone()).unwrap(); + let mut dispatcher = ide_host::rpc::Dispatcher::standard(); + remote.register(&mut dispatcher); + let (client_io, server_io) = tokio::io::duplex(1 << 20); + let (sr, sw) = tokio::io::split(server_io); + tokio::spawn(ide_host::rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw)); + struct Client; + impl ide_jsonrpc::Handler for Client { + fn on_notification(&self, _: &str, _: serde_json::Value) {} + fn on_request(&self, method: &str, _: serde_json::Value) -> Result { + Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned())) + } + } + let (cr, cw) = tokio::io::split(client_io); + let peer = ide_jsonrpc::Peer::connect(cr, cw, Arc::new(Client)); + let _: serde_json::Value = + peer.request("initialize", serde_json::json!({"version":ide_host::rpc::PROTOCOL_VERSION}), None).await.unwrap(); + async fn invoke(peer: &ide_jsonrpc::Peer, cmd: &str, args: serde_json::Value) -> serde_json::Value { + peer.request( + "invoke", + serde_json::to_value(ide_host::rpc::Invoke { window: "main".into(), cmd: cmd.into(), args }).unwrap(), + None, + ) + .await + .unwrap() + } + invoke(&peer, "project_trust_set", serde_json::json!({"path":root,"trusted":true,"folder":false})).await; + let files = serde_json::json!({ID:p.remote_package_files(ID).unwrap()}); + let synced = invoke( + &peer, + ide_host::plugins::remote_host::SYNC_PACKAGES, + serde_json::json!({"packages":p.remote_packages(),"files":files}), + ) + .await; + assert_eq!(synced["failed"], serde_json::json!([]), "{synced}"); + let listed = invoke(&peer, "plugin_formatters", serde_json::json!({})).await; + assert_eq!(listed[0]["id"], ID); + let visual = invoke(&peer, "plugin_language_visuals", serde_json::json!({"doc":doc.id})).await; + assert_eq!(visual["gutterActions"].as_array().unwrap().len(), 1); + set_editor_providers(Some(&p)); + p.set_enabled(ID, false).unwrap(); + assert!(ops::lsp::plugin_formatters().is_empty()); + set_editor_providers(None); +} + +#[test] +fn formatting_uses_the_command_budget_while_editor_calls_can_expire() { + let tmp = tempfile::tempdir().unwrap(); + let p = PluginHost::new( + tmp.path().join("plugins"), + Arc::new(|_: Option<&str>, _| {}), + Limits { edit_soft: Duration::ZERO, ..Default::default() }, + ) + .unwrap(); + p.install_from_dir(&fixture(), &Consent::all()).unwrap(); + let doc = + ProviderDocument { path: tmp.path().join("a.txt"), language: "text".into(), version: 1, text: "value".into() }; + assert_eq!( + p.language_format( + ID, + &doc, + None, + ide_plugin::language_features::FormatOptions { tab_size: 4, insert_spaces: true } + ) + .unwrap()[0] + .new_text, + "formatted" + ); + assert!(p.language_signature_help(&doc, Pos::default()).is_none()); + assert!(p.info_of(ID).unwrap().stats.slow_calls > 0); +} diff --git a/crates/ide-host/tests/plugin_refactoring.rs b/crates/ide-host/tests/plugin_refactoring.rs index c49901d1c..1556c1485 100644 --- a/crates/ide-host/tests/plugin_refactoring.rs +++ b/crates/ide-host/tests/plugin_refactoring.rs @@ -11,7 +11,7 @@ use std::{path::Path, sync::Arc}; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn indexed_usages_rename_preview_staleness_and_grouped_undo() { let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().canonicalize().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); let first = root.join("definition.idx"); let second = root.join("usage.idx"); let source = "def 😀route\nref 😀route\n"; @@ -35,9 +35,7 @@ async fn indexed_usages_rename_preview_staleness_and_grouped_undo() { project_environment: false, }); let project = host.open_light("main", &first).unwrap(); - // Editors retain ordinary Windows paths; index snapshots use canonical - // (verbatim on Windows) paths and the host's own project-root identity. - let doc = project.documents.open(dunce::simplified(&first)).unwrap(); + let doc = project.documents.open(&first).unwrap(); let plugins = Arc::new(PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap()); plugins.set_project_policy(Arc::new(|_| true)); diff --git a/crates/ide-host/tests/plugin_workbench.rs b/crates/ide-host/tests/plugin_workbench.rs new file mode 100644 index 000000000..08e790f19 --- /dev/null +++ b/crates/ide-host/tests/plugin_workbench.rs @@ -0,0 +1,354 @@ +use ide_core::{CancellationToken, Diagnostic, Pos, Range, Severity}; +use ide_host::plugins::{HostWorkspace, PluginWorkspace}; +use ide_host::{Host, HostConfig}; +use ide_plugin::TextQuery; +use std::path::PathBuf; +use std::sync::Arc; + +struct Harness { + host: Arc, + root: PathBuf, + _tmp: tempfile::TempDir, +} +impl Harness { + fn new() -> Self { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap().join("project"); + std::fs::create_dir_all(&root).unwrap(); + 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("local"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(ide_core::NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + host.open_project("main", &root).unwrap(); + host.open_project("second", &root).unwrap(); + Self { host, root, _tmp: tmp } + } + fn workspace(&self) -> HostWorkspace { + HostWorkspace(Arc::downgrade(&self.host)) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn window_editor_reports_use_document_language_dirty_state_and_focus() { + let f = Harness::new(); + let path = f.root.join("main.rs"); + std::fs::write(&path, "fn main() {}\n").unwrap(); + let p = f.host.project("main").unwrap(); + let doc = p.documents.open(&path).unwrap(); + p.documents + .apply_edits(doc.id, &[ide_core::TextEdit { range: Range::default(), new_text: "// unsaved\n".into() }]) + .unwrap(); + f.host.mcp_reports.set_open_editors("main", vec![path.clone()]); + f.host.mcp_reports.set_focused_editor( + "main", + Some(ide_host::mcp::FocusedEditor { + path: path.clone().into(), + selection: Range::default(), + selected_text: String::new(), + }), + true, + ); + let workspace = f.workspace(); + let editors = workspace.open_editors("main").unwrap(); + assert_eq!(editors.len(), 1); + assert!(editors[0].dirty && editors[0].active); + assert_eq!(editors[0].language, "rust"); + assert!(workspace.open_editors("second").unwrap().is_empty()); + let second = f.host.project("second").unwrap(); + second.documents.open(&path).unwrap(); + f.host.mcp_reports.set_open_editors("second", vec![path.clone()]); + assert!(!workspace.open_editors("second").unwrap()[0].dirty); + assert!(workspace.open_editors("main").unwrap()[0].dirty); + assert!(workspace.buffer_text_in(Some("main"), &path).unwrap().contains("unsaved")); + assert!(!workspace.buffer_text_in(Some("second"), &path).unwrap().contains("unsaved")); + assert_ne!(workspace.document_in("main", &path), workspace.document_in("second", &path)); + f.host.mcp_reports.forget_window("main"); + assert!(workspace.open_editors("main").unwrap().is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn text_search_uses_overlay_excludes_utf16_and_limits() { + let f = Harness::new(); + let path = f.root.join("a.txt"); + std::fs::write(&path, "disk\n").unwrap(); + std::fs::create_dir(f.root.join("target")).unwrap(); + std::fs::write(f.root.join("target/ignored.txt"), "needle").unwrap(); + let p = f.host.project("main").unwrap(); + p.settings + .set(ide_workspace::settings::SettingsScope::Project, "files.ignoredNames", serde_json::json!(["target"])) + .unwrap(); + let doc = p.documents.open(&path).unwrap(); + p.documents + .apply_edits(doc.id, &[ide_core::TextEdit { range: Range::default(), new_text: "😀needle\nneedle\n".into() }]) + .unwrap(); + f.host.project("second").unwrap().documents.open(&path).unwrap(); + let workspace = f.workspace(); + let query = TextQuery { pattern: "needle".into(), case_sensitive: true, ..Default::default() }; + let found = workspace.search_text(Some("main"), &f.root, query.clone(), 10, &CancellationToken::new()).unwrap(); + assert_eq!(found.matches.len(), 2, "{found:?}; text={:?}", workspace.buffer_text(&path)); + assert_eq!(found.matches[0].location.range, Range::new(Pos::new(0, 2), Pos::new(0, 8))); + assert!(found.matches.iter().all(|m| m.location.path == path)); + let only_file = TextQuery { include: vec!["a.txt".into()], ..query.clone() }; + assert!( + workspace + .search_text(Some("second"), &f.root, only_file, 10, &CancellationToken::new()) + .unwrap() + .matches + .is_empty() + ); + let capped = workspace.search_text(Some("main"), &f.root, query.clone(), 1, &CancellationToken::new()).unwrap(); + assert_eq!(capped.matches.len(), 1); + assert!(capped.limit_hit); + let excluded = TextQuery { exclude: vec!["*.txt".into()], ..query.clone() }; + assert!( + workspace + .search_text(Some("main"), &f.root, excluded, 10, &CancellationToken::new()) + .unwrap() + .matches + .is_empty() + ); + for query in [ + TextQuery { pattern: "NEEDLE".into(), case_sensitive: true, ..Default::default() }, + TextQuery { pattern: "need".into(), whole_word: true, ..Default::default() }, + TextQuery { include: vec!["*.rs".into()], ..query.clone() }, + ] { + assert!( + workspace + .search_text(Some("main"), &f.root, query, 10, &CancellationToken::new()) + .unwrap() + .matches + .is_empty() + ); + } + let multiline = TextQuery { pattern: "needle\nneedle".into(), regex: true, ..Default::default() }; + let found = workspace.search_text(Some("main"), &f.root, multiline, 10, &CancellationToken::new()).unwrap(); + assert_eq!(found.matches[0].location.range, Range::new(Pos::new(0, 2), Pos::new(1, 6))); + let cancelled = CancellationToken::new(); + cancelled.cancel(); + assert!(matches!( + workspace.search_text(Some("main"), &f.root, query, 10, &cancelled), + Err(ide_plugin::HostError::Cancelled) + )); +} + +#[tokio::test(flavor = "multi_thread")] +async fn problems_share_project_reports_and_keep_sources_separate() { + let f = Harness::new(); + let path = f.root.join("a.txt"); + std::fs::write(&path, "text").unwrap(); + let diagnostic = |source: &str| Diagnostic { + range: Range::default(), + severity: Severity::Warning, + message: source.into(), + source: Some(source.into()), + code: None, + unnecessary: false, + deprecated: false, + server: None, + }; + f.host.mcp_reports.report_problems( + "second", + true, + vec![ide_host::mcp::FileProblems { + path: path.clone().into(), + items: vec![diagnostic("lsp"), diagnostic("plugin")], + }], + ); + let workspace = f.workspace(); + let files = workspace.problems("main", Some(&path)).unwrap(); + assert_eq!(files.len(), 2); + assert!(files.iter().all(|f| f.diagnostics.len() == 1)); + assert!(workspace.problems("main", Some(&f.root.join("other.txt"))).unwrap().is_empty()); + f.host.mcp_reports.forget_window("second"); + assert!(workspace.problems("main", None).unwrap().is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn search_decodes_closed_documents_the_same_way_as_the_editor() { + let f = Harness::new(); + let path = f.root.join("utf16.txt"); + let mut bytes = vec![0xff, 0xfe]; + for unit in "😀needle\r\n".encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + std::fs::write(&path, bytes).unwrap(); + let query = TextQuery { pattern: "needle".into(), ..Default::default() }; + let result = f.workspace().search_text(Some("main"), &f.root, query, 10, &CancellationToken::new()).unwrap(); + assert_eq!(result.matches.len(), 1); + assert_eq!(result.matches[0].location.range, Range::new(Pos::new(0, 2), Pos::new(0, 8))); + assert_eq!(result.matches[0].line, "😀needle"); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn plugin_debug_uses_the_source_language_and_revalidates_its_owned_program() { + use ide_host::plugins::{Consent, PluginEvent, PluginHost, WindowRequest}; + use ide_plugin::{Invocation, Limits}; + let f = Harness::new(); + f.host.set_project_trust(&f.root, true, false).await.unwrap(); + std::fs::write(f.root.join("execution.marker"), "").unwrap(); + std::fs::write(f.root.join("entry.py"), "print('test')\n").unwrap(); + let events = Arc::new(parking_lot::Mutex::new(Vec::new())); + let captured = events.clone(); + let plugins = Arc::new( + PluginHost::new( + f._tmp.path().join("plugins"), + Arc::new(move |client: Option<&str>, event: PluginEvent| { + captured.lock().push((client.map(str::to_owned), event)) + }), + Limits::default(), + ) + .unwrap(), + ); + plugins.set_workspace(Some(Arc::new(f.workspace()))); + f.host.set_plugin_host(&plugins); + let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution"); + plugins.install_from_dir(&package, &Consent::all()).unwrap(); + plugins + .choose_program("execution", "process:execution-tool", Some(&f.root), Some(std::path::Path::new("/bin/cat"))) + .unwrap(); + let mut config = + plugins.run_detection().detect(&f.root).into_iter().find(|c| c.id == "plugin:execution:detected").unwrap(); + config.args = vec!["entry.py".into()]; + ide_run::save_user_configs(&f.root, &[config]).unwrap(); + let config = f.workspace().run_configuration("main", "execution", "detected").unwrap(); + assert_eq!(config.debug_adapter.as_deref(), Some("debugpy")); + let pinned = plugins.program_for_project("execution", "execution-tool", Some(&f.root)).unwrap(); + assert_eq!(plugins.prepare_debug_run("main", config.clone()).unwrap().command, pinned.to_string_lossy()); + let invocation = + Invocation { client: Some("main".into()), workspace_root: Some(f.root.clone()), ..Default::default() }; + assert!(plugins.run_command("execution", "debug", None, invocation).unwrap().is_some()); + assert!(events.lock().iter().any(|(client, event)| client.as_deref() == Some("main") && matches!(event, PluginEvent::WindowRequest { request: WindowRequest::Run { config, debug: true, .. }, .. } if config.command == pinned.to_string_lossy()))); + plugins.set_capability("execution", "process:execution-tool", false).unwrap(); + assert!(plugins.prepare_debug_run("main", config).is_err()); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn plugin_run_targets_register_prompt_route_revalidate_and_withdraw() { + use ide_host::plugins::{Consent, PluginEvent, PluginHost, run_targets}; + use ide_plugin::{Invocation, Limits}; + use std::os::unix::fs::PermissionsExt; + let f = Harness::new(); + f.host.set_project_trust(&f.root, true, false).await.unwrap(); + let plugins = Arc::new( + PluginHost::new( + f._tmp.path().join("plugins"), + Arc::new(|_: Option<&str>, _: PluginEvent| {}), + Limits { idle_timeout: Some(std::time::Duration::from_millis(10)), ..Default::default() }, + ) + .unwrap(), + ); + plugins.set_workspace(Some(Arc::new(f.workspace()))); + f.host.set_plugin_host(&plugins); + let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution"); + plugins.install_from_dir(&package, &Consent::all()).unwrap(); + let tool = f._tmp.path().join("wrapper"); + std::fs::write(&tool, "#!/bin/sh\nprintf 'cwd=%s\\n' \"$PWD\"\nprintf '<%s>\\n' \"$@\"\n").unwrap(); + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap(); + let invocation = + || Invocation { client: Some("main".into()), workspace_root: Some(f.root.clone()), ..Default::default() }; + assert!(plugins.run_command("execution", "target", None, invocation()).is_err()); + plugins.choose_program("execution", "process:execution-tool", Some(&f.root), Some(&tool)).unwrap(); + plugins.run_command("execution", "target", None, invocation()).unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + plugins.tick_at(std::time::Instant::now()); + assert_eq!(plugins.info_of("execution").unwrap().status, ide_host::plugins::PluginStatus::Inactive); + let targets = run_targets::list(&f.host, &plugins, "main").unwrap(); + assert_eq!(targets.len(), 1); + assert!(targets[0].prompt); + assert!(f.host.project("main").unwrap().settings.effective().run.default_target.is_empty()); + let id = &targets[0].id; + let settings = run_targets::answer(&f.host, &plugins, "main", id, false).unwrap(); + assert!(settings.run.default_target.is_empty()); + // A stale prompt in a second window cannot change an answered selection. + assert!(run_targets::answer(&f.host, &plugins, "second", id, true).unwrap().run.default_target.is_empty()); + plugins.run_command("execution", "untarget", None, invocation()).unwrap(); + assert!(plugins.run_targets(&f.root).is_empty()); + plugins.run_command("execution", "target", None, invocation()).unwrap(); + assert!(!plugins.run_targets(&f.root)[0].prompt); + plugins.run_command("execution", "target", Some("preferred"), invocation()).unwrap(); + let preferred = plugins.run_targets(&f.root).into_iter().find(|t| t.target.id == "preferred").unwrap(); + let settings = run_targets::answer(&f.host, &plugins, "main", &preferred.id, true).unwrap(); + assert_eq!(settings.run.default_target, preferred.id); + assert_eq!(settings.languages.php.run_target, preferred.id); + assert_eq!(settings.languages.node.run_target, preferred.id); + assert_eq!(f.host.project("second").unwrap().settings.effective().run.default_target, preferred.id); + std::fs::write(f.root.join("execution.marker"), "").unwrap(); + std::fs::write(f.root.join("entry.py"), "print('test')\n").unwrap(); + let mut detected = + plugins.run_detection().detect(&f.root).into_iter().find(|c| c.id == "plugin:execution:detected").unwrap(); + detected.args = vec!["entry.py".into()]; + ide_run::save_user_configs(&f.root, &[detected]).unwrap(); + let mut debug = f.workspace().run_configuration("main", "execution", "detected").unwrap(); + debug.extras.target = None; + let error = plugins.prepare_debug_run("main", debug).unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput); + assert!(error.to_string().contains("target")); + assert!(plugins.run_command("execution", "debug", None, invocation()).unwrap_err().to_string().contains("target")); + let output = Arc::new(parking_lot::Mutex::new(String::new())); + let captured = output.clone(); + let config = ide_run::RunConfig { + id: "php-target".into(), + name: "PHP target".into(), + kind: "php".into(), + command: "php".into(), + args: vec!["x.php".into(), "a b; $(touch forbidden)".into()], + cwd: None, + env: Default::default(), + detected: false, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: Default::default(), + }; + let run = ide_host::ops::run::start( + &f.host, + "main", + config.clone(), + Arc::new(move |chunk| { + if let ide_run::OutputChunk::Stdout { text } = chunk { + captured.lock().push_str(&text); + } + }), + ) + .await + .unwrap(); + for _ in 0..100 { + if f.host.processes.list().iter().any(|r| r.id == run.id && !r.running) { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + let text = output.lock().clone(); + assert!(text.contains("\n<--cwd=/container>\n\n\n"), "{text}"); + assert!(text.contains(&format!("cwd={}", f.root.display())), "{text}"); + assert!(!f.root.join("forbidden").exists()); + // Pin checks are repeated at launch, not only at registration. + std::fs::write(&tool, "#!/bin/sh\nexit 99\n").unwrap(); + assert!(ide_host::ops::run::start(&f.host, "main", config.clone(), Arc::new(|_| {})).await.is_err()); + plugins.set_capability("execution", "process:execution-tool", false).unwrap(); + assert!(plugins.run_targets(&f.root).is_empty()); + assert!(ide_host::ops::run::start(&f.host, "main", config, Arc::new(|_| {})).await.is_err()); + plugins.choose_program("execution", "process:execution-tool", Some(&f.root), Some(&tool)).unwrap(); + plugins.run_command("execution", "target", None, invocation()).unwrap(); + f.host.set_project_trust(&f.root, false, false).await.unwrap(); + assert!(plugins.run_targets(&f.root).is_empty()); + assert!( + plugins.run_command("execution", "target", None, invocation()).unwrap_err().to_string().contains("trusted") + ); + f.host.set_project_trust(&f.root, true, false).await.unwrap(); + plugins.run_command("execution", "target", None, invocation()).unwrap(); + f.host.close_project_detached("main"); + assert_eq!(plugins.run_targets(&f.root).len(), 1); + f.host.close_project_detached("second"); + assert!(plugins.run_targets(&f.root).is_empty()); + plugins.shutdown(); +} diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 9702d3f99..e2b0cb996 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -75,6 +75,26 @@ impl TestWorkspace { } impl PluginWorkspace for TestWorkspace { + fn run_configuration(&self, _client: &str, plugin: &str, id: &str) -> Result { + if plugin != "execution" || !matches!(id, "detected" | "dynamic") { + return Err(HostError::NotFound(id.into())); + } + Ok(ide_run::RunConfig { + id: format!("plugin:execution:{id}"), + name: "Tests".into(), + kind: "plugin".into(), + command: "execution-tool".into(), + args: vec!["test".into()], + cwd: Some(self.root.clone().into()), + env: Default::default(), + detected: true, + is_test: true, + debug_adapter: None, + allow_parallel: false, + extras: ide_run::RunExtras { params: [("framework".into(), "plugin".into())].into(), ..Default::default() }, + }) + } + fn projects(&self) -> Vec { std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect() } @@ -285,6 +305,7 @@ fn fixture(limits: Limits) -> Fixture { impl Fixture { fn inv(&self) -> Invocation { Invocation { + command_chain: Vec::new(), client: Some("main".into()), workspace_root: Some(self.project.clone()), editor: None, @@ -5642,3 +5663,365 @@ fn view_secondary_requires_api_1_3_and_a_boolean_tool_window_placement() { assert!(err.message.contains(message), "{api}, {location}, {value}: {err:?}"); } } + +#[test] +fn api14_denies_new_privileges_and_requires_an_invoking_window() { + let f = fixture(Limits::default()); + f.host.install_from_dir(&sample("api14"), &Consent::default()).unwrap(); + for (command, arg, capability) in [ + ("execute", "test.action", "ide.commands"), + ("execute", "plugin.other.run", "ide.commands"), + ("execute", "plugin.api14.other.run", "ide.commands"), + ("open-editors", "", "editor.read"), + ("open-editor", "a.txt", "project.read"), + ("search-text", "needle", "project.read"), + ("problems", "", "project.read"), + ("decorations", "a.txt", "project.read"), + ("clear-decorations", "", "project.read"), + ("file-decoration", "a.txt", "project.read"), + ("clear-file-decorations", "", "project.read"), + ("vcs-log", "", "vcs.read"), + ("vcs-blame", "a.txt", "vcs.read"), + ("vcs-diff", "", "vcs.read"), + ("ipc", "/tmp/api14-no-grant", "ipc:"), + ("fetch", "http://example.com/", "net.http:"), + ("secret", "value", "secrets"), + ("delete-secret", "", "secrets"), + ] { + let error = f.run("api14", command, Some(arg)).unwrap_err(); + assert!(error.contains(capability) && error.contains("not granted"), "{error}"); + } + for command in ["window-status", "remove-window-status", "execute", "oauth", "output"] { + let error = f.host.run_command("api14", command, Some("value"), Invocation::default()).unwrap_err(); + assert_eq!(error.kind, ErrorKind::InvalidInput, "{command}: {error:?}"); + } + let own = f.run("api14", "execute", Some("plugin.api14.execute")).unwrap_err(); + assert!(own.contains("recursive") && !own.contains("not granted"), "{own}"); + f.host.set_capability("api14", "secrets", true).unwrap(); + f.run("api14", "secret", Some(" raw value ")).unwrap(); + assert_eq!(f.host.declared_secret("api14", "token"), Some(" raw value ".into())); + assert!(f.run("api14", "undeclared-secret", Some("value")).unwrap_err().contains("undeclared")); + f.run("api14", "delete-secret", None).unwrap(); + assert_eq!(f.host.declared_secret("api14", "token"), None); +} + +#[test] +fn api14_window_status_overrides_fall_back_and_are_withdrawn() { + let f = fixture(Limits::default()); + f.install(&sample("api14")); + f.run("api14", "global-status", Some("global")).unwrap(); + f.run("api14", "window-status", Some("main")).unwrap(); + let snapshot = f.host.status_snapshot("main"); + assert!(snapshot.iter().any(|i| i.window && i.item.text == "main")); + assert!(!f.host.status_snapshot("other").iter().any(|i| i.window)); + f.run("api14", "global-status", Some("updated")).unwrap(); + f.run("api14", "remove-window-status", None).unwrap(); + assert_eq!(f.host.status_snapshot("main").len(), 1); + assert_eq!(f.host.status_snapshot("main")[0].item.text, "updated"); + f.run("api14", "window-status", Some("override")).unwrap(); + f.host.forget_client("main"); + assert!(!f.host.status_snapshot("main").iter().any(|i| i.window)); + f.host.reopen_client("main"); + f.run("api14", "window-status", Some("again")).unwrap(); + f.host.set_enabled("api14", false).unwrap(); + assert!(f.host.status_snapshot("main").is_empty()); + assert!(f.take_events().iter().any(|(window, event)| window.as_deref() == Some("main") + && matches!(event, PluginEvent::WindowStatusItemChanged { item: None, .. }))); +} + +#[test] +fn api14_command_requests_preserve_window_result_and_reject_cycles() { + let f = Arc::new(fixture(Limits::default())); + f.install(&sample("api14")); + assert!(f.run("api14", "execute", Some("plugin.api14.execute")).unwrap_err().contains("recursive")); + for answer in [CommandAnswer::Value(Some("result".into())), CommandAnswer::Value(None), CommandAnswer::NotFound] { + f.take_events(); + let runner = f.clone(); + let worker = std::thread::spawn(move || runner.run("api14", "execute", Some("test.action"))); + let deadline = Instant::now() + Duration::from_secs(5); + let request = loop { + if let Some((window, PluginEvent::ExecuteCommand { request_id, arg, chain, .. })) = + f.events.lock().iter().find(|(_, e)| matches!(e, PluginEvent::ExecuteCommand { .. })).cloned() + { + assert_eq!(window.as_deref(), Some("main")); + assert_eq!(arg.as_deref(), Some("argument")); + assert_eq!(chain, ["api14"]); + break request_id; + } + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(5)); + }; + assert!(!f.host.answer_command("other", &request, answer.clone())); + assert!(f.host.answer_command("main", &request, answer.clone())); + let result = worker.join().unwrap(); + match answer { + CommandAnswer::Value(value) => assert_eq!(result.unwrap(), value), + CommandAnswer::NotFound => assert!(result.unwrap_err().contains("test.action")), + _ => unreachable!(), + } + } +} + +#[test] +fn api14_settings_and_input_events_use_other_and_capture_the_editor() { + let f = fixture(Limits::default()); + f.install(&sample("api14")); + use ide_plugin::WorkbenchEvent as E; + f.host.post_events(Some("main"), vec![E::SettingsChanged(Vec::new())]); + f.workspace + .settings + .lock() + .insert("api14".into(), serde_json::from_value(serde_json::json!({"enabled": true})).unwrap()); + let path = to_ipc(&f.project.join("src/notes.txt")); + f.host.post_window_events( + "main", + vec![ + E::SettingsChanged(vec!["another-plugin-key".into()]), + E::FileClosed(path.clone()), + E::InputModeChanged(ide_plugin::InputMode { + path: path.clone(), + mode: "visual".into(), + sub: Some("linewise".into()), + }), + ], + Some(PluginEditor { + path: path.clone(), + language: "text".into(), + selections: vec![ide_core::Range::default()], + }), + ); + f.host.flush_events_at(Instant::now() + Duration::from_secs(2)); + assert!(f.host.wait_events(Duration::from_secs(5))); + let received: serde_json::Value = serde_json::from_str(&f.run("api14", "events", None).unwrap().unwrap()).unwrap(); + let events = received.as_array().unwrap(); + assert_eq!(events.len(), 3, "{received}"); + assert_eq!(events[0]["kind"], "settings-changed"); + assert_eq!(events[0]["data"], "[\"enabled\"]"); + assert_eq!(events[2]["kind"], "input-mode-changed"); + assert_eq!(events[2]["editor"][0], path); + assert_eq!(events[2]["editor"][1][0], serde_json::json!([0, 0, 0, 0])); +} + +#[cfg(unix)] +#[test] +fn api14_ipc_echo_checks_the_grant_in_the_wasm_binding() { + use std::io::{Read, Write}; + let f = fixture(Limits::default()); + let pkg = f._tmp.path().join("api14-package"); + copy_dir(&sample("api14"), &pkg); + let path = f._tmp.path().join("echo.sock"); + let manifest = pkg.join("plugin.toml"); + let text = std::fs::read_to_string(&manifest) + .unwrap() + .replace("ipc:$TMPDIR/api14-*", &format!("ipc:{}", f._tmp.path().join("*.sock").display())); + std::fs::write(manifest, text).unwrap(); + f.install(&pkg); + let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); + let worker = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut bytes = [0; 4]; + stream.read_exact(&mut bytes).unwrap(); + stream.write_all(&bytes).unwrap(); + }); + assert_eq!(f.run("api14", "ipc", path.to_str()).unwrap().as_deref(), Some("echo")); + worker.join().unwrap(); + f.host.set_capability("api14", &format!("ipc:{}", f._tmp.path().join("*.sock").display()), false).unwrap(); + assert!(f.run("api14", "ipc", path.to_str()).unwrap_err().contains("not granted")); +} + +#[test] +fn api14_oauth_wait_excludes_the_call_budget_and_closes_after_redirect() { + use std::io::{Read, Write}; + let f = Arc::new(fixture(Limits { timeout: Duration::from_millis(200), ..Limits::default() })); + f.install(&sample("api14")); + let runner = f.clone(); + let worker = std::thread::spawn(move || runner.run("api14", "oauth", None)); + let deadline = Instant::now() + Duration::from_secs(5); + let uri = loop { + if let Some((_, PluginEvent::OutputChanged { text: Some(text), .. })) = f + .events + .lock() + .iter() + .find(|(_, e)| matches!(e, PluginEvent::OutputChanged { channel, .. } if channel == "oauth")) + .cloned() + { + break text; + } + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(5)); + }; + assert!(uri.starts_with("http://127.0.0.1:")); + std::thread::sleep(Duration::from_millis(300)); + let authority = uri.strip_prefix("http://").unwrap().split('/').next().unwrap(); + let mut stream = std::net::TcpStream::connect(authority).unwrap(); + stream.write_all(b"GET /oauth/callback?code=a%2Bb&state=test HTTP/1.1\r\nHost: localhost\r\n\r\n").unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + assert!(response.contains("You can close this tab")); + assert_eq!(worker.join().unwrap().unwrap().as_deref(), Some("code=a%2Bb&state=test")); + assert!(std::net::TcpStream::connect(authority).is_err()); + f.run("api14", "global-status", Some("still running")).unwrap(); +} + +#[cfg(unix)] +#[test] +fn api14_executable_choices_are_pinned_per_project_and_reset_revalidates() { + use std::os::unix::fs::PermissionsExt; + let f = fixture(Limits::default()); + let package = f._tmp.path().join("api14-process"); + copy_dir(&sample("api14"), &package); + let manifest = package.join("plugin.toml"); + let text = + std::fs::read_to_string(&manifest).unwrap().replace("optional = [", "optional = [\"process:api14-tool\", "); + std::fs::write(manifest, text).unwrap(); + f.install(&package); + let executable = |name: &str| { + let path = f._tmp.path().join(format!("{name}-bin")); + std::fs::write(&path, format!("#!/bin/sh\nprintf '{name}'\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path.canonicalize().unwrap() + }; + let global = executable("global"); + let project = executable("project"); + f.host.choose_program("api14", "process:api14-tool", None, Some(&global)).unwrap(); + f.host.choose_program("api14", "process:api14-tool", Some(&f.project), Some(&project)).unwrap(); + assert_eq!(f.host.program_for("api14", "api14-tool").unwrap(), global); + assert_eq!(f.host.program_for_project("api14", "api14-tool", Some(&f.project)).unwrap(), project); + for command in ["process-run", "process-run-command", "process-start"] { + assert_eq!(f.run("api14", command, None).unwrap().as_deref(), Some("project")); + let invocation = Invocation { client: Some("global".into()), ..Invocation::default() }; + assert_eq!(f.host.run_command("api14", command, None, invocation).unwrap().as_deref(), Some("global")); + } + f.run("api14", "terminal", None).unwrap(); + assert!( + f.take_events().iter().any( + |(_, e)| matches!(e, PluginEvent::OpenTerminal { program, .. } if program.as_ref() == project.as_path()) + ) + ); + f.run("api14", "log", None).unwrap(); + assert!( + f.take_events() + .iter() + .any(|(_, e)| matches!(e, PluginEvent::OpenLogTab { log, .. } if log.program == "api14-tool")) + ); + let unrelated = f._tmp.path().join("unrelated"); + assert_eq!(f.host.program_for_project("api14", "api14-tool", Some(&unrelated)).unwrap(), global); + std::fs::write(&project, "changed").unwrap(); + assert!(f.host.program_for_project("api14", "api14-tool", Some(&f.project)).is_err()); + f.host.choose_program("api14", "process:api14-tool", Some(&f.project), None).unwrap(); + assert_eq!(f.host.program_for_project("api14", "api14-tool", Some(&f.project)).unwrap(), global); + f.host.choose_program("api14", "process:api14-tool", Some(&f.project), Some(&project)).unwrap(); + std::fs::write(&global, "changed").unwrap(); + f.host.choose_program("api14", "process:api14-tool", Some(&f.project), None).unwrap(); + assert!( + !f.host.info_of("api14").unwrap().capabilities.iter().find(|c| c.id == "process:api14-tool").unwrap().granted + ); +} + +#[cfg(unix)] +fn execution_fixture() -> Arc { + use std::os::unix::fs::PermissionsExt; + let f = Arc::new(fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() })); + f.install(&sample("execution")); + std::fs::write(f.project.join("execution.marker"), "").unwrap(); + let executable = f._tmp.path().join("execution-bin"); + std::fs::write(&executable, "#!/bin/sh\nprintf '%s\n' \"$@\"\n").unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + f.host.choose_program("execution", "process:execution-tool", Some(&f.project), Some(&executable)).unwrap(); + f +} + +#[cfg(unix)] +fn execution_request(f: &Arc, command: &str) -> (String, WindowRequest) { + let command = command.to_owned(); + let runner = f.clone(); + let worker = std::thread::spawn(move || runner.run("execution", &command, None)); + let mut request = None; + assert!(eventually(|| { + for (client, event) in f.take_events() { + if let PluginEvent::WindowRequest { request_id, request: r, .. } = event { + assert_eq!(client.as_deref(), Some("main")); + request = Some((request_id, r)); + } + } + request.is_some() + })); + let (request_id, request) = request.unwrap(); + assert!(!f.host.answer_command("second", &request_id, CommandAnswer::Value(None))); + if !request_id.is_empty() { + assert!(f.host.answer_command("main", &request_id, CommandAnswer::Value(None))); + } + let id = worker.join().unwrap().unwrap().unwrap_or_default(); + (id, request) +} + +#[cfg(unix)] +#[test] +fn api14_stage3_runs_and_terminals_deliver_exactly_one_exit_after_idle_reclamation() { + let f = execution_fixture(); + let (run, request) = execution_request(&f, "run"); + assert!(matches!(request, WindowRequest::Run { config, .. } if config.args == ["test", "appended"])); + assert!(f.run("execution", "debug", None).unwrap_err().contains("debugger")); + let (terminal, _) = execution_request(&f, "terminal"); + assert_eq!(execution_request(&f, "terminal").0, terminal); + assert!(f.run("execution", "write", None).unwrap_err().contains("running terminal")); + std::thread::sleep(Duration::from_millis(100)); + f.host.tick(); + assert_eq!(f.host.instances(), 0); + assert!(!f.host.finish_operation("second", &run, Some(2), false)); + assert!(f.host.finish_operation("main", &run, Some(2), true)); + assert!(!f.host.finish_operation("main", &run, Some(2), true)); + assert!(f.host.finish_operation("main", &terminal, None, true)); + assert!(!f.host.finish_operation("main", &terminal, Some(0), false)); + f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); + assert!(eventually(|| f.host.wait_events(Duration::from_secs(1)))); + let events: serde_json::Value = + serde_json::from_str(&f.run("execution", "events", None).unwrap().unwrap()).unwrap(); + let events = events.as_array().unwrap(); + assert_eq!(events.len(), 2); + assert_eq!(events[0]["kind"], "run-exited"); + let data: serde_json::Value = serde_json::from_str(events[0]["data"].as_str().unwrap()).unwrap(); + assert_eq!(data["id"], run); + assert_eq!(data["killed"], true); + assert_eq!(events[1]["kind"], "terminal-exited"); + assert!(f.run("execution", "write", None).unwrap_err().contains("running terminal")); +} + +#[cfg(unix)] +#[test] +fn api14_stage3_configurations_test_results_and_content_actions_use_real_exports() { + let f = execution_fixture(); + f.host.refresh_run_configurations(&f.project); + let configs = f.host.run_detection().detect(&f.project); + assert!(configs.iter().any(|c| c.id == "plugin:execution:dynamic" + && c.is_test + && c.extras.params.get("framework").map(String::as_str) == Some("plugin"))); + assert_eq!( + f.host.parse_test_results("main", "plugin:execution:dynamic", "batch".into()).unwrap().unwrap().len(), + 3 + ); + let (_, request) = execution_request(&f, "content"); + assert!(matches!(request, WindowRequest::ContentTab { tab } if tab.key == "config")); + f.take_events(); + let action = ide_plugin::ContentTabAction { + request_id: "ct-1".into(), + key: "config".into(), + action: "save".into(), + content: "edited".into(), + }; + assert!(f.host.content_tab_action("second", "execution", action.clone()).is_err()); + f.host.content_tab_action("main", "execution", action).unwrap(); + f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); + assert!(eventually(|| { + f.events.lock().iter().any(|(client, event)| client.as_deref() == Some("main") && matches!(event, PluginEvent::ContentTabReply { request_id, reply, .. } if request_id == "ct-1" && reply.saved == Some(true))) + })); + assert!(f.host.parse_test_results("main", "plugin:execution:dynamic", "trap".into()).unwrap().is_none()); +} + +#[cfg(unix)] +#[test] +fn api14_stage3_terminal_calls_are_denied_without_process_grants() { + let f = execution_fixture(); + f.host.set_capability("execution", "process:execution-tool", false).unwrap(); + assert!(f.run("execution", "terminal", None).unwrap_err().contains("not granted")); +} diff --git a/crates/ide-host/tests/remote_plugins.rs b/crates/ide-host/tests/remote_plugins.rs index 3416eef09..e2bc8e289 100644 --- a/crates/ide-host/tests/remote_plugins.rs +++ b/crates/ide-host/tests/remote_plugins.rs @@ -27,6 +27,7 @@ struct Client { events: Mutex>, plugin_events: Mutex>, secret_requests: Mutex>, + desktop: Option>, } impl Handler for Client { @@ -45,6 +46,19 @@ impl Handler for Client { self.secret_requests.lock().push(r); return Ok(serde_json::to_value(value).unwrap()); } + if let Some(desktop) = &self.desktop { + use ide_host::plugins::remote_host::{AUTH_REQUEST, SECRET_WRITE}; + if method == AUTH_REQUEST { + return Ok( + serde_json::to_value(desktop.remote_auth("main", serde_json::from_value(params).unwrap())).unwrap() + ); + } + if method == SECRET_WRITE { + return Ok( + serde_json::to_value(desktop.remote_secret_write(serde_json::from_value(params).unwrap())).unwrap() + ); + } + } Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned())) } } @@ -207,11 +221,11 @@ async fn remote_projects_run_the_desktops_code_plugins_on_the_server() { }) .await; - // Disabled on the desktop: gone from the server. + // Disabled on the desktop: retained but stopped on the server. desktop.set_enabled("openidea.checkstyle", false).unwrap(); let removed = sync(&peer, &desktop, &[]).await; - assert_eq!(removed.removed, ["openidea.checkstyle"]); - assert!(plugins.host().info_of("openidea.checkstyle").is_err()); + assert!(removed.removed.is_empty()); + assert_eq!(plugins.host().info_of("openidea.checkstyle").unwrap().status, PluginStatus::Disabled); plugins.host().shutdown(); } @@ -350,3 +364,444 @@ async fn remote_projects_get_plugin_views_and_custom_editors() { plugins.host().shutdown(); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let tmp = tempfile::tempdir().unwrap(); + let desktop = Arc::new( + PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()) + .unwrap(), + ); + desktop.install_from_dir(&fixture("api14"), &Consent::all()).unwrap(); + let project = tmp.path().join("project"); + std::fs::create_dir(&project).unwrap(); + let project = project.canonicalize().unwrap(); + let base = tmp.path().join("server"); + let peer_slot = Arc::new(OnceLock::new()); + let sink_peer = peer_slot.clone(); + 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.into()))), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap(); + let mut dispatcher = Dispatcher::standard(); + plugins.register(&mut dispatcher); + let (client_io, server_io) = tokio::io::duplex(1 << 20); + let (sr, sw) = tokio::io::split(server_io); + tokio::spawn(rpc::serve(host, dispatcher, peer_slot, sr, sw)); + let (cr, cw) = tokio::io::split(client_io); + let client = Arc::new(Client { desktop: Some(desktop.clone()), ..Client::default() }); + let peer = Peer::connect(cr, cw, client.clone()); + let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap(); + invoke(&peer, "open_project", json!({ "path": project })).await.unwrap(); + invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap(); + sync(&peer, &desktop, &[]).await; + let report = sync(&peer, &desktop, &["api14"]).await; + assert!(report.failed.is_empty(), "{report:?}"); + let run = |command: &str, arg: Option<&str>| json!({ "pluginId": "api14", "commandId": command, "arg": arg, "editor": null }); + let remote_file = project.join("remote.txt"); + std::fs::write(&remote_file, "remote text").unwrap(); + invoke(&peer, "mcp_set_open_editors", json!({"paths": [remote_file]})).await.unwrap(); + invoke(&peer, "mcp_set_focused_editor", json!({"editor": {"path": remote_file, "selection": {"start": {"line": 0, "col": 0}, "end": {"line": 0, "col": 0}}, "selectedText": ""}, "focused": true})).await.unwrap(); + let editors = invoke(&peer, "plugin_run_command", run("open-editors", None)).await.unwrap(); + assert!(editors.as_str().unwrap().contains("remote.txt")); + assert!(editors.as_str().unwrap().contains("active: true")); + let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(); + assert_eq!(problems.as_str(), Some("[]")); + invoke(&peer, "plugin_run_command", run("open-editor", Some("remote.txt"))).await.unwrap(); + invoke(&peer, "plugin_run_command", run("decorations", remote_file.to_str())).await.unwrap(); + let sets = invoke(&peer, "plugin_decoration_sets", json!({})).await.unwrap(); + assert_eq!(sets["api14"][0]["root"], project.to_str().unwrap()); + until("forwarded editor and decoration events", 5, || { + let events = client.plugin_events.lock(); + events.iter().any(|m| m.window.as_deref() == Some("main") && matches!(m.event, PluginEvent::EditorOpen { .. })) + && events.iter().any(|m| matches!(m.event, PluginEvent::DecorationsChanged { .. })) + }) + .await; + invoke(&peer, "plugin_run_command", run("secret", Some("remote-token"))).await.unwrap(); + assert_eq!(desktop.declared_secret("api14", "token").as_deref(), Some("remote-token")); + invoke(&peer, "plugin_run_command", run("delete-secret", None)).await.unwrap(); + assert_eq!(desktop.declared_secret("api14", "token"), None); + invoke(&peer, "plugin_run_command", run("window-status", Some("remote"))).await.unwrap(); + let status = invoke(&peer, "plugin_status_items", json!({})).await.unwrap(); + assert!(status.as_array().unwrap().iter().any(|s| s["window"] == true && s["item"]["text"] == "remote")); + let oauth_peer = peer.clone(); + let oauth = tokio::spawn(async move { + invoke( + &oauth_peer, + "plugin_run_command", + json!({ "pluginId": "api14", "commandId": "oauth", "arg": null, "editor": null }), + ) + .await + }); + until("remote OAuth URI", 5, || { + client + .plugin_events + .lock() + .iter() + .any(|m| matches!(&m.event, PluginEvent::OutputChanged { channel, .. } if channel == "oauth")) + }) + .await; + let uri = client + .plugin_events + .lock() + .iter() + .find_map(|m| match &m.event { + PluginEvent::OutputChanged { channel, text: Some(text), .. } if channel == "oauth" => Some(text.clone()), + _ => None, + }) + .unwrap(); + let authority = uri.strip_prefix("http://").unwrap().split('/').next().unwrap(); + let mut stream = tokio::net::TcpStream::connect(authority).await.unwrap(); + stream.write_all(b"GET /oauth/callback?code=desktop%2Bcode HTTP/1.1\r\nHost: localhost\r\n\r\n").await.unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).await.unwrap(); + assert!(response.contains("You can close this tab")); + assert_eq!(oauth.await.unwrap().unwrap(), json!("code=desktop%2Bcode")); + plugins.host().shutdown(); + desktop.shutdown(); + peer.kill(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution_host() { + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir(&bin).unwrap(); + let executable = bin.join("execution-tool"); + std::fs::write(&executable, "#!/bin/sh\nif [ \"$1\" = wrapped ]; then printf '<%s>\\n' \"$@\" > \"$PWD/wrapped.txt\"; exit; fi\nread line\nprintf '%s\\n' \"$line\"\n").unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + let path_var = { + let bin = bin.clone(); + Arc::new(move || Some(bin.clone().into_os_string())) + }; + let desktop = + PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()) + .unwrap(); + desktop.set_program_path(path_var.clone()); + desktop.install_from_dir(&fixture("execution"), &Consent::all()).unwrap(); + let project = tmp.path().join("project"); + std::fs::create_dir(&project).unwrap(); + let project = project.canonicalize().unwrap(); + std::fs::write(project.join("execution.marker"), "").unwrap(); + let base = tmp.path().join("server"); + let peer_slot = Arc::new(OnceLock::new()); + let sink_peer = peer_slot.clone(); + 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.into()))), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap(); + plugins.host().set_program_path(path_var); + let mut dispatcher = Dispatcher::standard(); + plugins.register(&mut dispatcher); + let (client_io, server_io) = tokio::io::duplex(1 << 20); + let (sr, sw) = tokio::io::split(server_io); + tokio::spawn(rpc::serve(host, dispatcher, peer_slot, sr, sw)); + let (cr, cw) = tokio::io::split(client_io); + let client = Arc::new(Client::default()); + let peer = Peer::connect(cr, cw, client.clone()); + let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap(); + invoke(&peer, "open_project", json!({ "path": project })).await.unwrap(); + invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap(); + sync(&peer, &desktop, &[]).await; + assert!(sync(&peer, &desktop, &["execution"]).await.failed.is_empty()); + invoke( + &peer, + "plugin_run_command", + json!({"pluginId":"execution", "commandId":"target", "arg":null, "editor":null}), + ) + .await + .unwrap(); + let targets = invoke(&peer, "plugin_run_targets", json!({})).await.unwrap(); + assert_eq!(targets.as_array().unwrap().len(), 1); + assert_eq!(targets[0]["pluginId"], "execution"); + assert!(desktop.run_targets(&project).is_empty()); + let id = targets[0]["id"].as_str().unwrap(); + let settings = invoke(&peer, "plugin_answer_run_target", json!({"id":id, "useTarget":false})).await.unwrap(); + assert_eq!(settings["run"]["defaultTarget"], ""); + let config = json!({"id":"wrapped-php", "name":"Wrapped PHP", "kind":"php", "command":"php", "args":["x.php", "literal ; $(touch bad)"], "env":{}, "detected":false, "isTest":false, "target":id}); + invoke(&peer, "run_start", json!({"config":config, "onOutput":{"$channel":38}})).await.unwrap(); + until("remote wrapped run", 5, || project.join("wrapped.txt").exists()).await; + assert_eq!( + std::fs::read_to_string(project.join("wrapped.txt")).unwrap(), + "\n<--cwd=/container>\n\n\n\n" + ); + assert!(!project.join("bad").exists()); + invoke( + &peer, + "plugin_run_command", + json!({"pluginId":"execution", "commandId":"untarget", "arg":null, "editor":null}), + ) + .await + .unwrap(); + assert_eq!(invoke(&peer, "plugin_run_targets", json!({})).await.unwrap(), json!([])); + client.plugin_events.lock().clear(); + client.events.lock().clear(); + let runner = peer.clone(); + let run = tokio::spawn(async move { + invoke( + &runner, + "plugin_run_command", + json!({"pluginId": "execution", "commandId": "terminal", "arg": null, "editor": null}), + ) + .await + .unwrap() + }); + until("terminal request", 5, || { + client.plugin_events.lock().iter().any(|m| matches!(m.event, PluginEvent::WindowRequest { .. })) + }) + .await; + let (request_id, operation) = client + .plugin_events + .lock() + .iter() + .find_map(|m| match &m.event { + PluginEvent::WindowRequest { + request_id, + request: ide_host::plugins::WindowRequest::Terminal { operation, .. }, + .. + } => { + assert_eq!(m.window.as_deref(), Some("main")); + Some((request_id.clone(), operation.clone())) + } + _ => None, + }) + .unwrap(); + let terminal = invoke( + &peer, + "plugin_terminal_session_spawn", + json!({"operation": operation, "cols": 80, "rows": 24, "onOutput": {"$channel": 35}}), + ) + .await + .unwrap(); + assert!(terminal["id"].as_u64().is_some()); + assert!( + invoke( + &peer, + "plugin_terminal_session_spawn", + json!({"operation": operation, "cols": 80, "rows": 24, "onOutput": {"$channel": 36}}) + ) + .await + .is_err() + ); + invoke( + &peer, + "plugin_answer_command", + json!({"requestId": request_id, "answer": {"kind": "value", "value": null}}), + ) + .await + .unwrap(); + assert_eq!(run.await.unwrap(), operation); + invoke( + &peer, + "plugin_run_command", + json!({"pluginId": "execution", "commandId": "write", "arg": "finished\n", "editor": null}), + ) + .await + .unwrap(); + until("PTY exit", 5, || client.events.lock().iter().any(|m| matches!(m.event, IdeEvent::TerminalExited { .. }))) + .await; + plugins.host().flush_events_at(std::time::Instant::now() + Duration::from_secs(3)); + plugins.host().wait_events(Duration::from_secs(5)); + let received = invoke( + &peer, + "plugin_run_command", + json!({"pluginId": "execution", "commandId": "events", "arg": null, "editor": null}), + ) + .await + .unwrap(); + let received: Value = serde_json::from_str(received.as_str().unwrap()).unwrap(); + assert_eq!(received.as_array().unwrap().len(), 1); + assert_eq!(received[0]["kind"], "terminal-exited"); + let data: Value = serde_json::from_str(received[0]["data"].as_str().unwrap()).unwrap(); + assert_eq!(data["id"], operation); + assert_eq!(data["status"], 0); + assert_eq!(data["closed"], false); + + client.plugin_events.lock().clear(); + client.events.lock().clear(); + let runner = peer.clone(); + let run = tokio::spawn(async move { + invoke( + &runner, + "plugin_run_command", + json!({"pluginId": "execution", "commandId": "run", "arg": null, "editor": null}), + ) + .await + .unwrap() + }); + until("run request", 5, || { + client.plugin_events.lock().iter().any(|m| { + matches!(&m.event, PluginEvent::WindowRequest { request: ide_host::plugins::WindowRequest::Run { .. }, .. }) + }) + }) + .await; + let (request_id, operation, config) = client + .plugin_events + .lock() + .iter() + .find_map(|m| match &m.event { + PluginEvent::WindowRequest { + request_id, + request: ide_host::plugins::WindowRequest::Run { operation, config, .. }, + .. + } => Some((request_id.clone(), operation.clone(), config.clone())), + _ => None, + }) + .unwrap(); + let info = invoke(&peer, "run_start", json!({"config": config, "onOutput": {"$channel": 37}})).await.unwrap(); + invoke( + &peer, + "plugin_answer_command", + json!({"requestId": request_id, "answer": {"kind": "value", "value": null}}), + ) + .await + .unwrap(); + assert_eq!(run.await.unwrap(), operation); + invoke(&peer, "run_stdin", json!({"id": info["id"], "data": "finished\n"})).await.unwrap(); + until("run exit", 5, || client.events.lock().iter().any(|m| matches!(&m.event, IdeEvent::RunExited { .. }))).await; + plugins.host().flush_events_at(std::time::Instant::now() + Duration::from_secs(3)); + plugins.host().wait_events(Duration::from_secs(5)); + let received = invoke( + &peer, + "plugin_run_command", + json!({"pluginId": "execution", "commandId": "events", "arg": null, "editor": null}), + ) + .await + .unwrap(); + let received: Value = serde_json::from_str(received.as_str().unwrap()).unwrap(); + assert_eq!(received.as_array().unwrap().len(), 2); + assert_eq!(received[1]["kind"], "run-exited"); + let data: Value = serde_json::from_str(received[1]["data"].as_str().unwrap()).unwrap(); + assert_eq!(data["id"], operation); + assert_eq!(data["status"], 0); + assert_eq!(data["killed"], false); + plugins.host().shutdown(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn api14_database_sources_credentials_and_disable_lifetime_run_on_the_server() { + ide_ssh::secrets::testing::use_memory_store(); + let tmp = tempfile::tempdir().unwrap(); + let id = "test.data-tools"; + let desktop = + PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()) + .unwrap(); + desktop.install_from_dir(&fixture("data-tools"), &Consent::all()).unwrap(); + let project = tmp.path().join("project"); + std::fs::create_dir(&project).unwrap(); + let project = project.canonicalize().unwrap(); + std::fs::write(project.join("data.sqlite"), "").unwrap(); + let base = tmp.path().join("server"); + let slot = Arc::new(OnceLock::new()); + let sink = slot.clone(); + let host = Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("local"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(move |w| rpc::event_sink(sink.clone(), Some(w.into()))), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + let plugins = RemotePlugins::start(&base.join("data"), &host, slot.clone()).unwrap(); + let mut dispatcher = Dispatcher::standard(); + plugins.register(&mut dispatcher); + let (client_io, server_io) = tokio::io::duplex(1 << 20); + let (sr, sw) = tokio::io::split(server_io); + tokio::spawn(rpc::serve(host.clone(), dispatcher, slot, sr, sw)); + let (cr, cw) = tokio::io::split(client_io); + let client = Arc::new(Client::default()); + let peer = Peer::connect(cr, cw, client.clone()); + let _: Value = peer.request("initialize", json!({"version":rpc::PROTOCOL_VERSION}), None).await.unwrap(); + invoke(&peer, "open_project", json!({"path":project})).await.unwrap(); + invoke(&peer, "project_trust_set", json!({"path":project,"trusted":true,"folder":false})).await.unwrap(); + assert!(sync(&peer, &desktop, &[id]).await.failed.is_empty()); + let source = + json!({"id":"local","name":"Remote local","driver":"sqlite","database":"data.sqlite","options":[]}).to_string(); + invoke(&peer, "plugin_run_command", json!({"pluginId":id,"commandId":"upsert","arg":source,"editor":null})) + .await + .unwrap(); + invoke( + &peer, + "plugin_run_command", + json!({"pluginId":id,"commandId":"credential","arg":"server-only-secret","editor":null}), + ) + .await + .unwrap(); + let sources = invoke(&peer, "db_sources", json!({})).await.unwrap(); + assert_eq!(sources[0]["pluginOwner"], id); + let internal = sources[0]["id"].as_str().unwrap(); + let store = ide_db::secrets::SecretStore::new(base.join("data/db-passwords.json"), &project); + assert_eq!(store.get(internal).as_deref(), Some("server-only-secret")); + until("database source change", 5, || { + client.events.lock().iter().any(|m| matches!(m.event, IdeEvent::DbSourcesChanged)) + }) + .await; + desktop.set_enabled(id, false).unwrap(); + let report = sync(&peer, &desktop, &[]).await; + assert!(report.removed.is_empty()); + assert!(!invoke(&peer, "db_sources", json!({})).await.unwrap().as_array().unwrap().is_empty()); + desktop.set_enabled(id, true).unwrap(); + assert!(sync(&peer, &desktop, &[]).await.failed.is_empty()); + let runner = peer.clone(); + let connection = tokio::spawn(async move { + invoke( + &runner, + "plugin_run_command", + json!({"pluginId":"test.data-tools","commandId":"connect","arg":null,"editor":null}), + ) + .await + }); + until("database connect request", 5, || { + client.plugin_events.lock().iter().any(|m| { + matches!( + m.event, + PluginEvent::WindowRequest { request: ide_host::plugins::WindowRequest::DatabaseConnect { .. }, .. } + ) + }) + }) + .await; + let request = client + .plugin_events + .lock() + .iter() + .find_map(|m| match &m.event { + PluginEvent::WindowRequest { + request_id, + request: ide_host::plugins::WindowRequest::DatabaseConnect { .. }, + .. + } => Some(request_id.clone()), + _ => None, + }) + .unwrap(); + invoke(&peer, "db_connect", json!({"id":internal})).await.unwrap(); + invoke(&peer, "plugin_answer_command", json!({"requestId":request,"answer":{"kind":"value","value":null}})) + .await + .unwrap(); + connection.await.unwrap().unwrap(); + assert!(project.join("data.sqlite").exists()); + desktop.uninstall(id).unwrap(); + assert_eq!(sync(&peer, &desktop, &[]).await.removed, [id]); + assert!(invoke(&peer, "db_sources", json!({})).await.unwrap().as_array().unwrap().is_empty()); + assert!(store.get(internal).is_none()); + plugins.host().shutdown(); +} diff --git a/crates/ide-http/src/client.rs b/crates/ide-http/src/client.rs index 05963a4c5..66fcc97d2 100644 --- a/crates/ide-http/src/client.rs +++ b/crates/ide-http/src/client.rs @@ -26,6 +26,11 @@ pub fn with_proxy(builder: reqwest::ClientBuilder, proxy: &ProxySettings) -> req /// A client for `proxy`. pub fn build(proxy: &ProxySettings) -> IdeResult { + build_with_redirect(proxy, reqwest::redirect::Policy::default()) +} + +/// A client with an explicit redirect policy, for callers enforcing network grants. +pub fn build_with_redirect(proxy: &ProxySettings, redirect: reqwest::redirect::Policy) -> IdeResult { use rustls_platform_verifier::BuilderVerifierExt; let fail = |e: String| IdeError::internal(format!("HTTP client: {e}")); let provider = Arc::new(rustls::crypto::ring::default_provider()); @@ -44,6 +49,7 @@ pub fn build(proxy: &ProxySettings) -> IdeResult { ]; let builder = reqwest::Client::builder() .tls_backend_preconfigured(tls) + .redirect(redirect) .resolve_to_addrs("localhost", &loopback) .connect_timeout(Duration::from_secs(30)) .user_agent(concat!("OpenIDEA-HttpClient/", env!("CARGO_PKG_VERSION"))); diff --git a/crates/ide-plugin/Cargo.toml b/crates/ide-plugin/Cargo.toml index da304e85e..d58c78131 100644 --- a/crates/ide-plugin/Cargo.toml +++ b/crates/ide-plugin/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] ide-core.workspace = true +dunce.workspace = true ide-extension.workspace = true blake3.workspace = true serde.workspace = true @@ -16,6 +17,7 @@ thiserror.workspace = true tracing.workspace = true parking_lot.workspace = true semver = "1" +url = "2" wasmtime = { version = "49.0.4", default-features = false, features = ["runtime", "cranelift", "component-model", "std", "parallel-compilation", "async"] } wasmtime-wasi = { version = "49.0.4", default-features = false, features = ["p2"] } diff --git a/crates/ide-plugin/src/execution.rs b/crates/ide-plugin/src/execution.rs new file mode 100644 index 000000000..36c85bd39 --- /dev/null +++ b/crates/ide-plugin/src/execution.rs @@ -0,0 +1,153 @@ +//! Runs, keyed terminals and plugin-owned content editors (API 1.4). + +use serde::{Deserialize, Serialize}; +use specta::Type; + +/// ContentAction data exchanged with the IDE. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ContentAction { + pub id: String, + pub label: String, + pub icon: Option, + pub primary: bool, + pub save: bool, +} + +/// ContentTab data exchanged with the IDE. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ContentTab { + pub key: String, + pub title: String, + pub file_name: Option, + pub language: Option, + pub content: String, + pub read_only: bool, + pub actions: Vec, +} + +/// ContentDiff data exchanged with the IDE. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ContentDiff { + pub left: String, + pub right: String, + pub left_label: Option, + pub right_label: Option, + pub title: Option, +} + +/// ContentTabReply data exchanged with the IDE. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ContentTabReply { + pub content: Option, + pub saved: Option, + pub read_only: Option, + pub status: Option, + pub error: Option, + pub diff: Option, +} + +/// PluginRunConfiguration data exchanged with the IDE. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PluginRunConfiguration { + pub id: String, + pub name: String, + pub program: String, + pub args: Vec, + pub cwd: Option, + pub test: bool, + pub framework: Option, +} + +/// RunExited data exchanged with the IDE. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RunExited { + pub id: String, + pub config: String, + pub status: Option, + pub killed: bool, +} + +/// TerminalExited data exchanged with the IDE. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TerminalExited { + pub id: String, + pub key: String, + pub status: Option, + pub closed: bool, +} + +/// ContentTabAction data exchanged with the IDE. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ContentTabAction { + pub request_id: String, + pub key: String, + pub action: String, + pub content: String, +} + +/// Incremental test tree data. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TestStart { + pub id: String, + pub parent: Option, + pub name: String, + pub location: Option, +} + +/// Incremental test tree data. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TestPassed { + pub id: String, + pub duration_ms: Option, +} + +/// Incremental test tree data. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TestFailed { + pub id: String, + pub duration_ms: Option, + pub message: String, + pub expected: Option, + pub actual: Option, + pub location: Option, +} + +/// Incremental test tree data. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TestSkipped { + pub id: String, + pub reason: Option, +} + +/// Incremental test tree data. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TestOutput { + pub id: String, + pub text: String, +} + +/// A test update, carried by the optional test-results export. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PluginTestEvent { + SuiteStarted(TestStart), + TestStarted(TestStart), + Passed(TestPassed), + Failed(TestFailed), + Skipped(TestSkipped), + Output(TestOutput), + Other { kind: String, data: String }, +} diff --git a/crates/ide-plugin/src/language_features.rs b/crates/ide-plugin/src/language_features.rs new file mode 100644 index 000000000..7530054d8 --- /dev/null +++ b/crates/ide-plugin/src/language_features.rs @@ -0,0 +1,109 @@ +//! Optional API 1.4 language features and embedded language ranges. +use ide_core::Range; +use serde::{Deserialize, Serialize}; +use specta::Type; +/// A parameter label, as text or UTF-16 offsets within the signature. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +pub enum ParameterLabel { + Text(String), + Offsets((u32, u32)), +} +/// FormatOptions data. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FormatOptions { + pub tab_size: u32, + pub insert_spaces: bool, +} +/// Parameter data. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct Parameter { + pub label: ParameterLabel, + pub documentation: Option, +} +/// Signature data. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct Signature { + pub label: String, + pub documentation: Option, + pub parameters: Vec, +} +/// SignatureHelp data. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SignatureHelp { + pub signatures: Vec, + pub active_signature: u32, + pub active_parameter: u32, +} +/// Highlight data. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct Highlight { + pub range: Range, + pub kind: String, +} +/// DocumentLink data. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DocumentLink { + pub range: Range, + pub target: String, +} +/// CodeLens data. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct CodeLens { + pub range: Range, + pub title: String, + pub command: String, + pub argument: Option, +} +/// FoldRange data. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FoldRange { + pub start_line: u32, + pub end_line: u32, + pub kind: String, +} +/// ColorInfo data. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ColorInfo { + pub range: Range, + pub red: f32, + pub green: f32, + pub blue: f32, + pub alpha: f32, +} +/// DocumentSymbol data. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DocumentSymbol { + pub name: String, + pub detail: String, + pub kind: String, + pub range: Range, + pub selection_range: Range, + pub parent: Option, +} +/// GutterAction data. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct GutterAction { + pub line: u32, + pub icon: Option, + pub tooltip: String, + pub command: String, + pub argument: Option, +} +/// Injection data. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct Injection { + pub range: Range, + pub language: String, +} diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index 1878bb790..6a000bfda 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -23,3 +23,10 @@ pub use runtime::{ pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real}; pub use types::*; pub use wasmtime::component::Component; + +mod execution; +pub use execution::*; + +pub use ide_core::remote_settings::PluginRunTarget; + +pub mod language_features; diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 28fa745a3..6c1bda1dc 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -16,6 +16,8 @@ use crate::types::{ FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, TableSort, View, ViewBody, ViewButton, ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport, }; +use crate::wit_host::injections_bindings::exports::openidea::plugin::injections as wit_inj; +use crate::wit_host::language_features_bindings::exports::openidea::plugin::language_features as wit_lf; use crate::wit_host::{ bindings, commit_checks_bindings, details_bindings, endpoints_bindings, file_actions_bindings, hover_bindings, indexing_bindings, inlay_bindings, providers_bindings, semantic_bindings, syntax_bindings, views_bindings, @@ -33,6 +35,8 @@ use wasmtime::{Config, Engine, Store, StoreLimits, StoreLimitsBuilder, UpdateDea use wasmtime_wasi::p2::pipe::MemoryOutputPipe; use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; +use crate::wit_host::run_configurations_bindings::exports::openidea::plugin::run_configurations as wit_configs; +use crate::wit_host::test_results_bindings::exports::openidea::plugin::test_results as wit_tests; use bindings::openidea::plugin::events as wit_events; use bindings::openidea::plugin::types as wit_types; use commit_checks_bindings::exports::openidea::plugin::commit_checker as wit_cc; @@ -50,7 +54,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view; use views_bindings::openidea::plugin::view_types as wit_vt; /// The API version of the world above, `major.minor` of [`host_api`]. -pub const API_VERSION: &str = "1.3"; +pub const API_VERSION: &str = "1.4"; const EPOCH_TICK: Duration = Duration::from_millis(10); pub(crate) const MAX_TEXT: usize = 1 << 20; pub(crate) const MAX_TITLE: usize = 256; @@ -62,6 +66,190 @@ pub const CANCELLED: &str = "was cancelled"; /// which routes notifications and status items to windows and edits to open /// documents. Capabilities are already checked when these are called. pub trait HostApi: Send + Sync { + /// Creates/replaces this plugin's non-secret project connection settings. + fn database_upsert(&self, root: &Path, source: crate::PluginDataSource) -> Result<(), HostError> { + let _ = (root, source); + Err(HostError::Invalid("Database subsystem is unavailable on this host".into())) + } + /// Removes only an owned data source and its credentials. + fn database_remove(&self, root: &Path, id: &str) -> Result<(), HostError> { + let _ = (root, id); + Err(HostError::Invalid("Database subsystem is unavailable on this host".into())) + } + /// Private ids of only this plugin's sources. + fn database_sources(&self, root: &Path) -> Result, HostError> { + let _ = root; + Err(HostError::Invalid("Database subsystem is unavailable on this host".into())) + } + /// Writes a password to the Database credential store, without a read path. + fn database_credential(&self, root: &Path, id: &str, password: String) -> Result<(), HostError> { + let _ = (root, id, password); + Err(HostError::Invalid("Database subsystem is unavailable on this host".into())) + } + /// Requests normal connection/introspection in the invoking project's window. + fn database_connect( + &self, + root: &Path, + client: &str, + id: &str, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result<(), HostError> { + let _ = (root, client, id, cancel, timeout); + Err(HostError::Invalid("Database subsystem is unavailable on this host".into())) + } + /// Stdio argv only; no URL or bearer token. + fn mcp_stdio_command(&self, client: &str) -> Option> { + let _ = client; + None + } + + /// Registers a project-scoped wrapper after validating its process grant and pin. + fn register_run_target(&self, root: &Path, target: crate::PluginRunTarget) -> Result<(), HostError> { + let _ = (root, target); + Err(HostError::Failed("run targets unavailable".into())) + } + /// Withdraws this plugin's target, preserving the user's remembered prompt answer. + fn unregister_run_target(&self, root: &Path, id: &str) { + let _ = (root, id); + } + + /// Starts one of this plugin's configurations in a window. + fn start_run( + &self, + client: &str, + config: &str, + args: Vec, + debug: bool, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result { + let _ = (client, config, args, debug, cancel, timeout); + Err(HostError::Failed("runs unavailable".into())) + } + /// Opens/reuses a keyed terminal, retained across idle instance reclamation. + fn open_terminal_session( + &self, + client: &str, + key: String, + title: String, + cmd: ProcessCommand, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result { + let _ = (client, key, title, cmd, cancel, timeout); + Err(HostError::Failed("terminals unavailable".into())) + } + /// Writes only to an owned, live terminal. + fn write_terminal( + &self, + client: &str, + key: &str, + text: String, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result<(), HostError> { + let _ = (client, key, text, cancel, timeout); + Err(HostError::Failed("terminals unavailable".into())) + } + /// Opens a content tab through the shared editor implementation. + fn open_content_tab( + &self, + client: &str, + tab: crate::ContentTab, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result<(), HostError> { + let _ = (client, tab, cancel, timeout); + Err(HostError::Failed("content tabs unavailable".into())) + } + /// Answers a content action in the invoking window. + fn reply_content_tab(&self, client: &str, request: &str, reply: crate::ContentTabReply) -> Result<(), HostError> { + let _ = (client, request, reply); + Err(HostError::Failed("content tabs unavailable".into())) + } + /// The invoking window's editors. + fn open_editors(&self, client: &str) -> Result, HostError> { + let _ = client; + Ok(Vec::new()) + } + /// Opens a validated file and optionally selects a range. + fn editor_open(&self, client: &str, path: &Path, selection: Option) -> Result<(), HostError> { + let _ = (client, path, selection); + Err(HostError::Failed("editor unavailable".into())) + } + /// Changes the selections, or reveals a range, only in an editor of this window. + fn editor_ranges( + &self, + client: &str, + path: &Path, + ranges: Vec, + reveal: bool, + ) -> Result<(), HostError> { + let _ = (client, path, ranges, reveal); + Err(HostError::Failed("editor unavailable".into())) + } + /// Find in Files with the project's document overlay. + fn search_text( + &self, + client: Option<&str>, + root: &Path, + query: crate::TextQuery, + limit: u32, + cancel: &CancellationToken, + deadline: Instant, + ) -> Result { + let _ = (client, root, query, limit, cancel, deadline); + Err(HostError::Failed("search unavailable".into())) + } + /// Window-reported Problems view snapshot. + fn problems(&self, client: &str, path: Option<&Path>) -> Result, HostError> { + let _ = (client, path); + Ok(Vec::new()) + } + /// Replace or clear an editor decoration set. + fn decorations(&self, path: Option<&Path>, set: &str, items: Vec) -> Result<(), HostError> { + let _ = (path, set, items); + Err(HostError::Failed("decorations unavailable".into())) + } + /// Replace or clear a tree decoration set. + fn file_decorations( + &self, + path: Option<&Path>, + set: &str, + item: Option, + ) -> Result<(), HostError> { + let _ = (path, set, item); + Err(HostError::Failed("decorations unavailable".into())) + } + /// Trusted repository history. + fn vcs_log( + &self, + root: &Path, + path: Option<&str>, + limit: u32, + cancel: &CancellationToken, + ) -> Result, HostError> { + let _ = (root, path, limit, cancel); + Err(HostError::Failed("git unavailable".into())) + } + /// Trusted repository attribution, including unsaved documents. + fn vcs_blame(&self, client: Option<&str>, root: &Path, path: &str) -> Result, HostError> { + let _ = (client, root, path); + Err(HostError::Failed("git unavailable".into())) + } + /// Trusted repository working-tree patch, bounded to 4 MiB. + fn vcs_diff( + &self, + root: &Path, + path: Option<&str>, + against: Option<&str>, + cancel: &CancellationToken, + ) -> Result, HostError> { + let _ = (root, path, against, cancel); + Err(HostError::Failed("git unavailable".into())) + } + /// Shows a notification; `actions` name commands the plugin declares. fn notify( &self, @@ -101,8 +289,45 @@ pub trait HostApi: Send + Sync { let _ = (client, view); Ok(()) } + /// Whether this stable id is a command declared by this plugin. + fn owns_command(&self, id: &str) -> bool { + let _ = id; + false + } + /// Executes a stable command in the invoking window and waits for its result. + fn execute_command( + &self, + invocation: &Invocation, + id: &str, + arg: Option, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result, HostError> { + let _ = (invocation, id, arg, cancel, timeout); + Err(HostError::NotFound("command".into())) + } + /// Appends text, or clears (`None`), in a plugin-owned output channel. + fn output_write(&self, channel: &str, text: Option<&str>) -> Result<(), HostError> { + let _ = (channel, text); + Err(HostError::Failed("output unavailable".into())) + } + /// Reveals an output channel in the invoking window. + fn output_reveal(&self, client: &str, channel: &str) -> Result<(), HostError> { + let _ = (client, channel); + Err(HostError::Failed("output unavailable".into())) + } + /// Checks manifest minimum API requirements for new declarations or subscriptions. + fn require_api(&self, what: &str, minor: u64) -> Result<(), HostError> { + let _ = (what, minor); + Ok(()) + } /// Sets (`Some`) or removes the plugin's status bar item `id`. fn status_item(&self, id: &str, item: Option); + /// Sets or removes the status override in the invoking window. + fn window_status_item(&self, client: &str, id: &str, item: Option) -> Result<(), HostError> { + let _ = (client, id, item); + Err(HostError::Invalid("window status unavailable".into())) + } /// Sets (`Some`, validated JSON text) or removes the plugin's context key, /// for window `client` (every window if `None`). fn set_context(&self, client: Option<&str>, key: &str, value: Option) -> Result<(), HostError> { @@ -181,6 +406,62 @@ pub trait HostApi: Send + Sync { /// Performs `request` (its host is granted), filling in secret placeholders. fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result; fn secret_is_set(&self, name: &str) -> bool; + + /// Stores or deletes a secret declared by this plugin; never returns its value. + fn secret_set(&self, name: &str, value: Option<&str>) -> Result<(), HostError> { + let _ = (name, value); + Err(HostError::NotFound("secret".into())) + } + /// Creates a desktop loopback listener and returns its handle and redirect URI. + fn begin_loopback(&self, client: &str, path: &str) -> Result<(u64, String), HostError> { + let _ = (client, path); + Err(HostError::Failed("loopback unavailable".into())) + } + /// Awaits a browser redirect, excluded from the caller's execution budget. + fn wait_loopback( + &self, + id: u64, + timeout: Duration, + cancel: &CancellationToken, + ) -> Result, HostError> { + let _ = (id, timeout, cancel); + Err(HostError::NotFound("loopback".into())) + } + /// Releases a desktop listener when its resource or plugin stops. + fn close_loopback(&self, id: u64) { + let _ = id; + } + /// Connects to an approved host-local socket; owned by this plugin instance. + fn ipc_connect(&self, name: &str, cancel: &CancellationToken, timeout: Duration) -> Result { + let _ = (name, cancel, timeout); + Err(HostError::Failed("IPC unavailable".into())) + } + /// Writes to an owned IPC connection. + fn ipc_write( + &self, + id: u64, + bytes: Vec, + cancel: &CancellationToken, + timeout: Duration, + ) -> Result<(), HostError> { + let _ = (id, bytes, cancel, timeout); + Err(HostError::NotFound("connection".into())) + } + /// Reads from an owned IPC connection, empty on timeout or EOF. + fn ipc_read( + &self, + id: u64, + max: usize, + timeout: Duration, + cancel: &CancellationToken, + ) -> Result, HostError> { + let _ = (id, max, timeout, cancel); + Err(HostError::NotFound("connection".into())) + } + /// Closes an owned IPC connection, including when its resource is dropped. + fn ipc_close(&self, id: u64) { + let _ = id; + } /// Runs the approved file of `process:` with `cmd.env` added to /// the inherited environment, and waits. fn run_process( @@ -631,6 +912,8 @@ pub struct Runtime { /// What the component exports: the `plugin` world, plus optional interfaces. struct Exports { + language_features: Option, + injections: Option, plugin: bindings::Plugin, syntax: Option, providers: Option, @@ -644,6 +927,9 @@ struct Exports { commit_checks: Option, inlay: Option, endpoints: Option, + run_configurations: Option, + test_results: Option, + mcp: Option, } /// Most results one syntax call may return (more are dropped). @@ -653,6 +939,30 @@ const MAX_BATCH: usize = 1000; fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event { match e { + WorkbenchEvent::RunExited(value) => wit_events::Event::Other(wit_types::Extension { + kind: "run-exited".into(), + data: serde_json::to_string(&value).unwrap(), + }), + WorkbenchEvent::TerminalExited(value) => wit_events::Event::Other(wit_types::Extension { + kind: "terminal-exited".into(), + data: serde_json::to_string(&value).unwrap(), + }), + WorkbenchEvent::ContentTabAction(value) => wit_events::Event::Other(wit_types::Extension { + kind: "content-tab-action".into(), + data: serde_json::to_string(&value).unwrap(), + }), + WorkbenchEvent::FileClosed(p) => wit_events::Event::Other(wit_types::Extension { + kind: "file-closed".into(), + data: serde_json::to_string(&p).expect("path serializes"), + }), + WorkbenchEvent::SettingsChanged(keys) => wit_events::Event::Other(wit_types::Extension { + kind: "settings-changed".into(), + data: serde_json::to_string(&keys).expect("keys serialize"), + }), + WorkbenchEvent::InputModeChanged(mode) => wit_events::Event::Other(wit_types::Extension { + kind: "input-mode-changed".into(), + data: serde_json::to_string(&mode).expect("mode serializes"), + }), WorkbenchEvent::FileOpened(p) => wit_events::Event::FileOpened(p), WorkbenchEvent::FileSaved(p) => wit_events::Event::FileSaved(p), WorkbenchEvent::FilesChanged(batch) => wit_events::Event::Other(wit_types::Extension { @@ -737,7 +1047,19 @@ impl Runtime { let commit_check_indices = wit_cc::GuestIndices::new(&pre).ok(); let inlay_indices = wit_inlay::GuestIndices::new(&pre).ok(); let endpoint_indices = wit_endpoints::GuestIndices::new(&pre).ok(); + let configs_indices = wit_configs::GuestIndices::new(&pre).ok(); + let tests_indices = wit_tests::GuestIndices::new(&pre).ok(); + let lf_indices = wit_lf::GuestIndices::new(&pre).ok(); + let inj_indices = wit_inj::GuestIndices::new(&pre).ok(); + let mcp_indices = + crate::wit_host::mcp_bindings::exports::openidea::plugin::mcp_provider::GuestIndices::new(&pre).ok(); let instance = pre.instantiate(&mut store).map_err(api_hint)?; + let mcp = mcp_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?; + let run_configurations = + configs_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?; + let test_results = tests_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?; + let language_features = lf_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?; + let injections = inj_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?; let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?; let syntax = match syntax_indices { Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), @@ -790,6 +1112,8 @@ impl Runtime { Ok(Self { store, exports: Exports { + language_features, + injections, plugin, syntax, providers, @@ -802,6 +1126,9 @@ impl Runtime { commit_checks, inlay, endpoints, + run_configurations, + test_results, + mcp, file_actions, }, limits: setup.limits, @@ -809,6 +1136,55 @@ impl Runtime { }) } + /// Complete configurations from the optional API 1.4 export. + pub fn run_configurations( + &mut self, + root: String, + timeout: Duration, + ) -> Result, CallError> { + if self.exports.run_configurations.is_none() { + return Err(CallError::missing_export("run-configurations")); + } + let invocation = Invocation { workspace_root: Some(root.clone().into()), ..Default::default() }; + let items = self.call(invocation, timeout, |e, store| { + e.run_configurations.as_ref().unwrap().call_configurations(store, &root) + })?; + if items.len() > 1000 { + return Err(CallError::Guest(GuestError::Invalid("too many run configurations".into()))); + } + Ok(items + .into_iter() + .map(|c| crate::PluginRunConfiguration { + id: c.id, + name: c.name, + program: c.program, + args: c.args, + cwd: c.cwd, + test: c.test, + framework: c.framework, + }) + .collect()) + } + /// One editor-budget batch of a plugin test parser. + pub fn parse_test_results( + &mut self, + config: String, + chunk: String, + invocation: Invocation, + timeout: Duration, + ) -> Result, CallError> { + if self.exports.test_results.is_none() { + return Err(CallError::missing_export("test-results")); + } + let items = self.call(invocation, timeout, |e, store| { + e.test_results.as_ref().unwrap().call_parse(store, &config, &chunk) + })?; + if items.len() > 10000 { + return Err(CallError::Guest(GuestError::Invalid("too many test events".into()))); + } + Ok(items.into_iter().map(test_event_from_wit).collect()) + } + /// The component exports `syntax-provider`. pub fn has_syntax(&self) -> bool { self.exports.syntax.is_some() @@ -1809,6 +2185,392 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent { } } +fn test_event_from_wit(event: wit_tests::TestEvent) -> crate::PluginTestEvent { + use crate::{PluginTestEvent as E, TestFailed, TestOutput, TestPassed, TestSkipped, TestStart}; + let location = |l: wit_types::Location| crate::Location { + path: l.path.into(), + range: ide_core::Range::new( + ide_core::Pos::new(l.range.start.line, l.range.start.character), + ide_core::Pos::new(l.range.end.line, l.range.end.character), + ), + }; + let start = |v: wit_tests::TestStart| TestStart { + id: v.id, + parent: v.parent, + name: v.name, + location: v.location.map(location), + }; + match event { + wit_tests::TestEvent::SuiteStarted(v) => E::SuiteStarted(start(v)), + wit_tests::TestEvent::TestStarted(v) => E::TestStarted(start(v)), + wit_tests::TestEvent::Passed(v) => E::Passed(TestPassed { id: v.id, duration_ms: v.duration_ms }), + wit_tests::TestEvent::Failed(v) => E::Failed(TestFailed { + id: v.id, + duration_ms: v.duration_ms, + message: v.message, + expected: v.expected, + actual: v.actual, + location: v.location.map(location), + }), + wit_tests::TestEvent::Skipped(v) => E::Skipped(TestSkipped { id: v.id, reason: v.reason }), + wit_tests::TestEvent::Output(v) => E::Output(TestOutput { id: v.id, text: v.text }), + wit_tests::TestEvent::Other(v) => E::Other { kind: v.kind, data: v.data }, + } +} + +fn parameter_from(v: wit_lf::Parameter) -> crate::language_features::Parameter { + crate::language_features::Parameter { + label: parameter_label_from(v.label), + documentation: v.documentation.map(|v| crate::runtime::clip(v, 65536)), + } +} +fn signature_from(v: wit_lf::Signature) -> crate::language_features::Signature { + crate::language_features::Signature { + label: crate::runtime::clip(v.label, 65536), + documentation: v.documentation.map(|v| crate::runtime::clip(v, 65536)), + parameters: v.parameters.into_iter().take(10000).map(parameter_from).collect(), + } +} +fn signature_help_from(v: wit_lf::SignatureHelpResult) -> crate::language_features::SignatureHelp { + crate::language_features::SignatureHelp { + signatures: v.signatures.into_iter().take(10000).map(signature_from).collect(), + active_signature: v.active_signature, + active_parameter: v.active_parameter, + } +} +fn highlight_from(v: wit_lf::Highlight) -> crate::language_features::Highlight { + crate::language_features::Highlight { range: range_from_wit(v.range), kind: crate::runtime::clip(v.kind, 65536) } +} +fn document_link_from(v: wit_lf::DocumentLink) -> crate::language_features::DocumentLink { + crate::language_features::DocumentLink { + range: range_from_wit(v.range), + target: crate::runtime::clip(v.target, 65536), + } +} +fn code_lens_from(v: wit_lf::CodeLens) -> crate::language_features::CodeLens { + crate::language_features::CodeLens { + range: range_from_wit(v.range), + title: crate::runtime::clip(v.title, 65536), + command: crate::runtime::clip(v.command, 65536), + argument: v.argument.map(|v| crate::runtime::clip(v, 65536)), + } +} +fn fold_range_from(v: wit_lf::FoldRange) -> crate::language_features::FoldRange { + crate::language_features::FoldRange { + start_line: v.start_line, + end_line: v.end_line, + kind: crate::runtime::clip(v.kind, 65536), + } +} +fn color_info_from(v: wit_lf::ColorInfo) -> crate::language_features::ColorInfo { + crate::language_features::ColorInfo { + range: range_from_wit(v.range), + red: v.red, + green: v.green, + blue: v.blue, + alpha: v.alpha, + } +} +fn document_symbol_from(v: wit_lf::DocumentSymbol) -> crate::language_features::DocumentSymbol { + crate::language_features::DocumentSymbol { + name: crate::runtime::clip(v.name, 65536), + detail: crate::runtime::clip(v.detail, 65536), + kind: crate::runtime::clip(v.kind, 65536), + range: range_from_wit(v.range), + selection_range: range_from_wit(v.selection_range), + parent: v.parent, + } +} +fn gutter_action_from(v: wit_lf::GutterAction) -> crate::language_features::GutterAction { + crate::language_features::GutterAction { + line: v.line, + icon: v.icon.map(|v| crate::runtime::clip(v, 65536)), + tooltip: crate::runtime::clip(v.tooltip, 65536), + command: crate::runtime::clip(v.command, 65536), + argument: v.argument.map(|v| crate::runtime::clip(v, 65536)), + } +} +fn parameter_label_from(v: wit_lf::ParameterLabel) -> crate::language_features::ParameterLabel { + use crate::language_features::ParameterLabel as P; + match v { + wit_lf::ParameterLabel::Text(t) => P::Text(clip(t, 4096)), + wit_lf::ParameterLabel::Offsets(t) => P::Offsets(t), + } +} +impl Runtime { + fn language_feature_call( + &mut self, + timeout: Duration, + f: impl FnOnce(&wit_lf::Guest, &mut Store) -> wasmtime::Result, + ) -> Result { + if self.exports.language_features.is_none() { + return Err(CallError::missing_export("language-features")); + } + self.call(Invocation::default(), timeout, |e, s| f(e.language_features.as_ref().expect("checked"), s)) + } + pub fn language_format( + &mut self, + doc: &ProviderDocument, + range: Option, + options: crate::language_features::FormatOptions, + timeout: Duration, + ) -> Result, CallError> { + let d = wit_lf::Document { + path: to_ipc(&doc.path), + language: doc.language.clone(), + version: doc.version, + text: doc.text.clone(), + }; + let result = self.language_feature_call(timeout, |p, s| { + p.call_format( + s, + &d, + range.map(range_to_wit), + wit_lf::FormatOptions { tab_size: options.tab_size, insert_spaces: options.insert_spaces }, + ) + })?; + let result = result.map_err(|e| CallError::Guest(e.into()))?; + Ok(result.into_iter().take(10000).map(edit_from_wit).collect()) + } + pub fn language_signature_help( + &mut self, + doc: &ProviderDocument, + at: ide_core::Pos, + timeout: Duration, + ) -> Result, CallError> { + let d = wit_lf::Document { + path: to_ipc(&doc.path), + language: doc.language.clone(), + version: doc.version, + text: doc.text.clone(), + }; + let result = self.language_feature_call(timeout, |p, s| p.call_signature_help(s, &d, pos_to_wit(at)))?; + Ok(result.map(signature_help_from)) + } + pub fn language_document_highlights( + &mut self, + doc: &ProviderDocument, + at: ide_core::Pos, + timeout: Duration, + ) -> Result, CallError> { + let d = wit_lf::Document { + path: to_ipc(&doc.path), + language: doc.language.clone(), + version: doc.version, + text: doc.text.clone(), + }; + let result = self.language_feature_call(timeout, |p, s| p.call_document_highlights(s, &d, pos_to_wit(at)))?; + Ok(result.into_iter().take(10000).map(highlight_from).collect()) + } + pub fn language_document_links( + &mut self, + doc: &ProviderDocument, + timeout: Duration, + ) -> Result, CallError> { + let d = wit_lf::Document { + path: to_ipc(&doc.path), + language: doc.language.clone(), + version: doc.version, + text: doc.text.clone(), + }; + let result = self.language_feature_call(timeout, |p, s| p.call_document_links(s, &d))?; + Ok(result.into_iter().take(10000).map(document_link_from).collect()) + } + pub fn language_code_lenses( + &mut self, + doc: &ProviderDocument, + timeout: Duration, + ) -> Result, CallError> { + let d = wit_lf::Document { + path: to_ipc(&doc.path), + language: doc.language.clone(), + version: doc.version, + text: doc.text.clone(), + }; + let result = self.language_feature_call(timeout, |p, s| p.call_code_lenses(s, &d))?; + Ok(result.into_iter().take(10000).map(code_lens_from).collect()) + } + pub fn language_selection_ranges( + &mut self, + doc: &ProviderDocument, + at: ide_core::Pos, + timeout: Duration, + ) -> Result, CallError> { + let d = wit_lf::Document { + path: to_ipc(&doc.path), + language: doc.language.clone(), + version: doc.version, + text: doc.text.clone(), + }; + let result = self.language_feature_call(timeout, |p, s| p.call_selection_ranges(s, &d, pos_to_wit(at)))?; + Ok(result.into_iter().take(10000).map(range_from_wit).collect()) + } + pub fn language_folding( + &mut self, + doc: &ProviderDocument, + timeout: Duration, + ) -> Result, CallError> { + let d = wit_lf::Document { + path: to_ipc(&doc.path), + language: doc.language.clone(), + version: doc.version, + text: doc.text.clone(), + }; + let result = self.language_feature_call(timeout, |p, s| p.call_folding(s, &d))?; + Ok(result.into_iter().take(10000).map(fold_range_from).collect()) + } + pub fn language_colors( + &mut self, + doc: &ProviderDocument, + timeout: Duration, + ) -> Result, CallError> { + let d = wit_lf::Document { + path: to_ipc(&doc.path), + language: doc.language.clone(), + version: doc.version, + text: doc.text.clone(), + }; + let result = self.language_feature_call(timeout, |p, s| p.call_colors(s, &d))?; + Ok(result.into_iter().take(10000).map(color_info_from).collect()) + } + pub fn language_color_presentations( + &mut self, + doc: &ProviderDocument, + color: crate::language_features::ColorInfo, + timeout: Duration, + ) -> Result, CallError> { + let d = wit_lf::Document { + path: to_ipc(&doc.path), + language: doc.language.clone(), + version: doc.version, + text: doc.text.clone(), + }; + let result = self.language_feature_call(timeout, |p, s| { + p.call_color_presentations( + s, + &d, + wit_lf::ColorInfo { + range: range_to_wit(color.range), + red: color.red, + green: color.green, + blue: color.blue, + alpha: color.alpha, + }, + ) + })?; + Ok(result.into_iter().take(10000).map(|v| crate::runtime::clip(v, 65536)).collect()) + } + pub fn language_document_symbols( + &mut self, + doc: &ProviderDocument, + timeout: Duration, + ) -> Result, CallError> { + let d = wit_lf::Document { + path: to_ipc(&doc.path), + language: doc.language.clone(), + version: doc.version, + text: doc.text.clone(), + }; + let result = self.language_feature_call(timeout, |p, s| p.call_document_symbols(s, &d))?; + Ok(result.into_iter().take(10000).map(document_symbol_from).collect()) + } + pub fn language_implementation( + &mut self, + doc: &ProviderDocument, + at: ide_core::Pos, + timeout: Duration, + ) -> Result, CallError> { + let d = wit_lf::Document { + path: to_ipc(&doc.path), + language: doc.language.clone(), + version: doc.version, + text: doc.text.clone(), + }; + let result = self.language_feature_call(timeout, |p, s| p.call_implementation(s, &d, pos_to_wit(at)))?; + Ok(result.into_iter().take(10000).map(location_from_wit).collect()) + } + pub fn language_type_definition( + &mut self, + doc: &ProviderDocument, + at: ide_core::Pos, + timeout: Duration, + ) -> Result, CallError> { + let d = wit_lf::Document { + path: to_ipc(&doc.path), + language: doc.language.clone(), + version: doc.version, + text: doc.text.clone(), + }; + let result = self.language_feature_call(timeout, |p, s| p.call_type_definition(s, &d, pos_to_wit(at)))?; + Ok(result.into_iter().take(10000).map(location_from_wit).collect()) + } + pub fn language_gutter_actions( + &mut self, + doc: &ProviderDocument, + timeout: Duration, + ) -> Result, CallError> { + let d = wit_lf::Document { + path: to_ipc(&doc.path), + language: doc.language.clone(), + version: doc.version, + text: doc.text.clone(), + }; + let result = self.language_feature_call(timeout, |p, s| p.call_gutter_actions(s, &d))?; + Ok(result.into_iter().take(10000).map(gutter_action_from).collect()) + } + pub fn provide_injections( + &mut self, + doc: &ProviderDocument, + timeout: Duration, + ) -> Result, CallError> { + if self.exports.injections.is_none() { + return Err(CallError::missing_export("injections")); + } + let d = wit_inj::Document { + path: to_ipc(&doc.path), + language: doc.language.clone(), + version: doc.version, + text: doc.text.clone(), + }; + let result = self.call(Invocation::default(), timeout, |e, s| { + e.injections.as_ref().expect("checked").call_injections(s, &d) + })?; + Ok(result + .into_iter() + .take(10000) + .map(|i| crate::language_features::Injection { + range: range_from_wit(i.range), + language: clip(i.language, 64), + }) + .collect()) + } +} + +impl Runtime { + /// Optional MCP export, with the same wall-clock and fuel budgets as commands. + pub fn mcp_call(&mut self, tool: &str, arguments: &str, invocation: Invocation) -> Result { + if self.exports.mcp.is_none() { + return Err(CallError::missing_export("mcp-provider")); + } + let result = self.call(invocation, self.limits.timeout, |e, store| { + let ctx = store.data_mut().new_invocation()?; + let result = e.mcp.as_ref().unwrap().call_call( + &mut *store, + tool, + arguments, + wasmtime::component::Resource::new_borrow(ctx.rep()), + ); + store.data_mut().drop_invocation(ctx); + result + })?; + let result = result.map_err(|e| CallError::Guest(e.into()))?; + if result.len() > 4 << 20 { + return Err(CallError::Guest(GuestError::Invalid("MCP result exceeds 4 MiB".into()))); + } + Ok(result) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ide-plugin/src/sandbox.rs b/crates/ide-plugin/src/sandbox.rs index 153cae609..0e320cf3c 100644 --- a/crates/ide-plugin/src/sandbox.rs +++ b/crates/ide-plugin/src/sandbox.rs @@ -17,7 +17,7 @@ fn joined_path(root: &Path, path: &str) -> Result<(PathBuf, PathBuf), String> { if path.is_empty() { return Err("empty path".into()); } - let root = root.canonicalize().map_err(|e| format!("project root unavailable: {e}"))?; + let root = dunce::canonicalize(root).map_err(|e| format!("project root unavailable: {e}"))?; let requested = Path::new(path); let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) }; Ok((root, joined)) @@ -31,7 +31,7 @@ pub fn resolve_real(joined: &Path) -> PathBuf { let mut tail = Vec::new(); let mut candidate = joined.to_path_buf(); let mut real = loop { - match candidate.canonicalize() { + match dunce::canonicalize(&candidate) { Ok(real) => break real, Err(_) => { let Some(last) = candidate.components().next_back() else { @@ -58,7 +58,7 @@ pub fn resolve_real(joined: &Path) -> PathBuf { /// path — after following `..` and symlinks — stays inside `root`. pub fn resolve_in_root(root: &Path, path: &str) -> Result { let (root, joined) = joined_path(root, path)?; - let real = joined.canonicalize().map_err(|_| format!("{path}: no such file"))?; + let real = dunce::canonicalize(&joined).map_err(|_| format!("{path}: no such file"))?; if !real.starts_with(&root) { return Err(format!("{path}: access outside the project root is not allowed")); } @@ -83,7 +83,7 @@ pub fn resolve_new_in_root(root: &Path, path: &str) -> Result { if path.is_empty() { return Err("empty path".into()); } - let root = root.canonicalize().map_err(|e| format!("project root unavailable: {e}"))?; + let root = dunce::canonicalize(root).map_err(|e| format!("project root unavailable: {e}"))?; let requested = Path::new(path); let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) }; let mut lexical = PathBuf::new(); @@ -108,7 +108,7 @@ pub fn resolve_new_in_root(root: &Path, path: &str) -> Result { missing.push(dir); existing = above; } - let mut real = existing.canonicalize().map_err(|e| format!("{path}: {e}"))?; + let mut real = dunce::canonicalize(existing).map_err(|e| format!("{path}: {e}"))?; real.extend(missing.into_iter().rev()); real.push(name); if !real.starts_with(&root) { @@ -207,9 +207,8 @@ fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result asked.canonicalize().map_err(|_| Refusal::Invalid(format!("{path}: no such file")))?, - Wanted::Write => asked - .canonicalize() + Wanted::Read => dunce::canonicalize(&asked).map_err(|_| Refusal::Invalid(format!("{path}: no such file")))?, + Wanted::Write => dunce::canonicalize(&asked) .or_else(|_| resolve_unsaved(&asked)) .map_err(|e| Refusal::Invalid(format!("{path}: {e}")))?, }; @@ -223,7 +222,7 @@ fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result strip_verbatim(&real), Err(_) => strip_verbatim(&expand_tilde(base)), }; @@ -246,20 +245,73 @@ fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result Result { let name = asked.file_name().ok_or_else(|| "not a file name".to_string())?; let parent = asked.parent().ok_or_else(|| "no directory to put it in".to_string())?; - let dir = parent.canonicalize().map_err(|_| "its directory does not exist".to_string())?; + let dir = dunce::canonicalize(parent).map_err(|_| "its directory does not exist".to_string())?; Ok(dir.join(name)) } -/// `\\?\C:\…` (what `canonicalize` returns on Windows) as `C:\…`. +/// Simplifies Windows drive paths only when the plain form preserves their meaning. fn strip_verbatim(path: &Path) -> String { - let text = path.to_string_lossy(); - text.strip_prefix(r"\\?\").unwrap_or(&text).to_owned() + dunce::simplified(path).to_string_lossy().into_owned() } #[cfg(test)] mod tests { use super::*; + #[cfg(windows)] + #[test] + fn grant_paths_simplify_drive_paths_and_preserve_unc_prefixes() { + assert_eq!(strip_verbatim(Path::new(r"\\?\C:\project\a.txt")), r"C:\project\a.txt"); + for path in [r"C:\project\a.txt", r"\\?\UNC\server\share\a.txt", r"\\server\share\a.txt"] { + assert_eq!(strip_verbatim(Path::new(path)), path); + } + } + + #[cfg(windows)] + #[test] + fn resolvers_return_plain_paths_for_plain_and_verbatim_inputs() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + std::fs::create_dir(&root).unwrap(); + std::fs::write(root.join("a.txt"), "text").unwrap(); + let plain = dunce::canonicalize(&root).unwrap(); + let verbatim = std::fs::canonicalize(&root).unwrap(); + assert_ne!(plain, verbatim); + let file = plain.join("a.txt"); + let missing = plain.join("new/deep/a.txt"); + for root in [&plain, &verbatim] { + for path in ["a.txt", file.to_str().unwrap(), verbatim.join("a.txt").to_str().unwrap()] { + assert_eq!(resolve_in_root(root, path).unwrap(), file); + assert_eq!(resolve_new_in_root(root, path).unwrap(), file); + assert!(inside_root(root, path).unwrap()); + } + for path in ["new/deep/a.txt", missing.to_str().unwrap(), verbatim.join("new/deep/a.txt").to_str().unwrap()] + { + assert_eq!(resolve_new_in_root(root, path).unwrap(), missing); + assert_eq!(resolve_real(&root.join(path)), missing); + assert!(inside_root(root, path).unwrap()); + } + let outside = tmp.path().join("outside.txt"); + std::fs::write(&outside, "secret").unwrap(); + assert!(resolve_in_root(root, outside.to_str().unwrap()).is_err()); + assert!(resolve_new_in_root(root, outside.to_str().unwrap()).is_err()); + } + for root in [&plain, &verbatim] { + let read = Capability::FsRead(root.to_string_lossy().into_owned()); + let write = Capability::FsWrite(root.to_string_lossy().into_owned()); + for path in [file.clone(), verbatim.join("a.txt")] { + assert_eq!(resolve_granted(std::slice::from_ref(&read), path.to_str().unwrap()).unwrap(), file); + assert_eq!(resolve_writable(std::slice::from_ref(&write), path.to_str().unwrap()).unwrap(), file); + } + for path in [plain.join("new.txt"), verbatim.join("new.txt")] { + assert_eq!( + resolve_writable(std::slice::from_ref(&write), path.to_str().unwrap()).unwrap(), + plain.join("new.txt") + ); + } + } + } + #[test] fn confines_reads_to_root() { let tmp = tempfile::tempdir().unwrap(); @@ -310,7 +362,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join("proj"); std::fs::create_dir_all(root.join("src")).unwrap(); - let root_real = root.canonicalize().unwrap(); + let root_real = dunce::canonicalize(&root).unwrap(); assert_eq!(resolve_new_in_root(&root, "src/new/a.txt").unwrap(), root_real.join("src/new/a.txt")); assert_eq!(resolve_new_in_root(&root, "src/../b.txt").unwrap(), root_real.join("b.txt")); @@ -329,7 +381,7 @@ mod tests { std::fs::write(root.join("target.txt"), "t").unwrap(); std::os::unix::fs::symlink(root.join("target.txt"), root.join("link.txt")).unwrap(); std::os::unix::fs::symlink(tmp.path(), root.join("out")).unwrap(); - let root_real = root.canonicalize().unwrap(); + let root_real = dunce::canonicalize(&root).unwrap(); assert_eq!(resolve_new_in_root(&root, "link.txt").unwrap(), root_real.join("link.txt")); assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside")); @@ -362,13 +414,13 @@ mod tests { /// The grant a manifest author would write for `dir`: the plain path, no /// `\\?\` prefix. fn grant_for(dir: &Path) -> Capability { - Capability::FsRead(strip_verbatim(&dir.canonicalize().unwrap())) + Capability::FsRead(strip_verbatim(&dunce::canonicalize(dir).unwrap())) } fn text_at(dir: &Path, name: &str, body: &str) -> PathBuf { let file = dir.join(name); std::fs::write(&file, body).unwrap(); - file.canonicalize().unwrap() + dunce::canonicalize(file).unwrap() } #[test] diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index 81c4f0952..c32a11eef 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -25,6 +25,8 @@ pub struct EditorContext { /// Where a command or event batch comes from: the window and its project. #[derive(Clone, Debug, Default)] pub struct Invocation { + /// Plugin instances waiting on this command chain; never re-enter one. + pub command_chain: Vec, pub client: Option, pub workspace_root: Option, /// Settings actions can read the global layer while retaining their project context. @@ -35,7 +37,7 @@ pub struct Invocation { } /// A failed host call, as the plugin sees it (WIT `types.error`). -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum HostError { /// Not granted; names the capability. Denied(String), @@ -98,6 +100,12 @@ pub enum Prompt { #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)] #[serde(rename_all = "kebab-case")] pub enum EventKind { + FileClosed, + SettingsChanged, + InputModeChanged, + RunExited, + TerminalExited, + ContentTabAction, FileOpened, FileSaved, FilesChanged, @@ -108,7 +116,13 @@ pub enum EventKind { } impl EventKind { - pub const ALL: [Self; 7] = [ + pub const ALL: [Self; 13] = [ + Self::FileClosed, + Self::SettingsChanged, + Self::InputModeChanged, + Self::RunExited, + Self::TerminalExited, + Self::ContentTabAction, Self::FileOpened, Self::FileSaved, Self::FilesChanged, @@ -120,6 +134,12 @@ impl EventKind { pub fn name(self) -> &'static str { match self { + Self::FileClosed => "file-closed", + Self::SettingsChanged => "settings-changed", + Self::InputModeChanged => "input-mode-changed", + Self::RunExited => "run-exited", + Self::TerminalExited => "terminal-exited", + Self::ContentTabAction => "content-tab-action", Self::FileOpened => "file-opened", Self::FileSaved => "file-saved", Self::FilesChanged => "files-changed", @@ -138,8 +158,8 @@ impl EventKind { /// manifest declares). pub fn capability(self) -> Option { match self { - Self::ActionPerformed => Some(ide_extension::Capability::InputEvents), - Self::Scheduled => None, + Self::ActionPerformed | Self::InputModeChanged => Some(ide_extension::Capability::InputEvents), + Self::Scheduled | Self::RunExited | Self::TerminalExited | Self::ContentTabAction => None, _ => Some(ide_extension::Capability::Events), } } @@ -188,10 +208,24 @@ pub struct FilesChanged { pub removed: Vec, } +/// The focused editor's input mode and optional visual sub-mode. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct InputMode { + pub path: String, + pub mode: String, + pub sub: Option, +} + /// A workbench event delivered to plugins. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase", tag = "kind", content = "value")] pub enum WorkbenchEvent { + RunExited(crate::RunExited), + TerminalExited(crate::TerminalExited), + ContentTabAction(crate::ContentTabAction), + FileClosed(String), + SettingsChanged(Vec), + InputModeChanged(InputMode), FileOpened(String), FileSaved(String), FilesChanged(FilesChanged), @@ -204,6 +238,12 @@ pub enum WorkbenchEvent { impl WorkbenchEvent { pub fn kind(&self) -> EventKind { match self { + Self::RunExited(_) => EventKind::RunExited, + Self::TerminalExited(_) => EventKind::TerminalExited, + Self::ContentTabAction(_) => EventKind::ContentTabAction, + Self::FileClosed(_) => EventKind::FileClosed, + Self::SettingsChanged(_) => EventKind::SettingsChanged, + Self::InputModeChanged(_) => EventKind::InputModeChanged, Self::FileOpened(_) => EventKind::FileOpened, Self::FileSaved(_) => EventKind::FileSaved, Self::FilesChanged(_) => EventKind::FilesChanged, @@ -350,6 +390,8 @@ pub struct HttpRequest { pub headers: Vec<(String, String)>, pub body: Option>, pub timeout: Duration, + /// Grants checked again before following a redirect. + pub grants: Vec, } #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -371,6 +413,8 @@ pub struct ProcessOutput { /// the environment to add to the inherited one (WIT `process.command`). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ProcessCommand { + /// The invoking project whose executable override applies, independent of cwd. + pub approval_root: Option, pub program: String, pub args: Vec, pub env: Vec<(String, String)>, @@ -388,23 +432,27 @@ pub struct ProcessChunk { /// Where `net:` requests may go: the URL's host when it is `https`, or /// `http` to the loopback interface. pub fn net_host(url: &str) -> Result { - let (scheme, rest) = url.split_once("://").ok_or_else(|| format!("{url:?} is not an absolute URL"))?; - let authority = rest.split(['/', '?', '#']).next().unwrap_or_default(); - let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h); - let host = if let Some(v6) = authority.strip_prefix('[') { - v6.split(']').next().unwrap_or_default().to_owned() - } else { - authority.split(':').next().unwrap_or_default().to_ascii_lowercase() - }; - if host.is_empty() { - return Err(format!("{url:?} has no host")); + let parsed = url::Url::parse(url).map_err(|e| format!("invalid URL: {e}"))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(format!("unsupported scheme {:?}", parsed.scheme())); } - let loopback = matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1"); - match scheme.to_ascii_lowercase().as_str() { - "https" => Ok(host), - "http" if loopback => Ok(host), - "http" => Err(format!("{url:?}: plain http is only allowed to localhost")), - other => Err(format!("{url:?}: unsupported scheme {other:?}")), + match parsed.host() { + Some(url::Host::Domain(host)) => Ok(host.trim_end_matches('.').to_ascii_lowercase()), + Some(url::Host::Ipv4(host)) => Ok(host.to_string()), + Some(url::Host::Ipv6(host)) => Ok(host.to_string()), + None => Err("URL has no host".into()), + } +} + +/// The grant required by a URL; loopback HTTP retains the original `net:` rule. +pub fn net_capability(url: &str) -> Result { + let host = net_host(url)?; + if url::Url::parse(url).is_ok_and(|u| u.scheme() == "http") + && !matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1") + { + Ok(ide_extension::Capability::NetHttp(host)) + } else { + Ok(ide_extension::Capability::Net(host)) } } @@ -427,7 +475,10 @@ mod tests { assert_eq!(net_host("https://API.example.com:8443/v1?x").unwrap(), "api.example.com"); assert_eq!(net_host("http://localhost:11434/api").unwrap(), "localhost"); assert_eq!(net_host("https://user:pw@h.io/").unwrap(), "h.io"); - assert!(net_host("http://example.com/").is_err()); + assert_eq!( + net_capability("http://example.com/").unwrap(), + ide_extension::Capability::NetHttp("example.com".into()) + ); assert!(net_host("file:///etc/passwd").is_err()); assert!(net_host("example.com").is_err()); } @@ -442,7 +493,8 @@ pub struct ProviderDocument { pub text: String, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename = "PluginLocation")] pub struct Location { pub path: PathBuf, pub range: ide_core::Range, @@ -710,3 +762,96 @@ pub struct NotificationAction { pub command: String, pub argument: Option, } + +/// An open editor reported by its window. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct OpenEditor { + pub path: String, + pub language: String, + pub dirty: bool, + pub active: bool, +} +/// Find in Files options. +#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TextQuery { + pub pattern: String, + pub regex: bool, + pub case_sensitive: bool, + pub whole_word: bool, + pub include: Vec, + pub exclude: Vec, +} +/// A search hit, using UTF-16 coordinates. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +pub struct TextMatch { + pub location: Location, + pub line: String, +} +/// Bounded search results. +#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TextSearch { + pub matches: Vec, + pub limit_hit: bool, +} +/// A file's problems from one producer. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +pub struct FileProblems { + pub path: String, + pub source: String, + pub diagnostics: Vec, +} +/// An editor decoration. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +pub struct Decoration { + pub range: ide_core::Range, + pub style: String, + pub hover: Option, + pub after: Option, +} +/// A tree decoration. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +pub struct FileDecoration { + pub badge: Option, + pub style: String, + pub tooltip: Option, +} +/// Commit metadata, date in Unix seconds. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct CommitInfo { + pub id: String, + pub short_id: String, + pub author: String, + pub author_email: String, + pub date: i64, + pub subject: String, + pub body: String, + pub parents: Vec, +} +/// One zero-based line's attribution, date in Unix seconds. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +pub struct BlameLine { + pub line: u32, + pub commit: String, + pub author: String, + pub date: i64, + pub summary: String, +} + +/// Non-secret settings of a plugin-owned project database source (API 1.4). +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginDataSource { + pub id: String, + pub name: String, + pub driver: String, + pub host: Option, + pub port: Option, + pub database: Option, + pub user: Option, + pub url: Option, + pub options: Vec<(String, String)>, +} diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index f1b8008c0..30d6869f9 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -6,7 +6,7 @@ use crate::runtime::{MAX_TITLE, StoreData, clip}; use crate::sandbox; use crate::types::{ EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, PickItem, ProcessChunk, ProcessCommand, - Prompt, StatusItem, net_host, + Prompt, StatusItem, net_capability, }; use ide_core::ipc_path::to_ipc; use ide_extension::Capability; @@ -25,6 +25,8 @@ pub(crate) mod bindings { "openidea:plugin/invocation.invocation": super::InvocationRes, "openidea:plugin/tasks.task": super::TaskRes, "openidea:plugin/process.child": super::ChildRes, + "openidea:plugin/ipc.connection": super::ConnectionRes, + "openidea:plugin/auth.loopback": super::LoopbackRes, }, }); } @@ -42,6 +44,7 @@ pub(crate) mod syntax_bindings { "openidea:plugin/storage": super::bindings::openidea::plugin::storage, "openidea:plugin/workspace": super::bindings::openidea::plugin::workspace, "openidea:plugin/editor": super::bindings::openidea::plugin::editor, + "openidea:plugin/decorations": super::bindings::openidea::plugin::decorations, "openidea:plugin/invocation": super::bindings::openidea::plugin::invocation, "openidea:plugin/vcs": super::bindings::openidea::plugin::vcs, "openidea:plugin/commit": super::bindings::openidea::plugin::commit, @@ -50,10 +53,17 @@ pub(crate) mod syntax_bindings { "openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics, "openidea:plugin/net": super::bindings::openidea::plugin::net, "openidea:plugin/secrets": super::bindings::openidea::plugin::secrets, + "openidea:plugin/run": super::bindings::openidea::plugin::run, "openidea:plugin/process": super::bindings::openidea::plugin::process, + "openidea:plugin/output": super::bindings::openidea::plugin::output, + "openidea:plugin/auth": super::bindings::openidea::plugin::auth, + "openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner, + "openidea:plugin/ipc": super::bindings::openidea::plugin::ipc, "openidea:plugin/fs": super::bindings::openidea::plugin::fs, "openidea:plugin/views": super::bindings::openidea::plugin::views, "openidea:plugin/settings": super::bindings::openidea::plugin::settings, + "openidea:plugin/database": super::bindings::openidea::plugin::database, + "openidea:plugin/mcp": super::bindings::openidea::plugin::mcp, }, }); } @@ -71,6 +81,7 @@ pub(crate) mod providers_bindings { "openidea:plugin/storage": super::bindings::openidea::plugin::storage, "openidea:plugin/workspace": super::bindings::openidea::plugin::workspace, "openidea:plugin/editor": super::bindings::openidea::plugin::editor, + "openidea:plugin/decorations": super::bindings::openidea::plugin::decorations, "openidea:plugin/invocation": super::bindings::openidea::plugin::invocation, "openidea:plugin/vcs": super::bindings::openidea::plugin::vcs, "openidea:plugin/commit": super::bindings::openidea::plugin::commit, @@ -79,10 +90,17 @@ pub(crate) mod providers_bindings { "openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics, "openidea:plugin/net": super::bindings::openidea::plugin::net, "openidea:plugin/secrets": super::bindings::openidea::plugin::secrets, + "openidea:plugin/run": super::bindings::openidea::plugin::run, "openidea:plugin/process": super::bindings::openidea::plugin::process, + "openidea:plugin/output": super::bindings::openidea::plugin::output, + "openidea:plugin/auth": super::bindings::openidea::plugin::auth, + "openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner, + "openidea:plugin/ipc": super::bindings::openidea::plugin::ipc, "openidea:plugin/fs": super::bindings::openidea::plugin::fs, "openidea:plugin/views": super::bindings::openidea::plugin::views, "openidea:plugin/settings": super::bindings::openidea::plugin::settings, + "openidea:plugin/database": super::bindings::openidea::plugin::database, + "openidea:plugin/mcp": super::bindings::openidea::plugin::mcp, }, }); } @@ -100,6 +118,7 @@ pub(crate) mod views_bindings { "openidea:plugin/storage": super::bindings::openidea::plugin::storage, "openidea:plugin/workspace": super::bindings::openidea::plugin::workspace, "openidea:plugin/editor": super::bindings::openidea::plugin::editor, + "openidea:plugin/decorations": super::bindings::openidea::plugin::decorations, "openidea:plugin/invocation": super::bindings::openidea::plugin::invocation, "openidea:plugin/vcs": super::bindings::openidea::plugin::vcs, "openidea:plugin/commit": super::bindings::openidea::plugin::commit, @@ -108,17 +127,24 @@ pub(crate) mod views_bindings { "openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics, "openidea:plugin/net": super::bindings::openidea::plugin::net, "openidea:plugin/secrets": super::bindings::openidea::plugin::secrets, + "openidea:plugin/run": super::bindings::openidea::plugin::run, "openidea:plugin/process": super::bindings::openidea::plugin::process, + "openidea:plugin/output": super::bindings::openidea::plugin::output, + "openidea:plugin/auth": super::bindings::openidea::plugin::auth, + "openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner, + "openidea:plugin/ipc": super::bindings::openidea::plugin::ipc, "openidea:plugin/fs": super::bindings::openidea::plugin::fs, "openidea:plugin/views": super::bindings::openidea::plugin::views, "openidea:plugin/settings": super::bindings::openidea::plugin::settings, + "openidea:plugin/database": super::bindings::openidea::plugin::database, + "openidea:plugin/mcp": super::bindings::openidea::plugin::mcp, }, }); } use bindings::openidea::plugin::{ - commit, detail_types, diagnostics, editor, events, fs, invocation, log, net, process, secrets, settings, storage, - tasks, types as wit, ui, vcs, views, workspace, + commit, detail_types, diagnostics, editor, events, fs, invocation, ipc, log, net, process, secrets, settings, + storage, tasks, types as wit, ui, vcs, views, workspace, }; /// A project handle: its root. @@ -319,6 +345,14 @@ impl StoreData { self.ask(Prompt::QuickPick { title: clip(title, MAX_TITLE), items, multiple }) } + fn require_window(&self) -> Result<(), wit::Error> { + self.require_interactive()?; + if self.invocation.client.is_none() { + return Err(wit::Error::Invalid("requires an invoking window".into())); + } + Ok(()) + } + /// APIs reserved for commands and events exclude code-action resolution. fn require_interactive(&self) -> Result<(), wit::Error> { if self.interactive { @@ -381,6 +415,29 @@ impl ui::Host for StoreData { Ok(()) } + fn set_window_status_item(&mut self, id: String, item: ui::StatusItem) -> Fallible<()> { + if let Err(e) = self.require_window() { + return Ok(Err(e)); + } + let item = StatusItem { + text: clip(item.text, MAX_STATUS_TEXT), + tooltip: item.tooltip.map(|t| clip(t, 1024)), + command: item.command.map(|c| clip(c, 128)), + }; + Ok(self + .host + .window_status_item(self.invocation.client.as_deref().expect("checked"), &clip(id, 64), Some(item)) + .map_err(to_wit)) + } + fn remove_window_status_item(&mut self, id: String) -> Fallible<()> { + if let Err(e) = self.require_window() { + return Ok(Err(e)); + } + Ok(self + .host + .window_status_item(self.invocation.client.as_deref().expect("checked"), &clip(id, 64), None) + .map_err(to_wit)) + } fn set_context(&mut self, key: String, value: ui::ContextValue) -> Fallible<()> { let valid_key = (1..=64).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')); @@ -532,6 +589,44 @@ impl workspace::Host for StoreData { } impl workspace::HostProject for StoreData { + fn search_text( + &mut self, + this: Resource, + query: workspace::TextQuery, + limit: u32, + ) -> Fallible { + if let Err(e) = self.require(Capability::ProjectRead) { + return Ok(Err(e)); + } + let root = &self.table.get(&this)?.root; + let query = crate::TextQuery { + pattern: query.pattern, + regex: query.regex, + case_sensitive: query.case_sensitive, + whole_word: query.whole_word, + include: query.include, + exclude: query.exclude, + }; + Ok(self + .host + .search_text(self.client(), root, query, limit.min(10_000), &self.cancel.token(), self.deadline) + .map(|s| workspace::TextSearch { + limit_hit: s.limit_hit, + matches: s + .matches + .into_iter() + .map(|m| workspace::TextMatch { + location: wit::Location { + path: to_ipc(&m.location.path), + range: range_to_wit(&m.location.range), + }, + line: m.line, + }) + .collect(), + }) + .map_err(to_wit)) + } + fn root(&mut self, this: Resource) -> Fallible { if let Err(e) = self.require(Capability::ProjectRead) { return Ok(Err(e)); @@ -659,11 +754,94 @@ impl workspace::HostProject for StoreData { } } -impl editor::Host for StoreData {} +impl editor::Host for StoreData { + fn open_editors(&mut self) -> Fallible> { + if let Err(e) = self.require(Capability::EditorRead).and_then(|_| self.require_window()) { + return Ok(Err(e)); + } + Ok(self + .host + .open_editors(self.client().unwrap()) + .map(|items| { + items + .into_iter() + .map(|e| editor::OpenEditor { + path: e.path, + language: e.language, + dirty: e.dirty, + active: e.active, + }) + .collect() + }) + .map_err(to_wit)) + } + fn open(&mut self, path: String, selection: Option) -> Fallible<()> { + if let Err(e) = self.require_window() { + return Ok(Err(e)); + } + let real = match self.openable_path(&path) { + Ok(p) => p, + Err(e) => return Ok(Err(e)), + }; + Ok(self.host.editor_open(self.client().unwrap(), &real, selection.map(core_range)).map_err(to_wit)) + } +} + +fn core_range(r: wit::Range) -> ide_core::Range { + ide_core::Range::new(pos(r.start), pos(r.end)) +} +impl StoreData { + fn decoration_path(&self, path: &str) -> Result { + if Path::new(path).is_absolute() { + return Ok(PathBuf::from(path)); + } + let root = self + .invocation + .workspace_root + .as_ref() + .ok_or_else(|| wit::Error::Invalid("relative decorations need an invoking project".into()))?; + sandbox::resolve_in_root(root, path).map_err(wit::Error::Invalid) + } + fn openable_path(&self, path: &str) -> Result { + if let Ok(dir) = self.host.plugin_dir() + && let Ok(real) = sandbox::resolve_granted(&[Capability::FsRead(to_ipc(&dir))], path) + { + return Ok(real); + } + self.require(Capability::ProjectRead)?; + let root = self + .invocation + .workspace_root + .as_ref() + .ok_or_else(|| wit::Error::Invalid("no project is open in this window".into()))?; + sandbox::resolve_in_root(root, path).map_err(wit::Error::Invalid) + } +} impl detail_types::Host for StoreData {} impl editor::HostTextEditor for StoreData { + fn set_selections(&mut self, this: Resource, selections: Vec) -> Fallible<()> { + if let Err(e) = self.require_window() { + return Ok(Err(e)); + } + if selections.is_empty() || selections.len() > 1000 { + return Ok(Err(wit::Error::Invalid("requires 1-1000 selections".into()))); + } + let path = &self.table.get(&this)?.ctx.path; + Ok(self + .host + .editor_ranges(self.client().unwrap(), path, selections.into_iter().map(core_range).collect(), false) + .map_err(to_wit)) + } + fn reveal(&mut self, this: Resource, range: wit::Range) -> Fallible<()> { + if let Err(e) = self.require_window() { + return Ok(Err(e)); + } + let path = &self.table.get(&this)?.ctx.path; + Ok(self.host.editor_ranges(self.client().unwrap(), path, vec![core_range(range)], true).map_err(to_wit)) + } + fn path(&mut self, this: Resource) -> wasmtime::Result { Ok(to_ipc(&self.table.get(&this)?.ctx.path)) } @@ -748,6 +926,69 @@ impl invocation::HostInvocation for StoreData { } impl vcs::Host for StoreData { + fn log( + &mut self, + project: Resource, + path: Option, + limit: u32, + ) -> Fallible> { + if let Err(e) = self.require(Capability::VcsRead) { + return Ok(Err(e)); + } + let root = &self.table.get(&project)?.root; + Ok(self + .host + .vcs_log(root, path.as_deref(), limit.min(1000), &self.cancel.token()) + .map(|v| { + v.into_iter() + .map(|c| vcs::CommitInfo { + id: c.id, + short_id: c.short_id, + author: c.author, + author_email: c.author_email, + date: c.date, + subject: c.subject, + body: c.body, + parents: c.parents, + }) + .collect() + }) + .map_err(to_wit)) + } + fn blame(&mut self, project: Resource, path: String) -> Fallible> { + if let Err(e) = self.require(Capability::VcsRead) { + return Ok(Err(e)); + } + let root = &self.table.get(&project)?.root; + Ok(self + .host + .vcs_blame(self.client(), root, &path) + .map(|v| { + v.into_iter() + .map(|c| vcs::BlameLine { + line: c.line, + commit: c.commit, + author: c.author, + date: c.date, + summary: c.summary, + }) + .collect() + }) + .map_err(to_wit)) + } + fn diff( + &mut self, + project: Resource, + path: Option, + against: Option, + ) -> Fallible> { + if let Err(e) = self.require(Capability::VcsRead) { + return Ok(Err(e)); + } + let root = &self.table.get(&project)?.root; + Ok(self.host.vcs_diff(root, path.as_deref(), against.as_deref(), &self.cancel.token()).map_err(to_wit)) + } + fn status(&mut self, project: Resource) -> Fallible> { if let Err(e) = self.require(Capability::VcsRead) { return Ok(Err(e)); @@ -850,6 +1091,18 @@ impl events::Host for StoreData { let Some(kind) = EventKind::parse(&kind) else { return Ok(Err(wit::Error::Invalid(format!("no event kind {kind:?} in this OpenIDEA")))); }; + if matches!( + kind, + EventKind::FileClosed + | EventKind::SettingsChanged + | EventKind::InputModeChanged + | EventKind::RunExited + | EventKind::TerminalExited + | EventKind::ContentTabAction + ) && let Err(e) = self.host.require_api(kind.name(), 4) + { + return Ok(Err(to_wit(e))); + } if let Some(cap) = kind.capability() && let Err(e) = self.require(cap) { @@ -941,6 +1194,40 @@ impl StoreData { } impl diagnostics::Host for StoreData { + fn problems(&mut self, path: Option) -> Fallible> { + if let Err(e) = self.require(Capability::ProjectRead) { + return Ok(Err(e)); + } + if self.client().is_none() { + return Ok(Err(wit::Error::Invalid("requires an invoking window".into()))); + } + let real = match path { + None => None, + Some(path) => { + let Some(root) = self.invocation.workspace_root.as_ref() else { + return Ok(Err(wit::Error::Invalid("no project in this window".into()))); + }; + match sandbox::resolve_new_in_root(root, &path) { + Ok(p) => Some(p), + Err(e) => return Ok(Err(wit::Error::Invalid(e))), + } + } + }; + Ok(self + .host + .problems(self.client().unwrap(), real.as_deref()) + .map(|v| { + v.into_iter() + .map(|f| diagnostics::FileProblems { + path: f.path, + source: f.source, + diagnostics: f.diagnostics.into_iter().map(diagnostic_to_wit).collect(), + }) + .collect() + }) + .map_err(to_wit)) + } + fn publish(&mut self, path: String, items: Vec) -> Fallible<()> { if let Err(e) = self.require(Capability::ProjectRead) { return Ok(Err(e)); @@ -952,11 +1239,11 @@ impl diagnostics::Host for StoreData { impl net::Host for StoreData { fn fetch(&mut self, req: net::Request) -> Fallible { - let host = match net_host(&req.url) { + let capability = match net_capability(&req.url) { Ok(h) => h, Err(e) => return Ok(Err(wit::Error::Invalid(e))), }; - if let Err(e) = self.require(Capability::Net(host)) { + if let Err(e) = self.require(capability) { return Ok(Err(e)); } if req.headers.iter().any(|h| h.value.contains("{{secret:")) @@ -974,6 +1261,7 @@ impl net::Host for StoreData { headers: req.headers.into_iter().map(|h| (h.name, h.value)).collect(), body: req.body, timeout: wanted.min(self.remaining()), + grants: self.granted.clone(), }; let token = self.cancel.token(); Ok(self @@ -989,6 +1277,21 @@ impl net::Host for StoreData { } impl secrets::Host for StoreData { + fn set(&mut self, name: String, value: String) -> Fallible<()> { + if let Err(e) = self.require(Capability::Secrets) { + return Ok(Err(e)); + } + if name.len() > 128 || value.len() > 64 << 10 { + return Ok(Err(wit::Error::Invalid("secret name or value too long".into()))); + } + Ok(self.host.secret_set(&name, Some(&value)).map_err(to_wit)) + } + fn delete(&mut self, name: String) -> Fallible<()> { + if let Err(e) = self.require(Capability::Secrets) { + return Ok(Err(e)); + } + Ok(self.host.secret_set(&name, None).map_err(to_wit)) + } fn is_set(&mut self, name: String) -> wasmtime::Result { Ok(self.granted.iter().any(|g| g.covers(&Capability::Secrets)) && self.host.secret_is_set(&name)) } @@ -1013,7 +1316,12 @@ impl StoreData { return Err(wit::Error::Invalid(format!("the value of {name:?} is too long or holds a NUL"))); } } - Ok(ProcessCommand { program: cmd.program, args: cmd.args, env: cmd.env }) + Ok(ProcessCommand { + program: cmd.program, + args: cmd.args, + env: cmd.env, + approval_root: self.invocation.workspace_root.clone(), + }) } /// The project a call runs in, when it named one. @@ -1120,6 +1428,68 @@ impl process::HostChild for StoreData { } impl views::Host for StoreData { + fn open_terminal_session(&mut self, key: String, title: String, cmd: process::Command) -> Fallible { + if let Err(e) = self.require_window() { + return Ok(Err(e)); + } + let cmd = match self.command(cmd) { + Ok(cmd) => cmd, + Err(e) => return Ok(Err(e)), + }; + Ok(self + .host + .open_terminal_session( + self.invocation.client.as_deref().unwrap(), + key, + title, + cmd, + &self.cancel.token(), + self.remaining(), + ) + .map_err(to_wit)) + } + fn write_terminal(&mut self, key: String, text: String) -> Fallible<()> { + if let Err(e) = self.require_window() { + return Ok(Err(e)); + } + if text.len() > MAX_STDIN_BYTES { + return Ok(Err(wit::Error::Invalid("terminal write too large".into()))); + } + Ok(self + .host + .write_terminal( + self.invocation.client.as_deref().unwrap(), + &key, + text, + &self.cancel.token(), + self.remaining(), + ) + .map_err(to_wit)) + } + fn open_content_tab(&mut self, tab: views::ContentTab) -> Fallible<()> { + if let Err(e) = self.require_window() { + return Ok(Err(e)); + } + Ok(self + .host + .open_content_tab( + self.invocation.client.as_deref().unwrap(), + tab.into(), + &self.cancel.token(), + self.remaining(), + ) + .map_err(to_wit)) + } + fn reply_content_tab(&mut self, request_id: String, reply: views::ContentTabReply) -> Fallible<()> { + if let Err(e) = self.require_window() { + return Ok(Err(e)); + } + Ok(self + .host + .reply_content_tab(self.invocation.client.as_deref().unwrap(), &request_id, reply.into()) + .map_err(to_wit)) + } + fn refresh(&mut self, view: String) -> wasmtime::Result<()> { self.host.refresh_view(&clip(view, 64)); Ok(()) @@ -1443,6 +1813,108 @@ fn validate_ui_path(path: &str) -> Result<(), wit::Error> { Ok(()) } +impl From for crate::ContentAction { + fn from(v: bindings::openidea::plugin::views::ContentAction) -> Self { + Self { id: v.id, label: v.label, icon: v.icon, primary: v.primary, save: v.save } + } +} + +impl From for crate::ContentTab { + fn from(v: bindings::openidea::plugin::views::ContentTab) -> Self { + Self { + key: v.key, + title: v.title, + file_name: v.file_name, + language: v.language, + content: v.content, + read_only: v.read_only, + actions: v.actions.into_iter().map(Into::into).collect(), + } + } +} + +impl From for crate::ContentDiff { + fn from(v: bindings::openidea::plugin::views::ContentDiff) -> Self { + Self { left: v.left, right: v.right, left_label: v.left_label, right_label: v.right_label, title: v.title } + } +} + +impl From for crate::ContentTabReply { + fn from(v: bindings::openidea::plugin::views::ContentTabReply) -> Self { + Self { + content: v.content, + saved: v.saved, + read_only: v.read_only, + status: v.status, + error: v.error, + diff: v.diff.map(Into::into), + } + } +} + +impl bindings::openidea::plugin::run_targets::Host for StoreData { + fn register( + &mut self, + project: Resource, + target: bindings::openidea::plugin::run_targets::RunTarget, + ) -> Fallible<()> { + if let Err(e) = self.require(Capability::Process(target.program.clone())) { + return Ok(Err(e)); + } + let root = self.table.get(&project)?.root.clone(); + Ok(self + .host + .register_run_target( + &root, + crate::PluginRunTarget { + id: target.id, + name: target.name, + program: target.program, + args: target.args, + workdir_args: target.workdir_args, + container_root: target.container_root, + commands: target.commands, + }, + ) + .map_err(to_wit)) + } + fn unregister(&mut self, project: Resource, id: String) -> wasmtime::Result<()> { + let root = self.table.get(&project)?.root.clone(); + self.host.unregister_run_target(&root, &id); + Ok(()) + } +} + +impl bindings::openidea::plugin::run::Host for StoreData { + fn start(&mut self, config: String, args: Vec, debug: bool) -> Fallible { + if let Err(e) = self.require_window() { + return Ok(Err(e)); + } + if args.len() > MAX_ARGS || args.iter().any(|a| a.len() > MAX_ARG_BYTES || a.contains('\0')) { + return Ok(Err(wit::Error::Invalid("invalid run arguments".into()))); + } + Ok(self + .host + .start_run( + self.invocation.client.as_deref().unwrap(), + &config, + args, + debug, + &self.cancel.token(), + self.remaining(), + ) + .map_err(to_wit)) + } +} +pub(crate) mod run_configurations_bindings { + wasmtime::component::bindgen!({ world: "configurations-provider", path: "../../wit" }); +} +pub(crate) mod test_results_bindings { + wasmtime::component::bindgen!({ world: "test-results-provider", path: "../../wit", + with: { "openidea:plugin/types": super::bindings::openidea::plugin::types }, + }); +} + #[cfg(test)] mod project_ui_tests { use super::validate_ui_path; @@ -1489,3 +1961,310 @@ pub(crate) fn diagnostics_from_wit(items: Vec, source: }) .collect() } + +/// An IPC connection owned by this plugin instance. +pub struct ConnectionRes { + id: u64, + host: std::sync::Arc, +} +impl Drop for ConnectionRes { + fn drop(&mut self) { + self.host.ipc_close(self.id); + } +} +impl ipc::Host for StoreData { + fn connect(&mut self, name: String) -> Fallible> { + if let Err(e) = ide_extension::validate_ipc_name(&name, false) { + return Ok(Err(wit::Error::Invalid(e))); + } + if let Err(e) = self.require(Capability::Ipc(name.clone())) { + return Ok(Err(e)); + } + let name = match ide_extension::expand_ipc_name(&name) { + Ok(name) => name, + Err(e) => return Ok(Err(wit::Error::Invalid(e))), + }; + match self.host.ipc_connect(&name, &self.cancel.token(), self.remaining()) { + Ok(id) => self.push(ConnectionRes { id, host: self.host.clone() }).map(Ok), + Err(e) => Ok(Err(to_wit(e))), + } + } +} +impl ipc::HostConnection for StoreData { + fn write(&mut self, this: Resource, bytes: Vec) -> Fallible<()> { + if bytes.len() > 1 << 20 { + return Ok(Err(wit::Error::Invalid("at most 1 MiB per write".into()))); + } + let id = self.table.get(&this)?.id; + Ok(self.host.ipc_write(id, bytes, &self.cancel.token(), self.remaining()).map_err(to_wit)) + } + fn read(&mut self, this: Resource, max_bytes: u32, timeout_ms: u32) -> Fallible> { + let id = self.table.get(&this)?.id; + let timeout = Duration::from_millis(u64::from(timeout_ms)).min(self.remaining()); + Ok(self.host.ipc_read(id, (max_bytes as usize).min(1 << 20), timeout, &self.cancel.token()).map_err(to_wit)) + } + fn close(&mut self, this: Resource) -> Fallible<()> { + let id = self.table.get(&this)?.id; + self.host.ipc_close(id); + Ok(Ok(())) + } + fn drop(&mut self, this: Resource) -> wasmtime::Result<()> { + self.table.delete(this)?; + Ok(()) + } +} + +impl bindings::openidea::plugin::command_runner::Host for StoreData { + fn execute(&mut self, id: String, arg: Option) -> Fallible> { + if let Err(e) = self.require_window() { + return Ok(Err(e)); + } + if !self.host.owns_command(&id) + && let Err(e) = self.require(Capability::IdeCommands) + { + return Ok(Err(e)); + } + if id.len() > 256 || arg.as_ref().is_some_and(|a| a.len() > 64 << 10) { + return Ok(Err(wit::Error::Invalid("command id or argument too long".into()))); + } + Ok(self + .host + .execute_command(&self.invocation, &id, arg, &self.cancel.token(), self.remaining()) + .map_err(to_wit)) + } +} + +/// A desktop OAuth listener; its URI remains available after the one-shot wait. +pub struct LoopbackRes { + id: u64, + uri: String, + host: std::sync::Arc, +} +impl Drop for LoopbackRes { + fn drop(&mut self) { + self.host.close_loopback(self.id); + } +} +impl bindings::openidea::plugin::auth::Host for StoreData { + fn begin_loopback(&mut self, path: String) -> Fallible> { + if let Err(e) = self.require_window() { + return Ok(Err(e)); + } + match self.host.begin_loopback(self.invocation.client.as_deref().expect("checked"), &path) { + Ok((id, uri)) => self.push(LoopbackRes { id, uri, host: self.host.clone() }).map(Ok), + Err(e) => Ok(Err(to_wit(e))), + } + } +} +impl bindings::openidea::plugin::auth::HostLoopback for StoreData { + fn redirect_uri(&mut self, this: Resource) -> wasmtime::Result { + Ok(self.table.get(&this)?.uri.clone()) + } + fn wait(&mut self, this: Resource, timeout_ms: u32) -> Fallible> { + if let Err(e) = self.require_window() { + return Ok(Err(e)); + } + let id = self.table.get(&this)?.id; + let started = Instant::now(); + let result = self.host.wait_loopback(id, Duration::from_millis(u64::from(timeout_ms)), &self.cancel.token()); + let waited = started.elapsed(); + self.deadline += waited; + self.call_started += waited; + Ok(result.map_err(to_wit)) + } + fn drop(&mut self, this: Resource) -> wasmtime::Result<()> { + self.table.delete(this)?; + Ok(()) + } +} + +impl bindings::openidea::plugin::output::Host for StoreData { + fn append(&mut self, channel: String, text: String) -> Fallible<()> { + Ok(self.host.output_write(&channel, Some(&text)).map_err(to_wit)) + } + fn clear(&mut self, channel: String) -> Fallible<()> { + Ok(self.host.output_write(&channel, None).map_err(to_wit)) + } + fn reveal(&mut self, channel: String) -> Fallible<()> { + if let Err(e) = self.require_window() { + return Ok(Err(e)); + } + Ok(self.host.output_reveal(self.invocation.client.as_deref().expect("checked"), &channel).map_err(to_wit)) + } +} + +fn diagnostic_to_wit(d: ide_core::Diagnostic) -> diagnostics::Diagnostic { + diagnostics::Diagnostic { + range: range_to_wit(&d.range), + severity: match d.severity { + ide_core::Severity::Error => diagnostics::Severity::Error, + ide_core::Severity::Warning => diagnostics::Severity::Warning, + ide_core::Severity::Info => diagnostics::Severity::Info, + ide_core::Severity::Hint => diagnostics::Severity::Hint, + }, + message: d.message, + code: d.code, + source: d.source, + tags: [(d.unnecessary, "unnecessary"), (d.deprecated, "deprecated")] + .into_iter() + .filter(|(yes, _)| *yes) + .map(|(_, tag)| tag.into()) + .collect(), + related: Vec::new(), + } +} +impl bindings::openidea::plugin::decorations::Host for StoreData { + fn set( + &mut self, + path: String, + set_id: String, + items: Vec, + ) -> Fallible<()> { + if let Err(e) = self.require(Capability::ProjectRead) { + return Ok(Err(e)); + } + if items.len() > 10_000 { + return Ok(Err(wit::Error::Invalid("at most 10000 decorations per file".into()))); + } + let items = items + .into_iter() + .map(|d| crate::Decoration { range: core_range(d.range), style: d.style, hover: d.hover, after: d.after }) + .collect(); + let real = match self.decoration_path(&path) { + Ok(real) => real, + Err(e) => return Ok(Err(e)), + }; + Ok(self.host.decorations(Some(&real), &set_id, items).map_err(to_wit)) + } + fn clear(&mut self, set_id: String) -> Fallible<()> { + if let Err(e) = self.require(Capability::ProjectRead) { + return Ok(Err(e)); + } + Ok(self.host.decorations(None, &set_id, Vec::new()).map_err(to_wit)) + } + fn set_file( + &mut self, + path: String, + set_id: String, + item: bindings::openidea::plugin::decorations::FileDecoration, + ) -> Fallible<()> { + if let Err(e) = self.require(Capability::ProjectRead) { + return Ok(Err(e)); + } + let real = match self.decoration_path(&path) { + Ok(real) => real, + Err(e) => return Ok(Err(e)), + }; + Ok(self + .host + .file_decorations( + Some(&real), + &set_id, + Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip }), + ) + .map_err(to_wit)) + } + fn clear_file(&mut self, set_id: String) -> Fallible<()> { + if let Err(e) = self.require(Capability::ProjectRead) { + return Ok(Err(e)); + } + Ok(self.host.file_decorations(None, &set_id, None).map_err(to_wit)) + } +} + +pub(crate) mod language_features_bindings { + wasmtime::component::bindgen!({ world: "editor-language-features", path: "../../wit", + with: { "openidea:plugin/types": super::bindings::openidea::plugin::types }, + }); +} +pub(crate) mod injections_bindings { + wasmtime::component::bindgen!({ world: "language-injections", path: "../../wit", + with: { "openidea:plugin/types": super::bindings::openidea::plugin::types }, + }); +} + +impl bindings::openidea::plugin::database::Host for StoreData { + fn upsert_source( + &mut self, + project: Resource, + source: bindings::openidea::plugin::database::DataSource, + ) -> Fallible<()> { + if let Err(e) = self.require(Capability::DatabaseSources) { + return Ok(Err(e)); + } + let root = self.table.get(&project)?.root.clone(); + Ok(self + .host + .database_upsert( + &root, + crate::PluginDataSource { + id: source.id, + name: source.name, + driver: source.driver, + host: source.host, + port: source.port, + database: source.database, + user: source.user, + url: source.url, + options: source.options, + }, + ) + .map_err(to_wit)) + } + fn remove_source(&mut self, project: Resource, id: String) -> Fallible<()> { + if let Err(e) = self.require(Capability::DatabaseSources) { + return Ok(Err(e)); + } + let root = self.table.get(&project)?.root.clone(); + Ok(self.host.database_remove(&root, &id).map_err(to_wit)) + } + fn sources(&mut self, project: Resource) -> Fallible> { + if let Err(e) = self.require(Capability::DatabaseSources) { + return Ok(Err(e)); + } + let root = self.table.get(&project)?.root.clone(); + Ok(self.host.database_sources(&root).map_err(to_wit)) + } + fn set_credential(&mut self, project: Resource, id: String, password: String) -> Fallible<()> { + if let Err(e) = self.require(Capability::DatabaseSources) { + return Ok(Err(e)); + } + if password.len() > 64 << 10 { + return Ok(Err(wit::Error::Invalid("credential exceeds 64 KiB".into()))); + } + let root = self.table.get(&project)?.root.clone(); + Ok(self.host.database_credential(&root, &id, password).map_err(to_wit)) + } + fn connect(&mut self, project: Resource, id: String) -> Fallible<()> { + if let Err(e) = self.require(Capability::DatabaseSources).and_then(|_| self.require_window()) { + return Ok(Err(e)); + } + let root = self.table.get(&project)?.root.clone(); + let started = Instant::now(); + let result = self.host.database_connect( + &root, + self.client().unwrap(), + &id, + &self.cancel.token(), + Duration::from_secs(180), + ); + let waited = started.elapsed(); + self.deadline += waited; + self.call_started += waited; + Ok(result.map_err(to_wit)) + } +} +impl bindings::openidea::plugin::mcp::Host for StoreData { + fn stdio_command(&mut self) -> wasmtime::Result>> { + Ok(self.client().and_then(|client| self.host.mcp_stdio_command(client))) + } +} + +pub(crate) mod mcp_bindings { + wasmtime::component::bindgen!({ world: "mcp-tools", path: "../../wit", + with: { + "openidea:plugin/types": super::bindings::openidea::plugin::types, + "openidea:plugin/invocation": super::bindings::openidea::plugin::invocation, + }, + }); +} diff --git a/crates/ide-plugin/tests/api_compat.rs b/crates/ide-plugin/tests/api_compat.rs index 7926dae3f..6e9778a63 100644 --- a/crates/ide-plugin/tests/api_compat.rs +++ b/crates/ide-plugin/tests/api_compat.rs @@ -32,6 +32,7 @@ fn components() -> Vec { repo().join("crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm"), repo().join("crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm"), repo().join("crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm"), + repo().join("crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm"), repo().join("crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm"), ]; let official = repo().join("plugins/official"); @@ -88,15 +89,15 @@ fn the_sdk_carries_the_hosts_wit() { #[test] fn the_version_check_runs_compatible_components_and_explains_the_rest() { - let host = v("1.3.0"); - for ok in ["1.0.0", "1.3.0", "1.3.7", "1.0.0-rc.1"] { + let host = v("1.4.0"); + for ok in ["1.0.0", "1.3.0", "1.3.7", "1.4.0", "1.0.0-rc.1"] { assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}"); } - let newer = check_api(&v("1.4.0"), &host).unwrap_err(); - assert!(newer.contains("1.4") && newer.contains("update OpenIDEA"), "{newer}"); + let newer = check_api(&v("1.5.0"), &host).unwrap_err(); + assert!(newer.contains("1.5") && newer.contains("update OpenIDEA"), "{newer}"); assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA")); let older = check_api(&v("0.2.0"), &host).unwrap_err(); - assert!(older.contains("rebuild it with the 1.3 SDK"), "{older}"); + assert!(older.contains("rebuild it with the 1.4 SDK"), "{older}"); // 0.x: every minor is its own API. assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(())); assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err()); diff --git a/crates/ide-plugin/tests/fixtures/api14/Cargo.lock b/crates/ide-plugin/tests/fixtures/api14/Cargo.lock new file mode 100644 index 000000000..fcc4da830 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/api14/Cargo.lock @@ -0,0 +1,337 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "openidea-plugin-api14" +version = "0.1.0" +dependencies = [ + "openidea-plugin", + "serde_json", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/ide-plugin/tests/fixtures/api14/Cargo.toml b/crates/ide-plugin/tests/fixtures/api14/Cargo.toml new file mode 100644 index 000000000..26cadfe50 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/api14/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-api14" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm new file mode 100644 index 000000000..708ba22f1 Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm.sources new file mode 100644 index 000000000..4813269ff --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm.sources @@ -0,0 +1 @@ +b9b06665132bd6db753aa098703f2d83c9185d756114cdd1abb986f9d6d56c06 diff --git a/crates/ide-plugin/tests/fixtures/api14/plugin.toml b/crates/ide-plugin/tests/fixtures/api14/plugin.toml new file mode 100644 index 000000000..6bf897867 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/api14/plugin.toml @@ -0,0 +1,137 @@ +id = "api14" +name = "API 1.4 host tests" +version = "0.1.0" +engines.openidea = "^1.4" +wasm = "dist/plugin.wasm" +activation = ["on-command:*", "on-event:file-closed", "on-event:settings-changed", "on-event:input-mode-changed"] + +[capabilities] +required = ["storage"] +optional = ["ide.commands", "project.read", "vcs.read", "events", "events.input", "editor.read", "secrets", "net:localhost", "net.http:example.com", "ipc:$TMPDIR/api14-*"] + +[[contributes.secrets]] +name = "token" +label = "Token" + +[contributes.settings] +schema = "settings.json" + +[[contributes.commands]] +id = "ipc" +title = "ipc" + +[[contributes.commands]] +id = "fetch" +title = "fetch" + +[[contributes.commands]] +id = "window-status" +title = "window-status" + +[[contributes.commands]] +id = "remove-window-status" +title = "remove-window-status" + +[[contributes.commands]] +id = "global-status" +title = "global-status" + +[[contributes.commands]] +id = "execute" +title = "execute" + +[[contributes.commands]] +id = "oauth" +title = "oauth" + +[[contributes.commands]] +id = "secret" +title = "secret" + +[[contributes.commands]] +id = "delete-secret" +title = "delete-secret" + +[[contributes.commands]] +id = "undeclared-secret" +title = "undeclared-secret" + +[[contributes.commands]] +id = "output" +title = "output" + +[[contributes.commands]] +id = "events" +title = "events" + +[[contributes.commands]] +id = "process-run" +title = "process-run" + +[[contributes.commands]] +id = "process-run-command" +title = "process-run-command" + +[[contributes.commands]] +id = "process-start" +title = "process-start" + +[[contributes.commands]] +id = "terminal" +title = "terminal" + +[[contributes.commands]] +id = "log" +title = "log" + +[[contributes.commands]] +id = "open-editors" +title = "open-editors" + +[[contributes.commands]] +id = "open-editor" +title = "open-editor" + +[[contributes.commands]] +id = "set-selections" +title = "set-selections" + +[[contributes.commands]] +id = "reveal" +title = "reveal" + +[[contributes.commands]] +id = "search-text" +title = "search-text" + +[[contributes.commands]] +id = "problems" +title = "problems" + +[[contributes.commands]] +id = "decorations" +title = "decorations" + +[[contributes.commands]] +id = "clear-decorations" +title = "clear-decorations" + +[[contributes.commands]] +id = "file-decoration" +title = "file-decoration" + +[[contributes.commands]] +id = "clear-file-decorations" +title = "clear-file-decorations" + +[[contributes.commands]] +id = "vcs-log" +title = "vcs-log" + +[[contributes.commands]] +id = "vcs-blame" +title = "vcs-blame" + +[[contributes.commands]] +id = "vcs-diff" +title = "vcs-diff" diff --git a/crates/ide-plugin/tests/fixtures/api14/settings.json b/crates/ide-plugin/tests/fixtures/api14/settings.json new file mode 100644 index 000000000..9a6af9374 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/api14/settings.json @@ -0,0 +1 @@ +{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":false}}} diff --git a/crates/ide-plugin/tests/fixtures/api14/src/lib.rs b/crates/ide-plugin/tests/fixtures/api14/src/lib.rs new file mode 100644 index 000000000..659f147e3 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/api14/src/lib.rs @@ -0,0 +1,158 @@ +use openidea_plugin::{Error, Event, Invocation, Plugin, StatusItem, host, register_plugin}; +#[derive(Default)] +struct Api14; +impl Plugin for Api14 { + fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result, Error> { + let arg = arg.unwrap_or_default(); + match id { + "open-editors" => Ok(Some(format!("{:?}", host::open_editors()?))), + "open-editor" => { + host::open_editor(arg, Some(openidea_plugin::Range::default()))?; + Ok(None) + } + "set-selections" => { + ctx.editor().unwrap().set_selections(&[openidea_plugin::Range::default()])?; + Ok(None) + } + "reveal" => { + ctx.editor().unwrap().reveal(openidea_plugin::Range::default())?; + Ok(None) + } + "search-text" => { + let result = ctx + .project() + .unwrap() + .search_text(&openidea_plugin::TextQuery { pattern: arg.into(), ..Default::default() }, 10)?; + Ok(Some(format!("{result:?}"))) + } + "problems" => Ok(Some(format!("{:?}", host::problems(if arg.is_empty() { None } else { Some(arg) })?))), + "decorations" => { + host::set_decorations( + arg, + "test", + &[openidea_plugin::Decoration { + range: openidea_plugin::Range::default(), + style: "highlight".into(), + hover: Some("hover".into()), + after: Some("after".into()), + }], + )?; + Ok(None) + } + "clear-decorations" => { + host::clear_decorations("test")?; + Ok(None) + } + "file-decoration" => { + host::set_file_decoration( + arg, + "test", + &openidea_plugin::FileDecoration { badge: Some("OK".into()), style: "added".into(), tooltip: None }, + )?; + Ok(None) + } + "clear-file-decorations" => { + host::clear_file_decorations("test")?; + Ok(None) + } + "vcs-log" => Ok(Some(format!( + "{:?}", + ctx.project().unwrap().vcs_log(if arg.is_empty() { None } else { Some(arg) }, 10)? + ))), + "vcs-blame" => Ok(Some(format!("{:?}", ctx.project().unwrap().vcs_blame(arg)?))), + "vcs-diff" => ctx.project().unwrap().vcs_diff(if arg.is_empty() { None } else { Some(arg) }, None), + "ipc" => { + let stream = host::ipc_connect(arg)?; + stream.write(b"echo")?; + let bytes = stream.read(4, 2000)?; + stream.close()?; + Ok(Some(String::from_utf8_lossy(&bytes).into())) + } + "fetch" => { + let response = host::fetch(&openidea_plugin::HttpRequest::get(arg))?; + Ok(Some(String::from_utf8_lossy(&response.body).into())) + } + "global-status" | "window-status" => { + let item = StatusItem { text: arg.into(), tooltip: None, command: Some("events".into()) }; + if id == "global-status" { + host::set_status_item("state", &item); + } else { + host::set_window_status_item("state", &item)?; + } + Ok(None) + } + "remove-window-status" => { + host::remove_window_status_item("state")?; + Ok(None) + } + "execute" => host::execute_command(arg, Some("argument")), + "oauth" => { + let redirect = host::begin_loopback("oauth/callback")?; + host::output_append("oauth", &redirect.redirect_uri())?; + redirect.wait(2000) + } + "secret" => { + host::secret_set("token", arg)?; + Ok(None) + } + "delete-secret" => { + host::secret_delete("token")?; + Ok(None) + } + "undeclared-secret" => { + host::secret_set("undeclared", arg)?; + Ok(None) + } + "output" => { + host::output_append("test", arg)?; + host::output_reveal("test")?; + Ok(None) + } + "process-run" => Ok(Some(host::run_process("api14-tool", &[], None)?.stdout)), + "process-run-command" => Ok(Some(host::run_command(&host::Command::new("api14-tool"), None, None)?.stdout)), + "process-start" => { + let child = host::start(&host::Command::new("api14-tool"), None)?; + Ok(Some(child.wait(2000)?.stdout)) + } + "terminal" => { + host::open_terminal("API test", "api14-tool", &[])?; + Ok(None) + } + "log" => { + host::open_log_tab( + "api-test", + "API test", + &openidea_plugin::details::ProcessLog { + program: "api14-tool".into(), + args: Vec::new(), + toggles: Vec::new(), + }, + )?; + Ok(None) + } + "events" => host::storage::get("events"), + _ => Err(Error::NotFound(id.into())), + } + } + fn handle_events(&mut self, events: &[Event], ctx: &Invocation) { + let mut received: Vec = + host::storage::get("events").unwrap().map(|s| serde_json::from_str(&s).unwrap()).unwrap_or_default(); + for event in events { + if let Event::Other { kind, data } = event { + let editor = ctx.editor().map(|e| { + ( + e.path(), + e.selections() + .unwrap() + .into_iter() + .map(|r| [r.start.line, r.start.character, r.end.line, r.end.character]) + .collect::>(), + ) + }); + received.push(serde_json::json!({ "kind": kind, "data": data, "editor": editor })); + } + } + host::storage::set("events", &serde_json::to_string(&received).unwrap()).unwrap(); + } +} +register_plugin!(Api14); diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm index 498018633..8d716ad4a 100644 Binary files a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources index 60b38a389..1dddda010 100644 --- a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -1 +1 @@ -6d7d707c7492d487fbd9debae791ad7e3755ac005f1f518efa54e6536ec59f08 +e986322ee0a7e8b09096deee3335e542823ad836cecdf14a4f44e2066fadf7b5 diff --git a/crates/ide-plugin/tests/fixtures/data-tools/Cargo.lock b/crates/ide-plugin/tests/fixtures/data-tools/Cargo.lock new file mode 100644 index 000000000..f1adf681a --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/data-tools/Cargo.lock @@ -0,0 +1,337 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "openidea-plugin-data-tools" +version = "0.1.0" +dependencies = [ + "openidea-plugin", + "serde_json", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/ide-plugin/tests/fixtures/data-tools/Cargo.toml b/crates/ide-plugin/tests/fixtures/data-tools/Cargo.toml new file mode 100644 index 000000000..94b74bc10 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/data-tools/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-data-tools" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/crates/ide-plugin/tests/fixtures/data-tools/arguments.json b/crates/ide-plugin/tests/fixtures/data-tools/arguments.json new file mode 100644 index 000000000..ae6f5476b --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/data-tools/arguments.json @@ -0,0 +1 @@ +{"type":"object","properties":{"text":{"type":"string"}},"additionalProperties":false} diff --git a/crates/ide-plugin/tests/fixtures/data-tools/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/data-tools/dist/plugin.wasm new file mode 100644 index 000000000..d921b4400 Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/data-tools/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/data-tools/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/data-tools/dist/plugin.wasm.sources new file mode 100644 index 000000000..71bbe995a --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/data-tools/dist/plugin.wasm.sources @@ -0,0 +1 @@ +cb74e35e0b0fa2bf7d46b143190d4e72d90abfa7a95962c037495b722d8c73d3 diff --git a/crates/ide-plugin/tests/fixtures/data-tools/plugin.toml b/crates/ide-plugin/tests/fixtures/data-tools/plugin.toml new file mode 100644 index 000000000..0fda63df1 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/data-tools/plugin.toml @@ -0,0 +1,68 @@ +id = "test.data-tools" +name = "Data tools fixture" +version = "0.1.0" +engines.openidea = "^1.4" +wasm = "dist/plugin.wasm" +activation = ["on-command:*"] +[capabilities] +required = ["project.read"] +optional = ["database.sources"] + +[[contributes.commands]] +id = "upsert" +title = "upsert" + +[[contributes.commands]] +id = "remove" +title = "remove" + +[[contributes.commands]] +id = "sources" +title = "sources" + +[[contributes.commands]] +id = "credential" +title = "credential" + +[[contributes.commands]] +id = "connect" +title = "connect" + +[[contributes.commands]] +id = "stdio" +title = "stdio" + +[[contributes.mcpTools]] +name = "echo" +description = "Fixture echo" +inputSchema = "arguments.json" +readOnly = true +destructive = false + +[[contributes.mcpTools]] +name = "write" +description = "Fixture write" +inputSchema = "arguments.json" +readOnly = false +destructive = false + +[[contributes.mcpTools]] +name = "erase" +description = "Fixture erase" +inputSchema = "arguments.json" +readOnly = false +destructive = true + +[[contributes.mcpTools]] +name = "spin" +description = "Fixture spin" +inputSchema = "arguments.json" +readOnly = true +destructive = false + +[[contributes.mcpTools]] +name = "denied" +description = "Fixture denied" +inputSchema = "arguments.json" +readOnly = true +destructive = false diff --git a/crates/ide-plugin/tests/fixtures/data-tools/src/lib.rs b/crates/ide-plugin/tests/fixtures/data-tools/src/lib.rs new file mode 100644 index 000000000..98bd09662 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/data-tools/src/lib.rs @@ -0,0 +1,39 @@ +use openidea_plugin::{Error, Invocation, McpProvider, Plugin, PluginDataSource, host, register_plugin}; +#[derive(Default)] +struct DataTools; +impl Plugin for DataTools { + fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result, Error> { + if id == "stdio" { + return Ok(Some(serde_json::to_string(&host::mcp_stdio_command()).unwrap())); + } + let p = ctx.project().ok_or("project required")?; + match id { + "upsert" => { + let source: PluginDataSource = + serde_json::from_str(arg.unwrap()).map_err(|e| Error::Invalid(e.to_string()))?; + p.upsert_data_source(&source)?; + } + "remove" => p.remove_data_source(arg.unwrap_or("local"))?, + "sources" => return Ok(Some(serde_json::to_string(&p.data_sources()?).unwrap())), + "credential" => p.set_data_source_credential("local", arg.unwrap_or("secret"))?, + "connect" => p.connect_data_source(arg.unwrap_or("local"))?, + _ => return Err(Error::NotFound(id.into())), + } + Ok(None) + } +} +impl McpProvider for DataTools { + fn call(&mut self, tool: &str, arguments: &str, ctx: &Invocation) -> Result { + match tool { + "spin" => loop { + std::hint::spin_loop(); + }, + "denied" => { + host::run_process("sh", &[], None)?; + Ok("unexpected".into()) + } + _ => Ok(format!("{}:{}:{}", tool, ctx.project().ok_or("project required")?.root()?, arguments)), + } + } +} +register_plugin!(DataTools, mcp); diff --git a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm index cdcf91b87..7197508c8 100644 Binary files a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources index 235ce4fd6..67c9fad7a 100644 --- a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources @@ -1 +1 @@ -652977e0bf63bc7664807016998a4d4795d2a7eb841ee1912ef562057e5437cb +7ef1de0b1eb9d70ed3f5ba1dfc8cec6ccf3445ce76ad128f10f3d2456a990c2c diff --git a/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm index 6aba123b1..37c4e7f30 100644 Binary files a/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources index 2f45ab6e3..c5936fe68 100644 --- a/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources @@ -1 +1 @@ -42d3856b634bdaa69e21f4a42a9f375b73c2d19753a8986b82f2228bf92d37bc +dcca07b99ce7d6691bd501d0e7c80d6f4ce0350b32a3bd7726b13f5b315c3982 diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm index 03887b874..91b128fd7 100644 Binary files a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources index a10b690a5..270ae051b 100644 --- a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources @@ -1 +1 @@ -cacd381d84e1070fffe8f0ac64fad91b768df63b759c0b3e5e2d22a6a2fdeb73 +6ab6593d3d7226a1a52bcdbe827697be39e9ed5117d238ba2366ad1a6911a623 diff --git a/crates/ide-plugin/tests/fixtures/execution/Cargo.lock b/crates/ide-plugin/tests/fixtures/execution/Cargo.lock new file mode 100644 index 000000000..9b44fecc2 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/execution/Cargo.lock @@ -0,0 +1,337 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "openidea-plugin-execution" +version = "0.1.0" +dependencies = [ + "openidea-plugin", + "serde_json", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/ide-plugin/tests/fixtures/execution/Cargo.toml b/crates/ide-plugin/tests/fixtures/execution/Cargo.toml new file mode 100644 index 000000000..c0affd517 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/execution/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-execution" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm new file mode 100644 index 000000000..996f085fa Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm.sources new file mode 100644 index 000000000..601209570 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm.sources @@ -0,0 +1 @@ +a410bcd828c7b43d550c67c433343d1c8d4a5310b62578fc1d7fec542f2761eb diff --git a/crates/ide-plugin/tests/fixtures/execution/plugin.toml b/crates/ide-plugin/tests/fixtures/execution/plugin.toml new file mode 100644 index 000000000..7da140bb5 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/execution/plugin.toml @@ -0,0 +1,52 @@ +id = "execution" +name = "Plugin execution tests" +version = "0.1.0" +engines.openidea = "^1.4" +wasm = "dist/plugin.wasm" +activation = ["on-command:*"] +[capabilities] +required = ["storage", "project.read"] +optional = ["process:execution-tool"] +[[contributes.runDetectors]] +id = "detected" +name = "Detected test" +files = ["execution.marker"] +command = "execution-tool" +args = ["test"] +test = true +framework = "plugin" +[[contributes.runConfigurations]] +files = ["execution.marker"] + +[[contributes.commands]] +id = "run" +title = "run" + +[[contributes.commands]] +id = "debug" +title = "debug" + +[[contributes.commands]] +id = "terminal" +title = "terminal" + +[[contributes.commands]] +id = "write" +title = "write" + + + +[[contributes.commands]] +id = "content" +title = "content" + +[[contributes.commands]] +id = "events" +title = "events" + +[[contributes.commands]] +id = "target" +title = "target" +[[contributes.commands]] +id = "untarget" +title = "untarget" diff --git a/crates/ide-plugin/tests/fixtures/execution/src/lib.rs b/crates/ide-plugin/tests/fixtures/execution/src/lib.rs new file mode 100644 index 000000000..a9d6548c6 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/execution/src/lib.rs @@ -0,0 +1,113 @@ +use openidea_plugin::{host, register_plugin, *}; +#[derive(Default)] +struct Execution; +impl Plugin for Execution { + fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result, Error> { + match id { + "target" => { + let project = ctx.project().ok_or_else(|| Error::Invalid("no project".into()))?; + host::register_run_target( + &project, + &PluginRunTarget { + id: arg.unwrap_or("wrapper").into(), + name: "Execution target".into(), + program: "execution-tool".into(), + args: vec!["wrapped".into()], + workdir_args: vec!["--cwd={cwd}".into()], + container_root: Some("/container".into()), + commands: vec![("composer".into(), vec!["composer".into()])], + }, + )?; + Ok(None) + } + "untarget" => { + host::unregister_run_target(&ctx.project().unwrap(), arg.unwrap_or("wrapper")); + Ok(None) + } + "run" | "debug" => { + host::start_run(arg.unwrap_or("detected"), &["appended".into()], id == "debug").map(Some) + } + "terminal" => host::open_terminal_session( + "session", + "Execution", + &host::Command::new("execution-tool").args(["interactive"]), + ) + .map(Some), + "write" => { + host::write_terminal("session", arg.unwrap_or("input\n"))?; + Ok(None) + } + "content" => { + host::open_content_tab(&ContentTab { + key: "config".into(), + title: "Config".into(), + content: "hello".into(), + actions: vec![ContentAction { + id: "save".into(), + label: "Save".into(), + save: true, + ..Default::default() + }], + ..Default::default() + })?; + Ok(None) + } + "events" => host::storage::get("events"), + _ => Err(Error::NotFound(id.into())), + } + } + fn handle_events(&mut self, events: &[Event], _: &Invocation) { + let mut received: Vec = + host::storage::get("events").unwrap().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(); + for event in events { + if let Event::Other { kind, data } = event { + received.push(serde_json::json!({"kind": kind, "data": data})); + if kind == "content-tab-action" { + let a: ContentTabAction = serde_json::from_str(data).unwrap(); + host::reply_content_tab( + &a.request_id, + &ContentTabReply { saved: Some(true), status: Some("Saved".into()), ..Default::default() }, + ) + .unwrap(); + } + } + } + host::storage::set("events", &serde_json::to_string(&received).unwrap()).unwrap(); + } +} +impl RunConfigurations for Execution { + fn configurations(&mut self, _: &str) -> Vec { + vec![PluginRunConfiguration { + id: "dynamic".into(), + name: "Dynamic tests".into(), + program: "execution-tool".into(), + args: vec!["test".into()], + test: true, + framework: Some("plugin".into()), + ..Default::default() + }] + } +} +impl TestResults for Execution { + fn parse(&mut self, _: &str, chunk: &str) -> Vec { + if chunk == "trap" { + panic!("parser failed"); + } + vec![ + PluginTestEvent::SuiteStarted(TestStart { + id: "suite".into(), + parent: None, + name: "Suite".into(), + location: None, + }), + PluginTestEvent::TestStarted(TestStart { + id: "test".into(), + parent: Some("suite".into()), + name: "Test".into(), + location: None, + }), + PluginTestEvent::Passed(TestPassed { id: "test".into(), duration_ms: Some(7) }), + ] + } +} +register_plugin!(Execution, run_configurations, test_results); diff --git a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm index a4f2867c3..c026fa17e 100644 Binary files a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources index 3fa85d991..154dd39da 100644 --- a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources @@ -1 +1 @@ -0d418585dca868fbceb6432d06c5c838c3d42a9e99e7a9c7641199f25fb8fbe8 +86238b45b09b0274d59282b35295aaee650e999aa309640d9347787e7d461ec4 diff --git a/crates/ide-plugin/tests/fixtures/files-events/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/files-events/dist/plugin.wasm index 3f37ce42e..fdba6cd26 100644 Binary files a/crates/ide-plugin/tests/fixtures/files-events/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/files-events/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/files-events/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/files-events/dist/plugin.wasm.sources index 22f4d5f57..326c9358d 100644 --- a/crates/ide-plugin/tests/fixtures/files-events/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/files-events/dist/plugin.wasm.sources @@ -1 +1 @@ -6caa94db0c866c0c7f86011dbf837b3f9edb1c891276a4d435a8d02e8f64e0cb +d433104828c588baf00dece077a087dc7f4cd78097952adca5b6049e366ca944 diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm index 865a9b957..704fc1115 100644 Binary files a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources index b9928c8e8..f792701c7 100644 --- a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources @@ -1 +1 @@ -8d9c6bcd572b6fb54dd42c79d66d4971e55ec3dc072fc769f5634212c2927e22 +4093e1d4791c3786f4b67fd4ef76928fc17048470f858824a29aafd71855c77d diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index fec17dc6c..a6cc6567e 100644 Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index e62ffb8cd..02efff3b3 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -2af65b7db7edce8450f0ca829adb3b79fcb64b1ec3d39b785fb9df9423815e7b +aeb6d2f0a589731a1b7f71c9c71232b72d2535f0f555c4fbdb7101861b906d77 diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm index 2a468a118..d7f853d24 100644 Binary files a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index 9cfccba7f..28d8e8fb7 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -40f80dcba2f370d02e2d5ad9609b21500fc42fc7c7202b439485a9cbf7baf727 +b723a8826d256fb57f04bbe8aa4b5300c9777f79d889bed2770f6f2bd7a65aa3 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm index 5bd99d2cd..9ead8900a 100644 Binary files a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index a1c8782be..97ffa84d9 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -a5ca61ba85297c52936cf9892ccf1c678383641c84a5a885389b24582fa1abfb +31f6c0067f7668235ce7f28cb14f75950549c690a8f191aa63a71fe25f8397c6 diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm index e8710f3dd..75d7e93a4 100644 Binary files a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources index b77911fb8..c5e877c0e 100644 --- a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources @@ -1 +1 @@ -5723b90f8a742f912b315209fc597e3ea7f428a2a234cf4ea5b4620454206d71 +f03ff9487293925027c5681800633d2e0a71ef908db9ea037dff5e4e0a48458e diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 7fe3f4277..fef4c6628 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index 53d5b7a23..44671ae8b 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -dd5d41490f53a3b9b03d45ee8aba9f3fb08dd9211e835db5d3f9f7c42d3b6f9a +98a083b972c0a887de07b720c606ffcf13f99d835cfa64d553a1243ffc19e4b1 diff --git a/crates/ide-plugin/tests/fixtures/language-features/Cargo.lock b/crates/ide-plugin/tests/fixtures/language-features/Cargo.lock new file mode 100644 index 000000000..fdd99c28d --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/language-features/Cargo.lock @@ -0,0 +1,336 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "openidea-test-language-features" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/ide-plugin/tests/fixtures/language-features/Cargo.toml b/crates/ide-plugin/tests/fixtures/language-features/Cargo.toml new file mode 100644 index 000000000..2d5067628 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/language-features/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "openidea-test-language-features" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm new file mode 100644 index 000000000..c7ac7feb2 Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm.sources new file mode 100644 index 000000000..dea43dfa8 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm.sources @@ -0,0 +1 @@ +639a25cbc3aaab3080bf2894bc4710f45a1043a8a0f0f7f8c41ac20253f8bc98 diff --git a/crates/ide-plugin/tests/fixtures/language-features/plugin.toml b/crates/ide-plugin/tests/fixtures/language-features/plugin.toml new file mode 100644 index 000000000..0b77ae880 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/language-features/plugin.toml @@ -0,0 +1,31 @@ +id = "test.language-features" +name = "Language features fixture" +version = "0.1.0" +engines.openidea = "^1.4" +wasm = "dist/plugin.wasm" + +[[contributes.providers]] +kinds = ["formatting", "signature-help", "document-highlights", "document-links", "code-lenses", "selection-ranges", "folding", "colors", "document-symbols", "implementation", "type-definition", "gutter-actions", "injections"] +languages = ["plaintext", "text", "sql"] + +[[contributes.providers]] +kinds = ["completion", "hover", "diagnostics"] +languages = ["sql"] + +[[contributes.commands]] +id = "run" +title = "Run fixture" +icon = "run" + +[[contributes.commands]] +id = "state" +title = "Fixture state" + +[[contributes.snippets]] +language = "sql" +path = "snippets.json" + +[[contributes.menus]] +menu = "editor.title" +command = "run" +when = "editorLangId == sql" diff --git a/crates/ide-plugin/tests/fixtures/language-features/snippets.json b/crates/ide-plugin/tests/fixtures/language-features/snippets.json new file mode 100644 index 000000000..f19ee3f95 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/language-features/snippets.json @@ -0,0 +1 @@ +{"Select":{"prefix":["sel","select"],"body":["SELECT ${1:column}","FROM ${2:table}$0"],"description":"Select rows"}} diff --git a/crates/ide-plugin/tests/fixtures/language-features/src/lib.rs b/crates/ide-plugin/tests/fixtures/language-features/src/lib.rs new file mode 100644 index 000000000..db7190a63 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/language-features/src/lib.rs @@ -0,0 +1,144 @@ +use openidea_plugin::language_features::*; +use openidea_plugin::{ + Diagnostic, Error, Invocation, Location, Plugin, Position, Range, Severity, TextEdit, register_plugin, text, +}; +#[derive(Default)] +struct Fixture { + last: Option, +} +fn range(a: u32, b: u32) -> Range { + Range { start: Position { line: 0, character: a }, end: Position { line: 0, character: b } } +} +impl Plugin for Fixture { + fn run_command(&mut self, id: &str, _: &Invocation, arg: Option<&str>) -> Result, Error> { + match id { + "run" => { + self.last = arg.map(str::to_owned); + Ok(self.last.clone()) + } + "state" => Ok(self.last.clone()), + _ => Err(Error::NotFound(id.into())), + } + } +} +impl LanguageFeatures for Fixture { + fn format(&mut self, d: &Document, r: Option, _: FormatOptions) -> Result, Error> { + Ok(vec![TextEdit { + range: r.unwrap_or(Range { start: Position::default(), end: text::position(&d.text, d.text.len()) }), + text: "formatted".into(), + }]) + } + fn signature_help(&mut self, _: &Document, _: Position) -> Option { + Some(SignatureHelp { + signatures: vec![Signature { + label: "query(value)".into(), + documentation: Some("Query".into()), + parameters: vec![Parameter { label: ParameterLabel::Text("value".into()), documentation: None }], + }], + active_signature: 0, + active_parameter: 0, + }) + } + fn document_highlights(&mut self, _: &Document, _: Position) -> Vec { + vec![Highlight { range: range(0, 1), kind: "read".into() }] + } + fn document_links(&mut self, _: &Document) -> Vec { + vec![ + DocumentLink { range: range(0, 1), target: "next.txt".into() }, + DocumentLink { range: range(1, 2), target: "https://example.com".into() }, + ] + } + fn code_lenses(&mut self, _: &Document) -> Vec { + vec![ + CodeLens { range: range(0, 1), title: "Run".into(), command: "run".into(), argument: Some("lens".into()) }, + CodeLens { range: range(0, 1), title: "Foreign".into(), command: "foreign".into(), argument: None }, + ] + } + fn selection_ranges(&mut self, _: &Document, _: Position) -> Vec { + vec![range(0, 1), range(0, 5)] + } + fn folding(&mut self, _: &Document) -> Vec { + vec![FoldRange { start_line: 0, end_line: 2, kind: "region".into() }] + } + fn colors(&mut self, _: &Document) -> Vec { + vec![ColorInfo { range: range(0, 1), red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0 }] + } + fn color_presentations(&mut self, d: &Document, _: ColorInfo) -> Vec { + vec![format!("red-{}", d.language), "#ff0000".into()] + } + fn document_symbols(&mut self, _: &Document) -> Vec { + vec![ + DocumentSymbol { + name: "Outer".into(), + detail: "module".into(), + kind: "module".into(), + range: range(0, 5), + selection_range: range(0, 1), + parent: None, + }, + DocumentSymbol { + name: "Inner".into(), + detail: "value".into(), + kind: "variable".into(), + range: range(1, 2), + selection_range: range(1, 2), + parent: Some(0), + }, + ] + } + fn implementation(&mut self, d: &Document, _: Position) -> Vec { + vec![Location { path: d.path.clone(), range: range(1, 2) }] + } + fn type_definition(&mut self, d: &Document, _: Position) -> Vec { + vec![Location { path: d.path.clone(), range: range(2, 3) }] + } + fn gutter_actions(&mut self, _: &Document) -> Vec { + vec![GutterAction { + line: 0, + icon: Some("run".into()), + tooltip: "Run fixture".into(), + command: "run".into(), + argument: Some("gutter".into()), + }] + } +} +impl Injections for Fixture { + fn injections(&mut self, d: &Document) -> Vec { + if d.language == "sql" { + return vec![]; + } + let Some(start) = d.text.find("[[") else { return vec![] }; + let Some(end) = d.text[start + 2..].find("]]") else { return vec![] }; + vec![Injection { + range: Range { start: text::position(&d.text, start + 2), end: text::position(&d.text, start + 2 + end) }, + language: "sql".into(), + }] + } +} +impl openidea_plugin::providers::Providers for Fixture { + fn completion( + &mut self, + d: &Document, + _: Position, + _: Option<&str>, + ) -> Vec { + vec![openidea_plugin::providers::CompletionItem::new( + &format!("{}:{}", d.language, d.text), + openidea_plugin::providers::CompletionKind::Text, + )] + } +} +impl openidea_plugin::hover::HoverProvider for Fixture { + fn hover(&mut self, d: &Document, _: Position) -> Option { + Some(openidea_plugin::hover::Hover::new(format!("{}:{}", d.language, d.text))) + } +} +impl openidea_plugin::diagnostics::DiagnosticsProvider for Fixture { + fn diagnostics(&mut self, d: &Document) -> Vec { + vec![ + Diagnostic::new(range(0, 1), Severity::Warning, "outside"), + Diagnostic::new(range(4, 7), Severity::Warning, format!("{}:{}", d.language, d.text)), + ] + } +} +register_plugin!(Fixture, language_features, injections, providers, hover, diagnostics); diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm index 1c6092c27..2a180d2c1 100644 Binary files a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 1612f2bb1..36c651b29 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -185557c699fc00796eb487f50c688c3e91d8895bd05d4185b492352c187f9d98 +741d2909f021ec4c2f572edba0d83bcee4843a1aae4d8a79ea0367419a667640 diff --git a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs index 59db88875..18234bec2 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs @@ -5,9 +5,9 @@ wit_bindgen::generate!({ inline: " package fixture:misbehave; world misbehave { - include openidea:plugin/plugin-syntax@1.3.0; - export openidea:plugin/providers@1.3.0; - export openidea:plugin/file-actions@1.3.0; + include openidea:plugin/plugin-syntax@1.4.0; + export openidea:plugin/providers@1.4.0; + export openidea:plugin/file-actions@1.4.0; } ", path: "../../../../../wit", diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm index b77075715..ac24fadae 100644 Binary files a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources index 6f4fdb848..57e81184a 100644 --- a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources @@ -1 +1 @@ -d047b5618051144ec754b481b6bc75fae428d96c83711e9f0edf78749df59016 +1da271855c8e63d9b6873521392bb2e14cb85f9de4fddcc7bce0c80916298caf diff --git a/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm index d437b5359..7b7305fb5 100644 Binary files a/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm.sources index 0d0841088..de92d6fac 100644 --- a/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm.sources @@ -1 +1 @@ -18957534fc011510894544b61e154a9d0972c27ab1872d9d2accd01864984fa0 +e14bfd9a7f61a7ff44f656a148ed133e973c66edfb0859bbff4d4cda9663eb3a diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm index d270f4772..883742c9d 100644 Binary files a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index da42ec0b8..9a638a03a 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -3562bc253b0b4f0fa1cb15b9c8a7470a09719b28008aa8f5f459bbaba8eda598 +1c2f975248675b935adac4ac096d23d3f45ca44a46e9e4a2ee578bb69bb35356 diff --git a/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm index 49f377ecb..82c1b2f44 100644 Binary files a/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm.sources index 7b3b8525e..655dad638 100644 --- a/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm.sources @@ -1 +1 @@ -f28b40f3708ec4f828d04f7cec29208b66d0ab6158b26c60c0bd989cdc74f9b4 +2d566981f7f3aa78fbd6188e2bbdb17e33c01f6863e9b9a0b656f541371374bc diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm index 6dad1e8a6..cbfc1524c 100644 Binary files a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources index 98a0bf52b..e37a46194 100644 --- a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources @@ -1 +1 @@ -60f6c34375c918ee941dc8f5a1c7851732fd0f5dd2c14ec609db1955c557026a +69b349e65d32c5b22f7b6d312944e0e7387d1f7b8e46a1e88fcfcd87262ea407 diff --git a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm index 5d26e6c68..228ca9e39 100644 Binary files a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources index afddaf44f..01822bf2f 100644 --- a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources @@ -1 +1 @@ -edac658f8a54e396f294c74d031a31787508583256659a8effa8cc3d330e9a08 +e62e75f6e21f4d3c3797554581b6a78fc75a5362ce32f627c9858dd5f1d4edc5 diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm index 44219a22a..7ed6ce40b 100644 Binary files a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources index 02312108a..8eed9241d 100644 --- a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources @@ -1 +1 @@ -28e1d58c8ab50574e184cb401050a6fd2755c9398cd3a3720dc376f67461a44b +c4740acdb3c805e13bf9a0c2e2c2a830336a1085285c39de3dce73a301f70150 diff --git a/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm index 176b52e99..aa61e4998 100644 Binary files a/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm.sources index 7c51baa74..a3dd95e7c 100644 --- a/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm.sources @@ -1 +1 @@ -e80605523d9aaa1c88c83deac439a39a739541bdb327e279352441547de3c232 +315d592adb776916170a6c2293379e7a7a086b9ae513dcac4aefad925d91df5d diff --git a/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm index 5671f3313..82022e0ed 100644 Binary files a/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources index f039130a9..ddc072cc4 100644 --- a/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources @@ -1 +1 @@ -efb03a86021bf5690feab0446c77d977c6252a4da1ee41030d923350e6cc5d11 +7a88345173c4a77f6f94a047af53a43852adcf7e0377199f670374f296b98b19 diff --git a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm index df2e4016d..2ab1c4e4b 100644 Binary files a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources index c59478b01..22ad70b65 100644 --- a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources @@ -1 +1 @@ -009f7e069920bcf0e7699b57cd41954dc1dcb620977e081c24ec58ef0a9809d2 +c98b94057e62a575d6c4b6c4c92e1e88e101bcef9138683e47b0bebf262e3f63 diff --git a/crates/ide-remote/Cargo.toml b/crates/ide-remote/Cargo.toml index eb9828c7d..cd69e8b50 100644 --- a/crates/ide-remote/Cargo.toml +++ b/crates/ide-remote/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] ide-core.workspace = true +dunce.workspace = true ide-ssh.workspace = true ide-run.workspace = true serde.workspace = true diff --git a/crates/ide-remote/src/lib.rs b/crates/ide-remote/src/lib.rs index 2d19e4ad3..7a42a0132 100644 --- a/crates/ide-remote/src/lib.rs +++ b/crates/ide-remote/src/lib.rs @@ -16,3 +16,5 @@ pub use deploy::{ pub use mapping::{Excludes, Mappings, ResolvedMapping}; pub use service::{DeployedPath, DeploymentRoot, RemoteService}; pub use target::{RemoteRuns, RemoteStart}; + +pub mod plugin_target; diff --git a/crates/ide-remote/src/plugin_target.rs b/crates/ide-remote/src/plugin_target.rs new file mode 100644 index 000000000..cec24b904 --- /dev/null +++ b/crates/ide-remote/src/plugin_target.rs @@ -0,0 +1,185 @@ +//! Shell-free argv construction for plugin-owned run targets. +use ide_core::remote_settings::PluginRunTarget; +use ide_core::{IdeError, IdeResult}; +use ide_run::{RunConfig, wrap}; +use std::path::Path; + +/// Validates a bounded target before replacing an existing registration. +pub fn validate(target: &PluginRunTarget) -> IdeResult<()> { + let label = |s: &str, max: usize| !s.is_empty() && s.len() <= max && !s.chars().any(char::is_control); + if !label(&target.id, 128) || !label(&target.name, 256) || !label(&target.program, 256) { + return Err(IdeError::invalid("invalid run target id, name or program")); + } + let argv = |args: &[String]| args.len() <= 256 && args.iter().all(|a| a.len() <= 16384 && !a.contains('\0')); + let mut names = std::collections::BTreeSet::new(); + if !argv(&target.args) + || !argv(&target.workdir_args) + || target.commands.len() > 64 + || target.commands.iter().any(|(name, args)| { + !label(name, 256) || wrap::program_name(name) != *name || !names.insert(name) || !argv(args) + }) + || serde_json::to_vec(target).map_or(true, |v| v.len() > 256 << 10) + || target + .container_root + .as_ref() + .is_some_and(|r| !r.starts_with('/') || r.contains(['\0', '\n', '\r']) || r.split('/').any(|p| p == "..")) + { + return Err(IdeError::invalid("invalid run target arguments or container root")); + } + Ok(()) +} + +/// Wraps argv, keeping the local cwd and environment for the approved wrapper. +/// The wrapper decides whether to forward environment into its container. +pub fn prepare(root: &Path, target: &PluginRunTarget, program: &Path, config: &RunConfig) -> IdeResult { + validate(target)?; + let cwd = wrap::run_dir(root, config); + let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + let cwd = dunce::canonicalize(&cwd).unwrap_or(cwd); + let relative = + cwd.strip_prefix(&root).ok().filter(|p| !p.components().any(|c| matches!(c, std::path::Component::ParentDir))); + let mapped_cwd = target.container_root.as_deref().zip(relative).map(|(container, rel)| { + if rel.as_os_str().is_empty() { + container.to_owned() + } else { + format!("{}/{}", container.trim_end_matches('/'), rel.to_string_lossy().replace('\\', "/")) + } + }); + let mapped = target + .container_root + .as_deref() + .map(|container| crate::target::argv_command(config, &Default::default(), &root, container)); + let command = mapped.as_ref().map_or_else( + || { + let p = Path::new(&config.command); + if p.is_absolute() && !p.starts_with(&root) { + wrap::program_name(&config.command) + } else { + config.command.clone() + } + }, + |m| m.program.clone(), + ); + let name = wrap::program_name(&config.command); + let override_args = target.commands.iter().find(|(n, _)| n == &name).map(|(_, a)| a); + let mut args = override_args.unwrap_or(&target.args).clone(); + if let Some(cwd) = mapped_cwd { + args.extend(target.workdir_args.iter().map(|a| a.replace("{cwd}", &cwd))); + } + if override_args.is_none() { + args.push(command); + } + args.extend(mapped.as_ref().map_or_else(|| config.args.clone(), |m| m.args.clone())); + let mut result = config.clone(); + result.command = program.to_string_lossy().into_owned(); + result.args = args; + result.cwd = Some(cwd.into()); + result.extras.target = None; + // A wrapper's -c/-e are its own flags, not interpreter flags to inspect. + result.kind = "shell".into(); + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + fn config(command: &str, args: &[&str], cwd: &str) -> RunConfig { + serde_json::from_value(serde_json::json!({"id":"test", "name":"Test", "kind":"php", "command":command, "args":args, "cwd":cwd, "env":{}, "detected":false, "isTest":false})).unwrap() + } + #[test] + fn prefix_override_cwd_and_literal_argv() { + let target = PluginRunTarget { + id: "ddev".into(), + name: "DDEV".into(), + program: "ddev".into(), + args: vec!["exec".into()], + workdir_args: vec!["--dir".into(), "{cwd}".into()], + container_root: Some("/var/www/html".into()), + commands: vec![("composer".into(), vec!["composer".into()])], + }; + let tmp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(tmp.path()).unwrap(); + let root = base.join("project"); + std::fs::create_dir_all(root.join("src")).unwrap(); + let program = base.join("approved/ddev"); + let php = base.join("tools/php"); + let run = + prepare(&root, &target, &program, &config(php.to_str().unwrap(), &["x.php", "$(touch bad); a b"], "src")) + .unwrap(); + assert_eq!(Path::new(&run.command), program); + assert_eq!(run.args, ["exec", "--dir", "/var/www/html/src", "php", "x.php", "$(touch bad); a b"]); + assert_eq!(run.cwd.unwrap(), root.join("src")); + let outside = base.join("outside"); + let run = prepare( + &root, + &target, + &program, + &config(r"C:\tools\composer.exe", &["install"], outside.to_str().unwrap()), + ) + .unwrap(); + assert_eq!(run.args, ["composer", "install"]); + let in_project = prepare(&root, &target, &program, &config("/tools/composer", &["install"], "src")).unwrap(); + assert_eq!(in_project.args, ["composer", "--dir", "/var/www/html/src", "install"]); + let run = prepare(&root, &target, &program, &config("composer", &["install"], "../outside")).unwrap(); + assert_eq!(run.args, ["composer", "install"]); + let mut target = target; + target.container_root = None; + let run = prepare(&root, &target, &program, &config("php", &["x.php"], "src")).unwrap(); + assert_eq!(run.args, ["exec", "php", "x.php"]); + let run = prepare(&root, &target, &program, &config("echo a; touch bad", &[], ".")).unwrap(); + assert_eq!(run.args, ["exec", "echo a; touch bad"]); + } + + #[test] + fn command_overrides_match_both_path_separators_on_every_platform() { + let tmp = tempfile::tempdir().unwrap(); + let target = PluginRunTarget { + id: "ddev".into(), + name: "DDEV".into(), + program: "ddev".into(), + args: vec!["exec".into()], + workdir_args: Vec::new(), + container_root: Some("/container".into()), + commands: vec![("php".into(), vec!["php".into()])], + }; + for command in ["php", "/usr/bin/php", r"C:\tools\php.exe", r"\\?\C:\tools\php.exe", "C:/tools/php.cmd"] { + for container_root in [None, target.container_root.clone()] { + let target = PluginRunTarget { container_root, ..target.clone() }; + let run = prepare(tmp.path(), &target, Path::new("ddev"), &config(command, &["x.php"], ".")).unwrap(); + assert_eq!(run.args, ["php", "x.php"], "{command}"); + } + } + } + + #[cfg(windows)] + #[test] + fn plain_and_verbatim_roots_map_project_commands_and_cwds() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir(tmp.path().join("src")).unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + let verbatim = std::fs::canonicalize(tmp.path()).unwrap(); + let target = PluginRunTarget { + id: "ddev".into(), + name: "DDEV".into(), + program: "ddev".into(), + args: vec!["exec".into()], + workdir_args: vec!["--dir={cwd}".into()], + container_root: Some("/container".into()), + commands: Vec::new(), + }; + let command = root.join("tool.exe"); + for project in [&root, &verbatim] { + for cwd in [root.join("src"), verbatim.join("src")] { + let run = prepare( + project, + &target, + Path::new("ddev"), + &config(command.to_str().unwrap(), &["arg"], cwd.to_str().unwrap()), + ) + .unwrap(); + assert_eq!(run.args, ["exec", "--dir=/container/src", "/container/tool.exe", "arg"]); + assert_eq!(run.cwd.unwrap(), root.join("src")); + } + } + } +} diff --git a/crates/ide-remote/src/target.rs b/crates/ide-remote/src/target.rs index 97577e0d9..1bf971216 100644 --- a/crates/ide-remote/src/target.rs +++ b/crates/ide-remote/src/target.rs @@ -109,6 +109,22 @@ fn interpreter<'a>(program: &str, target: &'a RunTarget) -> Option<&'a str> { /// Builds the remote command of `config`; `root` (the project) maps onto `remote_root`. pub fn remote_command(config: &RunConfig, target: &RunTarget, root: &Path, remote_root: &str) -> RemoteCommand { + target_command(config, target, root, remote_root, true) +} + +/// Maps paths and interpreters while preserving argv, including a command containing spaces. +/// Unlike SSH/container shell configurations, plugin wrappers never accept a shell line. +pub fn argv_command(config: &RunConfig, target: &RunTarget, root: &Path, remote_root: &str) -> RemoteCommand { + target_command(config, target, root, remote_root, false) +} + +fn target_command( + config: &RunConfig, + target: &RunTarget, + root: &Path, + remote_root: &str, + allow_shell: bool, +) -> RemoteCommand { let root_str = root.to_string_lossy().into_owned(); let map = |s: &str| -> String { if root_str.is_empty() { @@ -129,7 +145,7 @@ pub fn remote_command(config: &RunConfig, target: &RunTarget, root: &Path, remot }; let env = config.env.iter().map(|(k, v)| (k.clone(), map_text(v))).collect(); let command = config.command.trim(); - if config.args.is_empty() && command.contains(char::is_whitespace) { + if allow_shell && config.args.is_empty() && command.contains(char::is_whitespace) { return RemoteCommand { program: String::new(), args: Vec::new(), diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 7ad8e2009..14c689878 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -1173,6 +1173,29 @@ fn add_maven_options(args: &mut Vec, options: &[String], additive_profil args.splice(0..0, missing); } +/// The interpreter target or project default; an explicit local choice returns an empty id. +pub fn run_target<'a>(config: &RunConfig, settings: &'a Settings) -> Option<&'a str> { + let name = ide_run::wrap::program_name(&config.command); + let target = if matches!(name.as_str(), "php" | "composer" | "phpunit" | "pest") + || matches!(config.kind.as_str(), "php" | "composer" | "phpunit") + { + &settings.languages.php.run_target + } else if matches!(name.as_str(), "node" | "npm" | "npx" | "pnpm" | "yarn" | "bun") + || matches!(config.kind.as_str(), "node" | "npm" | "vitest" | "jest" | "mocha") + { + &settings.languages.node.run_target + } else { + return non_empty(&settings.run.default_target); + }; + if target == "local" { + Some("") + } else if target.is_empty() { + non_empty(&settings.run.default_target) + } else { + Some(target) + } +} + /// Points run configurations at the selected toolchains: Python configs use the /// project interpreter, npm scripts the chosen package manager and Node.js, /// cargo / go configs the selected toolchain, Maven / Gradle their runner JDK @@ -1210,6 +1233,19 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings }; for c in configs.iter_mut() { + if let Some(pm) = pm + && c.kind == "npm" + && c.detected + && matches!(c.command.as_str(), "npm" | "pnpm" | "yarn" | "bun") + { + c.command = pm.to_owned(); + } + if c.extras.target.is_none() { + c.extras.target = run_target(c, s).map(str::to_owned); + } + if c.extras.target.as_deref().is_some_and(|t| t.starts_with("plugin-target:")) { + continue; + } let owner = ide_run::scope::owner(root, c); resolve_run_command(c, root, s); let mut path_dirs: Vec = Vec::new(); @@ -1243,13 +1279,6 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings } } "npm" | "node" | "deno" | "bun" => { - if let Some(pm) = pm - && c.kind == "npm" - && c.detected - && matches!(c.command.as_str(), "npm" | "pnpm" | "yarn" | "bun") - { - c.command = pm.to_owned(); - } // The configuration's own Node interpreter, else the project's. let own = c.extras.params.get("nodeInterpreter").and_then(|p| setting_path(root, p)); if let Some(dir) = @@ -1367,6 +1396,9 @@ pub fn without_derived(config: &RunConfig, root: &Path, s: &Settings) -> RunConf c }; let mut out = config.clone(); + if out.extras.target.as_deref() == run_target(&out, s) { + out.extras.target = None; + } // Until nothing changes: some entries are derived only once others are gone // (the virtualenv's `PATH` is added only without `VIRTUAL_ENV`). An entry derived before // the configuration's own choice (an npm script's Node interpreter) is derived too. diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index ec9d12190..dcef83a8d 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -2169,3 +2169,31 @@ fn project_structure_module_jdk_language_level_and_external_libraries() { let groups = crate::libraries::project_libraries(root, &settings, &[]); assert!(groups.iter().any(|g| g.name == "servlet-api" && g.kind == "library" && g.roots.len() == 1)); } + +#[test] +fn plugin_interpreter_targets_route_all_php_node_command_families() { + let mut settings = Settings::default(); + settings.languages.php.run_target = "plugin-target:php".into(); + settings.languages.node.run_target = "plugin-target:node".into(); + settings.languages.php.interpreter = "/local/php".into(); + settings.languages.node.interpreter = "/local/node".into(); + for (command, kind, expected) in [ + ("php", "php", "php"), + ("composer", "composer", "php"), + ("vendor/bin/phpunit", "phpunit", "php"), + ("node", "node", "node"), + ("npm", "npm", "node"), + ("node_modules/.bin/vitest", "vitest", "node"), + ("npx", "node", "node"), + ] { + let mut config: ide_run::RunConfig = serde_json::from_value(serde_json::json!({ "id":"run", "name":"run", "kind":kind, "command":command, "args":[], "env":{}, "detected":true, "isTest":false })).unwrap(); + crate::apply::apply_to_run_configs(std::slice::from_mut(&mut config), Path::new("/project"), &settings); + assert_eq!(config.extras.target.as_deref(), Some(format!("plugin-target:{expected}").as_str())); + assert_eq!(config.command, command); + assert!(config.env.is_empty()); + assert!(crate::apply::without_derived(&config, Path::new("/project"), &settings).extras.target.is_none()); + config.extras.target = Some(String::new()); + crate::apply::apply_to_run_configs(std::slice::from_mut(&mut config), Path::new("/project"), &settings); + assert_eq!(config.extras.target.as_deref(), Some("")); + } +} diff --git a/crates/ide-vcs/src/cli.rs b/crates/ide-vcs/src/cli.rs index 3a73f2704..56b20e938 100644 --- a/crates/ide-vcs/src/cli.rs +++ b/crates/ide-vcs/src/cli.rs @@ -381,10 +381,46 @@ fn run_cancellable_raw_stdin( args: &[&str], stdin: Option<&[u8]>, cancel: &CancellationToken, +) -> IdeResult { + run_capture(root, args, stdin, cancel, usize::MAX) +} + +/// Neutralizes repository clean/process filters for a read of working-tree bytes. +/// Git still applies them with --no-ext-diff and --no-textconv. +pub(crate) fn filter_overrides(root: &Path, cancel: &CancellationToken) -> IdeResult> { + if crate::execution_allowed() { + return Ok(Vec::new()); + } + let config = run_bounded(root, &["config", "--null", "--list"], cancel, 4 << 20)?; + let mut overrides = Vec::new(); + for record in config.stdout_str().split('\0') { + let key = record.split('\n').next().unwrap_or_default(); + if key.starts_with("filter.") + && (key.ends_with(".clean") || key.ends_with(".process") || key.ends_with(".required")) + { + overrides.push("-c".into()); + overrides.push(format!("{key}={}", if key.ends_with(".required") { "false" } else { "" })); + } + } + Ok(overrides) +} + +/// Cancellable capture with bounded stdout and stderr; stops git on overflow. +pub(crate) fn run_bounded(root: &Path, args: &[&str], cancel: &CancellationToken, max: usize) -> IdeResult { + let out = run_capture(root, args, None, cancel, max)?; + if out.code == Some(0) { Ok(out) } else { Err(failure(args, &out.stderr, &out.stdout, out.code)) } +} +fn run_capture( + root: &Path, + args: &[&str], + stdin: Option<&[u8]>, + cancel: &CancellationToken, + max: usize, ) -> IdeResult { if cancel.is_cancelled() { return Err(IdeError::cancelled()); } + let overflow = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let mut cmd = command(root)?; cmd.args(args) .stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() }) @@ -404,22 +440,32 @@ fn run_cancellable_raw_stdin( _ => None, }; let capture = |mut pipe: Box| { + let overflow = overflow.clone(); std::thread::spawn(move || { let mut bytes = Vec::new(); - pipe.read_to_end(&mut bytes).map(|_| bytes) + (&mut pipe).take(max.saturating_add(1) as u64).read_to_end(&mut bytes)?; + if bytes.len() > max { + overflow.store(true, std::sync::atomic::Ordering::Release); + std::io::copy(&mut pipe, &mut std::io::sink())?; + } + Ok(bytes) }) }; let stdout = capture(Box::new(child.stdout.take().expect("piped stdout"))); let stderr = capture(Box::new(child.stderr.take().expect("piped stderr"))); let status = loop { - if cancel.is_cancelled() { + if cancel.is_cancelled() || overflow.load(std::sync::atomic::Ordering::Acquire) { terminate_tree(&mut child, KILL_GRACE); let _ = stdout.join(); let _ = stderr.join(); if let Some(w) = writer { let _ = w.join(); } - return Err(IdeError::cancelled()); + return Err(if cancel.is_cancelled() { + IdeError::cancelled() + } else { + IdeError::invalid(format!("git output exceeds {max} bytes")) + }); } if let Some(status) = child.try_wait()? { break status; @@ -434,6 +480,9 @@ fn run_cancellable_raw_stdin( }; let out = Output { stdout: join(stdout)?, stderr: join(stderr)?, code: status.code() }; session.finish(status.success(), &String::from_utf8_lossy(&out.stderr)); + if out.stdout.len() > max || out.stderr.len() > max { + return Err(IdeError::invalid(format!("git output exceeds {max} bytes"))); + } Ok(out) } diff --git a/crates/ide-vcs/src/compare.rs b/crates/ide-vcs/src/compare.rs index d2404185c..6e1bc738b 100644 --- a/crates/ide-vcs/src/compare.rs +++ b/crates/ide-vcs/src/compare.rs @@ -135,19 +135,7 @@ impl Repos { let mut compare = || -> IdeResult<(String, Vec)> { // Worktree comparisons must not run repository-defined clean/process filters // in Restricted Mode. External diff and textconv are disabled below as well. - let mut overrides = Vec::new(); - if !crate::execution_allowed() { - let config = cli::run_cancellable(repo.root(), &["config", "--null", "--list"], cancel)?; - for record in config.stdout_str().split('\0') { - let key = record.split('\n').next().unwrap_or_default(); - if key.starts_with("filter.") - && (key.ends_with(".clean") || key.ends_with(".process") || key.ends_with(".required")) - { - overrides.push("-c".to_owned()); - overrides.push(format!("{key}={}", if key.ends_with(".required") { "false" } else { "" })); - } - } - } + let overrides = cli::filter_overrides(repo.root(), cancel)?; let run = |args: &[&str]| { let mut full: Vec<&str> = overrides.iter().map(String::as_str).collect(); full.extend_from_slice(args); diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 9e59c7db3..9c1c12106 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -1013,6 +1013,30 @@ impl Repo { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); refs::stashes(self) } + /// Working tree against a revision, optionally restricted to a file. Bounded and cancellable. + pub fn working_tree_patch( + &self, + path: Option<&Path>, + against: Option<&str>, + max_bytes: usize, + cancel: &CancellationToken, + ) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + let rev = against.unwrap_or("HEAD"); + if rev.is_empty() || rev.starts_with('-') || rev.contains('\0') { + return Err(ide_core::IdeError::invalid("invalid revision")); + } + let rel = path.map(|p| self.rel_path(p)).transpose()?; + let overrides = cli::filter_overrides(self.root(), cancel)?; + let mut args: Vec<&str> = overrides.iter().map(String::as_str).collect(); + args.extend(["--literal-pathspecs", "--no-pager", "diff", "--no-ext-diff", "--no-textconv", rev, "--"]); + if let Some(rel) = &rel { + args.push(rel); + } + let out = cli::run_bounded(self.root(), &args, cancel, max_bytes)?; + Ok(out.stdout_str()) + } + /// Unified diff of a file: index↔worktree (`staged == false`, untracked files diff /// against /dev/null) or HEAD↔index (`staged == true`). Empty when unchanged. pub fn diff_text(&self, path: &Path, staged: bool, encoding: impl Into) -> IdeResult { diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index 64958f0fb..21ae5f937 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -146,7 +146,12 @@ fn converted_blame( let current = crate::diff::normalize_eol(text); let mut result = uncommitted_lines(current.split_inclusive('\n').count()); let Some(base) = r.file_bytes_at(path, Some(revision))? else { return Ok(result) }; - let out = run(r.root(), &["--literal-pathspecs", "blame", "--porcelain", revision, "--", rel], None)?; + let args: &[&str] = if crate::execution_allowed() { + &["--literal-pathspecs", "blame", "--porcelain", revision, "--", rel] + } else { + &["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", rel] + }; + let out = run(r.root(), args, None)?; let physical = crate::blame::parse_porcelain(&out.stdout_str()); let raw = crate::text_encoding::decode_raw(&base, encoding); let raw = crate::diff::normalize_eol(&raw); diff --git a/deny.toml b/deny.toml index 418b97b38..56636f0de 100644 --- a/deny.toml +++ b/deny.toml @@ -11,7 +11,7 @@ ignore = [ [licenses] allow = [ "MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", - "BSD-3-Clause", "ISC", "Zlib", "0BSD", "CC0-1.0", "Unlicense", + "BSD-3-Clause", "ISC", "Zlib", "0BSD", "CC0-1.0", "Unlicense", "MIT-0", # File-level copyleft, used unmodified as dependencies. "MPL-2.0", "Unicode-3.0", "Unicode-DFS-2016", "CDLA-Permissive-2.0", "OpenSSL", diff --git a/plugins/README.md b/plugins/README.md index 333cd1df4..af88cd494 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -37,7 +37,7 @@ sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host) crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens, hover, commit, inlay-hints ``` -The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.3.0`, world +The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.4.0`, world `plugin`). ## 1. Quick start @@ -137,24 +137,27 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK. ### Contribution points -| Point | Declaration | Code needed | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | -| `commands` | `id`, `title`, `category?`, `when?` | to run them | -| `themes` | `path` (theme JSON), `editor-scheme?` | no | -| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no | -| `languages` | `path` (`languages/.toml` format, see §6) | no | -| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no | -| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes | -| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them | -| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`, `commit.message`), `view?`, `command`, `when?` | no | -| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no | -| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no | -| `settings` | `schema` (JSON file) or `pages` (API 1.3), `title?` — see _Settings_ | no | -| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_), `group?`, `when?` (API 1.3) | without `web` | -| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` | -| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no | -| `fileTemplates` | `id`, `name`, `extension`, `path`; API 1.3: `fileName`, `variables = [{ name, prompt }]` — New menu | no | -| `commitChecks` | `id`, `title` — a before-commit check in Commit Options (see _Commits_) | yes | +| Point | Declaration | Code needed | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| `commands` | `id`, `title`, `category?`, `when?` | to run them | +| `themes` | `path` (theme JSON), `editor-scheme?` | no | +| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no | +| `languages` | `path` (`languages/.toml` format, see §6) | no | +| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?`, `framework?` (API 1.4; §6.2) | no | +| `runConfigurations` | `files`: marker files for the optional `run-configurations` export (API 1.4) | yes | +| `mcpTools` | `name`, `description`, `inputSchema` (package JSON Schema path), `readOnly`, `destructive` (API 1.4; see _Plugin MCP tools_) | yes | +| `snippets` | `language`, `path` (named JSON snippets; API 1.4) | no | +| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes | +| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them | +| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`, `commit.message`; API 1.4: `editor.title`, `mainToolbar`, `problems.context`, `searchResults.context`), `view?`, `command`, `when?` | no | +| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no | +| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no | +| `settings` | `schema` (JSON file) or `pages` (API 1.3), `title?` — see _Settings_ | no | +| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_), `group?`, `when?` (API 1.3) | without `web` | +| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` | +| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no | +| `fileTemplates` | `id`, `name`, `extension`, `path`; API 1.3: `fileName`, `variables = [{ name, prompt }]` — New menu | no | +| `commitChecks` | `id`, `title` — a before-commit check in Commit Options (see _Commits_) | yes | A `viewItem.context` menu needs `view`, the id of one of the plugin's own views. It adds the commands to the right-click menu of the items in that @@ -287,23 +290,27 @@ installing (or when a new version asks for more). Low-risk capabilities are listed; high-risk ones are called out as equivalent to running code on the machine. Users can revoke optional capabilities in Settings › Plugins. -| Capability | Risk | Unlocks today | -| ------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- | -| `project.read` | low | `Project::root`, `read_file`, `find_files`, `symbols`, `host::projects`, `publish_diagnostics` | -| `project.write` | medium | `Project::write_file` — creates and replaces text files inside the open projects | -| `fs.read:` | high | `host::fs::read_file`, `read_bytes`, `stat`, `read_dir` — files outside the projects, under the grant's directory | -| `fs.write:` | high | `host::fs::write_file`, `write_bytes` — files under that path; doesn't imply reading | -| `editor.read` | low | `TextEditor::document`, `selections` | -| `editor.write` | medium | `TextEditor::apply_edits` | -| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes), `vcs_remotes`, `host::pending_commit`, before-commit checks | -| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only), `host::set_commit_message` | -| `storage` | low | `host::storage` (64 MiB per plugin, counting UTF-8 bytes of keys and values) | -| `events` | low | file opened/saved, editor focused, repository changed events | -| `events.input` | medium | `action-performed` events (which actions you run, and how) | -| `process:` | high | `Project::run_process`, language servers, debug adapters, runs | -| `net:` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) | -| `secrets` | medium | `{{secret:}}` in request headers, `host::secret_is_set` | -| `clipboard` | medium | `host::copy_to_clipboard` and the clipboard API in the plugin's web views | +| Capability | Risk | Unlocks today | +| ------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `ide.commands` | high | Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing. API 1.4; declared own commands are exempt. | +| `database.sources` | medium | API 1.4: manage only the plugin's own project database sources, write their secure credentials and request connection; trusted projects only. | +| `project.read` | low | `Project::root`, `read_file`, `find_files`, `symbols`, `search_text`, `host::projects`, `publish_diagnostics`, `problems`, editor opening and decorations | +| `project.write` | medium | `Project::write_file` — creates and replaces text files inside the open projects | +| `fs.read:` | high | `host::fs::read_file`, `read_bytes`, `stat`, `read_dir` — files outside the projects, under the grant's directory | +| `fs.write:` | high | `host::fs::write_file`, `write_bytes` — files under that path; doesn't imply reading | +| `editor.read` | low | `TextEditor::document`, `selections`, `host::open_editors` | +| `editor.write` | medium | `TextEditor::apply_edits` | +| `vcs.read` | low | `Project::vcs_status`, `vcs_remotes`, `vcs_log`, `vcs_blame`, `vcs_diff` (new reads require trust), `host::pending_commit`, before-commit checks | +| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only), `host::set_commit_message` | +| `storage` | low | `host::storage` (64 MiB per plugin, counting UTF-8 bytes of keys and values) | +| `events` | low | file opened/saved, editor focused, repository changed events | +| `events.input` | medium | `action-performed` and `input-mode-changed` events | +| `process:` | high | `Project::run_process`, language servers, debug adapters, runs | +| `net:` | medium | `host::fetch` over HTTPS (also loopback HTTP); `*.example.com` matches subdomains | +| `net.http:` | medium | Unencrypted HTTP to that host; API 1.4, same wildcard rules as `net:` | +| `ipc:` | high | Local Unix sockets or Windows named pipes on the project machine; API 1.4 | +| `secrets` | medium | `{{secret:}}` in request headers, `host::secret_is_set`, `secret_set`, `secret_delete` | +| `clipboard` | medium | `host::copy_to_clipboard` and the clipboard API in the plugin's web views | `process:` capabilities are also derived from what the contributions start: a language's `language-servers` and `debugger` commands and a run detector's @@ -358,6 +365,49 @@ are always available. Calling a gated function without its capability returns ## 3. Writing code (Rust SDK) +### API 1.4 + +API 1.4.0 is planned for the next OpenIDEA release. Use +`engines.openidea = "^1.4"` and the matching SDK. Released 1.3 plugins keep +working. New in 1.4: + +- [Permissions](#capabilities): `net.http:` for unencrypted HTTP, + `ipc:` for project-host sockets/pipes, `ide.commands` for IDE/other + plugin commands, and `database.sources` for owned database connections. +- [Network and secrets](#network-and-secrets): scheme-aware HTTP grants, + socket resources, write-only secret set/delete and desktop OAuth loopback + redirect resources. Settings › Plugins can select/reapprove a process + executable globally or per project. +- [The API](#the-api): window status overrides, `command-runner.execute`, + per-plugin Output channels (`append`, `clear`, `reveal`). +- [Editor and project snapshots](#editor-and-project-snapshots-api-14): open + editors, open/select/reveal, editor and tree decorations, text search, + Problems snapshots, VCS log/blame/diff. +- [Events](#events): file-closed, settings-changed, input-mode-changed and + `vim.mode`; owned run-exited, terminal-exited and content-tab-action events. +- [Runs, terminals and tests](#runs-terminal-sessions-and-test-results-api-14): + `run.start`, keyed terminal sessions/write, `run-configurations` and + `test-results` exports, wasm content tabs and replies. +- [Run targets](#plugin-provided-run-targets-api-14): approved plugin wrappers, + project/PHP/Node defaults, path/cwd mapping and per-command overrides. +- [Language features](#additional-language-features-api-14): formatting, + signature help, highlights, links, code lenses, selection ranges, folds, + colors/presentations, symbols, implementation, type definition and gutter + actions; [injections](#embedded-languages-api-14) route plugin providers to + embedded virtual documents. +- [Snippets](#snippets-api-14) and [menus](#additional-menu-locations-api-14): + numbered placeholders and editor title, main toolbar, Problems and search + result context contributions, with optional command icons. +- [Database sources](#database-sources-api-14): non-secret owned settings, + write-only credentials, connection/introspection and uninstall cleanup. +- [MCP tools](#plugin-mcp-tools-api-14): manifest schemas, optional + `mcp-provider` export, trust/confirmation rules and `mcp.stdio-command`. + +Small runnable API examples: [blame annotations](official/blame-annotations/), +[container wrapper](official/container-runner/), [test runner](official/test-runner/) +and [TODO finder](official/todo-finder/). These demonstrate extension points; +see [official and community plugins](#81-official-and-community-plugins). + `openidea-plugin new` writes this for you. `Cargo.toml`: ```toml @@ -438,45 +488,73 @@ then run `openidea-plugin check`. ### The API -| Rust SDK | WIT | Notes | -| ------------------------------------------------------------------------ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Plugin::run_command(id, ctx, arg)` | export `commands.run` | `ctx` is the window: `ctx.project()`, `ctx.editor()`. An error shows _" failed: …"_; the plugin keeps running. `"msg".into()` is `Error::Failed`; `Cancelled` and `Denied` don't count as the plugin failing. | -| `Plugin::handle_events(events, ctx)` | export `event-handler.handle` | Batches of subscribed events, oldest first, per window. | -| `Plugin::activate` / `deactivate` | export `lifecycle` | Per instance: after a crash or idle reclaim a new one is activated. | -| `TextEditor::document()` → `{version, text}` | `editor.text-editor.document` | `selections()` are those when the command ran (0-based lines, UTF-16 columns). | -| `TextEditor::apply_edits(version, edits)` | `editor.text-editor.apply-edits` | One undoable change, only if the document is still at `version` — else `Error::Stale` and nothing changes. `transform_selections` retries once. | -| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win; a missing path is `Error::NotFound`. | -| `Project::exists(path)`, `stat(path)` | `workspace.project` | `exists` covers files and directories, hidden and ignored included, without reading content; `stat` gives kind, size and modified time (`Error::NotFound` for a missing path). | -| `host::projects()` | `workspace.projects` | Every open project (for scheduled work without a window). | -| `Project::vcs_status()`, `vcs_remotes()`, `vcs_fetch()` | `vcs` | `vcs_remotes` returns `(name, url)` pairs; fetch runs git, so only in trusted projects (call it inside a task). | -| `host::pending_commit()`, `host::set_commit_message(revision, text)` | `commit` | The commit being prepared in the calling window and its message; see _Commits_. | -| `host::subscribe(kind)` | `events.subscribe` | For the session, across idle reclaim (an event activates the plugin). Or declare `on-event:<kind>`. | -| `host::begin_task(title, cancellable)` → `Task` | `tasks.begin` | Shown with the background tasks; `report`, `is_cancelled`; the call may run 60 s instead of 5 s while it is open; dropping it ends it. | -| `host::storage::{get, set, delete, keys}` | `storage` | Survives restarts; the instance's memory does not. Deleted on uninstall. | -| `host::set_status_item(id, item)`, `remove_status_item` | `ui` | `text`, `tooltip`, `command` (one of yours, run on click); at most 8; kept while the plugin is idle. | -| `host::set_context(key, value)`, `remove_context` | `ui.{set,remove}-context` | Sets `plugin.<id>.<key>` for `when` clauses in the window the call came from (`true`, `3.5`, `"text"`; `ContextValue`). Key 1–64 of `A-Za-z0-9_-`, 100 per plugin; gone when the plugin stops. | -| `host::notify(level, title, body)` | `ui.show-notification` | Balloon in the window the command or events came from (every window otherwise). | -| `host::copy_to_clipboard(text)` | `ui.copy-to-clipboard` | Copies `text` to the system clipboard. Needs `clipboard`. | -| `host::open_url(url)` | `ui.open-url` | Opens an `http://` or `https://` URL in the user's default browser. | -| `host::pick(title, items)`, `pick_many` | `ui.pick`, `ui.pick-many` | Picks key(s) from `PickItem`s, or `None` when dismissed. From a command/event or code-action resolution in its editor window (else `invalid`); waiting is excluded from the budget. | -| `host::input(prompt, value)` | `ui.input` | A one-line text box pre-filled with `value`; `None` when dismissed. Same rules as `pick`. | -| `host::input_multiline(prompt, value)` | `ui.input-multiline` | A multi-line text box pre-filled with `value`; `None` when dismissed; Cmd/Ctrl+Enter confirms. Only from a command or event of a window. | -| `host::confirm(title, body)` | `ui.confirm` | OK / Cancel; `true` on OK. Same rules as `pick`. | -| `host::locale()` | `ui.locale` | The IDE's UI language as a BCP 47 tag (`en`, `fr`, `zh-CN`); no capability. See _Translations_. | -| `host::log(level, msg)` | `log` | IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. | -| `Project::find_files(glob, limit)` | `workspace.project.find-files` | Project-relative glob (`**/*Mapper.xml`); honours `.gitignore`; at most `limit` paths. | -| `Project::symbols(query, limit)` | `workspace.symbols` | New in 1.3; `project.read`. Fuzzy in-memory search (no LSP): name, camelCase kind, container and UTF-16 location. Queries ≤ 256 UTF-8 bytes; limits capped at 1000. Snapshot notes below. | -| `host::publish_diagnostics(path, items)` | `diagnostics.publish` | Replaces this plugin's problems for the file (editor squiggles and the Problems view); an empty list clears them. | -| `host::fetch(request)` → `{status, headers, body}` | `net.fetch` | See _Network_. | -| `Project::run_process(program, args, stdin)` → `{code, stdout, stderr}` | `process.run` | Only the pinned program of a `process:` grant; runs in the project root of a trusted project; 4 MiB of output; killed when the call is cancelled or times out. | -| `host::run_command(cmd, project, stdin)` → `{code, stdout, stderr}` | `process.run-command` | API 1.3: same limits as `run_process`, with append-only environment variables; needs the pinned `process:<program>` grant. | -| `host::start(cmd, project)` → `Child` | `process.start` | API 1.3: `poll(max_bytes)`, `write(text)`, `close_stdin()`, `wait(timeout_ms)`, `kill()`; at most 8 handles; dropping one kills its process. | -| `host::refresh_view(view)` | `views.refresh` | The view (or custom editors) render again; call it when the content changed. | -| `host::reveal_view(view)` | `views.reveal` | Brings one of your declared `[[contributes.views]]` to the front in the calling window. | -| `host::post_view_message(view, text)` | `views.post-message` | To the script of the view's `html` body. | -| `host::open_terminal(title, program, args)` | `views.open-terminal` | A terminal tab in the invoking window running the approved `process:<program>` in the project root: output that keeps coming, interactive sessions. | -| `host::open_location(path, line, column)` | `views.open-location` | Opens a file inside the project in an editor of the invoking window, the caret at `line`/`column` (0-based, UTF-16): a view's links to code. Needs `project.read`. | -| `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. | +| Rust SDK | WIT | Notes | +| -------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Plugin::run_command(id, ctx, arg)` | export `commands.run` | `ctx` is the window: `ctx.project()`, `ctx.editor()`. An error shows _"<title> failed: …"_; the plugin keeps running. `"msg".into()` is `Error::Failed`; `Cancelled` and `Denied` don't count as the plugin failing. | +| `Plugin::handle_events(events, ctx)` | export `event-handler.handle` | Batches of subscribed events, oldest first, per window. | +| `Plugin::activate` / `deactivate` | export `lifecycle` | Per instance: after a crash or idle reclaim a new one is activated. | +| `TextEditor::document()` → `{version, text}` | `editor.text-editor.document` | `selections()` are those when the command ran (0-based lines, UTF-16 columns). | +| `Project::{upsert_data_source,remove_data_source,data_sources,set_data_source_credential,connect_data_source}` | `database` | API 1.4: owned project sources; requires `database.sources` and trust. See _Database sources_. | +| `host::mcp_stdio_command()` | `mcp.stdio-command` | API 1.4: invoking project bridge argv, `None` while off. No URL/token. | +| `McpProvider::call(tool, arguments, ctx)` | export `mcp-provider.call` | API 1.4: declared tools under plugin grants and command budget. See _Plugin MCP tools_. | +| `host::open_editors()` | `editor.open-editors` | API 1.4: invoking window's path/language/dirty/active snapshots; requires `editor.read`. Commands/events of a window only. | +| `host::open_editor(path, selection)` | `editor.open` | API 1.4: open/select a project file (`project.read`) or plugin-dir file; same path rules as `views.open-location`. Commands/events of a window only. | +| `TextEditor::set_selections(ranges)`, `reveal(range)` | `editor.text-editor` | API 1.4: selections or scrolling in this window's already open editor; no capability. Commands/events only, UTF-16 coordinates. | +| `host::set_decorations`, `clear_decorations` | `decorations.{set,clear}` | API 1.4: project-scoped editor annotations cleared on plugin stop. Requires `project.read`. | +| `host::set_file_decoration`, `clear_file_decorations` | `decorations.{set-file,clear-file}` | API 1.4: project-tree badges/styles. Requires `project.read`. | +| `Project::search_text(query, limit)` | `workspace.project.search-text` | API 1.4: cancellable Find in Files with unsaved documents and project excludes, at most 10,000 matches. Requires `project.read`. | +| `host::problems(path)` | `diagnostics.problems` | API 1.4: project Problems reports grouped by file/source, optional file filter. Requires `project.read` and an invoking window. Empty for remote projects. | +| `Project::vcs_log(path, limit)` | `vcs.log` | API 1.4: newest first, at most 1000 commits, optional file history. Requires `vcs.read` and trust. | +| `Project::vcs_blame(path)` | `vcs.blame` | API 1.4: attribution including unsaved editor text. Requires `vcs.read` and trust. | +| `Project::vcs_diff(path, against)` | `vcs.diff` | API 1.4: disk working tree against HEAD/revision, optionally one file. None outside Git; fails above 4 MiB. Requires `vcs.read` and trust. | +| `TextEditor::apply_edits(version, edits)` | `editor.text-editor.apply-edits` | One undoable change, only if the document is still at `version` — else `Error::Stale` and nothing changes. `transform_selections` retries once. | +| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win; a missing path is `Error::NotFound`. | +| `Project::exists(path)`, `stat(path)` | `workspace.project` | `exists` covers files and directories, hidden and ignored included, without reading content; `stat` gives kind, size and modified time (`Error::NotFound` for a missing path). | +| `host::projects()` | `workspace.projects` | Every open project (for scheduled work without a window). | +| `Project::vcs_status()`, `vcs_remotes()`, `vcs_fetch()` | `vcs` | `vcs_remotes` returns `(name, url)` pairs; fetch runs git, so only in trusted projects (call it inside a task). | +| `host::pending_commit()`, `host::set_commit_message(revision, text)` | `commit` | The commit being prepared in the calling window and its message; see _Commits_. | +| `host::subscribe(kind)` | `events.subscribe` | For the session, across idle reclaim (an event activates the plugin). Or declare `on-event:<kind>`. | +| `host::begin_task(title, cancellable)` → `Task` | `tasks.begin` | Shown with the background tasks; `report`, `is_cancelled`; the call may run 60 s instead of 5 s while it is open; dropping it ends it. | +| `host::storage::{get, set, delete, keys}` | `storage` | Survives restarts; the instance's memory does not. Deleted on uninstall. | +| `host::set_status_item(id, item)`, `remove_status_item` | `ui` | `text`, `tooltip`, `command` (one of yours, run on click); at most 8; kept while the plugin is idle. | +| `host::set_window_status_item(id, item)`, `remove_window_status_item(id)` | `ui.{set,remove}-window-status-item` | API 1.4: window-only override; removed on window close or plugin stop; removing falls back to the global item. Click runs its command in that window. | +| `host::execute_command(id, arg)` → `Option<String>` | `command-runner.execute` | API 1.4: requires high-risk `ide.commands` for IDE actions and other plugins; this plugin's own commands are exempt. Stable IDE action or `plugin.<plugin-id>.<command-id>` in the invoking window. IDE actions return none; plugin commands return their result. Unknown ids are not-found; at most 8 nested calls, no re-entry into a waiting instance. | +| `host::ipc_connect(name)` → `Connection` | `ipc.connect` | API 1.4: `write(bytes)`, `read(max_bytes, timeout_ms)`, `close()`; requires `ipc:<path>`, at most 8 connections, closed on drop or plugin stop. Runs on the project server in remote projects. | +| `host::secret_set(name, value)`, `secret_delete(name)` | `secrets.{set,delete}` | API 1.4: requires `secrets` and a declared name. Never returns the value. | +| `host::begin_loopback(path)` → `Loopback` | `auth.begin-loopback` | API 1.4: `redirect_uri()`, `wait(timeout_ms)` → raw query or none on timeout. Desktop-only, even for remote projects; no capability. At most 2 listeners; one-shot, closed on timeout, drop or stop. Waiting excludes the call budget. | +| `host::output_append(channel, text)`, `output_clear`, `output_reveal` | `output.{append,clear,reveal}` | API 1.4: read-only Output tabs; 32 named channels per plugin, each a 1 MiB UTF-8 ring buffer. Reveal requires the invoking window. | +| `host::set_context(key, value)`, `remove_context` | `ui.{set,remove}-context` | Sets `plugin.<id>.<key>` for `when` clauses in the window the call came from (`true`, `3.5`, `"text"`; `ContextValue`). Key 1–64 of `A-Za-z0-9_-`, 100 per plugin; gone when the plugin stops. | +| `host::notify(level, title, body)` | `ui.show-notification` | Balloon in the window the command or events came from (every window otherwise). | +| `host::copy_to_clipboard(text)` | `ui.copy-to-clipboard` | Copies `text` to the system clipboard. Needs `clipboard`. | +| `host::open_url(url)` | `ui.open-url` | Opens an `http://` or `https://` URL in the user's default browser. | +| `host::pick(title, items)`, `pick_many` | `ui.pick`, `ui.pick-many` | Picks key(s) from `PickItem`s, or `None` when dismissed. From a command/event or code-action resolution in its editor window (else `invalid`); waiting is excluded from the budget. | +| `host::input(prompt, value)` | `ui.input` | A one-line text box pre-filled with `value`; `None` when dismissed. Same rules as `pick`. | +| `host::input_multiline(prompt, value)` | `ui.input-multiline` | A multi-line text box pre-filled with `value`; `None` when dismissed; Cmd/Ctrl+Enter confirms. Only from a command or event of a window. | +| `host::confirm(title, body)` | `ui.confirm` | OK / Cancel; `true` on OK. Same rules as `pick`. | +| `host::locale()` | `ui.locale` | The IDE's UI language as a BCP 47 tag (`en`, `fr`, `zh-CN`); no capability. See _Translations_. | +| `host::log(level, msg)` | `log` | IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. | +| `Project::find_files(glob, limit)` | `workspace.project.find-files` | Project-relative glob (`**/*Mapper.xml`); honours `.gitignore`; at most `limit` paths. | +| `Project::symbols(query, limit)` | `workspace.symbols` | New in 1.3; `project.read`. Fuzzy in-memory search (no LSP): name, camelCase kind, container and UTF-16 location. Queries ≤ 256 UTF-8 bytes; limits capped at 1000. Snapshot notes below. | +| `host::publish_diagnostics(path, items)` | `diagnostics.publish` | Replaces this plugin's problems for the file (editor squiggles and the Problems view); an empty list clears them. | +| `host::fetch(request)` → `{status, headers, body}` | `net.fetch` | See _Network_. | +| `Project::run_process(program, args, stdin)` → `{code, stdout, stderr}` | `process.run` | Only the pinned program of a `process:` grant; runs in the project root of a trusted project; 4 MiB of output; killed when the call is cancelled or times out. | +| `host::run_command(cmd, project, stdin)` → `{code, stdout, stderr}` | `process.run-command` | API 1.3: same limits as `run_process`, with append-only environment variables; needs the pinned `process:<program>` grant. | +| `host::start(cmd, project)` → `Child` | `process.start` | API 1.3: `poll(max_bytes)`, `write(text)`, `close_stdin()`, `wait(timeout_ms)`, `kill()`; at most 8 handles; dropping one kills its process. | +| `host::refresh_view(view)` | `views.refresh` | The view (or custom editors) render again; call it when the content changed. | +| `host::reveal_view(view)` | `views.reveal` | Brings one of your declared `[[contributes.views]]` to the front in the calling window. | +| `host::post_view_message(view, text)` | `views.post-message` | To the script of the view's `html` body. | +| `host::open_terminal(title, program, args)` | `views.open-terminal` | A terminal tab in the invoking window running the approved `process:<program>` in the project root: output that keeps coming, interactive sessions. | +| `host::register_run_target(project, target)` | `run-targets.register` | API 1.4: project-scoped, read-only argv wrapper; requires its process grant, executable pin and project trust; remote projects run on the server. | +| `host::unregister_run_target(project, id)` | `run-targets.unregister` | Withdraw this plugin's target; remembered prompt answers remain. | +| `host::start_run(config, args, debug)` | `run.start` | API 1.4: start an owned configuration in this window's Run tool window; returns an id, followed by `run-exited`. Approved `process:` program; commands/events only. | +| `host::open_terminal_session(key, title, cmd)` | `views.open-terminal-session` | API 1.4: keyed, pinned terminal in this window, append-only env; returns its operation id and later `terminal-exited`. | +| `host::write_terminal(key, text)` | `views.write-terminal` | API 1.4: write only to this plugin's still-running terminal in this window; trusted project, commands/events only. | +| `host::open_content_tab(tab)` | `views.open-content-tab` | API 1.4: the shared content editor protocol, without a web view; actions arrive as `content-tab-action`. | +| `host::reply_content_tab(request_id, reply)` | `views.reply-content-tab` | API 1.4: reply to an owned action in the originating window. No capability; commands/events only. | +| `RunConfigurations::configurations(root)` | export `run-configurations.configurations` | API 1.4: optional `plugin-run-configurations` world; SDK registration `run_configurations`. Full project list, refreshed on configuration listing. | +| `TestResults::parse(config, chunk)` | export `test-results.parse` | API 1.4: optional `plugin-test-results` world; SDK registration `test_results`. Bounded output batches for `framework = "plugin"`; failures use built-in parsers. | +| `host::open_location(path, line, column)` | `views.open-location` | Opens a file inside the project in an editor of the invoking window, the caret at `line`/`column` (0-based, UTF-16): a view's links to code. Needs `project.read`. | +| `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. | `Project::symbols` reads the current in-memory index: results may be partial while indexing, and locations can lag unsaved edits. Normal saves and filesystem @@ -505,17 +583,185 @@ plugin ends its children, as does closing the window that started them. `run-command` receives an optional string argument: `executeAction("plugin.<plugin id>.<command id>", "arg")` from another feature, or nothing when run from Search Everywhere / a shortcut. `ctx.selected_paths()` lists the files and folders a Project view or editor tab menu ran the command on (the argument is the first of them); it is empty otherwise. No capability is needed. +### Database sources (API 1.4) + +Declare `database.sources` (medium risk). All five calls require it and a +trusted, open project; it grants no access to user-created or another plugin's +sources. The `database` interface imports `workspace.project` and `types.error`: + +| Rust SDK | WIT | Result | +| --------------------------------------------------- | ------------------------------------------------ | -------------------------------------------------------- | +| `Project::upsert_data_source(&PluginDataSource)` | `database.upsert-source(project, source)` | Create/replace an owned source | +| `Project::remove_data_source(id)` | `database.remove-source(project, id)` | Remove an owned source and its credentials | +| `Project::data_sources()` | `database.sources(project)` | Only this plugin's private ids | +| `Project::set_data_source_credential(id, password)` | `database.set-credential(project, id, password)` | Write directly to the Database secure store | +| `Project::connect_data_source(id)` | `database.connect(project, id)` | Ask this project's window to connect/introspect normally | + +`database.data-source` (`PluginDataSource`) has `id`, `name`, `driver`, optional +`host`, `port` (`u16`), `database`, `user`, `url`, and `options` (string pairs). +Driver ids are `sqlite`, `postgres`, `mysql` (MariaDB too), `sqlServer`, +`clickhouse`, `oracle`. For SQLite, `database` is the file path (absolute or +relative to the project), or use a `sqlite:` URL. Other options are the +Database Advanced tab's non-secret driver properties. A URL selects URL-only +connection settings; its driver must agree. URLs containing passwords or +credential query parameters, and password/token/secret options, are rejected. +Never put credentials in any settings field: use `set-credential` instead. +There is no credential read function, including for credentials just written. + +```rust +let project = ctx.project().ok_or("Open a project first")?; +project.upsert_data_source(&openidea_plugin::PluginDataSource { + id: "local".into(), name: "Local service".into(), driver: "postgres".into(), + host: Some("127.0.0.1".into()), port: Some(5432), + database: Some("app".into()), user: Some("app".into()), + ..Default::default() +})?; +// If needed: project.set_data_source_credential("local", password)?; +project.connect_data_source("local")?; +``` + +Ids are plugin-private, 1–128 bytes; the host allocates the actual connection +id. At most 64 sources per plugin/project; names at most 256 bytes, 128 option +pairs, each value/optional connection field at most 16 KiB. Credential input is +at most 64 KiB. Unknown/unowned ids return `not-found` without revealing whether +another owner's source exists. The host checks stored ownership even when a +user source happens to use the generated id format. + +Owned sources persist in `.openidea/data-sources.toml` with `pluginOwner` and +`pluginSourceId`; updates preserve unrelated entries, comments and formatting. +Plugin edits require the normal `[[data-source]]` table format; inline table arrays +are rejected. No password is written there. The Database tree refreshes on source changes +and labels owned sources with their plugin id. Owned sources stay project-scoped; +duplicating one creates a user-owned copy. Disabling retains them; +uninstall removes them and their credentials, including in closed projects +remembered by the host. A remote disable also retains its sources; a later +uninstall is applied when that server synchronizes with the desktop. + +`connect` is valid only from a command/event of that project's window and +uses the usual Database connection, password prompts and introspection. Waiting +for this window is excluded from the plugin call budget (like `ui.pick`), with +a 180-second window-response timeout and cooperative cancellation. +Source persistence and credential storage run where the Database subsystem +runs: on the project server for remote projects. A host without that subsystem +returns `invalid("Database subsystem is unavailable on this host")`. + +### Plugin MCP tools (API 1.4) + +Tools extend the IDE's built-in MCP server. Declare a self-contained JSON Schema +file in the package (local `$ref` fragments are supported; network/filesystem +references are refused): + +```toml +[[contributes.mcpTools]] +name = "find_notes" +description = "Find notes in this project" +inputSchema = "find-notes.schema.json" +readOnly = true +destructive = false +``` + +Names match `[a-z0-9_]{1,40}`. The registered name is +`<plugin-id with every non [a-z0-9_] character replaced by _>__<name>`; the +complete name must fit 64 bytes or installation/check fails. Descriptions +(1–4096 bytes) identify the originating plugin in the server listing. Schema +files are at most 256 KiB, validated at install and `openidea-plugin check`, +and must describe an object. Inputs are checked against that schema at call +time (at most 1 MiB of JSON); text results are at most 4 MiB. If two live +plugins normalize to the same tool name, both ambiguous tools are withheld. + +Implement the new optional `mcp-provider.call(tool, arguments, ctx)` export +(`tool` is the local name, `arguments` JSON text, `ctx` a borrowed invocation). +The result is text, or JSON encoded as text if the tool wants structure. +`plugin-mcp` includes the base plugin world; export-only `mcp-tools` composes +with it. In Rust: + +```rust +impl openidea_plugin::McpProvider for MyPlugin { + fn call(&mut self, tool: &str, arguments: &str, ctx: &Invocation) + -> Result<String, Error> + { + // Validate/interpret this tool's arguments, then call ordinary host APIs. + Ok(format!("{tool}: {arguments}")) + } +} +register_plugin!(MyPlugin, mcp); +``` + +Enabled tools are listed on each request, so disabling/uninstalling withdraws +them immediately. They execute in the owning plugin's actor with its own +grants, fuel and command wall-clock budget; an MCP client grants no additional +permissions. `readOnly = false` tools are hidden and uncallable in Restricted +Mode. Every `destructive = true` call asks the project's window for confirmation +through the same UI as built-in run tools; rejection, cancellation or a missing +window fails closed. A tool has the project's invocation context, but is not a +user command/event: APIs explicitly reserved to commands/events remain invalid. +The built-in server currently serves local projects; remote project tool calls +retain its documented “not supported for remote projects yet” response. + +The new host interface `mcp` has `stdio-command() -> option<list<string>>` +(`host::mcp_stdio_command()`). It returns only bridge argv for the invoking +project, such as `["/path/openidea", "--mcp-stdio", "--project", "/project"]`; +never the HTTP URL or bearer token. It returns `none` while the server is off, +without a project window, or on a remote host without its own running server. +It needs no additional capability. + +### Editor and project snapshots (API 1.4) + +Editor lists come from the invoking window's existing workbench reports; language +and dirty state come from the open documents. Selection and reveal operations +require an editor belonging to that window, even when another window has the same +project. Ranges use zero-based lines and UTF-16 columns. Selection changes accept +1–1000 ranges and do not change document text. Opening a file acknowledges a UI +request; the usual editor handles the actual open. + +`Project::search_text` takes `TextQuery { pattern, regex, case_sensitive, +whole_word, include, exclude }`. Globs follow Find in Files' masks. It uses the +same search engine and unsaved-document overlay as the built-in MCP tools, with +the project's exclusions; only files inside this project's root are returned. +Results contain `{ location, line }` and `limit_hit`; limits are capped at 10,000, +zero returns no matches. Line previews are capped at 4096 UTF-8 bytes and the +result budget is 4 MiB (`limit_hit` also covers that cap). Closed-file text is +read with the editor's encoding defaults, at most 16 MiB per file; a matched file +that cannot be materialized also sets `limit_hit`. Cancellation or the +call deadline interrupts the search. `host::problems` reuses the Problems view's +reports, including LSP and plugin diagnostics; a remote project currently returns +an empty list because it does not forward Problems reports. + +Decorations are plain text, with fixed styles mapped to theme tokens: +`highlight`, `search`, `added`, `modified`, `removed`, `warning`, `error`, `info`, +`underline`, `strikethrough`, `muted`. Unknown styles are ignored. Editor items +have `{ range, style, hover, after }`; tree items have `{ badge, style, tooltip }`, +where a badge has at most two Unicode characters. Paths must resolve inside an +open project; absolute paths select that project, relative paths use the invoking +project. Sets are isolated by plugin and project and shared by all windows of +that project. Setting replaces a set's items for one file; clearing removes that +set in all projects. Each plugin has at most 50 editor sets and 50 tree sets, +10,000 editor items per file across its sets, and 4 MiB total metadata. Disabling, +unloading or crashing the plugin withdraws them; idle reclamation preserves them. + +The new Git reads run on the project machine, require a trusted open project and +`vcs.read`, and disable repository-configured helpers, textconv and external diff +programs. `vcs_log` returns commit ids, author/email, Unix seconds, subject/body +and parents. `vcs_blame` uses the IDE annotate backend: unsaved changed/inserted +lines are uncommitted; `line` is zero-based and dates are Unix seconds. +`vcs_diff` uses disk text (including staged and unstaged changes) against HEAD or +`against`, and stops with an error if output exceeds 4 MiB. Without a repository, +log/blame return empty lists and diff returns `None`. + ### Events -| Kind | Capability | Delivered | -| ------------------ | -------------- | ------------------------------------------------------------------------- | -| `file-opened` | `events` | a file opens in an editor | -| `files-changed` | `events` | debounced created, modified and removed paths in open projects (API 1.3) | -| `file-saved` | `events` | after the file is written | -| `editor-focused` | `events` | the focused editor changes (path, or none) | -| `vcs-changed` | `events` | a repository's state changed (commit, checkout, fetch…; its root) | -| `action-performed` | `events.input` | an IDE action ran: id, title, source (keyboard, menu, toolbar…), shortcut | -| `scheduled` | — | the `on-schedule` period elapsed | +| Kind | Capability | Delivered | +| -------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `file-opened` | `events` | a file opens in an editor | +| `file-closed` | `events` | API 1.4, `Event::Other`: JSON path string | +| `settings-changed` | `events` | API 1.4, `Event::Other`: JSON array of this plugin's changed setting keys (effective values) | +| `input-mode-changed` | `events.input` | API 1.4, `Event::Other`: JSON `{path, mode, sub}` on focused Vim mode changes; `ctx.editor()` includes the document and current selections | +| `files-changed` | `events` | debounced created, modified and removed paths in open projects (API 1.3) | +| `file-saved` | `events` | after the file is written | +| `editor-focused` | `events` | the focused editor changes (path, or none) | +| `vcs-changed` | `events` | a repository's state changed (commit, checkout, fetch…; its root) | +| `action-performed` | `events.input` | an IDE action ran: id, title, source (keyboard, menu, toolbar…), shortcut | +| `scheduled` | — | the `on-schedule` period elapsed | Events are batched: a running plugin gets them within a fraction of a second, an idle one collects them for 2 s before it is woken, so frequent events @@ -631,8 +877,7 @@ The referenced icon path is package-relative, up to 256 bytes; both SVGs use the references are rejected during installation. Missing or unknown icons retain the navigation glyph. A marker's optional target still navigates on click. For several cross-file targets, publish document-index mappings; the existing -chooser lists every matching definition. Command clicks are deferred; use a -code action on the same line. +chooser lists every matching definition. Command clicks are available through the API 1.4 `gutter-actions` kind. Document-indexer markers (`Edges.markers`) use a declared icon when their key starts with one of its `markers` prefixes; the longest matching prefix wins, @@ -805,12 +1050,139 @@ shows it. The host asks the language server and the plugins together, under the edit-path budget, and shows both; the editor asks again after edits and scrolling. Labels are cut at 256 bytes and tooltips at 1 KiB. +#### Additional language features (API 1.4) + +Declare the following kinds under `[[contributes.providers]]`, with +`engines.openidea = "^1.4"`, implement `language_features::LanguageFeatures`, +and register `register_plugin!(MyPlugin, language_features)`: + +| Kind | SDK method | Host policy | +| --------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `formatting` | `format(doc, range, options)` | Settings › Languages & Frameworks › Plugin Formatters selects a plugin per language; it takes precedence over automatic formatters. Explicit external-formatter actions still select that tool. Format Document, Selection and format-on-save share this choice. Uses the 5-second command budget. | +| `signature-help` | `signature_help(doc, at)` | Used when LSP returns no signatures. Parameter labels accept literal text or UTF-16 offsets within the signature label. | +| `document-highlights` | `document_highlights(doc, at)` | Appended to LSP highlights; the LSP wins duplicate ranges. Kinds are `text`, `read`, `write`. | +| `document-links` | `document_links(doc)` | Appended to LSP links, in the same Ctrl/⌘-click UI. Targets are absolute paths, paths relative to the project root, or HTTP(S) URLs. Other URI schemes are ignored. | +| `code-lenses` | `code_lenses(doc)` | Rendered beside LSP and built-in code-vision lenses. Clicks invoke only the returning plugin's declared command. | +| `selection-ranges` | `selection_ranges(doc, at)` | Extend Selection prefers valid plugin ranges, innermost to outermost, then falls back to tree-sitter. | +| `folding` | `folding(doc)` | Merged with tree-sitter folds, deduplicated by start/end lines. Applies to any declared language; `syntax-provider.folding` remains available for a plugin's own languages. | +| `colors` | `colors(doc)`, `color_presentations(doc, color)` | Color Preview shows swatches and the native picker. A presentation replaces the original range; multiple presentations open a chooser. Channels are finite floats in 0–1. | +| `document-symbols` | `document_symbols(doc)` | Structure and breadcrumbs use these only when LSP returns no document symbols (including an empty list); otherwise LSP symbols win. Tree-sitter is the final fallback. | +| `implementation` | `implementation(doc, at)` | Merged with LSP Go to Implementation, deduplicated by location. | +| `type-definition` | `type_definition(doc, at)` | Merged with LSP Go to Type Declaration, deduplicated by location. | +| `gutter-actions` | `gutter_actions(doc)` | Buttons beside line markers. Clicks invoke only the returning plugin's declared command, with its optional string argument and the editor window/project context. Icons accept the same names, aliases and declared marker SVG paths as line markers. | + +The optional WIT interface is `language-features`, in worlds +`plugin-language-features` / `editor-language-features`. Its own `document` +record contains path, language, version and unsaved text. All functions are +present in the interface from its first release, but the host calls only +kinds declared by the plugin. No additional capability is required for the +supplied document; host imports retain their normal permission checks. +Except formatting, these calls use the editor-provider soft/hard budgets. +Missing/unavailable LSP services permit plugin results; other LSP errors +remain errors. + +`document-symbol.parent` is an index in the returned flat list. A parent +must precede its children and contain their ranges; invalid parents become +roots, and outline depth is bounded to 64. Symbol/fold/highlight kinds are +strings so later host versions can recognize new ones. Unknown kinds use +neutral UI defaults. Invalid document ranges are dropped. Each list is +limited to 10,000 items; signature help retains at most 100 signatures and +100 parameters each, presentations at most 100 strings. Text is bounded to +64 KiB per string. Format edits must be valid and non-overlapping. +Disabling a selected formatter reports it as unavailable rather than silently +formatting with another provider. Format-on-save uses the editor's existing +Format on save setting. Native tests can obtain the current unsaved snapshot +with `MockHost::provider_document()` and call the traits directly. + +#### Embedded languages (API 1.4) + +Implement `language_features::Injections`, declare `kinds = ["injections"]` +for the host language, and register `register_plugin!(MyPlugin, injections)`. +The optional `injections` interface has worlds `plugin-injections` / +`language-injections` and returns `{range, language}` entries. Ranges use +host-document UTF-16 coordinates. The editor refreshes them after edits +(300 ms debounce) and highlights each slice using the embedded language's +CodeMirror parser and current color scheme, over the host tokens. + +Completion, hover, diagnostics and additional language features use embedded +languages' **plugin** providers. A virtual document keeps the original path, +version and newlines, replaces text outside all ranges of that language with +spaces, and changes the language id. One space is emitted per UTF-16 unit, +so emoji outside a range do not shift its positions. Point requests ask the +embedded language at the caret before the host language. Document features +filter results to their injected ranges, and diagnostics outside those +ranges are dropped. Completion replacement/additional edits are confined to +the embedded ranges. Ranges are half-open; the end belongs to the host. + +At most 1,000 non-overlapping injection ranges and 32 embedded languages +are routed per document. Earlier ranges in document order win overlaps. +Documents over 2 MiB are excluded. Invalid ranges, self-injections and invalid +language ids are ignored. Injections are not applied recursively. The same +server-side plugin host handles remote editing. **Embedded LSP forwarding is +future host-side work**, not part of 1.4, and needs no WIT change. + +### Snippets (API 1.4) + +Contribution-only or code plugins can package read-only live templates: + +```toml +[[contributes.snippets]] +language = "sql" +path = "snippets/sql.json" +``` + +```json +{ + "Select rows": { + "prefix": ["sel", "select"], + "body": ["SELECT ${1:column}", "FROM ${2:table}$0"], + "description": "Select rows from a table" + } +} +``` + +`prefix` and `body` each accept a string or an array of strings (body lines +join with newlines). Numbered `$1`, `${1:default}` and final `$0` placeholders +use the existing linked live-template fields, in numeric tab order; `\$` +escapes a literal dollar. They appear in completion, Tab expansion and +Settings › Editor › Live Templates, grouped by the plugin name and read-only. +Disabling or removing the plugin removes them. Plugin locales translate +`description`; prefixes and bodies remain code. Installation and +`openidea-plugin check` validate every entry and package path. Limits: 1 MiB +per file, 1,000 named entries, 32 prefixes per entry, 64 KiB per body, 4 KiB +per description. No additional permission or runtime export is needed. + +### Additional menu locations (API 1.4) + +`[[contributes.menus]]` also accepts `editor.title` (the editor tab bar's +actions area), `mainToolbar`, `problems.context` and `searchResults.context`. +The existing `when` clauses apply alongside command enablement. Toolbar +icons use the command's optional API-1.4 `icon`, then the plugin manifest +icon. A command icon may be a built-in icon name or a package SVG path, +validated and bundled at installation. Context menus also support the +existing submenu grouping. + +Commands receive the usual invoking window/project/editor context. For a +problem or search match, `selected-paths` contains its file and the command's +string argument is JSON `{ "path": "…", "line": 0, "column": 0 }`; lines +and columns are zero-based and columns are UTF-16 units. Editor-title and +main-toolbar actions use the focused editor context. + ### Network and secrets -`host::fetch` makes an HTTPS request (plain HTTP only to `localhost`) to a host -the plugin was granted with `net:<host>`; redirects to another host are -refused and responses are limited to 8 MiB. Requests are made from the -machine the project is on. +`host::fetch` requires `net:<host>` for HTTPS and loopback HTTP. API 1.4 adds +`net.http:<host>` for unencrypted HTTP to other hosts; it does not imply an +HTTPS grant. Both use the same wildcard rules. Each redirect is checked before +connecting to the destination; HTTPS → HTTP also requires `net.http:<host>`, +even on loopback. Responses are limited to 8 MiB. Requests run on the project +machine. A manifest using `net.http:` or `ipc:` declares `engines.openidea = "^1.4"`. + +`ipc:<path>` grants a local byte stream: an absolute Unix socket path (the host +expands `~/`, `$TMPDIR/` and `$XDG_RUNTIME_DIR/`) or `\\.\pipe\<name>` on Windows. +Only a grant's last path segment may contain `*`, for example +`ipc:$TMPDIR/discord-ipc-*`. It never grants TCP. A remote project's connection +runs on the server. Reads and writes honor cancellation and the call budget; +reads return empty bytes on timeout or EOF, and each operation is capped at 1 MiB. Secrets declared under `[[contributes.secrets]]` are entered by the user in Settings › Plugins and kept in the system keychain. The plugin never reads @@ -818,6 +1190,27 @@ them: it writes `{{secret:<name>}}` in a request header and the host fills it in on the way out (`secrets` capability). `host::secret_is_set(name)` tells whether the user has entered one. +API 1.4 can also store or delete declared secrets with `host::secret_set` and +`host::secret_delete`; the plugin still cannot read them. An OAuth flow uses +`begin_loopback("oauth/callback")`, `ui.open-url` with its `redirect_uri()`, +`wait`, an authorized `net.fetch` code exchange and `secrets.set` for the token. +The listener binds only `127.0.0.1` on a random port on the **desktop**, including +for remote projects, serves "You can close this tab", then closes. There are +no OS URI callbacks or `on-uri` activation. Validate OAuth state in the plugin. + +Window status overrides, command execution, loopback creation/waiting and +output reveal are valid only in a command or event from a window. The Vim +context key `vim.mode` is `normal`, `insert`, `visual` or `replace`, and empty +when Vim is disabled. Subscribing to the three new event kinds, including +`on-event:<kind>` activation, requires `engines.openidea = "^1.4"`. + +Settings › Plugins lets the user choose the executable of a `process:<name>` +grant globally or for the current project. The native chooser's selection is +approved again and pinned by path and content hash. Project choices override +the global choice for runs, process handles, terminals and log panes. Resetting +a choice verifies its fallback, or revokes the grant if that is unavailable. +The plugin continues to call its declared program name. + ### Views and custom editors A view is data: a toolbar, a body — `Tree`, `List`, `Form`, `Table`, @@ -1197,6 +1590,84 @@ also ask the sandboxed page to open a tab, exercising the Wasm-to-page bridge; `Log Probe: Open From Code` opens one from the plugin's code. This extends the web bridge without changing released WIT signatures. +### Runs, terminal sessions and test results (API 1.4) + +`host::start_run("tests", &["--verbose".into()], false)` runs this plugin's +`runDetectors` entry or a configuration returned by its optional export, in the +invoking window's normal Run tool window. Both the short id and the full +`plugin:<plugin>:<config>` id work. Appended arguments follow the configuration's +arguments. The id is allocated before asynchronous preparation (including before-launch +builds); a later startup failure still produces its completion with a null status. +The IDE owns Stop, Rerun and target handling, and verifies the approved +program at launch and on rerun. Debug uses the debugger of the source file named in the configuration's arguments +(or the debugger explicitly chosen on its saved configuration). Without one it +returns `invalid`. Local Debug and Rerun recheck the owned program pin; debugging +these configurations on a run target is currently invalid. Starting from activation or a provider is invalid. + +An optional configuration provider declares marker files: + +```toml +engines.openidea = "^1.4" +[[contributes.runConfigurations]] +files = ["package.json"] +``` + +Implement `RunConfigurations` and register `run_configurations`. Each returned +`PluginRunConfiguration` has `id`, `name`, `program` (a declared and granted +`process:` name), `args`, optional project-contained `cwd`, `test` and optional +`framework`. The host bounds the list to 1000 entries / 4 MiB and keeps the last +valid list after an export failure. Entries without an approved program are not +offered. Configuration exports run without a window or prompt context. + +For a static detector, `framework = "plugin"` is an API 1.4 manifest field. For +either kind of configuration, it enables the optional `TestResults` export +(registration `test_results`). `parse(config, chunk)` receives the short config +id and sequential chunks of output, not necessarily complete lines. The plugin +retains any partial line between calls. Test ids are scoped to a run; a suite or +test's parent must already exist. Locations use absolute project-contained paths, +zero-based lines and UTF-16 columns. Passed/failed durations are milliseconds. +The host uses the editor-provider soft/hard budget per batch and bounds returned +events to 10,000 / 4 MiB. A missing, failed or invalid parser switches that run to +the built-in parsers; common framework commands retain their existing rerun-failed +filters. Arbitrary frameworks have no failed-test selection protocol in this +export and therefore cannot supply a custom rerun-failed filter. + +`host::open_terminal_session(key, title, &Command::new("tool"))` opens a normal +Terminal tab in this window's project root. Reopening the same key focuses its +running tab and returns the same id; after exit a new call starts a new operation. +The host rechecks the program pin and trust at PTY spawn. Environment entries +have the same append-only validation and collision errors as `run-command`. +`write_terminal(key, text)` requires the owned live session and project trust, +like writing to a process child. Closing the tab stops the operation. Idle wasm +reclamation retains running sessions; disabling the plugin or revoking its grant +ends its PTYs. `views.open-terminal` keeps its released behavior. + +Completions go only to the owning plugin, in its launching window, even without +a subscription and even when its wasm instance was reclaimed. A terminal emits +exactly one completion from its PTY exit. The following kinds are also subscribable +and accepted in `on-event:<kind>` activation declarations with API 1.4: + +| Kind (`event::other`) | JSON data | +| --------------------- | ----------------------------------- | +| `run-exited` | `{id, config, status, killed}` | +| `terminal-exited` | `{id, key, status, closed}` | +| `content-tab-action` | `{requestId, key, action, content}` | + +`status` is a signed exit code or null (for example a signal); `killed` indicates +an IDE Stop, and `closed` indicates the terminal tab was closed before process +exit. Use the SDK's `Event::run_exited`, `terminal_exited` and +`content_tab_action` accessors to decode these extensions. No `events` capability +is needed to receive the completion or content action of an operation you own. +Remote plugins and their programs run on the server; the UI requests and replies +are forwarded to the originating desktop window. + +`host::open_content_tab(&ContentTab { .. })` reuses the web content editor below, +with the same content, action, reply and diff behavior. Its key is scoped to the +plugin and window, independently of web views. Save and toolbar actions arrive +as `content-tab-action`; answer with `host::reply_content_tab(request_id, reply)`. +The host accepts replies only from that plugin and window. Content and diff text +are limited to 1 Mi UTF-16 units, with at most 16 actions and one Save action. + ### Plugin-backed content editor tabs (API 1.3) A web view can open an editor tab on content the plugin supplies, with @@ -1724,6 +2195,11 @@ It lists two kinds of plugins: and publishes its index, which `plugins-index.yml` merges into the release's within the hour. Its README has the rules. +The four `blame-annotations`, `container-runner`, `test-runner` and `todo-finder` +packages are small API 1.4 examples, with native tests and checked-in components, +rather than feature plugins. Their fake executables and protocols are for adapting +the examples to a real tool. + Pull requests that add a plugin to this repository are for official plugins. A plugin that needs a new extension point brings the extension point here, as its own pull request, and the plugin to the registry. @@ -1753,7 +2229,8 @@ the API itself follows _Evolving the plugin API_ below. ## 10. Evolving the plugin API Released versions are frozen: 1.0.0 shipped with OpenIDEA 0.1.36, 1.1.0 -with 0.1.37 and 1.2.0 with 0.1.38. wasmtime decides what stays compatible: it resolves interface names +with 0.1.37, 1.2.0 with 0.1.38 and 1.3.0 with 0.1.39. API 1.4.0 is planned +with the next release. wasmtime decides what stays compatible: it resolves interface names by semver (a component importing `openidea:plugin/x@1.0.0` links against a host defining `@1.1.0`, and the host finds its exports the same way), but function types must match exactly — a record with another field count, or a @@ -1879,3 +2356,57 @@ one dismisses the balloon and runs the command in the receiving window with its normal editor and project context. No capability is needed. Invalid or undeclared actions reject the whole notification. Labels are limited to 256 bytes and arguments to 64 KiB. Plain `host::notify` behaves as before. + +### Plugin-provided run targets (API 1.4) + +`host::register_run_target(project, target)` registers or replaces a read-only, +project-scoped target owned by the plugin. `host::unregister_run_target(project, +id)` removes it. `PluginRunTarget` has `id`, `name`, a granted `process:` `program`, +`args`, `workdir_args`, optional `container_root`, and `commands` (pairs of tool +names and replacement argv). Targets appear in Settings › Run Targets and every +run configuration's Run on selector, labelled with their plugin. They survive +idle instance reclamation; stopping, disabling, reloading or uninstalling a plugin +withdraws them. Registrations and execution for remote projects are on the server. +A missing target fails the run rather than silently using the local tool. + +For each launch the host checks project trust and the wrapper's current process +grant and executable pin, including per-project executable choices. Debugging on +plugin targets returns `invalid`; language servers are not run in these targets. +There are at most 32 targets per plugin/project, 64 unique command overrides and +256 arguments per prefix (16 KiB each, 256 KiB metadata per registration). + +The host constructs argv directly, without a shell: + +- No override: `program + args + workdir_args + command + command arguments`. +- Override matching `wrap::program_name(command)` (basename without `.exe`, `.cmd` + or `.bat`): `program + override + workdir_args + command arguments`. The override + replaces **both** the prefix and the command name and includes the tool itself. + +DDEV, for example: `program = "ddev"`, `args = ["exec"]`, +`commands = [("composer", ["composer"])]` runs `php x.php` as +`ddev exec php x.php` and `composer install` as `ddev composer install`. + +Local absolute tool paths outside the project use the tool basename. +When `container_root` is set, project paths in argv map under that root. Each +`workdir_args` element may contain `{cwd}`; for example `["--dir", "{cwd}"]`. +The host substitutes the configuration's working directory mapped under +`container_root`. It omits **all** workdir arguments when there is no container +root or the working directory is outside the project. Without `workdir_args`, +the wrapper manages its container directory itself. The wrapper process always +starts in the configuration's **local** working directory, so tools such as DDEV +can find their project. Configuration env and env files reach the local wrapper; +no generic env-forwarding flags are added. Container env is forwarded only if +the wrapper's own conventions allow it. + +Registration never changes a selection. The first registration prompts the +project's windows: “Use <name> for running PHP / Node / Composer in this project?” +with **Use** / **Not now**. The first answer is remembered on the host per +project + plugin + target id. Use sets the project's default run target and its +PHP and Node run-target choices; Not now leaves settings alone. Interpreters +also have a Run in target choice in Settings (Default run target, Local machine, +or a named target). The `languages.php.runTarget` / `languages.node.runTarget` +settings store a selector id, empty for the default, or `local` for local execution. Code-created PHP/Node runs, +Composer/npm scripts, PHPUnit and Node tests use that choice through the normal +Run flow. Explicit per-configuration targets take precedence. Choosing Local +machine explicitly overrides defaults. Language servers, tool discovery/version +probes and external analysis helpers remain local to the host. diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index 90c83adc3..f0ecc3db1 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index 12a94dd9e..c715f2339 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -7dfbea1faeae8930b462fe80d431c83c49aeafa45ac7acd36f7d7f05fac3aaa4 +acb5f0a97061616d5bdcf7193d8fbe45c27818663dc0a9eab2e8203589492919 diff --git a/plugins/official/blame-annotations/Cargo.lock b/plugins/official/blame-annotations/Cargo.lock new file mode 100644 index 000000000..5c2101816 --- /dev/null +++ b/plugins/official/blame-annotations/Cargo.lock @@ -0,0 +1,336 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "openidea-plugin-openidea-blame-annotations" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/official/blame-annotations/Cargo.toml b/plugins/official/blame-annotations/Cargo.toml new file mode 100644 index 000000000..b221d2723 --- /dev/null +++ b/plugins/official/blame-annotations/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-blame-annotations" +version = "0.1.0" +edition = "2024" +publish = false + +# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/blame-annotations/README.md b/plugins/official/blame-annotations/README.md new file mode 100644 index 000000000..56bd9552e --- /dev/null +++ b/plugins/official/blame-annotations/README.md @@ -0,0 +1,21 @@ +# blame-annotations — API 1.4 example + +Run **Example: Show Blame Annotations** in a trusted Git project with a text +file open, or click its code lens. `vcs.blame` becomes `decorations.set` after +text at each UTF-16 line end (up to 2,000 lines). The host withdraws the set +when the plugin stops. The lens performs no Git work itself. + +Grants: `project.read`, `editor.read`, `vcs.read`. This example annotates on +demand; it intentionally has no background refresh or attribution cache. + +Build from the repository root: + +```sh +plugins/build.sh official/blame-annotations +cargo test --manifest-path plugins/official/blame-annotations/Cargo.toml +cargo run -p ide-host --bin openidea-plugin -- check plugins/official/blame-annotations +``` + +Install the built package folder through Settings › Plugins › Install from +Disk. See the [plugin guide](../../README.md#api-14) and +[official/community policy](../../README.md#81-official-and-community-plugins). diff --git a/plugins/official/blame-annotations/dist/plugin.wasm b/plugins/official/blame-annotations/dist/plugin.wasm new file mode 100644 index 000000000..e587d7424 Binary files /dev/null and b/plugins/official/blame-annotations/dist/plugin.wasm differ diff --git a/plugins/official/blame-annotations/dist/plugin.wasm.sources b/plugins/official/blame-annotations/dist/plugin.wasm.sources new file mode 100644 index 000000000..f3af33fc5 --- /dev/null +++ b/plugins/official/blame-annotations/dist/plugin.wasm.sources @@ -0,0 +1 @@ +aaa3cdd1595ba8aeabae6472f7f1ef635bd3115662d64d9284152747edfb6b16 diff --git a/plugins/official/blame-annotations/plugin.toml b/plugins/official/blame-annotations/plugin.toml new file mode 100644 index 000000000..dcd31b019 --- /dev/null +++ b/plugins/official/blame-annotations/plugin.toml @@ -0,0 +1,18 @@ +id = "openidea.blame-annotations" +name = "Blame Annotations (API Example)" +version = "0.1.0" +description = "Small plugin API 1.4 example; see the package README." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +engines.openidea = "^1.4" +wasm = "dist/plugin.wasm" +activation = ["on-command:*"] + +[capabilities] +required = ["project.read", "editor.read", "vcs.read"] +[[contributes.commands]] +id = "annotate" +title = "Example: Show Blame Annotations" +[[contributes.providers]] +kinds = ["code-lenses"] +languages = ["*"] diff --git a/plugins/official/blame-annotations/src/lib.rs b/plugins/official/blame-annotations/src/lib.rs new file mode 100644 index 000000000..937576e47 --- /dev/null +++ b/plugins/official/blame-annotations/src/lib.rs @@ -0,0 +1,74 @@ +//! A command does repository work; its lens only offers the command. +use openidea_plugin::language_features::{CodeLens, Document, LanguageFeatures}; +use openidea_plugin::{Decoration, Error, Invocation, Plugin, Position, Range, host, register_plugin}; + +#[derive(Default)] +pub struct BlameAnnotations; +impl Plugin for BlameAnnotations { + fn run_command(&mut self, _id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> { + let project = ctx.project().ok_or("Open a project first")?; + let editor = ctx.editor().ok_or("Open a text file first")?; + let text = editor.document()?.text; + let lines: Vec<_> = text.lines().collect(); + let items = project + .vcs_blame(&editor.path())? + .into_iter() + .take(2000) + .filter_map(|b| { + let line = lines.get(b.line as usize)?; + let at = Position::new(b.line, line.encode_utf16().count() as u32); + Some(Decoration { + range: Range::new(at, at), + style: "muted".into(), + hover: Some(b.summary), + after: Some(format!(" {} · {}", b.author, b.commit.chars().take(8).collect::<String>())), + }) + }) + .collect::<Vec<_>>(); + host::set_decorations(&editor.path(), "blame", &items)?; + Ok(None) + } +} +impl LanguageFeatures for BlameAnnotations { + fn code_lenses(&mut self, _doc: &Document) -> Vec<CodeLens> { + vec![CodeLens { + range: Range::default(), + title: "Show blame annotations".into(), + command: "annotate".into(), + argument: None, + }] + } +} +register_plugin!(BlameAnnotations, language_features); + +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::{BlameLine, mock::MockHost}; + #[test] + fn annotations_use_utf16_line_ends_and_require_vcs_permission() { + let mock = MockHost::install(); + mock.open_editor("/project/a.txt", "text", "😀x\n", &[]); + mock.set_vcs_reads( + vec![], + "/project/a.txt", + vec![BlameLine { + line: 0, + commit: "1234567890".into(), + author: "Ada".into(), + date: 0, + summary: "First".into(), + }], + None, + ); + BlameAnnotations.run_command("annotate", &mock.invocation(), None).unwrap(); + let items = mock.decorations(); + let item = &items.values().next().unwrap()[0]; + assert_eq!(item.range.start.character, 3); + assert_eq!(item.after.as_deref(), Some(" Ada · 12345678")); + drop(mock); + let mock = MockHost::install().with_capabilities(&["project.read", "editor.read"]); + mock.open_editor("/project/a.txt", "text", "x", &[]); + assert!(matches!(BlameAnnotations.run_command("annotate", &mock.invocation(), None), Err(Error::Denied(_)))); + } +} diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index 35b84c97c..d365a3c23 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index e3440fcf3..ef8ba4ca4 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -adeef6ba69276d3ac8e9ba349ca6e787befa6532ccac69bc35a381c0428af189 +7d09b4e0e3edfbed92be8efaba47d468b7707d6164abf45b0bec495ac454f4dd diff --git a/plugins/official/container-runner/Cargo.lock b/plugins/official/container-runner/Cargo.lock new file mode 100644 index 000000000..e65b12d0e --- /dev/null +++ b/plugins/official/container-runner/Cargo.lock @@ -0,0 +1,336 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "openidea-plugin-openidea-container-runner" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/official/container-runner/Cargo.toml b/plugins/official/container-runner/Cargo.toml new file mode 100644 index 000000000..1767d21da --- /dev/null +++ b/plugins/official/container-runner/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-container-runner" +version = "0.1.0" +edition = "2024" +publish = false + +# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/container-runner/README.md b/plugins/official/container-runner/README.md new file mode 100644 index 000000000..1f8cb6b2e --- /dev/null +++ b/plugins/official/container-runner/README.md @@ -0,0 +1,33 @@ +# container-runner — API 1.4 example + +Put this directory on PATH **before installing/granting** the plugin, so +`process:openidea-example-wrapper` resolves to the included executable. Run +**Example: Register Container Wrapper** in a trusted project. It registers a +project run target, opens a keyed terminal, writes an Output channel and sets +a window status item. The owned `terminal-exited` event updates that window. +Disable withdraws the target; terminal/output lifetimes follow the host rules. +The fake wrapper just executes argv locally and exits its demo terminal. + +To adapt it to DDEV, declare `process:ddev`, change `program` to `ddev`, +`args` to `["exec"]`, and use command overrides such as +`("composer", ["composer"])`. For Lando declare `process:lando` and supply its +CLI's command prefixes. Only approved executables run; selecting one in +Settings › Plugins reapproves/pins it. No shell command strings are evaluated. +For mapped working directories set `container_root` and `workdir_args` using +your wrapper's documented argv convention (for example `--cwd {cwd}`), or let +the wrapper manage cwd. Environment forwarding also belongs to that wrapper. + +Build from the repository root: + +```sh +plugins/build.sh official/container-runner +cargo test --manifest-path plugins/official/container-runner/Cargo.toml +cargo run -p ide-host --bin openidea-plugin -- check plugins/official/container-runner +``` + +Install the built package folder through Settings › Plugins › Install from +Disk. See the [plugin guide](../../README.md#api-14) and +[official/community policy](../../README.md#81-official-and-community-plugins). + +On Windows, supply an equivalent approved executable; the included demo script +requires a POSIX shell. The host API itself is cross-platform. diff --git a/plugins/official/container-runner/dist/plugin.wasm b/plugins/official/container-runner/dist/plugin.wasm new file mode 100644 index 000000000..13f0d8ac4 Binary files /dev/null and b/plugins/official/container-runner/dist/plugin.wasm differ diff --git a/plugins/official/container-runner/dist/plugin.wasm.sources b/plugins/official/container-runner/dist/plugin.wasm.sources new file mode 100644 index 000000000..88f53d631 --- /dev/null +++ b/plugins/official/container-runner/dist/plugin.wasm.sources @@ -0,0 +1 @@ +929b309e182c078790940244429b9872d8c7723c0bc25a07b152c83300905c47 diff --git a/plugins/official/container-runner/openidea-example-wrapper b/plugins/official/container-runner/openidea-example-wrapper new file mode 100755 index 000000000..b4be0959e --- /dev/null +++ b/plugins/official/container-runner/openidea-example-wrapper @@ -0,0 +1,9 @@ +#!/bin/sh +# Install this directory on PATH before granting process:openidea-example-wrapper. +case "$1" in + terminal) printf 'Example wrapper terminal finished\n'; exit 0 ;; + exec) shift ;; + *) exit 2 ;; +esac +if [ "$1" = --cwd ]; then cd "$2" || exit; shift 2; fi +exec "$@" diff --git a/plugins/official/container-runner/plugin.toml b/plugins/official/container-runner/plugin.toml new file mode 100644 index 000000000..515eff627 --- /dev/null +++ b/plugins/official/container-runner/plugin.toml @@ -0,0 +1,15 @@ +id = "openidea.container-runner" +name = "Container Runner (API Example)" +version = "0.1.0" +description = "Small plugin API 1.4 example; see the package README." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +engines.openidea = "^1.4" +wasm = "dist/plugin.wasm" +activation = ["on-command:*"] + +[capabilities] +required = ["project.read", "process:openidea-example-wrapper"] +[[contributes.commands]] +id = "start" +title = "Example: Register Container Wrapper" diff --git a/plugins/official/container-runner/src/lib.rs b/plugins/official/container-runner/src/lib.rs new file mode 100644 index 000000000..b6a738be5 --- /dev/null +++ b/plugins/official/container-runner/src/lib.rs @@ -0,0 +1,59 @@ +//! A fake executable makes the argv/cwd and terminal lifecycle easy to inspect. +use openidea_plugin::{Error, Event, Invocation, Plugin, PluginRunTarget, StatusItem, host, register_plugin}; +const PROGRAM: &str = "openidea-example-wrapper"; +#[derive(Default)] +pub struct ContainerRunner; +impl Plugin for ContainerRunner { + fn run_command(&mut self, _id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> { + let project = ctx.project().ok_or("Open a project first")?; + host::register_run_target( + &project, + &PluginRunTarget { + id: "example".into(), + name: "Example wrapper".into(), + program: PROGRAM.into(), + args: vec!["exec".into()], + workdir_args: vec!["--cwd".into(), "{cwd}".into()], + container_root: None, + commands: vec![], + }, + )?; + let id = + host::open_terminal_session("example", "Wrapper example", &host::Command::new(PROGRAM).arg("terminal"))?; + host::set_window_status_item( + "wrapper", + &StatusItem { text: "Wrapper running".into(), tooltip: Some(id.clone()), command: Some("start".into()) }, + )?; + host::output_append("wrapper", &format!("Started terminal {id}\n"))?; + host::output_reveal("wrapper")?; + Ok(Some(id)) + } + fn handle_events(&mut self, events: &[Event], _ctx: &Invocation) { + for event in events { + if let Some(exit) = event.terminal_exited() { + let _ = host::output_append("wrapper", &format!("Terminal {} exited: {:?}\n", exit.id, exit.status)); + let _ = host::set_window_status_item( + "wrapper", + &StatusItem { text: "Wrapper exited".into(), tooltip: None, command: Some("start".into()) }, + ); + } + } + } +} +register_plugin!(ContainerRunner); +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::mock::MockHost; + #[test] + fn terminal_completion_updates_status_and_output() { + let mock = MockHost::install().with_capabilities(&["project.read", "process:openidea-example-wrapper"]); + let mut plugin = ContainerRunner; + plugin.run_command("start", &mock.invocation(), None).unwrap(); + assert_eq!(mock.run_targets().len(), 1); + let event = mock.finish_terminal("example").unwrap(); + plugin.handle_events(&[event], &mock.invocation()); + assert_eq!(mock.status_items()["wrapper"].text, "Wrapper exited"); + assert!(mock.output()["wrapper"].contains("Some(0)")); + } +} diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index c0e3c433e..180db9233 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index 66190f363..6d851be5b 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -51def3be1a570ee61cb23f0b738002427298dbdafffcb687896531685bbe3c77 +5099f6ceba7936a9b54340a4ce0c9b50a49962dd96053c850c010ea5c803c6a2 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index 3fde3ebc0..18426a611 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 390cff508..6b3a47871 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -6c4439b36b22289c8dc54498d6e7e72a4c4cc15eac1a4957fc2b71d4fbd3c330 +6deb9876934b6e21bd99c791f2a86167f1d67e808fbb1cc514c7e621514b654c diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index eb2f4ce88..403852a4c 100644 Binary files a/plugins/official/graphviz/dist/plugin.wasm and b/plugins/official/graphviz/dist/plugin.wasm differ diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index 7df840afa..6f56a0252 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -00d56f431ab8e59c04796cf56054a98ba78040a5e4142c3f727725c31781a008 +5c2f5a254b25ba018369366df87707b423e77a573fcb73cc3c74b1f321f5aa61 diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index 8526a5f22..a3dacfd42 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 3d0ab2bcf..61effb082 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -9fca272eec5a601b8de6071aa358954e9b79db08f45714f077624418e8f616bc +cb4ed7e9d793df4219e92edd5d64616db65411cb77e111df862ca8237f647efb diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index 57a9de496..7f13fe3ca 100644 Binary files a/plugins/official/license-header/dist/plugin.wasm and b/plugins/official/license-header/dist/plugin.wasm differ diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index 60a829496..c7b41d834 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -5adfc0d749feabe5fe7e20857fd636d01341b91f3dd8440779af6dcd14435619 +f43f1e52813a5f992c3d06cbe03aa26092dd56db45322b090906dfbb8d08b1ab diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index 97883f1e2..14b74de75 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index bcefdb21e..ecf858484 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -1b388c40cb6be29c5e10e938c0c8f6848d10cb4bf1f7438db37f53b13314708e +656b51f57467a75b5416575871a5722e4f44be98407f6bfd0e6fa1e44f0bfa80 diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index 6974f2cff..686d70ed3 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index fd050c58b..5044baccb 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -af4478864196b5802f505d26beafe4492bc1335536311bc873b9d18773f9eebb +bafe2cfc88d8c1272eea289d3d804287d56159b1c5893147b73a6be88e00370f diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index f73c4c5d5..4b490f21d 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index b414f0c96..49b5d5e91 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -46ce9f2d5b4f87a4be26facea4870f3de59952dc95c536fcf33fb0ff0a7c27a7 +ee5d474028c7068b06771efa5e8e2fa710cd0108d53737a36d8bf9f0f3490c08 diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index e9871c762..5912af33d 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index 2d44ef4df..1ddc444e0 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -f4e37cdac925f376bac945d5bb86c648b559787aa22737e6440835456f9f74cf +867fc7bba0e795155e96c39f5a05cc3a08a3b6809c4e3922437e393d65f6d829 diff --git a/plugins/official/test-runner/Cargo.lock b/plugins/official/test-runner/Cargo.lock new file mode 100644 index 000000000..48e52f503 --- /dev/null +++ b/plugins/official/test-runner/Cargo.lock @@ -0,0 +1,336 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "openidea-plugin-openidea-test-runner" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/official/test-runner/Cargo.toml b/plugins/official/test-runner/Cargo.toml new file mode 100644 index 000000000..282ebf51e --- /dev/null +++ b/plugins/official/test-runner/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-openidea-test-runner" +version = "0.1.0" +edition = "2024" +publish = false + +# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/test-runner/README.md b/plugins/official/test-runner/README.md new file mode 100644 index 000000000..92e9902da --- /dev/null +++ b/plugins/official/test-runner/README.md @@ -0,0 +1,25 @@ +# test-runner — API 1.4 example + +Put this directory on PATH **before installing/granting** the plugin, so +`process:openidea-example-tests` resolves to the included executable. Copy +`example.tests` to a trusted project. Its presence enables the dynamic +`run-configurations` export. Open it as plain text and click a gutter run +button, or use **Example: Run Tests** / the Run selector for all tests. + +The gutter command appends the test name as a filter to `run.start`. The fake +executable prints a deliberately tiny `start NAME` / `pass NAME` protocol; +`test-results.parse` retains split lines and emits stable test ids. Replace +that protocol/parser and executable to integrate a real framework. This is an +API example, not a general test framework or a custom rerun-failed contract. + +Build from the repository root: + +```sh +plugins/build.sh official/test-runner +cargo test --manifest-path plugins/official/test-runner/Cargo.toml +cargo run -p ide-host --bin openidea-plugin -- check plugins/official/test-runner +``` + +Install the built package folder through Settings › Plugins › Install from +Disk. See the [plugin guide](../../README.md#api-14) and +[official/community policy](../../README.md#81-official-and-community-plugins). diff --git a/plugins/official/test-runner/dist/plugin.wasm b/plugins/official/test-runner/dist/plugin.wasm new file mode 100644 index 000000000..57025d99e Binary files /dev/null and b/plugins/official/test-runner/dist/plugin.wasm differ diff --git a/plugins/official/test-runner/dist/plugin.wasm.sources b/plugins/official/test-runner/dist/plugin.wasm.sources new file mode 100644 index 000000000..273f94c58 --- /dev/null +++ b/plugins/official/test-runner/dist/plugin.wasm.sources @@ -0,0 +1 @@ +b7aaac21c6c34d9d722aa23c13c66e61ef11028f038fa877fe67d03b03c18483 diff --git a/plugins/official/test-runner/example.tests b/plugins/official/test-runner/example.tests new file mode 100644 index 000000000..f3b70fe37 --- /dev/null +++ b/plugins/official/test-runner/example.tests @@ -0,0 +1,2 @@ +test adds +test subtracts diff --git a/plugins/official/test-runner/openidea-example-tests b/plugins/official/test-runner/openidea-example-tests new file mode 100755 index 000000000..0c675c486 --- /dev/null +++ b/plugins/official/test-runner/openidea-example-tests @@ -0,0 +1,10 @@ +#!/bin/sh +# A tiny deterministic protocol, not a test framework. +while IFS= read -r line; do + case "$line" in + 'test '*) name=${line#test } ;; + *) continue ;; + esac + [ -z "$1" ] || [ "$1" = "$name" ] || continue + printf 'start %s\npass %s\n' "$name" "$name" +done < example.tests diff --git a/plugins/official/test-runner/plugin.toml b/plugins/official/test-runner/plugin.toml new file mode 100644 index 000000000..7d33f9db1 --- /dev/null +++ b/plugins/official/test-runner/plugin.toml @@ -0,0 +1,20 @@ +id = "openidea.test-runner" +name = "Test Runner (API Example)" +version = "0.1.0" +description = "Small plugin API 1.4 example; see the package README." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +engines.openidea = "^1.4" +wasm = "dist/plugin.wasm" +activation = ["on-command:*"] + +[capabilities] +required = ["process:openidea-example-tests"] +[[contributes.commands]] +id = "test" +title = "Example: Run Tests" +[[contributes.runConfigurations]] +files = ["example.tests"] +[[contributes.providers]] +kinds = ["gutter-actions"] +languages = ["plaintext", "text"] diff --git a/plugins/official/test-runner/src/lib.rs b/plugins/official/test-runner/src/lib.rs new file mode 100644 index 000000000..4156d15e7 --- /dev/null +++ b/plugins/official/test-runner/src/lib.rs @@ -0,0 +1,99 @@ +//! Lines beginning `test ` get a gutter button; the executable accepts the name as a filter. +use openidea_plugin::language_features::{Document, GutterAction, LanguageFeatures}; +use openidea_plugin::{ + Error, Invocation, Plugin, PluginRunConfiguration, PluginTestEvent, RunConfigurations, TestFailed, TestPassed, + TestResults, TestStart, host, register_plugin, +}; +#[derive(Default)] +pub struct TestRunner { + pending: String, +} +impl Plugin for TestRunner { + fn run_command(&mut self, _id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> { + let args = arg.into_iter().map(str::to_owned).collect::<Vec<_>>(); + host::start_run("tests", &args, false).map(Some) + } +} +impl RunConfigurations for TestRunner { + fn configurations(&mut self, _root: &str) -> Vec<PluginRunConfiguration> { + vec![PluginRunConfiguration { + id: "tests".into(), + name: "Example tests".into(), + program: "openidea-example-tests".into(), + test: true, + framework: Some("plugin".into()), + ..Default::default() + }] + } +} +impl LanguageFeatures for TestRunner { + fn gutter_actions(&mut self, doc: &Document) -> Vec<GutterAction> { + if !doc.path.ends_with("example.tests") { + return vec![]; + } + doc.text + .lines() + .enumerate() + .filter_map(|(line, text)| { + let name = text.strip_prefix("test ")?.trim(); + Some(GutterAction { + line: line as u32, + icon: Some("run".into()), + tooltip: format!("Run {name}"), + command: "test".into(), + argument: Some(name.into()), + }) + }) + .collect() + } +} +impl TestResults for TestRunner { + fn parse(&mut self, _config: &str, chunk: &str) -> Vec<PluginTestEvent> { + self.pending.push_str(chunk); + let mut events = vec![]; + while let Some(end) = self.pending.find('\n') { + let line: String = self.pending.drain(..=end).collect(); + let Some((kind, name)) = line.trim().split_once(' ') else { continue }; + let id = name.to_owned(); + match kind { + "start" => events.push(PluginTestEvent::TestStarted(TestStart { + id, + parent: None, + name: name.into(), + location: None, + })), + "pass" => events.push(PluginTestEvent::Passed(TestPassed { id, duration_ms: None })), + "fail" => events.push(PluginTestEvent::Failed(TestFailed { + id, + duration_ms: None, + message: "Example failure".into(), + expected: None, + actual: None, + location: None, + })), + _ => {} + } + } + if self.pending.len() > 64 * 1024 { + self.pending.clear(); + } + events + } +} +register_plugin!(TestRunner, run_configurations, test_results, language_features); +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::mock::MockHost; + #[test] + fn filters_reach_runs_and_split_output_builds_the_test_tree() { + let mock = MockHost::install().with_capabilities(&["process:openidea-example-tests"]); + let mut plugin = TestRunner::default(); + mock.set_run_configurations(plugin.configurations("/project")); + plugin.run_command("test", &mock.invocation(), Some("adds")).unwrap(); + assert_eq!(mock.configuration_runs()[0].2, vec!["adds"]); + assert!(plugin.parse("tests", "sta").is_empty()); + let events = plugin.parse("tests", "rt adds\npass adds\n"); + assert!(matches!(&events[..], [PluginTestEvent::TestStarted(_), PluginTestEvent::Passed(_)])); + } +} diff --git a/plugins/official/todo-finder/Cargo.lock b/plugins/official/todo-finder/Cargo.lock new file mode 100644 index 000000000..11ac1af6d --- /dev/null +++ b/plugins/official/todo-finder/Cargo.lock @@ -0,0 +1,337 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "openidea-plugin-openidea-todo-finder" +version = "0.1.0" +dependencies = [ + "openidea-plugin", + "serde_json", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/official/todo-finder/Cargo.toml b/plugins/official/todo-finder/Cargo.toml new file mode 100644 index 000000000..3c4c0f009 --- /dev/null +++ b/plugins/official/todo-finder/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "openidea-plugin-openidea-todo-finder" +version = "0.1.0" +edition = "2024" +publish = false + +# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" } + +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/plugins/official/todo-finder/README.md b/plugins/official/todo-finder/README.md new file mode 100644 index 000000000..b33ae82e6 --- /dev/null +++ b/plugins/official/todo-finder/README.md @@ -0,0 +1,23 @@ +# todo-finder — API 1.4 example + +Run **Example: Find TODOs**, including from a search result's context menu. +`project.search-text` supplies a list view; activating a row opens its location. +The invoking window's status item combines the match count with +`diagnostics.problems`. Both search and rendering use the invocation's project, +so the example keeps no cross-window cache. Results are capped at 500. + +Grant: `project.read`. Remote Problems snapshots are currently empty; text +search still runs on the server. This is a small view/API example, not a TODO +indexer or a replacement for Find in Files. + +Build from the repository root: + +```sh +plugins/build.sh official/todo-finder +cargo test --manifest-path plugins/official/todo-finder/Cargo.toml +cargo run -p ide-host --bin openidea-plugin -- check plugins/official/todo-finder +``` + +Install the built package folder through Settings › Plugins › Install from +Disk. See the [plugin guide](../../README.md#api-14) and +[official/community policy](../../README.md#81-official-and-community-plugins). diff --git a/plugins/official/todo-finder/dist/plugin.wasm b/plugins/official/todo-finder/dist/plugin.wasm new file mode 100644 index 000000000..b548e7ef0 Binary files /dev/null and b/plugins/official/todo-finder/dist/plugin.wasm differ diff --git a/plugins/official/todo-finder/dist/plugin.wasm.sources b/plugins/official/todo-finder/dist/plugin.wasm.sources new file mode 100644 index 000000000..ef43b99b8 --- /dev/null +++ b/plugins/official/todo-finder/dist/plugin.wasm.sources @@ -0,0 +1 @@ +66e92c09c0050dbf4c302793eea5048e21d1c91b9b44811b5f7edbae6eaa9075 diff --git a/plugins/official/todo-finder/plugin.toml b/plugins/official/todo-finder/plugin.toml new file mode 100644 index 000000000..d3238e783 --- /dev/null +++ b/plugins/official/todo-finder/plugin.toml @@ -0,0 +1,23 @@ +id = "openidea.todo-finder" +name = "Todo Finder (API Example)" +version = "0.1.0" +description = "Small plugin API 1.4 example; see the package README." +authors = ["OpenIDEA contributors"] +license = "Apache-2.0" +engines.openidea = "^1.4" +wasm = "dist/plugin.wasm" +activation = ["on-command:*"] + +[capabilities] +required = ["project.read"] +[[contributes.commands]] +id = "find" +title = "Example: Find TODOs" +[[contributes.views]] +id = "todos" +title = "TODO Example" +icon = "search" +location = "right" +[[contributes.menus]] +menu = "searchResults.context" +command = "find" diff --git a/plugins/official/todo-finder/src/lib.rs b/plugins/official/todo-finder/src/lib.rs new file mode 100644 index 000000000..6f759635b --- /dev/null +++ b/plugins/official/todo-finder/src/lib.rs @@ -0,0 +1,79 @@ +//! A view searches its invoking project afresh, so instance state never mixes windows. +use openidea_plugin::views::{Body, ListItem, View, ViewEvent, Viewport, Views}; +use openidea_plugin::{Error, Invocation, Location, Plugin, StatusItem, TextQuery, host, register_plugin}; +#[derive(Default)] +pub struct TodoFinder; +fn query() -> TextQuery { + TextQuery { pattern: "TODO".into(), case_sensitive: true, ..Default::default() } +} +impl Plugin for TodoFinder { + fn run_command(&mut self, _id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> { + let found = ctx.project().ok_or("Open a project first")?.search_text(&query(), 500)?; + let problems: usize = host::problems(None)?.iter().map(|p| p.diagnostics.len()).sum(); + host::set_window_status_item( + "todos", + &StatusItem { + text: format!("{} TODOs · {problems} problems", found.matches.len()), + tooltip: None, + command: Some("find".into()), + }, + )?; + host::refresh_view("todos"); + host::reveal_view("todos")?; + Ok(None) + } +} +impl Views for TodoFinder { + fn render(&mut self, _view: &str, _viewport: Option<Viewport>, ctx: &Invocation) -> Result<View, Error> { + let found = ctx.project().ok_or("Open a project first")?.search_text(&query(), 500)?; + let items = found + .matches + .into_iter() + .map(|m| { + ListItem::new(&serde_json::to_string(&m.location).unwrap(), &m.line).detail(&format!( + "{}:{}", + m.location.path, + m.location.range.start.line + 1 + )) + }) + .collect(); + Ok(View { + toolbar: vec![], + body: Body::List(items), + status: found.limit_hit.then(|| "Showing the first 500 matches".into()), + }) + } + fn handle(&mut self, _view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> { + if let ViewEvent::Activate(key) = event { + let location: Location = serde_json::from_str(&key).map_err(|e| Error::Invalid(e.to_string()))?; + host::open_location(&location.path, location.range.start.line, location.range.start.character)?; + } + Ok(()) + } +} +register_plugin!(TodoFinder, views); +#[cfg(test)] +mod tests { + use super::*; + use openidea_plugin::{Range, TextMatch, TextSearch, mock::MockHost}; + #[test] + fn search_results_open_their_location_and_count_problems() { + let mock = MockHost::install(); + mock.on_search(|q, _| { + assert_eq!(q.pattern, "TODO"); + Ok(TextSearch { + matches: vec![TextMatch { + location: Location { path: "/project/a.txt".into(), range: Range::default() }, + line: "TODO fix".into(), + }], + limit_hit: false, + }) + }); + TodoFinder.run_command("find", &mock.invocation(), None).unwrap(); + assert_eq!(mock.status_items()["todos"].text, "1 TODOs · 0 problems"); + let view = TodoFinder.render("todos", None, &mock.invocation()).unwrap(); + let Body::List(items) = view.body else { panic!() }; + TodoFinder.handle("todos", ViewEvent::Activate(items[0].key.clone()), &mock.invocation()).unwrap(); + assert_eq!(mock.opened_locations()[0], ("/project/a.txt".into(), 0, 0)); + } +} diff --git a/sdk/rust/openidea-plugin/src/execution.rs b/sdk/rust/openidea-plugin/src/execution.rs new file mode 100644 index 000000000..e8866312d --- /dev/null +++ b/sdk/rust/openidea-plugin/src/execution.rs @@ -0,0 +1,193 @@ +//! Runs, keyed terminals and plugin-owned content editors (API 1.4). + +use serde::{Deserialize, Serialize}; + +/// ContentAction data exchanged with the IDE. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContentAction { + pub id: String, + pub label: String, + pub icon: Option<String>, + pub primary: bool, + pub save: bool, +} + +/// ContentTab data exchanged with the IDE. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContentTab { + pub key: String, + pub title: String, + pub file_name: Option<String>, + pub language: Option<String>, + pub content: String, + pub read_only: bool, + pub actions: Vec<ContentAction>, +} + +/// ContentDiff data exchanged with the IDE. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContentDiff { + pub left: String, + pub right: String, + pub left_label: Option<String>, + pub right_label: Option<String>, + pub title: Option<String>, +} + +/// ContentTabReply data exchanged with the IDE. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContentTabReply { + pub content: Option<String>, + pub saved: Option<bool>, + pub read_only: Option<bool>, + pub status: Option<String>, + pub error: Option<String>, + pub diff: Option<ContentDiff>, +} + +/// PluginRunConfiguration data exchanged with the IDE. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginRunConfiguration { + pub id: String, + pub name: String, + pub program: String, + pub args: Vec<String>, + pub cwd: Option<String>, + pub test: bool, + pub framework: Option<String>, +} + +/// RunExited data exchanged with the IDE. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunExited { + pub id: String, + pub config: String, + pub status: Option<i32>, + pub killed: bool, +} + +/// TerminalExited data exchanged with the IDE. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalExited { + pub id: String, + pub key: String, + pub status: Option<i32>, + pub closed: bool, +} + +/// ContentTabAction data exchanged with the IDE. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContentTabAction { + pub request_id: String, + pub key: String, + pub action: String, + pub content: String, +} + +/// Incremental test tree data. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestStart { + pub id: String, + pub parent: Option<String>, + pub name: String, + pub location: Option<crate::Location>, +} + +/// Incremental test tree data. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestPassed { + pub id: String, + pub duration_ms: Option<u32>, +} + +/// Incremental test tree data. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestFailed { + pub id: String, + pub duration_ms: Option<u32>, + pub message: String, + pub expected: Option<String>, + pub actual: Option<String>, + pub location: Option<crate::Location>, +} + +/// Incremental test tree data. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestSkipped { + pub id: String, + pub reason: Option<String>, +} + +/// Incremental test tree data. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestOutput { + pub id: String, + pub text: String, +} + +/// A test update, carried by the optional test-results export. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PluginTestEvent { + SuiteStarted(TestStart), + TestStarted(TestStart), + Passed(TestPassed), + Failed(TestFailed), + Skipped(TestSkipped), + Output(TestOutput), + Other { kind: String, data: String }, +} + +/// Supplies complete project configurations; register with run_configurations. +pub trait RunConfigurations { + fn configurations(&mut self, project_root: &str) -> Vec<PluginRunConfiguration>; +} +/// Parses bounded test output batches; register with test_results. +pub trait TestResults { + fn parse(&mut self, config: &str, chunk: &str) -> Vec<PluginTestEvent>; +} + +impl crate::Event { + /// Completion of a run started by this plugin, even after idle reclamation. + pub fn run_exited(&self) -> Option<RunExited> { + self.execution_event("run-exited") + } + /// Exactly one completion for a keyed terminal operation. + pub fn terminal_exited(&self) -> Option<TerminalExited> { + self.execution_event("terminal-exited") + } + /// A toolbar action or Save from this plugin's wasm content tab. + pub fn content_tab_action(&self) -> Option<ContentTabAction> { + self.execution_event("content-tab-action") + } + fn execution_event<T: serde::de::DeserializeOwned>(&self, expected: &str) -> Option<T> { + let Self::Other { kind, data } = self else { return None }; + (kind == expected).then(|| serde_json::from_str(data).ok()).flatten() + } +} + +/// A plugin-owned argv wrapper for project runs (API 1.4). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginRunTarget { + pub id: String, + pub name: String, + pub program: String, + pub args: Vec<String>, + pub workdir_args: Vec<String>, + pub container_root: Option<String>, + pub commands: Vec<(String, Vec<String>)>, +} diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index 0fe71b92b..b492677a0 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -91,6 +91,37 @@ mod imp { wit::ui::set_status_item(id, &item) } + pub fn set_window_status_item(id: &str, item: &StatusItem) -> Result<(), Error> { + wit::ui::set_window_status_item( + id, + &wit::ui::StatusItem { + text: item.text.clone(), + tooltip: item.tooltip.clone(), + command: item.command.clone(), + }, + ) + .map_err(error) + } + pub fn remove_window_status_item(id: &str) -> Result<(), Error> { + wit::ui::remove_window_status_item(id).map_err(error) + } + pub fn execute_command(id: &str, arg: Option<&str>) -> Result<Option<String>, Error> { + wit::command_runner::execute(id, arg).map_err(error) + } + pub type ConnectionRes = wit::ipc::Connection; + pub fn ipc_connect(name: &str) -> Result<ConnectionRes, Error> { + wit::ipc::connect(name).map_err(error) + } + pub fn ipc_write(c: &ConnectionRes, bytes: &[u8]) -> Result<(), Error> { + c.write(bytes).map_err(error) + } + pub fn ipc_read(c: &ConnectionRes, max: u32, timeout: u32) -> Result<Vec<u8>, Error> { + c.read(max, timeout).map_err(error) + } + pub fn ipc_close(c: &ConnectionRes) -> Result<(), Error> { + c.close().map_err(error) + } + pub fn remove_status_item(id: &str) { wit::ui::remove_status_item(id) } @@ -232,6 +263,150 @@ mod imp { e.apply_edits(version, &edits).map_err(error) } + pub fn open_editors() -> Result<Vec<OpenEditor>, Error> { + wit::editor::open_editors() + .map(|v| { + v.into_iter() + .map(|e| OpenEditor { path: e.path, language: e.language, dirty: e.dirty, active: e.active }) + .collect() + }) + .map_err(error) + } + pub fn editor_open(path: &str, selection: Option<Range>) -> Result<(), Error> { + wit::editor::open(path, selection.map(range_out)).map_err(error) + } + pub fn editor_set_selections(e: &EditorRes, ranges: &[Range]) -> Result<(), Error> { + e.set_selections(&ranges.iter().copied().map(range_out).collect::<Vec<_>>()).map_err(error) + } + pub fn editor_reveal(e: &EditorRes, range: Range) -> Result<(), Error> { + e.reveal(range_out(range)).map_err(error) + } + pub fn project_search_text(p: &ProjectRes, q: &TextQuery, limit: u32) -> Result<TextSearch, Error> { + let q = wit::workspace::TextQuery { + pattern: q.pattern.clone(), + regex: q.regex, + case_sensitive: q.case_sensitive, + whole_word: q.whole_word, + include: q.include.clone(), + exclude: q.exclude.clone(), + }; + p.search_text(&q, limit) + .map(|s| TextSearch { + limit_hit: s.limit_hit, + matches: s + .matches + .into_iter() + .map(|m| TextMatch { + location: Location { path: m.location.path, range: range_in(m.location.range) }, + line: m.line, + }) + .collect(), + }) + .map_err(error) + } + fn diagnostic_in(d: wit::diagnostics::Diagnostic) -> Diagnostic { + Diagnostic { + range: range_in(d.range), + severity: match d.severity { + wit::diagnostics::Severity::Error => Severity::Error, + wit::diagnostics::Severity::Warning => Severity::Warning, + wit::diagnostics::Severity::Info => Severity::Info, + wit::diagnostics::Severity::Hint => Severity::Hint, + }, + message: d.message, + code: d.code, + source: d.source, + unnecessary: d.tags.iter().any(|s| s == "unnecessary"), + deprecated: d.tags.iter().any(|s| s == "deprecated"), + related: d + .related + .into_iter() + .map(|r| RelatedInformation { + location: Location { path: r.location.path, range: range_in(r.location.range) }, + message: r.message, + }) + .collect(), + } + } + pub fn problems(path: Option<&str>) -> Result<Vec<FileProblems>, Error> { + wit::diagnostics::problems(path) + .map(|v| { + v.into_iter() + .map(|f| FileProblems { + path: f.path, + source: f.source, + diagnostics: f.diagnostics.into_iter().map(diagnostic_in).collect(), + }) + .collect() + }) + .map_err(error) + } + pub fn set_decorations(path: &str, set: &str, items: &[Decoration]) -> Result<(), Error> { + let items = items + .iter() + .map(|d| wit::decorations::Decoration { + range: range_out(d.range), + style: d.style.clone(), + hover: d.hover.clone(), + after: d.after.clone(), + }) + .collect::<Vec<_>>(); + wit::decorations::set(path, set, &items).map_err(error) + } + pub fn clear_decorations(set: &str) -> Result<(), Error> { + wit::decorations::clear(set).map_err(error) + } + pub fn set_file_decoration(path: &str, set: &str, item: &FileDecoration) -> Result<(), Error> { + wit::decorations::set_file( + path, + set, + &wit::decorations::FileDecoration { + badge: item.badge.clone(), + style: item.style.clone(), + tooltip: item.tooltip.clone(), + }, + ) + .map_err(error) + } + pub fn clear_file_decorations(set: &str) -> Result<(), Error> { + wit::decorations::clear_file(set).map_err(error) + } + pub fn vcs_log(p: &ProjectRes, path: Option<&str>, limit: u32) -> Result<Vec<CommitInfo>, Error> { + wit::vcs::log(p, path, limit) + .map(|v| { + v.into_iter() + .map(|c| CommitInfo { + id: c.id, + short_id: c.short_id, + author: c.author, + author_email: c.author_email, + date: c.date, + subject: c.subject, + body: c.body, + parents: c.parents, + }) + .collect() + }) + .map_err(error) + } + pub fn vcs_blame(p: &ProjectRes, path: &str) -> Result<Vec<BlameLine>, Error> { + wit::vcs::blame(p, path) + .map(|v| { + v.into_iter() + .map(|c| BlameLine { + line: c.line, + commit: c.commit, + author: c.author, + date: c.date, + summary: c.summary, + }) + .collect() + }) + .map_err(error) + } + pub fn vcs_diff(p: &ProjectRes, path: Option<&str>, against: Option<&str>) -> Result<Option<String>, Error> { + wit::vcs::diff(p, path, against).map_err(error) + } pub fn vcs_status(p: &ProjectRes) -> Result<Option<RepositoryStatus>, Error> { wit::vcs::status(p) .map(|s| { @@ -435,6 +610,31 @@ mod imp { .map_err(error) } + pub fn secret_set(name: &str, value: &str) -> Result<(), Error> { + wit::secrets::set(name, value).map_err(error) + } + pub fn secret_delete(name: &str) -> Result<(), Error> { + wit::secrets::delete(name).map_err(error) + } + pub type LoopbackRes = wit::auth::Loopback; + pub fn begin_loopback(path: &str) -> Result<LoopbackRes, Error> { + wit::auth::begin_loopback(path).map_err(error) + } + pub fn loopback_uri(l: &LoopbackRes) -> String { + l.redirect_uri() + } + pub fn loopback_wait(l: &LoopbackRes, timeout_ms: u32) -> Result<Option<String>, Error> { + l.wait(timeout_ms).map_err(error) + } + pub fn output_append(channel: &str, text: &str) -> Result<(), Error> { + wit::output::append(channel, text).map_err(error) + } + pub fn output_clear(channel: &str) -> Result<(), Error> { + wit::output::clear(channel).map_err(error) + } + pub fn output_reveal(channel: &str) -> Result<(), Error> { + wit::output::reveal(channel).map_err(error) + } pub fn secret_is_set(name: &str) -> bool { wit::secrets::is_set(name) } @@ -447,6 +647,121 @@ mod imp { wit::views::post_message(view, message).map_err(error) } + fn content_action_to(v: &crate::ContentAction) -> wit::views::ContentAction { + wit::views::ContentAction { + id: v.id.clone(), + label: v.label.clone(), + icon: v.icon.clone(), + primary: v.primary, + save: v.save, + } + } + + fn content_tab_to(v: &crate::ContentTab) -> wit::views::ContentTab { + wit::views::ContentTab { + key: v.key.clone(), + title: v.title.clone(), + file_name: v.file_name.clone(), + language: v.language.clone(), + content: v.content.clone(), + read_only: v.read_only, + actions: v.actions.iter().map(content_action_to).collect(), + } + } + + fn content_diff_to(v: &crate::ContentDiff) -> wit::views::ContentDiff { + wit::views::ContentDiff { + left: v.left.clone(), + right: v.right.clone(), + left_label: v.left_label.clone(), + right_label: v.right_label.clone(), + title: v.title.clone(), + } + } + + fn content_tab_reply_to(v: &crate::ContentTabReply) -> wit::views::ContentTabReply { + wit::views::ContentTabReply { + content: v.content.clone(), + saved: v.saved, + read_only: v.read_only, + status: v.status.clone(), + error: v.error.clone(), + diff: v.diff.as_ref().map(content_diff_to), + } + } + + pub fn database_upsert(project: &ProjectRes, source: &PluginDataSource) -> Result<(), Error> { + wit::database::upsert_source( + project, + &wit::database::DataSource { + id: source.id.clone(), + name: source.name.clone(), + driver: source.driver.clone(), + host: source.host.clone(), + port: source.port, + database: source.database.clone(), + user: source.user.clone(), + url: source.url.clone(), + options: source.options.clone(), + }, + ) + .map_err(error) + } + pub fn database_remove(project: &ProjectRes, id: &str) -> Result<(), Error> { + wit::database::remove_source(project, id).map_err(error) + } + pub fn database_sources(project: &ProjectRes) -> Result<Vec<String>, Error> { + wit::database::sources(project).map_err(error) + } + pub fn database_credential(project: &ProjectRes, id: &str, password: &str) -> Result<(), Error> { + wit::database::set_credential(project, id, password).map_err(error) + } + pub fn database_connect(project: &ProjectRes, id: &str) -> Result<(), Error> { + wit::database::connect(project, id).map_err(error) + } + pub fn mcp_stdio_command() -> Option<Vec<String>> { + wit::mcp::stdio_command() + } + + pub fn register_run_target(project: &ProjectRes, target: &crate::PluginRunTarget) -> Result<(), Error> { + wit::run_targets::register( + project, + &wit::run_targets::RunTarget { + id: target.id.clone(), + name: target.name.clone(), + program: target.program.clone(), + args: target.args.clone(), + workdir_args: target.workdir_args.clone(), + container_root: target.container_root.clone(), + commands: target.commands.clone(), + }, + ) + .map_err(error) + } + pub fn unregister_run_target(project: &ProjectRes, id: &str) { + wit::run_targets::unregister(project, id); + } + pub fn start_run(config: &str, args: &[String], debug: bool) -> Result<String, Error> { + wit::run::start(config, args, debug).map_err(error) + } + pub fn open_terminal_session(key: &str, title: &str, cmd: &super::Command) -> Result<String, Error> { + wit::views::open_terminal_session( + key, + title, + &wit::process::Command { program: cmd.program.clone(), args: cmd.args.clone(), env: cmd.env.clone() }, + ) + .map_err(error) + } + pub fn write_terminal(key: &str, text: &str) -> Result<(), Error> { + wit::views::write_terminal(key, text).map_err(error) + } + pub fn open_content_tab(tab: &crate::ContentTab) -> Result<(), Error> { + wit::views::open_content_tab(&content_tab_to(tab)).map_err(error) + } + pub fn reply_content_tab(request: &str, reply: &crate::ContentTabReply) -> Result<(), Error> { + wit::views::reply_content_tab(request, &content_tab_reply_to(reply)).map_err(error) + } + pub fn open_terminal(title: &str, program: &str, args: &[String]) -> Result<(), Error> { wit::views::open_terminal(title, program, args).map_err(error) } @@ -585,6 +900,45 @@ pub struct Project { } impl Project { + /// Creates/replaces only this plugin's source. Requires database.sources and trust. + pub fn upsert_data_source(&self, source: &PluginDataSource) -> Result<(), Error> { + imp::database_upsert(&self.inner, source) + } + /// Removes an owned source and its credentials; other ids are not-found. + pub fn remove_data_source(&self, id: &str) -> Result<(), Error> { + imp::database_remove(&self.inner, id) + } + /// Lists only this plugin's private source ids. + pub fn data_sources(&self) -> Result<Vec<String>, Error> { + imp::database_sources(&self.inner) + } + /// Writes directly to the Database secure store, without a read API. + pub fn set_data_source_credential(&self, id: &str, password: &str) -> Result<(), Error> { + imp::database_credential(&self.inner, id, password) + } + /// Normal Database connection/introspection in this project's invoking command/event window. + pub fn connect_data_source(&self, id: &str) -> Result<(), Error> { + imp::database_connect(&self.inner, id) + } + + /// Find in Files with unsaved text, project excludes, cancellation and a 10,000 match cap. + /// Requires project.read; locations use zero-based lines and UTF-16 columns. + pub fn search_text(&self, query: &TextQuery, limit: u32) -> Result<TextSearch, Error> { + imp::project_search_text(&self.inner, query, limit) + } + /// Trusted project history, newest first, at most 1000 commits. Requires vcs.read. + pub fn vcs_log(&self, path: Option<&str>, limit: u32) -> Result<Vec<CommitInfo>, Error> { + imp::vcs_log(&self.inner, path, limit) + } + /// Trusted project attribution including unsaved text. Requires vcs.read. + pub fn vcs_blame(&self, path: &str) -> Result<Vec<BlameLine>, Error> { + imp::vcs_blame(&self.inner, path) + } + /// Disk working tree versus HEAD or a revision, failing above 4 MiB. Requires vcs.read and trust. + pub fn vcs_diff(&self, path: Option<&str>, against: Option<&str>) -> Result<Option<String>, Error> { + imp::vcs_diff(&self.inner, path, against) + } + /// Absolute path of the root. Needs `project.read`. pub fn root(&self) -> Result<String, Error> { imp::project_root(&self.inner) @@ -684,8 +1038,8 @@ pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error imp::publish_diagnostics(path, items) } -/// HTTP(S) to a host granted with `net:<host>` (plain http only to -/// localhost). `{{secret:<name>}}` in a header is filled in by the IDE (needs +/// HTTPS with `net:<host>`; unencrypted HTTP with `net.http:<host>` +/// (loopback HTTP retains `net:<host>`). `{{secret:<name>}}` in a header is filled in by the IDE (needs /// `secrets`). `Error::Cancelled` when the call was superseded. pub fn fetch(request: &HttpRequest) -> Result<HttpResponse, Error> { imp::fetch(request) @@ -869,6 +1223,15 @@ pub struct TextEditor { } impl TextEditor { + /// Changes this window's selections. Commands/events only; no capability needed. + pub fn set_selections(&self, ranges: &[Range]) -> Result<(), Error> { + imp::editor_set_selections(&self.inner, ranges) + } + /// Scrolls a range into view in this window. Commands/events only; no capability needed. + pub fn reveal(&self, range: Range) -> Result<(), Error> { + imp::editor_reveal(&self.inner, range) + } + pub fn path(&self) -> String { imp::editor_path(&self.inner) } @@ -1177,3 +1540,148 @@ pub fn notify_with_actions( pub fn __diagnostic_to(d: &Diagnostic) -> crate::bindings::openidea::plugin::diagnostics::Diagnostic { imp::diagnostic_to(d) } + +/// Overrides an item in the invoking window, until removed or the window/plugin closes. +pub fn set_window_status_item(id: &str, item: &StatusItem) -> Result<(), Error> { + imp::set_window_status_item(id, item) +} +/// Removes this window's status override, exposing the global item. +pub fn remove_window_status_item(id: &str) -> Result<(), Error> { + imp::remove_window_status_item(id) +} + +/// An approved byte stream on the project machine (the server in remote projects). +pub struct Connection { + inner: imp::ConnectionRes, +} +impl Connection { + /// Writes at most 1 MiB. + pub fn write(&self, bytes: &[u8]) -> Result<(), Error> { + imp::ipc_write(&self.inner, bytes) + } + /// Reads up to 1 MiB; empty on timeout or EOF. + pub fn read(&self, max_bytes: u32, timeout_ms: u32) -> Result<Vec<u8>, Error> { + imp::ipc_read(&self.inner, max_bytes, timeout_ms) + } + /// Closes this stream. Dropping it also closes it. + pub fn close(&self) -> Result<(), Error> { + imp::ipc_close(&self.inner) + } +} +/// Connects to a granted absolute socket path or Windows named pipe (API 1.4). +pub fn ipc_connect(name: &str) -> Result<Connection, Error> { + imp::ipc_connect(name).map(|inner| Connection { inner }) +} + +/// Executes a stable action or plugin command in the invoking window (API 1.4). +/// Requires high-risk `ide.commands`, except for this plugin's declared commands. +/// IDE actions return `None`; plugin commands return their result. Waiting instances +/// cannot re-enter themselves; command nesting is bounded. +pub fn execute_command(id: &str, arg: Option<&str>) -> Result<Option<String>, Error> { + imp::execute_command(id, arg) +} + +/// Stores a declared secret in the desktop keychain; requires `secrets`. +pub fn secret_set(name: &str, value: &str) -> Result<(), Error> { + imp::secret_set(name, value) +} +/// Deletes a declared secret; requires `secrets`. +pub fn secret_delete(name: &str) -> Result<(), Error> { + imp::secret_delete(name) +} +/// A one-shot browser redirect on the desktop, including for remote projects. +pub struct Loopback { + inner: imp::LoopbackRes, +} +impl Loopback { + /// The authorization flow's loopback redirect URI. + pub fn redirect_uri(&self) -> String { + imp::loopback_uri(&self.inner) + } + /// Awaits the raw query string; `None` on timeout. Waiting excludes the call budget. + pub fn wait(&self, timeout_ms: u32) -> Result<Option<String>, Error> { + imp::loopback_wait(&self.inner, timeout_ms) + } +} +/// Begins an OAuth redirect listener in the invoking window (no capability). +pub fn begin_loopback(path: &str) -> Result<Loopback, Error> { + imp::begin_loopback(path).map(|inner| Loopback { inner }) +} + +/// Appends to a plugin-owned Output tab's 1 MiB ring buffer. +pub fn output_append(channel: &str, text: &str) -> Result<(), Error> { + imp::output_append(channel, text) +} +/// Clears a plugin-owned output channel. +pub fn output_clear(channel: &str) -> Result<(), Error> { + imp::output_clear(channel) +} +/// Reveals a read-only Output tab in the invoking window. +pub fn output_reveal(channel: &str) -> Result<(), Error> { + imp::output_reveal(channel) +} + +/// The invoking window's open editors. Requires editor.read; commands/events only. +pub fn open_editors() -> Result<Vec<OpenEditor>, Error> { + imp::open_editors() +} +/// Opens a project file (project.read) or plugin-dir file, optionally selecting a range. +/// Only available in commands/events of a window. +pub fn open_editor(path: &str, selection: Option<Range>) -> Result<(), Error> { + imp::editor_open(path, selection) +} +/// The invoking window's project Problems view. Requires project.read. Empty for remote projects. +pub fn problems(path: Option<&str>) -> Result<Vec<FileProblems>, Error> { + imp::problems(path) +} +/// Replace a file's editor decorations. Requires project.read; 10,000 items/file, 50 sets. +pub fn set_decorations(path: &str, set: &str, items: &[Decoration]) -> Result<(), Error> { + imp::set_decorations(path, set, items) +} +/// Clear this plugin's editor decoration set in all projects. Requires project.read. +pub fn clear_decorations(set: &str) -> Result<(), Error> { + imp::clear_decorations(set) +} +/// Set a project-tree badge/style. Requires project.read. Badge is at most two characters. +pub fn set_file_decoration(path: &str, set: &str, item: &FileDecoration) -> Result<(), Error> { + imp::set_file_decoration(path, set, item) +} +/// Clear this plugin's tree decoration set in all projects. Requires project.read. +pub fn clear_file_decorations(set: &str) -> Result<(), Error> { + imp::clear_file_decorations(set) +} + +/// Starts an owned run configuration in the invoking window. Its id receives run-exited. +pub fn start_run(config: &str, args: &[String], debug: bool) -> Result<String, Error> { + imp::start_run(config, args, debug) +} +/// Opens/reuses a keyed, approved terminal with append-only environment variables. +pub fn open_terminal_session(key: &str, title: &str, cmd: &Command) -> Result<String, Error> { + imp::open_terminal_session(key, title, cmd) +} +/// Writes to this plugin's running keyed terminal in the invoking window. +pub fn write_terminal(key: &str, text: &str) -> Result<(), Error> { + imp::write_terminal(key, text) +} +/// Opens a content editor through the existing content-tab protocol. +pub fn open_content_tab(tab: &crate::ContentTab) -> Result<(), Error> { + imp::open_content_tab(tab) +} +/// Replies to a content-tab-action event in its originating window. +pub fn reply_content_tab(request: &str, reply: &crate::ContentTabReply) -> Result<(), Error> { + imp::reply_content_tab(request, reply) +} + +/// Registers/replaces a read-only project target. Requires the program's process grant. +pub fn register_run_target(project: &Project, target: &crate::PluginRunTarget) -> Result<(), Error> { + imp::register_run_target(&project.inner, target) +} +/// Withdraws a target owned by this plugin. +pub fn unregister_run_target(project: &Project, id: &str) { + imp::unregister_run_target(&project.inner, id); +} + +/// Stdio bridge argv for the invoking project, none while the built-in MCP server is off. +pub fn mcp_stdio_command() -> Option<Vec<String>> { + imp::mcp_stdio_command() +} diff --git a/sdk/rust/openidea-plugin/src/language_features.rs b/sdk/rust/openidea-plugin/src/language_features.rs new file mode 100644 index 000000000..683748f51 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/language_features.rs @@ -0,0 +1,347 @@ +//! Optional API 1.4 language features and embedded language ranges. +pub use crate::providers::Document; +use crate::{Error, Location, Position, Range, TextEdit}; +/// A parameter label, as text or UTF-16 offsets within the signature. +#[derive(Clone, Debug, PartialEq)] +pub enum ParameterLabel { + Text(String), + Offsets((u32, u32)), +} +/// FormatOptions data. +#[derive(Clone, Debug, PartialEq)] +pub struct FormatOptions { + pub tab_size: u32, + pub insert_spaces: bool, +} +/// Parameter data. +#[derive(Clone, Debug, PartialEq)] +pub struct Parameter { + pub label: ParameterLabel, + pub documentation: Option<String>, +} +/// Signature data. +#[derive(Clone, Debug, PartialEq)] +pub struct Signature { + pub label: String, + pub documentation: Option<String>, + pub parameters: Vec<Parameter>, +} +/// SignatureHelp data. +#[derive(Clone, Debug, PartialEq)] +pub struct SignatureHelp { + pub signatures: Vec<Signature>, + pub active_signature: u32, + pub active_parameter: u32, +} +/// Highlight data. +#[derive(Clone, Debug, PartialEq)] +pub struct Highlight { + pub range: Range, + pub kind: String, +} +/// DocumentLink data. +#[derive(Clone, Debug, PartialEq)] +pub struct DocumentLink { + pub range: Range, + pub target: String, +} +/// CodeLens data. +#[derive(Clone, Debug, PartialEq)] +pub struct CodeLens { + pub range: Range, + pub title: String, + pub command: String, + pub argument: Option<String>, +} +/// FoldRange data. +#[derive(Clone, Debug, PartialEq)] +pub struct FoldRange { + pub start_line: u32, + pub end_line: u32, + pub kind: String, +} +/// ColorInfo data. +#[derive(Clone, Debug, PartialEq)] +pub struct ColorInfo { + pub range: Range, + pub red: f32, + pub green: f32, + pub blue: f32, + pub alpha: f32, +} +/// DocumentSymbol data. +#[derive(Clone, Debug, PartialEq)] +pub struct DocumentSymbol { + pub name: String, + pub detail: String, + pub kind: String, + pub range: Range, + pub selection_range: Range, + pub parent: Option<u32>, +} +/// GutterAction data. +#[derive(Clone, Debug, PartialEq)] +pub struct GutterAction { + pub line: u32, + pub icon: Option<String>, + pub tooltip: String, + pub command: String, + pub argument: Option<String>, +} +/// Injection data. +#[derive(Clone, Debug, PartialEq)] +pub struct Injection { + pub range: Range, + pub language: String, +} +/// Implement only the kinds declared under contributes.providers. +pub trait LanguageFeatures { + fn format( + &mut self, + _doc: &Document, + _range: Option<Range>, + _options: FormatOptions, + ) -> Result<Vec<TextEdit>, Error> { + Ok(Vec::new()) + } + fn signature_help(&mut self, _doc: &Document, _at: Position) -> Option<SignatureHelp> { + None + } + fn document_highlights(&mut self, _doc: &Document, _at: Position) -> Vec<Highlight> { + Vec::new() + } + fn document_links(&mut self, _doc: &Document) -> Vec<DocumentLink> { + Vec::new() + } + fn code_lenses(&mut self, _doc: &Document) -> Vec<CodeLens> { + Vec::new() + } + fn selection_ranges(&mut self, _doc: &Document, _at: Position) -> Vec<Range> { + Vec::new() + } + fn folding(&mut self, _doc: &Document) -> Vec<FoldRange> { + Vec::new() + } + fn colors(&mut self, _doc: &Document) -> Vec<ColorInfo> { + Vec::new() + } + fn color_presentations(&mut self, _doc: &Document, _color: ColorInfo) -> Vec<String> { + Vec::new() + } + fn document_symbols(&mut self, _doc: &Document) -> Vec<DocumentSymbol> { + Vec::new() + } + fn implementation(&mut self, _doc: &Document, _at: Position) -> Vec<Location> { + Vec::new() + } + fn type_definition(&mut self, _doc: &Document, _at: Position) -> Vec<Location> { + Vec::new() + } + fn gutter_actions(&mut self, _doc: &Document) -> Vec<GutterAction> { + Vec::new() + } +} +/// Identifies embedded ranges in a host document. +pub trait Injections { + fn injections(&mut self, doc: &Document) -> Vec<Injection>; +} + +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod wasm { + use super::*; + use crate::bindings::openidea::plugin::types as b; + use crate::injections_bindings::exports::openidea::plugin::injections as wi; + use crate::language_features_bindings::exports::openidea::plugin::language_features as w; + fn format_options_from(v: w::FormatOptions) -> FormatOptions { + FormatOptions { tab_size: v.tab_size, insert_spaces: v.insert_spaces } + } + fn parameter_to(v: Parameter) -> w::Parameter { + w::Parameter { label: parameter_label_to(v.label), documentation: v.documentation } + } + fn signature_to(v: Signature) -> w::Signature { + w::Signature { + label: v.label, + documentation: v.documentation, + parameters: v.parameters.into_iter().map(parameter_to).collect(), + } + } + fn signature_help_to(v: SignatureHelp) -> w::SignatureHelpResult { + w::SignatureHelpResult { + signatures: v.signatures.into_iter().map(signature_to).collect(), + active_signature: v.active_signature, + active_parameter: v.active_parameter, + } + } + fn highlight_to(v: Highlight) -> w::Highlight { + w::Highlight { range: crate::host::__range_to(v.range), kind: v.kind } + } + fn document_link_to(v: DocumentLink) -> w::DocumentLink { + w::DocumentLink { range: crate::host::__range_to(v.range), target: v.target } + } + fn code_lens_to(v: CodeLens) -> w::CodeLens { + w::CodeLens { + range: crate::host::__range_to(v.range), + title: v.title, + command: v.command, + argument: v.argument, + } + } + fn fold_range_to(v: FoldRange) -> w::FoldRange { + w::FoldRange { start_line: v.start_line, end_line: v.end_line, kind: v.kind } + } + fn color_info_to(v: ColorInfo) -> w::ColorInfo { + w::ColorInfo { + range: crate::host::__range_to(v.range), + red: v.red, + green: v.green, + blue: v.blue, + alpha: v.alpha, + } + } + fn color_info_from(v: w::ColorInfo) -> ColorInfo { + ColorInfo { + range: crate::host::__range_from(v.range), + red: v.red, + green: v.green, + blue: v.blue, + alpha: v.alpha, + } + } + fn document_symbol_to(v: DocumentSymbol) -> w::DocumentSymbol { + w::DocumentSymbol { + name: v.name, + detail: v.detail, + kind: v.kind, + range: crate::host::__range_to(v.range), + selection_range: crate::host::__range_to(v.selection_range), + parent: v.parent, + } + } + fn gutter_action_to(v: GutterAction) -> w::GutterAction { + w::GutterAction { line: v.line, icon: v.icon, tooltip: v.tooltip, command: v.command, argument: v.argument } + } + fn parameter_label_to(v: ParameterLabel) -> w::ParameterLabel { + match v { + ParameterLabel::Text(t) => w::ParameterLabel::Text(t), + ParameterLabel::Offsets(t) => w::ParameterLabel::Offsets(t), + } + } + pub fn format<T: crate::Plugin + LanguageFeatures + Default>( + d: w::Document, + range: Option<b::Range>, + options: w::FormatOptions, + ) -> Result<Vec<b::TextEdit>, b::Error> { + let doc = Document { path: d.path, language: d.language, version: d.version, text: d.text }; + let result = crate::__rt::with::<T, _>(|p| { + p.format(&doc, range.map(crate::host::__range_from), format_options_from(options)) + }); + result + .map(|result| { + result + .into_iter() + .map(|v| b::TextEdit { range: crate::host::__range_to(v.range), text: v.text }) + .collect() + }) + .map_err(|e| match e { + Error::Denied(s) => b::Error::Denied(s), + Error::Stale => b::Error::Stale, + Error::NotFound(s) => b::Error::NotFound(s), + Error::Invalid(s) => b::Error::Invalid(s), + Error::Cancelled => b::Error::Cancelled, + Error::Failed(s) => b::Error::Failed(s), + }) + } + pub fn signature_help<T: crate::Plugin + LanguageFeatures + Default>( + d: w::Document, + at: b::Position, + ) -> Option<w::SignatureHelpResult> { + let doc = Document { path: d.path, language: d.language, version: d.version, text: d.text }; + let result = + crate::__rt::with::<T, _>(|p| p.signature_help(&doc, Position { line: at.line, character: at.character })); + result.map(signature_help_to) + } + pub fn document_highlights<T: crate::Plugin + LanguageFeatures + Default>( + d: w::Document, + at: b::Position, + ) -> Vec<w::Highlight> { + let doc = Document { path: d.path, language: d.language, version: d.version, text: d.text }; + let result = crate::__rt::with::<T, _>(|p| { + p.document_highlights(&doc, Position { line: at.line, character: at.character }) + }); + result.into_iter().map(highlight_to).collect() + } + pub fn document_links<T: crate::Plugin + LanguageFeatures + Default>(d: w::Document) -> Vec<w::DocumentLink> { + let doc = Document { path: d.path, language: d.language, version: d.version, text: d.text }; + let result = crate::__rt::with::<T, _>(|p| p.document_links(&doc)); + result.into_iter().map(document_link_to).collect() + } + pub fn code_lenses<T: crate::Plugin + LanguageFeatures + Default>(d: w::Document) -> Vec<w::CodeLens> { + let doc = Document { path: d.path, language: d.language, version: d.version, text: d.text }; + let result = crate::__rt::with::<T, _>(|p| p.code_lenses(&doc)); + result.into_iter().map(code_lens_to).collect() + } + pub fn selection_ranges<T: crate::Plugin + LanguageFeatures + Default>( + d: w::Document, + at: b::Position, + ) -> Vec<b::Range> { + let doc = Document { path: d.path, language: d.language, version: d.version, text: d.text }; + let result = crate::__rt::with::<T, _>(|p| { + p.selection_ranges(&doc, Position { line: at.line, character: at.character }) + }); + result.into_iter().map(crate::host::__range_to).collect() + } + pub fn folding<T: crate::Plugin + LanguageFeatures + Default>(d: w::Document) -> Vec<w::FoldRange> { + let doc = Document { path: d.path, language: d.language, version: d.version, text: d.text }; + let result = crate::__rt::with::<T, _>(|p| p.folding(&doc)); + result.into_iter().map(fold_range_to).collect() + } + pub fn colors<T: crate::Plugin + LanguageFeatures + Default>(d: w::Document) -> Vec<w::ColorInfo> { + let doc = Document { path: d.path, language: d.language, version: d.version, text: d.text }; + let result = crate::__rt::with::<T, _>(|p| p.colors(&doc)); + result.into_iter().map(color_info_to).collect() + } + pub fn color_presentations<T: crate::Plugin + LanguageFeatures + Default>( + d: w::Document, + color: w::ColorInfo, + ) -> Vec<String> { + let doc = Document { path: d.path, language: d.language, version: d.version, text: d.text }; + let result = crate::__rt::with::<T, _>(|p| p.color_presentations(&doc, color_info_from(color))); + result.into_iter().collect() + } + pub fn document_symbols<T: crate::Plugin + LanguageFeatures + Default>(d: w::Document) -> Vec<w::DocumentSymbol> { + let doc = Document { path: d.path, language: d.language, version: d.version, text: d.text }; + let result = crate::__rt::with::<T, _>(|p| p.document_symbols(&doc)); + result.into_iter().map(document_symbol_to).collect() + } + pub fn implementation<T: crate::Plugin + LanguageFeatures + Default>( + d: w::Document, + at: b::Position, + ) -> Vec<b::Location> { + let doc = Document { path: d.path, language: d.language, version: d.version, text: d.text }; + let result = + crate::__rt::with::<T, _>(|p| p.implementation(&doc, Position { line: at.line, character: at.character })); + result.into_iter().map(|v| b::Location { path: v.path, range: crate::host::__range_to(v.range) }).collect() + } + pub fn type_definition<T: crate::Plugin + LanguageFeatures + Default>( + d: w::Document, + at: b::Position, + ) -> Vec<b::Location> { + let doc = Document { path: d.path, language: d.language, version: d.version, text: d.text }; + let result = + crate::__rt::with::<T, _>(|p| p.type_definition(&doc, Position { line: at.line, character: at.character })); + result.into_iter().map(|v| b::Location { path: v.path, range: crate::host::__range_to(v.range) }).collect() + } + pub fn gutter_actions<T: crate::Plugin + LanguageFeatures + Default>(d: w::Document) -> Vec<w::GutterAction> { + let doc = Document { path: d.path, language: d.language, version: d.version, text: d.text }; + let result = crate::__rt::with::<T, _>(|p| p.gutter_actions(&doc)); + result.into_iter().map(gutter_action_to).collect() + } + pub fn injections<T: crate::Plugin + Injections + Default>(d: wi::Document) -> Vec<wi::Injection> { + let doc = Document { path: d.path, language: d.language, version: d.version, text: d.text }; + crate::__rt::with::<T, _>(|p| p.injections(&doc)) + .into_iter() + .map(|i| wi::Injection { range: crate::host::__range_to(i.range), language: i.language }) + .collect() + } +} diff --git a/sdk/rust/openidea-plugin/src/lib.rs b/sdk/rust/openidea-plugin/src/lib.rs index 8518a3a4c..faa56f39c 100644 --- a/sdk/rust/openidea-plugin/src/lib.rs +++ b/sdk/rust/openidea-plugin/src/lib.rs @@ -98,7 +98,7 @@ pub mod providers_bindings { export_macro_name: "__export_editor_providers_world", default_bindings_module: "openidea_plugin::providers_bindings", with: { - "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types, + "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types, }, }); } @@ -115,10 +115,10 @@ pub mod views_bindings { export_macro_name: "__export_views_world", default_bindings_module: "openidea_plugin::views_bindings", with: { - "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types, - "openidea:plugin/workspace@1.3.0": crate::bindings::openidea::plugin::workspace, - "openidea:plugin/editor@1.3.0": crate::bindings::openidea::plugin::editor, - "openidea:plugin/invocation@1.3.0": crate::bindings::openidea::plugin::invocation, + "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types, + "openidea:plugin/workspace@1.4.0": crate::bindings::openidea::plugin::workspace, + "openidea:plugin/editor@1.4.0": crate::bindings::openidea::plugin::editor, + "openidea:plugin/invocation@1.4.0": crate::bindings::openidea::plugin::invocation, }, }); } @@ -130,7 +130,7 @@ pub mod indexing_bindings { world: "indexing", path: "wit", pub_export_macro: true, export_macro_name: "__export_indexing_world", default_bindings_module: "openidea_plugin::indexing_bindings", - with: { "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types }, + with: { "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types }, }); } @@ -142,10 +142,10 @@ pub mod details_bindings { export_macro_name: "__export_details_world", default_bindings_module: "openidea_plugin::details_bindings", with: { - "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types, - "openidea:plugin/workspace@1.3.0": crate::bindings::openidea::plugin::workspace, - "openidea:plugin/editor@1.3.0": crate::bindings::openidea::plugin::editor, - "openidea:plugin/invocation@1.3.0": crate::bindings::openidea::plugin::invocation, + "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types, + "openidea:plugin/workspace@1.4.0": crate::bindings::openidea::plugin::workspace, + "openidea:plugin/editor@1.4.0": crate::bindings::openidea::plugin::editor, + "openidea:plugin/invocation@1.4.0": crate::bindings::openidea::plugin::invocation, }, }); } @@ -157,7 +157,7 @@ pub mod semantic_bindings { world: "semantic-tokens", path: "wit", pub_export_macro: true, export_macro_name: "__export_semantic_tokens_world", default_bindings_module: "openidea_plugin::semantic_bindings", - with: { "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types }, + with: { "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types }, }); } @@ -169,8 +169,8 @@ pub mod diagnostics_bindings { export_macro_name: "__export_diagnostics_world", default_bindings_module: "openidea_plugin::diagnostics_bindings", with: { - "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types, - "openidea:plugin/diagnostics@1.3.0": crate::bindings::openidea::plugin::diagnostics, + "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types, + "openidea:plugin/diagnostics@1.4.0": crate::bindings::openidea::plugin::diagnostics, }, }); } @@ -182,7 +182,7 @@ pub mod hover_bindings { world: "hover", path: "wit", pub_export_macro: true, export_macro_name: "__export_hover_world", default_bindings_module: "openidea_plugin::hover_bindings", - with: { "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types }, + with: { "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types }, }); } @@ -194,11 +194,11 @@ pub mod commit_checks_bindings { export_macro_name: "__export_commit_checks_world", default_bindings_module: "openidea_plugin::commit_checks_bindings", with: { - "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types, - "openidea:plugin/workspace@1.3.0": crate::bindings::openidea::plugin::workspace, - "openidea:plugin/editor@1.3.0": crate::bindings::openidea::plugin::editor, - "openidea:plugin/invocation@1.3.0": crate::bindings::openidea::plugin::invocation, - "openidea:plugin/commit@1.3.0": crate::bindings::openidea::plugin::commit, + "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types, + "openidea:plugin/workspace@1.4.0": crate::bindings::openidea::plugin::workspace, + "openidea:plugin/editor@1.4.0": crate::bindings::openidea::plugin::editor, + "openidea:plugin/invocation@1.4.0": crate::bindings::openidea::plugin::invocation, + "openidea:plugin/commit@1.4.0": crate::bindings::openidea::plugin::commit, }, }); } @@ -210,7 +210,7 @@ pub mod inlay_bindings { world: "inlay-hints", path: "wit", pub_export_macro: true, export_macro_name: "__export_inlay_hints_world", default_bindings_module: "openidea_plugin::inlay_bindings", - with: { "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types }, + with: { "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types }, }); } @@ -221,7 +221,7 @@ pub mod file_actions_bindings { world: "file-actions-provider", path: "wit", pub_export_macro: true, export_macro_name: "__export_file_actions_world", default_bindings_module: "openidea_plugin::file_actions_bindings", - with: { "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types }, + with: { "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types }, }); } @@ -233,10 +233,10 @@ pub mod endpoints_bindings { export_macro_name: "__export_endpoints_world", default_bindings_module: "openidea_plugin::endpoints_bindings", with: { - "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types, - "openidea:plugin/workspace@1.3.0": crate::bindings::openidea::plugin::workspace, - "openidea:plugin/editor@1.3.0": crate::bindings::openidea::plugin::editor, - "openidea:plugin/invocation@1.3.0": crate::bindings::openidea::plugin::invocation, + "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types, + "openidea:plugin/workspace@1.4.0": crate::bindings::openidea::plugin::workspace, + "openidea:plugin/editor@1.4.0": crate::bindings::openidea::plugin::editor, + "openidea:plugin/invocation@1.4.0": crate::bindings::openidea::plugin::invocation, }, }); } @@ -286,6 +286,12 @@ macro_rules! register_plugin { #[doc(hidden)] #[macro_export] macro_rules! __optional_exports { + (injections, $plugin:ty, $component:ident) => { $crate::__injections_exports!($plugin, $component); }; + (language_features, $plugin:ty, $component:ident) => { $crate::__language_features_exports!($plugin, $component); }; + (mcp, $plugin:ty, $component:ident) => { $crate::__mcp_exports!($plugin, $component); }; + (run_configurations, $plugin:ty, $component:ident) => { $crate::__run_configurations_exports!($plugin, $component); }; + (test_results, $plugin:ty, $component:ident) => { $crate::__test_results_exports!($plugin, $component); }; + (providers, $plugin:ty, $component:ident) => { $crate::__providers_exports!($plugin, $component); }; @@ -653,7 +659,7 @@ pub mod __rt { static INSTANCE: RefCell<Option<Box<dyn Any>>> = const { RefCell::new(None) }; } - fn with<T: Plugin + Default, R>(f: impl FnOnce(&mut T) -> R) -> R { + pub(crate) fn with<T: Plugin + Default, R>(f: impl FnOnce(&mut T) -> R) -> R { INSTANCE.with(|cell| { let mut slot = cell.borrow_mut(); let plugin = slot.get_or_insert_with(|| Box::new(T::default())); @@ -1069,6 +1075,72 @@ pub mod __rt { with::<T, _>(|p| p.render(&view, viewport, &ctx)).map(view_out).map_err(error_out) } + pub fn mcp_call<T: Plugin + crate::McpProvider + Default>( + tool: &str, + arguments: &str, + ctx: &b::invocation::Invocation, + ) -> Result<String, b::types::Error> { + let ctx = Invocation::__new(ctx); + with::<T, _>(|p| p.call(tool, arguments, &ctx)).map_err(error_out) + } + + pub fn configurations<T: Plugin + crate::RunConfigurations + Default>( + root: &str, + ) -> Vec<crate::run_configurations_bindings::exports::openidea::plugin::run_configurations::RunConfiguration> { + use crate::run_configurations_bindings::exports::openidea::plugin::run_configurations::RunConfiguration as C; + with::<T, _>(|p| p.configurations(root)) + .into_iter() + .map(|c| C { + id: c.id, + name: c.name, + program: c.program, + args: c.args, + cwd: c.cwd, + test: c.test, + framework: c.framework, + }) + .collect() + } + pub fn test_results<T: Plugin + crate::TestResults + Default>( + config: &str, + chunk: &str, + ) -> Vec<crate::test_results_bindings::exports::openidea::plugin::test_results::TestEvent> { + use crate::PluginTestEvent as E; + use crate::test_results_bindings::exports::openidea::plugin::test_results as w; + let location = |l: crate::Location| b::types::Location { + path: l.path, + range: b::types::Range { + start: b::types::Position { line: l.range.start.line, character: l.range.start.character }, + end: b::types::Position { line: l.range.end.line, character: l.range.end.character }, + }, + }; + let start = |v: crate::TestStart| w::TestStart { + id: v.id, + parent: v.parent, + name: v.name, + location: v.location.map(location), + }; + with::<T, _>(|p| p.parse(config, chunk)) + .into_iter() + .map(|event| match event { + E::SuiteStarted(v) => w::TestEvent::SuiteStarted(start(v)), + E::TestStarted(v) => w::TestEvent::TestStarted(start(v)), + E::Passed(v) => w::TestEvent::Passed(w::TestPassed { id: v.id, duration_ms: v.duration_ms }), + E::Failed(v) => w::TestEvent::Failed(w::TestFailed { + id: v.id, + duration_ms: v.duration_ms, + message: v.message, + expected: v.expected, + actual: v.actual, + location: v.location.map(location), + }), + E::Skipped(v) => w::TestEvent::Skipped(w::TestSkipped { id: v.id, reason: v.reason }), + E::Output(v) => w::TestEvent::Output(w::TestOutput { id: v.id, text: v.text }), + E::Other { kind, data } => w::TestEvent::Other(b::types::Extension { kind, data }), + }) + .collect() + } + pub fn endpoints<T: Plugin + crate::endpoints::EndpointsProvider + Default>( ctx: &b::invocation::Invocation, ) -> Result<Vec<crate::endpoints_bindings::exports::openidea::plugin::endpoints_provider::Endpoint>, b::types::Error> @@ -1211,3 +1283,132 @@ pub mod __rt { .collect() } } + +pub mod execution; +pub use execution::*; + +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod run_configurations_bindings { + wit_bindgen::generate!({ world: "configurations-provider", path: "wit", pub_export_macro: true, + export_macro_name: "__export_configurations_world", default_bindings_module: "openidea_plugin::run_configurations_bindings" }); +} +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod test_results_bindings { + wit_bindgen::generate!({ world: "test-results-provider", path: "wit", pub_export_macro: true, + export_macro_name: "__export_tests_world", default_bindings_module: "openidea_plugin::test_results_bindings", + with: { "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types }, + }); +} +#[doc(hidden)] +#[macro_export] +macro_rules! __run_configurations_exports { + ($plugin:ty, $component:ident) => { + impl $crate::run_configurations_bindings::exports::openidea::plugin::run_configurations::Guest for $component { + fn configurations(root: String) -> Vec<$crate::run_configurations_bindings::exports::openidea::plugin::run_configurations::RunConfiguration> { + $crate::__rt::configurations::<$plugin>(&root) + } + } + $crate::run_configurations_bindings::__export_configurations_world!($component with_types_in $crate::run_configurations_bindings); + }; +} +#[doc(hidden)] +#[macro_export] +macro_rules! __test_results_exports { + ($plugin:ty, $component:ident) => { + impl $crate::test_results_bindings::exports::openidea::plugin::test_results::Guest for $component { + fn parse(config: String, chunk: String) -> Vec<$crate::test_results_bindings::exports::openidea::plugin::test_results::TestEvent> { + $crate::__rt::test_results::<$plugin>(&config, &chunk) + } + } + $crate::test_results_bindings::__export_tests_world!($component with_types_in $crate::test_results_bindings); + }; +} + +pub mod language_features; + +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod language_features_bindings { + wit_bindgen::generate!({world: "editor-language-features", path: "wit", pub_export_macro: true, + export_macro_name: "__export_language_features_world", default_bindings_module: "openidea_plugin::language_features_bindings", + with: { "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types }, + }); +} + +#[doc(hidden)] +#[macro_export] +macro_rules! __language_features_exports { + ($plugin:ty, $component:ident) => { + impl $crate::language_features_bindings::exports::openidea::plugin::language_features::Guest for $component { +fn format(doc: $crate::language_features_bindings::exports::openidea::plugin::language_features::Document, range: Option<$crate::bindings::openidea::plugin::types::Range>, options: $crate::language_features_bindings::exports::openidea::plugin::language_features::FormatOptions) -> Result<Vec<$crate::bindings::openidea::plugin::types::TextEdit>, $crate::bindings::openidea::plugin::types::Error> { $crate::language_features::wasm::format::<$plugin>(doc, range, options) } +fn signature_help(doc: $crate::language_features_bindings::exports::openidea::plugin::language_features::Document, at: $crate::bindings::openidea::plugin::types::Position) -> Option<$crate::language_features_bindings::exports::openidea::plugin::language_features::SignatureHelpResult> { $crate::language_features::wasm::signature_help::<$plugin>(doc, at) } +fn document_highlights(doc: $crate::language_features_bindings::exports::openidea::plugin::language_features::Document, at: $crate::bindings::openidea::plugin::types::Position) -> Vec<$crate::language_features_bindings::exports::openidea::plugin::language_features::Highlight> { $crate::language_features::wasm::document_highlights::<$plugin>(doc, at) } +fn document_links(doc: $crate::language_features_bindings::exports::openidea::plugin::language_features::Document) -> Vec<$crate::language_features_bindings::exports::openidea::plugin::language_features::DocumentLink> { $crate::language_features::wasm::document_links::<$plugin>(doc) } +fn code_lenses(doc: $crate::language_features_bindings::exports::openidea::plugin::language_features::Document) -> Vec<$crate::language_features_bindings::exports::openidea::plugin::language_features::CodeLens> { $crate::language_features::wasm::code_lenses::<$plugin>(doc) } +fn selection_ranges(doc: $crate::language_features_bindings::exports::openidea::plugin::language_features::Document, at: $crate::bindings::openidea::plugin::types::Position) -> Vec<$crate::bindings::openidea::plugin::types::Range> { $crate::language_features::wasm::selection_ranges::<$plugin>(doc, at) } +fn folding(doc: $crate::language_features_bindings::exports::openidea::plugin::language_features::Document) -> Vec<$crate::language_features_bindings::exports::openidea::plugin::language_features::FoldRange> { $crate::language_features::wasm::folding::<$plugin>(doc) } +fn colors(doc: $crate::language_features_bindings::exports::openidea::plugin::language_features::Document) -> Vec<$crate::language_features_bindings::exports::openidea::plugin::language_features::ColorInfo> { $crate::language_features::wasm::colors::<$plugin>(doc) } +fn color_presentations(doc: $crate::language_features_bindings::exports::openidea::plugin::language_features::Document, color: $crate::language_features_bindings::exports::openidea::plugin::language_features::ColorInfo) -> Vec<String> { $crate::language_features::wasm::color_presentations::<$plugin>(doc, color) } +fn document_symbols(doc: $crate::language_features_bindings::exports::openidea::plugin::language_features::Document) -> Vec<$crate::language_features_bindings::exports::openidea::plugin::language_features::DocumentSymbol> { $crate::language_features::wasm::document_symbols::<$plugin>(doc) } +fn implementation(doc: $crate::language_features_bindings::exports::openidea::plugin::language_features::Document, at: $crate::bindings::openidea::plugin::types::Position) -> Vec<$crate::bindings::openidea::plugin::types::Location> { $crate::language_features::wasm::implementation::<$plugin>(doc, at) } +fn type_definition(doc: $crate::language_features_bindings::exports::openidea::plugin::language_features::Document, at: $crate::bindings::openidea::plugin::types::Position) -> Vec<$crate::bindings::openidea::plugin::types::Location> { $crate::language_features::wasm::type_definition::<$plugin>(doc, at) } +fn gutter_actions(doc: $crate::language_features_bindings::exports::openidea::plugin::language_features::Document) -> Vec<$crate::language_features_bindings::exports::openidea::plugin::language_features::GutterAction> { $crate::language_features::wasm::gutter_actions::<$plugin>(doc) } + } + $crate::language_features_bindings::__export_language_features_world!($component with_types_in $crate::language_features_bindings); + }; +} + +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod injections_bindings { + wit_bindgen::generate!({world: "language-injections", path: "wit", pub_export_macro: true, + export_macro_name: "__export_injections_world", default_bindings_module: "openidea_plugin::injections_bindings", + with: { "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types }, + }); +} + +#[doc(hidden)] +#[macro_export] +macro_rules! __injections_exports { + ($plugin:ty, $component:ident) => { + impl $crate::injections_bindings::exports::openidea::plugin::injections::Guest for $component { +fn injections(doc: $crate::injections_bindings::exports::openidea::plugin::injections::Document) -> Vec<$crate::injections_bindings::exports::openidea::plugin::injections::Injection> { $crate::language_features::wasm::injections::<$plugin>(doc) } + } + $crate::injections_bindings::__export_injections_world!($component with_types_in $crate::injections_bindings); + }; +} + +/// Embedded-language range providers (API 1.4). +pub mod injections { + pub use crate::language_features::{Document, Injection, Injections}; +} + +/// Implements tools declared in contributes.mcpTools. Arguments/results are JSON/text. +pub trait McpProvider { + fn call(&mut self, tool: &str, arguments: &str, ctx: &Invocation) -> Result<String, Error>; +} +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod mcp_bindings { + wit_bindgen::generate!({ world: "mcp-tools", path: "wit", pub_export_macro: true, + export_macro_name: "__export_mcp_world", default_bindings_module: "openidea_plugin::mcp_bindings", + with: { + "openidea:plugin/types@1.4.0": crate::bindings::openidea::plugin::types, + "openidea:plugin/invocation@1.4.0": crate::bindings::openidea::plugin::invocation, + }, + }); +} +#[doc(hidden)] +#[macro_export] +macro_rules! __mcp_exports { + ($plugin:ty, $component:ident) => { + impl $crate::mcp_bindings::exports::openidea::plugin::mcp_provider::Guest for $component { + fn call(tool: String, arguments: String, ctx: &$crate::bindings::openidea::plugin::invocation::Invocation) -> Result<String, $crate::bindings::openidea::plugin::types::Error> { + $crate::__rt::mcp_call::<$plugin>(&tool, &arguments, ctx) + } + } + $crate::mcp_bindings::__export_mcp_world!($component with_types_in $crate::mcp_bindings); + }; +} diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index 46edd3292..bb02de632 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -36,6 +36,10 @@ pub struct MockTask { pub ended: bool, } +type CommandHandler = dyn Fn(Option<&str>) -> Result<Option<String>, Error>; + +type SearchHandler = Box<dyn Fn(&TextQuery, u32) -> Result<TextSearch, Error>>; + type NetHandler = Box<dyn Fn(&HttpRequest) -> Result<HttpResponse, Error>>; type ProcessHandler = Box<dyn Fn(&[String], Option<&str>) -> Result<ProcessOutput, Error>>; @@ -94,6 +98,13 @@ struct State { action_notifications: Vec<ActionNotification>, logs: Vec<(LogLevel, String)>, status: BTreeMap<String, StatusItem>, + window_status: BTreeMap<String, StatusItem>, + ipc: BTreeMap<u64, VecDeque<u8>>, + next_ipc: u64, + loopbacks: BTreeSet<u64>, + next_loopback: u64, + output: BTreeMap<String, String>, + revealed_output: Vec<String>, context: BTreeMap<String, ContextValue>, storage: BTreeMap<String, String>, subscriptions: BTreeSet<EventKind>, @@ -112,6 +123,19 @@ struct State { diagnostics: BTreeMap<String, Vec<Diagnostic>>, provider_diagnostics: BTreeMap<String, Vec<Diagnostic>>, secrets: BTreeMap<String, String>, + declared_secrets: BTreeSet<String>, + commands: BTreeMap<String, Rc<CommandHandler>>, + command_depth: usize, + owned_commands: BTreeSet<String>, + search: Option<SearchHandler>, + commits: Vec<CommitInfo>, + blame: BTreeMap<String, Vec<BlameLine>>, + patch: Option<String>, + restricted: bool, + decorations: BTreeMap<(String, String), Vec<Decoration>>, + file_decorations: BTreeMap<(String, String), FileDecoration>, + revealed_ranges: Vec<Range>, + loopback_queries: VecDeque<Option<String>>, net: Option<NetHandler>, requests: Vec<HttpRequest>, processes: BTreeMap<String, ProcessHandler>, @@ -122,6 +146,17 @@ struct State { refreshed: Vec<String>, view_messages: Vec<(String, String)>, terminals: Vec<MockTerminal>, + terminal_sessions: BTreeMap<String, (String, crate::host::Command, String)>, + terminal_writes: Vec<(String, String)>, + content_tabs: BTreeMap<String, crate::ContentTab>, + content_replies: Vec<(String, crate::ContentTabReply)>, + database_sources: BTreeMap<(String, String), PluginDataSource>, + database_credentials: BTreeSet<(String, String)>, + database_connections: Vec<(String, String)>, + mcp_stdio: Option<Vec<String>>, + run_targets: BTreeMap<(String, String), crate::PluginRunTarget>, + run_configurations: BTreeMap<String, crate::PluginRunConfiguration>, + configuration_runs: Vec<(String, String, Vec<String>, bool)>, log_tabs: Vec<MockLogTab>, locations: Vec<(String, u32, u32)>, settings: BTreeMap<String, String>, @@ -158,6 +193,8 @@ pub struct MockHost { /// Every capability a plugin can hold, except `process:`/`net:`/`fs.read:`. const ALL: &[&str] = &[ + "ide.commands", + "database.sources", "project.read", "editor.read", "editor.write", @@ -170,6 +207,41 @@ const ALL: &[&str] = &[ ]; impl MockHost { + /// Registers one of this plugin's declared commands, callable without ide.commands. + pub fn on_own_command(&self, id: &str, handler: impl Fn(Option<&str>) -> Result<Option<String>, Error> + 'static) { + let stable = format!("plugin.mock.{id}"); + self.on_command(&stable, handler); + self.state.borrow_mut().owned_commands.insert(stable); + } + + /// Supplies search responses without duplicating the IDE search engine. + pub fn on_search(&self, handler: impl Fn(&TextQuery, u32) -> Result<TextSearch, Error> + 'static) { + self.state.borrow_mut().search = Some(Box::new(handler)); + } + /// Supplies read-only repository data. + pub fn set_vcs_reads(&self, commits: Vec<CommitInfo>, path: &str, blame: Vec<BlameLine>, patch: Option<String>) { + let mut s = self.state.borrow_mut(); + s.commits = commits; + s.blame.insert(path.into(), blame); + s.patch = patch; + } + /// Simulates Restricted Mode for new VCS read APIs. + pub fn set_restricted(&self, restricted: bool) { + self.state.borrow_mut().restricted = restricted; + } + /// Editor decoration snapshots keyed by (set id, absolute path). + pub fn decorations(&self) -> BTreeMap<(String, String), Vec<Decoration>> { + self.state.borrow().decorations.clone() + } + /// Tree decoration snapshots keyed by (set id, absolute path). + pub fn file_decorations(&self) -> BTreeMap<(String, String), FileDecoration> { + self.state.borrow().file_decorations.clone() + } + /// Ranges requested to scroll into view. + pub fn revealed_ranges(&self) -> Vec<Range> { + self.state.borrow().revealed_ranges.clone() + } + /// Installs a mock granting the usual capabilities, with project `/project`. pub fn install() -> Self { let state = Rc::new(RefCell::new(State { @@ -274,7 +346,8 @@ impl MockHost { } pub fn status_items(&self) -> BTreeMap<String, StatusItem> { - self.state.borrow().status.clone() + let s = self.state.borrow(); + s.status.iter().chain(&s.window_status).map(|(k, v)| (k.clone(), v.clone())).collect() } pub fn context_keys(&self) -> BTreeMap<String, ContextValue> { @@ -372,6 +445,16 @@ impl MockHost { self.state.borrow().diagnostics.clone() } + /// Snapshot supplied to optional language-feature and injection traits in native tests. + pub fn provider_document(&self) -> Option<crate::providers::Document> { + self.state.borrow().editor.as_ref().map(|e| crate::providers::Document { + path: e.path.clone(), + language: e.language.clone(), + version: e.version, + text: e.text.clone(), + }) + } + /// Runs a diagnostics provider against the current mock editor snapshot. /// This explicit call replaces the real host's debounce; it needs no capability. pub fn refresh_diagnostics(&self, provider: &mut impl crate::diagnostics::DiagnosticsProvider) { @@ -411,6 +494,35 @@ impl MockHost { self.state.borrow().locations.clone() } + /// Registered targets of this mock plugin, scoped by project root. + pub fn run_targets(&self) -> BTreeMap<(String, String), crate::PluginRunTarget> { + self.state.borrow().run_targets.clone() + } + /// Configurations available to start_run in native tests. + /// Run launches recorded as (operation id, configuration id, appended args, debug). + pub fn configuration_runs(&self) -> Vec<(String, String, Vec<String>, bool)> { + self.state.borrow().configuration_runs.clone() + } + /// Configures only argv discovery; the mock has no token or URL. + pub fn set_mcp_stdio_command(&self, command: Option<Vec<String>>) { + self.state.borrow_mut().mcp_stdio = command; + } + pub fn set_run_configurations(&self, configs: Vec<crate::PluginRunConfiguration>) { + self.state.borrow_mut().run_configurations = configs.into_iter().map(|c| (c.id.clone(), c)).collect(); + } + /// Text written through write_terminal. + pub fn terminal_writes(&self) -> Vec<(String, String)> { + self.state.borrow().terminal_writes.clone() + } + /// Simulates exit, releasing the key for another operation. + pub fn finish_terminal(&self, key: &str) -> Option<crate::Event> { + let (id, _, _) = self.state.borrow_mut().terminal_sessions.remove(key)?; + Some(crate::Event::Other { + kind: "terminal-exited".into(), + data: serde_json::to_string(&crate::TerminalExited { id, key: key.into(), status: Some(0), closed: false }) + .unwrap(), + }) + } /// Terminals the plugin opened. pub fn terminals(&self) -> Vec<MockTerminal> { self.state.borrow().terminals.clone() @@ -431,8 +543,29 @@ impl MockHost { self.state.borrow_mut().locale = Some(tag.into()); } + /// Registers the result of a stable action or plugin command. + pub fn on_command(&self, id: &str, handler: impl Fn(Option<&str>) -> Result<Option<String>, Error> + 'static) { + let mut state = self.state.borrow_mut(); + state.owned_commands.remove(id); + state.commands.insert(id.into(), Rc::new(handler)); + } + /// Queues the raw browser query returned by the next loopback wait. + pub fn queue_loopback_query(&self, query: Option<&str>) { + self.state.borrow_mut().loopback_queries.push_back(query.map(str::to_owned)); + } + + /// Declares a secret the plugin is allowed to store (the mock has no manifest). + pub fn declare_secret(&self, name: &str) { + self.state.borrow_mut().declared_secrets.insert(name.into()); + } + /// The plugin's read-only output buffers. + pub fn output(&self) -> BTreeMap<String, String> { + self.state.borrow().output.clone() + } + /// A secret the user stored. pub fn set_secret(&self, name: &str, value: &str) { + self.state.borrow_mut().declared_secrets.insert(name.into()); self.state.borrow_mut().secrets.insert(name.into(), value.into()); } @@ -663,6 +796,85 @@ pub mod current { with(|s| s.status.insert(id.to_owned(), item.clone())); } + pub fn set_window_status_item(id: &str, item: &StatusItem) -> Result<(), Error> { + with(|s| { + s.window_status.insert(id.into(), item.clone()); + }); + Ok(()) + } + pub fn remove_window_status_item(id: &str) -> Result<(), Error> { + with(|s| { + s.window_status.remove(id); + }); + Ok(()) + } + pub fn execute_command(id: &str, arg: Option<&str>) -> Result<Option<String>, Error> { + let handler = with(|s| { + if !s.owned_commands.contains(id) { + require(s, "ide.commands")?; + } + if s.command_depth >= 8 { + return Err(Error::Invalid("command nesting limit (8)".into())); + } + let handler = s.commands.get(id).cloned().ok_or_else(|| Error::NotFound(id.into()))?; + s.command_depth += 1; + Ok(handler) + })?; + let result = handler(arg); + with(|s| s.command_depth -= 1); + result + } + pub struct ConnectionRes { + id: u64, + owner: Rc<RefCell<State>>, + } + impl Drop for ConnectionRes { + fn drop(&mut self) { + self.owner.borrow_mut().ipc.remove(&self.id); + } + } + pub fn ipc_connect(name: &str) -> Result<ConnectionRes, Error> { + with(|s| { + let cap = format!("ipc:{name}"); + let granted = s.capabilities.iter().any(|g| { + g == &cap + || g.strip_prefix("ipc:").is_some_and(|pattern| { + let Some((base, tail)) = pattern.rsplit_once('/') else { return false }; + let Some((parent, file)) = name.rsplit_once('/') else { return false }; + base == parent && tail.strip_suffix('*').is_some_and(|prefix| file.starts_with(prefix)) + }) + }); + if !granted { + return Err(Error::Denied(cap)); + } + if s.ipc.len() >= 8 { + return Err(Error::Invalid("at most eight IPC connections".into())); + } + s.next_ipc += 1; + s.ipc.insert(s.next_ipc, VecDeque::new()); + let owner = CURRENT.with(|c| c.borrow().as_ref().unwrap().clone()); + Ok(ConnectionRes { id: s.next_ipc, owner }) + }) + } + pub fn ipc_write(c: &ConnectionRes, bytes: &[u8]) -> Result<(), Error> { + if bytes.len() > 1 << 20 { + return Err(Error::Invalid("at most 1 MiB".into())); + } + let mut s = c.owner.borrow_mut(); + let data = s.ipc.get_mut(&c.id).ok_or_else(|| Error::NotFound("connection".into()))?; + data.extend(bytes); + Ok(()) + } + pub fn ipc_read(c: &ConnectionRes, max: u32, _timeout: u32) -> Result<Vec<u8>, Error> { + let mut s = c.owner.borrow_mut(); + let data = s.ipc.get_mut(&c.id).ok_or_else(|| Error::NotFound("connection".into()))?; + let count = data.len().min(max as usize).min(1 << 20); + Ok(data.drain(..count).collect()) + } + pub fn ipc_close(c: &ConnectionRes) -> Result<(), Error> { + c.owner.borrow_mut().ipc.remove(&c.id); + Ok(()) + } pub fn remove_status_item(id: &str) { with(|s| s.status.remove(id)); } @@ -1098,6 +1310,204 @@ pub mod current { }) } + pub fn open_editors() -> Result<Vec<OpenEditor>, Error> { + with(|s| { + require(s, "editor.read")?; + Ok(s.editor + .iter() + .map(|e| OpenEditor { + path: e.path.clone(), + language: e.language.clone(), + dirty: e.version > 1, + active: true, + }) + .collect()) + }) + } + pub fn editor_open(path: &str, selection: Option<Range>) -> Result<(), Error> { + let text = with(|s| { + if is_private_path(path) { + return s.external.get(path).cloned().ok_or_else(|| Error::NotFound(path.into())); + } + require(s, "project.read")?; + let p = MockProject { root: s.root.clone().ok_or_else(|| Error::Invalid("no project".into()))? }; + let rel = rel_in_root(&p, path)?; + s.files.get(rel).or_else(|| s.files.get(path)).cloned().ok_or_else(|| Error::NotFound(path.into())) + })?; + with(|s| { + let path = + if path.starts_with('/') { path.into() } else { format!("{}/{path}", s.root.as_deref().unwrap()) }; + s.editor = Some(Editor { + path, + language: "text".into(), + text, + version: 1, + selections: selection.into_iter().collect(), + }); + }); + Ok(()) + } + fn valid_ranges(text: &str, ranges: &[Range]) -> bool { + ranges.iter().all(|r| matches!((crate::text::offset(text, r.start), crate::text::offset(text, r.end)), (Some(a), Some(b)) if a <= b)) + } + pub fn editor_set_selections(_: &EditorRes, ranges: &[Range]) -> Result<(), Error> { + editor(|_, e| { + if ranges.is_empty() || ranges.len() > 1000 || !valid_ranges(&e.text, ranges) { + return Err(Error::Invalid("invalid selections".into())); + } + e.selections = ranges.to_vec(); + Ok(()) + }) + } + pub fn editor_reveal(_: &EditorRes, range: Range) -> Result<(), Error> { + editor(|s, e| { + if !valid_ranges(&e.text, &[range]) { + return Err(Error::Invalid("invalid range".into())); + } + s.revealed_ranges.push(range); + Ok(()) + }) + } + pub fn project_search_text(_: &ProjectRes, query: &TextQuery, limit: u32) -> Result<TextSearch, Error> { + with(|s| { + require(s, "project.read")?; + if limit == 0 { + return Ok(TextSearch::default()); + } + let mut result = match &s.search { + Some(f) => f(query, limit.min(10_000))?, + None => TextSearch::default(), + }; + result.limit_hit |= result.matches.len() > limit.min(10_000) as usize; + result.matches.truncate(limit.min(10_000) as usize); + Ok(result) + }) + } + pub fn problems(path: Option<&str>) -> Result<Vec<FileProblems>, Error> { + with(|s| { + require(s, "project.read")?; + let p = MockProject { root: s.root.clone().ok_or_else(|| Error::Invalid("no project".into()))? }; + let wanted = path.map(|path| rel_in_root(&p, path).map(|rel| format!("{}/{rel}", p.root))).transpose()?; + Ok(s.diagnostics + .iter() + .chain(s.provider_diagnostics.iter()) + .filter(|(path, _)| wanted.as_ref().is_none_or(|p| p == *path)) + .map(|(path, diagnostics)| FileProblems { + path: path.clone(), + source: "mock".into(), + diagnostics: diagnostics.clone(), + }) + .collect()) + }) + } + fn decoration_path(s: &State, path: &str) -> Result<String, Error> { + let p = MockProject { root: s.root.clone().ok_or_else(|| Error::Invalid("no project".into()))? }; + Ok(format!("{}/{}", p.root, rel_in_root(&p, path)?)) + } + fn style(s: &str) -> bool { + matches!( + s, + "highlight" + | "search" + | "added" + | "modified" + | "removed" + | "warning" + | "error" + | "info" + | "underline" + | "strikethrough" + | "muted" + ) + } + pub fn set_decorations(path: &str, set: &str, items: &[Decoration]) -> Result<(), Error> { + with(|s| { + require(s, "project.read")?; + let path = decoration_path(s, path)?; + let mut next = s.decorations.clone(); + next.insert((set.into(), path.clone()), items.iter().filter(|d| style(&d.style)).cloned().collect()); + let names: BTreeSet<_> = next.keys().map(|(set, _)| set).collect(); + let count: usize = next.iter().filter(|((_, p), _)| *p == path).map(|(_, i)| i.len()).sum(); + if set.is_empty() || set.len() > 128 || names.len() > 50 || count > 10_000 { + return Err(Error::Invalid("decoration limit".into())); + } + s.decorations = next; + Ok(()) + }) + } + pub fn clear_decorations(set: &str) -> Result<(), Error> { + with(|s| { + require(s, "project.read")?; + s.decorations.retain(|(id, _), _| id != set); + Ok(()) + }) + } + pub fn set_file_decoration(path: &str, set: &str, item: &FileDecoration) -> Result<(), Error> { + with(|s| { + require(s, "project.read")?; + let path = decoration_path(s, path)?; + let mut next = s.file_decorations.clone(); + next.remove(&(set.into(), path.clone())); + if style(&item.style) { + next.insert((set.into(), path), item.clone()); + } + let names: BTreeSet<_> = next.keys().map(|(set, _)| set).collect(); + if set.is_empty() + || set.len() > 128 + || names.len() > 50 + || item.badge.as_ref().is_some_and(|b| b.chars().count() > 2) + { + return Err(Error::Invalid("file decoration limit".into())); + } + s.file_decorations = next; + Ok(()) + }) + } + pub fn clear_file_decorations(set: &str) -> Result<(), Error> { + with(|s| { + require(s, "project.read")?; + s.file_decorations.retain(|(id, _), _| id != set); + Ok(()) + }) + } + fn vcs_read(s: &State) -> Result<(), Error> { + require(s, "vcs.read")?; + if s.restricted { + return Err(Error::Failed("project is in Restricted Mode".into())); + } + Ok(()) + } + pub fn vcs_log(p: &ProjectRes, path: Option<&str>, limit: u32) -> Result<Vec<CommitInfo>, Error> { + with(|s| { + vcs_read(s)?; + if let Some(path) = path { + rel_in_root(p, path)?; + } + Ok(s.commits.iter().take(limit.min(1000) as usize).cloned().collect()) + }) + } + pub fn vcs_blame(p: &ProjectRes, path: &str) -> Result<Vec<BlameLine>, Error> { + with(|s| { + vcs_read(s)?; + rel_in_root(p, path)?; + Ok(s.blame.get(path).cloned().unwrap_or_default()) + }) + } + pub fn vcs_diff(p: &ProjectRes, path: Option<&str>, against: Option<&str>) -> Result<Option<String>, Error> { + with(|s| { + vcs_read(s)?; + if let Some(path) = path { + rel_in_root(p, path)?; + } + if against.is_some_and(|r| r.is_empty() || r.starts_with('-') || r.contains('\0')) { + return Err(Error::Invalid("invalid revision".into())); + } + if s.patch.as_ref().is_some_and(|p| p.len() > 4 << 20) { + return Err(Error::Failed("diff exceeds 4 MiB".into())); + } + Ok(s.patch.clone()) + }) + } pub fn vcs_status(_: &ProjectRes) -> Result<Option<RepositoryStatus>, Error> { with(|s| { require(s, "vcs.read")?; @@ -1181,8 +1591,11 @@ pub mod current { pub fn subscribe(kind: EventKind) -> Result<(), Error> { with(|s| { let cap = match kind { - EventKind::ActionPerformed => Some("events.input"), - EventKind::Scheduled => None, + EventKind::ActionPerformed | EventKind::InputModeChanged => Some("events.input"), + EventKind::Scheduled + | EventKind::RunExited + | EventKind::TerminalExited + | EventKind::ContentTabAction => None, _ => Some("events"), }; if let Some(cap) = cap { @@ -1242,8 +1655,18 @@ pub mod current { pub fn fetch(req: &HttpRequest) -> Result<HttpResponse, Error> { with(|s| { let host = net_host(&req.url).ok_or_else(|| Error::Invalid(format!("{:?} is not a URL", req.url)))?; - let cap = format!("net:{host}"); - if !s.capabilities.iter().any(|c| *c == cap || c == "net:*") { + let kind = if req.url.starts_with("http://") && !matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1") + { + "net.http" + } else { + "net" + }; + let cap = format!("{kind}:{host}"); + if !s.capabilities.iter().any(|c| { + *c == cap + || c == &format!("{kind}:*") + || c.strip_prefix(&format!("{kind}:*.")).is_some_and(|suffix| host.ends_with(&format!(".{suffix}"))) + }) { return Err(Error::Denied(cap)); } let mut sent = req.clone(); @@ -1265,6 +1688,87 @@ pub mod current { }) } + pub fn secret_set(name: &str, value: &str) -> Result<(), Error> { + with(|s| { + require(s, "secrets")?; + if !s.declared_secrets.contains(name) { + return Err(Error::NotFound(name.into())); + } + s.secrets.insert(name.into(), value.into()); + Ok(()) + }) + } + pub fn secret_delete(name: &str) -> Result<(), Error> { + with(|s| { + require(s, "secrets")?; + if !s.declared_secrets.contains(name) { + return Err(Error::NotFound(name.into())); + } + s.secrets.remove(name); + Ok(()) + }) + } + pub struct LoopbackRes { + id: u64, + owner: Rc<RefCell<State>>, + uri: String, + } + impl Drop for LoopbackRes { + fn drop(&mut self) { + self.owner.borrow_mut().loopbacks.remove(&self.id); + } + } + pub fn begin_loopback(path: &str) -> Result<LoopbackRes, Error> { + with(|s| { + if s.loopbacks.len() >= 2 { + return Err(Error::Invalid("at most two loopbacks".into())); + } + s.next_loopback += 1; + s.loopbacks.insert(s.next_loopback); + Ok(LoopbackRes { + id: s.next_loopback, + owner: CURRENT.with(|c| c.borrow().as_ref().unwrap().clone()), + uri: format!("http://127.0.0.1:{}/{}", 49152 + s.next_loopback, path.trim_start_matches('/')), + }) + }) + } + pub fn loopback_uri(l: &LoopbackRes) -> String { + l.uri.clone() + } + pub fn loopback_wait(l: &LoopbackRes, _timeout_ms: u32) -> Result<Option<String>, Error> { + if !l.owner.borrow_mut().loopbacks.remove(&l.id) { + return Err(Error::Invalid("loopback already awaited".into())); + } + Ok(l.owner.borrow_mut().loopback_queries.pop_front().flatten()) + } + pub fn output_append(channel: &str, text: &str) -> Result<(), Error> { + with(|s| { + if channel.is_empty() || channel.len() > 128 || channel.chars().any(char::is_control) { + return Err(Error::Invalid("invalid channel".into())); + } + if !s.output.contains_key(channel) && s.output.len() >= 32 { + return Err(Error::Invalid("at most 32 channels".into())); + } + let buffer = s.output.entry(channel.into()).or_default(); + buffer.push_str(text); + let mut remove = buffer.len().saturating_sub(1 << 20); + while !buffer.is_char_boundary(remove) { + remove += 1; + } + buffer.drain(..remove); + Ok(()) + }) + } + pub fn output_clear(channel: &str) -> Result<(), Error> { + output_append(channel, "")?; + with(|s| s.output.get_mut(channel).unwrap().clear()); + Ok(()) + } + pub fn output_reveal(channel: &str) -> Result<(), Error> { + output_append(channel, "")?; + with(|s| s.revealed_output.push(channel.into())); + Ok(()) + } pub fn secret_is_set(name: &str) -> bool { with(|s| s.capabilities.iter().any(|c| c == "secrets") && s.secrets.contains_key(name)) } @@ -1278,6 +1782,147 @@ pub mod current { Ok(()) } + pub fn database_upsert(project: &ProjectRes, source: &PluginDataSource) -> Result<(), Error> { + with(|s| { + require(s, "database.sources")?; + if s.restricted { + return Err(Error::Denied("project is not trusted".into())); + } + if source.id.is_empty() || source.id.len() > 128 || source.name.trim().is_empty() { + return Err(Error::Invalid("invalid data source".into())); + } + s.database_sources.insert((project.root.clone(), source.id.clone()), source.clone()); + Ok(()) + }) + } + pub fn database_sources(project: &ProjectRes) -> Result<Vec<String>, Error> { + with(|s| { + require(s, "database.sources")?; + if s.restricted { + return Err(Error::Denied("project is not trusted".into())); + } + Ok(s.database_sources.keys().filter(|(r, _)| r == &project.root).map(|(_, id)| id.clone()).collect()) + }) + } + pub fn database_remove(project: &ProjectRes, id: &str) -> Result<(), Error> { + with(|s| { + require(s, "database.sources")?; + if s.restricted { + return Err(Error::Denied("project is not trusted".into())); + } + let key = (project.root.clone(), id.into()); + s.database_sources + .remove(&key) + .ok_or_else(|| Error::NotFound("no owned data source with this id".into()))?; + s.database_credentials.remove(&key); + Ok(()) + }) + } + pub fn database_credential(project: &ProjectRes, id: &str, _password: &str) -> Result<(), Error> { + with(|s| { + require(s, "database.sources")?; + if s.restricted { + return Err(Error::Denied("project is not trusted".into())); + } + let key = (project.root.clone(), id.into()); + if !s.database_sources.contains_key(&key) { + return Err(Error::NotFound("no owned data source with this id".into())); + } + s.database_credentials.insert(key); + Ok(()) + }) + } + pub fn database_connect(project: &ProjectRes, id: &str) -> Result<(), Error> { + with(|s| { + require(s, "database.sources")?; + if s.restricted { + return Err(Error::Denied("project is not trusted".into())); + } + let key = (project.root.clone(), id.into()); + if !s.database_sources.contains_key(&key) { + return Err(Error::NotFound("no owned data source with this id".into())); + } + s.database_connections.push(key); + Ok(()) + }) + } + pub fn mcp_stdio_command() -> Option<Vec<String>> { + with(|s| s.mcp_stdio.clone()) + } + + pub fn register_run_target(project: &ProjectRes, target: &crate::PluginRunTarget) -> Result<(), Error> { + with(|s| { + require(s, &format!("process:{}", target.program))?; + if target.id.is_empty() + || target.name.is_empty() + || target + .args + .iter() + .chain(&target.workdir_args) + .chain(target.commands.iter().flat_map(|(_, a)| a)) + .any(|a| a.contains('\0')) + { + return Err(Error::Invalid("invalid run target".into())); + } + s.run_targets.insert((project.root.clone(), target.id.clone()), target.clone()); + Ok(()) + }) + } + pub fn unregister_run_target(project: &ProjectRes, id: &str) { + with(|s| { + s.run_targets.remove(&(project.root.clone(), id.into())); + }); + } + pub fn start_run(config: &str, args: &[String], debug: bool) -> Result<String, Error> { + with(|s| { + let c = s.run_configurations.get(config).ok_or_else(|| Error::NotFound(config.into()))?; + require(s, &format!("process:{}", c.program))?; + if debug { + return Err(Error::Invalid("this configuration has no debugger".into())); + } + let id = format!("mock-run-{}", s.configuration_runs.len()); + s.configuration_runs.push((id.clone(), config.into(), args.to_vec(), debug)); + Ok(id) + }) + } + pub fn open_terminal_session(key: &str, title: &str, cmd: &crate::host::Command) -> Result<String, Error> { + command_env(cmd)?; + with(|s| { + require(s, &format!("process:{}", cmd.program))?; + if key.is_empty() || key.len() > 128 || title.is_empty() || title.len() > 256 { + return Err(Error::Invalid("invalid terminal key or title".into())); + } + if let Some((id, _, _)) = s.terminal_sessions.get(key) { + return Ok(id.clone()); + } + s.next_child += 1; + let id = format!("mock-terminal-{}", s.next_child); + s.terminal_sessions.insert(key.into(), (id.clone(), cmd.clone(), title.into())); + Ok(id) + }) + } + pub fn write_terminal(key: &str, text: &str) -> Result<(), Error> { + with(|s| { + if !s.terminal_sessions.contains_key(key) { + return Err(Error::NotFound(key.into())); + } + s.terminal_writes.push((key.into(), text.into())); + Ok(()) + }) + } + pub fn open_content_tab(tab: &crate::ContentTab) -> Result<(), Error> { + if tab.key.is_empty() || tab.title.is_empty() || tab.content.contains('\0') { + return Err(Error::Invalid("invalid content tab".into())); + } + with(|s| { + s.content_tabs.insert(tab.key.clone(), tab.clone()); + }); + Ok(()) + } + pub fn reply_content_tab(request: &str, reply: &crate::ContentTabReply) -> Result<(), Error> { + with(|s| s.content_replies.push((request.into(), reply.clone()))); + Ok(()) + } pub fn open_terminal(title: &str, program: &str, args: &[String]) -> Result<(), Error> { with(|s| { require(s, &format!("process:{program}"))?; diff --git a/sdk/rust/openidea-plugin/src/types.rs b/sdk/rust/openidea-plugin/src/types.rs index 1168f7519..763bad4b6 100644 --- a/sdk/rust/openidea-plugin/src/types.rs +++ b/sdk/rust/openidea-plugin/src/types.rs @@ -67,7 +67,7 @@ pub enum NotificationLevel { } /// 0-based line and UTF-16 code unit column (as in the editor and LSP). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)] pub struct Position { pub line: u32, pub character: u32, @@ -80,7 +80,7 @@ impl Position { } /// Half-open range; a cursor is an empty range. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)] pub struct Range { pub start: Position, pub end: Position, @@ -211,6 +211,12 @@ impl From<String> for ContextValue { #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] #[non_exhaustive] pub enum EventKind { + FileClosed, + SettingsChanged, + InputModeChanged, + RunExited, + TerminalExited, + ContentTabAction, FileOpened, FileSaved, FilesChanged, @@ -225,6 +231,12 @@ impl EventKind { /// The name the IDE knows it by. pub fn name(self) -> &'static str { match self { + Self::FileClosed => "file-closed", + Self::SettingsChanged => "settings-changed", + Self::InputModeChanged => "input-mode-changed", + Self::RunExited => "run-exited", + Self::TerminalExited => "terminal-exited", + Self::ContentTabAction => "content-tab-action", Self::FileOpened => "file-opened", Self::FileSaved => "file-saved", Self::FilesChanged => "files-changed", @@ -365,7 +377,7 @@ pub struct CommitDraft { } /// A range in a file. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Location { /// Absolute path. pub path: String, @@ -517,3 +529,92 @@ impl Event { Self::Other { kind, data } } } + +/// An editor in the invoking window. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OpenEditor { + pub path: String, + pub language: String, + pub dirty: bool, + pub active: bool, +} +/// Find in Files options. Include and exclude are project-relative globs. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TextQuery { + pub pattern: String, + pub regex: bool, + pub case_sensitive: bool, + pub whole_word: bool, + pub include: Vec<String>, + pub exclude: Vec<String>, +} +/// A text search match with zero-based UTF-16 coordinates. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TextMatch { + pub location: Location, + pub line: String, +} +/// Bounded text search result. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TextSearch { + pub matches: Vec<TextMatch>, + pub limit_hit: bool, +} +/// A file's Problems view diagnostics from one source. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FileProblems { + pub path: String, + pub source: String, + pub diagnostics: Vec<Diagnostic>, +} +/// An editor annotation. Styles are fixed theme-token names; unknown names are ignored. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Decoration { + pub range: Range, + pub style: String, + pub hover: Option<String>, + pub after: Option<String>, +} +/// A project-tree annotation. Badge is at most two Unicode characters. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FileDecoration { + pub badge: Option<String>, + pub style: String, + pub tooltip: Option<String>, +} +/// Repository history metadata. Date is Unix seconds. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommitInfo { + pub id: String, + pub short_id: String, + pub author: String, + pub author_email: String, + pub date: i64, + pub subject: String, + pub body: String, + pub parents: Vec<String>, +} +/// Attribution for a zero-based document line. Date is Unix seconds. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BlameLine { + pub line: u32, + pub commit: String, + pub author: String, + pub date: i64, + pub summary: String, +} + +/// Non-secret settings of a plugin-owned project database source (API 1.4). +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginDataSource { + pub id: String, + pub name: String, + pub driver: String, + pub host: Option<String>, + pub port: Option<u16>, + pub database: Option<String>, + pub user: Option<String>, + pub url: Option<String>, + pub options: Vec<(String, String)>, +} diff --git a/sdk/rust/openidea-plugin/tests/api14.rs b/sdk/rust/openidea-plugin/tests/api14.rs new file mode 100644 index 000000000..26e485a3c --- /dev/null +++ b/sdk/rust/openidea-plugin/tests/api14.rs @@ -0,0 +1,62 @@ +use openidea_plugin::{Error, HttpRequest, StatusItem, host, mock::MockHost}; + +#[test] +fn ipc_is_granted_bounded_and_closed_on_drop() { + let mock = MockHost::install().with_capabilities(&[]); + assert!(matches!(host::ipc_connect("/tmp/test-1"), Err(Error::Denied(_)))); + let _mock = mock.with_capabilities(&["ipc:/tmp/test-*"]); + let connection = host::ipc_connect("/tmp/test-1").unwrap(); + connection.write(b"echo").unwrap(); + assert_eq!(connection.read(4, 100).unwrap(), b"echo"); + let others: Vec<_> = (0..7).map(|_| host::ipc_connect("/tmp/test-2").unwrap()).collect(); + assert!(matches!(host::ipc_connect("/tmp/test-3"), Err(Error::Invalid(_)))); + connection.close().unwrap(); + assert!(connection.read(4, 0).is_err()); + host::ipc_connect("/tmp/test-3").unwrap(); + drop(others); +} + +#[test] +fn http_requires_its_own_grant_and_secrets_are_declaration_scoped() { + let mock = MockHost::install().with_capabilities(&["net:*.example.com", "secrets"]); + assert!( + matches!(host::fetch(&HttpRequest::get("http://api.example.com/")), Err(Error::Denied(c)) if c == "net.http:api.example.com") + ); + assert!(matches!(host::secret_set("token", "value"), Err(Error::NotFound(_)))); + mock.declare_secret("token"); + host::secret_set("token", "value").unwrap(); + assert!(host::secret_is_set("token")); + host::secret_delete("token").unwrap(); + assert!(!host::secret_is_set("token")); +} + +#[test] +fn scoped_status_commands_output_and_loopbacks_are_available_in_the_sdk() { + let mock = MockHost::install(); + let item = |text: &str| StatusItem { text: text.into(), tooltip: None, command: None }; + host::set_status_item("state", &item("global")); + host::set_window_status_item("state", &item("window")).unwrap(); + host::set_status_item("state", &item("updated")); + assert_eq!(mock.status_items()["state"].text, "window"); + host::remove_window_status_item("state").unwrap(); + assert_eq!(mock.status_items()["state"].text, "updated"); + mock.on_command("other.command", |arg| Ok(arg.map(str::to_owned))); + assert_eq!(host::execute_command("other.command", Some("result")).unwrap(), Some("result".into())); + assert!(matches!(host::execute_command("missing", None), Err(Error::NotFound(_)))); + mock.on_command("recursive", |_| host::execute_command("recursive", None)); + assert!(matches!(host::execute_command("recursive", None), Err(Error::Invalid(_)))); + host::output_append("build", &"😀".repeat(300_000)).unwrap(); + assert_eq!(mock.output()["build"].len(), 1 << 20); + host::output_clear("build").unwrap(); + assert!(mock.output()["build"].is_empty()); + host::output_reveal("build").unwrap(); + let listener = host::begin_loopback("callback").unwrap(); + assert!(listener.redirect_uri().starts_with("http://127.0.0.1:")); + let other = host::begin_loopback("second").unwrap(); + assert!(matches!(host::begin_loopback("third"), Err(Error::Invalid(_)))); + mock.queue_loopback_query(Some("code=a%2Bb&state=test")); + assert_eq!(listener.wait(100).unwrap().as_deref(), Some("code=a%2Bb&state=test")); + assert!(listener.wait(100).is_err()); + drop(other); + host::begin_loopback("third").unwrap(); +} diff --git a/sdk/rust/openidea-plugin/tests/api14_stage2.rs b/sdk/rust/openidea-plugin/tests/api14_stage2.rs new file mode 100644 index 000000000..2241d3792 --- /dev/null +++ b/sdk/rust/openidea-plugin/tests/api14_stage2.rs @@ -0,0 +1,94 @@ +use openidea_plugin::{Decoration, Error, FileDecoration, Range, TextQuery, TextSearch, host, mock::MockHost}; + +#[test] +fn command_permission_distinguishes_own_commands() { + let mock = MockHost::install().with_capabilities(&[]); + for id in ["test.action", "plugin.other.run", "plugin.mock.other.run"] { + mock.on_command(id, |_| Ok(Some("ok".into()))); + } + mock.on_own_command("run", |_| Ok(Some("ok".into()))); + for id in ["test.action", "plugin.other.run", "plugin.mock.other.run"] { + assert!(matches!(host::execute_command(id, None), Err(Error::Denied(c)) if c == "ide.commands")); + } + assert_eq!(host::execute_command("plugin.mock.run", None).unwrap().as_deref(), Some("ok")); + let _mock = mock.with_capabilities(&["ide.commands"]); + assert!(host::execute_command("test.action", None).is_ok()); + assert!(host::execute_command("plugin.other.run", None).is_ok()); +} + +#[test] +fn workbench_wrappers_enforce_grants_and_editor_ranges_need_no_grant() { + let mock = MockHost::install().with_capabilities(&[]); + mock.open_editor("/project/a.txt", "text", "😀text", &[]); + mock.add_file("a.txt", "disk"); + let ctx = mock.invocation(); + let editor = ctx.editor().unwrap(); + editor + .set_selections(&[Range::new(openidea_plugin::Position::new(0, 2), openidea_plugin::Position::new(0, 4))]) + .unwrap(); + editor.reveal(Range::default()).unwrap(); + assert_eq!(mock.revealed_ranges(), [Range::default()]); + let project = ctx.project().unwrap(); + assert!(matches!(host::open_editors(), Err(Error::Denied(c)) if c == "editor.read")); + assert!(host::open_editor("a.txt", None).is_err()); + assert!(project.search_text(&TextQuery::default(), 1).is_err()); + assert!(host::problems(None).is_err()); + assert!(host::set_decorations("a.txt", "set", &[]).is_err()); + assert!(host::clear_decorations("set").is_err()); + assert!(host::clear_file_decorations("set").is_err()); + assert!(project.vcs_log(None, 1).is_err()); + assert!(project.vcs_blame("a.txt").is_err()); + assert!(project.vcs_diff(None, None).is_err()); + let _mock = mock.with_capabilities(&["project.read", "editor.read", "vcs.read"]); + assert_eq!(host::open_editors().unwrap()[0].language, "text"); + host::open_editor("a.txt", Some(Range::default())).unwrap(); + assert!(host::open_editor("../outside", None).is_err()); + assert!(host::problems(None).unwrap().is_empty()); +} + +#[test] +fn decorations_replace_clear_validate_styles_badges_paths_and_limits() { + let mock = MockHost::install(); + let item = |style: &str| Decoration { + range: Range::default(), + style: style.into(), + hover: None, + after: Some("text".into()), + }; + host::set_decorations("a.txt", "test", &[item("highlight"), item("unknown")]).unwrap(); + assert_eq!(mock.decorations().values().next().unwrap().len(), 1); + assert!(host::set_decorations("../outside", "test", &[]).is_err()); + host::set_decorations("a.txt", "test", &[]).unwrap(); + assert!(mock.decorations().values().next().unwrap().is_empty()); + host::clear_decorations("test").unwrap(); + assert!(mock.decorations().is_empty()); + let file = |badge: &str| FileDecoration { badge: Some(badge.into()), style: "added".into(), tooltip: None }; + assert!(host::set_file_decoration("a.txt", "test", &file("abc")).is_err()); + host::set_file_decoration("a.txt", "test", &file("😀a")).unwrap(); + host::clear_file_decorations("test").unwrap(); + assert!(mock.file_decorations().is_empty()); + for i in 0..50 { + host::set_decorations("a.txt", &format!("set{i}"), &[]).unwrap(); + } + assert!(host::set_decorations("a.txt", "overflow", &[]).is_err()); +} + +#[test] +fn search_limits_and_vcs_trust_are_modelled() { + let mock = MockHost::install(); + mock.on_search(|query, limit| { + assert_eq!(query.pattern, "needle"); + assert_eq!(limit, 10_000); + Ok(TextSearch::default()) + }); + let ctx = mock.invocation(); + let project = ctx.project().unwrap(); + project.search_text(&TextQuery { pattern: "needle".into(), ..Default::default() }, u32::MAX).unwrap(); + mock.set_vcs_reads(Vec::new(), "a.txt", Vec::new(), Some("patch".into())); + assert_eq!(project.vcs_diff(None, None).unwrap().as_deref(), Some("patch")); + assert!(project.vcs_diff(None, Some("--output=x")).is_err()); + mock.set_restricted(true); + assert!(project.vcs_log(None, 1).is_err()); + assert!(project.vcs_blame("a.txt").is_err()); + assert!(project.vcs_diff(None, None).is_err()); +} diff --git a/sdk/rust/openidea-plugin/tests/api14_stage3.rs b/sdk/rust/openidea-plugin/tests/api14_stage3.rs new file mode 100644 index 000000000..86f011b73 --- /dev/null +++ b/sdk/rust/openidea-plugin/tests/api14_stage3.rs @@ -0,0 +1,55 @@ +use openidea_plugin::{host, mock::MockHost, *}; +#[test] +fn keyed_terminals_require_program_grants_and_reuse_only_live_operations() { + let mock = MockHost::install(); + let cmd = host::Command::new("runner"); + assert!(matches!(host::open_terminal_session("key", "Terminal", &cmd), Err(Error::Denied(_)))); + let mock = mock.with_capabilities(&["process:runner"]); + let first = host::open_terminal_session("key", "Terminal", &cmd).unwrap(); + assert_eq!(host::open_terminal_session("key", "Again", &cmd).unwrap(), first); + host::write_terminal("key", "hello\n").unwrap(); + assert_eq!(mock.terminal_writes(), [("key".into(), "hello\n".into())]); + let event = mock.finish_terminal("key").unwrap(); + assert_eq!(event.terminal_exited().unwrap().id, first); + assert_ne!(host::open_terminal_session("key", "Next", &cmd).unwrap(), first); + let _ = mock.finish_terminal("key"); + assert!(matches!(host::write_terminal("key", "late"), Err(Error::NotFound(_)))); + assert!(host::open_terminal_session("key", "Next", &cmd.env("VALUE", "first").env("VALUE", "collision")).is_err()); +} +#[test] +fn owned_configurations_require_grants_and_reject_debug_without_a_debugger() { + let mock = MockHost::install(); + mock.set_run_configurations(vec![PluginRunConfiguration { + id: "tests".into(), + program: "runner".into(), + ..Default::default() + }]); + assert!(matches!(host::start_run("tests", &[], false), Err(Error::Denied(_)))); + let _mock = mock.with_capabilities(&["process:runner"]); + assert!(host::start_run("tests", &["extra".into()], false).unwrap().starts_with("mock-run-")); + assert!(matches!(host::start_run("tests", &[], true), Err(Error::Invalid(_)))); + assert!(matches!(host::start_run("foreign", &[], false), Err(Error::NotFound(_)))); +} + +#[test] +fn run_targets_require_a_program_grant_and_are_project_scoped() { + let mock = MockHost::install(); + let project = mock.invocation().project().unwrap(); + let target = PluginRunTarget { + id: "ddev".into(), + name: "DDEV".into(), + program: "ddev".into(), + args: vec!["exec".into()], + ..Default::default() + }; + assert!(matches!(host::register_run_target(&project, &target), Err(Error::Denied(_)))); + let mock = mock.with_capabilities(&["process:ddev"]); + host::register_run_target(&project, &target).unwrap(); + assert_eq!(mock.run_targets().len(), 1); + host::register_run_target(&project, &target).unwrap(); + assert_eq!(mock.run_targets().len(), 1); + host::unregister_run_target(&project, "missing"); + assert_eq!(mock.run_targets().len(), 1); + host::unregister_run_target(&project, "ddev"); + assert!(mock.run_targets().is_empty()); +} diff --git a/sdk/rust/openidea-plugin/tests/api14_stage4.rs b/sdk/rust/openidea-plugin/tests/api14_stage4.rs new file mode 100644 index 000000000..418e2972f --- /dev/null +++ b/sdk/rust/openidea-plugin/tests/api14_stage4.rs @@ -0,0 +1,39 @@ +use openidea_plugin::{Position, Range, language_features::*, mock::MockHost}; +struct Features; +impl LanguageFeatures for Features { + fn signature_help(&mut self, d: &Document, _: Position) -> Option<SignatureHelp> { + Some(SignatureHelp { + signatures: vec![Signature { + label: d.text.clone(), + documentation: None, + parameters: vec![Parameter { + label: ParameterLabel::Offsets((0, 2)), + documentation: Some(d.language.clone()), + }], + }], + active_signature: 0, + active_parameter: 0, + }) + } +} +impl Injections for Features { + fn injections(&mut self, _: &Document) -> Vec<Injection> { + vec![Injection { + range: Range { start: Position::default(), end: Position { line: 0, character: 2 } }, + language: "sql".into(), + }] + } +} +#[test] +fn optional_traits_use_the_unsaved_mock_snapshot_without_extra_capabilities() { + let host = MockHost::install().with_capabilities(&[]); + host.open_editor("/project/a.txt", "text", "😀", &[]); + host.type_in_editor(" SELECT"); + let doc = host.provider_document().unwrap(); + assert_eq!(doc.version, 2); + let mut provider = Features; + assert_eq!(provider.signature_help(&doc, Position::default()).unwrap().signatures[0].label, "😀 SELECT"); + assert_eq!(provider.injections(&doc)[0].range.end.character, 2); + assert!(provider.document_links(&doc).is_empty()); + assert!(provider.format(&doc, None, FormatOptions { tab_size: 4, insert_spaces: true }).unwrap().is_empty()); +} diff --git a/sdk/rust/openidea-plugin/tests/api14_stage5.rs b/sdk/rust/openidea-plugin/tests/api14_stage5.rs new file mode 100644 index 000000000..83a0472d6 --- /dev/null +++ b/sdk/rust/openidea-plugin/tests/api14_stage5.rs @@ -0,0 +1,34 @@ +use openidea_plugin::{Error, PluginDataSource, host, mock::MockHost}; +#[test] +fn database_wrappers_are_owned_project_scoped_and_write_only() { + let mock = MockHost::install().with_capabilities(&[]); + let p = mock.invocation().project().unwrap(); + let source = + PluginDataSource { id: "local".into(), name: "Local".into(), driver: "postgres".into(), ..Default::default() }; + assert!(matches!(p.upsert_data_source(&source), Err(Error::Denied(c)) if c == "database.sources")); + assert!(p.data_sources().is_err()); + assert!(p.remove_data_source("foreign").is_err()); + assert!(p.set_data_source_credential("local", "password").is_err()); + assert!(p.connect_data_source("local").is_err()); + let mock = mock.with_capabilities(&["database.sources"]); + p.upsert_data_source(&source).unwrap(); + p.set_data_source_credential("local", "password").unwrap(); + p.connect_data_source("local").unwrap(); + assert_eq!(p.data_sources().unwrap(), ["local"]); + assert!(matches!(p.remove_data_source("foreign"), Err(Error::NotFound(_)))); + mock.set_project_root(Some("/other")); + assert!(mock.invocation().project().unwrap().data_sources().unwrap().is_empty()); + mock.set_restricted(true); + assert!(p.data_sources().is_err()); + mock.set_restricted(false); + p.remove_data_source("local").unwrap(); + assert!(p.data_sources().unwrap().is_empty()); +} +#[test] +fn stdio_discovery_returns_only_argv_and_none_while_off() { + let mock = MockHost::install(); + assert!(host::mcp_stdio_command().is_none()); + let command = vec!["openidea".into(), "--mcp-stdio".into(), "--project".into(), "/project".into()]; + mock.set_mcp_stdio_command(Some(command.clone())); + assert_eq!(host::mcp_stdio_command(), Some(command)); +} diff --git a/sdk/rust/openidea-plugin/wit/commit.wit b/sdk/rust/openidea-plugin/wit/commit.wit index a70b889c1..91dbe0e28 100644 --- a/sdk/rust/openidea-plugin/wit/commit.wit +++ b/sdk/rust/openidea-plugin/wit/commit.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// The commit being prepared in the window the current command or event came /// from (the Commit tool window, or the Commit dialog while it is open): the diff --git a/sdk/rust/openidea-plugin/wit/database.wit b/sdk/rust/openidea-plugin/wit/database.wit new file mode 100644 index 000000000..4e77bd9c8 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/database.wit @@ -0,0 +1,34 @@ +package openidea:plugin@1.4.0; + +/// Plugin-owned project data sources. Requires database.sources and project trust. +/// Runs on the Database subsystem's host, including on remote project servers. +interface database { + use types.{error}; + use workspace.{project}; + /// Non-secret connection settings. Id is private to this plugin (1–128 bytes). + /// Drivers: sqlite, postgres, mysql, sqlServer, clickhouse, oracle. + /// SQLite uses database as its file path, or a sqlite: URL. Options are driver properties. + /// URLs and options must not contain passwords, tokens or other credentials. + record data-source { + id: string, + name: string, + driver: string, + host: option<string>, + port: option<u16>, + database: option<string>, + user: option<string>, + url: option<string>, + options: list<tuple<string, string>>, + } + /// Creates or replaces only this plugin's source; unrelated file entries are preserved. + upsert-source: func(project: borrow<project>, source: data-source) -> result<_, error>; + /// Removes an owned source and its credentials; not-found for every other id. + remove-source: func(project: borrow<project>, id: string) -> result<_, error>; + /// Lists only this plugin's private ids, never connection settings or credentials. + sources: func(project: borrow<project>) -> result<list<string>, error>; + /// Writes an owned source's password directly to the Database secure store. Never readable. + set-credential: func(project: borrow<project>, id: string, password: string) -> result<_, error>; + /// Requests the project's usual connection/introspection in the invoking window. + /// Invalid outside a command/event of that project's window. + connect: func(project: borrow<project>, id: string) -> result<_, error>; +} diff --git a/sdk/rust/openidea-plugin/wit/decorations.wit b/sdk/rust/openidea-plugin/wit/decorations.wit new file mode 100644 index 000000000..1c5a033e6 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/decorations.wit @@ -0,0 +1,22 @@ +package openidea:plugin@1.4.0; + +/// Project-scoped decorations (API 1.4). Requires project.read; paths must be inside +/// an open project. Sets belong to the plugin and disappear when it stops. +/// Styles: highlight, search, added, modified, removed, warning, error, info, +/// underline, strikethrough, muted. Unknown styles are ignored. Theme tokens only. +interface decorations { + use types.{error, range}; + /// An editor annotation; after is plain text shown after the range. + record decoration { range: range, style: string, hover: option<string>, after: option<string> } + /// A project-tree annotation; badge is at most two Unicode characters. + record file-decoration { badge: option<string>, style: string, tooltip: option<string> } + /// Replace this set for a file. At most 10,000 items/file and 50 editor sets/plugin. + /// Metadata is bounded to 4 MiB per plugin. Unknown styles are ignored. + set: func(path: string, set-id: string, items: list<decoration>) -> result<_, error>; + /// Remove this editor set from every file/project. + clear: func(set-id: string) -> result<_, error>; + /// Replace this set's tree annotation for a file; at most 50 tree sets/plugin. + set-file: func(path: string, set-id: string, item: file-decoration) -> result<_, error>; + /// Remove this tree set from every file/project. + clear-file: func(set-id: string) -> result<_, error>; +} diff --git a/sdk/rust/openidea-plugin/wit/details.wit b/sdk/rust/openidea-plugin/wit/details.wit index 9e889851a..f7342991d 100644 --- a/sdk/rust/openidea-plugin/wit/details.wit +++ b/sdk/rust/openidea-plugin/wit/details.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// What the pane beside the Services tree shows for a plugin's node. interface detail-types { diff --git a/sdk/rust/openidea-plugin/wit/diagnostics.wit b/sdk/rust/openidea-plugin/wit/diagnostics.wit index d5d62de72..499335fde 100644 --- a/sdk/rust/openidea-plugin/wit/diagnostics.wit +++ b/sdk/rust/openidea-plugin/wit/diagnostics.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Optional export (world `plugin-diagnostics`): problems in the current, /// possibly unsaved document, for the languages declared with the diff --git a/sdk/rust/openidea-plugin/wit/endpoints.wit b/sdk/rust/openidea-plugin/wit/endpoints.wit index 95302fcb0..f4a446fe0 100644 --- a/sdk/rust/openidea-plugin/wit/endpoints.wit +++ b/sdk/rust/openidea-plugin/wit/endpoints.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// HTTP endpoints found in a project (`[[contributes.endpoints]]`). interface endpoints-provider { diff --git a/sdk/rust/openidea-plugin/wit/events.wit b/sdk/rust/openidea-plugin/wit/events.wit index 8851d18a7..f52030971 100644 --- a/sdk/rust/openidea-plugin/wit/events.wit +++ b/sdk/rust/openidea-plugin/wit/events.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Workbench events, delivered in batches to `event-handler.handle`. interface events { @@ -29,6 +29,9 @@ interface events { /// Delivers `kind` events from now on, for the rest of the session (also /// across idle reclaim: an event activates the plugin again). Declare /// `activation = ["on-event:<kind>"]` to get them before first use. + /// API 1.4 also delivers `file-closed` (JSON path string), `settings-changed` + /// (JSON array of the plugin's changed setting keys), and `input-mode-changed` + /// (JSON {path, mode, sub}) through `other`. The last needs `events.input`. /// Kinds: `file-opened`, `file-saved`, `files-changed` (batched disk changes /// as `other`, with created/modified/removed path lists in JSON), `editor-focused` (the focused editor /// changed), `vcs-changed` (a repository's state changed: commit, diff --git a/sdk/rust/openidea-plugin/wit/exports.wit b/sdk/rust/openidea-plugin/wit/exports.wit index 67d7c6060..434b29ceb 100644 --- a/sdk/rust/openidea-plugin/wit/exports.wit +++ b/sdk/rust/openidea-plugin/wit/exports.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; interface lifecycle { use types.{context, error}; diff --git a/sdk/rust/openidea-plugin/wit/file-actions.wit b/sdk/rust/openidea-plugin/wit/file-actions.wit index 477809180..12b4aec17 100644 --- a/sdk/rust/openidea-plugin/wit/file-actions.wit +++ b/sdk/rust/openidea-plugin/wit/file-actions.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Optional export (world `plugin-file-actions`): quick fixes and /// refactorings that create, rename or delete files, next to the text edits diff --git a/sdk/rust/openidea-plugin/wit/fs.wit b/sdk/rust/openidea-plugin/wit/fs.wit index 7cd07fd71..c1a96cf83 100644 --- a/sdk/rust/openidea-plugin/wit/fs.wit +++ b/sdk/rust/openidea-plugin/wit/fs.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Files outside the open projects: the configuration of the program a /// plugin integrates with, a tool installed elsewhere, a licence file. diff --git a/sdk/rust/openidea-plugin/wit/host.wit b/sdk/rust/openidea-plugin/wit/host.wit index 91c3e12fc..c5ce20fbe 100644 --- a/sdk/rust/openidea-plugin/wit/host.wit +++ b/sdk/rust/openidea-plugin/wit/host.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Writes to the IDE log, tagged with the plugin id. interface log { @@ -53,6 +53,11 @@ interface ui { /// Shows or updates the plugin's status bar item `id` in every window. set-status-item: func(id: string, item: status-item); remove-status-item: func(id: string); + /// Overrides the global item in the invoking window only (API 1.4). + /// Invalid outside a command or event of a window; removed on window close or plugin stop. + set-window-status-item: func(id: string, item: status-item) -> result<_, error>; + /// Removes this window's override, falling back to the global item. + remove-window-status-item: func(id: string) -> result<_, error>; /// Sets the plugin's context key `key`, which `when` clauses test as /// `plugin.<id>.<key>`, in the window the current command or event came /// from (every window when there is none). A window's value takes @@ -104,3 +109,23 @@ interface storage { /// Keys starting with `prefix`, sorted. keys: func(prefix: string) -> result<list<string>, error>; } + +/// Executes stable IDE action ids or `plugin.<plugin-id>.<command-id>` (API 1.4). +interface command-runner { + use types.{error}; + /// Only in commands/events of a window. Returns a plugin command's value, + /// or none for IDE actions; unknown ids are not-found. Nested calls are bounded. + /// Requires high-risk `ide.commands`, except for this plugin's own commands. + execute: func(id: string, arg: option<string>) -> result<option<string>, error>; +} + +/// Per-plugin named, read-only Output tabs (API 1.4), with a 1 MiB UTF-8 ring buffer. +interface output { + use types.{error}; + /// Appends text to a channel (at most 32 channels, names 1-128 bytes). + append: func(channel: string, text: string) -> result<_, error>; + /// Clears a channel's buffer. + clear: func(channel: string) -> result<_, error>; + /// Reveals the channel in the invoking window; only commands/events of a window. + reveal: func(channel: string) -> result<_, error>; +} diff --git a/sdk/rust/openidea-plugin/wit/hover.wit b/sdk/rust/openidea-plugin/wit/hover.wit index a95c22758..5ddde792e 100644 --- a/sdk/rust/openidea-plugin/wit/hover.wit +++ b/sdk/rust/openidea-plugin/wit/hover.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Optional export (world `plugin-hover`): Markdown added to the hover of /// documents of the languages declared with the `hover` provider kind, diff --git a/sdk/rust/openidea-plugin/wit/indexing.wit b/sdk/rust/openidea-plugin/wit/indexing.wit index 4ee08d64b..227aab726 100644 --- a/sdk/rust/openidea-plugin/wit/indexing.wit +++ b/sdk/rust/openidea-plugin/wit/indexing.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Cross-file associations a plugin publishes for the files it declares under /// `[[contributes.documentIndexers]]`. The host calls `index` in cancellable diff --git a/sdk/rust/openidea-plugin/wit/injections.wit b/sdk/rust/openidea-plugin/wit/injections.wit new file mode 100644 index 000000000..5f9d7c2eb --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/injections.wit @@ -0,0 +1,12 @@ +package openidea:plugin@1.4.0; + +/// Embedded languages. This host routes plugin providers, not embedded LSP servers. +interface injections { + /// A versioned document snapshot. Coordinates are zero-based UTF-16. + record document { path: string, language: string, version: u64, text: string } + /// A host-document range containing the named language. + record injection { range: range, language: string } + use types.{range}; + /// Embedded ranges, refreshed after edits under the editor-provider budget. + injections: func(doc: document) -> list<injection>; +} diff --git a/sdk/rust/openidea-plugin/wit/inlay.wit b/sdk/rust/openidea-plugin/wit/inlay.wit index ab00b0c9b..1098869a5 100644 --- a/sdk/rust/openidea-plugin/wit/inlay.wit +++ b/sdk/rust/openidea-plugin/wit/inlay.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Optional export (world `plugin-inlay-hints`): inline hints in documents of /// the languages declared with the `inlay-hints` provider kind, including diff --git a/sdk/rust/openidea-plugin/wit/io.wit b/sdk/rust/openidea-plugin/wit/io.wit index cffd883be..b27e48d62 100644 --- a/sdk/rust/openidea-plugin/wit/io.wit +++ b/sdk/rust/openidea-plugin/wit/io.wit @@ -1,8 +1,13 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Problems in files, shown in the editor and the Problems view. interface diagnostics { use types.{error, range, location}; + /// Problems reported by a source for a file in this window's project. + record file-problems { path: string, source: string, diagnostics: list<diagnostic> } + /// Current Problems view snapshot, optionally filtered to a project file. + /// Requires project.read and an invoking window. Empty for remote projects. + problems: func(path: option<string>) -> result<list<file-problems>, error>; enum severity { error, @@ -35,7 +40,7 @@ interface diagnostics { publish: func(path: string, diagnostics: list<diagnostic>) -> result<_, error>; } -/// HTTP(S) to the hosts granted with `net:<host>`. +/// HTTPS with `net:<host>`; unencrypted HTTP with `net.http:<host>`. interface net { use types.{error}; @@ -46,7 +51,8 @@ interface net { record request { method: string, - /// `https://…`; plain `http://` only to `localhost` / `127.0.0.1`. + /// `https://…` requires `net:<host>`. Plain `http://…` requires + /// `net.http:<host>` (API 1.4), except loopback, which uses `net:<host>`. url: string, /// `{{secret:<name>}}` in a value is replaced by the plugin's secret /// `name` on the way out (requires `secrets`); the plugin never sees it. @@ -72,6 +78,11 @@ interface net { /// OS keychain). Plugins only use them through `{{secret:<name>}}` in /// `net` requests. Requires `secrets`. interface secrets { + use types.{error}; + /// Stores a declared secret without ever exposing it back to plugin code (API 1.4). + set: func(name: string, value: string) -> result<_, error>; + /// Deletes a declared secret (API 1.4). + delete: func(name: string) -> result<_, error>; is-set: func(name: string) -> bool; } @@ -144,3 +155,38 @@ interface process { /// also ends its processes. start: func(cmd: command, cwd: option<borrow<project>>) -> result<child, error>; } + +/// Local sockets on the project machine, including the server for remote projects. +/// Requires `ipc:<path>`; host-expanded `~/`, `$TMPDIR/`, `$XDG_RUNTIME_DIR/` +/// and Windows `\\.\pipe\name` are accepted. API 1.4. +interface ipc { + use types.{error}; + /// A byte stream. At most eight per plugin; dropping closes it, as does stopping the plugin. + resource connection { + /// Writes up to 1 MiB, subject to the current call's budget and cancellation. + write: func(bytes: list<u8>) -> result<_, error>; + /// Reads up to 1 MiB; empty on EOF or timeout. Zero timeout only checks. + read: func(max-bytes: u32, timeout-ms: u32) -> result<list<u8>, error>; + /// Closes the stream; subsequent reads and writes fail. + close: func() -> result<_, error>; + } + /// Connects to an absolute Unix socket or Windows named pipe; never TCP. + connect: func(name: string) -> result<connection, error>; +} + +/// OAuth browser redirects received on the desktop, even for remote projects (API 1.4). +/// No capability needed; only commands/events of a window may use this interface. +interface auth { + use types.{error}; + /// A one-shot IPv4 loopback listener. At most two open per plugin. + resource loopback { + /// `http://127.0.0.1:<random-port>/<path>` for the authorization URL. + redirect-uri: func() -> string; + /// Returns the first matching request's raw query string, or none on timeout. + /// Waiting is excluded from the call budget. The listener serves a short + /// completion page and closes; resource drop and plugin stop also close it. + wait: func(timeout-ms: u32) -> result<option<string>, error>; + } + /// Binds 127.0.0.1 only. `path` has no query or fragment. + begin-loopback: func(path: string) -> result<loopback, error>; +} diff --git a/sdk/rust/openidea-plugin/wit/language-features.wit b/sdk/rust/openidea-plugin/wit/language-features.wit new file mode 100644 index 000000000..c47c4b621 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/language-features.wit @@ -0,0 +1,114 @@ +package openidea:plugin@1.4.0; + +/// Additional language features, called only for declared API 1.4 provider kinds. +/// Editor calls have a 50 ms soft / 500 ms hard budget; formatting has 5 seconds. +interface language-features { + use types.{error, position, range, text-edit, location}; + /// A versioned document snapshot. Coordinates are zero-based UTF-16. + record document { + path: string, + language: string, + version: u64, + text: string, + } + /// A parameter label, either literal text or UTF-16 offsets within the signature label. + variant parameter-label { text(string), offsets(tuple<u32, u32>) } + /// Indentation options selected by the editor. + record format-options { + tab-size: u32, + insert-spaces: bool, + } + /// One parameter, with optional Markdown documentation. + record parameter { + label: parameter-label, + documentation: option<string>, + } + /// One callable signature; documentation is Markdown. + record signature { + label: string, + documentation: option<string>, + parameters: list<parameter>, + } + /// Callable signatures and zero-based active signature/parameter indices. + record signature-help-result { + signatures: list<signature>, + active-signature: u32, + active-parameter: u32, + } + /// An occurrence of the symbol. Kind: text, read or write; unknown is text. + record highlight { + range: range, + kind: string, + } + /// An absolute path, project-relative path or HTTP(S) URL to follow on click. + record document-link { + range: range, + target: string, + } + /// An inline action. Command must be this plugin's declared command id. + record code-lens { + range: range, + title: string, + command: string, + argument: option<string>, + } + /// A multi-line fold. Kinds: region, comment or imports; unknown is region. + record fold-range { + start-line: u32, + end-line: u32, + kind: string, + } + /// A color literal. All channels are finite floats in the inclusive range 0–1. + record color-info { + range: range, + red: f32, + green: f32, + blue: f32, + alpha: f32, + } + /// An outline node. Parent indexes this list, precedes the child and contains it. + /// Kinds are LSP-style names (e.g. class, method, enum-member); unknown is neutral. + record document-symbol { + name: string, + detail: string, + kind: string, + range: range, + selection-range: range, + parent: option<u32>, + } + /// A line action for this plugin's own declared command. Icons follow line-marker + /// aliases, registered icon names or declared manifest marker SVG paths. + record gutter-action { + line: u32, + icon: option<string>, + tooltip: string, + command: string, + argument: option<string>, + } + /// Format the whole document or the selected range. + format: func(doc: document, range: option<range>, options: format-options) -> result<list<text-edit>, error>; + /// Signature help when the language server has no signatures. + signature-help: func(doc: document, at: position) -> option<signature-help-result>; + /// Occurrences at the caret, appended to language-server highlights. + document-highlights: func(doc: document, at: position) -> list<highlight>; + /// Navigable ranges, appended to language-server document links. + document-links: func(doc: document) -> list<document-link>; + /// Clicks run only this plugin's declared commands. + code-lenses: func(doc: document) -> list<code-lens>; + /// Nested ranges, innermost to outermost. + selection-ranges: func(doc: document, at: position) -> list<range>; + /// Folds to merge with tree-sitter, for any declared language. + folding: func(doc: document) -> list<fold-range>; + /// Color literals to show in the existing color-preview gutter and picker. + colors: func(doc: document) -> list<color-info>; + /// Replacement strings for the selected color range. + color-presentations: func(doc: document, color: color-info) -> list<string>; + /// Used only when no LSP document symbols are available. + document-symbols: func(doc: document) -> list<document-symbol>; + /// Implementation locations, merged with language-server results. + implementation: func(doc: document, at: position) -> list<location>; + /// Type-declaration locations, merged with language-server results. + type-definition: func(doc: document, at: position) -> list<location>; + /// Clicks run only this plugin's declared commands. + gutter-actions: func(doc: document) -> list<gutter-action>; +} diff --git a/sdk/rust/openidea-plugin/wit/mcp.wit b/sdk/rust/openidea-plugin/wit/mcp.wit new file mode 100644 index 000000000..e250f5496 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/mcp.wit @@ -0,0 +1,16 @@ +package openidea:plugin@1.4.0; + +/// Discovery of the IDE's built-in MCP server; never exposes its URL or token. +interface mcp { + /// Stdio bridge argv for the invoking project; none without a window/project or while off. + stdio-command: func() -> option<list<string>>; +} + +/// Optional tools declared by contributes.mcpTools, under the plugin's grants and command budget. +interface mcp-provider { + use types.{error}; + use invocation.{invocation}; + /// Calls the declared local tool name with JSON arguments and the project's window context. + /// Returns text; tools may encode structured data as JSON text. + call: func(tool: string, arguments: string, ctx: borrow<invocation>) -> result<string, error>; +} diff --git a/sdk/rust/openidea-plugin/wit/plugin.wit b/sdk/rust/openidea-plugin/wit/plugin.wit index 8d1451ac3..04c707d20 100644 --- a/sdk/rust/openidea-plugin/wit/plugin.wit +++ b/sdk/rust/openidea-plugin/wit/plugin.wit @@ -8,7 +8,7 @@ /// `fs` and `workspace`, and everything else goes through the imports below, /// gated by the capabilities granted in `plugin.toml` (a missing one returns /// `error::denied`). -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; world plugin { import log; @@ -17,6 +17,7 @@ world plugin { import fs; import workspace; import editor; + import decorations; import invocation; import vcs; import commit; @@ -26,7 +27,15 @@ world plugin { import net; import secrets; import process; + import ipc; + import output; + import auth; + import command-runner; import views; + import run; + import run-targets; + import database; + import mcp; import settings; export lifecycle; @@ -168,3 +177,26 @@ world plugin-diagnostics { world editor-diagnostics { export diagnostics-provider; } + +/// A plugin that exports project run configurations. +world plugin-run-configurations { include plugin; export run-configurations; } +/// Export-only configurations world, composed with plugin by SDK macros. +world configurations-provider { export run-configurations; } +/// A plugin that parses its test output. +world plugin-test-results { include plugin; export test-results; } +/// Export-only test parser world, composed with plugin by SDK macros. +world test-results-provider { export test-results; } + +/// A plugin with additional API 1.4 language features. +world plugin-language-features { include plugin; export language-features; } +/// Export-only language features, composed with the base plugin world. +world editor-language-features { export language-features; } +/// A plugin that identifies embedded language ranges. +world plugin-injections { include plugin; export injections; } +/// Export-only injections, composed with the base plugin world. +world language-injections { export injections; } + +/// A plugin contributing tools to the built-in MCP server. +world plugin-mcp { include plugin; export mcp-provider; } +/// Export-only MCP tools, composed with the base plugin world. +world mcp-tools { export mcp-provider; } diff --git a/sdk/rust/openidea-plugin/wit/providers.wit b/sdk/rust/openidea-plugin/wit/providers.wit index 317eb81d1..8c0713ec6 100644 --- a/sdk/rust/openidea-plugin/wit/providers.wit +++ b/sdk/rust/openidea-plugin/wit/providers.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Editor features a plugin provides for the languages it declares under /// `[[contributes.providers]]` (the host calls only declared kinds). They run diff --git a/sdk/rust/openidea-plugin/wit/run-targets.wit b/sdk/rust/openidea-plugin/wit/run-targets.wit new file mode 100644 index 000000000..66a41e700 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/run-targets.wit @@ -0,0 +1,36 @@ +package openidea:plugin@1.4.0; + +/// Project-scoped targets owned by the registering plugin (API 1.4). +interface run-targets { + use types.{error}; + use workspace.{project}; + + /// An argv wrapper using a granted, pinned process program. No shell is used. + /// Normally: program + args + workdir-args + command + command arguments. + /// An override matches wrap::program_name(command) and replaces args AND command: + /// program + override + workdir-args + command arguments. + /// DDEV: program="ddev", args=["exec"], commands=[("composer", ["composer"])]: + /// `ddev exec php x.php` and `ddev composer install`. + /// Each workdir-args element may contain {cwd}, mapped under container-root. + /// All workdir-args are omitted without container-root or for cwd outside the project. + /// Without workdir-args the wrapper manages its container directory itself. + /// The wrapper always starts in the configuration's LOCAL working directory. + /// Environment reaches the wrapper; forwarding into a container depends on its conventions. + /// Registration never selects the target. Debugging plugin targets is invalid. + record run-target { + id: string, + name: string, + program: string, + args: list<string>, + workdir-args: list<string>, + container-root: option<string>, + commands: list<tuple<string, list<string>>>, + } + + /// Registers/replaces this plugin's target in an open, trusted project. + /// Targets survive idle reclamation, and disappear on unregister or plugin stop. + /// Remote project registration and execution take place on the server. + register: func(project: borrow<project>, target: run-target) -> result<_, error>; + /// Removes only this plugin's target from the project; missing ids are harmless. + unregister: func(project: borrow<project>, id: string); +} diff --git a/sdk/rust/openidea-plugin/wit/run.wit b/sdk/rust/openidea-plugin/wit/run.wit new file mode 100644 index 000000000..c1bc79cf9 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/run.wit @@ -0,0 +1,50 @@ +package openidea:plugin@1.4.0; + +/// Launches this plugin's configurations in the invoking window's Run tool window. +interface run { + use types.{error}; + /// Starts an owned configuration, appending args. Debug requires a debugger. + /// Only from a window command/event. The returned id receives run-exited. + start: func(config: string, args: list<string>, debug: bool) -> result<string, error>; +} + +/// Optional dynamic configurations, refreshed alongside run detectors. +interface run-configurations { + /// A configuration running an approved process in the project sandbox. + record run-configuration { + id: string, + name: string, + program: string, + args: list<string>, + cwd: option<string>, + test: bool, + framework: option<string>, + } + /// Returns the complete set for this project root. + configurations: func(project-root: string) -> list<run-configuration>; +} + +/// Optional parser for test configurations whose framework is plugin. +interface test-results { + use types.{location, extension}; + /// A suite or test; parents must precede children and ids remain stable for a run. + record test-start { id: string, parent: option<string>, name: string, location: option<location> } + /// A successful test and optional elapsed milliseconds. + record test-passed { id: string, duration-ms: option<u32> } + /// A failed test, comparison and optional declaration location. + record test-failed { + id: string, duration-ms: option<u32>, message: string, + expected: option<string>, actual: option<string>, location: option<location>, + } + /// A skipped test and its explanation. + record test-skipped { id: string, reason: option<string> } + /// Output attached to a test or suite. + record test-output { id: string, text: string } + /// Incremental updates to the IDE's test tree. Unknown extensions are ignored. + variant test-event { + suite-started(test-start), test-started(test-start), passed(test-passed), + failed(test-failed), skipped(test-skipped), output(test-output), other(extension), + } + /// Parses one bounded output batch; failures fall back to built-in parsers. + parse: func(config: string, chunk: string) -> list<test-event>; +} diff --git a/sdk/rust/openidea-plugin/wit/semantic.wit b/sdk/rust/openidea-plugin/wit/semantic.wit index ec89fe405..586c324e6 100644 --- a/sdk/rust/openidea-plugin/wit/semantic.wit +++ b/sdk/rust/openidea-plugin/wit/semantic.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Optional export (world `plugin-semantic-tokens`): semantic colouring of /// documents of the languages declared with the `semantic-tokens` provider diff --git a/sdk/rust/openidea-plugin/wit/types.wit b/sdk/rust/openidea-plugin/wit/types.wit index 8114a2db0..4192e783c 100644 --- a/sdk/rust/openidea-plugin/wit/types.wit +++ b/sdk/rust/openidea-plugin/wit/types.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Shared types. Positions are 0-based lines and UTF-16 code unit columns, /// as in the editor and LSP. diff --git a/sdk/rust/openidea-plugin/wit/views.wit b/sdk/rust/openidea-plugin/wit/views.wit index 78da35354..a34be2d46 100644 --- a/sdk/rust/openidea-plugin/wit/views.wit +++ b/sdk/rust/openidea-plugin/wit/views.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Declarative views: tool windows declared /// under `[[contributes.views]]` and custom editors under @@ -124,6 +124,31 @@ interface view-types { interface views { use types.{error}; use detail-types.{process-log}; + use process.{command}; + + /// An editor toolbar action; at most one action is the Save handler. + record content-action { id: string, label: string, icon: option<string>, primary: bool, save: bool } + /// A plugin-owned content editor, scoped by key to the invoking window. + record content-tab { + key: string, title: string, file-name: option<string>, language: option<string>, + content: string, read-only: bool, actions: list<content-action>, + } + /// A comparison opened by a content action reply. + record content-diff { left: string, right: string, left-label: option<string>, right-label: option<string>, title: option<string> } + /// Optional updates to an outstanding action or Save request. + record content-tab-reply { + content: option<string>, saved: option<bool>, read-only: option<bool>, + status: option<string>, error: option<string>, diff: option<content-diff>, + } + /// Opens or focuses a running session with this key; env is append-only. + /// Only from a window command/event; the id receives one terminal-exited. + open-terminal-session: func(key: string, title: string, cmd: command) -> result<string, error>; + /// Writes to this plugin's running keyed terminal in the invoking window. + write-terminal: func(key: string, text: string) -> result<_, error>; + /// Opens the existing content editor protocol without a web view. + open-content-tab: func(tab: content-tab) -> result<_, error>; + /// Answers content-tab-action in its originating window. + reply-content-tab: func(request-id: string, reply: content-tab-reply) -> result<_, error>; /// The view (or every custom editor of that id) shows outdated content: /// the IDE renders it again. diff --git a/sdk/rust/openidea-plugin/wit/workspace.wit b/sdk/rust/openidea-plugin/wit/workspace.wit index 556dd0da8..7c1e9dd19 100644 --- a/sdk/rust/openidea-plugin/wit/workspace.wit +++ b/sdk/rust/openidea-plugin/wit/workspace.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; interface workspace { use types.{error, location}; @@ -39,10 +39,28 @@ interface workspace { location: location, } + /// Text search options. Include/exclude are project-relative globs, as in Find in Files. + record text-query { + pattern: string, + regex: bool, + case-sensitive: bool, + whole-word: bool, + %include: list<string>, + exclude: list<string>, + } + /// A match with zero-based lines and UTF-16 columns, and its displayed line. + record text-match { location: location, line: string } + /// Bounded search results; limit-hit says there may be more matches. + record text-search { matches: list<text-match>, limit-hit: bool } + /// A project open in the IDE. resource project { /// Absolute path of the project root. Requires `project.read`. root: func() -> result<string, error>; + /// Find in Files with unsaved documents and project excludes. Requires project.read. + /// Cancellable, at most 10,000 matches. Zero limit returns no matches. + /// Line previews are capped at 4096 UTF-8 bytes, results at 4 MiB (limit-hit). + search-text: func(query: text-query, limit: u32) -> result<text-search, error>; /// Reads a UTF-8 text file inside the project (absolute, or relative /// to the root); paths leaving the root are refused. Unsaved editor /// contents win over the disk. A missing path is `not-found`. @@ -94,10 +112,22 @@ interface editor { text: string, } + /// An editor in the invoking window. + record open-editor { path: string, language: string, dirty: bool, active: bool } + /// Only from commands/events of a window. Requires editor.read. + open-editors: func() -> result<list<open-editor>, error>; + /// Open a project file (project.read) or a file in plugin-dir, selecting a range. + /// Only from commands/events of a window. Paths follow views.open-location. + open: func(path: string, selection: option<range>) -> result<_, error>; + /// A file open in an editor. resource text-editor { /// Absolute path of the file. path: func() -> string; + /// Replace selections in this window's editor. No capability; commands/events only. + set-selections: func(selections: list<range>) -> result<_, error>; + /// Scroll a range into view in this window's editor. No capability; commands/events only. + reveal: func(range: range) -> result<_, error>; /// Language id (`rust`, `solidity`, …). language: func() -> string; /// Current version and text. Requires `editor.read`. @@ -151,6 +181,22 @@ interface vcs { changed-files: u32, } + /// A commit in repository history; date is Unix seconds. + record commit-info { + id: string, short-id: string, author: string, author-email: string, + date: s64, subject: string, body: string, parents: list<string>, + } + /// One zero-based document line's attribution; date is Unix seconds. + record blame-line { line: u32, commit: string, author: string, date: s64, summary: string } + /// Newest commits first, at most 1000. Trusted projects only; requires vcs.read. + log: func(project: borrow<project>, path: option<string>, limit: u32) -> result<list<commit-info>, error>; + /// Includes unsaved text, with changed lines attributed to uncommitted changes. + /// Trusted projects only; requires vcs.read. Paths must be inside the project. + blame: func(project: borrow<project>, path: string) -> result<list<blame-line>, error>; + /// Working tree versus HEAD or against. None outside a repository. Disk text only. + /// Fails above 4 MiB. Trusted projects only; requires vcs.read. + diff: func(project: borrow<project>, path: option<string>, against: option<string>) -> result<option<string>, error>; + /// `none` when the project is not in a repository. Requires `vcs.read`. status: func(project: borrow<project>) -> result<option<repository-status>, error>; /// Configured remotes (`name`, fetch `url`), in order. Requires `vcs.read`. diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 14a51a69f..baf2cdc6f 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -460,3 +460,29 @@ pub async fn lsp_execute_code_action( ) -> IdeResult<Vec<ide_core::IpcPath>> { ide_host::ops::lsp::lsp_execute_code_action(&state.host, window.label(), request, doc, action, answers).await } + +#[tauri::command] +#[specta::specta] +pub async fn plugin_language_visuals( + window: Window, + state: State<'_, AppState>, + doc: DocId, +) -> IdeResult<ide_host::ops::lsp::PluginLanguageVisuals> { + ide_host::ops::lsp::plugin_language_visuals(&state.host, window.label(), doc).await +} +#[tauri::command] +#[specta::specta] +pub async fn plugin_color_presentations( + window: Window, + state: State<'_, AppState>, + doc: DocId, + plugin: String, + color: ide_plugin::language_features::ColorInfo, +) -> IdeResult<Vec<String>> { + ide_host::ops::lsp::plugin_color_presentations(&state.host, window.label(), doc, plugin, color).await +} +#[tauri::command] +#[specta::specta] +pub fn plugin_formatters() -> IdeResult<Vec<ide_host::plugins::language_features::FormatterChoice>> { + Ok(ide_host::ops::lsp::plugin_formatters()) +} diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 7c159e924..5f30d329c 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -27,6 +27,7 @@ pub fn apply(state: &AppState) { running.stop(); } ide_agent::set_mcp_executable(None); + state.host.set_mcp_executable(None); *state.mcp.error.lock() = None; return; } @@ -41,6 +42,7 @@ pub fn apply(state: &AppState) { ) { Ok(started) => { let exe = std::env::current_exe().unwrap_or_default(); + state.host.set_mcp_executable(Some(exe.clone())); ide_agent::set_mcp_executable(Some(exe)); *server = Some(started); *state.mcp.error.lock() = None; @@ -54,6 +56,7 @@ pub fn apply(state: &AppState) { /// Stops the server on exit (its discovery file goes with it). pub fn stop(state: &AppState) { + state.host.set_mcp_executable(None); ide_agent::set_mcp_executable(None); if let Some(server) = state.mcp.server.lock().take() { server.stop(); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 3fdfcaa79..bcfc8a1e4 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -224,6 +224,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> { search::replace_in_files, search::replace_preview, search::search_validate, + lsp::plugin_language_visuals, + lsp::plugin_color_presentations, + lsp::plugin_formatters, lsp::lsp_completion, lsp::lsp_inline_completion, lsp::lsp_resolve_completion, @@ -500,10 +503,20 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> { plugin::plugin_post_events, plugin::plugin_event_kinds, plugin::plugin_status_items, + plugin::plugin_decoration_sets, plugin::plugin_context_keys, plugin::plugin_cancel_task, plugin::plugin_answer_prompt, plugin::plugin_answer_commit, + plugin::plugin_answer_command, + plugin::plugin_finish_operation, + plugin::plugin_prepare_debug_run, + plugin::plugin_terminal_session_spawn, + plugin::plugin_terminal_session_close, + plugin::plugin_parse_test_results, + plugin::plugin_content_tab_action, + plugin::plugin_choose_program, + plugin::plugin_program, plugin::plugin_commit_checks, plugin::plugin_view_render, plugin::plugin_view_event, @@ -521,6 +534,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> { plugin::plugin_navigation_goto, plugin::plugin_navigation_markers, plugin::plugin_endpoints, + plugin::plugin_run_targets, + plugin::plugin_answer_run_target, plugin::plugin_market, plugin::plugin_market_url, plugin::plugin_set_market_url, diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 1fd07a381..6e288d8f2 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -104,6 +104,7 @@ pub fn create_host(app: &AppHandle, data_dir: &Path, project_host: &Arc<ide_host deliver(client, event); }; let host = Arc::new(PluginHost::new(data_dir.join("plugins"), Arc::new(sink), Limits::default())?); + project_host.set_plugin_host(&host); // Plugin languages join detection, LSP and DAP of every project (forks share the store), // and the syntax of the ones without a built-in grammar comes from their plugin. project_host.languages.attach_plugins(host.language_store().clone()); @@ -338,10 +339,12 @@ pub async fn plugin_run_command( editor: Option<PluginEditor>, paths: Option<Vec<PathBuf>>, settings_scope: Option<SettingsScope>, + command_chain: Option<Vec<String>>, ) -> IdeResult<Option<String>> { let host = state.plugins.clone(); let project = state.project(window.label()).ok(); let invocation = Invocation { + command_chain: command_chain.unwrap_or_default(), client: Some(window.label().to_owned()), workspace_root: project.as_ref().map(|p| p.root.clone()), editor: editor.map(PluginEditor::context), @@ -354,8 +357,13 @@ pub async fn plugin_run_command( /// Workbench events from this window (only the kinds of `plugin_event_kinds`). #[tauri::command] #[specta::specta] -pub fn plugin_post_events(window: Window, state: State<'_, AppState>, events: Vec<WorkbenchEvent>) { - state.plugins.post_events(Some(window.label()), events); +pub fn plugin_post_events( + window: Window, + state: State<'_, AppState>, + events: Vec<WorkbenchEvent>, + editor: Option<PluginEditor>, +) { + state.plugins.post_window_events(window.label(), events, editor); } /// Kinds of workbench events plugins want (re-fetch on `subscriptionsChanged`). @@ -375,13 +383,8 @@ pub async fn plugin_event_kinds(app: tauri::AppHandle) -> Vec<EventKind> { /// Every plugin status bar item (later changes arrive as `statusItemChanged`). #[tauri::command] #[specta::specta] -pub fn plugin_status_items(state: State<'_, AppState>) -> Vec<PluginStatusItem> { - state - .plugins - .status_items() - .into_iter() - .map(|(plugin_id, id, item)| PluginStatusItem { plugin_id, id, item }) - .collect() +pub fn plugin_status_items(window: Window, state: State<'_, AppState>) -> Vec<PluginStatusItem> { + state.plugins.status_snapshot(window.label()) } /// Plugin context keys in effect for this window (later changes arrive as `contextChanged`). @@ -876,3 +879,173 @@ pub async fn plugin_navigation_markers( ide_host::plugins::index_ops::plugin_navigation_markers(&state.host, state.plugins.clone(), window.label(), paths) .await } + +/// The invoking window's reply to a plugin command request. +#[tauri::command] +#[specta::specta] +pub fn plugin_answer_command( + window: Window, + state: State<'_, AppState>, + request_id: String, + answer: ide_host::plugins::CommandAnswer, +) -> IdeResult<bool> { + Ok(state.plugins.answer_command(window.label(), &request_id, answer)) +} + +/// Re-approves a plugin's executable, globally or for this window's project. +#[tauri::command] +#[specta::specta] +pub async fn plugin_choose_program( + window: Window, + state: State<'_, AppState>, + plugin_id: String, + capability: String, + scope: SettingsScope, + path: Option<PathBuf>, +) -> IdeResult<PluginInfo> { + let host = state.plugins.clone(); + let root = match scope { + SettingsScope::Global => None, + SettingsScope::Project => Some(state.project(window.label())?.root.clone()), + }; + blocking(move || host.choose_program(&plugin_id, &capability, root.as_deref(), path.as_deref())).await +} + +/// The currently approved executable in the selected scope, after revalidating its pin. +#[tauri::command] +#[specta::specta] +pub async fn plugin_program( + window: Window, + state: State<'_, AppState>, + plugin_id: String, + program: String, + scope: SettingsScope, +) -> IdeResult<ide_core::IpcPath> { + let host = state.plugins.clone(); + let root = match scope { + SettingsScope::Global => None, + SettingsScope::Project => Some(state.project(window.label())?.root.clone()), + }; + blocking(move || host.program_for_project(&plugin_id, &program, root.as_deref()).map(Into::into)).await +} + +/// Project-scoped decoration snapshots; the window filters to its current root. +#[tauri::command] +#[specta::specta] +pub fn plugin_decoration_sets( + state: State<'_, AppState>, +) -> std::collections::BTreeMap<String, Vec<ide_host::plugins::DecorationSet>> { + state.plugins.decoration_sets() +} + +/// Reports exactly one completion from the operation's launching window. +#[tauri::command] +#[specta::specta] +pub fn plugin_finish_operation( + window: Window, + state: State<'_, AppState>, + id: String, + status: Option<i32>, + stopped: bool, +) -> IdeResult<bool> { + Ok(state.plugins.finish_operation(window.label(), &id, status, stopped)) +} + +/// Dispatches a wasm content editor action through the owning plugin's event handler. +#[tauri::command] +#[specta::specta] +pub fn plugin_content_tab_action( + window: Window, + state: State<'_, AppState>, + plugin_id: String, + action: ide_plugin::ContentTabAction, +) -> IdeResult<()> { + state.plugins.content_tab_action(window.label(), &plugin_id, action) +} + +/// Parses one batch of an owned test configuration's output. +#[tauri::command] +#[specta::specta] +pub async fn plugin_parse_test_results( + window: Window, + state: State<'_, AppState>, + config: String, + chunk: String, +) -> IdeResult<Option<Vec<ide_plugin::PluginTestEvent>>> { + let plugins = state.plugins.clone(); + let client = window.label().to_owned(); + tokio::task::spawn_blocking(move || plugins.parse_test_results(&client, &config, chunk)) + .await + .map_err(|e| ide_core::IdeError::internal(e.to_string()))? +} + +/// Starts only the pinned command recorded for this window's terminal operation. +#[tauri::command] +#[specta::specta] +pub async fn plugin_terminal_session_spawn( + window: Window, + state: State<'_, AppState>, + operation: String, + cols: u16, + rows: u16, + on_output: Channel<String>, +) -> IdeResult<ide_terminal::TerminalInfo> { + let (plugins, host, window) = (state.plugins.clone(), state.host.clone(), window.label().to_owned()); + tokio::task::spawn_blocking(move || { + plugins.spawn_terminal_session( + &host, + &window, + &operation, + cols, + rows, + Arc::new(move |chunk| { + let _ = on_output.send(chunk); + }), + ) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))? +} + +/// Marks a keyed tab closed before ending its process. +#[tauri::command] +#[specta::specta] +pub fn plugin_terminal_session_close(window: Window, state: State<'_, AppState>, operation: String) -> IdeResult<()> { + state.plugins.close_terminal_session(window.label(), &operation) +} + +/// Revalidates a plugin run's executable before debugging, including reruns. +#[tauri::command] +#[specta::specta] +pub async fn plugin_prepare_debug_run( + window: Window, + state: State<'_, AppState>, + config: ide_run::RunConfig, +) -> IdeResult<ide_run::RunConfig> { + let (plugins, client) = (state.plugins.clone(), window.label().to_owned()); + tokio::task::spawn_blocking(move || plugins.prepare_debug_run(&client, config)) + .await + .map_err(|e| IdeError::internal(e.to_string()))? +} + +#[tauri::command] +#[specta::specta] +pub async fn plugin_run_targets( + window: Window, + state: State<'_, AppState>, +) -> IdeResult<Vec<ide_host::plugins::run_targets::RegisteredRunTarget>> { + ide_host::plugins::run_targets::list(&state.host, &state.plugins, window.label()) +} +#[tauri::command] +#[specta::specta] +pub async fn plugin_answer_run_target( + window: Window, + state: State<'_, AppState>, + id: String, + use_target: bool, +) -> IdeResult<ide_core::settings::Settings> { + let host = state.host.clone(); + let plugins = state.plugins.clone(); + let window = window.label().to_owned(); + state.host.blocking(move || ide_host::plugins::run_targets::answer(&host, &plugins, &window, &id, use_target)).await +} diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index 81dc86bc9..595012e0a 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -237,7 +237,9 @@ impl Handler for ClientHandler { } fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> { - use ide_host::plugins::remote_host::{SECRET_REQUEST, SecretRequest}; + use ide_host::plugins::remote_host::{ + AUTH_REQUEST, AuthRequest, SECRET_REQUEST, SECRET_WRITE, SecretRequest, SecretWrite, + }; match method { // A remote plugin's request header needs a secret from this machine's keychain. SECRET_REQUEST => { @@ -246,6 +248,18 @@ impl Handler for ClientHandler { let state = self.app.state::<AppState>(); Ok(serde_json::to_value(state.plugins.declared_secret(&r.plugin, &r.name)).unwrap_or(Value::Null)) } + SECRET_WRITE => { + let request: SecretWrite = + serde_json::from_value(params).map_err(|e| (ide_jsonrpc::codes::INVALID_PARAMS, e.to_string()))?; + Ok(serde_json::to_value(self.app.state::<AppState>().plugins.remote_secret_write(request)) + .unwrap_or(Value::Null)) + } + AUTH_REQUEST => { + let request: AuthRequest = + serde_json::from_value(params).map_err(|e| (ide_jsonrpc::codes::INVALID_PARAMS, e.to_string()))?; + Ok(serde_json::to_value(self.app.state::<AppState>().plugins.remote_auth(&self.window, request)) + .unwrap_or(Value::Null)) + } _ => Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, format!("unhandled method {method}"))), } } @@ -537,8 +551,11 @@ pub async fn remote_connect( state.remote_dev.0.lock().insert(label.clone(), connection.clone()); let weak = Arc::downgrade(&connection); let peer = connection.peer.clone(); + let auth_plugins = state.plugins.clone(); + let auth_window = label.clone(); tauri::async_runtime::spawn(async move { peer.closed().await; + auth_plugins.forget_remote_loopbacks(&auth_window); if let Some(connection) = weak.upgrade() { connection.forwards.lock().clear(); } diff --git a/src/bindings.ts b/src/bindings.ts index 85e0f2333..1ac0544a4 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -407,6 +407,9 @@ export const commands = { replacePreview: (query: SearchQuery, replacement: string, preserveCase: boolean, path: string, only: Pos[] | null) => typedError<ReplacePreview, IdeError>(__TAURI_INVOKE("replace_preview", { query, replacement, preserveCase, path, only })), /** Validates a query (e.g. regex syntax) while the user types. */ searchValidate: (query: SearchQuery) => typedError<null, IdeError>(__TAURI_INVOKE("search_validate", { query })), + pluginLanguageVisuals: (doc: DocId) => typedError<PluginLanguageVisuals, IdeError>(__TAURI_INVOKE("plugin_language_visuals", { doc })), + pluginColorPresentations: (doc: DocId, plugin: string, color: ColorInfo) => typedError<string[], IdeError>(__TAURI_INVOKE("plugin_color_presentations", { doc, plugin, color })), + pluginFormatters: () => typedError<FormatterChoice[], IdeError>(__TAURI_INVOKE("plugin_formatters")), lspCompletion: (request: RequestId, doc: DocId, pos: Pos, trigger: string | null) => typedError<CompletionList, IdeError>(__TAURI_INVOKE("lsp_completion", { request, doc, pos, trigger })), /** Inline (ghost text) suggestions at `pos`; `automatic` when triggered by typing. */ lspInlineCompletion: (request: RequestId, doc: DocId, pos: Pos, automatic: boolean) => typedError<InlineCompletionItem[], IdeError>(__TAURI_INVOKE("lsp_inline_completion", { request, doc, pos, automatic })), @@ -1062,7 +1065,7 @@ export const commands = { /** Reads the profile a profiled run left behind. */ profilerCollect: (spec: ProfileSpec) => typedError<Profile, IdeError>(__TAURI_INVOKE("profiler_collect", { spec })), /** Routes this window's plugin events to `on_event` (replaces an earlier subscription). */ - pluginSubscribe: (onEvent: Channel<PluginEvent>) => __TAURI_INVOKE<void>("plugin_subscribe", { onEvent }), + pluginSubscribe: (onEvent: Channel<PluginEvent_Deserialize>) => __TAURI_INVOKE<void>("plugin_subscribe", { onEvent }), /** * Installed and linked plugins plus the project plugins of this window's * project (`<project>/.openidea/plugins/*`, rescanned on every call), with @@ -1103,13 +1106,19 @@ export const commands = { path: string, language: string, selections: Range[], -} | null, paths: string[] | null, settingsScope: "global" | "project" | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("plugin_run_command", { pluginId, commandId, arg, editor, paths, settingsScope })), +} | null, paths: string[] | null, settingsScope: "global" | "project" | null, commandChain: string[] | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("plugin_run_command", { pluginId, commandId, arg, editor, paths, settingsScope, commandChain })), /** Workbench events from this window (only the kinds of `plugin_event_kinds`). */ - pluginPostEvents: (events: WorkbenchEvent[]) => __TAURI_INVOKE<void>("plugin_post_events", { events }), + pluginPostEvents: (events: WorkbenchEvent[], editor: { + path: string, + language: string, + selections: Range[], +} | null) => __TAURI_INVOKE<void>("plugin_post_events", { events, editor }), /** Kinds of workbench events plugins want (re-fetch on `subscriptionsChanged`). */ pluginEventKinds: () => __TAURI_INVOKE<EventKind[]>("plugin_event_kinds"), /** Every plugin status bar item (later changes arrive as `statusItemChanged`). */ pluginStatusItems: () => __TAURI_INVOKE<PluginStatusItem[]>("plugin_status_items"), + /** Project-scoped decoration snapshots; the window filters to its current root. */ + pluginDecorationSets: () => __TAURI_INVOKE<{ [key in string]: DecorationSet[] }>("plugin_decoration_sets"), /** Plugin context keys in effect for this window (later changes arrive as `contextChanged`). */ pluginContextKeys: () => __TAURI_INVOKE<PluginContextKey[]>("plugin_context_keys"), /** Cancels a plugin task (`taskStarted` with `cancellable`). */ @@ -1124,6 +1133,24 @@ export const commands = { * longer waits. */ pluginAnswerCommit: (requestId: string, answer: CommitAnswer) => __TAURI_INVOKE<boolean>("plugin_answer_commit", { requestId, answer }), + /** The invoking window's reply to a plugin command request. */ + pluginAnswerCommand: (requestId: string, answer: CommandAnswer) => typedError<boolean, IdeError>(__TAURI_INVOKE("plugin_answer_command", { requestId, answer })), + /** Reports exactly one completion from the operation's launching window. */ + pluginFinishOperation: (id: string, status: number | null, stopped: boolean) => typedError<boolean, IdeError>(__TAURI_INVOKE("plugin_finish_operation", { id, status, stopped })), + /** Revalidates a plugin run's executable before debugging, including reruns. */ + pluginPrepareDebugRun: (config: RunConfig_Deserialize) => typedError<RunConfig_Serialize, IdeError>(__TAURI_INVOKE("plugin_prepare_debug_run", { config })), + /** Starts only the pinned command recorded for this window's terminal operation. */ + pluginTerminalSessionSpawn: (operation: string, cols: number, rows: number, onOutput: Channel<string>) => typedError<TerminalInfo, IdeError>(__TAURI_INVOKE("plugin_terminal_session_spawn", { operation, cols, rows, onOutput })), + /** Marks a keyed tab closed before ending its process. */ + pluginTerminalSessionClose: (operation: string) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_terminal_session_close", { operation })), + /** Parses one batch of an owned test configuration's output. */ + pluginParseTestResults: (config: string, chunk: string) => typedError<PluginTestEvent[] | null, IdeError>(__TAURI_INVOKE("plugin_parse_test_results", { config, chunk })), + /** Dispatches a wasm content editor action through the owning plugin's event handler. */ + pluginContentTabAction: (pluginId: string, action: ContentTabAction) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_content_tab_action", { pluginId, action })), + /** Re-approves a plugin's executable, globally or for this window's project. */ + pluginChooseProgram: (pluginId: string, capability: string, scope: SettingsScope, path: string | null) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_choose_program", { pluginId, capability, scope, path })), + /** The currently approved executable in the selected scope, after revalidating its pin. */ + pluginProgram: (pluginId: string, program: string, scope: SettingsScope) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("plugin_program", { pluginId, program, scope })), /** * Runs plugins' before-commit checks on what `selection` commits, until * `request` is cancelled. @@ -1184,6 +1211,8 @@ export const commands = { } | null, IdeError>(__TAURI_INVOKE("plugin_navigation_goto", { doc, pos })), pluginNavigationMarkers: (paths: string[]) => typedError<FileMarkers[], IdeError>(__TAURI_INVOKE("plugin_navigation_markers", { paths })), pluginEndpoints: (pluginId: string) => typedError<PluginEndpoint[], IdeError>(__TAURI_INVOKE("plugin_endpoints", { pluginId })), + pluginRunTargets: () => typedError<RegisteredRunTarget[], IdeError>(__TAURI_INVOKE("plugin_run_targets")), + pluginAnswerRunTarget: (id: string, useTarget: boolean) => typedError<Settings_Serialize, IdeError>(__TAURI_INVOKE("plugin_answer_run_target", { id, useTarget })), /** The marketplace's plugins: read again when `refresh` or when none was read yet, else the last index read. */ pluginMarket: (refresh: boolean) => typedError<MarketPlugin[], IdeError>(__TAURI_INVOKE("plugin_market", { refresh })), pluginMarketUrl: () => __TAURI_INVOKE<string | null>("plugin_market_url"), @@ -1302,8 +1331,8 @@ export const commands = { * edited (not yet applied); the response status, or why there was none. */ proxyCheckConnection: (proxy: ProxySettings, url: string) => typedError<string, IdeError>(__TAURI_INVOKE("proxy_check_connection", { proxy, url })), - dbSources: () => typedError<DataSource[], IdeError>(__TAURI_INVOKE("db_sources")), - dbSaveSources: (sources: DataSource[]) => typedError<null, IdeError>(__TAURI_INVOKE("db_save_sources", { sources })), + dbSources: () => typedError<DataSource_Serialize[], IdeError>(__TAURI_INVOKE("db_sources")), + dbSaveSources: (sources: DataSource_Deserialize[]) => typedError<null, IdeError>(__TAURI_INVOKE("db_save_sources", { sources })), dbSetPassword: (id: string, password: string | null, save: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("db_set_password", { id, password, save })), dbSetSshSecret: (id: string, secret: string | null, save: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("db_set_ssh_secret", { id, secret, save })), dbTrustSshHost: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("db_trust_ssh_host", { id })), @@ -1401,7 +1430,7 @@ export const commands = { * (and the project's `remoteEnv` on a remote host); running ones keep theirs. */ envReload: () => typedError<EnvReloaded, IdeError>(__TAURI_INVOKE("env_reload")), - dbTest: (source: DataSource, password: string | null, sshSecret: string | null) => typedError<string, IdeError>(__TAURI_INVOKE("db_test", { source, password, sshSecret })), + dbTest: (source: DataSource_Deserialize, password: string | null, sshSecret: string | null) => typedError<string, IdeError>(__TAURI_INVOKE("db_test", { source, password, sshSecret })), dbConnected: () => typedError<string[], IdeError>(__TAURI_INVOKE("db_connected")), dbConnect: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("db_connect", { id })), dbDisconnect: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("db_disconnect", { id })), @@ -2423,12 +2452,24 @@ export type CodeStyleSettings_Serialize = { formatterTagsRegexp: boolean, }; +/** ColorInfo data. */ +export type ColorInfo = { + range: Range, + red: number | null, + green: number | null, + blue: number | null, + alpha: number | null, +}; + export type Command = { title: string, command: string, arguments: string[], }; +/** The invoking window's response to a command request. */ +export type CommandAnswer = { kind: "value"; value: string | null } | { kind: "notFound" } | { kind: "failed"; value: string }; + export type CommandForm = { command: Command, formFields: FormField[], @@ -2715,6 +2756,53 @@ export type ConsoleSettings = { export type ContainerOp = "start" | "stop" | "restart" | "pause" | "unpause" | "kill"; +/** ContentAction data exchanged with the IDE. */ +export type ContentAction = { + id: string, + label: string, + icon: string | null, + primary: boolean, + save: boolean, +}; + +/** ContentDiff data exchanged with the IDE. */ +export type ContentDiff = { + left: string, + right: string, + leftLabel: string | null, + rightLabel: string | null, + title: string | null, +}; + +/** ContentTab data exchanged with the IDE. */ +export type ContentTab = { + key: string, + title: string, + fileName: string | null, + language: string | null, + content: string, + readOnly: boolean, + actions: ContentAction[], +}; + +/** ContentTabAction data exchanged with the IDE. */ +export type ContentTabAction = { + requestId: string, + key: string, + action: string, + content: string, +}; + +/** ContentTabReply data exchanged with the IDE. */ +export type ContentTabReply = { + content: string | null, + saved: boolean | null, + readOnly: boolean | null, + status: string | null, + error: string | null, + diff: ContentDiff | null, +}; + /** Number of declarations of one extension point. */ export type ContributionCount = { point: string, @@ -2820,7 +2908,9 @@ export type DataBreakpointInfo = { accessTypes: string[], }; -export type DataSource = { +export type DataSource = DataSource_Serialize | DataSource_Deserialize; + +export type DataSource_Deserialize = { id?: string, name?: string, driver?: DbDriver, @@ -2856,6 +2946,52 @@ export type DataSource = { * so not written to it. */ global?: boolean, + /** Plugin id owning this source; absent for user-managed sources. */ + pluginOwner?: string | null, + /** Id private to the owner, never used as a connection target. */ + pluginSourceId?: string | null, +}; + +export type DataSource_Serialize = { + id: string, + name: string, + driver: DbDriver, + host: string, + port: number | null, + database: string, + user: string, + /** SQLite database file (absolute, or relative to the project root). */ + file: IpcPath | null, + ssl: DbSslMode, + /** Keep the password in the saved passwords. */ + savePassword: boolean, + /** Refuse statements other than queries (enforced by the database session where possible). */ + readOnly: boolean, + /** SSH tunnel settings (`None`: never configured; kept but unused when not `enabled`). */ + ssh: DbSsh | null, + /** Wire-compatible product preset (`cockroachdb`, `tidb`…; empty: the driver itself). */ + vendor: string, + color: DbColor | null, + comment: string, + /** Connect with [`DataSource::url`] instead of the host / port / database fields. */ + urlOnly: boolean, + url: string, + /** PEM files; `~/` is the home directory. */ + sslCaFile: string, + sslCertFile: string, + sslKeyFile: string, + options: DbOptions, + introspection: DbIntrospection, + properties: DbProperty[], + /** + * Stored app-wide and listed in every project. Implied by the file it is read from, + * so not written to it. + */ + global: boolean, + /** Plugin id owning this source; absent for user-managed sources. */ + pluginOwner?: string | null, + /** Id private to the owner, never used as a connection target. */ + pluginSourceId?: string | null, }; /** System Settings › Date Formats (Git log, Local History, blame). */ @@ -3186,6 +3322,24 @@ export type DebuggerSettings = { python?: PythonDebuggerSettings, }; +/** An editor decoration. */ +export type Decoration = { + range: Range, + style: string, + hover: string | null, + after: string | null, +}; + +/** A plugin-owned editor or project-tree decoration set for one file. */ +export type DecorationSet = { + root: IpcPath, + path: IpcPath, + setId: string, + tree: boolean, + items: Decoration[], + file: FileDecoration | null, +}; + /** A deleted local branch, enough to restore it (IDEA's "Restore" notification action). */ export type DeletedBranch = { name: string, @@ -3785,7 +3939,7 @@ export type ErrorKind = "notFound" | "alreadyExists" | "permissionDenied" | "inv "unavailable" | "io" | "protocol" | "internal"; /** Kinds of workbench events a plugin can subscribe to (`on-event:<kind>`). */ -export type EventKind = "file-opened" | "file-saved" | "files-changed" | "editor-focused" | "action-performed" | "vcs-changed" | "scheduled"; +export type EventKind = "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action" | "file-opened" | "file-saved" | "files-changed" | "editor-focused" | "action-performed" | "vcs-changed" | "scheduled"; /** An exception breakpoint filter offered by an adapter (`exceptionBreakpointFilters`). */ export type ExceptionFilter = { @@ -3893,6 +4047,13 @@ export type FileCoverage = { branchesCovered: number, }; +/** A tree decoration. */ +export type FileDecoration = { + badge: string | null, + style: string, + tooltip: string | null, +}; + export type FileEdit = { kind: "edit"; path: IpcPath; edits: TextEdit[]; /** LSP TextDocumentEdit version; null/unversioned edits use content preconditions. */ version?: number | null } | { kind: "create"; path: IpcPath; overwrite: boolean } | { kind: "rename"; from: IpcPath; to: IpcPath } | { kind: "delete"; path: IpcPath; recursive: boolean }; @@ -4127,6 +4288,12 @@ export type FormatOptions = { tool?: ExternalFormatter | null, }; +export type FormatterChoice = { + id: string, + name: string, + languages: string[], +}; + /** A port of the remote host reachable on this machine at `localhost:local_port`. */ export type ForwardedPort = { remotePort: number, @@ -4400,6 +4567,15 @@ export type GradleTask = { description: string | null, }; +/** GutterAction data. */ +export type GutterAction = { + line: number, + icon: string | null, + tooltip: string, + command: string, + argument: string | null, +}; + /** HEAD before and after a history rewrite, for its Undo. */ export type HeadChange = { before: string, @@ -4587,7 +4763,9 @@ export type IdeError = { data?: ErrorData | null, }; -export type IdeEvent = { type: "mcpRequest"; id: number; request: McpUiRequest } | { type: "mcpRequestClosed"; id: number } | +export type IdeEvent = +/** Project data source settings changed; reload the Database tree. */ +{ type: "dbSourcesChanged" } | { type: "mcpRequest"; id: number; request: McpUiRequest } | { type: "mcpRequestClosed"; id: number } | /** Project window membership or selection changed. */ { type: "windowGroupsChanged" } | /** A window entered or left full screen, itself or as a member of a full-screen window group. */ @@ -4746,6 +4924,12 @@ export type IndexedMarker = { icon: string | null, }; +/** Injection data. */ +export type Injection = { + range: Range, + language: string, +}; + export type InlayHint = { position: Pos, label: string, @@ -4808,6 +4992,13 @@ export type InlineCompletionSettings = { disabledLanguages?: string[], }; +/** The focused editor's input mode and optional visual sub-mode. */ +export type InputMode = { + path: string, + mode: string, + sub: string | null, +}; + export type InspectEvent = /** Files about to be inspected. */ { type: "started"; total: number } | @@ -5234,6 +5425,8 @@ export type LanguageServerSettings_Serialize = { }; export type LanguagesSettings = { + /** Explicit plugin formatter, by language id. Empty entries use the normal formatter policy. */ + pluginFormatters?: { [key in string]: string }, python?: PythonSettings, node?: NodeSettings, php?: PhpSettings, @@ -5841,6 +6034,8 @@ export type NodeAt = { export type NodeSettings = { /** `node` executable; empty = the one on `PATH`. */ interpreter?: string, + /** Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local. */ + runTarget?: string, /** `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`. */ packageManager?: string, }; @@ -6038,6 +6233,8 @@ export type PhpLevelSource = export type PhpSettings = { /** `php` CLI executable; empty = the one on `PATH`. */ interpreter?: string, + /** Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local. */ + runTarget?: string, /** * PHP version the language server checks the code against (`7.4`); empty = from * `composer.json` (`config.platform.php`, else the lowest version `require.php` allows). @@ -6122,78 +6319,195 @@ export type PluginEndpoint = { }; /** Backend → UI notifications (Tauri event `ide://plugin`). */ -export type PluginEvent = +export type PluginEvent = PluginEvent_Serialize | PluginEvent_Deserialize; + +/** Backend → UI notifications (Tauri event `ide://plugin`). */ +export type PluginEvent_Deserialize = +/** Project target registrations or remembered prompt answers changed. */ +({ type: "runTargetsChanged" }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** A window-owned operation; an empty request id queues an asynchronous run. */ +({ type: "windowRequest"; pluginId: string; requestId: string; request: WindowRequest_Deserialize }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** Reply to a content action in this window. */ +({ type: "contentTabReply"; pluginId: string; requestId: string; reply: ContentTabReply }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; request?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | /** Plugins, their status or their contributions changed: re-fetch `plugin_list`. */ -{ type: "commandsChanged" } | +({ type: "commandsChanged" }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** Complete replacement of one plugin's project-scoped decorations. */ +({ type: "decorationsChanged"; pluginId: string; sets: DecorationSet[] }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** Open a file in the invoking window. */ +({ type: "editorOpen"; path: IpcPath; selection: Range | null }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** Change selection or scrolling only in this window's already open editor. */ +({ type: "editorRanges"; path: IpcPath; ranges: Range[]; reveal: boolean }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; reply?: never; request?: never; requestId?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** A bounded output channel snapshot, or withdrawal when the plugin stops. */ +({ type: "outputChanged"; pluginId: string; channel: string; text: string | null }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** Reveal a read-only Output tab in the receiving window. */ +({ type: "revealOutput"; pluginId: string; channel: string; text: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** Execute an action through the receiving window and answer over IPC. */ +({ type: "executeCommand"; requestId: string; id: string; arg: string | null; chain: string[] }) & { actions?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; channel?: never; column?: never; cwd?: never; done?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | /** Cached document associations were disposed (reload, disable or failure). */ -{ type: "documentIndexesChanged" } | +({ type: "documentIndexesChanged" }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | /** * The routes `plugin_id` reports for the project `root` may have changed: * ask `plugin_endpoints` again. */ -{ type: "endpointsChanged"; pluginId: string; root: IpcPath } | +({ type: "endpointsChanged"; pluginId: string; root: IpcPath }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | /** * The marketplace offers newer versions of installed plugins (announced * once per set of versions). */ -{ type: "updatesAvailable"; updates: PluginUpdate[] } | { type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string; +({ type: "updatesAvailable"; updates: PluginUpdate[] }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; url?: never; value?: never; view?: never } | ({ type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string; /** Buttons running the plugin's own commands (`ui.notify`). */ -actions: NotificationAction[] } | +actions: NotificationAction[] }) & { arg?: never; args?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | /** A plugin's status bar item was set (`item`) or removed. */ -{ type: "statusItemChanged"; pluginId: string; id: string; item: StatusItem | null } | +({ type: "statusItemChanged"; pluginId: string; id: string; item: StatusItem | null }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** An override in the receiving window; removal exposes the global item. */ +({ type: "windowStatusItemChanged"; pluginId: string; id: string; item: StatusItem | null }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | /** * A plugin's context key was set (`value`: JSON text) or removed, in the * emitted window (every window when the client is `None`). */ -{ type: "contextChanged"; pluginId: string; key: string; value: string | null } | +({ type: "contextChanged"; pluginId: string; key: string; value: string | null }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; view?: never } | /** A plugin began a task: show it with the background tasks. */ -{ type: "taskStarted"; pluginId: string; taskId: string; title: string; cancellable: boolean } | { type: "taskProgress"; pluginId: string; taskId: string; done: number; total: number | null; message: string | null } | { type: "taskEnded"; pluginId: string; taskId: string } | +({ type: "taskStarted"; pluginId: string; taskId: string; title: string; cancellable: boolean }) & { actions?: never; arg?: never; args?: never; body?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; text?: never; theme?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | ({ type: "taskProgress"; pluginId: string; taskId: string; done: number; total: number | null; message: string | null }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; text?: never; theme?: never; title?: never; updates?: never; url?: never; value?: never; view?: never } | ({ type: "taskEnded"; pluginId: string; taskId: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | /** * The kinds of workbench events plugins want changed: re-fetch * `plugin_event_kinds` (the UI reports only those). */ -{ type: "subscriptionsChanged" } | { type: "themeChanged"; theme: ActiveTheme | null } | +({ type: "subscriptionsChanged" }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | ({ type: "themeChanged"; theme: ActiveTheme | null }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | /** * A plugin needs capabilities the user has not granted yet (first use * after migrating from 0.1): show its consent dialog. */ -{ type: "consentRequested"; pluginId: string; capabilities: CapabilityInfo[] } | +({ type: "consentRequested"; pluginId: string; capabilities: CapabilityInfo[] }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | /** A view (or the custom editors) of a plugin must be rendered again. */ -{ type: "viewChanged"; pluginId: string; view: string } | +({ type: "viewChanged"; pluginId: string; view: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never } | /** For the script of a view's `html` body. */ -{ type: "viewMessage"; pluginId: string; view: string; message: string } | +({ type: "viewMessage"; pluginId: string; view: string; message: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never } | /** Open a terminal running `program` (already approved and verified). */ -{ type: "openTerminal"; pluginId: string; title: string; program: IpcPath; args: string[]; cwd: IpcPath } | { type: "openLocation"; pluginId: string; path: IpcPath; line: number; column: number } | +({ type: "openTerminal"; pluginId: string; title: string; program: IpcPath; args: string[]; cwd: IpcPath }) & { actions?: never; arg?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | ({ type: "openLocation"; pluginId: string; path: IpcPath; line: number; column: number }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; log?: never; message?: never; newWindow?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | /** * Open (or focus) the log tab `key` following `log` (`views.open-log-tab`); * it starts the program with `plugin_log_tab_spawn`. */ -{ type: "openLogTab"; pluginId: string; key: string; title: string; log: ServiceLog } | +({ type: "openLogTab"; pluginId: string; key: string; title: string; log: ServiceLog }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; level?: never; line?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | /** * Plugin languages, or the servers they may start, changed: re-fetch * `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]). */ -{ type: "languagesChanged" } | +({ type: "languagesChanged" }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | /** Copy `text` to the system clipboard. */ -{ type: "copyToClipboard"; pluginId: string; text: string } | +({ type: "copyToClipboard"; pluginId: string; text: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | /** Open `url` (`http(s)://`) in the system browser. */ -{ type: "openUrl"; pluginId: string; url: string } | +({ type: "openUrl"; pluginId: string; url: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; value?: never; view?: never } | /** A window command/event requests the normal File > Open flow. */ -{ type: "openProject"; pluginId: string; path: string; newWindow: boolean } | +({ type: "openProject"; pluginId: string; path: string; newWindow: boolean }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | /** * A command waits for the user's answer: show `prompt`, then report it with * `plugin_answer_prompt(prompt_id, …)`. */ -{ type: "promptRequested"; pluginId: string; promptId: string; prompt: Prompt } | +({ type: "promptRequested"; pluginId: string; promptId: string; prompt: Prompt }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | /** The call that asked gave up (cancelled, or its plugin stopped): close the prompt. */ -{ type: "promptClosed"; promptId: string } | +({ type: "promptClosed"; promptId: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | /** Bring the tool window of the plugin's view `view` to the front. */ -{ type: "revealView"; pluginId: string; view: string } | +({ type: "revealView"; pluginId: string; view: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never } | /** * A command needs the window's Commit view: answer with * `plugin_answer_commit(request_id, …)`. Closed like a prompt (`PromptClosed`). */ -{ type: "commitRequested"; pluginId: string; requestId: string; request: CommitRequest }; +({ type: "commitRequested"; pluginId: string; requestId: string; request: CommitRequest }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never }; + +/** Backend → UI notifications (Tauri event `ide://plugin`). */ +export type PluginEvent_Serialize = +/** Project target registrations or remembered prompt answers changed. */ +({ type: "runTargetsChanged" }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** A window-owned operation; an empty request id queues an asynchronous run. */ +({ type: "windowRequest"; pluginId: string; requestId: string; request: WindowRequest_Serialize }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** Reply to a content action in this window. */ +({ type: "contentTabReply"; pluginId: string; requestId: string; reply: ContentTabReply }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; request?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** Plugins, their status or their contributions changed: re-fetch `plugin_list`. */ +({ type: "commandsChanged" }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** Complete replacement of one plugin's project-scoped decorations. */ +({ type: "decorationsChanged"; pluginId: string; sets: DecorationSet[] }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** Open a file in the invoking window. */ +({ type: "editorOpen"; path: IpcPath; selection: Range | null }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** Change selection or scrolling only in this window's already open editor. */ +({ type: "editorRanges"; path: IpcPath; ranges: Range[]; reveal: boolean }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; reply?: never; request?: never; requestId?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** A bounded output channel snapshot, or withdrawal when the plugin stops. */ +({ type: "outputChanged"; pluginId: string; channel: string; text: string | null }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** Reveal a read-only Output tab in the receiving window. */ +({ type: "revealOutput"; pluginId: string; channel: string; text: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** Execute an action through the receiving window and answer over IPC. */ +({ type: "executeCommand"; requestId: string; id: string; arg: string | null; chain: string[] }) & { actions?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; channel?: never; column?: never; cwd?: never; done?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** Cached document associations were disposed (reload, disable or failure). */ +({ type: "documentIndexesChanged" }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** + * The routes `plugin_id` reports for the project `root` may have changed: + * ask `plugin_endpoints` again. + */ +({ type: "endpointsChanged"; pluginId: string; root: IpcPath }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** + * The marketplace offers newer versions of installed plugins (announced + * once per set of versions). + */ +({ type: "updatesAvailable"; updates: PluginUpdate[] }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; url?: never; value?: never; view?: never } | ({ type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string; +/** Buttons running the plugin's own commands (`ui.notify`). */ +actions: NotificationAction[] }) & { arg?: never; args?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** A plugin's status bar item was set (`item`) or removed. */ +({ type: "statusItemChanged"; pluginId: string; id: string; item: StatusItem | null }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** An override in the receiving window; removal exposes the global item. */ +({ type: "windowStatusItemChanged"; pluginId: string; id: string; item: StatusItem | null }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** + * A plugin's context key was set (`value`: JSON text) or removed, in the + * emitted window (every window when the client is `None`). + */ +({ type: "contextChanged"; pluginId: string; key: string; value: string | null }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; view?: never } | +/** A plugin began a task: show it with the background tasks. */ +({ type: "taskStarted"; pluginId: string; taskId: string; title: string; cancellable: boolean }) & { actions?: never; arg?: never; args?: never; body?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; text?: never; theme?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | ({ type: "taskProgress"; pluginId: string; taskId: string; done: number; total: number | null; message: string | null }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; text?: never; theme?: never; title?: never; updates?: never; url?: never; value?: never; view?: never } | ({ type: "taskEnded"; pluginId: string; taskId: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** + * The kinds of workbench events plugins want changed: re-fetch + * `plugin_event_kinds` (the UI reports only those). + */ +({ type: "subscriptionsChanged" }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | ({ type: "themeChanged"; theme: ActiveTheme | null }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** + * A plugin needs capabilities the user has not granted yet (first use + * after migrating from 0.1): show its consent dialog. + */ +({ type: "consentRequested"; pluginId: string; capabilities: CapabilityInfo[] }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** A view (or the custom editors) of a plugin must be rendered again. */ +({ type: "viewChanged"; pluginId: string; view: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never } | +/** For the script of a view's `html` body. */ +({ type: "viewMessage"; pluginId: string; view: string; message: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never } | +/** Open a terminal running `program` (already approved and verified). */ +({ type: "openTerminal"; pluginId: string; title: string; program: IpcPath; args: string[]; cwd: IpcPath }) & { actions?: never; arg?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | ({ type: "openLocation"; pluginId: string; path: IpcPath; line: number; column: number }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; log?: never; message?: never; newWindow?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** + * Open (or focus) the log tab `key` following `log` (`views.open-log-tab`); + * it starts the program with `plugin_log_tab_spawn`. + */ +({ type: "openLogTab"; pluginId: string; key: string; title: string; log: ServiceLog }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; level?: never; line?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** + * Plugin languages, or the servers they may start, changed: re-fetch + * `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]). + */ +({ type: "languagesChanged" }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** Copy `text` to the system clipboard. */ +({ type: "copyToClipboard"; pluginId: string; text: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** Open `url` (`http(s)://`) in the system browser. */ +({ type: "openUrl"; pluginId: string; url: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; value?: never; view?: never } | +/** A window command/event requests the normal File > Open flow. */ +({ type: "openProject"; pluginId: string; path: string; newWindow: boolean }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** + * A command waits for the user's answer: show `prompt`, then report it with + * `plugin_answer_prompt(prompt_id, …)`. + */ +({ type: "promptRequested"; pluginId: string; promptId: string; prompt: Prompt }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** The call that asked gave up (cancelled, or its plugin stopped): close the prompt. */ +({ type: "promptClosed"; promptId: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } | +/** Bring the tool window of the plugin's view `view` to the front. */ +({ type: "revealView"; pluginId: string; view: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never } | +/** + * A command needs the window's Commit view: answer with + * `plugin_answer_commit(request_id, …)`. Closed like a prompt (`PromptClosed`). + */ +({ type: "commitRequested"; pluginId: string; requestId: string; request: CommitRequest }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never }; /** A plugin's own icon (`icon`, `icon-dark` of its manifest): sanitised SVG markup. */ export type PluginIcon = { @@ -6233,12 +6547,39 @@ export type PluginInfo = { previousVersion: string | null, }; +/** + * Commands of this module the remote server answers. + * Versioned plugin visuals, refreshed after the editor synchronizes its buffer. + */ +export type PluginLanguageVisuals = { + version: number, + injections: Injection[], + colors: ([string, ColorInfo])[], + gutterActions: ([string, GutterAction])[], +}; + +export type PluginLocation = { + path: string, + range: Range, +}; + /** A line a view's `process` body printed. */ export type PluginOutputLine = { stderr: boolean, text: string, }; +/** A plugin-owned argv wrapper for project runs (API 1.4). */ +export type PluginRunTarget = { + id: string, + name: string, + program: string, + args: string[], + workdirArgs: string[], + containerRoot: string | null, + commands: ([string, string[]])[], +}; + /** A secret a plugin declares, and whether the user stored it. */ export type PluginSecretState = { name: string, @@ -6285,11 +6626,28 @@ export type PluginStatus = /** A plugin's status bar item. */ export type PluginStatusItem = { + /** Whether this item overrides the global item in the requesting window. */ + window: boolean, pluginId: string, id: string, item: StatusItem, }; +/** A test update, carried by the optional test-results export. */ +export type PluginTestEvent = { + type: "suiteStarted", +} & TestStart | { + type: "testStarted", +} & TestStart | { + type: "passed", +} & TestPassed | { + type: "failed", +} & TestFailed | { + type: "skipped", +} & TestSkipped | { + type: "output", +} & TestOutput | { type: "other"; kind: string; data: string }; + /** A newer version of an installed plugin in the marketplace. */ export type PluginUpdate = { id: string, @@ -6764,6 +7122,15 @@ export type ReferencesIncomplete = /** A native query failed; indexed anchors are still available. */ "serverFailed"; +export type RegisteredRunTarget = { + /** Opaque selector id, namespaced by the host. */ + id: string, + pluginId: string, + pluginName: string, + target: PluginRunTarget, + prompt: boolean, +}; + /** A registry agent this machine can run (or has installed). */ export type RegistryAgent = { id: string, @@ -7055,6 +7422,14 @@ export type RunConfig_Serialize = { allowParallel?: boolean, } & RunExtras_Serialize; +/** RunExited data exchanged with the IDE. */ +export type RunExited = { + id: string, + config: string, + status: number | null, + killed: boolean, +}; + export type RunExtras = RunExtras_Serialize | RunExtras_Deserialize; export type RunExtras_Deserialize = { @@ -7188,6 +7563,8 @@ export type RunInfo = { /** Run/Debug configuration behaviour. */ export type RunSettings = { + /** Target for configurations without an explicit target. Empty = local. */ + defaultTarget?: string, confirmRerun?: boolean, /** Ask before closing a Run tab whose process is still running. */ confirmTerminate?: boolean, @@ -7962,6 +8339,14 @@ export type TableSort = { /** A long-running background task shown in the status bar. */ export type TaskId = number; +/** TerminalExited data exchanged with the IDE. */ +export type TerminalExited = { + id: string, + key: string, + status: number | null, + closed: boolean, +}; + /** An integrated terminal session. */ export type TerminalId = number; @@ -8031,6 +8416,42 @@ export type TestCaseReport = { export type TestCaseStatus = "passed" | "failed" | "skipped"; +/** Incremental test tree data. */ +export type TestFailed = { + id: string, + durationMs: number | null, + message: string, + expected: string | null, + actual: string | null, + location: PluginLocation | null, +}; + +/** Incremental test tree data. */ +export type TestOutput = { + id: string, + text: string, +}; + +/** Incremental test tree data. */ +export type TestPassed = { + id: string, + durationMs: number | null, +}; + +/** Incremental test tree data. */ +export type TestSkipped = { + id: string, + reason: string | null, +}; + +/** Incremental test tree data. */ +export type TestStart = { + id: string, + parent: string | null, + name: string, + location: PluginLocation | null, +}; + /** Editor › Color Scheme override of one text attribute. */ export type TextAttributesOverride = TextAttributesOverride_Serialize | TextAttributesOverride_Deserialize; @@ -8475,6 +8896,23 @@ export type WebReferenceKind = /** `var(--name)`: the custom property's declarations. */ "cssVariable"; +/** A request that the invoking window acknowledges through plugin_answer_command. */ +export type WindowRequest = WindowRequest_Serialize | WindowRequest_Deserialize; + +/** A request that the invoking window acknowledges through plugin_answer_command. */ +export type WindowRequest_Deserialize = +/** Connect and introspect using the normal Database action in this window. */ +({ kind: "databaseConnect"; id: string }) & { args?: never; config?: never; cwd?: never; debug?: never; env?: never; key?: never; operation?: never; program?: never; tab?: never; title?: never } | +/** Open and acknowledge validation by the shared content editor. */ +({ kind: "contentTab"; tab: ContentTab }) & { args?: never; config?: never; cwd?: never; debug?: never; env?: never; id?: never; key?: never; operation?: never; program?: never; title?: never } | ({ kind: "run"; operation: string; config: RunConfig_Deserialize; debug: boolean }) & { args?: never; cwd?: never; env?: never; id?: never; key?: never; program?: never; tab?: never; title?: never } | ({ kind: "terminal"; operation: string; key: string; title: string; program: IpcPath; args: string[]; env: { [key in string]: string }; cwd: IpcPath }) & { config?: never; debug?: never; id?: never; tab?: never }; + +/** A request that the invoking window acknowledges through plugin_answer_command. */ +export type WindowRequest_Serialize = +/** Connect and introspect using the normal Database action in this window. */ +({ kind: "databaseConnect"; id: string }) & { args?: never; config?: never; cwd?: never; debug?: never; env?: never; key?: never; operation?: never; program?: never; tab?: never; title?: never } | +/** Open and acknowledge validation by the shared content editor. */ +({ kind: "contentTab"; tab: ContentTab }) & { args?: never; config?: never; cwd?: never; debug?: never; env?: never; id?: never; key?: never; operation?: never; program?: never; title?: never } | ({ kind: "run"; operation: string; config: RunConfig_Serialize; debug: boolean }) & { args?: never; cwd?: never; env?: never; id?: never; key?: never; program?: never; tab?: never; title?: never } | ({ kind: "terminal"; operation: string; key: string; title: string; program: IpcPath; args: string[]; env: { [key in string]: string }; cwd: IpcPath }) & { config?: never; debug?: never; id?: never; tab?: never }; + export type WindowTab = { label: string, name: string, @@ -8483,7 +8921,7 @@ export type WindowTab = { }; /** A workbench event delivered to plugins. */ -export type WorkbenchEvent = { kind: "fileOpened"; value: string } | { kind: "fileSaved"; value: string } | { kind: "filesChanged"; value: FilesChanged } | { kind: "editorFocused"; value: string | null } | { kind: "actionPerformed"; value: ActionPerformed } | { kind: "vcsChanged"; value: string } | { kind: "scheduled" }; +export type WorkbenchEvent = { kind: "runExited"; value: RunExited } | { kind: "terminalExited"; value: TerminalExited } | { kind: "contentTabAction"; value: ContentTabAction } | { kind: "fileClosed"; value: string } | { kind: "settingsChanged"; value: string[] } | { kind: "inputModeChanged"; value: InputMode } | { kind: "fileOpened"; value: string } | { kind: "fileSaved"; value: string } | { kind: "filesChanged"; value: FilesChanged } | { kind: "editorFocused"; value: string | null } | { kind: "actionPerformed"; value: ActionPerformed } | { kind: "vcsChanged"; value: string } | { kind: "scheduled" }; export type WorkingTreeChange = { path: string, diff --git a/src/editor/api.ts b/src/editor/api.ts index 5206a786b..6be4d04eb 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -456,6 +456,7 @@ export function _provideNavigationHistory(provider: () => NavigationHistorySnaps // --- editor events ------------------------------------------------------------ export type EditorEvent = + | { type: "inputModeChanged"; path: string; mode: string; sub: string | null } | { type: "docOpened"; doc: number; path: string; language: string } | { type: "docClosed"; doc: number; path: string } /** After the backend wrote the file. */ @@ -465,6 +466,11 @@ export type EditorEvent = /** Caret moved in the active text editor (throttled to animation frames). 0-based. */ | { type: "caretMoved"; doc: number; path: string; line: number; col: number; offset: number }; +/** Reports the focused editor's input mode from an editor contribution. */ +export function reportInputMode(path: string, mode: string, sub: string | null) { + _emitEditorEvent({ type: "inputModeChanged", path, mode, sub }); +} + type EditorEventListener = (e: EditorEvent) => void; const editorEventListeners = new Set<EditorEventListener>(); diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index 13e22533d..cbf6f7354 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -1,3 +1,4 @@ +import { ContributedToolbar } from "@/workbench/ContributedToolbar"; import { useFilePresentationIcons } from "@/platform/filePresentation"; /** * The editor area: split layout of panes, each with IDEA-style tabs (preview @@ -474,6 +475,7 @@ function EditorTabActions({ paneId, tab }: { paneId: string; tab: Tab }) { const moreRef = useRef<HTMLButtonElement>(null); return ( <div className={styles.tabActions}> + <ContributedToolbar slot="editor.title" /> {LayoutActions && <LayoutActions path={tab.input.path!} tabId={tab.id} />} <IconButton icon="split" diff --git a/src/editor/lsp/index.ts b/src/editor/lsp/index.ts index 6f1e8c170..f3c634471 100644 --- a/src/editor/lsp/index.ts +++ b/src/editor/lsp/index.ts @@ -1,3 +1,4 @@ +import { serverCodeLenses } from "./serverCodeLens"; /** * LSP-backed editor features: completion, diagnostics, hover / quick docs, * parameter info, navigation, document highlights, intentions, rename, @@ -46,6 +47,7 @@ import { HierarchyDialog } from "@/editor/refactor"; export function lspExtensions(ctx: EditorDocContext): Extension[] { return [ lspDoc.of(ctx), + serverCodeLenses(ctx), lspTheme, hintExtension(), diagnosticsExtension(), diff --git a/src/editor/lsp/pluginVisuals.ts b/src/editor/lsp/pluginVisuals.ts new file mode 100644 index 000000000..1771ac7c7 --- /dev/null +++ b/src/editor/lsp/pluginVisuals.ts @@ -0,0 +1,16 @@ +/** One versioned request shared by plugin highlighting, gutter actions and color previews. */ +import type { Text } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; +import { whenSynced, type EditorDocContext } from "@/editor/api"; +import { call, commands, type PluginLanguageVisuals } from "@/platform/ipc"; +import { navigationEpoch } from "@/editor/navigationProviders"; +const pending = new WeakMap<Text, { epoch: number; doc: number; value: Promise<PluginLanguageVisuals> }>(); +export function pluginVisuals(view: EditorView, ctx: EditorDocContext): Promise<PluginLanguageVisuals> { + const text = view.state.doc; + const epoch = navigationEpoch(); + const cached = pending.get(text); + if (cached?.epoch === epoch && cached.doc === ctx.doc) return cached.value; + const value = whenSynced(view).then(() => call(commands.pluginLanguageVisuals(ctx.doc))); + pending.set(text, { epoch, doc: ctx.doc, value }); + return value; +} diff --git a/src/editor/lsp/serverCodeLens.test.ts b/src/editor/lsp/serverCodeLens.test.ts new file mode 100644 index 000000000..7d554e6b5 --- /dev/null +++ b/src/editor/lsp/serverCodeLens.test.ts @@ -0,0 +1,40 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { codeLenses } from "@/editor/api"; +const ipc = vi.hoisted(() => ({ + lspCodeLens: vi.fn(), + lspExecuteCommand: vi.fn(async () => ({ status: "ok", data: null })), +})); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original<typeof import("@/platform/ipc")>()), + commands: { ...(await original<typeof import("@/platform/ipc")>()).commands, ...ipc }, +})); +const { serverCodeLenses } = await import("./serverCodeLens"); +const views: EditorView[] = []; +afterEach(() => { + views.splice(0).forEach((v) => v.destroy()); + vi.useRealTimers(); + vi.clearAllMocks(); +}); +it("renders LSP and plugin lens commands in the shared facet and invokes their commands", async () => { + vi.useFakeTimers(); + const command = { title: "Run fixture", command: "openidea.plugin-language:p:run", arguments: ['"lens"'] }; + ipc.lspCodeLens.mockResolvedValue({ + status: "ok", + data: [{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 1 } }, command }], + }); + const view = new EditorView({ + state: EditorState.create({ + doc: "x", + extensions: serverCodeLenses({ doc: 1, path: "/p/a", language: "text", uiMode: null, readOnly: false }), + }), + parent: document.body, + }); + views.push(view); + await vi.advanceTimersByTimeAsync(400); + const lenses = view.state.facet(codeLenses); + expect(lenses.map((l) => l.title)).toEqual(["Run fixture"]); + lenses[0]!.run!(view, new MouseEvent("click")); + expect(ipc.lspExecuteCommand).toHaveBeenCalledWith(1, command); +}); diff --git a/src/editor/lsp/serverCodeLens.ts b/src/editor/lsp/serverCodeLens.ts new file mode 100644 index 000000000..c7ed3d7c7 --- /dev/null +++ b/src/editor/lsp/serverCodeLens.ts @@ -0,0 +1,81 @@ +/** Language-server and plugin lenses in the shared editor code-vision renderer. */ +import { StateEffect, StateField, type Extension } from "@codemirror/state"; +import { ViewPlugin, type ViewUpdate } from "@codemirror/view"; +import { codeLenses, whenSynced, type CodeLens, type EditorDocContext } from "@/editor/api"; +import { call, commands } from "@/platform/ipc"; +import { onNavigationProvidersChanged } from "@/editor/navigationProviders"; +import { posToOffset } from "./positions"; +import { onLspRefresh, onServerSettled } from "./availability"; +const set = StateEffect.define<CodeLens[]>(); +const field = StateField.define<CodeLens[]>({ + create: () => [], + update(value, tr) { + for (const e of tr.effects) if (e.is(set)) return e.value; + return tr.docChanged ? [] : value; + }, + provide: (f) => codeLenses.from(f), +}); +export function serverCodeLenses(ctx: EditorDocContext): Extension { + if (ctx.doc < 0) return []; + return [ + field, + ViewPlugin.define( + (view) => + new (class { + timer: ReturnType<typeof setTimeout> | undefined; + generation = 0; + off: () => void; + constructor() { + const offs = [ + onNavigationProvidersChanged(() => this.schedule()), + onLspRefresh("codeLens", () => this.schedule()), + onServerSettled(() => this.schedule()), + ]; + this.off = () => offs.forEach((f) => f()); + this.schedule(); + } + schedule() { + this.generation++; + clearTimeout(this.timer); + this.timer = setTimeout(() => void this.run(), 400); + } + update(u: ViewUpdate) { + if (u.docChanged) this.schedule(); + } + async run() { + const gen = this.generation; + const text = view.state.doc; + try { + await whenSynced(view); + const result = await call(commands.lspCodeLens(ctx.doc)); + if (gen !== this.generation || text !== view.state.doc || !view.dom.isConnected) return; + view.dispatch({ + effects: set.of( + result.flatMap((l) => + l.command + ? [ + { + pos: posToOffset(text, l.range.start), + title: l.command.title, + run: () => { + void call(commands.lspExecuteCommand(ctx.doc, l.command!)).catch(console.warn); + }, + }, + ] + : [], + ), + ), + }); + } catch { + if (gen === this.generation && view.dom.isConnected) view.dispatch({ effects: set.of([]) }); + } + } + destroy() { + this.generation++; + clearTimeout(this.timer); + this.off(); + } + })(), + ), + ]; +} diff --git a/src/features/code-insight/ProblemsView.tsx b/src/features/code-insight/ProblemsView.tsx index abcde0838..924368e14 100644 --- a/src/features/code-insight/ProblemsView.tsx +++ b/src/features/code-insight/ProblemsView.tsx @@ -1,3 +1,4 @@ +import { menuGroupItems } from "@/workbench/contributions"; /** Problems tool window: "File" (current editor) and "Project Errors" tabs. */ import { useCallback, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { create } from "zustand"; @@ -387,6 +388,7 @@ export function ProblemsView() { run: () => copy(n), }, ...inspectionMenu(n.kind === "diag" ? diagInspection(map, n.path, n.d) : null), + ...menuGroupItems("problems", { paths: [t.path], line: t.d.range.start.line, column: t.d.range.start.col }), ]); }} activateExpandable={false} diff --git a/src/features/color-preview/index.ts b/src/features/color-preview/index.ts index 834401bd3..552824967 100644 --- a/src/features/color-preview/index.ts +++ b/src/features/color-preview/index.ts @@ -1,7 +1,12 @@ /** Color swatches in the editor gutter for color literals (Settings › Editor › General › Gutter Icons). */ import { registerEditorContribution } from "@/editor/api"; +import { pluginColorPreview } from "./pluginColors"; import { colorPreviewGutter } from "./gutter"; export function activate(): () => void { - return registerEditorContribution({ id: "colorPreview.gutter", order: 59, extensions: colorPreviewGutter }); + return registerEditorContribution({ + id: "colorPreview.gutter", + order: 59, + extensions: (ctx) => [colorPreviewGutter(ctx), pluginColorPreview(ctx)], + }); } diff --git a/src/features/color-preview/pluginColors.test.ts b/src/features/color-preview/pluginColors.test.ts new file mode 100644 index 000000000..ce093d352 --- /dev/null +++ b/src/features/color-preview/pluginColors.test.ts @@ -0,0 +1,55 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +const ipc = vi.hoisted(() => ({ + pluginLanguageVisuals: vi.fn(), + pluginColorPresentations: vi.fn(async () => ({ status: "ok", data: ["red"] })), +})); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original<typeof import("@/platform/ipc")>()), + commands: ipc, +})); +const { pluginColorPreview } = await import("./pluginColors"); +const views: EditorView[] = []; +afterEach(() => { + views.splice(0).forEach((v) => v.destroy()); + vi.useRealTimers(); + vi.clearAllMocks(); + document.querySelectorAll('input[type="color"]').forEach((n) => n.remove()); +}); +it("uses plugin presentations to replace precisely the reported range", async () => { + vi.useFakeTimers(); + Object.defineProperty(HTMLInputElement.prototype, "showPicker", { value: vi.fn(), configurable: true }); + ipc.pluginLanguageVisuals.mockResolvedValue({ + status: "ok", + data: { + version: 1, + injections: [], + gutterActions: [], + colors: [ + ["p", { range: { start: { line: 0, col: 1 }, end: { line: 0, col: 2 } }, red: 1, green: 0, blue: 0, alpha: 1 }], + ], + }, + }); + const view = new EditorView({ + state: EditorState.create({ + doc: "xyz", + extensions: pluginColorPreview({ doc: 1, path: "/p/a", language: "text", uiMode: null, readOnly: false }), + }), + parent: document.body, + }); + views.push(view); + await vi.advanceTimersByTimeAsync(300); + const swatch = view.dom.querySelector<HTMLButtonElement>("button")!; + swatch.click(); + const picker = document.querySelector<HTMLInputElement>('input[type="color"]')!; + picker.value = "#00ff00"; + picker.dispatchEvent(new Event("change")); + await vi.advanceTimersByTimeAsync(0); + expect(view.state.doc.toString()).toBe("xredz"); + expect(ipc.pluginColorPresentations).toHaveBeenCalledWith( + 1, + "p", + expect.objectContaining({ red: 0, green: 1, blue: 0, alpha: 1 }), + ); +}); diff --git a/src/features/color-preview/pluginColors.ts b/src/features/color-preview/pluginColors.ts new file mode 100644 index 000000000..afc1f4dcc --- /dev/null +++ b/src/features/color-preview/pluginColors.ts @@ -0,0 +1,153 @@ +/** Plugin colors use the same native picker and provider-supplied replacement strings. */ +import { RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state"; +import { GutterMarker, ViewPlugin, gutter, type EditorView, type ViewUpdate } from "@codemirror/view"; +import { type EditorDocContext } from "@/editor/api"; +import { rangeToOffsets } from "@/editor/lsp/positions"; +import { pluginVisuals } from "@/editor/lsp/pluginVisuals"; +import { onNavigationProvidersChanged } from "@/editor/navigationProviders"; +import { call, commands, type ColorInfo } from "@/platform/ipc"; +import { showContextMenu } from "@/ui"; +import styles from "./ColorPreview.module.css"; +const setColors = StateEffect.define<[string, ColorInfo][]>(); +const colors = StateField.define<[string, ColorInfo][]>({ + create: () => [], + update(v, tr) { + for (const e of tr.effects) if (e.is(setColors)) return e.value; + return tr.docChanged ? [] : v; + }, +}); +class Swatch extends GutterMarker { + constructor( + readonly plugin: string, + readonly color: ColorInfo, + readonly view: EditorView, + readonly ctx: EditorDocContext, + ) { + super(); + } + override eq(other: GutterMarker) { + return ( + other instanceof Swatch && + this.plugin === other.plugin && + JSON.stringify(this.color) === JSON.stringify(other.color) + ); + } + override toDOM() { + const el = document.createElement("button"); + el.type = "button"; + el.className = styles.swatch!; + el.title = `rgba(${this.color.red}, ${this.color.green}, ${this.color.blue}, ${this.color.alpha})`; + el.setAttribute("aria-label", el.title); + const fill = document.createElement("span"); + fill.style.background = `rgba(${(this.color.red ?? 0) * 255}, ${(this.color.green ?? 0) * 255}, ${(this.color.blue ?? 0) * 255}, ${this.color.alpha})`; + el.append(fill); + el.onclick = () => { + if (this.view.state.readOnly) return; + const rect = el.getBoundingClientRect(); + pick(this.view, this.ctx, this.plugin, this.color, rect.left, rect.bottom + 2); + }; + return el; + } +} +function pick(view: EditorView, ctx: EditorDocContext, plugin: string, color: ColorInfo, x: number, y: number) { + const text = view.state.doc; + const { from, to } = rangeToOffsets(text, color.range); + const input = document.createElement("input"); + input.type = "color"; + input.className = styles.picker!; + input.style.left = `${x}px`; + input.style.top = `${y}px`; + input.value = + "#" + + [color.red, color.green, color.blue] + .map((c) => + Math.round((c ?? 0) * 255) + .toString(16) + .padStart(2, "0"), + ) + .join(""); + document.body.append(input); + input.onchange = () => { + input.remove(); + const hex = input.value; + const chosen = { + ...color, + red: parseInt(hex.slice(1, 3), 16) / 255, + green: parseInt(hex.slice(3, 5), 16) / 255, + blue: parseInt(hex.slice(5, 7), 16) / 255, + }; + void call(commands.pluginColorPresentations(ctx.doc, plugin, chosen)) + .then((list) => { + const apply = (insert: string) => { + if (view.state.doc === text && !view.state.readOnly) + view.dispatch({ changes: { from, to, insert }, userEvent: "input.color" }); + }; + if (view.state.doc !== text) return; + if (list.length === 1) apply(list[0]!); + else if (list.length) + showContextMenu( + { x, y }, + list.map((label) => ({ label, verbatim: true, run: () => apply(label) })), + ); + }) + .catch(console.warn); + }; + input.oncancel = () => input.remove(); + try { + input.showPicker(); + } catch { + input.click(); + } +} +export function pluginColorPreview(ctx: EditorDocContext): Extension { + if (ctx.doc < 0) return []; + return [ + colors, + gutter({ + class: `${styles.gutter} cm-plugin-color-preview`, + markers: (view) => + RangeSet.of( + view.state + .field(colors) + .map(([p, c]) => new Swatch(p, c, view, ctx).range(view.state.doc.line(c.range.start.line + 1).from)), + true, + ), + }), + ViewPlugin.define( + (view) => + new (class { + timer: ReturnType<typeof setTimeout> | undefined; + generation = 0; + off: () => void; + constructor() { + this.off = onNavigationProvidersChanged(() => this.schedule()); + this.schedule(); + } + schedule() { + this.generation++; + clearTimeout(this.timer); + this.timer = setTimeout(() => void this.run(), 300); + } + update(u: ViewUpdate) { + if (u.docChanged) this.schedule(); + } + async run() { + const gen = this.generation; + const text = view.state.doc; + try { + const r = await pluginVisuals(view, ctx); + if (gen === this.generation && view.state.doc === text && view.dom.isConnected) + view.dispatch({ effects: setColors.of(r.colors) }); + } catch { + if (gen === this.generation && view.dom.isConnected) view.dispatch({ effects: setColors.of([]) }); + } + } + destroy() { + this.generation++; + clearTimeout(this.timer); + this.off(); + } + })(), + ), + ]; +} diff --git a/src/features/database/DatabaseView.tsx b/src/features/database/DatabaseView.tsx index afd2627d8..3af50928e 100644 --- a/src/features/database/DatabaseView.tsx +++ b/src/features/database/DatabaseView.tsx @@ -292,7 +292,7 @@ export function DatabaseView() { const label = (n: Node): string => { switch (n.kind) { case "source": - return n.source.name ?? ""; + return n.source.pluginOwner ? `${n.source.name} (${n.source.pluginOwner})` : (n.source.name ?? ""); case "database": return n.name; case "schema": diff --git a/src/features/database/SourceDialog.tsx b/src/features/database/SourceDialog.tsx index a30749ca2..2952388d2 100644 --- a/src/features/database/SourceDialog.tsx +++ b/src/features/database/SourceDialog.tsx @@ -1110,7 +1110,12 @@ export function SourceDialog({ /> {/* IDEA's Make Global: stored with the IDE and listed in every project. */} <span title={tx("Available in all projects")}> - <Checkbox checked={!!s.global} onChange={(global) => set({ global })} label="Global" /> + <Checkbox + checked={!!s.global} + disabled={!!s.pluginOwner} + onChange={(global) => set({ global })} + label="Global" + /> </span> </div> <span /> diff --git a/src/features/database/index.ts b/src/features/database/index.ts index 842478fba..c6a0653d7 100644 --- a/src/features/database/index.ts +++ b/src/features/database/index.ts @@ -6,6 +6,8 @@ */ import { lazyComponent, lazyWhen } from "@/ui"; import { activeEditor, lazyEditorExtension, registerEditorContribution } from "@/editor/api"; +import { tx } from "@/platform/i18n"; +import { onIdeEvent } from "@/platform/ipc"; import { registerActions } from "@/platform/actions"; import { activeTableEditor, installTableEditorFocus } from "./tableEditorFocus"; import { useProject } from "@/platform/project"; @@ -27,6 +29,11 @@ import { consoleSource, consoleTints, loadSources, + connect, + loadSchemas, + loadDatabases, + listsDatabases, + sourceById, openConsole, openHistory, openSourceDialog, @@ -38,6 +45,9 @@ const useDialogOpen = () => useDatabase((s) => !!s.dialog); export function activate(): () => void { const disposers = [ + onIdeEvent("dbSourcesChanged", () => { + void loadSources(); + }), registerToolWindow({ id: DB_TOOL_WINDOW, title: "Database", @@ -109,6 +119,23 @@ export function activate(): () => void { installTableEditorFocus(), registerDatabaseTreeActions(), registerActions([ + { + id: "db.pluginConnect", + title: "Connect Plugin Data Source", + category: "Tools", + when: "projectOpen", + run: async (e) => { + if (!useProject.getState().project?.trusted) + throw new Error(tx("Trust this project to connect plugin data sources")); + const id = e.arg; + if (typeof id !== "string") throw new Error(tx("Invalid data source id")); + await loadSources(); + toolWindowService().show(DB_TOOL_WINDOW, true); + if (!(await connect(id))) throw new Error(tx("Database connection failed")); + if (listsDatabases(sourceById(id))) await loadDatabases(id); + else await loadSchemas(id); + }, + }, { id: "db.showToolWindow", title: "Database", diff --git a/src/features/database/store.ts b/src/features/database/store.ts index e651f116a..4adef497f 100644 --- a/src/features/database/store.ts +++ b/src/features/database/store.ts @@ -2,6 +2,7 @@ * Database tools state: data sources, connection status, the introspection * cache of the tree, query consoles (file → data source) and their results. */ +import { useProject } from "@/platform/project"; import { create } from "zustand"; import { IpcError, @@ -243,11 +244,20 @@ export function connectedTargets(id: string): string[] { return [...useDatabase.getState().connected].filter((t) => splitTarget(t).id === id); } +let sourcesRequest = 0; + export async function loadSources(): Promise<void> { + const request = ++sourcesRequest; + const root = useProject.getState().project?.root; try { const [sources, connected] = await Promise.all([call(commands.dbSources()), call(commands.dbConnected())]); + if (request !== sourcesRequest || useProject.getState().project?.root !== root) return; + for (const old of useDatabase.getState().sources) { + if (JSON.stringify(old) !== JSON.stringify(sources.find((s) => s.id === old.id))) forget(old.id ?? ""); + } useDatabase.setState({ sources, connected: new Set(connected), loaded: true }); } catch (e) { + if (request !== sourcesRequest || useProject.getState().project?.root !== root) return; useDatabase.setState({ loaded: true }); notifications().notify({ level: "error", title: "Cannot read data sources", body: message(e), key: "db-sources" }); } @@ -299,7 +309,10 @@ export async function duplicateSource(id: string): Promise<void> { let n = 2; while (names.has(`${src.name} [${n}]`)) n++; try { - await saveSource({ ...src, id: newSourceId(), name: `${src.name} [${n}]` }, undefined); + await saveSource( + { ...src, id: newSourceId(), name: `${src.name} [${n}]`, pluginOwner: null, pluginSourceId: null }, + undefined, + ); } catch (e) { notifications().notify({ level: "error", title: "Cannot duplicate the data source", body: message(e) }); } diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index a87e18827..75acdf27a 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -323,6 +323,7 @@ async function startConfig(arg: unknown) { await startJavaConfig(config, root, origin ?? config, show); return; } + if (config.kind === "plugin") config = await call(commands.pluginPrepareDebugRun(config)); const dotnet = config.kind === "dotnet" && config.debugAdapter === "netcoredbg"; const prepared = dotnet ? await call(commands.runDotnetResolve(config, true)) : config; // php-debug starts the PHP chosen in Settings unless the command names one. @@ -387,6 +388,45 @@ export function registerDebugActions(): () => void { }); return registerActions([ + { + id: "debug.pluginStart", + title: "Debug Plugin Configuration", + category: "Debug", + hidden: true, + when: "projectOpen", + run: async ({ arg }) => { + const a = arg as { config: RunConfig; onExit: (code: number | null, stopped: boolean) => void }; + const config = await call(commands.pluginPrepareDebugRun(a.config)); + const root = useProject.getState().project?.root; + if (!root) throw new Error("No project"); + const launch = launchConfigFor(config, root); + if (!launch) throw new Error("This configuration has no debugger"); + let id: number | null = null; + let stopped = false; + const observe = () => { + if (id === null) return; + const session = useDebug.getState().sessions[id]; + if (session?.status === "stopping") stopped = true; + if (!session || session.status === "terminated") { + off(); + a.onExit(session?.exitCode ?? null, stopped); + } + }; + const off = useDebug.subscribe(observe); + try { + await maybeEditorService()?.saveAll(); + id = await startSession(launch, { runConfig: config }); + if (id === null) { + off(); + throw new Error("Debug session did not start"); + } + observe(); + } catch (error) { + off(); + throw error; + } + }, + }, { id: "debug.startConfig", title: "Debug…", diff --git a/src/features/mcp/index.ts b/src/features/mcp/index.ts index ce7163ef2..a64faeef5 100644 --- a/src/features/mcp/index.ts +++ b/src/features/mcp/index.ts @@ -30,7 +30,7 @@ export function activate(): () => void { let timer: ReturnType<typeof setTimeout> | null = null; const report = () => { timer = null; - if (isRemoteWindow() || !useProject.getState().project) return; + if (!useProject.getState().project) return; const paths = openEditorPaths(); const editor = activeEditor(); const focused = document.hasFocus(); diff --git a/src/features/mcp/reporters.test.ts b/src/features/mcp/reporters.test.ts index 2c1598c0f..4741eb2c7 100644 --- a/src/features/mcp/reporters.test.ts +++ b/src/features/mcp/reporters.test.ts @@ -106,10 +106,11 @@ describe("MCP editor reports", () => { expect(state.reportOpen).toHaveBeenCalledTimes(2); expect(state.reportFocused).toHaveBeenCalledTimes(2); }); - it("does not report remote windows or after disposal", async () => { + it("forwards remote editor reports and stops after disposal", async () => { state.remote = true; await vi.advanceTimersByTimeAsync(300); - expect(state.reportOpen).not.toHaveBeenCalled(); + expect(state.reportOpen).toHaveBeenCalledWith(["/p/a.txt"]); + state.reportOpen.mockClear(); state.remote = false; state.registryChanged(); stop(); diff --git a/src/features/plugins/PluginsPage.module.css b/src/features/plugins/PluginsPage.module.css index 305ad6483..4eb73a89a 100644 --- a/src/features/plugins/PluginsPage.module.css +++ b/src/features/plugins/PluginsPage.module.css @@ -334,6 +334,41 @@ } /* Permission dialog and notices */ +.capabilityItem { + min-width: 0; + padding: 8px 10px; +} +.capabilityItem + .capabilityItem { + border-top: 1px solid var(--border); +} +.capabilityHeader { + display: flex; + align-items: center; + gap: 10px; +} +.capabilityHeader > .mono { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; +} +.capabilityDescription { + margin: 4px 0 0 26px; + color: var(--fg); + overflow-wrap: anywhere; +} +.programPermission { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-width: 0; + margin: 6px 0 0 26px; +} +.programPath { + flex-basis: 100%; + min-width: 0; + overflow-wrap: anywhere; +} .consent { display: flex; flex-direction: column; diff --git a/src/features/plugins/PluginsPage.tsx b/src/features/plugins/PluginsPage.tsx index ec1f9546c..4ad771cbc 100644 --- a/src/features/plugins/PluginsPage.tsx +++ b/src/features/plugins/PluginsPage.tsx @@ -31,7 +31,7 @@ import { type MenuItem, T, } from "@/ui"; -import { RISK_LABEL, sortByRisk } from "./consent"; +import { RISK_LABEL, sortByRisk, capabilityDescription } from "./consent"; import { pluginIconName } from "./pluginIcon"; import { localizeContributions, usePluginCatalogs, usePluginT } from "./pluginText"; import { itemsOf, uiPoint } from "./projection"; @@ -59,6 +59,8 @@ import { openExternalUrl } from "@/platform/openUrl"; import { importIntellijTheme, showInstalledIntellijThemes } from "./intellijImport"; import { promptInput } from "@/workbench/inputDialogs"; import styles from "./PluginsPage.module.css"; +import { pluginsBackend } from "./backend"; +import { confirm } from "@/workbench/messages"; import { tx } from "@/platform/i18n"; const STATUS: Record<PluginStatus, { label: string; tone: "success" | "accent" | "neutral" | "danger" | "warning" }> = { @@ -293,17 +295,86 @@ function PointSection({ plugin, point }: { plugin: PluginInfo; point: string }) ); } -function CapabilityRow({ cap, children }: { cap: CapabilityInfo; children?: ReactNode }) { +function CapabilityRow({ cap, children, program }: { cap: CapabilityInfo; children?: ReactNode; program?: ReactNode }) { return ( - <li className={styles.item}> - {children} - <span className={styles.itemMain}>{tx(cap.description)}</span> - <span className={styles.mono}>{cap.id}</span> - <Badge tone={RISK_LABEL[cap.risk].tone}>{RISK_LABEL[cap.risk].label}</Badge> + <li className={styles.capabilityItem}> + <div className={styles.capabilityHeader}> + {children} + <span className={styles.mono}>{cap.id}</span> + <Badge tone={RISK_LABEL[cap.risk].tone}>{RISK_LABEL[cap.risk].label}</Badge> + </div> + <p className={styles.capabilityDescription}>{capabilityDescription(cap)}</p> + {program} </li> ); } +function ProgramPermission({ plugin, cap, disabled }: { plugin: PluginInfo; cap: CapabilityInfo; disabled: boolean }) { + const project = useProject((s) => s.project); + const [scope, setScope] = useState<"global" | "project">("global"); + const [path, setPath] = useState(""); + const [error, setError] = useState(""); + const [working, setWorking] = useState(false); + useEffect(() => { + let live = true; + void pluginsBackend() + .program(plugin.id, cap.id.slice(8), scope) + .then((p) => { + if (live) setPath(p); + }) + .catch(() => { + if (live) setPath(""); + }); + return () => { + live = false; + }; + }, [plugin, cap.id, scope, working]); + const choose = async (reset: boolean) => { + setError(""); + setWorking(true); + try { + const selected = reset ? null : await pluginsBackend().pickProgram(); + if (!reset && !selected) return; + const message = reset + ? tx("Revalidate the default executable? If it is unavailable, this permission will be revoked.") + : tx`Allow ${plugin.name} to run ${selected}? This is equivalent to running arbitrary code on this machine.`; + if (!(await confirm({ title: tx("Approve plugin executable"), message, ok: tx("Allow"), icon: "warning" }))) + return; + await pluginsBackend().chooseProgram(plugin.id, cap.id, scope, selected); + await refreshPlugins(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setWorking(false); + } + }; + return ( + <div className={styles.programPermission}> + {path && <span className={clsx(styles.mono, styles.programPath)}>{path}</span>} + <Select + aria-label={tx("Executable scope")} + value={scope} + onChange={(value) => setScope(value as "global" | "project")} + options={[ + { value: "global", label: tx("Global") }, + ...(project ? [{ value: "project", label: tx("Project") }] : []), + ]} + /> + <Button disabled={disabled || working} onClick={() => void choose(false)}> + {tx("Choose executable…")} + </Button> + <Button disabled={disabled || working} onClick={() => void choose(true)}> + {tx("Reset executable")} + </Button> + {error && ( + <span role="alert" className={styles.error}> + {error} + </span> + )} + </div> + ); +} + function CapabilityList({ plugin, busy }: { plugin: PluginInfo; busy: boolean }) { if (plugin.capabilities.length === 0) { return ( @@ -316,7 +387,13 @@ function CapabilityList({ plugin, busy }: { plugin: PluginInfo; busy: boolean }) return ( <ul className={styles.items} aria-label={tx("Permissions")}> {sortByRisk(plugin.capabilities).map((c) => ( - <CapabilityRow key={c.id} cap={c}> + <CapabilityRow + key={c.id} + cap={c} + program={ + c.id.startsWith("process:") && <ProgramPermission plugin={plugin} cap={c} disabled={busy || !confirmed} /> + } + > <Checkbox checked={c.granted} disabled={busy || !confirmed} diff --git a/src/features/plugins/__tests__/decorations.test.ts b/src/features/plugins/__tests__/decorations.test.ts new file mode 100644 index 000000000..1f8460f03 --- /dev/null +++ b/src/features/plugins/__tests__/decorations.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, expect, it } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { DecorationSet } from "@/bindings"; +import { _addEditor, _removeEditor, type EditorDocContext, type TextEditorHandle } from "@/editor/api"; +import { useProject } from "@/platform/project"; +import { getFileDecoration } from "@/workbench/contributions"; +import { decorationExtension, setPluginDecorations } from "../decorations"; +import { capabilityDescription } from "../consent"; +import { handleRuntimeEvent } from "../runtimeUi"; + +const ctx: EditorDocContext = { doc: 1, path: "/p/a.txt", language: "text", uiMode: null, readOnly: false }; +let editor: TextEditorHandle; +const range = { start: { line: 0, col: 2 }, end: { line: 0, col: 4 } }; +const set = (root = "/p"): DecorationSet => ({ + root, + path: "/p/a.txt", + setId: "test", + tree: false, + items: [{ range, style: "highlight", hover: "hover", after: "<plain>" }], + file: null, +}); +beforeEach(() => { + useProject.setState({ project: { root: "/p", trusted: true } as never }); + editor = { + ctx, + view: new EditorView({ + state: EditorState.create({ + doc: "😀text", + extensions: [EditorState.allowMultipleSelections.of(true), decorationExtension(ctx)], + }), + parent: document.body, + }), + }; + _addEditor(editor); +}); +afterEach(() => { + setPluginDecorations("test", []); + _removeEditor(editor); + editor.view.destroy(); + useProject.setState({ project: null }); +}); + +it("renders plain annotations, maps them through edits, and withdraws them", () => { + setPluginDecorations("test", [set()]); + expect(editor.view.dom.textContent).toContain("<plain>"); + expect(editor.view.dom.querySelector('[title="hover"]')?.textContent).toBe("te"); + expect(editor.view.dom.querySelector("plain")).toBeNull(); + editor.view.dispatch({ changes: { from: 0, insert: "x" } }); + expect(editor.view.dom.querySelector('[title="hover"]')?.textContent).toBe("te"); + setPluginDecorations("test", []); + expect(editor.view.dom.textContent).not.toContain("<plain>"); +}); + +it("filters project roots and unknown styles and replaces tree badges", () => { + setPluginDecorations("test", [set("/other")]); + expect(editor.view.dom.textContent).not.toContain("<plain>"); + const unknown = set(); + unknown.items[0]!.style = "unknown"; + setPluginDecorations("test", [unknown]); + expect(editor.view.dom.textContent).not.toContain("<plain>"); + const tree = { ...set(), tree: true, items: [], file: { style: "modified", badge: "M", tooltip: "changed" } }; + setPluginDecorations("test", [tree]); + expect(getFileDecoration("/p/a.txt")?.badge).toBe("M"); + setPluginDecorations("test", []); + expect(getFileDecoration("/p/a.txt")?.badge).toBeUndefined(); +}); + +it("selects UTF-16 ranges and reveals without changing text", () => { + handleRuntimeEvent({ type: "editorRanges", path: ctx.path, ranges: [range], reveal: false }); + expect(editor.view.state.selection.main.from).toBe(2); + expect(editor.view.state.selection.main.to).toBe(4); + handleRuntimeEvent({ type: "editorRanges", path: ctx.path, ranges: [range], reveal: true }); + expect(editor.view.state.doc.toString()).toBe("😀text"); + handleRuntimeEvent({ + type: "editorRanges", + path: "/other/a.txt", + ranges: [{ start: { line: 0, col: 0 }, end: { line: 0, col: 1 } }], + reveal: false, + }); + expect(editor.view.state.selection.main.from).toBe(2); +}); + +it("describes ide.commands explicitly in consent", () => { + const description = capabilityDescription({ + id: "ide.commands", + description: "", + optional: false, + granted: false, + risk: "high", + }); + expect(description).toContain("including running programs, committing and pushing"); +}); diff --git a/src/features/plugins/__tests__/logEditor.test.tsx b/src/features/plugins/__tests__/logEditor.test.tsx index da0293c34..d25a5c8da 100644 --- a/src/features/plugins/__tests__/logEditor.test.tsx +++ b/src/features/plugins/__tests__/logEditor.test.tsx @@ -8,6 +8,7 @@ import { useContributions } from "@/workbench/contributions"; import { usePlugins } from "../store"; import { startViewUi } from "../uiPoints"; import { registerLogActions } from "../views/logActions"; +import { setPluginOutput, usePluginOutput } from "../views/outputStore"; import { PluginLogEditor } from "../views/logEditor"; import { boundedLog, logTabData, type PluginLogEditorData } from "../views/logTab"; import { logTabPtyBackend, webPtyBackend } from "../views/ptyBackend"; @@ -71,6 +72,33 @@ function setup(extra: Partial<PluginLogEditorData> = {}) { } describe("plugin streaming log tabs", () => { + it("shows output channel snapshots in the read-only log editor without starting a process", () => { + vi.mocked(webPtyBackend).mockClear(); + vi.mocked(logTabPtyBackend).mockClear(); + usePluginOutput.setState({ channels: new Map() }); + setPluginOutput("probe", "build", "first output"); + const data: PluginLogEditorData = { + pluginId: "probe", + view: "", + key: "build", + title: "Build output", + program: "", + args: [], + toggles: [], + origin: "output", + }; + const rendered = render(<PluginLogEditor input={{ kind: "pluginLog", key: "output", data }} />); + const view = EditorView.findFromDOM(rendered.container.querySelector(".cm-content") as HTMLElement)!; + expect(view.state.doc.toString()).toBe("first output"); + expect(view.state.readOnly).toBe(true); + act(() => setPluginOutput("probe", "build", "next output")); + expect(view.state.doc.toString()).toBe("next output"); + act(() => setPluginOutput("probe", "build", "")); + expect(view.state.doc.length).toBe(0); + expect(webPtyBackend).not.toHaveBeenCalled(); + expect(logTabPtyBackend).not.toHaveBeenCalled(); + }); + it("rejects malformed requests and exact-grant failures instead of rewriting commands", () => { usePlugins.setState({ plugins: [plugin] }); for (const bad of [ diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index 3a79818ca..b51f5f60d 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -502,6 +502,60 @@ describe("bridge events", () => { expect(useTasks.getState().tasks.find((t) => t.id === "plugin:7")?.done).toBe(true); }); + it("keeps a window status override above global updates and falls back on removal", () => { + const global = { text: "global", tooltip: null, command: null }; + const local = { text: "window", tooltip: null, command: null }; + handleRuntimeEvent({ type: "statusItemChanged", pluginId: "scope", id: "state", item: global }); + handleRuntimeEvent({ type: "windowStatusItemChanged", pluginId: "scope", id: "state", item: local }); + handleRuntimeEvent({ + type: "statusItemChanged", + pluginId: "scope", + id: "state", + item: { ...global, text: "updated" }, + }); + expect(usePluginStatusItems.getState().items.get("scope/state")?.item.text).toBe("window"); + handleRuntimeEvent({ type: "windowStatusItemChanged", pluginId: "scope", id: "state", item: null }); + expect(usePluginStatusItems.getState().items.get("scope/state")?.item.text).toBe("updated"); + handleRuntimeEvent({ type: "statusItemChanged", pluginId: "scope", id: "state", item: null }); + }); + + it("executes host command requests through actions and returns plugin command results", async () => { + const answerCommand = vi.fn().mockResolvedValue(undefined); + const runCommand = vi.fn().mockResolvedValue("plugin result"); + setPluginsBackend({ ...backend, answerCommand, runCommand }); + const run = vi.fn(); + const dispose = registerAction({ id: "test.host-command", title: "Test", run }); + try { + handleRuntimeEvent({ + type: "executeCommand", + requestId: "ide", + id: "test.host-command", + arg: "argument", + chain: ["caller"], + }); + await waitFor(() => expect(answerCommand).toHaveBeenCalledWith("ide", { kind: "value", value: null })); + expect(run).toHaveBeenCalledWith(expect.objectContaining({ arg: "argument" })); + usePlugins.setState({ + ui: [{ pluginId: "other", point: "commands", key: "test", decl: { id: "test", title: "Test" } }], + }); + handleRuntimeEvent({ + type: "executeCommand", + requestId: "plugin", + id: "plugin.other.test", + arg: "argument", + chain: ["caller"], + }); + await waitFor(() => + expect(answerCommand).toHaveBeenCalledWith("plugin", { kind: "value", value: "plugin result" }), + ); + expect(runCommand).toHaveBeenCalledWith("other", "test", "argument", null, [], undefined, ["caller"]); + handleRuntimeEvent({ type: "executeCommand", requestId: "missing", id: "unknown", arg: null, chain: ["caller"] }); + await waitFor(() => expect(answerCommand).toHaveBeenCalledWith("missing", { kind: "notFound" })); + } finally { + dispose(); + } + }); + it("runs notification buttons as commands of the publishing plugin with their arguments", async () => { const runCommand = vi.fn().mockResolvedValue(null); setPluginsBackend({ ...backend, runCommand }); diff --git a/src/features/plugins/__tests__/projectUi.test.tsx b/src/features/plugins/__tests__/projectUi.test.tsx index 4edb884a5..83e44bbb7 100644 --- a/src/features/plugins/__tests__/projectUi.test.tsx +++ b/src/features/plugins/__tests__/projectUi.test.tsx @@ -74,3 +74,28 @@ describe("project UI requests", () => { } }); }); + +it("acknowledges database connection only after the normal project action succeeds", async () => { + const run = vi.fn().mockResolvedValue(undefined); + dispose = registerAction({ id: "db.pluginConnect", title: "Connect Plugin Data Source", run }); + const answerCommand = vi.fn().mockResolvedValue(true); + setPluginsBackend({ ...createMemoryPluginsBackend(), answerCommand }); + handleRuntimeEvent({ + type: "windowRequest", + pluginId: "p", + requestId: "db", + request: { kind: "databaseConnect", id: "owned" }, + }); + await waitFor(() => expect(answerCommand).toHaveBeenCalledWith("db", { kind: "value", value: null })); + expect(run).toHaveBeenCalledWith(expect.objectContaining({ arg: "owned" })); + run.mockRejectedValueOnce(new Error("offline")); + handleRuntimeEvent({ + type: "windowRequest", + pluginId: "p", + requestId: "failed", + request: { kind: "databaseConnect", id: "owned" }, + }); + await waitFor(() => + expect(answerCommand).toHaveBeenCalledWith("failed", { kind: "failed", value: "Error: offline" }), + ); +}); diff --git a/src/features/plugins/__tests__/runTargets.test.ts b/src/features/plugins/__tests__/runTargets.test.ts new file mode 100644 index 000000000..6e3f2ca39 --- /dev/null +++ b/src/features/plugins/__tests__/runTargets.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { useProject } from "@/platform/project"; +import { usePluginRunTargets } from "@/platform/pluginRunTargets"; +import { withRunTarget } from "@/platform/runTargetSelection"; +import type { RegisteredRunTarget, RunConfig, Settings } from "@/bindings"; +const mocks = vi.hoisted(() => ({ list: vi.fn(), answer: vi.fn(), notify: vi.fn(), expire: vi.fn() })); +vi.mock("../backend", () => ({ pluginsBackend: () => ({ runTargets: mocks.list, answerRunTarget: mocks.answer }) })); +vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify: mocks.notify, expire: mocks.expire }) })); +import { refreshRunTargets, startRunTargets } from "../runTargets"; +const target: RegisteredRunTarget = { + id: "plugin-target:4:testddev", + pluginId: "test", + pluginName: "Test", + prompt: true, + target: { + id: "ddev", + name: "DDEV", + program: "ddev", + args: ["exec"], + workdirArgs: [], + containerRoot: null, + commands: [], + }, +}; +beforeEach(() => { + vi.clearAllMocks(); + useProject.setState({ project: { root: `/project-${Math.random()}` } as never, settings: {} }); + usePluginRunTargets.setState({ targets: [] }); + mocks.list.mockResolvedValue([target]); +}); +it("loads settings-window targets after its project arrives without prompting there", async () => { + useProject.setState({ project: null }); + const stop = startRunTargets(false); + try { + useProject.setState({ project: { root: "/settings-owner" } as never }); + await vi.waitFor(() => expect(usePluginRunTargets.getState().targets).toEqual([target])); + expect(mocks.notify).not.toHaveBeenCalled(); + mocks.list.mockResolvedValue([]); + await refreshRunTargets(false); + expect(usePluginRunTargets.getState().targets).toEqual([]); + expect(mocks.answer).not.toHaveBeenCalled(); + } finally { + stop(); + } +}); +it("offers Use / Not now once without changing the selection on registration", async () => { + const original = useProject.getState().settings; + await refreshRunTargets(); + expect(useProject.getState().settings).toBe(original); + expect(mocks.answer).not.toHaveBeenCalled(); + const notification = mocks.notify.mock.calls[0]![0]; + expect(notification.body).toBe("Use DDEV for running PHP / Node / Composer in this project?"); + expect(notification.actions.map((a: { label: string }) => a.label)).toEqual(["Use", "Not now"]); + await refreshRunTargets(); + expect(mocks.notify).toHaveBeenCalledTimes(1); + mocks.answer.mockResolvedValue(original); + mocks.list.mockResolvedValue([{ ...target, prompt: false }]); + notification.actions[1].run(); + await vi.waitFor(() => expect(mocks.answer).toHaveBeenCalledWith(target.id, false)); + expect(useProject.getState().settings).toBe(original); +}); +it("Use applies only the host-returned settings and a removed target disappears", async () => { + const settings: Settings = { + run: { defaultTarget: target.id }, + languages: { php: { runTarget: target.id }, node: { runTarget: target.id } }, + }; + await refreshRunTargets(); + mocks.answer.mockResolvedValue(settings); + mocks.list.mockResolvedValue([{ ...target, prompt: false }]); + mocks.notify.mock.calls[0]![0].actions[0].run(); + await vi.waitFor(() => expect(useProject.getState().settings).toBe(settings)); + mocks.list.mockResolvedValue([]); + await refreshRunTargets(); + expect(usePluginRunTargets.getState().targets).toEqual([]); + expect(mocks.expire).toHaveBeenCalledWith(`plugin-run-target:${target.id}`); +}); +it("explicit local and per-config selections win over interpreter and project defaults", () => { + useProject.setState({ + settings: { + run: { defaultTarget: "default" }, + languages: { php: { runTarget: "php-target" }, node: { runTarget: "node-target" } }, + }, + }); + const config = { command: "php", kind: "php" } as RunConfig; + expect(withRunTarget(config).target).toBe("php-target"); + expect(withRunTarget({ ...config, command: "npm", kind: "npm" }).target).toBe("node-target"); + expect(withRunTarget({ ...config, command: "cargo", kind: "cargo" }).target).toBe("default"); + expect(withRunTarget({ ...config, target: "" }).target).toBe(""); + expect(withRunTarget({ ...config, target: "explicit" }).target).toBe("explicit"); +}); diff --git a/src/features/plugins/__tests__/views.test.tsx b/src/features/plugins/__tests__/views.test.tsx index 8f008fe1b..c4d78af60 100644 --- a/src/features/plugins/__tests__/views.test.tsx +++ b/src/features/plugins/__tests__/views.test.tsx @@ -713,3 +713,32 @@ describe("API 1.3 read-only values and table rows", () => { }, ); }); + +it("carries zero-based problem and search coordinates as JSON and selected paths", () => { + const off = registerAction({ id: "plugin.p.check", title: "Inspect", run: () => {} }); + const plugin = { id: "p", name: "P", status: "active", enabled: true } as unknown as Parameters< + typeof syncUiContributions + >[1][number]; + syncUiContributions( + ["problems.context", "searchResults.context"].map((menu) => ({ + pluginId: "p", + point: "menus", + key: menu, + decl: { menu, command: "check" }, + })), + [plugin], + ); + for (const menu of ["problems", "searchResults"] as const) { + const item = pluginMenuItems(menu, ["/p/a.sql"], { line: 3, column: 5 })[0]!; + expect(item).toMatchObject({ + kind: "action", + action: "plugin.p.check", + arg: { path: "/p/a.sql", paths: ["/p/a.sql"], line: 3, column: 5 }, + }); + expect(commandArgs("arg" in item ? item.arg : null)).toEqual([ + '{"path":"/p/a.sql","line":3,"column":5}', + ["/p/a.sql"], + ]); + } + off(); +}); diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index 4612feec1..c83ed774b 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -1,3 +1,4 @@ +import { useProject } from "@/platform/project"; /** * Plugin host backend: `plugin_*` IPC + a `plugin_subscribe` event channel in the desktop * app; an in-memory simulation (the bundled samples) in plain-browser @@ -9,6 +10,7 @@ import type { CapabilityInfo, CatalogPlugin, CommitAnswer, + CommandAnswer, CommitSelection, Consent, EventKind, @@ -35,6 +37,7 @@ import type { WorkbenchEvent, } from "@/bindings"; import { tx } from "@/platform/i18n"; +import { useRemote } from "@/platform/remote"; import { call, commands, IpcError, isCancelled, isTauri, nextRequestId } from "@/platform/ipc"; import { editorContext } from "./editorContext"; @@ -46,6 +49,14 @@ export interface PluginsBackend { installDir(path: string, consent: Consent): Promise<PluginInfo>; /** Confirms a plugin waiting for consent. */ grant(id: string, consent: Consent): Promise<PluginInfo>; + chooseProgram( + pluginId: string, + capability: string, + scope: "global" | "project", + path: string | null, + ): Promise<PluginInfo>; + program(pluginId: string, program: string, scope: "global" | "project"): Promise<string>; + pickProgram(): Promise<string | null>; setCapability(id: string, capability: string, granted: boolean): Promise<PluginInfo>; /** Validated assets (icon theme mapping and SVGs) whose id starts with `prefix`. */ assets(pluginId: string, prefix: string): Promise<[string, string][]>; @@ -63,6 +74,7 @@ export interface PluginsBackend { editor: PluginEditor | null, paths: string[], settingsScope?: "global" | "project", + commandChain?: string[], ): Promise<string | null>; /** Workbench events plugins subscribed to (see `eventKinds`). */ postEvents(events: WorkbenchEvent[]): Promise<void>; @@ -75,6 +87,17 @@ export interface PluginsBackend { /** Reports the answer to a plugin's `promptRequested`: keys, `[text]`, anything for OK, `null` when dismissed. */ answerPrompt(promptId: string, answer: string[] | null): Promise<void>; /** This window's answer to a plugin's `commitRequested`. */ + answerCommand(requestId: string, answer: CommandAnswer): Promise<void>; + parseTestResults(config: string, chunk: string): Promise<import("@/bindings").PluginTestEvent[] | null>; + closeTerminalSession(operation: string): Promise<void>; + terminalSessionSpawn( + operation: string, + cols: number, + rows: number, + output: Channel<string>, + ): Promise<import("@/bindings").TerminalInfo>; + finishOperation(id: string, status: number | null, stopped: boolean): Promise<void>; + contentTabAction(pluginId: string, action: import("@/bindings").ContentTabAction): Promise<void>; answerCommit(requestId: string, answer: CommitAnswer): Promise<void>; /** Runs plugins' before-commit checks on what `selection` commits. */ commitChecks(selection: CommitSelection, checks: PluginCommitCheckRef[]): Promise<PluginCommitCheckResult[]>; @@ -104,6 +127,8 @@ export interface PluginsBackend { /** The detail tabs of item `key` in a Services or `detail = true` view. */ serviceDetail(pluginId: string, view: string, key: string): Promise<ServiceTab[]>; /** The HTTP routes a plugin reports for this window's project (`[[contributes.endpoints]]`). */ + runTargets(): Promise<import("@/bindings").RegisteredRunTarget[]>; + answerRunTarget(id: string, useTarget: boolean): Promise<import("@/bindings").Settings>; endpoints(pluginId: string): Promise<PluginEndpoint[]>; /** * Runs the program of node `key`'s log tab `tab` (with the toggles in `on`) and streams its @@ -175,6 +200,20 @@ export const tauriPluginsBackend: PluginsBackend = { previewInstall: (path) => call(commands.pluginPreviewInstall(path)), installDir: (path, consent) => call(commands.pluginInstallDir(path, consent)), grant: (id, consent) => call(commands.pluginGrant(id, consent)), + chooseProgram: async (id, cap, scope, path) => { + const remote = !!useRemote.getState().session; + if (remote && path !== null) await call(commands.pluginSetCapability(id, cap, true)); + const chosen = await call(commands.pluginChooseProgram(id, cap, scope, path)); + if (remote && !chosen.capabilities.some((c) => c.id === cap && c.granted)) + await call(commands.pluginSetCapability(id, cap, false)); + return chosen; + }, + program: (id, program, scope) => call(commands.pluginProgram(id, program, scope)), + pickProgram: async () => { + const { open } = await import("@tauri-apps/plugin-dialog"); + const picked = await open({ directory: false, multiple: false, title: tx("Choose plugin executable") }); + return (Array.isArray(picked) ? picked[0] : picked) ?? null; + }, setCapability: (id, capability, granted) => call(commands.pluginSetCapability(id, capability, granted)), assets: (pluginId, prefix) => call(commands.pluginAssets(pluginId, prefix)), activationEvent: async (kind, value) => { @@ -185,10 +224,12 @@ export const tauriPluginsBackend: PluginsBackend = { await call(commands.pluginUninstall(id)); }, reload: (id) => call(commands.pluginReload(id)), - runCommand: (pluginId, commandId, arg, editor, paths, settingsScope) => - call(commands.pluginRunCommand(pluginId, commandId, arg, editor, paths, settingsScope ?? null)), + runCommand: (pluginId, commandId, arg, editor, paths, settingsScope, commandChain) => + call( + commands.pluginRunCommand(pluginId, commandId, arg, editor, paths, settingsScope ?? null, commandChain ?? null), + ), postEvents: async (events) => { - await commands.pluginPostEvents(events); + await commands.pluginPostEvents(events, editorContext()); }, eventKinds: () => commands.pluginEventKinds(), statusItems: () => commands.pluginStatusItems(), @@ -199,6 +240,21 @@ export const tauriPluginsBackend: PluginsBackend = { answerPrompt: async (promptId, answer) => { await commands.pluginAnswerPrompt(promptId, answer); }, + parseTestResults: (config, chunk) => call(commands.pluginParseTestResults(config, chunk)), + closeTerminalSession: async (operation) => { + await call(commands.pluginTerminalSessionClose(operation)); + }, + terminalSessionSpawn: (operation, cols, rows, output) => + call(commands.pluginTerminalSessionSpawn(operation, cols, rows, output)), + finishOperation: async (id, status, stopped) => { + await call(commands.pluginFinishOperation(id, status, stopped)); + }, + contentTabAction: async (pluginId, action) => { + await call(commands.pluginContentTabAction(pluginId, action)); + }, + answerCommand: async (requestId, answer) => { + await call(commands.pluginAnswerCommand(requestId, answer)); + }, answerCommit: async (requestId, answer) => { await commands.pluginAnswerCommit(requestId, answer); }, @@ -239,6 +295,8 @@ export const tauriPluginsBackend: PluginsBackend = { await call(commands.pluginViewEvent(pluginId, view, event, doc, doc == null ? editorContext() : null)); }, serviceDetail: (pluginId, view, key) => call(commands.pluginServiceDetail(pluginId, view, key)), + runTargets: () => call(commands.pluginRunTargets()), + answerRunTarget: (id, useTarget) => call(commands.pluginAnswerRunTarget(id, useTarget)), endpoints: (pluginId) => call(commands.pluginEndpoints(pluginId)), serviceLog: (pluginId, view, key, tab, on, onLine, onError, onExit) => { const request = nextRequestId(); @@ -520,6 +578,11 @@ export function createMemoryPluginsBackend( emit({ type: "commandsChanged" }); return { ...p }; }, + chooseProgram: async (id) => { + return { ...find(id) }; + }, + program: async (_id, program) => program, + pickProgram: async () => null, setCapability: async (id, capability, granted) => { const p = find(id); p.capabilities = p.capabilities.map((c) => (c.id === capability ? { ...c, granted } : c)); @@ -593,7 +656,17 @@ export function createMemoryPluginsBackend( }), viewEvent: async () => {}, serviceDetail: async () => [], + runTargets: async () => [], + answerRunTarget: async () => useProject.getState().settings as import("@/bindings").Settings, endpoints: async () => [], + answerCommand: async () => {}, + finishOperation: async () => {}, + closeTerminalSession: async () => {}, + terminalSessionSpawn: async () => { + throw new Error("Terminal unavailable"); + }, + parseTestResults: async () => null, + contentTabAction: async () => {}, answerCommit: async () => {}, commitChecks: async () => [], serviceLog: (pluginId, view, key, _tab, _on, _onLine, onError) => { diff --git a/src/features/plugins/bridge.ts b/src/features/plugins/bridge.ts index a0fd057ce..328b0c2e9 100644 --- a/src/features/plugins/bridge.ts +++ b/src/features/plugins/bridge.ts @@ -36,6 +36,7 @@ import { defineBuiltinUiPoints } from "./points"; import { localizeContributions, loadPluginCatalogs, usePluginCatalogs } from "./pluginText"; import { clearUiContributions, syncUiContributions } from "./projection"; import { handleRuntimeEvent, refreshEventKinds, startRuntimeUi } from "./runtimeUi"; +import { refreshRunTargets } from "./runTargets"; import { grantPlugin, runPluginCommand, @@ -330,6 +331,7 @@ export function handlePluginEvent(e: PluginEvent) { */ export function followPluginChanges(): () => void { return pluginsBackend().onEvent((e) => { + if (e.type === "runTargetsChanged") void refreshRunTargets(false).catch(() => {}); if (e.type === "commandsChanged" || e.type === "themeChanged" || e.type === "languagesChanged") handlePluginEvent(e); }); diff --git a/src/features/plugins/consent.tsx b/src/features/plugins/consent.tsx index b2f1268a6..8daf92b00 100644 --- a/src/features/plugins/consent.tsx +++ b/src/features/plugins/consent.tsx @@ -30,6 +30,26 @@ export function sortByRisk(caps: readonly CapabilityInfo[]): CapabilityInfo[] { return [...caps].sort((a, b) => RISK_ORDER[a.risk] - RISK_ORDER[b.risk] || a.id.localeCompare(b.id)); } +/** Parameterised permission text, translated independently of the granted host/path. */ +export function capabilityDescription(cap: CapabilityInfo): string { + if (cap.id === "database.sources") + return tx("Manage its own database sources and store their passwords in trusted projects"); + if (cap.id === "ide.commands") { + return tx( + "Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing", + ); + } + if (cap.id.startsWith("net.http:")) { + const host = cap.id.slice(9); + return tx`Connect to ${host} over unencrypted HTTP (from the machine the project is on)`; + } + if (cap.id.startsWith("ipc:")) { + const path = cap.id.slice(4); + return tx`Connect to local socket ${path} — equivalent to running arbitrary code on the project machine`; + } + return tx(cap.description); +} + function ask(req: ConsentRequest): Promise<Consent | null> { return new Promise((resolve) => usePending.setState((s) => ({ queue: [...s.queue, { req, resolve }] }))); } @@ -84,28 +104,30 @@ function ConsentDialog({ pending, onDone }: { pending: Pending; onDone: () => vo ) : ( <ul className={styles.items} aria-label={tx("Requested permissions")}> {caps.map((c) => ( - <li key={c.id} className={styles.item}> - {c.optional ? ( - <Checkbox - checked={accepted.has(c.id)} - onChange={(on) => - setAccepted((s) => { - const next = new Set(s); - if (on) next.add(c.id); - else next.delete(c.id); - return next; - }) - } - aria-label={tx`Allow ${c.id}`} - /> - ) : ( - <span className={styles.consentRequired} title={tx("Required")}> - • - </span> - )} - <span className={styles.itemMain}>{tx(c.description)}</span> - <span className={styles.mono}>{c.id}</span> - <Badge tone={RISK_LABEL[c.risk].tone}>{RISK_LABEL[c.risk].label}</Badge> + <li key={c.id} className={styles.capabilityItem}> + <div className={styles.capabilityHeader}> + {c.optional ? ( + <Checkbox + checked={accepted.has(c.id)} + onChange={(on) => + setAccepted((s) => { + const next = new Set(s); + if (on) next.add(c.id); + else next.delete(c.id); + return next; + }) + } + aria-label={tx`Allow ${c.id}`} + /> + ) : ( + <span className={styles.consentRequired} title={tx("Required")}> + • + </span> + )} + <span className={styles.mono}>{c.id}</span> + <Badge tone={RISK_LABEL[c.risk].tone}>{RISK_LABEL[c.risk].label}</Badge> + </div> + <p className={styles.capabilityDescription}>{capabilityDescription(c)}</p> </li> ))} </ul> diff --git a/src/features/plugins/decorations.module.css b/src/features/plugins/decorations.module.css new file mode 100644 index 000000000..572ea515f --- /dev/null +++ b/src/features/plugins/decorations.module.css @@ -0,0 +1,39 @@ +.highlight { + background: var(--accent-soft); +} +.search { + background: var(--bg-search-match); + color: var(--fg-search-match); +} +.added { + background: color-mix(in srgb, var(--vcs-added) 18%, transparent); +} +.modified { + background: color-mix(in srgb, var(--vcs-modified) 18%, transparent); +} +.removed { + background: color-mix(in srgb, var(--vcs-deleted) 18%, transparent); +} +.warning { + text-decoration: underline wavy var(--warning); +} +.error { + text-decoration: underline wavy var(--danger); +} +.info { + text-decoration: underline dotted var(--info); +} +.underline { + text-decoration: underline; +} +.strikethrough { + text-decoration: line-through; +} +.muted { + color: var(--fg-muted); +} +.after { + white-space: pre; + margin-inline-start: calc(var(--space-2) / 2); + color: var(--fg-muted); +} diff --git a/src/features/plugins/decorations.ts b/src/features/plugins/decorations.ts new file mode 100644 index 000000000..cdf6e439e --- /dev/null +++ b/src/features/plugins/decorations.ts @@ -0,0 +1,149 @@ +import { StateEffect, StateField, type Text } from "@codemirror/state"; +import { Decoration, EditorView, WidgetType, type DecorationSet as CmDecorations } from "@codemirror/view"; +import type { DecorationSet, Range } from "@/bindings"; +import { registerEditorContribution, useEditorRegistry, type EditorDocContext } from "@/editor/api"; +import { commands, isTauri } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { setFileDecorations, type FileDecoration } from "@/workbench/contributions"; +import styles from "./decorations.module.css"; + +const snapshots = new Map<string, DecorationSet[]>(); +const refresh = StateEffect.define<null>(); +const versions = new Map<string, number>(); +const colors: Record<string, string> = { + highlight: "var(--accent)", + search: "var(--fg-search-match)", + added: "var(--vcs-added)", + modified: "var(--vcs-modified)", + removed: "var(--vcs-deleted)", + warning: "var(--warning)", + error: "var(--danger)", + info: "var(--info)", + underline: "var(--fg-editor)", + strikethrough: "var(--fg-editor)", + muted: "var(--fg-muted)", +}; + +function currentSets(sets: DecorationSet[]) { + const root = useProject.getState().project?.root; + return sets.filter((s) => s.root === root); +} + +function updateTree(plugin: string, sets: DecorationSet[]) { + const items = new Map<string, FileDecoration>(); + for (const s of currentSets(sets)) + if (s.tree && s.file && Object.hasOwn(colors, s.file.style)) { + items.set(s.path, { + color: colors[s.file.style], + badge: s.file.badge ?? undefined, + tooltip: s.file.tooltip ?? undefined, + faded: s.file.style === "muted", + error: s.file.style === "error", + }); + } + setFileDecorations(`plugin:${plugin}`, items); +} + +/** Replace a plugin's snapshots. Project roots are filtered on every render. */ +export function setPluginDecorations(plugin: string, sets: DecorationSet[]) { + versions.set(plugin, (versions.get(plugin) ?? 0) + 1); + snapshots.set(plugin, sets); + updateTree(plugin, sets); + for (const editor of useEditorRegistry.getState().editors) editor.view.dispatch({ effects: refresh.of(null) }); +} + +function offset(doc: Text, pos: Range["start"]): number | null { + if (pos.line >= doc.lines) return null; + const line = doc.line(pos.line + 1); + return pos.col <= line.length ? line.from + pos.col : null; +} + +class AfterText extends WidgetType { + constructor( + readonly text: string, + readonly style: string, + readonly hover: string | null, + ) { + super(); + } + override eq(other: AfterText) { + return this.text === other.text && this.style === other.style && this.hover === other.hover; + } + override toDOM() { + const span = document.createElement("span"); + span.className = `${styles.after} ${this.style}`; + span.textContent = this.text; + if (this.hover) span.title = this.hover; + return span; + } +} + +export function decorationExtension(ctx: EditorDocContext) { + const build = (doc: Text): CmDecorations => { + const ranges = []; + for (const sets of snapshots.values()) + for (const set of currentSets(sets)) + if (!set.tree && set.path === ctx.path) { + for (const item of set.items) { + const className = Object.hasOwn(colors, item.style) ? styles[item.style] : undefined; + if (!className) continue; + const from = offset(doc, item.range.start), + to = offset(doc, item.range.end); + if (from == null || to == null || from > to) continue; + if (from < to) + ranges.push( + Decoration.mark({ class: className, attributes: item.hover ? { title: item.hover } : {} }).range( + from, + to, + ), + ); + if (item.after) + ranges.push( + Decoration.widget({ widget: new AfterText(item.after, className, item.hover), side: 1 }).range(to), + ); + } + } + return Decoration.set(ranges, true); + }; + return StateField.define<CmDecorations>({ + create: (state) => build(state.doc), + update: (value, tr) => (tr.effects.some((e) => e.is(refresh)) ? build(tr.state.doc) : value.map(tr.changes)), + provide: (field) => EditorView.decorations.from(field), + }); +} + +/** Catch up editors and project trees opened after publication. */ +export function startPluginDecorations(): () => void { + let disposed = false; + let revision = 0; + const load = () => { + const at = ++revision; + const before = new Map(versions); + if (isTauri()) + void Promise.resolve(commands.pluginDecorationSets()) + .then((all) => { + if (disposed || at !== revision) return; + for (const plugin of snapshots.keys()) + if (!all[plugin] && versions.get(plugin) === before.get(plugin)) setPluginDecorations(plugin, []); + for (const [plugin, sets] of Object.entries(all)) + if (versions.get(plugin) === before.get(plugin)) setPluginDecorations(plugin, sets); + }) + .catch(() => {}); + }; + const offProject = useProject.subscribe((s, prev) => { + if (s.project?.root === prev.project?.root) return; + for (const [plugin, sets] of snapshots) updateTree(plugin, sets); + for (const editor of useEditorRegistry.getState().editors) editor.view.dispatch({ effects: refresh.of(null) }); + load(); + }); + const off = registerEditorContribution({ id: "plugins.decorations", extensions: decorationExtension }); + load(); + return () => { + disposed = true; + offProject(); + off(); + for (const plugin of snapshots.keys()) setPluginDecorations(plugin, []); + snapshots.clear(); + versions.clear(); + }; +} diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts index d2cf79c39..2f06e61eb 100644 --- a/src/features/plugins/index.ts +++ b/src/features/plugins/index.ts @@ -1,3 +1,4 @@ +import { pluginLanguageVisuals } from "./languageVisuals"; /** * Plugins: Settings › Plugins (installed list, enable/disable, install from * disk with a permission page, uninstall, theme selection) and the runtime @@ -33,7 +34,9 @@ import { defineBuiltinUiPoints } from "./points"; import { startViewUi } from "./uiPoints"; import { showInstalledIntellijThemes, useIntellijThemesDialog } from "./intellijThemesDialogStore"; import { startWorkspaceContains } from "./workspaceContains"; +import { startPluginDecorations } from "./decorations"; import { startIndexedMarkers } from "./indexedMarkers"; +import { startRunTargets } from "./runTargets"; import { startPluginNavigation } from "./navigation"; import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, updateAllPlugins, usePlugins } from "./store"; @@ -62,6 +65,7 @@ export function activateSettingsPages(): () => void { provideConsentDialog(), defineBuiltinUiPoints(), followPluginChanges(), + startRunTargets(false), syncProjection(), trackPluginTheme(), trackIconTheme(), @@ -151,10 +155,12 @@ export function activate(): () => void { startPluginBridge(), startPluginNavigation(), startIndexedMarkers(), + startPluginDecorations(), // Plugin custom editors (tab kind) and context menu entries. startViewUi((input) => void editorService().openInput(input)), startWorkspaceContains(), // Gutter icons from plugins' `line-markers` providers (next to the LSP ones). + registerEditorContribution({ id: "plugins.languageFeatures", order: 58, extensions: pluginLanguageVisuals }), registerEditorContribution({ id: "plugins.lineMarkers", order: 57, extensions: pluginLineMarkerGutter }), // Dev plugins (`<project>/.openidea/plugins`) depend on the open project. useProject.subscribe((s, prev) => { diff --git a/src/features/plugins/languageVisuals.test.ts b/src/features/plugins/languageVisuals.test.ts new file mode 100644 index 000000000..5186d7349 --- /dev/null +++ b/src/features/plugins/languageVisuals.test.ts @@ -0,0 +1,72 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { syntaxHighlighting, defaultHighlightStyle } from "@codemirror/language"; +const ipc = vi.hoisted(() => ({ + pluginLanguageVisuals: vi.fn(), + lspExecuteCommand: vi.fn(async () => ({ status: "ok", data: null })), +})); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original<typeof import("@/platform/ipc")>()), + commands: ipc, +})); +const { injectedHighlights, pluginLanguageVisuals } = await import("./languageVisuals"); +const ctx = { doc: 7, path: "/p/a.txt", language: "text", uiMode: null, readOnly: false }; +const views: EditorView[] = []; +afterEach(() => { + views.splice(0).forEach((v) => v.destroy()); + vi.useRealTimers(); + vi.clearAllMocks(); +}); +it("highlights only the embedded slice with its own parser and the editor theme", async () => { + const state = EditorState.create({ + doc: "host SELECT a FROM t host", + extensions: [syntaxHighlighting(defaultHighlightStyle)], + }); + const deco = await injectedHighlights(state, [ + { range: { start: { line: 0, col: 5 }, end: { line: 0, col: 20 } }, language: "sql" }, + ]); + expect(deco.size).toBeGreaterThan(0); + const cursor = deco.iter(); + while (cursor.value) { + expect(cursor.from).toBeGreaterThanOrEqual(5); + expect(cursor.to).toBeLessThanOrEqual(20); + expect(cursor.value.spec.class).toMatch(/\S/); + cursor.next(); + } +}); +it("renders owned gutter commands and drops answers to superseded buffers", async () => { + vi.useFakeTimers(); + let answer!: (v: unknown) => void; + ipc.pluginLanguageVisuals.mockImplementationOnce( + () => + new Promise((r) => { + answer = r; + }), + ); + const visual = { + version: 2, + injections: [], + colors: [], + gutterActions: [["test", { line: 0, icon: "run", tooltip: "Run test", command: "run", argument: "value" }]], + }; + ipc.pluginLanguageVisuals.mockResolvedValue({ status: "ok", data: visual }); + const view = new EditorView({ + state: EditorState.create({ doc: "before", extensions: pluginLanguageVisuals(ctx) }), + parent: document.body, + }); + views.push(view); + await vi.advanceTimersByTimeAsync(300); + view.dispatch({ changes: { from: 0, to: 6, insert: "after" } }); + answer({ status: "ok", data: visual }); + await vi.advanceTimersByTimeAsync(0); + expect(view.dom.querySelector(".cm-plugin-gutter-action")).toBeNull(); + await vi.advanceTimersByTimeAsync(300); + const button = view.dom.querySelector<HTMLButtonElement>(".cm-plugin-gutter-action")!; + expect(button.getAttribute("aria-label")).toBe("Run test"); + button.click(); + expect(ipc.lspExecuteCommand).toHaveBeenCalledWith( + 7, + expect.objectContaining({ command: "openidea.plugin-language:test:run", arguments: ['"value"'] }), + ); +}); diff --git a/src/features/plugins/languageVisuals.ts b/src/features/plugins/languageVisuals.ts new file mode 100644 index 000000000..d14440932 --- /dev/null +++ b/src/features/plugins/languageVisuals.ts @@ -0,0 +1,190 @@ +/** Embedded highlighting and command gutter actions share the editor-provider refresh. */ +import { + Compartment, + Prec, + RangeSet, + StateEffect, + StateField, + type Extension, + type EditorState, +} from "@codemirror/state"; +import { + Decoration, + EditorView, + GutterMarker, + ViewPlugin, + gutter, + type DecorationSet, + type ViewUpdate, +} from "@codemirror/view"; +import { highlightingFor } from "@codemirror/language"; +import { highlightTree } from "@lezer/highlight"; +import { type EditorDocContext } from "@/editor/api"; +import { rangeToOffsets } from "@/editor/lsp/positions"; +import { pluginVisuals } from "@/editor/lsp/pluginVisuals"; +import { findLanguageDescription, languageInfo, loadSupport } from "@/editor/core/cm/languages"; +import { onNavigationProvidersChanged } from "@/editor/navigationProviders"; +import { call, commands, type GutterAction, type Injection } from "@/platform/ipc"; +import { gutterIconMarkup } from "@/ui/icons"; +import { resolveMarkerIcon, onMarkerIconsChanged } from "./markerIcons"; +import { markerIcon } from "./lineMarkerGutter"; +import styles from "@/editor/navigation/LineMarkers.module.css"; + +export async function injectedHighlights(state: EditorState, injections: readonly Injection[]): Promise<DecorationSet> { + const ranges = await Promise.all( + injections.map(async (i) => { + const { from, to } = rangeToOffsets(state.doc, i.range); + if (from >= to) return []; + const desc = findLanguageDescription(languageInfo(i.language)?.uiMode ?? i.language, ""); + const support = desc ? await loadSupport(desc) : null; + if (!support) return []; + const marks = [] as ReturnType<Decoration["range"]>[]; + highlightTree( + support.language.parser.parse(state.sliceDoc(from, to)), + { style: (tags) => highlightingFor(state, tags) }, + (a, b, cls) => marks.push(Decoration.mark({ class: cls }).range(from + a, from + b)), + ); + return marks; + }), + ); + return Decoration.set(ranges.flat(), true); +} +const setHighlight = StateEffect.define<DecorationSet>(); +const highlights = StateField.define<DecorationSet>({ + create: () => Decoration.none, + update(value, tr) { + for (const e of tr.effects) if (e.is(setHighlight)) return e.value; + return tr.docChanged ? Decoration.none : value; + }, + provide: (f) => Prec.high(EditorView.decorations.from(f)), +}); +class ActionMarker extends GutterMarker { + constructor( + readonly plugin: string, + readonly action: GutterAction, + readonly ctx: EditorDocContext, + ) { + super(); + } + override eq(other: GutterMarker) { + return ( + other instanceof ActionMarker && + other.plugin === this.plugin && + JSON.stringify(other.action) === JSON.stringify(this.action) + ); + } + override toDOM() { + const el = document.createElement("button"); + el.type = "button"; + el.className = `${styles.marker} cm-plugin-gutter-action`; + el.title = this.action.tooltip; + el.setAttribute("aria-label", this.action.tooltip); + el.innerHTML = gutterIconMarkup(markerIcon(this.action.icon)) ?? ""; + el.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + void call( + commands.lspExecuteCommand(this.ctx.doc, { + title: this.action.tooltip, + command: `openidea.plugin-language:${this.plugin}:${this.action.command}`, + arguments: [JSON.stringify(this.action.argument)], + }), + ).catch(console.warn); + }; + return el; + } +} +const setActions = StateEffect.define<{ pos: number; marker: ActionMarker }[]>(); +const actions = StateField.define<{ pos: number; marker: ActionMarker }[]>({ + create: () => [], + update(v, tr) { + for (const e of tr.effects) if (e.is(setActions)) return e.value; + return tr.docChanged ? [] : v; + }, +}); +const column = gutter({ + class: `${styles.gutter} cm-plugin-gutter-actions`, + markers: (view) => + RangeSet.of( + view.state.field(actions).map((a) => a.marker.range(a.pos)), + true, + ), +}); +export function pluginLanguageVisuals(ctx: EditorDocContext): Extension { + if (ctx.doc < 0) return []; + const compartment = new Compartment(); + return [ + highlights, + actions, + compartment.of([]), + ViewPlugin.define( + (view) => + new (class { + timer: ReturnType<typeof setTimeout> | undefined; + generation = 0; + shown = false; + off: () => void; + constructor() { + const a = onNavigationProvidersChanged(() => this.schedule(true)); + const b = onMarkerIconsChanged(() => this.schedule()); + this.off = () => { + a(); + b(); + }; + this.schedule(); + } + schedule(clear = false) { + this.generation++; + clearTimeout(this.timer); + if (clear) + queueMicrotask(() => { + if (view.dom.isConnected) + view.dispatch({ + effects: [setHighlight.of(Decoration.none), setActions.of([]), compartment.reconfigure([])], + }); + this.shown = false; + }); + this.timer = setTimeout(() => void this.run(), 300); + } + update(u: ViewUpdate) { + if (u.docChanged) this.schedule(); + } + async run() { + const generation = this.generation; + const text = view.state.doc; + try { + const result = await pluginVisuals(view, ctx); + const deco = await injectedHighlights(view.state, result.injections); + const placed = await Promise.all( + result.gutterActions + .filter(([, a]) => a.line < text.lines) + .map(async ([plugin, a]) => ({ + pos: text.line(a.line + 1).from, + marker: new ActionMarker(plugin, { ...a, icon: await resolveMarkerIcon(plugin, a.icon) }, ctx), + })), + ); + if (generation !== this.generation || view.state.doc !== text || !view.dom.isConnected) return; + this.shown = !!placed.length; + view.dispatch({ + effects: [ + setHighlight.of(deco), + setActions.of(placed), + compartment.reconfigure(this.shown ? column : []), + ], + }); + } catch { + if (generation === this.generation && view.dom.isConnected) + view.dispatch({ + effects: [setHighlight.of(Decoration.none), setActions.of([]), compartment.reconfigure([])], + }); + } + } + destroy() { + this.generation++; + clearTimeout(this.timer); + this.off(); + } + })(), + ), + ]; +} diff --git a/src/features/plugins/pluginText.ts b/src/features/plugins/pluginText.ts index 8f9b1291c..ce28f0d64 100644 --- a/src/features/plugins/pluginText.ts +++ b/src/features/plugins/pluginText.ts @@ -82,6 +82,7 @@ const TEXT_KEYS: Record<string, readonly string[]> = { statusBar: ["text", "tooltip"], settings: ["title"], fileTemplates: ["name"], + snippets: [], iconThemes: ["label"], themes: ["name"], secrets: ["label", "description"], @@ -91,6 +92,7 @@ function localizeDecl(decl: unknown, keys: readonly string[], t: PluginTranslate if (!decl || typeof decl !== "object") return decl; const out: Record<string, unknown> = { ...(decl as Record<string, unknown>) }; for (const key of keys) if (typeof out[key] === "string") out[key] = t(out[key]); + if (Array.isArray(out.entries)) out.entries = out.entries.map((s) => localizeDecl(s, ["description"], t)); if (Array.isArray(out.properties)) out.properties = out.properties.map((p) => localizeDecl(p, ["title", "description", "group"], t)); if (Array.isArray(out.pages)) out.pages = out.pages.map((p) => localizeDecl(p, ["title"], t)); diff --git a/src/features/plugins/points.tsx b/src/features/plugins/points.tsx index 3e3f5cc2f..60007fb97 100644 --- a/src/features/plugins/points.tsx +++ b/src/features/plugins/points.tsx @@ -5,6 +5,7 @@ */ import { registerEndpointProvider, type EndpointEntry } from "@/workbench/contributions"; import type { PluginInfo, ThemeDef } from "@/bindings"; +import { pluginIconName } from "./pluginIcon"; import { registerAction } from "@/platform/actions"; import { tx } from "@/platform/i18n"; import { addContributedTheme, pluginThemeRef } from "@/platform/themes"; @@ -22,8 +23,15 @@ import styles from "./PluginsPage.module.css"; /** Unpacks an action argument: a bare path, or a menu's `{ path, paths }`. */ export function commandArgs(arg: unknown): [string | null, string[]] { if (typeof arg === "string") return [arg, []]; - const { path, paths } = (arg ?? {}) as { path?: unknown; paths?: unknown }; + const { path, paths, line, column } = (arg ?? {}) as { + path?: unknown; + paths?: unknown; + line?: unknown; + column?: unknown; + }; if (typeof path !== "string" || !Array.isArray(paths)) return [null, []]; + if (typeof line === "number" && typeof column === "number") + return [JSON.stringify({ path, line, column }), paths.filter((p): p is string => typeof p === "string")]; return [path, paths.filter((p): p is string => typeof p === "string")]; } @@ -32,6 +40,7 @@ export const pluginActionId = (pluginId: string, commandId: string) => `plugin.$ interface CommandDecl { id: string; title: string; + icon?: string | null; category?: string | null; when?: string | null; } @@ -181,24 +190,47 @@ function providePluginEndpoints(pluginId: string): () => void { /** Registers the client side of every first-party UI point. */ export function defineBuiltinUiPoints(): () => void { const disposers = [ + defineUiPoint("mcpTools", {}), defineUiPoint("endpoints", { project: ({ pluginId }) => providePluginEndpoints(pluginId) }), defineUiPoint<CommandDecl>("commands", { title: "Commands", - project: ({ pluginId, decl }, { plugin }) => - registerAction({ + project: ({ pluginId, decl }, { plugin }) => { + let disposed = false; + const action = { id: pluginActionId(pluginId, decl.id), title: decl.title, category: decl.category ?? plugin.name, - icon: "package", + icon: + decl.icon && !decl.icon.endsWith(".svg") + ? decl.icon + : plugin.icon + ? pluginIconName(pluginId, plugin.icon) + : "package", when: decl.when ?? undefined, verbatim: true, keywords: ["plugin", plugin.name, pluginId], description: tx`From plugin ${plugin.name}`, - run: async ({ arg }) => { + run: async ({ arg }: { arg?: unknown }) => { const [path, paths] = commandArgs(arg); await runPluginCommand(pluginId, decl.id, path, paths); }, - }), + }; + let off = registerAction(action); + if (decl.icon?.endsWith(".svg")) + void pluginsBackend() + .assets(pluginId, `commandIcon/${decl.id}`) + .then((assets) => { + const svg = assets.find(([id]) => id === `commandIcon/${decl.id}`)?.[1]; + if (!svg || disposed) return; + off(); + off = registerAction({ ...action, icon: pluginIconName(pluginId, { light: svg, dark: null }) }); + }) + .catch(console.warn); + return () => { + disposed = true; + off(); + }; + }, Details: CommandsDetails, }), // Offered by every theme picker while the plugin is live; `followThemeSetting` applies the selected one. diff --git a/src/features/plugins/runTargets.ts b/src/features/plugins/runTargets.ts new file mode 100644 index 000000000..761f77b86 --- /dev/null +++ b/src/features/plugins/runTargets.ts @@ -0,0 +1,79 @@ +import type { RegisteredRunTarget } from "@/bindings"; +import { tx } from "@/platform/i18n"; +import { isTauri } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { usePluginRunTargets } from "@/platform/pluginRunTargets"; +import { notifications } from "@/workbench/contributions"; +import { pluginsBackend } from "./backend"; + +let revision = 0; +const shown = new Set<string>(); +function clearTargets(prompts = true) { + revision++; + if (prompts) + for (const target of usePluginRunTargets.getState().targets) + notifications().expire?.(`plugin-run-target:${target.id}`); + shown.clear(); + usePluginRunTargets.setState({ targets: [] }); +} +export async function refreshRunTargets(prompts = true) { + const request = ++revision; + const root = useProject.getState().project?.root; + if (!root) { + clearTargets(prompts); + return; + } + const targets = await pluginsBackend().runTargets(); + if (request !== revision || root !== useProject.getState().project?.root) return; + for (const previous of prompts ? usePluginRunTargets.getState().targets : []) { + if (!targets.some((t) => t.id === previous.id && t.prompt)) { + notifications().expire?.(`plugin-run-target:${previous.id}`); + shown.delete(`${root}\0${previous.id}`); + } + } + usePluginRunTargets.setState({ targets }); + if (!prompts) return; + for (const target of targets) { + const key = `${root}\0${target.id}`; + if (!target.prompt || shown.has(key)) continue; + shown.add(key); + const answer = async (use: boolean) => { + if (root !== useProject.getState().project?.root) return; + try { + const settings = await pluginsBackend().answerRunTarget(target.id, use); + if (root === useProject.getState().project?.root) useProject.setState({ settings }); + await refreshRunTargets(); + } catch (error) { + shown.delete(key); + notifications().notify({ group: "plugins", level: "error", title: "Run Targets", body: String(error) }); + } + }; + notifyTarget(target, answer); + } +} +function notifyTarget(target: RegisteredRunTarget, answer: (use: boolean) => Promise<void>) { + notifications().notify({ + sticky: true, + group: "plugins", + key: `plugin-run-target:${target.id}`, + level: "info", + title: "Run Targets", + body: tx`Use ${target.target.name} for running PHP / Node / Composer in this project?`, + actions: [ + { label: "Use", run: () => void answer(true) }, + { label: "Not now", run: () => void answer(false) }, + ], + }); +} +export function startRunTargets(prompts = true): () => void { + if (isTauri()) void refreshRunTargets(prompts).catch(() => {}); + const unsubscribe = useProject.subscribe((s, old) => { + if (s.project?.root === old.project?.root) return; + clearTargets(prompts); + void refreshRunTargets(prompts).catch(() => {}); + }); + return () => { + unsubscribe(); + clearTargets(prompts); + }; +} diff --git a/src/features/plugins/runtimeUi.tsx b/src/features/plugins/runtimeUi.tsx index ecd86c00c..74c68c84a 100644 --- a/src/features/plugins/runtimeUi.tsx +++ b/src/features/plugins/runtimeUi.tsx @@ -4,9 +4,19 @@ * workbench events it subscribed to (`events.subscribe`), reported in * batches. */ +import { providePluginTestParser } from "@/workbench/contributions"; +import { + contentTabData, + contentTabKey, + handleContentTabReplyText, + PLUGIN_CONTENT_EDITOR_KIND, + WASM_CONTENT_VIEW, +} from "./views/contentTab"; import { create } from "zustand"; +import { refreshRunTargets, startRunTargets } from "./runTargets"; import type { CommitAnswer, + CommandAnswer, CommitRequest, EventKind, PluginContextKey, @@ -15,8 +25,11 @@ import type { StatusItem, WorkbenchEvent, } from "@/bindings"; -import { onEditorEvent } from "@/editor/api"; -import { executeAction, getAction, onActionExecuted } from "@/platform/actions"; +import { EditorSelection } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { setPluginDecorations } from "./decorations"; +import { editorsFor, onEditorEvent } from "@/editor/api"; +import { executeAction, executeActionOrThrow, getAction, onActionExecuted } from "@/platform/actions"; import { clearContext, setContext, type ContextValue } from "@/platform/context"; import { onIdeEvent } from "@/platform/ipc"; import { openExternalUrl } from "@/platform/openUrl"; @@ -29,8 +42,10 @@ import { notifications, maybeCommitService, maybeEditorService, registerStatusIt import { pickItems, promptInput } from "@/workbench/inputDialogs"; import { confirm } from "@/workbench/messages"; import { removeTask, upsertTask, useTasks } from "@/workbench/tasks"; +import { editorContext } from "./editorContext"; import { pluginsBackend } from "./backend"; import { usePluginT } from "./pluginText"; +import { setPluginOutput } from "./views/outputStore"; import { PLUGIN_LOG_EDITOR_KIND } from "./views/logEditor"; import type { PluginLogEditorData } from "./views/logTab"; import { bumpView, deliverViewMessage } from "./views/viewStore"; @@ -45,11 +60,22 @@ export const usePluginStatusItems = create<{ items: Map<ItemKey, { pluginId: str items: new Map(), })); -function setStatusItem(pluginId: string, id: string, item: StatusItem | null) { +let statusRevision = 0; +let statusRequest = 0; +const globalStatus = new Map<ItemKey, { pluginId: string; item: StatusItem }>(); +const windowStatus = new Map<ItemKey, { pluginId: string; item: StatusItem }>(); + +function setStatusItem(pluginId: string, id: string, item: StatusItem | null, window = false) { + statusRevision++; + const key: ItemKey = `${pluginId}/${id}`; + const scope = window ? windowStatus : globalStatus; + if (item) scope.set(key, { pluginId, item }); + else scope.delete(key); + const effective = windowStatus.get(key) ?? globalStatus.get(key); usePluginStatusItems.setState((s) => { const items = new Map(s.items); - if (item) items.set(`${pluginId}/${id}`, { pluginId, item }); - else items.delete(`${pluginId}/${id}`); + if (effective) items.set(key, effective); + else items.delete(key); return { items }; }); } @@ -161,9 +187,21 @@ function projectStatusItems(): () => void { * window's project is open and trusted, so call it again when that happens. */ function refreshStatusItems() { + const request = ++statusRequest; + const revision = statusRevision; void pluginsBackend() .statusItems() - .then((items) => items.forEach((i) => setStatusItem(i.pluginId, i.id, i.item))) + .then((items) => { + if (request !== statusRequest) return; + if (revision !== statusRevision) { + refreshStatusItems(); + return; + } + globalStatus.clear(); + windowStatus.clear(); + usePluginStatusItems.setState({ items: new Map() }); + items.forEach((i) => setStatusItem(i.pluginId, i.id, i.item, i.window)); + }) .catch(() => {}); } @@ -271,7 +309,10 @@ let postTimer: ReturnType<typeof setTimeout> | null = null; export function refreshEventKinds() { void pluginsBackend() .eventKinds() - .then((kinds) => (wanted = new Set(kinds))) + .then((kinds) => { + wanted = new Set(kinds); + if (wanted.has("settings-changed")) post({ kind: "settingsChanged", value: [] }); + }) .catch(() => {}); } @@ -293,6 +334,9 @@ function trackWorkbenchEvents(): () => void { refreshEventKinds(); const offEditor = onEditorEvent((e) => { if (e.type === "docOpened" && wanted.has("file-opened")) post({ kind: "fileOpened", value: e.path }); + if (e.type === "docClosed" && wanted.has("file-closed")) post({ kind: "fileClosed", value: e.path }); + if (e.type === "inputModeChanged" && wanted.has("input-mode-changed")) + post({ kind: "inputModeChanged", value: { path: e.path, mode: e.mode, sub: e.sub } }); if (e.type === "docSaved" && wanted.has("file-saved")) post({ kind: "fileSaved", value: e.path }); }); const offActions = onActionExecuted((id, _arg, source) => { @@ -303,6 +347,9 @@ function trackWorkbenchEvents(): () => void { value: { action: id, title: action?.title ?? id, source, shortcut: shortcutLabel(id) ?? null }, }); }); + const offSettings = onIdeEvent("settingsChanged", () => { + if (wanted.has("settings-changed")) post({ kind: "settingsChanged", value: [] }); + }); const offVcs = onIdeEvent("vcsChanged", (e) => { if (wanted.has("vcs-changed")) post({ kind: "vcsChanged", value: e.root }); }); @@ -310,6 +357,7 @@ function trackWorkbenchEvents(): () => void { offEditor(); offActions(); offVcs(); + offSettings(); if (postTimer) clearTimeout(postTimer); postTimer = null; queue = []; @@ -395,12 +443,78 @@ async function answerCommit(requestId: string, request: CommitRequest) { .catch(() => {}); } +async function answerCommand(e: Extract<PluginEvent, { type: "executeCommand" }>) { + let answer: CommandAnswer; + try { + const command = usePlugins + .getState() + .ui.find((c) => c.point === "commands" && e.id === `plugin.${c.pluginId}.${(c.decl as { id: string }).id}`); + if (command) { + const value = await pluginsBackend().runCommand( + command.pluginId, + (command.decl as { id: string }).id, + e.arg, + editorContext(), + [], + undefined, + e.chain, + ); + answer = { kind: "value", value }; + } else if (getAction(e.id)) { + await executeAction(e.id, e.arg); + answer = { kind: "value", value: null }; + } else answer = { kind: "notFound" }; + } catch (error) { + answer = { kind: "failed", value: error instanceof Error ? error.message : String(error) }; + } + await pluginsBackend() + .answerCommand(e.requestId, answer) + .catch(() => {}); +} + /** Handles the runtime UI events; returns whether `e` was one. */ export function handleRuntimeEvent(e: PluginEvent): boolean { switch (e.type) { + case "runTargetsChanged": + void refreshRunTargets().catch(() => {}); + return true; + case "decorationsChanged": + setPluginDecorations(e.pluginId, e.sets); + return true; + case "editorOpen": { + const r = e.selection; + void maybeEditorService()?.openFile(e.path, { + focus: true, + selection: r ? { line: r.start.line, col: r.start.col, endLine: r.end.line, endCol: r.end.col } : undefined, + }); + return true; + } + case "editorRanges": { + for (const editor of editorsFor(e.path)) { + const doc = editor.view.state.doc; + const ranges = e.ranges.map((r) => { + if (r.start.line >= doc.lines || r.end.line >= doc.lines) return null; + const start = doc.line(r.start.line + 1), + end = doc.line(r.end.line + 1); + if (r.start.col > start.length || r.end.col > end.length) return null; + return EditorSelection.range(start.from + r.start.col, end.from + r.end.col); + }); + if (!ranges.length || ranges.some((r) => r == null)) continue; + const valid = ranges.filter((r) => r != null); + if (e.reveal) editor.view.dispatch({ effects: EditorView.scrollIntoView(valid[0]!, { y: "center" }) }); + else editor.view.dispatch({ selection: EditorSelection.create(valid) }); + } + return true; + } + case "executeCommand": + void answerCommand(e); + return true; case "contextChanged": refreshContextKeys(); return true; + case "windowStatusItemChanged": + setStatusItem(e.pluginId, e.id, e.item, true); + return true; case "statusItemChanged": setStatusItem(e.pluginId, e.id, e.item); return true; @@ -418,10 +532,43 @@ export function handleRuntimeEvent(e: PluginEvent): boolean { case "viewMessage": deliverViewMessage(e.pluginId, e.view, e.message); return true; + case "windowRequest": + void handleWindowRequest(e); + return true; + case "contentTabReply": + handleContentTabReplyText( + e.pluginId, + JSON.stringify({ + op: "content-tab-reply", + requestId: e.requestId, + ...JSON.parse(JSON.stringify(e.reply), (_k, v: unknown) => (v === null ? undefined : v)), + }), + ); + return true; case "openTerminal": // The program was approved (`process:`) and verified by the host. void executeAction("terminal.runCommand", { program: e.program, args: e.args, name: e.title, cwd: e.cwd }); return true; + case "outputChanged": + setPluginOutput(e.pluginId, e.channel, e.text); + return true; + case "revealOutput": + setPluginOutput(e.pluginId, e.channel, e.text); + void maybeEditorService()?.openInput({ + kind: PLUGIN_LOG_EDITOR_KIND, + key: `pluginOutput:${JSON.stringify([e.pluginId, e.channel])}`, + data: { + pluginId: e.pluginId, + view: "", + key: e.channel, + title: tx`Output: ${e.channel}`, + program: "", + args: [], + toggles: [], + origin: "output", + } satisfies PluginLogEditorData, + }); + return true; case "openLogTab": void maybeEditorService()?.openInput({ kind: PLUGIN_LOG_EDITOR_KIND, @@ -477,6 +624,10 @@ export function handleRuntimeEvent(e: PluginEvent): boolean { function refreshOnProjectChange(): () => void { return useProject.subscribe((s, prev) => { if (s.project === prev.project) return; + statusRevision++; + globalStatus.clear(); + windowStatus.clear(); + usePluginStatusItems.setState({ items: new Map() }); refreshEventKinds(); refreshStatusItems(); refreshContextKeys(); @@ -484,6 +635,58 @@ function refreshOnProjectChange(): () => void { } export function startRuntimeUi(): () => void { - const disposers = [projectStatusItems(), trackWorkbenchEvents(), refreshOnProjectChange()]; + const stopParser = providePluginTestParser((config, chunk) => pluginsBackend().parseTestResults(config, chunk)); + const disposers = [ + startRunTargets(), + stopParser, + projectStatusItems(), + trackWorkbenchEvents(), + refreshOnProjectChange(), + ]; return () => disposers.reverse().forEach((d) => d()); } + +async function handleWindowRequest(e: Extract<PluginEvent, { type: "windowRequest" }>) { + const r = e.request; + try { + if (r.kind === "databaseConnect") { + if (!getAction("db.pluginConnect")) throw new Error(tx("Database service unavailable")); + await executeActionOrThrow("db.pluginConnect", r.id); + await pluginsBackend().answerCommand(e.requestId, { kind: "value", value: null }); + return; + } + if (r.kind === "contentTab") { + const raw = JSON.parse(JSON.stringify(r.tab), (_k, v: unknown) => (v === null ? undefined : v)) as object; + const data = contentTabData(e.pluginId, WASM_CONTENT_VIEW, raw, JSON.stringify(raw).length); + const editor = maybeEditorService(); + if (!editor) throw new Error("Editor unavailable"); + await editor.openInput({ + kind: PLUGIN_CONTENT_EDITOR_KIND, + key: contentTabKey(e.pluginId, WASM_CONTENT_VIEW, data.key), + data, + }); + await pluginsBackend().answerCommand(e.requestId, { kind: "value", value: null }); + return; + } + const finish = (status: number | null, stopped: boolean) => { + if (r.kind === "run") void pluginsBackend().finishOperation(r.operation, status, stopped); + else if (stopped) void pluginsBackend().closeTerminalSession(r.operation); + }; + if (r.kind === "run") { + if (!getAction("run.pluginStart")) throw new Error("Run service unavailable"); + await executeActionOrThrow("run.pluginStart", { config: r.config, debug: r.debug, onExit: finish }); + } else { + if (!getAction("terminal.pluginSession")) throw new Error("Terminal service unavailable"); + await executeActionOrThrow("terminal.pluginSession", { + ...r, + onExit: finish, + spawn: (cols: number, rows: number, output: import("@tauri-apps/api/core").Channel<string>) => + pluginsBackend().terminalSessionSpawn(r.operation, cols, rows, output), + }); + } + if (e.requestId) await pluginsBackend().answerCommand(e.requestId, { kind: "value", value: null }); + } catch (error) { + if (r.kind === "run") await pluginsBackend().finishOperation(r.operation, null, false); + if (e.requestId) await pluginsBackend().answerCommand(e.requestId, { kind: "failed", value: String(error) }); + } +} diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx index 9e1f1a8cd..614161e52 100644 --- a/src/features/plugins/uiPoints.tsx +++ b/src/features/plugins/uiPoints.tsx @@ -22,6 +22,7 @@ import { isMacHost, registerExtensionBindings } from "@/platform/keymap"; import { addFileIconProvider, fileIconName, registerExternalIcons, type MenuItem } from "@/ui"; import { addContributedFileTemplate } from "@/workbench/contributions"; import { + addContributedSnippets, registerEditorKind, registerFileEditorProvider, registerCommitCheckProvider, @@ -70,6 +71,10 @@ import { projectSettings, type SettingsDecl } from "./settings"; interface MenuDecl { menu: + | "editor.title" + | "mainToolbar" + | "problems.context" + | "searchResults.context" | "editor.context" | "projectView.context" | "editorTab.context" @@ -87,6 +92,8 @@ interface MenuDecl { const CONTEXT_MENUS: Record<string, ContextMenuId> = { "editor.context": "editor", + "problems.context": "problems", + "searchResults.context": "searchResults", "projectView.context": "projectView", "editorTab.context": "editorTab", }; @@ -127,7 +134,11 @@ function flattenSingleGroups(items: MenuItem[]): MenuItem[] { } /** Plugin items of a context menu: their command's action with the menu's file as argument. */ -export function pluginMenuItems(menu: ContextMenuId, paths: readonly string[]): MenuItem[] { +export function pluginMenuItems( + menu: ContextMenuId, + paths: readonly string[], + position?: { line: number; column: number }, +): MenuItem[] { const out: MenuItem[] = []; const groups = new Map<string, MenuItem[]>(); for (const { pluginId, decl } of contextItems.values()) { @@ -138,7 +149,7 @@ export function pluginMenuItems(menu: ContextMenuId, paths: readonly string[]): addGrouped(out, groups, pluginId, decl.submenu, { kind: "action", action, - arg: menu === "editor" ? undefined : { path: paths[0], paths: [...paths] }, + arg: menu === "editor" ? undefined : { path: paths[0], paths: [...paths], ...(position ?? {}) }, }); } return flattenSingleGroups(out); @@ -165,14 +176,14 @@ function projectMenu(pluginId: string, decl: MenuDecl, plugin: PluginInfo): () = const title = decl.commandTitle ?? getAction(action)?.title ?? `${plugin.name}: ${decl.command}`; return registerMenuSlotItem("pluginTools", { action, label: title, verbatim: true }); } - if (decl.menu === "commit.message") { + if (decl.menu === "commit.message" || decl.menu === "editor.title" || decl.menu === "mainToolbar") { const action = pluginActionId(pluginId, decl.command); const label = getAction(action)?.title ?? `${plugin.name}: ${decl.command}`; - return registerMenuSlotItem("commitMessage", { + return registerMenuSlotItem(decl.menu === "commit.message" ? "commitMessage" : decl.menu, { action, label, verbatim: true, - icon: pluginIconName(pluginId, plugin.icon), + icon: getAction(action)?.icon ?? pluginIconName(pluginId, plugin.icon), when: decl.when ?? null, }); } @@ -487,6 +498,24 @@ export function defineViewUiPoints(): () => void { title: "Commit Checks", project: ({ pluginId, decl }) => projectCommitCheck(pluginId, decl), }), + defineUiPoint<{ + source: string; + language: string; + entries: { name: string; prefixes: string[]; body: string; description: string }[]; + }>("snippets", { + title: "Snippets", + project: ({ pluginId, decl }, { plugin }) => + addContributedSnippets( + decl.entries.map((s, i) => ({ + id: `plugin-snippet:${pluginId}:${decl.source}:${i}`, + pluginName: plugin.name, + language: decl.language, + prefixes: s.prefixes, + body: s.body, + description: s.description, + })), + ), + }), defineUiPoint<FileTemplateDecl>("fileTemplates", { title: "File Templates", project: ({ pluginId, decl }) => @@ -539,12 +568,16 @@ export function startViewUi(openInput: (input: ReturnType<typeof customEditorInp }), registerMenuGroup({ id: "plugins", - menus: ["editor", "projectView", "editorTab"], + menus: ["editor", "projectView", "editorTab", "problems", "searchResults"], order: 90, items: (menu, ctx) => { - const items = pluginMenuItems(menu, ctx.paths); + const items = pluginMenuItems( + menu, + ctx.paths, + ctx.line === undefined ? undefined : { line: ctx.line, column: ctx.column ?? 0 }, + ); const path = ctx.paths[0]; - if (menu !== "editor" && path && !ctx.dirs?.[0]) { + if ((menu === "projectView" || menu === "editorTab") && path && !ctx.dirs?.[0]) { const editors = [...openWith.values()].filter((e) => e.matches(path)); if (editors.length) items.push({ diff --git a/src/features/plugins/views/contentTab.ts b/src/features/plugins/views/contentTab.ts index 3bef3a463..124ea6b8d 100644 --- a/src/features/plugins/views/contentTab.ts +++ b/src/features/plugins/views/contentTab.ts @@ -4,6 +4,8 @@ import { pluginsBackend } from "../backend"; import { usePlugins } from "../store"; import { onViewMessage } from "./viewStore"; +export const WASM_CONTENT_VIEW = "@wasm-content"; + export const PLUGIN_CONTENT_EDITOR_KIND = "pluginContent"; const BUILTIN_ICONS = new Set<string>(ICON_NAMES); @@ -245,7 +247,15 @@ export function dispatchContentTabAction( if (senders) { for (const send of senders) send({ text: payload }); } - if (plugin.hasCode) { + if (data.view === WASM_CONTENT_VIEW) { + void pluginsBackend() + .contentTabAction(data.pluginId, { requestId, key: data.key, action, content }) + .catch((err: unknown) => { + if (!pendingRequests.delete(requestId)) return; + clearTimeout(timer); + reject(err instanceof Error ? err : new Error(String(err))); + }); + } else if (plugin.hasCode) { let offOnce = () => {}; offOnce = onViewMessage(data.pluginId, data.view, (raw) => { if (handleContentTabReplyText(data.pluginId, raw)) offOnce(); diff --git a/src/features/plugins/views/logEditor.tsx b/src/features/plugins/views/logEditor.tsx index d36c762a7..002cd3141 100644 --- a/src/features/plugins/views/logEditor.tsx +++ b/src/features/plugins/views/logEditor.tsx @@ -24,6 +24,7 @@ import { usePlugins } from "../store"; import { bindLogControls } from "./logActions"; import { boundedLog, type PluginLogEditorData } from "./logTab"; import { logTabPtyBackend, webPtyBackend } from "./ptyBackend"; +import { outputKey, usePluginOutput } from "./outputStore"; import { ptyBridge } from "./ptyBridge"; export const PLUGIN_LOG_EDITOR_KIND = "pluginLog"; @@ -50,7 +51,7 @@ const wheelZoom = EditorView.domEventHandlers({ export function PluginLogEditor({ input }: { input: EditorInput }) { const data = input.data as PluginLogEditorData | undefined; - if (!data || !data.program) { + if (!data || (!data.program && data.origin !== "output")) { return <EmptyState title={tx("Invalid log stream configuration")} />; } return <PluginLogEditorBody input={input} data={data} />; @@ -336,6 +337,16 @@ function PluginLogEditorBody({ input, data }: { input: EditorInput; data: Plugin useEffect(() => { const view = viewRef.current; if (!view) return; + if (data.origin === "output") { + const key = outputKey(data.pluginId, data.key ?? ""); + const update = () => { + const text = usePluginOutput.getState().channels.get(key) ?? ""; + view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } }); + if (stickRef.current) view.scrollDOM.scrollTop = view.scrollDOM.scrollHeight; + }; + update(); + return usePluginOutput.subscribe(update); + } receivedRef.current = false; view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: tx("Waiting for output…") } }); stickRef.current = true; diff --git a/src/features/plugins/views/logTab.ts b/src/features/plugins/views/logTab.ts index 0ed1dc6c9..700584912 100644 --- a/src/features/plugins/views/logTab.ts +++ b/src/features/plugins/views/logTab.ts @@ -16,7 +16,7 @@ export interface PluginLogEditorData { args: string[]; toggles: PluginLogToggleSpec[]; /** Opened by the plugin's code (`views.open-log-tab`): the host starts what it approved for `key`. */ - origin?: "code"; + origin?: "code" | "output"; } const text = (v: unknown, max: number): v is string => diff --git a/src/features/plugins/views/outputStore.ts b/src/features/plugins/views/outputStore.ts new file mode 100644 index 000000000..80c33e75f --- /dev/null +++ b/src/features/plugins/views/outputStore.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; + +/** Snapshots of the host's bounded, read-only output channels. */ +export const usePluginOutput = create<{ channels: Map<string, string> }>(() => ({ channels: new Map() })); +export const outputKey = (pluginId: string, channel: string) => JSON.stringify([pluginId, channel]); +export function setPluginOutput(pluginId: string, channel: string, text: string | null) { + usePluginOutput.setState((s) => { + const channels = new Map(s.channels); + if (text === null) channels.delete(outputKey(pluginId, channel)); + else channels.set(outputKey(pluginId, channel), text); + return { channels }; + }); +} diff --git a/src/features/run/ConfigForm.tsx b/src/features/run/ConfigForm.tsx index 61989a47c..526dc760e 100644 --- a/src/features/run/ConfigForm.tsx +++ b/src/features/run/ConfigForm.tsx @@ -4,6 +4,7 @@ * Before launch list. */ import { isJvmApplication, parseJavaArgs } from "@/platform/javaLaunch"; +import { useRunTargets } from "@/platform/pluginRunTargets"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"; import clsx from "clsx"; import { isTauri, type BeforeLaunchTask, type BuildPolicy, type RunConfig } from "@/platform/ipc"; @@ -45,7 +46,6 @@ import { import { gradleContainsModule } from "./gradleRun"; import { EnvDialog } from "./EnvDialog"; import { useBuildModel } from "@/platform/buildModel"; -import { withTarget } from "./remote"; import { COMPOUND, formParams, terminalApplies, withParams } from "./types"; import { TomcatSettings } from "./TomcatSettings"; import styles from "./Run.module.css"; @@ -243,7 +243,7 @@ function BrowseButton({ export function ConfigForm({ config, all, type, errors, template, onChange, modifyRequest }: ConfigFormProps) { const openByDefault = useWorkflowSettings((s) => s.run.showOnStart); const externalTools = useWorkflowSettings((s) => s.externalTools); - const runTargets = useWorkflowSettings((s) => s.runTargets); + const runTargets = useRunTargets(); const toolWindows = useContributions((s) => s.toolWindows); const tx = useTx(); const txTemplate = useTxTemplate(); @@ -383,6 +383,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi }); const targetOptions: SelectOption[] = [ + { value: "__default", label: "Default run target" }, { value: "", label: "Local machine" }, ...runTargets.map((t) => ({ value: t.id, label: t.name.trim() || t.id })), ]; @@ -446,9 +447,14 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi } > <Select - value={config.target ?? ""} + value={config.target ?? "__default"} options={targetOptions} - onChange={(target) => onChange(withTarget(config, target))} + onChange={(target) => { + if (target === "__default") { + const { target: _target, ...rest } = config; + onChange(rest); + } else onChange({ ...config, target }); + }} aria-label="Run on" /> </Field> diff --git a/src/features/run/EditConfigsDialog.tsx b/src/features/run/EditConfigsDialog.tsx index 311f251ae..3b80a514f 100644 --- a/src/features/run/EditConfigsDialog.tsx +++ b/src/features/run/EditConfigsDialog.tsx @@ -1,9 +1,10 @@ +/** Sidebar rows: type groups, their folders (first), then loose configurations; collapsed nodes hide their children. */ +import { useRunTargets } from "@/platform/pluginRunTargets"; import { useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from "react"; import clsx from "clsx"; import { executeAction } from "@/platform/actions"; import { call, commands, isTauri, type RunConfig, type RunImportSource } from "@/platform/ipc"; import { letterOf } from "@/platform/keymap"; -import { useWorkflowSettings } from "@/platform/workflowSettings"; import { Button, Dialog, Icon, IconButton, Input, T } from "@/ui"; import { notifications, @@ -49,7 +50,6 @@ export type TreeRow = | { type: "folder"; key: string; kind: string; name: string } | { type: "config"; key: string; config: RunConfig; depth: 1 | 2 }; -/** Sidebar rows: type groups, their folders (first), then loose configurations; collapsed nodes hide their children. */ export function treeRows( configs: readonly RunConfig[], extraFolders: ReadonlyArray<{ kind: string; name: string }> = [], @@ -133,7 +133,7 @@ function EditConfigsDialog() { const [dirty, setDirty] = useState(() => useRun.getState().editDirty); const [removedDetectedIds, setRemovedDetectedIds] = useState<string[]>([]); const [saving, setSaving] = useState(false); - const runTargets = useWorkflowSettings((s) => s.runTargets); + const runTargets = useRunTargets(); const [adding, setAdding] = useState<{ x: number; y: number } | null>(null); const [modifyRequest, setModifyRequest] = useState(0); const addRef = useRef<HTMLButtonElement>(null); diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 86884df1b..49ac906b8 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -3,7 +3,7 @@ * test runner) and run/debug/stop actions. */ import { registerEditorContribution } from "@/editor/api"; -import { executeAction, getAction, registerActions } from "@/platform/actions"; +import { executeAction, executeActionOrThrow, getAction, registerActions } from "@/platform/actions"; import { getContext, setContext, useContextKeys } from "@/platform/context"; import { call, commands, onIdeEvent, type RunConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; @@ -590,6 +590,28 @@ export function activate(): () => void { return loadConfigs(); }, }, + { + id: "run.pluginStart", + title: "Run Plugin Configuration", + category: "Run", + hidden: true, + when: "projectOpen", + run: async ({ arg }) => { + const a = arg as { + config: import("@/bindings").RunConfig; + debug: boolean; + onExit: (code: number | null, stopped: boolean) => void; + }; + if (a.debug) { + if (!getAction("debug.pluginStart")) throw new Error("Debugger unavailable"); + await executeActionOrThrow("debug.pluginStart", a); + return; + } + const session = await startRun(a.config, undefined, undefined, { onExit: a.onExit }); + if (!session || session.status === "failed") throw new Error("Run did not start"); + if (session.runId === null && session.status === "finished") a.onExit(session.exitCode, !!session.stopped); + }, + }, { id: "run.runConfig", title: "Run Configuration", diff --git a/src/features/run/remote.test.tsx b/src/features/run/remote.test.tsx index 52dcc896a..c87304481 100644 --- a/src/features/run/remote.test.tsx +++ b/src/features/run/remote.test.tsx @@ -41,6 +41,7 @@ import { executeAction, registerActions } from "@/platform/actions"; import { setContext } from "@/platform/context"; import { IpcError, type RunConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; +import { usePluginRunTargets } from "@/platform/pluginRunTargets"; import { DEFAULT_RUN_TARGET, type RunTargetValid } from "@/platform/remoteSettings"; import { provideNotificationService, @@ -96,6 +97,7 @@ const fail = (message: string, kind = "permissionDenied") => ({ status: "error" const notify = vi.fn(); beforeEach(() => { + usePluginRunTargets.setState({ targets: [] }); resetRuns(); for (const f of Object.values(ipc)) f.mockClear(); confirm.mockClear(); @@ -148,13 +150,46 @@ describe("run targets", () => { <ConfigForm config={cfg("app")} all={[]} type={undefined} errors={{}} onChange={onChange} modifyRequest={0} />, ); const select = screen.getByRole("button", { name: "Run on" }); - expect(select.textContent).toContain("Local machine"); + expect(select.textContent).toContain("Default run target"); fireEvent.click(select); const popup = document.querySelector("[data-autofocus]")!; fireEvent.keyDown(popup, { key: "ArrowDown" }); + fireEvent.keyDown(popup, { key: "ArrowDown" }); fireEvent.keyDown(popup, { key: "Enter" }); expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ target: "box" })); }); + it("includes plugin targets labelled with their owner in the target selector", () => { + const id = "plugin-target:9:executionwrapper"; + usePluginRunTargets.setState({ + targets: [ + { + id, + pluginId: "execution", + pluginName: "Fixture", + prompt: false, + target: { + id: "wrapper", + name: "Execution", + program: "wrapper", + args: [], + workdirArgs: [], + containerRoot: null, + commands: [], + }, + }, + ], + }); + const onChange = vi.fn(); + render( + <ConfigForm config={cfg("app")} all={[]} type={undefined} errors={{}} onChange={onChange} modifyRequest={0} />, + ); + fireEvent.click(screen.getByRole("button", { name: "Run on" })); + const popup = document.querySelector("[data-autofocus]")!; + expect(popup.textContent).toContain("Execution (Fixture)"); + fireEvent.keyDown(popup, { key: "End" }); + fireEvent.keyDown(popup, { key: "Enter" }); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ target: id })); + }); }); describe("remote debugging", () => { diff --git a/src/features/run/remote.ts b/src/features/run/remote.ts index 513b4e17f..1ceb48da4 100644 --- a/src/features/run/remote.ts +++ b/src/features/run/remote.ts @@ -3,11 +3,13 @@ * SSH or in a Docker / Compose container. Labels, the debug and executor guards, * and the connection remedies of `runStart`. */ +import { withRunTarget } from "@/platform/runTargetSelection"; +import { useRunTargets, runTargets as allRunTargets, type RuntimeRunTarget } from "@/platform/pluginRunTargets"; import { executeAction } from "@/platform/actions"; import { tx, txTemplate } from "@/platform/i18n"; import { call, commands, IpcError, type RunConfig } from "@/platform/ipc"; -import { inContainer, type RunTargetValid, type SshConfigValid } from "@/platform/remoteSettings"; -import { useWorkflowSettings, workflowSettings } from "@/platform/workflowSettings"; +import { inContainer, type SshConfigValid } from "@/platform/remoteSettings"; +import { workflowSettings } from "@/platform/workflowSettings"; import { notifications } from "@/workbench/contributions"; import { confirm } from "@/workbench/messages"; import { promptSecret } from "./dialogs"; @@ -15,16 +17,17 @@ import { promptSecret } from "./dialogs"; /** The run target `config` runs on; undefined when it runs locally or the target is gone. */ export function targetOf( config: Pick<RunConfig, "target">, - targets: readonly RunTargetValid[] = workflowSettings().runTargets, -): RunTargetValid | undefined { + targets: readonly RuntimeRunTarget[] = allRunTargets(), +): RuntimeRunTarget | undefined { return config.target ? targets.find((t) => t.id === config.target) : undefined; } /** Display name of the target `config` runs on (its id when missing); null when local. */ export function targetName( config: Pick<RunConfig, "target">, - targets: readonly RunTargetValid[] = workflowSettings().runTargets, + targets: readonly RuntimeRunTarget[] = allRunTargets(), ): string | null { + if ("command" in config) config = withRunTarget(config as RunConfig); if (!config.target) return null; const t = targetOf(config, targets); return t ? t.name.trim() || t.id : config.target; @@ -33,7 +36,7 @@ export function targetName( /** `name @ target` for configurations on a run target, else the name. */ export function remoteLabel( config: Pick<RunConfig, "name" | "target">, - targets: readonly RunTargetValid[] = workflowSettings().runTargets, + targets: readonly RuntimeRunTarget[] = allRunTargets(), ): string { const t = targetName(config, targets); return t ? `${config.name} @ ${t}` : config.name; @@ -41,20 +44,20 @@ export function remoteLabel( /** `remoteLabel` that re-renders when the run targets change. */ export function useRemoteLabel(): (config: Pick<RunConfig, "name" | "target">) => string { - const targets = useWorkflowSettings((s) => s.runTargets); + const targets = useRunTargets(); return (config) => remoteLabel(config, targets); } /** `config` running on `target` (empty: this machine, the key is dropped). */ export function withTarget(config: RunConfig, target: string): RunConfig { const { target: _previous, ...rest } = config; - return target ? { ...rest, target } : rest; + return target || workflowSettings().run.defaultTarget ? { ...rest, target } : rest; } /** A Python configuration on a Docker / Compose target: debugged in its container (`debugInContainer`). */ export function containerDebuggable( config: RunConfig, - targets: readonly RunTargetValid[] = workflowSettings().runTargets, + targets: readonly RuntimeRunTarget[] = allRunTargets(), ): boolean { const target = targetOf(config, targets); return !!target && inContainer(target) && config.debugAdapter === "debugpy"; @@ -64,10 +67,8 @@ export function containerDebuggable( * Debugging on run targets isn't supported, except Python in containers: tells so * (offering Run) and returns true when `config` can't be debugged where it runs. */ -export function remoteDebugBlocked( - config: RunConfig, - targets: readonly RunTargetValid[] = workflowSettings().runTargets, -): boolean { +export function remoteDebugBlocked(config: RunConfig, targets: readonly RuntimeRunTarget[] = allRunTargets()): boolean { + config = withRunTarget(config); if (!config.target || containerDebuggable(config, targets)) return false; const target = targetName(config, targets) ?? config.target; const container = targetOf(config, targets); @@ -96,7 +97,7 @@ export function remoteDebugBlocked( */ export function containerExecutorBlocked( config: RunConfig, - targets: readonly RunTargetValid[] = workflowSettings().runTargets, + targets: readonly RuntimeRunTarget[] = allRunTargets(), ): boolean { const target = targetOf(config, targets); if (!target || !inContainer(target)) return false; @@ -132,7 +133,7 @@ export interface RemedyDeps { askSecret: (message: string, save: boolean) => Promise<{ secret: string; save: boolean } | null>; trustHost: (sshConfig: string) => Promise<unknown>; setSecret: (sshConfig: string, secret: string, save: boolean) => Promise<unknown>; - targets: () => readonly RunTargetValid[]; + targets: () => readonly RuntimeRunTarget[]; sshConfigs: () => readonly SshConfigValid[]; } @@ -147,7 +148,7 @@ const defaultDeps: RemedyDeps = { askSecret: (message, save) => promptSecret(message, save), trustHost: (id) => call(commands.sshTrustHost(id)), setSecret: (id, secret, save) => commands.sshSetSecret(id, secret, save), - targets: () => workflowSettings().runTargets, + targets: () => allRunTargets(), sshConfigs: () => workflowSettings().ssh.configs, }; diff --git a/src/features/run/runtimes.test.ts b/src/features/run/runtimes.test.ts index 44ef2f6d1..8aa033d0a 100644 --- a/src/features/run/runtimes.test.ts +++ b/src/features/run/runtimes.test.ts @@ -13,9 +13,11 @@ vi.mock("@/platform/ipc", () => ({ sdkLocateTools: async () => ({ compileDir: "/tmp/run", tools: [] }), }, })); +import { useProject } from "@/platform/project"; import { clearRuntimeCache, rootMarkers, toolEnv } from "./runtimes"; beforeEach(() => { + useProject.setState({ settings: null }); entries.clear(); pythonNear.mockReset().mockResolvedValue("/env/python"); clearRuntimeCache(); @@ -41,3 +43,14 @@ it("the production Python runtime query includes the source directory", async () expect(pythonNear).toHaveBeenCalledWith("/repo/apps/python/src"); expect(env.python).toBe("/env/python"); }); + +it("target PHP and Node runs do not require locally installed interpreters", async () => { + useProject.setState({ + settings: { languages: { php: { runTarget: "plugin-target:php" }, node: { runTarget: "plugin-target:node" } } }, + }); + const env = await toolEnv(["php", "node", "npx", "tsx"], "/repo/main.php", "/repo", false, true); + expect(env.php).toBe("php"); + expect(env.tool("node")).toBeUndefined(); + expect(env.tool("npx")).toBeUndefined(); + expect(env.tool("tsx")).toBeUndefined(); +}); diff --git a/src/features/run/runtimes.ts b/src/features/run/runtimes.ts index be2deb15b..3ee88a2dd 100644 --- a/src/features/run/runtimes.ts +++ b/src/features/run/runtimes.ts @@ -4,6 +4,7 @@ * minute per directory. Without that command (an older backend) Node.js and * Rust come from SDK discovery and everything else resolves at launch. */ +import { withRunTarget } from "@/platform/runTargetSelection"; import { call, commands, isTauri } from "@/platform/ipc"; import { isWindows } from "./jvmTypes"; import type { LocatedTool, ToolEnv } from "./producers"; @@ -121,10 +122,23 @@ export async function toolEnv( python: boolean, php = false, ): Promise<ToolEnv> { + const targetFor = (command: string) => + withRunTarget({ command, kind: command } as import("@/bindings").RunConfig).target; + const targetTools = new Set( + names.filter( + (n) => + ["php", "node", "npm", "npx", "pnpm", "yarn", "bun", "tsx", "ts-node"].includes(n) && + targetFor(n === "tsx" || n === "ts-node" ? "node" : n), + ), + ); + const phpTarget = php && targetFor("php"); const [tools, py, phpInterpreter, markers] = await Promise.all([ - locateTools(names, file), + locateTools( + names.filter((n) => !targetTools.has(n)), + file, + ), python ? projectPython(file) : Promise.resolve(null), - php ? projectPhp() : Promise.resolve(null), + phpTarget ? Promise.resolve("php") : php ? projectPhp() : Promise.resolve(null), names.includes("deno") || names.includes("bun") ? rootMarkers(root, file) : Promise.resolve({ deno: false, bun: false }), diff --git a/src/features/run/store.ts b/src/features/run/store.ts index e2f87a2aa..c66968919 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -3,6 +3,7 @@ * (one Run tool window tab each). Output streams through a Channel into a * per-session `ConsoleModel`, batched per animation frame. */ +import { withRunTarget } from "@/platform/runTargetSelection"; import { create } from "zustand"; import { Channel } from "@tauri-apps/api/core"; import { @@ -42,6 +43,7 @@ import { type TestEvent, type TestOutputParser, } from "./testing"; +import { PluginTestOutput } from "./testing/plugin"; import { GoTestOutput } from "./testing/go"; import { moveToReportedClasses } from "./testing/jvm"; import { RubyTestOutput } from "./testing/ruby"; @@ -96,6 +98,7 @@ interface SessionRuntime { console: ConsoleModel; tests: TestResults | null; parser: TestOutputParser | null; + pluginTests: PluginTestOutput | null; /** Machine-readable stdout (`go test -json`, `--teamcity`, the Ruby reporter): fed to the parser, shown decoded. */ stdout: TestStdout | null; queue: OutputChunk[]; @@ -616,7 +619,15 @@ export function applyConsoleSettings() { function newRuntime(config: RunConfig): SessionRuntime { const tests = config.isTest ? new TestResults() : null; - const parser = config.isTest ? createTestParser(config) : null; + const builtIn = config.isTest ? createTestParser(config) : null; + const pluginTests = + tests && builtIn && config.params?.framework === "plugin" + ? new PluginTestOutput(config.id, builtIn, (events) => { + tests.apply(events); + for (const [id, rt] of runtimes) if (rt.tests === tests) bump(id); + }) + : null; + const parser = pluginTests ? null : builtIn; const feed = (line: string) => { if (tests && parser) tests.apply(parser.feed(line)); }; @@ -635,9 +646,11 @@ function newRuntime(config: RunConfig): SessionRuntime { stdout, tests, parser, + pluginTests, console: new ConsoleModel({ maxChars: consoleCharLimit(), onLine: (text, kind) => { + if (pluginTests && kind !== "system") pluginTests.feed(text); if (parser && tests && kind !== "system" && !(kind === "stdout" && stdout)) tests.apply(parser.feed(text)); if (kind !== "system") for (const l of runtime.lineListeners) l(text); }, @@ -712,6 +725,7 @@ export async function startRun( chain?: readonly string[]; } = {}, ): Promise<RunSession | null> { + config = withRunTarget(config); if (!options.launch && isCMakeBuild(config)) { await buildCMake(config.id, options.show !== false); return null; @@ -1005,8 +1019,10 @@ function finishSession(sessionId: string, code: number | null) { if (text) rt.console.append({ kind: "stdout", text }); } rt.console.flush(); - if (rt.tests && rt.parser) { - rt.tests.apply(rt.parser.end?.() ?? []); + await rt.pluginTests?.end(); + if (runtimes.get(sessionId) !== rt) return; + if (rt.tests && (rt.parser || rt.pluginTests)) { + rt.tests.apply(rt.parser?.end?.() ?? []); // Surefire / Gradle print only failures and class totals: per-test results come from the XML reports. if (frameworkFor(session.config) === "jvm") { const reports = await jvmReports(session); diff --git a/src/features/run/testing/plugin.test.ts b/src/features/run/testing/plugin.test.ts new file mode 100644 index 000000000..fcf5375ef --- /dev/null +++ b/src/features/run/testing/plugin.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import { providePluginTestParser } from "@/workbench/contributions"; +import { PluginTestEvents, PluginTestOutput } from "./plugin"; +import { TestResults } from "./results"; + +describe("plugin test events", () => { + it("maps stable ids, nested paths, failures, output and UTF-16 locations to the existing tree", () => { + const mapping = new PluginTestEvents(); + const location = { path: "/project/test.php", range: { start: { line: 2, col: 4 }, end: { line: 2, col: 6 } } }; + const events = mapping.map([ + { type: "suiteStarted", id: "suite", parent: null, name: "Suite", location: null }, + { type: "testStarted", id: "t", parent: "suite", name: "case", location }, + { type: "failed", id: "t", durationMs: 7, message: "mismatch", expected: "one", actual: "two", location: null }, + ]); + expect(events).toContainEqual({ + type: "location", + path: ["Suite", "case"], + location: { path: location.path, line: 3, col: 5, resolve: "path" }, + }); + const tree = new TestResults(); + tree.apply(events); + expect(tree.counts().failed).toBe(1); + expect(mapping.map([{ type: "output", id: "t", text: "later" }])).toEqual([ + { type: "output", path: ["Suite", "case"], line: "later" }, + ]); + }); + it("rejects an invalid parent without corrupting the last valid mapping", () => { + const mapping = new PluginTestEvents(); + expect(() => + mapping.map([{ type: "testStarted", id: "x", parent: "missing", name: "bad", location: null }]), + ).toThrow(); + expect(() => mapping.map([{ type: "passed", id: "x", durationMs: null }])).toThrow(); + }); + it("serializes bounded batches and falls back when a parser fails", async () => { + const chunks: string[] = []; + const dispose = providePluginTestParser(async (_id, chunk) => { + chunks.push(chunk); + return null; + }); + const feed = vi.fn(() => [{ type: "start" as const, path: ["fallback"] }]); + const apply = vi.fn(); + const parser = new PluginTestOutput("plugin:fixture:tests", { feed }, apply); + parser.feed("x".repeat(40000)); + await parser.end(); + expect(chunks[0]!.length).toBeLessThanOrEqual(16000); + expect(apply).toHaveBeenCalledWith([{ type: "start", path: ["fallback"] }]); + dispose(); + }); +}); diff --git a/src/features/run/testing/plugin.ts b/src/features/run/testing/plugin.ts new file mode 100644 index 000000000..f11c23aeb --- /dev/null +++ b/src/features/run/testing/plugin.ts @@ -0,0 +1,146 @@ +import { tx } from "@/platform/i18n"; +import type { PluginTestEvent } from "@/bindings"; +import { parsePluginTests } from "@/workbench/contributions"; +import type { TestEvent, TestOutputParser } from "./types"; + +/** IDs stay private to a run; the existing tree uses display-name paths. */ +export class PluginTestEvents { + private paths = new Map<string, string[]>(); + private names = new Map<string, string>(); + map(events: readonly PluginTestEvent[]): TestEvent[] { + const paths = new Map(this.paths); + const names = new Map(this.names); + const out: TestEvent[] = []; + for (const event of events) { + if (event.type === "other") continue; + if (event.type === "suiteStarted" || event.type === "testStarted") { + if (!event.id || !event.name || (event.parent && !paths.has(event.parent))) + throw new Error("Invalid test parent"); + const path = [...(event.parent ? paths.get(event.parent)! : []), event.name]; + if (path.length > 128 || paths.size > 100000) throw new Error("Test tree too large"); + let key = JSON.stringify(path); + if (names.has(key) && names.get(key) !== event.id) { + path[path.length - 1] += ` [${event.id}]`; + key = JSON.stringify(path); + } + names.set(key, event.id); + paths.set(event.id, path); + out.push({ type: "start", path }); + if (event.location) + out.push({ + type: "location", + path, + location: { + path: event.location.path, + line: event.location.range.start.line + 1, + col: event.location.range.start.col + 1, + resolve: "path", + }, + }); + continue; + } + const path = paths.get(event.id); + if (!path) throw new Error("Unknown test id"); + if (event.type === "output") out.push({ type: "output", path, line: event.text }); + else if (event.type === "skipped") { + out.push({ type: "result", path, status: "skipped" }); + if (event.reason) out.push({ type: "output", path, line: event.reason }); + } else { + out.push({ + type: "result", + path, + status: event.type === "failed" ? "failed" : "passed", + durationMs: event.durationMs ?? undefined, + }); + if (event.type === "failed") { + out.push({ type: "output", path, line: event.message }); + if (event.expected !== null) out.push({ type: "output", path, line: tx`Expected: ${event.expected}` }); + if (event.actual !== null) out.push({ type: "output", path, line: tx`Actual: ${event.actual}` }); + if (event.location) + out.push({ + type: "location", + path, + location: { + path: event.location.path, + line: event.location.range.start.line + 1, + col: event.location.range.start.col + 1, + resolve: "path", + }, + }); + } + } + } + this.paths = paths; + this.names = names; + return out; + } +} + +/** Sequential, bounded batches; a parser failure switches this run to built-in parsing. */ +export class PluginTestOutput { + private readonly events = new PluginTestEvents(); + private pending = Promise.resolve(); + private pendingChars = 0; + private buffer = ""; + private scheduled = false; + private failed = false; + private fallbackEvents: TestEvent[] = []; + constructor( + private readonly config: string, + private readonly fallback: TestOutputParser, + private readonly apply: (events: TestEvent[]) => void, + ) {} + feed(line: string) { + const fallback = this.fallback.feed(line); + if (this.failed) { + this.apply(fallback); + return; + } + this.fallbackEvents.push(...fallback); + if (this.fallbackEvents.length > 10000) this.fallbackEvents.splice(0, this.fallbackEvents.length - 10000); + this.buffer += `${line}\n`; + if (!this.scheduled) { + this.scheduled = true; + queueMicrotask(() => { + this.scheduled = false; + this.flush(); + }); + } + } + private flush() { + while (this.buffer.length) { + let end = Math.min(this.buffer.length, 16000); + const last = this.buffer.charCodeAt(end - 1); + if (last >= 0xd800 && last <= 0xdbff) end--; + const chunk = this.buffer.slice(0, end); + this.buffer = this.buffer.slice(chunk.length); + this.pendingChars += chunk.length; + if (this.pendingChars > 1 << 20) { + this.failed = true; + this.buffer = ""; + this.apply(this.fallbackEvents); + this.fallbackEvents = []; + break; + } + this.pending = this.pending.then(async () => { + this.pendingChars -= chunk.length; + if (this.failed) return; + try { + const events = await parsePluginTests(this.config, chunk); + if (events === null) throw new Error("Parser unavailable"); + this.apply(this.events.map(events)); + } catch { + this.failed = true; + this.apply(this.fallbackEvents); + this.fallbackEvents = []; + } + }); + } + } + async end() { + this.flush(); + await this.pending; + if (this.failed) this.apply(this.fallback.end?.() ?? []); + this.fallbackEvents = []; + } +} diff --git a/src/features/search/find/FindToolWindow.tsx b/src/features/search/find/FindToolWindow.tsx index 90066080e..cfd11a405 100644 --- a/src/features/search/find/FindToolWindow.tsx +++ b/src/features/search/find/FindToolWindow.tsx @@ -1,3 +1,4 @@ +import { menuGroupItems } from "@/workbench/contributions"; import { useMemo, useRef, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; import { create } from "zustand"; @@ -459,6 +460,9 @@ function FindTabView({ tab }: { tab: FindTab }) { const groups = [...sel].flatMap((k) => (byKey.get(k) ? nodeGroups(byKey.get(k)!) : [])); const excludedAll = groups.length > 0 && groups.every((g) => groupState(g, marks) === "excluded"); showContextMenu(e, [ + ...(_n?.kind === "match" + ? menuGroupItems("searchResults", { paths: [_n.path], line: _n.line, column: _n.col }) + : []), { id: "exclude", label: excludedAll ? "Include" : "Exclude", diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index 76e209a40..221727ea1 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -18,6 +18,9 @@ import { validateSettings } from "../model/schema"; describe("languages settings", () => { it("defaults and normalises malformed values", () => { expect(validateLanguages(undefined)).toEqual(DEFAULT_LANGUAGES); + expect(validateLanguages({ pluginFormatters: { sql: "example.formatter", text: 42 } }).pluginFormatters).toEqual({ + sql: "example.formatter", + }); const v = validateLanguages({ python: { interpreter: " /usr/bin/python3 ", activateVirtualenv: "yes" }, node: { packageManager: "pip" }, @@ -78,6 +81,7 @@ describe("languages settings", () => { it("defaults the PHP language level to Auto and keeps only known levels", () => { expect(validateSettings({}).languages.php).toEqual({ + runTarget: "", interpreter: "", languageLevel: "", includePaths: [], @@ -302,6 +306,7 @@ describe("language pages", () => { "Ruby", "Rust", "TypeScript", + "Plugin Formatters", ]); }); diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index 44d9cff15..ed0ced3d1 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -3,6 +3,8 @@ * Build Tools › Maven: the toolchain each language's server and run configurations * use (applied by `ide-sdk` when language servers start and when run configurations load). */ +import { useRunTargets } from "@/platform/pluginRunTargets"; +import { tx } from "@/platform/i18n"; import { useEffect, useId, useMemo, useState } from "react"; import { call, commands, type Sdk } from "@/platform/ipc"; import { IconButton, Input, type SelectOption, T } from "@/ui"; @@ -73,6 +75,23 @@ function SdkRow({ // --------------------------------------------------------------------------- // Node.js +function RunTargetRow({ language }: { language: "php" | "node" }) { + const [target, setTarget] = useSetting<string>(`languages.${language}.runTarget`); + const targets = useRunTargets(); + const options = [ + { value: "", label: "Default run target" }, + { value: "local", label: "Local machine" }, + ...targets.map((t) => ({ value: t.id, label: tx`Run in target ${t.name}` })), + ]; + if (target && target !== "local" && !targets.some((t) => t.id === target)) + options.push({ value: target, label: tx`Run in target ${target}` }); + return ( + <Row label="Run in target"> + <Select value={target ?? ""} options={options} onChange={setTarget} aria-label="Run in target" /> + </Row> + ); +} + export function NodePage() { const [pm, setPm] = useSetting<string>("languages.node.packageManager"); const { list } = useDetected("packageManager"); @@ -103,6 +122,7 @@ export function NodePage() { aria-label="Node interpreter" hint="Runs npm scripts and Node-based language servers (vtsls, eslint, pyright…)." /> + <RunTargetRow language="node" /> <Row label="Package manager" hint="Used by the npm run configurations detected from package.json scripts."> <Select value={pm} options={pmOptions} onChange={setPm} aria-label="Package manager" /> </Row> @@ -212,6 +232,7 @@ export function PhpPage() { /> </Section> <Section title="CLI Interpreter"> + <RunTargetRow language="php" /> <SdkRow k="languages.php.interpreter" label="PHP interpreter" diff --git a/src/features/settings/languages/PluginFormattersPage.tsx b/src/features/settings/languages/PluginFormattersPage.tsx new file mode 100644 index 000000000..d257779d3 --- /dev/null +++ b/src/features/settings/languages/PluginFormattersPage.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from "react"; +import { call, commands, type FormatterChoice } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; +import { Row, Section, Select, Toggle } from "../components/Form"; +import { useSetting } from "../components/context"; +export function PluginFormattersPage() { + const [known, setKnown] = useState<string[]>([]); + const [choices, setChoices] = useState<FormatterChoice[]>([]); + const [selected, setSelected] = useSetting<Record<string, string>>("languages.pluginFormatters"); + const [save, setSave] = useSetting<boolean>("editor.formatOnSave"); + useEffect(() => { + let active = true; + void call(commands.pluginFormatters()) + .then((v) => { + if (active) setChoices(v); + }) + .catch(() => {}); + return () => { + active = false; + }; + }, []); + useEffect(() => { + void commands + .languages() + .then((list) => setKnown(list.map((l) => l.id))) + .catch(() => {}); + }, []); + const languages = [ + ...new Set([...known, ...Object.keys(selected ?? {}), ...choices.flatMap((c) => c.languages)]), + ].filter( + (l) => l !== "*" && (selected?.[l] || choices.some((c) => c.languages.includes(l) || c.languages.includes("*"))), + ); + return ( + <Section title="Plugin Formatters"> + {languages.map((language) => ( + <Row key={language} label={language}> + <Select + aria-label={language} + value={selected?.[language] ?? ""} + onChange={(value) => setSelected({ ...selected, [language]: value })} + options={[ + { value: "", label: tx("Automatic (language server first)") }, + ...choices + .filter((c) => c.languages.includes(language) || c.languages.includes("*")) + .map((c) => ({ value: c.id, label: c.name })), + ...(selected?.[language] && !choices.some((c) => c.id === selected[language]) + ? [{ value: selected[language]!, label: selected[language]! }] + : []), + ]} + /> + </Row> + ))} + <Toggle label="Format on save" checked={save} onChange={setSave} /> + </Section> + ); +} diff --git a/src/features/settings/pages/languages.tsx b/src/features/settings/pages/languages.tsx index c456a7cc9..bec8ef533 100644 --- a/src/features/settings/pages/languages.tsx +++ b/src/features/settings/pages/languages.tsx @@ -1,3 +1,4 @@ +import { PluginFormattersPage } from "../languages/PluginFormattersPage"; /** * PyCharm's top-level "Python" group (Interpreter), "Project Structure", and * Languages & Frameworks › Go / Java / Markdown / Node.js / PHP / Rust / TypeScript. Pages live in @@ -15,6 +16,15 @@ import type { PageSpec } from "./spec"; export const LANGUAGES_GROUP = "Languages & Frameworks"; export const LANGUAGE_PAGES: PageSpec[] = [ + { + id: "languages.pluginFormatters", + title: "Plugin Formatters", + group: LANGUAGES_GROUP, + projectScope: true, + order: 450, + keywords: ["format", "plugins"], + component: PluginFormattersPage, + }, { id: "python.interpreter", projectScope: true, diff --git a/src/features/settings/pages/runTargets.tsx b/src/features/settings/pages/runTargets.tsx index fd1a2c2b6..b81efefa5 100644 --- a/src/features/settings/pages/runTargets.tsx +++ b/src/features/settings/pages/runTargets.tsx @@ -3,6 +3,7 @@ * containers run configurations can run on (`RunConfig.target`). Consumed by * `ide-run`, `ide-remote` and the run feature. */ +import { usePluginRunTargets } from "@/platform/pluginRunTargets"; import { useCallback, useEffect, useId, useState } from "react"; import clsx from "clsx"; import { call, commands, isTauri, type DockerConnection, type RemoteEntry } from "@/platform/ipc"; @@ -414,8 +415,17 @@ function ContainerFields({ target, update }: { target: RunTargetValid; update: ( } function RunTargetsEditor() { + const plugins = usePluginRunTargets((s) => s.targets); + const [defaultTarget, setDefaultTarget] = useSetting<string>("run.defaultTarget"); const [raw, setTargets] = useSetting<RunTargetValid[] | undefined>("runTargets"); const targets = raw ?? []; + const allTargets = [...targets, ...plugins.map((t) => ({ id: t.id, name: `${t.target.name} (${t.pluginName})` }))]; + const defaultOptions: SelectOption[] = [ + { value: "", label: "Local machine" }, + ...allTargets.map((t) => ({ value: t.id, label: t.name })), + ]; + if (defaultTarget && !allTargets.some((t) => t.id === defaultTarget)) + defaultOptions.push({ value: defaultTarget, label: tx`${defaultTarget} (missing)` }); const sshConfigs = useSshConfigs(); const [selected, setSelected] = useState<string | null>(targets[0]?.id ?? null); const current = targets.find((t) => t.id === selected) ?? null; @@ -478,11 +488,19 @@ function RunTargetsEditor() { /> </div> <div className={styles.toolList} role="listbox" aria-label={tx("Run targets")}> - {targets.length === 0 && ( + {targets.length === 0 && plugins.length === 0 && ( <div className={clsx(styles.toolRow, styles.empty)}> <T>No run targets. Click + to add one.</T> </div> )} + {plugins.map((t) => ( + <div key={t.id} className={styles.toolRow} role="option" aria-selected={false}> + <span className={styles.toolName}>{t.target.name}</span> + <span className={styles.toolGroup}> + {t.pluginName} · <T>Read-only</T> + </span> + </div> + ))} {targets.map((t) => ( <div key={t.id} @@ -504,6 +522,16 @@ function RunTargetsEditor() { ))} </div> </div> + <Section> + <Row label="Default run target"> + <Select + value={defaultTarget ?? ""} + onChange={setDefaultTarget} + aria-label="Default run target" + options={defaultOptions} + /> + </Row> + </Section> {current && ( <Section title="Target Settings"> <TextField label="Name" value={current.name} onChange={(name) => update({ name })} /> diff --git a/src/features/templates/__tests__/snippets.test.ts b/src/features/templates/__tests__/snippets.test.ts new file mode 100644 index 000000000..74ed379ac --- /dev/null +++ b/src/features/templates/__tests__/snippets.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { addContributedSnippets } from "@/workbench/contributions"; +import { liveTemplates, saveTemplate, removeTemplate } from "../live/store"; +import { snippetTemplates } from "../live/snippets"; +import { expandTemplate } from "../live/model"; +const snippet = { + id: "plugin-snippet:p:s:0", + pluginName: "Plugin", + language: "sql", + prefixes: ["sel", "select"], + body: "${2:table} ${1:column} $1 $0 \\$", + description: "Select rows", +}; +describe("plugin snippets", () => { + it("preserves defaults, linked stops, numeric tab order and escaped dollars", () => { + const ts = snippetTemplates(snippet); + expect(ts.map((t) => t.abbrev)).toEqual(["sel", "select"]); + expect(ts[0]!.variables?.map((v) => v.name)).toEqual(["P1", "P2"]); + const expanded = expandTemplate(ts[0]!); + expect(expanded.preview).toBe("table column column $"); + expect(expanded.snippet).toBe("${2:table} ${1:column} ${1:column} ${0} $"); + }); + it("withdraws read-only plugin entries without persisting customizations", () => { + const off = addContributedSnippets([snippet]); + const t = liveTemplates().find((t) => t.id.startsWith(snippet.id))!; + expect(t.pluginOrigin).toBe("Plugin"); + saveTemplate({ ...t, body: "changed" }); + removeTemplate(t.id); + expect(liveTemplates().find((x) => x.id === t.id)?.body).toBe(t.body); + off(); + expect(liveTemplates().some((t) => t.id.startsWith(snippet.id))).toBe(false); + }); +}); diff --git a/src/features/templates/__tests__/views.test.tsx b/src/features/templates/__tests__/views.test.tsx index ce63f2169..3a5fbc444 100644 --- a/src/features/templates/__tests__/views.test.tsx +++ b/src/features/templates/__tests__/views.test.tsx @@ -1,3 +1,4 @@ +import { addContributedSnippets } from "@/workbench/contributions"; import { describe, expect, it } from "vitest"; import { act, fireEvent, render, screen } from "@testing-library/react"; import { LiveTemplatesPage, groupTemplates } from "../settings/LiveTemplatesPage"; @@ -172,3 +173,24 @@ describe("dialogs", () => { expect(picked).toBe("t"); }); }); + +it("shows plugin snippets as read-only live templates and withdraws them with the plugin", () => { + const off = addContributedSnippets([ + { + id: "plugin-snippet:test:file:0", + pluginName: "SQL plugin", + language: "sql", + prefixes: ["selectrows"], + body: "SELECT ${1:column}$0", + description: "Select rows", + }, + ]); + const view = render(<LiveTemplatesPage />); + fireEvent.click(screen.getByText("selectrows")); + expect(screen.getByLabelText("Description")).toBeDisabled(); + expect(screen.getByRole("button", { name: "Remove Template" })).toBeDisabled(); + expect(screen.getByLabelText("Enable selectrows")).toBeDisabled(); + act(() => off()); + expect(screen.queryByText("selectrows")).toBeNull(); + view.unmount(); +}); diff --git a/src/features/templates/live/model.ts b/src/features/templates/live/model.ts index f947a0ac3..04d8b2539 100644 --- a/src/features/templates/live/model.ts +++ b/src/features/templates/live/model.ts @@ -21,6 +21,8 @@ export interface TemplateVariable { } export interface LiveTemplate { + /** Read-only contribution, withdrawn with its plugin. */ + pluginOrigin?: string; id: string; /** Settings grouping, e.g. "Rust", "Surround". */ group: string; diff --git a/src/features/templates/live/snippets.ts b/src/features/templates/live/snippets.ts new file mode 100644 index 000000000..324dbedef --- /dev/null +++ b/src/features/templates/live/snippets.ts @@ -0,0 +1,41 @@ +/** Numbered snippet placeholders translated into the existing linked live-template fields. */ +import type { ContributedSnippet } from "@/workbench/contributions"; +import type { LiveTemplate, TemplateVariable } from "./model"; +export function snippetTemplates(s: ContributedSnippet): LiveTemplate[] { + const variables = new Map<number, TemplateVariable>(); + let body = ""; + for (let i = 0; i < s.body.length;) { + if (s.body[i] === "\\" && /[$}\\]/.test(s.body[i + 1] ?? "")) { + body += s.body[i + 1] === "$" ? "$$" : s.body[i + 1]; + i += 2; + continue; + } + const m = /^\$(?:(\d+)|\{(\d+)(?::((?:\\.|[^}])*))?\})/.exec(s.body.slice(i)); + if (m) { + const n = Number(m[1] ?? m[2]); + if (n === 0) body += "$END$"; + else { + const name = `P${n}`; + const value = m[3]?.replace(/\\([$}\\])/g, "$1"); + if (!variables.has(n) || value !== undefined) + variables.set(n, { name, defaultValue: value === undefined ? undefined : JSON.stringify(value) }); + body += `$${name}$`; + } + i += m[0].length; + } else { + body += s.body[i] === "$" ? "$$" : s.body[i]; + i++; + } + } + return s.prefixes.map((abbrev, i) => ({ + id: `${s.id}:${i}`, + group: s.pluginName, + pluginOrigin: s.pluginName, + abbrev, + description: s.description, + body, + contexts: [s.language], + scope: "any", + variables: [...variables].sort(([a], [b]) => a - b).map(([, v]) => v), + })); +} diff --git a/src/features/templates/live/store.ts b/src/features/templates/live/store.ts index e048d919a..21ce2eb93 100644 --- a/src/features/templates/live/store.ts +++ b/src/features/templates/live/store.ts @@ -1,3 +1,5 @@ +import { useContributedSnippets } from "@/workbench/contributions"; +import { snippetTemplates } from "./snippets"; /** * Effective live templates: built-ins with the user's edits applied plus * user-defined templates, persisted globally in localStorage. @@ -21,7 +23,10 @@ export function mergeTemplates(builtins: readonly LiveTemplate[], c: LiveTemplat const deleted = new Set(c.deleted); const out = builtins.filter((t) => !deleted.has(t.id)).map((t) => c.templates[t.id] ?? t); for (const t of Object.values(c.templates)) if (!BUILTIN_IDS.has(t.id) && !deleted.has(t.id)) out.push(t); - return out; + return [ + ...out.filter((t) => !t.id.startsWith("plugin-snippet:")), + ...useContributedSnippets.getState().snippets.flatMap(snippetTemplates), + ]; } interface State { @@ -46,11 +51,13 @@ export function liveTemplates(): LiveTemplate[] { } export function saveTemplate(t: LiveTemplate) { + if (t.pluginOrigin) return; const c = useLiveTemplates.getState().custom; commit({ ...c, templates: { ...c.templates, [t.id]: t }, deleted: c.deleted.filter((d) => d !== t.id) }); } export function removeTemplate(id: string) { + if (id.startsWith("plugin-snippet:")) return; const c = useLiveTemplates.getState().custom; const templates = { ...c.templates }; delete templates[id]; @@ -102,3 +109,7 @@ export function reloadLiveTemplates() { useLiveTemplates.setState({ custom, templates: mergeTemplates(BUILTIN_TEMPLATES, custom) }); useLiveTemplateOptions.setState(loadJson(OPTIONS_KEY, { expandWithTab: true, showInCompletion: true })); } + +useContributedSnippets.subscribe(() => + useLiveTemplates.setState((s) => ({ templates: mergeTemplates(BUILTIN_TEMPLATES, s.custom) })), +); diff --git a/src/features/templates/settings/LiveTemplatesPage.tsx b/src/features/templates/settings/LiveTemplatesPage.tsx index 62a763a63..8b6cf95d8 100644 --- a/src/features/templates/settings/LiveTemplatesPage.tsx +++ b/src/features/templates/settings/LiveTemplatesPage.tsx @@ -73,7 +73,7 @@ export function LiveTemplatesPage() { icon="minus" size="small" title="Remove Template" - disabled={!selected} + disabled={!selected || !!selected.pluginOrigin} onClick={() => { if (!selected) return; removeTemplate(selected.id); @@ -105,6 +105,7 @@ export function LiveTemplatesPage() { onClick={() => setSelectedId(t.id)} > <Checkbox + disabled={!!t.pluginOrigin} checked={t.enabled !== false} onChange={(v) => saveTemplate({ ...t, enabled: v })} aria-label={tx`Enable ${t.abbrev}`} @@ -123,7 +124,9 @@ export function LiveTemplatesPage() { </div> <div className={styles.editor}> {selected ? ( - <TemplateEditor key={selected.id} template={selected} /> + <fieldset disabled={!!selected.pluginOrigin} className={styles.readonly}> + <TemplateEditor key={selected.id} template={selected} /> + </fieldset> ) : ( <div className={styles.empty}> <T>Select a template to edit it</T> diff --git a/src/features/templates/settings/Settings.module.css b/src/features/templates/settings/Settings.module.css index 9146bfaea..1cf90abff 100644 --- a/src/features/templates/settings/Settings.module.css +++ b/src/features/templates/settings/Settings.module.css @@ -220,3 +220,15 @@ background: var(--bg-selected); color: var(--fg-strong); } + +.readonly { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + gap: 8px; + border: 0; + padding: 0; + margin: 0; + min-width: 0; +} diff --git a/src/features/terminal/index.ts b/src/features/terminal/index.ts index 2aa97c529..06fd6c03c 100644 --- a/src/features/terminal/index.ts +++ b/src/features/terminal/index.ts @@ -101,6 +101,19 @@ export function activate(): () => void { hidden: true, run: ({ arg }) => openNewTab(typeof arg === "string" && arg ? arg : undefined), }, + { + id: "terminal.pluginSession", + title: "Open Plugin Terminal Session", + category: "Terminal", + hidden: true, + when: "projectOpen", + run: async ({ arg }) => { + const rt = await loadTerminalRuntime(); + await rt.pluginSession(arg as Parameters<typeof rt.pluginSession>[0]); + toolWindowService().show(WINDOW, true); + focusActiveSoon(); + }, + }, { id: "terminal.runCommand", title: "Run Command in Terminal", diff --git a/src/features/terminal/loader.ts b/src/features/terminal/loader.ts index 2bfdc8d7a..acd7d839d 100644 --- a/src/features/terminal/loader.ts +++ b/src/features/terminal/loader.ts @@ -4,6 +4,7 @@ * exist before it has loaded. */ import type * as Runtime from "./runtime"; +import { useProject } from "@/platform/project"; export type TerminalRuntime = typeof Runtime; @@ -16,5 +17,8 @@ export function terminalRuntime(): TerminalRuntime | null { } export function loadTerminalRuntime(): Promise<TerminalRuntime> { - return (pending ??= import("./runtime").then((m) => (loaded = m))); + return (pending ??= import("./runtime").then((m) => { + m.syncTerminalProject(useProject.getState().project?.root ?? null); + return (loaded = m); + })); } diff --git a/src/features/terminal/persist.test.ts b/src/features/terminal/persist.test.ts index aff470f25..062570d05 100644 --- a/src/features/terminal/persist.test.ts +++ b/src/features/terminal/persist.test.ts @@ -1,5 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { decode } from "./persist"; +import { useProject } from "@/platform/project"; import { closeAll, getSession, @@ -15,24 +16,38 @@ import { vi.mock("./session", () => ({ TerminalSession: class { key: string; - state: { cwd: string }; - command: { program: string; args: string[]; shell?: boolean } | undefined; + state: { cwd: string; status: string }; + command: + | { program: string; args: string[]; shell?: boolean; onExit?: (code: number | null, closed: boolean) => void } + | undefined; fallbackRoot: string | undefined; constructor(opts: { key: string; cwd: string; - command?: { program: string; args: string[]; shell?: boolean }; + command?: { + program: string; + args: string[]; + shell?: boolean; + onExit?: (code: number | null, closed: boolean) => void; + }; fallbackRoot?: string; }) { this.key = opts.key; - this.state = { cwd: opts.cwd }; + this.state = { cwd: opts.cwd, status: "idle" }; this.command = opts.command; this.fallbackRoot = opts.fallbackRoot; } - dispose() {} + async start() { + this.state.status = "running"; + } + dispose() { + this.command?.onExit?.(null, true); + } }, })); +vi.mock("./runtime", async () => await vi.importActual("./store")); + const state = () => useTerminals.getState(); const names = () => state().tabs.map((t) => t.customName ?? t.name); const sessionOf = (key: string) => @@ -46,7 +61,30 @@ beforeEach(() => { closeAll(); }); +afterEach(() => useProject.setState({ project: null })); + describe("terminal tab memory", () => { + it("keeps a plugin terminal opened before the terminal pane first mounts", async () => { + useProject.setState({ project: { root: "/lazy-project" } } as never); + const { loadTerminalRuntime } = await import("./loader"); + const runtime = await loadTerminalRuntime(); + const onExit = vi.fn(); + await runtime.pluginSession({ + kind: "terminal", + operation: "first-plugin-terminal", + title: "Wrapper", + program: "/approved/wrapper", + cwd: "/lazy-project", + onExit, + }); + const tab = state().tabs[0]!; + const session = getSession(tab.session); + runtime.syncTerminalProject("/lazy-project"); + expect(getSession(tab.session)).toBe(session); + expect(state().tabs).toContainEqual(tab); + expect(onExit).not.toHaveBeenCalled(); + }); + it("rejects a stored value that is not this version", () => { expect(decode(null)).toBeNull(); expect(decode({ v: 2, tabs: [], layout: "g" })).toBeNull(); diff --git a/src/features/terminal/runtime.ts b/src/features/terminal/runtime.ts index fdb6e1d30..0816ce4ca 100644 --- a/src/features/terminal/runtime.ts +++ b/src/features/terminal/runtime.ts @@ -5,6 +5,7 @@ export { applyThemeToAll, closeAll, newTab, + pluginSession, syncTerminalProject, unbindTerminals, newTabAt, diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index 8e61ab329..4bd9ccd89 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -71,7 +71,14 @@ export interface SessionOptions { * Run this program instead of the configured shell (e.g. `docker exec -it <id> sh`). * `shell`: it is a shell (a predefined session), so shell integration still applies. */ - command?: { program: string; args: string[]; shell?: boolean }; + command?: { + program: string; + args: string[]; + shell?: boolean; + env?: Record<string, string>; + spawn?: (cols: number, rows: number, output: Channel<string>) => Promise<import("@/bindings").TerminalInfo>; + onExit?: (status: number | null, closed: boolean) => void; + }; /** * Restored tabs only: the project root, used when `cwd` no longer exists * (deleted since the last run, see `resolveStartCwd`). @@ -569,20 +576,22 @@ export class TerminalSession { const args = custom ? custom.args : shellArgs; const cwd = await resolveStartCwd(this.opts.cwd, this.opts.fallbackRoot); if (cwd !== this.opts.cwd) this.update({ cwd }); - const info = await call( - commands.termSpawn( - { - cwd, - shell: shell ?? null, - args, - env: { ...t.env }, - cols, - rows, - shellIntegration: (!custom || !!custom.shell) && t.shellIntegration, - }, - channel, - ), - ); + const info = custom?.spawn + ? await custom.spawn(cols, rows, channel) + : await call( + commands.termSpawn( + { + cwd, + shell: shell ?? null, + args, + env: custom?.onExit ? { ...custom.env } : { ...t.env, ...custom?.env }, + cols, + rows, + shellIntegration: (!custom || !!custom.shell) && t.shellIntegration, + }, + channel, + ), + ); if (this.disposed || generation !== this.generation) { void commands.termKill(info.id); return; @@ -593,7 +602,7 @@ export class TerminalSession { } catch (e) { this.pendingInput = ""; this.term.writeln(`\x1b[31mFailed to start terminal: ${e instanceof Error ? e.message : String(e)}\x1b[0m`); - this.term.writeln(RESTART_HINT); + if (!custom?.onExit) this.term.writeln(RESTART_HINT); this.update({ status: "error" }); } } @@ -622,18 +631,21 @@ export class TerminalSession { exited(code: number | null) { if (this.disposed) return; this.ptyId = null; + this.opts.command?.onExit?.(code, false); this.pendingInput = ""; this.update({ status: "exited", exitCode: code }); // Let output still in flight on the channel land first. setTimeout(() => { if (this.disposed || this.snapshot.status !== "exited") return; const suffix = code !== null && code !== 0 ? ` (exit code ${code})` : ""; - this.term.write(`\r\n\x1b[2m[Process completed${suffix}]\x1b[0m\r\n${RESTART_HINT}\r\n`); + const hint = this.opts.command?.onExit ? "" : `${RESTART_HINT}\r\n`; + this.term.write(`\r\n\x1b[2m[Process completed${suffix}]\x1b[0m\r\n${hint}`); this.opts.onExit?.(this.key); }, 60); } restart() { + if (this.opts.command?.onExit) return; if (this.snapshot.status === "running" || this.snapshot.status === "starting") return; this.term.write("\r\n"); this.pendingInput = ""; @@ -761,12 +773,14 @@ export class TerminalSession { dispose() { if (this.disposed) return; this.disposed = true; + if (this.snapshot.status === "running" || this.snapshot.status === "starting") + this.opts.command?.onExit?.(null, true); this.fitter.cancel(); this.ptyResize.cancel(); this.pendingInput = ""; this.resizeObserver?.disconnect(); this.host.removeEventListener("mousedown", this.onMiddleClick); - if (this.ptyId !== null) void commands.termKill(this.ptyId); + if (this.ptyId !== null && !this.opts.command?.spawn) void commands.termKill(this.ptyId); this.ptyId = null; this.disposables.forEach((d) => d.dispose()); this.term.dispose(); diff --git a/src/features/terminal/store.ts b/src/features/terminal/store.ts index 76e00f9a5..0f3a6382f 100644 --- a/src/features/terminal/store.ts +++ b/src/features/terminal/store.ts @@ -631,3 +631,44 @@ export function unbindTerminals() { restoring = false; } } + +const pluginSessions = new Map<string, string>(); + +/** Host-authorized plugin sessions use the same tabs and PTY controls as user terminals. */ +export async function pluginSession(a: { + kind: "terminal"; + operation: string; + title?: string; + program?: string; + args?: string[]; + cwd?: string; + env?: Record<string, string>; + spawn?: NonNullable<SessionOptions["command"]>["spawn"]; + onExit: (code: number | null, closed: boolean) => void; +}) { + const tab = useTerminals.getState().tabs.find((t) => t.id === pluginSessions.get(a.operation)); + const session = tab && getSession(tab.session); + if (session && session.state.status === "running") { + selectTab(tab.id); + return; + } + if (!a.cwd || !a.program) throw new Error("Invalid terminal request"); + const created = newTab(a.cwd, a.title, { + program: a.program, + args: a.args ?? [], + env: a.env, + spawn: a.spawn, + onExit: (code, closed) => { + pluginSessions.delete(a.operation); + a.onExit(code, closed); + }, + }); + pluginSessions.set(a.operation, created.id); + const started = getSession(created.session)!; + await started.start(); + if (started.state.status === "error") { + closeTab(created.id); + pluginSessions.delete(a.operation); + throw new Error("Terminal did not start"); + } +} diff --git a/src/features/vim/__tests__/vim.test.ts b/src/features/vim/__tests__/vim.test.ts index 097a3138e..069c2ee18 100644 --- a/src/features/vim/__tests__/vim.test.ts +++ b/src/features/vim/__tests__/vim.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { EditorState, Prec } from "@codemirror/state"; import { EditorView, keymap } from "@codemirror/view"; import { getCM, Vim } from "@replit/codemirror-vim"; -import { embeddedEditorExtensions, transientChange, useEditorRegistry } from "@/editor/api"; +import { embeddedEditorExtensions, transientChange, useEditorRegistry, onEditorEvent } from "@/editor/api"; import { executeAction, registerAction } from "@/platform/actions"; import { getContext } from "@/platform/context"; import { _handleEx, applyRcCommands, vimExtension } from "../engine"; @@ -49,6 +49,7 @@ describe("Vim mode toggle", () => { await executeAction("vim.toggle"); expect(useVimSettings.getState().enabled).toBe(false); expect(hasContribution()).toBe(false); + expect(getContext()["vim.mode"]).toBe(""); expect(diffSide.dom.hasAttribute("data-openidea-vim")).toBe(false); expect(getCM(diffSide)).toBeNull(); diffSide.destroy(); @@ -217,7 +218,17 @@ describe("Vim engine", () => { const closePopup = Prec.highest( keymap.of([{ key: "Escape", run: () => (popupOpen ? !(popupOpen = false) : false) }]), ); - const view = makeView([vimExtension(), closePopup]); + const modes: string[] = []; + const offModes = onEditorEvent((event) => { + if (event.type === "inputModeChanged") { + expect(event.path).toBe("/p/a.ts"); + modes.push(event.mode); + } + }); + const view = makeView([ + vimExtension({ doc: 1, path: "/p/a.ts", language: "typescript", uiMode: null, readOnly: false }), + closePopup, + ]); view.focus(); const cm = getCM(view)!; Vim.handleKey(cm as never, "i", "user"); @@ -233,6 +244,9 @@ describe("Vim engine", () => { expect(cm.state.vim?.insertMode).toBe(false); Vim.handleKey(cm as never, "v", "user"); expect(useVimMode.getState().mode).toBe("visual"); + expect(getContext()["vim.mode"]).toBe("visual"); + expect(modes).toEqual(expect.arrayContaining(["insert", "normal", "visual"])); + offModes(); view.destroy(); }); }); diff --git a/src/features/vim/engine.ts b/src/features/vim/engine.ts index c3612a200..4a8209bea 100644 --- a/src/features/vim/engine.ts +++ b/src/features/vim/engine.ts @@ -6,7 +6,8 @@ import { EditorState, Prec, StateEffect, StateField, Transaction, type Extension } from "@codemirror/state"; import { EditorView, ViewPlugin, runScopeHandlers } from "@codemirror/view"; import { Vim, getCM, vim } from "@replit/codemirror-vim"; -import { transientChange } from "@/editor/api"; +import { transientChange, reportInputMode, type EditorDocContext } from "@/editor/api"; +import { setContext } from "@/platform/context"; import { executeAction, getAction } from "@/platform/actions"; import { showStatusInfo } from "@/workbench/contributions"; import { VIM_EDITOR_ATTR } from "./conflicts"; @@ -190,27 +191,32 @@ const escapeClosesPopupsFirst = EditorView.domEventHandlers({ }, }); -const modeTracker = ViewPlugin.define((view) => { - const cm = getCM(view); - let state: VimModeState = { mode: cm?.state.vim?.insertMode ? "insert" : "normal", sub: null }; - const publish = () => useVimMode.setState(state); - // Not `view.hasFocus`: that also needs the window focused, and this editor stays the last focused one. - const focused = () => view.root.activeElement === view.contentDOM; - const onModeChange = (e: { mode: string; subMode?: string }) => { - state = { mode: e.mode, sub: e.subMode === "linewise" || e.subMode === "blockwise" ? e.subMode : null }; - queueMicrotask(() => view.dispatch({ effects: setVimInsert.of(e.mode === "insert" || e.mode === "replace") })); +const modeTracker = (ctx?: EditorDocContext) => + ViewPlugin.define((view) => { + const cm = getCM(view); + let state: VimModeState = { mode: cm?.state.vim?.insertMode ? "insert" : "normal", sub: null }; + const publish = () => { + useVimMode.setState(state); + setContext("vim.mode", state.mode); + if (ctx) reportInputMode(ctx.path, state.mode, state.sub); + }; + // Not `view.hasFocus`: that also needs the window focused, and this editor stays the last focused one. + const focused = () => view.root.activeElement === view.contentDOM; + const onModeChange = (e: { mode: string; subMode?: string }) => { + state = { mode: e.mode, sub: e.subMode === "linewise" || e.subMode === "blockwise" ? e.subMode : null }; + queueMicrotask(() => view.dispatch({ effects: setVimInsert.of(e.mode === "insert" || e.mode === "replace") })); + if (focused()) publish(); + }; + cm?.on("vim-mode-change", onModeChange); + view.contentDOM.addEventListener("focus", publish); if (focused()) publish(); - }; - cm?.on("vim-mode-change", onModeChange); - view.contentDOM.addEventListener("focus", publish); - if (focused()) publish(); - return { - destroy() { - cm?.off("vim-mode-change", onModeChange); - view.contentDOM.removeEventListener("focus", publish); - }, - }; -}); + return { + destroy() { + cm?.off("vim-mode-change", onModeChange); + view.contentDOM.removeEventListener("focus", publish); + }, + }; + }); const caret = "var(--caret, var(--fg-strong))"; @@ -237,7 +243,7 @@ const vimTheme = EditorView.theme({ }); /** Vim for one editor. Highest precedence: Vim sees keys before the editor's keymaps. */ -export function vimExtension(): Extension { +export function vimExtension(ctx?: EditorDocContext): Extension { defineExCommands(); return [ Prec.highest([ @@ -248,7 +254,7 @@ export function vimExtension(): Extension { imeTransactions, escapeClosesPopupsFirst, vim(), - modeTracker, + modeTracker(ctx), ]), imeCleanup, vimTheme, diff --git a/src/features/vim/index.ts b/src/features/vim/index.ts index 7ccdba177..c9c684f00 100644 --- a/src/features/vim/index.ts +++ b/src/features/vim/index.ts @@ -52,6 +52,7 @@ export function activate(): () => void { const gen = ++generation; setContext("vimMode", enabled); if (!enabled) { + setContext("vim.mode", ""); contribution?.(); contribution = null; return; @@ -63,7 +64,7 @@ export function activate(): () => void { id: "vim", order: 0, embedded: true, - extensions: () => e.vimExtension(), + extensions: (ctx) => e.vimExtension(ctx), }); }; @@ -117,6 +118,7 @@ export function activate(): () => void { contribution?.(); contribution = null; setContext("vimMode", false); + setContext("vim.mode", ""); disposers.reverse().forEach((d) => d()); }; } diff --git a/src/platform/actions.test.ts b/src/platform/actions.test.ts new file mode 100644 index 000000000..614393ce1 --- /dev/null +++ b/src/platform/actions.test.ts @@ -0,0 +1,21 @@ +import { expect, it, vi } from "vitest"; +import { executeAction, executeActionOrThrow, registerAction } from "./actions"; + +it("propagates host launch errors while retaining ordinary action error handling", async () => { + const dispose = registerAction({ + id: "test.hostLaunch", + title: "Test", + run: async () => { + throw new Error("spawn failed"); + }, + }); + const log = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + await expect(executeActionOrThrow("test.hostLaunch")).rejects.toThrow("spawn failed"); + await expect(executeAction("test.hostLaunch")).resolves.toBe(true); + expect(log).toHaveBeenCalledOnce(); + } finally { + dispose(); + log.mockRestore(); + } +}); diff --git a/src/platform/actions.ts b/src/platform/actions.ts index dd5777a8f..74362d1fa 100644 --- a/src/platform/actions.ts +++ b/src/platform/actions.ts @@ -167,7 +167,16 @@ export function onActionExecuted(listener: ActionListener): () => void { } /** Runs an action if registered and enabled. Returns whether it ran. */ -export async function executeAction(id: string, arg?: unknown): Promise<boolean> { +export function executeAction(id: string, arg?: unknown): Promise<boolean> { + return runAction(id, arg, false); +} + +/** Host requests need the launch error before acknowledging an operation. */ +export async function executeActionOrThrow(id: string, arg?: unknown): Promise<void> { + if (!(await runAction(id, arg, true))) throw new Error(`Action unavailable: ${id}`); +} + +async function runAction(id: string, arg: unknown, propagateError: boolean): Promise<boolean> { const action = getAction(id); if (!action) { console.warn(`unknown action: ${id}`); @@ -192,6 +201,7 @@ export async function executeAction(id: string, arg?: unknown): Promise<boolean> } await pending; } catch (e) { + if (propagateError) throw e; console.error(`action ${id} failed`, e); } return true; diff --git a/src/platform/languageSettings.ts b/src/platform/languageSettings.ts index f97105753..329683d8c 100644 --- a/src/platform/languageSettings.ts +++ b/src/platform/languageSettings.ts @@ -130,10 +130,12 @@ export interface ArtifactConfigValid { } export interface LanguagesValid { + pluginFormatters: Record<string, string>; python: { interpreter: string; activateVirtualenv: boolean }; ruby: { interpreter: string }; - node: { interpreter: string; packageManager: "" | "npm" | "pnpm" | "yarn" | "bun" }; + node: { runTarget: string; interpreter: string; packageManager: "" | "npm" | "pnpm" | "yarn" | "bun" }; php: { + runTarget: string; interpreter: string; languageLevel: (typeof PHP_LANGUAGE_LEVELS)[number]; includePaths: string[]; @@ -225,10 +227,11 @@ export type FolderListKey = | "unmarked"; export const DEFAULT_LANGUAGES: LanguagesValid = { + pluginFormatters: {}, python: { interpreter: "", activateVirtualenv: true }, ruby: { interpreter: "" }, - node: { interpreter: "", packageManager: "" }, - php: { interpreter: "", languageLevel: "", includePaths: [], stubs: [], phpstanConfig: "" }, + node: { runTarget: "", interpreter: "", packageManager: "" }, + php: { runTarget: "", interpreter: "", languageLevel: "", includePaths: [], stubs: [], phpstanConfig: "" }, rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" }, go: { goroot: "", gopath: "", buildTags: DEFAULT_GO_BUILD_TAGS, modules: { env: {}, autoVendor: true } }, typescript: { version: "bundled" }, @@ -344,16 +347,23 @@ export function validateLanguages(raw: unknown): LanguagesValid { const gradle = obj(java.gradle); const markdown = obj(l.markdown); return { + pluginFormatters: Object.fromEntries( + Object.entries(obj(l.pluginFormatters)).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ), python: { interpreter: text(py.interpreter, d.python.interpreter), activateVirtualenv: bool(py.activateVirtualenv, d.python.activateVirtualenv), }, ruby: { interpreter: text(ruby.interpreter, d.ruby.interpreter) }, node: { + runTarget: text(node.runTarget, ""), interpreter: text(node.interpreter, d.node.interpreter), packageManager: oneOf(node.packageManager, ["", "npm", "pnpm", "yarn", "bun"] as const, d.node.packageManager), }, php: { + runTarget: text(php.runTarget, ""), interpreter: text(php.interpreter, d.php.interpreter), languageLevel: oneOf(php.languageLevel, PHP_LANGUAGE_LEVELS, d.php.languageLevel), includePaths: strings(php.includePaths), diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index d630a2740..260ade947 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -561,5 +561,45 @@ "Show status bar": "Show status bar", "No background tasks": "No background tasks", "Status Bar Widgets": "Status Bar Widgets", - "Background Tasks": "Background Tasks" + "Background Tasks": "Background Tasks", + "Choose plugin executable": "Choose plugin executable", + "Approve plugin executable": "Approve plugin executable", + "Executable scope": "Executable scope", + "Choose executable…": "Choose executable…", + "Reset executable": "Reset executable", + "Revalidate the default executable? If it is unavailable, this permission will be revoked.": "Revalidate the default executable? If it is unavailable, this permission will be revoked.", + "Allow {name} to run {selected}? This is equivalent to running arbitrary code on this machine.": "Allow {name} to run {selected}? This is equivalent to running arbitrary code on this machine.", + "Connect to {host} over unencrypted HTTP (from the machine the project is on)": "Connect to {host} over unencrypted HTTP (from the machine the project is on)", + "Connect to local socket {path} — equivalent to running arbitrary code on the project machine": "Connect to local socket {path} — equivalent to running arbitrary code on the project machine", + "Output: {channel}": "Output: {channel}", + "You can close this tab.": "You can close this tab.", + "Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing": "Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing", + "Run Plugin Configuration": "Run Plugin Configuration", + "Open Plugin Terminal Session": "Open Plugin Terminal Session", + "Debug Plugin Configuration": "Debug Plugin Configuration", + "Expected: {0}": "Expected: {0}", + "Actual: {0}": "Actual: {0}", + "Use {0} for running PHP / Node / Composer in this project?": "Use {0} for running PHP / Node / Composer in this project?", + "Use": "Use", + "Not now": "Not now", + "Run in target": "Run in target", + "Run in target {0}": "Run in target {0}", + "Default run target": "Default run target", + "Read-only": "Read-only", + "Snippets": "Snippets", + "Plugin Formatters": "Plugin Formatters", + "Format on save": "Format on save", + "The selected plugin formatter is unavailable": "The selected plugin formatter is unavailable", + "Plugin formatter returned an invalid range": "Plugin formatter returned an invalid range", + "Plugin formatter returned overlapping edits": "Plugin formatter returned overlapping edits", + "Invalid plugin command": "Invalid plugin command", + "Plugin host unavailable": "Plugin host unavailable", + "The plugin did not declare this command": "The plugin did not declare this command", + "Invalid color range": "Invalid color range", + "Manage its own database sources and store their passwords in trusted projects": "Manage its own database sources and store their passwords in trusted projects", + "Connect Plugin Data Source": "Connect Plugin Data Source", + "Invalid data source id": "Invalid data source id", + "Database connection failed": "Database connection failed", + "Database service unavailable": "Database service unavailable", + "Trust this project to connect plugin data sources": "Trust this project to connect plugin data sources" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index bbe7692cd..5aca76293 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6904,5 +6904,44 @@ "When off, Alt key combinations still work but pressing Alt alone does not focus the main menu": "关闭后,Alt 组合键仍然可用,但单独按下 Alt 不会聚焦主菜单", "the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`": "{gemfile} 的 bundle 中没有 `debug` gem。请在 Gemfile 中添加 `gem \"debug\"`,然后运行 `bundle install`", "No background tasks": "没有后台任务", - "Status Bar Widgets": "状态栏微件" + "Status Bar Widgets": "状态栏微件", + "Choose plugin executable": "选择插件可执行文件", + "Approve plugin executable": "批准插件可执行文件", + "Executable scope": "可执行文件作用域", + "Choose executable…": "选择可执行文件…", + "Reset executable": "重置可执行文件", + "Revalidate the default executable? If it is unavailable, this permission will be revoked.": "重新验证默认可执行文件?如果不可用,将撤销此权限。", + "Allow {name} to run {selected}? This is equivalent to running arbitrary code on this machine.": "允许 {name} 运行 {selected}?这等同于在此计算机上运行任意代码。", + "Connect to {host} over unencrypted HTTP (from the machine the project is on)": "通过未加密的 HTTP 连接到 {host}(从项目所在的计算机)", + "Connect to local socket {path} — equivalent to running arbitrary code on the project machine": "连接到本地套接字 {path} — 等同于在项目所在的计算机上运行任意代码", + "Output: {channel}": "输出:{channel}", + "You can close this tab.": "你可以关闭此标签页。", + "Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing": "如同您亲自选择一样运行任意 IDE 操作或其他插件的命令,包括运行程序、提交和推送", + "Run Plugin Configuration": "运行插件配置", + "Open Plugin Terminal Session": "打开插件终端会话", + "Debug Plugin Configuration": "调试插件配置", + "Expected: {0}": "期望值:{0}", + "Actual: {0}": "实际值:{0}", + "Use {0} for running PHP / Node / Composer in this project?": "在此项目中使用 {0} 运行 PHP / Node / Composer?", + "Use": "使用", + "Not now": "暂不使用", + "Run in target": "在目标中运行", + "Run in target {0}": "在目标 {0} 中运行", + "Default run target": "默认运行目标", + "Snippets": "代码片段", + "Plugin Formatters": "插件格式化工具", + "Format on save": "保存时格式化", + "The selected plugin formatter is unavailable": "所选插件格式化工具不可用", + "Plugin formatter returned an invalid range": "插件格式化工具返回了无效范围", + "Plugin formatter returned overlapping edits": "插件格式化工具返回了重叠的编辑", + "Invalid plugin command": "无效的插件命令", + "Plugin host unavailable": "插件宿主不可用", + "The plugin did not declare this command": "插件未声明此命令", + "Invalid color range": "无效的颜色范围", + "Manage its own database sources and store their passwords in trusted projects": "在受信任项目中管理其自己的数据库数据源并保存密码", + "Connect Plugin Data Source": "连接插件数据源", + "Invalid data source id": "数据源标识无效", + "Database connection failed": "数据库连接失败", + "Database service unavailable": "数据库服务不可用", + "Trust this project to connect plugin data sources": "信任此项目后才能连接插件数据源" } diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index c6fa6e52d..030a9c05b 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -565,7 +565,7 @@ function scanTypeScript(): Scan { /** Read strings without letting a Rust character literal open a string match. */ function backendLiterals(source: string): string[] { const out: string[] = []; - for (const m of source.matchAll(/'(?:\\.|[^'\\\r\n])'|"((?:[^"\\]|\\.)*)"/g)) { + for (const m of source.matchAll(/'(?:\\.|[^'\\\r\n])'|"((?:[^"\\]|\\[\s\S])*)"/g)) { if (m[1] !== undefined) out.push(m[1]); } return out; @@ -577,6 +577,13 @@ it("reads backend messages after quote character literals", () => { ).toEqual(["Select a .NET project", "Expected 'x'"]); }); +it("reads backend messages continued over a line", () => { + expect(backendLiterals('let m = "can\'t rename; \\\n half renamed"; let n = "Next";')).toEqual([ + "can't rename; \\\n half renamed", + "Next", + ]); +}); + /** String literals of the Rust crates and language definitions (backend messages). */ function scanBackend(literals: Set<string>) { const files = [ diff --git a/src/platform/pluginRunTargets.ts b/src/platform/pluginRunTargets.ts new file mode 100644 index 000000000..d8c3d67d2 --- /dev/null +++ b/src/platform/pluginRunTargets.ts @@ -0,0 +1,24 @@ +/** Host-owned registrations shared by plugins, Run and Settings. */ +import { create } from "zustand"; +import { useMemo } from "react"; +import type { RegisteredRunTarget } from "@/bindings"; +import { DEFAULT_RUN_TARGET, type RunTargetValid } from "./remoteSettings"; +import { useWorkflowSettings, workflowSettings } from "./workflowSettings"; + +export const usePluginRunTargets = create<{ targets: RegisteredRunTarget[] }>(() => ({ targets: [] })); +export type RuntimeRunTarget = Omit<RunTargetValid, "kind"> & { kind: RunTargetValid["kind"] | "plugin" }; +const converted = (targets: RegisteredRunTarget[]): RuntimeRunTarget[] => + targets.map((t) => ({ + ...DEFAULT_RUN_TARGET, + kind: "plugin", + id: t.id, + name: `${t.target.name} (${t.pluginName})`, + })); +export function runTargets(): RuntimeRunTarget[] { + return [...workflowSettings().runTargets, ...converted(usePluginRunTargets.getState().targets)]; +} +export function useRunTargets(): RuntimeRunTarget[] { + const saved = useWorkflowSettings((s) => s.runTargets); + const plugins = usePluginRunTargets((s) => s.targets); + return useMemo(() => [...saved, ...converted(plugins)], [saved, plugins]); +} diff --git a/src/platform/remoteSettings.ts b/src/platform/remoteSettings.ts index b1576f629..75ad2fefe 100644 --- a/src/platform/remoteSettings.ts +++ b/src/platform/remoteSettings.ts @@ -243,7 +243,7 @@ let seq = 0; export const newRemoteId = (prefix: string) => `${prefix}-${Date.now().toString(36)}-${++seq}`; /** Whether `target` runs configurations in a container (Docker, Compose) rather than over SSH. */ -export const inContainer = (target: Pick<RunTargetValid, "kind">) => target.kind !== "ssh"; +export const inContainer = (target: { kind: string }) => target.kind === "docker" || target.kind === "compose"; /** Backend connection descriptor for deployment and folder browsing. */ export function deploymentConnection(server: DeploymentServerValid): { kind: "local" } | { kind: "sftp"; ssh: string } { diff --git a/src/platform/runTargetSelection.ts b/src/platform/runTargetSelection.ts new file mode 100644 index 000000000..58ed19e3a --- /dev/null +++ b/src/platform/runTargetSelection.ts @@ -0,0 +1,24 @@ +import type { RunConfig } from "@/bindings"; +import { validateLanguages } from "./languageSettings"; +import { useProject } from "./project"; +import { workflowSettings } from "./workflowSettings"; + +/** Mirrors ide-sdk's interpreter target selection for guards before IPC launch. */ +export function withRunTarget(config: RunConfig): RunConfig { + if (config.target !== undefined && config.target !== null) return config; + const languages = validateLanguages(useProject.getState().settings?.languages); + const name = config.command + .split(/[\\/]/) + .at(-1) + ?.replace(/\.(exe|cmd|bat)$/i, ""); + const selected = + ["php", "composer", "phpunit", "pest"].includes(name ?? "") || ["php", "composer", "phpunit"].includes(config.kind) + ? languages.php.runTarget + : ["node", "npm", "npx", "pnpm", "yarn", "bun"].includes(name ?? "") || + ["node", "npm", "vitest", "jest", "mocha"].includes(config.kind) + ? languages.node.runTarget + : ""; + if (selected === "local") return { ...config, target: "" }; + const target = selected || workflowSettings().run.defaultTarget; + return target ? { ...config, target } : config; +} diff --git a/src/platform/workflowSettings.ts b/src/platform/workflowSettings.ts index 5e1caa97b..2c5a90138 100644 --- a/src/platform/workflowSettings.ts +++ b/src/platform/workflowSettings.ts @@ -95,6 +95,7 @@ export interface DebuggerSettingsValid { } export interface RunSettingsValid { + defaultTarget: string; confirmRerun: boolean; confirmTerminate: boolean; showOnStart: boolean; @@ -256,7 +257,7 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = { skipLibraryCode: true, python: { attachSubprocess: true, gevent: false }, }, - run: { confirmRerun: true, confirmTerminate: true, showOnStart: true, terminationTimeoutMs: 3000 }, + run: { defaultTarget: "", confirmRerun: true, confirmTerminate: true, showOnStart: true, terminationTimeoutMs: 3000 }, terminal: { startDirectory: "", env: {}, @@ -403,6 +404,7 @@ export function validateRun(raw: unknown): RunSettingsValid { const d = DEFAULT_WORKFLOW.run; const v = obj(raw); return { + defaultTarget: text(v.defaultTarget, ""), confirmRerun: bool(v.confirmRerun, d.confirmRerun), confirmTerminate: bool(v.confirmTerminate, d.confirmTerminate), showOnStart: bool(v.showOnStart, d.showOnStart), diff --git a/src/workbench/ContributedToolbar.test.tsx b/src/workbench/ContributedToolbar.test.tsx new file mode 100644 index 000000000..9a7fe9785 --- /dev/null +++ b/src/workbench/ContributedToolbar.test.tsx @@ -0,0 +1,33 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { ContributedToolbar } from "./ContributedToolbar"; +import { registerMenuSlotItem } from "./contributions"; +import { registerAction } from "@/platform/actions"; +import { setContext } from "@/platform/context"; +const offs: (() => void)[] = []; +afterEach(() => + offs + .splice(0) + .reverse() + .forEach((f) => f()), +); +it("renders toolbar slots with command icons and honors when clauses", () => { + const run = vi.fn(); + offs.push(registerAction({ id: "test.pluginMenu", title: "Plugin action", icon: "run", run })); + offs.push( + registerMenuSlotItem("editor.title", { + action: "test.pluginMenu", + label: "Plugin action", + icon: "run", + when: "editorLang == sql", + }), + ); + setContext("editorLang", "sql"); + const r = render(<ContributedToolbar slot="editor.title" />); + fireEvent.click(screen.getByRole("button", { name: /Plugin action/ })); + expect(run).toHaveBeenCalledOnce(); + r.unmount(); + setContext("editorLang", "text"); + render(<ContributedToolbar slot="editor.title" />); + expect(screen.queryByRole("button")).toBeNull(); +}); diff --git a/src/workbench/ContributedToolbar.tsx b/src/workbench/ContributedToolbar.tsx new file mode 100644 index 000000000..77cc0f64c --- /dev/null +++ b/src/workbench/ContributedToolbar.tsx @@ -0,0 +1,26 @@ +import { useActions } from "@/platform/actions"; +/** Plugin action slots in existing editor and main toolbars. */ +import { useContextKeys, evaluateWhen } from "@/platform/context"; +import { IconButton } from "@/ui"; +import { useMenuSlotItems } from "./contributions"; +export function ContributedToolbar({ slot }: { slot: string }) { + const actions = useActions((s) => s.actions); + const items = useMenuSlotItems((s) => s.slots[slot]); + useContextKeys(); + return ( + <> + {(items ?? []) + .filter((i) => evaluateWhen(i.when ?? undefined)) + .map((i) => ( + <IconButton + key={i.action} + icon={actions.get(i.action)?.icon ?? i.icon ?? "plugin"} + action={i.action} + actionArg={i.arg} + title={i.label} + size="small" + /> + ))} + </> + ); +} diff --git a/src/workbench/TitleBar.tsx b/src/workbench/TitleBar.tsx index e88b337ce..459640323 100644 --- a/src/workbench/TitleBar.tsx +++ b/src/workbench/TitleBar.tsx @@ -1,3 +1,4 @@ +import { ContributedToolbar } from "./ContributedToolbar"; import { useMemo, useRef, useState } from "react"; import clsx from "clsx"; import { useActions } from "@/platform/actions"; @@ -357,6 +358,7 @@ export function TitleBar({ {!minimal && ( <> {toolbar && !menuExpanded && shown.right.map(entry)} + {toolbar && !menuExpanded && <ContributedToolbar slot="mainToolbar" />} {!menuExpanded && ( <div className={styles.tools}> <GearButton customize={menu} /> diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 9b42a9a24..50da15fb5 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -1043,6 +1043,8 @@ export type ContextMenuId = | "editor" | "projectView" | "editorTab" + | "problems" + | "searchResults" | "editorGutter" | "openIn" | "stopProcess" @@ -1085,6 +1087,8 @@ export interface MenuGroupContext { text?: string; /** Editor menu: 0-based caret line; gutter menu: the clicked line. */ line?: number; + /** Zero-based UTF-16 column for problem/search result commands. */ + column?: number; /** The tab's input, when the menu is for an editor tab without a path. */ input?: EditorInput; } @@ -1731,3 +1735,31 @@ export function stepFocusedSurfaceFontZoom(delta: number): boolean { export function resetSurfaceFontZoom(): void { for (const step of fontZoomSurfaces.values()) step(0); } + +/** Optional plugin test parser; the Run feature keeps the existing test tree. */ +type PluginTestParser = (config: string, chunk: string) => Promise<import("@/bindings").PluginTestEvent[] | null>; +let pluginTestParser: PluginTestParser | null = null; +export function providePluginTestParser(parser: PluginTestParser): () => void { + pluginTestParser = parser; + return () => { + if (pluginTestParser === parser) pluginTestParser = null; + }; +} +export function parsePluginTests(config: string, chunk: string) { + return pluginTestParser?.(config, chunk) ?? Promise.resolve(null); +} + +/** Read-only named snippets supplied by plugins. */ +export interface ContributedSnippet { + id: string; + pluginName: string; + language: string; + prefixes: string[]; + body: string; + description: string; +} +export const useContributedSnippets = create<{ snippets: ContributedSnippet[] }>(() => ({ snippets: [] })); +export function addContributedSnippets(snippets: ContributedSnippet[]): () => void { + useContributedSnippets.setState((s) => ({ snippets: [...s.snippets, ...snippets] })); + return () => useContributedSnippets.setState((s) => ({ snippets: s.snippets.filter((x) => !snippets.includes(x)) })); +} diff --git a/wit/commit.wit b/wit/commit.wit index a70b889c1..91dbe0e28 100644 --- a/wit/commit.wit +++ b/wit/commit.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// The commit being prepared in the window the current command or event came /// from (the Commit tool window, or the Commit dialog while it is open): the diff --git a/wit/database.wit b/wit/database.wit new file mode 100644 index 000000000..4e77bd9c8 --- /dev/null +++ b/wit/database.wit @@ -0,0 +1,34 @@ +package openidea:plugin@1.4.0; + +/// Plugin-owned project data sources. Requires database.sources and project trust. +/// Runs on the Database subsystem's host, including on remote project servers. +interface database { + use types.{error}; + use workspace.{project}; + /// Non-secret connection settings. Id is private to this plugin (1–128 bytes). + /// Drivers: sqlite, postgres, mysql, sqlServer, clickhouse, oracle. + /// SQLite uses database as its file path, or a sqlite: URL. Options are driver properties. + /// URLs and options must not contain passwords, tokens or other credentials. + record data-source { + id: string, + name: string, + driver: string, + host: option<string>, + port: option<u16>, + database: option<string>, + user: option<string>, + url: option<string>, + options: list<tuple<string, string>>, + } + /// Creates or replaces only this plugin's source; unrelated file entries are preserved. + upsert-source: func(project: borrow<project>, source: data-source) -> result<_, error>; + /// Removes an owned source and its credentials; not-found for every other id. + remove-source: func(project: borrow<project>, id: string) -> result<_, error>; + /// Lists only this plugin's private ids, never connection settings or credentials. + sources: func(project: borrow<project>) -> result<list<string>, error>; + /// Writes an owned source's password directly to the Database secure store. Never readable. + set-credential: func(project: borrow<project>, id: string, password: string) -> result<_, error>; + /// Requests the project's usual connection/introspection in the invoking window. + /// Invalid outside a command/event of that project's window. + connect: func(project: borrow<project>, id: string) -> result<_, error>; +} diff --git a/wit/decorations.wit b/wit/decorations.wit new file mode 100644 index 000000000..1c5a033e6 --- /dev/null +++ b/wit/decorations.wit @@ -0,0 +1,22 @@ +package openidea:plugin@1.4.0; + +/// Project-scoped decorations (API 1.4). Requires project.read; paths must be inside +/// an open project. Sets belong to the plugin and disappear when it stops. +/// Styles: highlight, search, added, modified, removed, warning, error, info, +/// underline, strikethrough, muted. Unknown styles are ignored. Theme tokens only. +interface decorations { + use types.{error, range}; + /// An editor annotation; after is plain text shown after the range. + record decoration { range: range, style: string, hover: option<string>, after: option<string> } + /// A project-tree annotation; badge is at most two Unicode characters. + record file-decoration { badge: option<string>, style: string, tooltip: option<string> } + /// Replace this set for a file. At most 10,000 items/file and 50 editor sets/plugin. + /// Metadata is bounded to 4 MiB per plugin. Unknown styles are ignored. + set: func(path: string, set-id: string, items: list<decoration>) -> result<_, error>; + /// Remove this editor set from every file/project. + clear: func(set-id: string) -> result<_, error>; + /// Replace this set's tree annotation for a file; at most 50 tree sets/plugin. + set-file: func(path: string, set-id: string, item: file-decoration) -> result<_, error>; + /// Remove this tree set from every file/project. + clear-file: func(set-id: string) -> result<_, error>; +} diff --git a/wit/details.wit b/wit/details.wit index 9e889851a..f7342991d 100644 --- a/wit/details.wit +++ b/wit/details.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// What the pane beside the Services tree shows for a plugin's node. interface detail-types { diff --git a/wit/diagnostics.wit b/wit/diagnostics.wit index d5d62de72..499335fde 100644 --- a/wit/diagnostics.wit +++ b/wit/diagnostics.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Optional export (world `plugin-diagnostics`): problems in the current, /// possibly unsaved document, for the languages declared with the diff --git a/wit/endpoints.wit b/wit/endpoints.wit index 95302fcb0..f4a446fe0 100644 --- a/wit/endpoints.wit +++ b/wit/endpoints.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// HTTP endpoints found in a project (`[[contributes.endpoints]]`). interface endpoints-provider { diff --git a/wit/events.wit b/wit/events.wit index 8851d18a7..f52030971 100644 --- a/wit/events.wit +++ b/wit/events.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Workbench events, delivered in batches to `event-handler.handle`. interface events { @@ -29,6 +29,9 @@ interface events { /// Delivers `kind` events from now on, for the rest of the session (also /// across idle reclaim: an event activates the plugin again). Declare /// `activation = ["on-event:<kind>"]` to get them before first use. + /// API 1.4 also delivers `file-closed` (JSON path string), `settings-changed` + /// (JSON array of the plugin's changed setting keys), and `input-mode-changed` + /// (JSON {path, mode, sub}) through `other`. The last needs `events.input`. /// Kinds: `file-opened`, `file-saved`, `files-changed` (batched disk changes /// as `other`, with created/modified/removed path lists in JSON), `editor-focused` (the focused editor /// changed), `vcs-changed` (a repository's state changed: commit, diff --git a/wit/exports.wit b/wit/exports.wit index 67d7c6060..434b29ceb 100644 --- a/wit/exports.wit +++ b/wit/exports.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; interface lifecycle { use types.{context, error}; diff --git a/wit/file-actions.wit b/wit/file-actions.wit index 477809180..12b4aec17 100644 --- a/wit/file-actions.wit +++ b/wit/file-actions.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Optional export (world `plugin-file-actions`): quick fixes and /// refactorings that create, rename or delete files, next to the text edits diff --git a/wit/fs.wit b/wit/fs.wit index 7cd07fd71..c1a96cf83 100644 --- a/wit/fs.wit +++ b/wit/fs.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Files outside the open projects: the configuration of the program a /// plugin integrates with, a tool installed elsewhere, a licence file. diff --git a/wit/host.wit b/wit/host.wit index 91c3e12fc..c5ce20fbe 100644 --- a/wit/host.wit +++ b/wit/host.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Writes to the IDE log, tagged with the plugin id. interface log { @@ -53,6 +53,11 @@ interface ui { /// Shows or updates the plugin's status bar item `id` in every window. set-status-item: func(id: string, item: status-item); remove-status-item: func(id: string); + /// Overrides the global item in the invoking window only (API 1.4). + /// Invalid outside a command or event of a window; removed on window close or plugin stop. + set-window-status-item: func(id: string, item: status-item) -> result<_, error>; + /// Removes this window's override, falling back to the global item. + remove-window-status-item: func(id: string) -> result<_, error>; /// Sets the plugin's context key `key`, which `when` clauses test as /// `plugin.<id>.<key>`, in the window the current command or event came /// from (every window when there is none). A window's value takes @@ -104,3 +109,23 @@ interface storage { /// Keys starting with `prefix`, sorted. keys: func(prefix: string) -> result<list<string>, error>; } + +/// Executes stable IDE action ids or `plugin.<plugin-id>.<command-id>` (API 1.4). +interface command-runner { + use types.{error}; + /// Only in commands/events of a window. Returns a plugin command's value, + /// or none for IDE actions; unknown ids are not-found. Nested calls are bounded. + /// Requires high-risk `ide.commands`, except for this plugin's own commands. + execute: func(id: string, arg: option<string>) -> result<option<string>, error>; +} + +/// Per-plugin named, read-only Output tabs (API 1.4), with a 1 MiB UTF-8 ring buffer. +interface output { + use types.{error}; + /// Appends text to a channel (at most 32 channels, names 1-128 bytes). + append: func(channel: string, text: string) -> result<_, error>; + /// Clears a channel's buffer. + clear: func(channel: string) -> result<_, error>; + /// Reveals the channel in the invoking window; only commands/events of a window. + reveal: func(channel: string) -> result<_, error>; +} diff --git a/wit/hover.wit b/wit/hover.wit index a95c22758..5ddde792e 100644 --- a/wit/hover.wit +++ b/wit/hover.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Optional export (world `plugin-hover`): Markdown added to the hover of /// documents of the languages declared with the `hover` provider kind, diff --git a/wit/indexing.wit b/wit/indexing.wit index 4ee08d64b..227aab726 100644 --- a/wit/indexing.wit +++ b/wit/indexing.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Cross-file associations a plugin publishes for the files it declares under /// `[[contributes.documentIndexers]]`. The host calls `index` in cancellable diff --git a/wit/injections.wit b/wit/injections.wit new file mode 100644 index 000000000..5f9d7c2eb --- /dev/null +++ b/wit/injections.wit @@ -0,0 +1,12 @@ +package openidea:plugin@1.4.0; + +/// Embedded languages. This host routes plugin providers, not embedded LSP servers. +interface injections { + /// A versioned document snapshot. Coordinates are zero-based UTF-16. + record document { path: string, language: string, version: u64, text: string } + /// A host-document range containing the named language. + record injection { range: range, language: string } + use types.{range}; + /// Embedded ranges, refreshed after edits under the editor-provider budget. + injections: func(doc: document) -> list<injection>; +} diff --git a/wit/inlay.wit b/wit/inlay.wit index ab00b0c9b..1098869a5 100644 --- a/wit/inlay.wit +++ b/wit/inlay.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Optional export (world `plugin-inlay-hints`): inline hints in documents of /// the languages declared with the `inlay-hints` provider kind, including diff --git a/wit/io.wit b/wit/io.wit index cffd883be..b27e48d62 100644 --- a/wit/io.wit +++ b/wit/io.wit @@ -1,8 +1,13 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Problems in files, shown in the editor and the Problems view. interface diagnostics { use types.{error, range, location}; + /// Problems reported by a source for a file in this window's project. + record file-problems { path: string, source: string, diagnostics: list<diagnostic> } + /// Current Problems view snapshot, optionally filtered to a project file. + /// Requires project.read and an invoking window. Empty for remote projects. + problems: func(path: option<string>) -> result<list<file-problems>, error>; enum severity { error, @@ -35,7 +40,7 @@ interface diagnostics { publish: func(path: string, diagnostics: list<diagnostic>) -> result<_, error>; } -/// HTTP(S) to the hosts granted with `net:<host>`. +/// HTTPS with `net:<host>`; unencrypted HTTP with `net.http:<host>`. interface net { use types.{error}; @@ -46,7 +51,8 @@ interface net { record request { method: string, - /// `https://…`; plain `http://` only to `localhost` / `127.0.0.1`. + /// `https://…` requires `net:<host>`. Plain `http://…` requires + /// `net.http:<host>` (API 1.4), except loopback, which uses `net:<host>`. url: string, /// `{{secret:<name>}}` in a value is replaced by the plugin's secret /// `name` on the way out (requires `secrets`); the plugin never sees it. @@ -72,6 +78,11 @@ interface net { /// OS keychain). Plugins only use them through `{{secret:<name>}}` in /// `net` requests. Requires `secrets`. interface secrets { + use types.{error}; + /// Stores a declared secret without ever exposing it back to plugin code (API 1.4). + set: func(name: string, value: string) -> result<_, error>; + /// Deletes a declared secret (API 1.4). + delete: func(name: string) -> result<_, error>; is-set: func(name: string) -> bool; } @@ -144,3 +155,38 @@ interface process { /// also ends its processes. start: func(cmd: command, cwd: option<borrow<project>>) -> result<child, error>; } + +/// Local sockets on the project machine, including the server for remote projects. +/// Requires `ipc:<path>`; host-expanded `~/`, `$TMPDIR/`, `$XDG_RUNTIME_DIR/` +/// and Windows `\\.\pipe\name` are accepted. API 1.4. +interface ipc { + use types.{error}; + /// A byte stream. At most eight per plugin; dropping closes it, as does stopping the plugin. + resource connection { + /// Writes up to 1 MiB, subject to the current call's budget and cancellation. + write: func(bytes: list<u8>) -> result<_, error>; + /// Reads up to 1 MiB; empty on EOF or timeout. Zero timeout only checks. + read: func(max-bytes: u32, timeout-ms: u32) -> result<list<u8>, error>; + /// Closes the stream; subsequent reads and writes fail. + close: func() -> result<_, error>; + } + /// Connects to an absolute Unix socket or Windows named pipe; never TCP. + connect: func(name: string) -> result<connection, error>; +} + +/// OAuth browser redirects received on the desktop, even for remote projects (API 1.4). +/// No capability needed; only commands/events of a window may use this interface. +interface auth { + use types.{error}; + /// A one-shot IPv4 loopback listener. At most two open per plugin. + resource loopback { + /// `http://127.0.0.1:<random-port>/<path>` for the authorization URL. + redirect-uri: func() -> string; + /// Returns the first matching request's raw query string, or none on timeout. + /// Waiting is excluded from the call budget. The listener serves a short + /// completion page and closes; resource drop and plugin stop also close it. + wait: func(timeout-ms: u32) -> result<option<string>, error>; + } + /// Binds 127.0.0.1 only. `path` has no query or fragment. + begin-loopback: func(path: string) -> result<loopback, error>; +} diff --git a/wit/language-features.wit b/wit/language-features.wit new file mode 100644 index 000000000..c47c4b621 --- /dev/null +++ b/wit/language-features.wit @@ -0,0 +1,114 @@ +package openidea:plugin@1.4.0; + +/// Additional language features, called only for declared API 1.4 provider kinds. +/// Editor calls have a 50 ms soft / 500 ms hard budget; formatting has 5 seconds. +interface language-features { + use types.{error, position, range, text-edit, location}; + /// A versioned document snapshot. Coordinates are zero-based UTF-16. + record document { + path: string, + language: string, + version: u64, + text: string, + } + /// A parameter label, either literal text or UTF-16 offsets within the signature label. + variant parameter-label { text(string), offsets(tuple<u32, u32>) } + /// Indentation options selected by the editor. + record format-options { + tab-size: u32, + insert-spaces: bool, + } + /// One parameter, with optional Markdown documentation. + record parameter { + label: parameter-label, + documentation: option<string>, + } + /// One callable signature; documentation is Markdown. + record signature { + label: string, + documentation: option<string>, + parameters: list<parameter>, + } + /// Callable signatures and zero-based active signature/parameter indices. + record signature-help-result { + signatures: list<signature>, + active-signature: u32, + active-parameter: u32, + } + /// An occurrence of the symbol. Kind: text, read or write; unknown is text. + record highlight { + range: range, + kind: string, + } + /// An absolute path, project-relative path or HTTP(S) URL to follow on click. + record document-link { + range: range, + target: string, + } + /// An inline action. Command must be this plugin's declared command id. + record code-lens { + range: range, + title: string, + command: string, + argument: option<string>, + } + /// A multi-line fold. Kinds: region, comment or imports; unknown is region. + record fold-range { + start-line: u32, + end-line: u32, + kind: string, + } + /// A color literal. All channels are finite floats in the inclusive range 0–1. + record color-info { + range: range, + red: f32, + green: f32, + blue: f32, + alpha: f32, + } + /// An outline node. Parent indexes this list, precedes the child and contains it. + /// Kinds are LSP-style names (e.g. class, method, enum-member); unknown is neutral. + record document-symbol { + name: string, + detail: string, + kind: string, + range: range, + selection-range: range, + parent: option<u32>, + } + /// A line action for this plugin's own declared command. Icons follow line-marker + /// aliases, registered icon names or declared manifest marker SVG paths. + record gutter-action { + line: u32, + icon: option<string>, + tooltip: string, + command: string, + argument: option<string>, + } + /// Format the whole document or the selected range. + format: func(doc: document, range: option<range>, options: format-options) -> result<list<text-edit>, error>; + /// Signature help when the language server has no signatures. + signature-help: func(doc: document, at: position) -> option<signature-help-result>; + /// Occurrences at the caret, appended to language-server highlights. + document-highlights: func(doc: document, at: position) -> list<highlight>; + /// Navigable ranges, appended to language-server document links. + document-links: func(doc: document) -> list<document-link>; + /// Clicks run only this plugin's declared commands. + code-lenses: func(doc: document) -> list<code-lens>; + /// Nested ranges, innermost to outermost. + selection-ranges: func(doc: document, at: position) -> list<range>; + /// Folds to merge with tree-sitter, for any declared language. + folding: func(doc: document) -> list<fold-range>; + /// Color literals to show in the existing color-preview gutter and picker. + colors: func(doc: document) -> list<color-info>; + /// Replacement strings for the selected color range. + color-presentations: func(doc: document, color: color-info) -> list<string>; + /// Used only when no LSP document symbols are available. + document-symbols: func(doc: document) -> list<document-symbol>; + /// Implementation locations, merged with language-server results. + implementation: func(doc: document, at: position) -> list<location>; + /// Type-declaration locations, merged with language-server results. + type-definition: func(doc: document, at: position) -> list<location>; + /// Clicks run only this plugin's declared commands. + gutter-actions: func(doc: document) -> list<gutter-action>; +} diff --git a/wit/mcp.wit b/wit/mcp.wit new file mode 100644 index 000000000..e250f5496 --- /dev/null +++ b/wit/mcp.wit @@ -0,0 +1,16 @@ +package openidea:plugin@1.4.0; + +/// Discovery of the IDE's built-in MCP server; never exposes its URL or token. +interface mcp { + /// Stdio bridge argv for the invoking project; none without a window/project or while off. + stdio-command: func() -> option<list<string>>; +} + +/// Optional tools declared by contributes.mcpTools, under the plugin's grants and command budget. +interface mcp-provider { + use types.{error}; + use invocation.{invocation}; + /// Calls the declared local tool name with JSON arguments and the project's window context. + /// Returns text; tools may encode structured data as JSON text. + call: func(tool: string, arguments: string, ctx: borrow<invocation>) -> result<string, error>; +} diff --git a/wit/plugin.wit b/wit/plugin.wit index 8d1451ac3..04c707d20 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -8,7 +8,7 @@ /// `fs` and `workspace`, and everything else goes through the imports below, /// gated by the capabilities granted in `plugin.toml` (a missing one returns /// `error::denied`). -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; world plugin { import log; @@ -17,6 +17,7 @@ world plugin { import fs; import workspace; import editor; + import decorations; import invocation; import vcs; import commit; @@ -26,7 +27,15 @@ world plugin { import net; import secrets; import process; + import ipc; + import output; + import auth; + import command-runner; import views; + import run; + import run-targets; + import database; + import mcp; import settings; export lifecycle; @@ -168,3 +177,26 @@ world plugin-diagnostics { world editor-diagnostics { export diagnostics-provider; } + +/// A plugin that exports project run configurations. +world plugin-run-configurations { include plugin; export run-configurations; } +/// Export-only configurations world, composed with plugin by SDK macros. +world configurations-provider { export run-configurations; } +/// A plugin that parses its test output. +world plugin-test-results { include plugin; export test-results; } +/// Export-only test parser world, composed with plugin by SDK macros. +world test-results-provider { export test-results; } + +/// A plugin with additional API 1.4 language features. +world plugin-language-features { include plugin; export language-features; } +/// Export-only language features, composed with the base plugin world. +world editor-language-features { export language-features; } +/// A plugin that identifies embedded language ranges. +world plugin-injections { include plugin; export injections; } +/// Export-only injections, composed with the base plugin world. +world language-injections { export injections; } + +/// A plugin contributing tools to the built-in MCP server. +world plugin-mcp { include plugin; export mcp-provider; } +/// Export-only MCP tools, composed with the base plugin world. +world mcp-tools { export mcp-provider; } diff --git a/wit/providers.wit b/wit/providers.wit index 317eb81d1..8c0713ec6 100644 --- a/wit/providers.wit +++ b/wit/providers.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Editor features a plugin provides for the languages it declares under /// `[[contributes.providers]]` (the host calls only declared kinds). They run diff --git a/wit/run-targets.wit b/wit/run-targets.wit new file mode 100644 index 000000000..66a41e700 --- /dev/null +++ b/wit/run-targets.wit @@ -0,0 +1,36 @@ +package openidea:plugin@1.4.0; + +/// Project-scoped targets owned by the registering plugin (API 1.4). +interface run-targets { + use types.{error}; + use workspace.{project}; + + /// An argv wrapper using a granted, pinned process program. No shell is used. + /// Normally: program + args + workdir-args + command + command arguments. + /// An override matches wrap::program_name(command) and replaces args AND command: + /// program + override + workdir-args + command arguments. + /// DDEV: program="ddev", args=["exec"], commands=[("composer", ["composer"])]: + /// `ddev exec php x.php` and `ddev composer install`. + /// Each workdir-args element may contain {cwd}, mapped under container-root. + /// All workdir-args are omitted without container-root or for cwd outside the project. + /// Without workdir-args the wrapper manages its container directory itself. + /// The wrapper always starts in the configuration's LOCAL working directory. + /// Environment reaches the wrapper; forwarding into a container depends on its conventions. + /// Registration never selects the target. Debugging plugin targets is invalid. + record run-target { + id: string, + name: string, + program: string, + args: list<string>, + workdir-args: list<string>, + container-root: option<string>, + commands: list<tuple<string, list<string>>>, + } + + /// Registers/replaces this plugin's target in an open, trusted project. + /// Targets survive idle reclamation, and disappear on unregister or plugin stop. + /// Remote project registration and execution take place on the server. + register: func(project: borrow<project>, target: run-target) -> result<_, error>; + /// Removes only this plugin's target from the project; missing ids are harmless. + unregister: func(project: borrow<project>, id: string); +} diff --git a/wit/run.wit b/wit/run.wit new file mode 100644 index 000000000..c1bc79cf9 --- /dev/null +++ b/wit/run.wit @@ -0,0 +1,50 @@ +package openidea:plugin@1.4.0; + +/// Launches this plugin's configurations in the invoking window's Run tool window. +interface run { + use types.{error}; + /// Starts an owned configuration, appending args. Debug requires a debugger. + /// Only from a window command/event. The returned id receives run-exited. + start: func(config: string, args: list<string>, debug: bool) -> result<string, error>; +} + +/// Optional dynamic configurations, refreshed alongside run detectors. +interface run-configurations { + /// A configuration running an approved process in the project sandbox. + record run-configuration { + id: string, + name: string, + program: string, + args: list<string>, + cwd: option<string>, + test: bool, + framework: option<string>, + } + /// Returns the complete set for this project root. + configurations: func(project-root: string) -> list<run-configuration>; +} + +/// Optional parser for test configurations whose framework is plugin. +interface test-results { + use types.{location, extension}; + /// A suite or test; parents must precede children and ids remain stable for a run. + record test-start { id: string, parent: option<string>, name: string, location: option<location> } + /// A successful test and optional elapsed milliseconds. + record test-passed { id: string, duration-ms: option<u32> } + /// A failed test, comparison and optional declaration location. + record test-failed { + id: string, duration-ms: option<u32>, message: string, + expected: option<string>, actual: option<string>, location: option<location>, + } + /// A skipped test and its explanation. + record test-skipped { id: string, reason: option<string> } + /// Output attached to a test or suite. + record test-output { id: string, text: string } + /// Incremental updates to the IDE's test tree. Unknown extensions are ignored. + variant test-event { + suite-started(test-start), test-started(test-start), passed(test-passed), + failed(test-failed), skipped(test-skipped), output(test-output), other(extension), + } + /// Parses one bounded output batch; failures fall back to built-in parsers. + parse: func(config: string, chunk: string) -> list<test-event>; +} diff --git a/wit/semantic.wit b/wit/semantic.wit index ec89fe405..586c324e6 100644 --- a/wit/semantic.wit +++ b/wit/semantic.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Optional export (world `plugin-semantic-tokens`): semantic colouring of /// documents of the languages declared with the `semantic-tokens` provider diff --git a/wit/types.wit b/wit/types.wit index 8114a2db0..4192e783c 100644 --- a/wit/types.wit +++ b/wit/types.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Shared types. Positions are 0-based lines and UTF-16 code unit columns, /// as in the editor and LSP. diff --git a/wit/views.wit b/wit/views.wit index 78da35354..a34be2d46 100644 --- a/wit/views.wit +++ b/wit/views.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; /// Declarative views: tool windows declared /// under `[[contributes.views]]` and custom editors under @@ -124,6 +124,31 @@ interface view-types { interface views { use types.{error}; use detail-types.{process-log}; + use process.{command}; + + /// An editor toolbar action; at most one action is the Save handler. + record content-action { id: string, label: string, icon: option<string>, primary: bool, save: bool } + /// A plugin-owned content editor, scoped by key to the invoking window. + record content-tab { + key: string, title: string, file-name: option<string>, language: option<string>, + content: string, read-only: bool, actions: list<content-action>, + } + /// A comparison opened by a content action reply. + record content-diff { left: string, right: string, left-label: option<string>, right-label: option<string>, title: option<string> } + /// Optional updates to an outstanding action or Save request. + record content-tab-reply { + content: option<string>, saved: option<bool>, read-only: option<bool>, + status: option<string>, error: option<string>, diff: option<content-diff>, + } + /// Opens or focuses a running session with this key; env is append-only. + /// Only from a window command/event; the id receives one terminal-exited. + open-terminal-session: func(key: string, title: string, cmd: command) -> result<string, error>; + /// Writes to this plugin's running keyed terminal in the invoking window. + write-terminal: func(key: string, text: string) -> result<_, error>; + /// Opens the existing content editor protocol without a web view. + open-content-tab: func(tab: content-tab) -> result<_, error>; + /// Answers content-tab-action in its originating window. + reply-content-tab: func(request-id: string, reply: content-tab-reply) -> result<_, error>; /// The view (or every custom editor of that id) shows outdated content: /// the IDE renders it again. diff --git a/wit/workspace.wit b/wit/workspace.wit index 556dd0da8..7c1e9dd19 100644 --- a/wit/workspace.wit +++ b/wit/workspace.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.3.0; +package openidea:plugin@1.4.0; interface workspace { use types.{error, location}; @@ -39,10 +39,28 @@ interface workspace { location: location, } + /// Text search options. Include/exclude are project-relative globs, as in Find in Files. + record text-query { + pattern: string, + regex: bool, + case-sensitive: bool, + whole-word: bool, + %include: list<string>, + exclude: list<string>, + } + /// A match with zero-based lines and UTF-16 columns, and its displayed line. + record text-match { location: location, line: string } + /// Bounded search results; limit-hit says there may be more matches. + record text-search { matches: list<text-match>, limit-hit: bool } + /// A project open in the IDE. resource project { /// Absolute path of the project root. Requires `project.read`. root: func() -> result<string, error>; + /// Find in Files with unsaved documents and project excludes. Requires project.read. + /// Cancellable, at most 10,000 matches. Zero limit returns no matches. + /// Line previews are capped at 4096 UTF-8 bytes, results at 4 MiB (limit-hit). + search-text: func(query: text-query, limit: u32) -> result<text-search, error>; /// Reads a UTF-8 text file inside the project (absolute, or relative /// to the root); paths leaving the root are refused. Unsaved editor /// contents win over the disk. A missing path is `not-found`. @@ -94,10 +112,22 @@ interface editor { text: string, } + /// An editor in the invoking window. + record open-editor { path: string, language: string, dirty: bool, active: bool } + /// Only from commands/events of a window. Requires editor.read. + open-editors: func() -> result<list<open-editor>, error>; + /// Open a project file (project.read) or a file in plugin-dir, selecting a range. + /// Only from commands/events of a window. Paths follow views.open-location. + open: func(path: string, selection: option<range>) -> result<_, error>; + /// A file open in an editor. resource text-editor { /// Absolute path of the file. path: func() -> string; + /// Replace selections in this window's editor. No capability; commands/events only. + set-selections: func(selections: list<range>) -> result<_, error>; + /// Scroll a range into view in this window's editor. No capability; commands/events only. + reveal: func(range: range) -> result<_, error>; /// Language id (`rust`, `solidity`, …). language: func() -> string; /// Current version and text. Requires `editor.read`. @@ -151,6 +181,22 @@ interface vcs { changed-files: u32, } + /// A commit in repository history; date is Unix seconds. + record commit-info { + id: string, short-id: string, author: string, author-email: string, + date: s64, subject: string, body: string, parents: list<string>, + } + /// One zero-based document line's attribution; date is Unix seconds. + record blame-line { line: u32, commit: string, author: string, date: s64, summary: string } + /// Newest commits first, at most 1000. Trusted projects only; requires vcs.read. + log: func(project: borrow<project>, path: option<string>, limit: u32) -> result<list<commit-info>, error>; + /// Includes unsaved text, with changed lines attributed to uncommitted changes. + /// Trusted projects only; requires vcs.read. Paths must be inside the project. + blame: func(project: borrow<project>, path: string) -> result<list<blame-line>, error>; + /// Working tree versus HEAD or against. None outside a repository. Disk text only. + /// Fails above 4 MiB. Trusted projects only; requires vcs.read. + diff: func(project: borrow<project>, path: option<string>, against: option<string>) -> result<option<string>, error>; + /// `none` when the project is not in a repository. Requires `vcs.read`. status: func(project: borrow<project>) -> result<option<repository-status>, error>; /// Configured remotes (`name`, fetch `url`), in order. Requires `vcs.read`.