Compare commits

..
Author SHA1 Message Date
l0ng-ai bc42133a36 fix(debugger): keep the selected tab when a step finishes
Every pause brought Threads & Variables to the front, so stepping from the
Console tab switched away from the output after each Step Over. Only a stop
at a breakpoint, exception or pause request does that now; finishing a step
keeps the tab the user is on.

Refs #2494
2026-11-10 23:22:32 +08:00
527 changed files with 2446 additions and 20311 deletions

No files matched your search

-3
View File
@@ -3,6 +3,3 @@ contact_links:
- name: Download the latest release
url: https://github.com/l0ng-ai/openidea/releases/latest
about: Many problems are already fixed. Please check the latest version before reporting.
- name: Report a security vulnerability
url: https://github.com/l0ng-ai/openidea/security/advisories/new
about: Please report security problems privately, not in a public issue.
Generated
+118 -83
View File
@@ -1522,7 +1522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 3.0.9",
"syn 2.0.119",
]
[[package]]
@@ -1578,6 +1578,16 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "darling"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
dependencies = [
"darling_core 0.13.4",
"darling_macro 0.13.4",
]
[[package]]
name = "darling"
version = "0.23.0"
@@ -1598,6 +1608,20 @@ dependencies = [
"darling_macro 0.24.1",
]
[[package]]
name = "darling_core"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim 0.10.0",
"syn 1.0.109",
]
[[package]]
name = "darling_core"
version = "0.23.0"
@@ -1607,7 +1631,7 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"strsim 0.11.1",
"syn 2.0.119",
]
@@ -1620,10 +1644,21 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"strsim 0.11.1",
"syn 3.0.9",
]
[[package]]
name = "darling_macro"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
dependencies = [
"darling_core 0.13.4",
"quote",
"syn 1.0.109",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
@@ -2650,7 +2685,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasi",
]
[[package]]
@@ -4145,7 +4180,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"dunce",
"encoding_rs",
@@ -4169,7 +4204,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4183,7 +4218,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"serde",
@@ -4194,7 +4229,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"blake3",
"chardetng",
@@ -4212,7 +4247,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4226,7 +4261,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"gtk",
"objc2 0.6.5",
@@ -4236,7 +4271,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"dunce",
"encoding_rs",
@@ -4253,7 +4288,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-run",
@@ -4267,7 +4302,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"futures",
"ide-core",
@@ -4288,7 +4323,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"bb8",
"blake3",
@@ -4298,7 +4333,7 @@ dependencies = [
"ide-core",
"ide-http",
"ide-ssh",
"oracledb",
"oracle",
"parking_lot",
"reqwest",
"rustls",
@@ -4318,7 +4353,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"serde",
@@ -4331,11 +4366,10 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"base64 0.22.1",
"blake3",
"dunce",
"ed25519-dalek",
"ide-core",
"parking_lot",
@@ -4352,7 +4386,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4364,7 +4398,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4377,7 +4411,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4443,10 +4477,9 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-vfs",
"parking_lot",
"reqwest",
"rustls",
@@ -4462,7 +4495,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"blake3",
"criterion",
@@ -4484,7 +4517,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"encoding_rs",
"futures",
@@ -4501,7 +4534,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"blake3",
"criterion",
@@ -4557,7 +4590,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"dunce",
"futures",
@@ -4583,7 +4616,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"bytes",
"dunce",
@@ -4602,7 +4635,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"futures",
"ide-core",
@@ -4625,7 +4658,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"blake3",
"dunce",
@@ -4648,7 +4681,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-run",
@@ -4661,7 +4694,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"dunce",
"globset",
@@ -4680,7 +4713,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"dunce",
"encoding_rs",
@@ -4704,7 +4737,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"dunce",
"ide-core",
@@ -4724,7 +4757,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"criterion",
"globset",
@@ -4744,7 +4777,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"parking_lot",
@@ -4757,7 +4790,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4778,7 +4811,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-language",
@@ -4793,7 +4826,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"libc",
@@ -4806,7 +4839,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"criterion",
"ide-core",
@@ -4819,7 +4852,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"flate2",
"hex",
@@ -4840,7 +4873,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"blake3",
"dunce",
@@ -4861,7 +4894,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"chardetng",
"dunce",
@@ -4885,7 +4918,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"ide-core",
"open",
@@ -4901,7 +4934,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"blake3",
"dunce",
@@ -5718,7 +5751,7 @@ checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasi",
"windows-sys 0.61.2",
]
@@ -6454,6 +6487,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "odpic-sys"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed"
dependencies = [
"cc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -6479,7 +6521,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.41"
version = "0.1.40"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -6567,24 +6609,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "oracledb"
version = "26.0.0-beta.5"
name = "oracle"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b98c7346f2bb5169ec4b2d4f066fb616529ac766b395440c5019fe88d1ef45"
checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0"
dependencies = [
"aes",
"base16ct",
"base64ct",
"cbc",
"chrono",
"pbkdf2",
"pkcs8",
"rand 0.10.3",
"rustls",
"sha2 0.11.1",
"uuid",
"webpki-roots",
"whoami",
"cc",
"odpic-sys",
"once_cell",
"oracle_procmacro",
"paste",
"rustversion",
]
[[package]]
name = "oracle_procmacro"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27"
dependencies = [
"darling 0.13.4",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
@@ -8871,6 +8918,12 @@ dependencies = [
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -8907,6 +8960,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
@@ -9129,7 +9183,7 @@ dependencies = [
"serde",
"serde_json",
"static_vcruntime",
"strsim",
"strsim 0.11.1",
"tauri-utils",
"tauri-winres",
"walkdir",
@@ -10669,15 +10723,6 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasi"
version = "0.14.7+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
dependencies = [
"wasip2",
]
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
@@ -10687,15 +10732,6 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasite"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
dependencies = [
"wasi 0.14.7+wasi-0.2.4",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
@@ -11279,7 +11315,6 @@ dependencies = [
"libc",
"libredox",
"objc2-system-configuration",
"wasite",
"web-sys",
]
+2 -3
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.41"
version = "0.1.40"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -106,8 +106,7 @@ rustls-platform-verifier = "0.7"
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
# Pure-Rust thin Oracle driver; pinned to the beta until it stabilizes.
oracledb = "=26.0.0-beta.5"
oracle = { version = "0.6", default-features = false }
bb8 = "0.9"
uuid = { version = "1", features = ["v4"] }
# OS credential stores
-26
View File
@@ -1,26 +0,0 @@
# Security policy
## Reporting a vulnerability
Please do not report security problems in public issues, pull requests or discussions.
Report them privately through GitHub:
[Report a vulnerability](https://github.com/l0ng-ai/openidea/security/advisories/new)
(repository **Security** tab → **Report a vulnerability**). Only the maintainers can see
the report.
Include what you can of:
- the affected version, platform, and whether remote development (`openidea-server`)
or plugins are involved;
- steps to reproduce, or a proof of concept;
- the impact you expect, and any fix you suggest.
We will acknowledge the report, keep you updated in the advisory, and agree on a
disclosure date with you. Fixes are released as a new version, and the advisory is
published once users can update.
## Supported versions
Security fixes go into the latest release. Please check that the problem still occurs
there before reporting.
-7
View File
@@ -1,12 +1,5 @@
# Third-party licenses
## Flutter and Vite project icons
`src/ui/brands/flutter.svg` and `vite.svg` use the unmodified logo paths from
[Simple Icons](https://github.com/simple-icons/simple-icons), retrieved through
Iconify (`simple-icons:flutter`, `simple-icons:vite`) and filled with the project
icon colors. Licensed under [CC0 1.0 Universal](https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md).
## Project Lombok
`java/lombok/lombok-1.18.48-openidea.3.jar` contains Project Lombok v1.18.48 with the Equinox
-5
View File
@@ -164,11 +164,6 @@ impl Negotiated {
self.capabilities.pointer(&format!("/promptCapabilities/{name}")).and_then(Value::as_bool) == Some(true)
}
/// `mcpCapabilities.<name>` (`http`, `sse`).
pub fn mcp(&self, name: &str) -> bool {
self.capabilities.pointer(&format!("/mcpCapabilities/{name}")).and_then(Value::as_bool) == Some(true)
}
/// `sessionCapabilities.<name>` (`fork`, `resume`, `close`, `list`, `delete`).
pub fn session(&self, name: &str) -> bool {
self.capabilities.pointer(&format!("/sessionCapabilities/{name}")).is_some_and(|v| !v.is_null())
+21 -163
View File
@@ -10,7 +10,6 @@
mod acp;
mod config;
mod conversation;
pub mod mcp;
mod model;
mod native;
mod provider;
@@ -92,9 +91,6 @@ type Connector = Arc<
+ Sync,
>;
/// Returns the configured MCP servers (User and Workspace) for this project.
pub type McpServersFn = dyn Fn() -> Vec<McpServer> + Send + Sync;
struct Shared {
paths: AgentPaths,
runtime: tokio::runtime::Handle,
@@ -108,7 +104,6 @@ struct Shared {
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
idle_release_ms: AtomicU64,
mcp_servers_hook: RwLock<Option<Arc<McpServersFn>>>,
}
/// What an agent offered in its last session, for the New Task page.
@@ -136,13 +131,6 @@ impl Shared {
}
}
fn mcp_servers_for_session(&self, provider: &str, init: &acp::Negotiated) -> Vec<Value> {
let mut servers = mcp_servers_for(init.mcp_stdio(), &self.paths.project_root);
let configured = self.mcp_servers_hook.read().as_ref().map(|hook| hook()).unwrap_or_default();
servers.extend(mcp::acp_mcp_servers(&configured, provider, init));
servers
}
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
// Model-specific options are offered separately, never as provider-wide defaults.
@@ -175,25 +163,16 @@ impl Shared {
let shared = self.clone();
// Keep the lookup alive if a composer unmounts while it is waiting.
self.runtime.spawn(async move {
if let Ok(found) =
if let Ok((commands, options)) =
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
{
let commands =
config::commands_of(&json!({ "availableCommands": found.commands }), &spec.id);
let options = config::options_of(&json!({ "configOptions": found.options }));
let levels: Vec<_> = found
.efforts
.into_iter()
.map(|(model, o)| {
let options = o.map(|o| config::options_of(&json!({ "configOptions": [o] })));
(model, options.unwrap_or_default())
})
.collect();
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
let options = config::options_of(&json!({ "configOptions": options }));
let _ = tokio::task::spawn_blocking(move || {
// Notifies subscribers itself when the discovery
// changed the cached config; unchanged results
// stay silent.
shared.remember_discovered(&spec.id, &options, &commands, &levels);
shared.remember_discovered(&spec.id, &options, &commands);
})
.await;
}
@@ -214,18 +193,6 @@ impl Shared {
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
}
/// Whether the thinking levels of every model the agent offers are known (#1686).
fn knows_all_levels(&self, provider: &str) -> bool {
let cache = self.known.lock();
let Some(known) = cache.get(provider) else { return false };
known
.config
.iter()
.filter(|o| o.category.as_deref() == Some("model"))
.flat_map(|o| &o.values)
.all(|v| known.thought_levels.contains_key(&v.value))
}
fn root_key(&self) -> String {
self.paths.project_root.to_string_lossy().into_owned()
}
@@ -238,19 +205,11 @@ impl Shared {
commands: Option<&[AgentCommand]>,
model: Option<&str>,
) {
self.remember_inner(provider, config, commands, model, &[], false);
self.remember_inner(provider, config, commands, model, false);
}
/// `levels`: each model's thinking-level options, so a new task offers them for any model
/// it picks (#1686). A session's report for a model wins over these.
fn remember_discovered(
&self,
provider: &str,
config: &[AgentConfigOption],
commands: &[AgentCommand],
levels: &[(String, Vec<AgentConfigOption>)],
) {
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), levels, true);
fn remember_discovered(&self, provider: &str, config: &[AgentConfigOption], commands: &[AgentCommand]) {
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), true);
}
fn remember_inner(
@@ -259,19 +218,12 @@ impl Shared {
config: Option<&[AgentConfigOption]>,
commands: Option<&[AgentCommand]>,
model: Option<&str>,
discovered_levels: &[(String, Vec<AgentConfigOption>)],
discovered: bool,
) {
// Keep the update and its disk write ordered; a session report wins over discovery.
let mut cache = self.known.lock();
let known = cache.entry(provider.to_owned()).or_default();
let mut changed = false;
for (model, levels) in discovered_levels {
if !known.thought_levels.contains_key(model) {
known.thought_levels.insert(model.clone(), levels.clone());
changed = true;
}
}
if let Some(c) = config.filter(|_| !discovered || known.config.is_empty()) {
if let Some(model) = model {
let levels: Vec<_> =
@@ -325,9 +277,6 @@ struct TaskFile {
/// In the project's own checkout: files already changed when the task started.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
before: BTreeMap<String, worktree::Stamp>,
/// The folder (in the task store) with copies of the `before` files, for rolling back.
#[serde(default, skip_serializing_if = "Option::is_none")]
saved: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
subagents: Vec<SavedSubagent>,
/// Turns to drop from the session once it is loaded (a fork of an earlier point).
@@ -344,7 +293,6 @@ struct TaskState {
conversation: Conversation,
wanted: Vec<AgentConfigChoice>,
before: Arc<BTreeMap<String, worktree::Stamp>>,
saved: Option<String>,
pending_rollback: u32,
/// The user named the task; see `TaskFile::title_renamed`.
title_renamed: bool,
@@ -367,7 +315,7 @@ struct Live {
embedded: bool,
/// `session/load`: the process can be stopped between turns and the session reloaded.
resumable: bool,
init: acp::Negotiated,
mcp_stdio: bool,
}
struct Task {
@@ -437,7 +385,6 @@ impl AgentManager {
probes: Mutex::new(HashMap::new()),
command_fetches: Mutex::new(HashMap::new()),
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
mcp_servers_hook: RwLock::new(None),
});
let mut tasks = HashMap::new();
for file in std::fs::read_dir(&shared.paths.store_dir).into_iter().flatten().flatten() {
@@ -478,11 +425,8 @@ impl AgentManager {
/// The agents (custom ones re-read), whether each is installed, and what they offered last time.
pub fn providers(&self) -> Vec<AgentProvider> {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
let in_use: std::collections::HashSet<String> =
self.tasks.read().values().map(|t| t.state.lock().task.provider.clone()).collect();
let list = specs
.iter()
.filter(|p| !provider::is_unlisted(p) || in_use.contains(&p.id))
.map(|p| {
let mut info = provider::describe(p);
info.config = self.shared.known_config(&p.id);
@@ -528,11 +472,6 @@ impl AgentManager {
self.shared.emit(AgentEvent::Providers);
}
/// Provides the user and workspace MCP servers configured in settings.
pub fn set_mcp_servers_hook(&self, hook: Box<McpServersFn>) {
*self.shared.mcp_servers_hook.write() = Some(Arc::from(hook));
}
/// The slash commands to complete for `provider`, in `task`'s directory or (without one)
/// in the project: what the agent offered last time there, unadvertised builtins,
/// and, for Claude Code, skills and custom commands on disk it has not listed.
@@ -549,7 +488,7 @@ impl AgentManager {
// merged without duplicating what `availableCommands` or the disk offer.
let mut offered = offered;
if task.is_none()
&& (offered.is_empty() || !self.shared.has_config(&provider) || !self.shared.knows_all_levels(&provider))
&& (offered.is_empty() || !self.shared.has_config(&provider))
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
{
self.shared.fetch_settings(spec).await;
@@ -637,24 +576,9 @@ impl AgentManager {
}));
}
let now = now_ms();
let id = uuid::Uuid::new_v4().simple().to_string();
// Copies of the files the user had changed already, for rolling back the task's changes to them.
let saved = if before.is_empty() {
None
} else {
let name = format!("{id}.before");
let (dir, at, files) = (self.shared.paths.store_dir.join(&name), cwd.clone(), before.clone());
match blocking(move || worktree::save_before(&at, &files, &dir)).await {
Ok(saved) => saved.then_some(name),
Err(e) => {
tracing::warn!("saving the changed files of agent task {id}: {e}");
None
}
}
};
let task = AgentTask {
project_root,
id,
id: uuid::Uuid::new_v4().simple().to_string(),
title,
provider: spec.id.clone(),
model: model_of(&config),
@@ -682,7 +606,6 @@ impl AgentManager {
entries: Vec::new(),
wanted: req.config,
before,
saved,
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: false,
@@ -850,16 +773,10 @@ impl AgentManager {
return Err(IdeError::invalid("this agent can't copy a conversation from there"));
}
let cwd = source.state.lock().task.cwd.0.clone();
let provider_id = source.state.lock().task.provider.clone();
let mcp_servers = source.shared.mcp_servers_for_session(&provider_id, &live.init);
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
let reply: Value = live
.peer
.request(
"session/fork",
json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": mcp_servers }),
None,
)
.request("session/fork", json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": mcp_servers_for(live.mcp_stdio, &source.shared.paths.project_root) }), None)
.await
.map_err(|e| IdeError::internal(source.describe(e)))?;
let session = reply
@@ -893,7 +810,6 @@ impl AgentManager {
entries,
wanted: s.wanted.clone(),
before: (*s.before).clone(),
saved: s.saved.clone(),
subagents: s.conversation.saved_subagents(),
pending_rollback: total - keep,
title_renamed: s.title_renamed,
@@ -1157,12 +1073,6 @@ impl AgentManager {
self.tasks.write().remove(id);
*task.deleted.lock() = true;
let _ = std::fs::remove_file(self.shared.paths.store_dir.join(format!("{id}.json")));
let saved = task.state.lock().saved.clone();
if let Some(saved) = saved
&& !self.tasks.read().values().any(|t| t.state.lock().saved.as_ref() == Some(&saved))
{
let _ = std::fs::remove_dir_all(self.shared.paths.store_dir.join(saved));
}
self.shared.emit(AgentEvent::Removed { id: id.to_owned() });
Ok(())
}
@@ -1181,41 +1091,6 @@ impl AgentManager {
blocking(move || worktree::file_at(&cwd, &path, &base)).await
}
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they
/// were when it started (files the user had changed then, as the user left them).
pub async fn rollback_changes(&self, id: &str, rel_paths: Option<Vec<String>>) -> IdeResult<()> {
let task = self.task(id)?;
if task.state.lock().busy {
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
}
let (cwd, base) = task.cwd_and_base();
let (before, saved) = {
let s = task.state.lock();
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
};
let restamped = blocking(move || {
let changes = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before);
let mut restamped = Vec::new();
for change in changes.iter().filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path))) {
worktree::restore(&cwd, &base, change, &before, saved.as_deref())?;
// Back to the user's own edits: not a change of the task any more.
if before.contains_key(&change.rel_path) {
restamped.push((change.rel_path.clone(), worktree::stamp(&change.path.0)));
}
}
Ok(restamped)
})
.await?;
if !restamped.is_empty() {
let mut s = task.state.lock();
let mut before = (*s.before).clone();
before.extend(restamped);
s.before = Arc::new(before);
}
task.persist();
Ok(())
}
/// Stops every agent (project closed).
pub fn shutdown(&self) {
for task in self.tasks.read().values() {
@@ -1241,7 +1116,6 @@ impl Task {
conversation,
wanted: file.wanted,
before: Arc::new(file.before),
saved: file.saved,
pending_rollback: file.pending_rollback,
title_renamed: file.title_renamed,
busy: false,
@@ -1354,7 +1228,6 @@ impl Task {
entries: s.conversation.main.entries.clone(),
wanted: s.wanted.clone(),
before: (*s.before).clone(),
saved: s.saved.clone(),
subagents: s.conversation.saved_subagents(),
pending_rollback: s.pending_rollback,
title_renamed: s.title_renamed,
@@ -1660,17 +1533,16 @@ impl Task {
let init = peer.initialize(&client_info()).await.map_err(|e| self.describe(e))?;
let sign_in_message = spec.sign_in_message(&init.auth_methods);
let can_load = init.load_session();
let mcp_stdio = init.mcp_stdio();
let (capabilities, embedded) = capabilities_of(&init);
let mcp_servers = self.shared.mcp_servers_for_session(&provider_id, &init);
// Agents that compare canonical paths fail in a symlinked directory (macOS `/tmp`).
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
let mut session = None;
if let (Some(id), true) = (saved, can_load) {
self.replaying.store(true, Ordering::Release);
let loaded: Result<Value, RpcError> = peer
.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": mcp_servers }), None)
.await;
let loaded: Result<Value, RpcError> =
peer.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": mcp_servers_for(mcp_stdio, &self.shared.paths.project_root) }), None).await;
tokio::time::sleep(REPLAY_GRACE).await;
self.replaying.store(false, Ordering::Release);
match loaded {
@@ -1683,7 +1555,11 @@ impl Task {
Some(s) => s,
None => {
let v: Value = peer
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": mcp_servers }), None)
.request(
"session/new",
json!({ "cwd": cwd_s, "mcpServers": mcp_servers_for(mcp_stdio, &self.shared.paths.project_root) }),
None,
)
.await
.map_err(|e| self.sign_in_error(&e, Some(&sign_in_message)).unwrap_or_else(|| self.describe(e)))?;
let id = v.get("sessionId").and_then(Value::as_str).ok_or("the agent returned no session")?.to_owned();
@@ -1775,7 +1651,7 @@ impl Task {
self.persist();
self.emit_task();
let live =
Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load, init };
Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load, mcp_stdio };
*self.live.lock() = Some(live.clone());
Ok(live)
}
@@ -1846,7 +1722,7 @@ impl Task {
format!("The agent process exited:\n{}", tail.join("\n"))
}
}
RpcError::Server { message, data, .. } => with_details(message, data.as_ref()),
RpcError::Server { message, .. } => message,
e => e.to_string(),
}
}
@@ -2304,24 +2180,6 @@ fn usage_limit(e: &RpcError) -> Option<Option<f64>> {
.then_some(None)
}
/// An error's message with the detail ACP agents put in `data` (`{"details": …}` or a string):
/// generic codes such as "Internal error" say nothing without it.
fn with_details(message: String, data: Option<&Value>) -> String {
let details = match data {
Some(Value::String(text)) => text.as_str(),
Some(data) => data.get("details").and_then(Value::as_str).unwrap_or_default(),
None => "",
}
.trim();
if details.is_empty() || message.contains(details) {
message
} else if message.trim().is_empty() {
details.to_owned()
} else {
format!("{message}: {details}")
}
}
fn client_info() -> Value {
json!({ "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") })
}
-507
View File
@@ -1,507 +0,0 @@
//! Model Context Protocol (MCP) server configurations stored in OpenIDEA's own settings
//! (global `settings.toml` and project `.openidea/settings.toml`), passed to ACP agents in
//! `session/new`, `session/load`, and `session/fork`, plus one-way import from `.mcp.json` /
//! `.vscode/mcp.json`.
use crate::acp::Negotiated;
use crate::{AgentMcpServer, McpKeyValue, McpScope, McpServer, McpServerInput, McpTransport};
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashSet};
use std::path::Path;
/// Stable ID for a server in a given scope (`"user::<name>"` or `"workspace::<name>"`).
pub fn server_id(scope: McpScope, name: &str) -> String {
let prefix = match scope {
McpScope::User => "user",
McpScope::Workspace => "workspace",
};
format!("{prefix}::{name}")
}
/// Parses a server ID (`"user::<name>"` or `"workspace::<name>"`).
pub fn parse_server_id(id: &str) -> Option<(McpScope, &str)> {
let (prefix, name) = id.split_once("::")?;
if name.is_empty() {
return None;
}
match prefix {
"user" => Some((McpScope::User, name)),
"workspace" => Some((McpScope::Workspace, name)),
_ => None,
}
}
/// Converts the User (`settings.toml`) and Workspace (`.openidea/settings.toml`) MCP server maps
/// into the ordered list displayed in Settings › Agents › MCP Servers.
pub fn servers_from_settings(
user: &BTreeMap<String, AgentMcpServer>,
workspace: &BTreeMap<String, AgentMcpServer>,
) -> Vec<McpServer> {
user.iter()
.map(|(name, cfg)| to_mcp_server(McpScope::User, name, cfg))
.chain(workspace.iter().map(|(name, cfg)| to_mcp_server(McpScope::Workspace, name, cfg)))
.collect()
}
fn to_mcp_server(scope: McpScope, name: &str, cfg: &AgentMcpServer) -> McpServer {
McpServer {
id: server_id(scope, name),
name: name.to_owned(),
scope,
transport: cfg.transport,
enabled: cfg.enabled,
command: cfg.command.clone(),
args: cfg.args.clone(),
env: cfg.env.iter().map(|(k, v)| McpKeyValue { name: k.clone(), value: v.clone() }).collect(),
url: cfg.url.clone(),
headers: cfg.headers.iter().map(|(k, v)| McpKeyValue { name: k.clone(), value: v.clone() }).collect(),
agents: cfg.agents.clone(),
}
}
fn normalize_provider(id: &str) -> &str {
id.strip_suffix("-acp").unwrap_or(id)
}
fn matches_agent(agents: &[String], provider: &str) -> bool {
if agents.is_empty() {
return true;
}
let target = normalize_provider(provider);
agents.iter().any(|a| {
let trimmed = a.trim();
trimmed == provider || normalize_provider(trimmed) == target
})
}
/// Builds the ACP `mcpServers` array for `session/new`, `session/load`, and `session/fork`.
/// Workspace entries take precedence over User entries with the same name.
pub(crate) fn acp_mcp_servers(all: &[McpServer], provider: &str, init: &Negotiated) -> Vec<Value> {
let supports_stdio = init.mcp_stdio();
let supports_http = init.mcp("http");
let supports_sse = init.mcp("sse");
let mut seen = HashSet::new();
let mut servers = Vec::new();
let ordered =
all.iter().filter(|s| s.scope == McpScope::Workspace).chain(all.iter().filter(|s| s.scope == McpScope::User));
for srv in ordered {
if !srv.enabled || !matches_agent(&srv.agents, provider) || !seen.insert(srv.name.clone()) {
continue;
}
match srv.transport {
McpTransport::Stdio if supports_stdio => {
let Some(cmd) = srv.command.as_deref().filter(|c| !c.trim().is_empty()) else {
continue;
};
let env: Vec<Value> = srv.env.iter().map(|kv| json!({ "name": kv.name, "value": kv.value })).collect();
servers.push(json!({
"name": srv.name,
"command": cmd,
"args": srv.args,
"env": env,
}));
}
McpTransport::Http if supports_http => {
let Some(url) = srv.url.as_deref().filter(|u| !u.trim().is_empty()) else {
continue;
};
let headers: Vec<Value> =
srv.headers.iter().map(|kv| json!({ "name": kv.name, "value": kv.value })).collect();
servers.push(json!({
"type": "http",
"name": srv.name,
"url": url,
"headers": headers,
}));
}
McpTransport::Sse if supports_sse => {
let Some(url) = srv.url.as_deref().filter(|u| !u.trim().is_empty()) else {
continue;
};
let headers: Vec<Value> =
srv.headers.iter().map(|kv| json!({ "name": kv.name, "value": kv.value })).collect();
servers.push(json!({
"type": "sse",
"name": srv.name,
"url": url,
"headers": headers,
}));
}
_ => {}
}
}
servers
}
/// Applies a create or update operation to the User / Workspace MCP server maps.
pub fn apply_save(
user: &mut BTreeMap<String, AgentMcpServer>,
workspace: &mut BTreeMap<String, AgentMcpServer>,
has_workspace: bool,
input: McpServerInput,
) -> Result<(), String> {
let name = input.name.trim();
if name.is_empty() {
return Err("Server name is required".into());
}
if input.scope == McpScope::Workspace && !has_workspace {
return Err("Open a project to configure workspace MCP servers".into());
}
let cfg = match input.transport {
McpTransport::Stdio => {
let cmd = input.command.as_deref().map(str::trim).unwrap_or_default();
if cmd.is_empty() {
return Err("Command is required for stdio MCP servers".into());
}
let env = input
.env
.into_iter()
.filter_map(|kv| {
let k = kv.name.trim();
(!k.is_empty()).then(|| (k.to_owned(), kv.value))
})
.collect();
AgentMcpServer {
transport: McpTransport::Stdio,
enabled: input.enabled,
command: Some(cmd.to_owned()),
args: input.args,
env,
url: None,
headers: BTreeMap::new(),
agents: normalize_agents_list(input.agents),
}
}
McpTransport::Http | McpTransport::Sse => {
let url = input.url.as_deref().map(str::trim).unwrap_or_default();
if url.is_empty() {
return Err("URL is required for HTTP/SSE MCP servers".into());
}
let headers = input
.headers
.into_iter()
.filter_map(|kv| {
let k = kv.name.trim();
(!k.is_empty()).then(|| (k.to_owned(), kv.value))
})
.collect();
AgentMcpServer {
transport: input.transport,
enabled: input.enabled,
command: None,
args: Vec::new(),
env: BTreeMap::new(),
url: Some(url.to_owned()),
headers,
agents: normalize_agents_list(input.agents),
}
}
};
if let Some(prev_id) = input.id.as_deref()
&& let Some((prev_scope, prev_name)) = parse_server_id(prev_id)
&& (prev_scope != input.scope || prev_name != name)
{
match prev_scope {
McpScope::User => {
user.remove(prev_name);
}
McpScope::Workspace => {
workspace.remove(prev_name);
}
}
}
match input.scope {
McpScope::User => {
user.insert(name.to_owned(), cfg);
}
McpScope::Workspace => {
workspace.insert(name.to_owned(), cfg);
}
}
Ok(())
}
/// Enables or disables a User or Workspace MCP server by ID.
pub fn apply_set_enabled(
user: &mut BTreeMap<String, AgentMcpServer>,
workspace: &mut BTreeMap<String, AgentMcpServer>,
id: &str,
enabled: bool,
) -> Result<McpScope, String> {
let (scope, name) = parse_server_id(id).ok_or_else(|| format!("MCP server `{id}` not found"))?;
let map = match scope {
McpScope::User => user,
McpScope::Workspace => workspace,
};
let srv = map.get_mut(name).ok_or_else(|| format!("MCP server `{name}` not found"))?;
srv.enabled = enabled;
Ok(scope)
}
/// Removes a User or Workspace MCP server by ID.
pub fn apply_remove(
user: &mut BTreeMap<String, AgentMcpServer>,
workspace: &mut BTreeMap<String, AgentMcpServer>,
id: &str,
) -> Result<McpScope, String> {
let (scope, name) = parse_server_id(id).ok_or_else(|| format!("MCP server `{id}` not found"))?;
let map = match scope {
McpScope::User => user,
McpScope::Workspace => workspace,
};
if map.remove(name).is_none() {
return Err(format!("MCP server `{name}` not found"));
}
Ok(scope)
}
/// Imports MCP servers from `<workspace_root>/.mcp.json` and `<workspace_root>/.vscode/mcp.json`
/// into `workspace` without modifying the external files. Returns the number of newly added servers.
pub fn apply_import(workspace: &mut BTreeMap<String, AgentMcpServer>, workspace_root: &Path) -> usize {
let mut added = 0;
for rel in [".mcp.json", ".vscode/mcp.json"] {
let path = workspace_root.join(rel);
for (name, srv) in read_external_file(&path) {
if let std::collections::btree_map::Entry::Vacant(e) = workspace.entry(name) {
e.insert(srv);
added += 1;
}
}
}
added
}
fn normalize_agents_list(agents: Vec<String>) -> Vec<String> {
let mut out = Vec::new();
for a in agents {
let trimmed = a.trim();
if !trimmed.is_empty() && !out.iter().any(|existing: &String| existing == trimmed) {
out.push(trimmed.to_owned());
}
}
out
}
fn read_external_file(path: &Path) -> BTreeMap<String, AgentMcpServer> {
let Ok(bytes) = std::fs::read(path) else {
return BTreeMap::new();
};
let Ok(root) = serde_json::from_slice::<Value>(&bytes) else {
return BTreeMap::new();
};
let Some(servers_obj) = root.get("mcpServers").or_else(|| root.get("servers")).and_then(Value::as_object) else {
return BTreeMap::new();
};
let mut out = BTreeMap::new();
for (name, def) in servers_obj {
let name = name.trim();
let Some(obj) = def.as_object().filter(|_| !name.is_empty()) else {
continue;
};
let disabled = obj.get("disabled").and_then(Value::as_bool).unwrap_or(false)
|| obj.get("enabled").and_then(Value::as_bool) == Some(false);
let type_str = obj.get("type").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase();
let url = obj
.get("url")
.or_else(|| obj.get("serverUrl"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned);
let command =
obj.get("command").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned);
let transport = match type_str.as_str() {
"sse" => McpTransport::Sse,
"http" | "streamable-http" | "streamable_http" => McpTransport::Http,
"stdio" | "local" => McpTransport::Stdio,
_ if url.is_some() && command.is_none() => McpTransport::Http,
_ => McpTransport::Stdio,
};
match transport {
McpTransport::Stdio if command.is_none() => continue,
McpTransport::Http | McpTransport::Sse if url.is_none() => continue,
_ => {}
}
let args = obj
.get("args")
.and_then(Value::as_array)
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
.unwrap_or_default();
out.insert(
name.to_owned(),
AgentMcpServer {
transport,
enabled: !disabled,
command,
args,
env: parse_key_values_map(obj.get("env")),
url,
headers: parse_key_values_map(obj.get("headers")),
agents: Vec::new(),
},
);
}
out
}
fn parse_key_values_map(val: Option<&Value>) -> BTreeMap<String, String> {
match val {
Some(Value::Object(map)) => map
.iter()
.filter_map(|(k, v)| {
let s = match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => return None,
};
Some((k.clone(), s))
})
.collect(),
Some(Value::Array(arr)) => arr
.iter()
.filter_map(|item| {
let obj = item.as_object()?;
let name = obj.get("name")?.as_str()?.to_owned();
let value = obj.get("value")?.as_str()?.to_owned();
Some((name, value))
})
.collect(),
_ => BTreeMap::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crud_and_acp_filtering_by_scope_agent_and_transport() {
let mut user = BTreeMap::new();
let mut workspace = BTreeMap::new();
apply_save(
&mut user,
&mut workspace,
true,
McpServerInput {
id: None,
name: "shared".into(),
scope: McpScope::User,
transport: McpTransport::Stdio,
enabled: true,
command: Some("npx".into()),
args: vec!["-y".into(), "user-pkg".into()],
env: vec![McpKeyValue { name: "TOKEN".into(), value: "u1".into() }],
url: None,
headers: Vec::new(),
agents: Vec::new(),
},
)
.unwrap();
apply_save(
&mut user,
&mut workspace,
true,
McpServerInput {
id: None,
name: "shared".into(),
scope: McpScope::Workspace,
transport: McpTransport::Stdio,
enabled: true,
command: Some("npx".into()),
args: vec!["-y".into(), "workspace-pkg".into()],
env: Vec::new(),
url: None,
headers: Vec::new(),
agents: Vec::new(),
},
)
.unwrap();
apply_save(
&mut user,
&mut workspace,
true,
McpServerInput {
id: None,
name: "claude-http".into(),
scope: McpScope::User,
transport: McpTransport::Http,
enabled: true,
command: None,
args: Vec::new(),
env: Vec::new(),
url: Some("https://example.com/mcp".into()),
headers: vec![McpKeyValue { name: "Authorization".into(), value: "Bearer secret".into() }],
agents: vec!["claude".into()],
},
)
.unwrap();
let list = servers_from_settings(&user, &workspace);
assert_eq!(list.len(), 3);
// Negotiated with HTTP support: `claude` gets workspace `shared` + `claude-http`; `codex` only gets `shared`.
let init_http = Negotiated::from_initialize(&json!({
"agentCapabilities": { "mcpCapabilities": { "http": true, "sse": false } }
}));
let claude_servers = acp_mcp_servers(&list, "claude-acp", &init_http);
assert_eq!(claude_servers.len(), 2);
assert_eq!(claude_servers[0]["name"], "shared");
assert_eq!(claude_servers[0]["args"], json!(["-y", "workspace-pkg"]));
assert_eq!(claude_servers[1]["name"], "claude-http");
assert_eq!(claude_servers[1]["type"], "http");
let codex_servers = acp_mcp_servers(&list, "codex", &init_http);
assert_eq!(codex_servers.len(), 1);
assert_eq!(codex_servers[0]["name"], "shared");
// Disable workspace `shared` and remove user `claude-http`.
apply_set_enabled(&mut user, &mut workspace, "workspace::shared", false).unwrap();
apply_remove(&mut user, &mut workspace, "user::claude-http").unwrap();
let list = servers_from_settings(&user, &workspace);
let after = acp_mcp_servers(&list, "claude", &init_http);
assert_eq!(after.len(), 1);
assert_eq!(after[0]["args"], json!(["-y", "user-pkg"]));
}
#[test]
fn imports_from_mcp_json_and_vscode_mcp_json_without_modifying_them() {
let tmp = tempfile::tempdir().unwrap();
let mcp_json = tmp.path().join(".mcp.json");
let vscode_dir = tmp.path().join(".vscode");
std::fs::create_dir_all(&vscode_dir).unwrap();
let vscode_mcp = vscode_dir.join("mcp.json");
let raw_mcp = r#"{"mcpServers":{"sqlite":{"command":"uvx","args":["mcp-server-sqlite"]}}}"#;
let raw_vscode = r#"{"servers":{"remote":{"type":"http","url":"https://mcp.example.com"}}}"#;
std::fs::write(&mcp_json, raw_mcp).unwrap();
std::fs::write(&vscode_mcp, raw_vscode).unwrap();
let mut workspace = BTreeMap::new();
let added = apply_import(&mut workspace, tmp.path());
assert_eq!(added, 2);
assert_eq!(workspace["sqlite"].command.as_deref(), Some("uvx"));
assert_eq!(workspace["remote"].transport, McpTransport::Http);
assert_eq!(workspace["remote"].url.as_deref(), Some("https://mcp.example.com"));
// Re-importing does not duplicate or overwrite existing entries, and leaves files untouched.
workspace.get_mut("sqlite").unwrap().enabled = false;
assert_eq!(apply_import(&mut workspace, tmp.path()), 0);
assert!(!workspace["sqlite"].enabled);
assert_eq!(std::fs::read_to_string(&mcp_json).unwrap(), raw_mcp);
assert_eq!(std::fs::read_to_string(&vscode_mcp).unwrap(), raw_vscode);
}
}
-67
View File
@@ -535,70 +535,3 @@ pub enum AgentEvent {
/// The installed or configured agent providers changed.
Providers,
}
pub use ide_core::settings::{AgentMcpServer, McpTransport};
/// Where an MCP server configuration is stored (`settings.toml` vs `.openidea/settings.toml`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum McpScope {
User,
Workspace,
}
/// A named environment variable or HTTP header for an MCP server.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct McpKeyValue {
pub name: String,
pub value: String,
}
/// One configured Model Context Protocol (MCP) server.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct McpServer {
pub id: String,
pub name: String,
pub scope: McpScope,
pub transport: McpTransport,
pub enabled: bool,
pub command: Option<String>,
pub args: Vec<String>,
pub env: Vec<McpKeyValue>,
pub url: Option<String>,
pub headers: Vec<McpKeyValue>,
/// Agent provider ids this server is enabled for; empty means enabled for all agents.
#[serde(default)]
pub agents: Vec<String>,
}
/// Payload for creating or updating a user or workspace MCP server.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct McpServerInput {
#[serde(default)]
pub id: Option<String>,
pub name: String,
pub scope: McpScope,
pub transport: McpTransport,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: Vec<McpKeyValue>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub headers: Vec<McpKeyValue>,
/// Agent provider ids this server is enabled for; empty means enabled for all agents.
#[serde(default)]
pub agents: Vec<String>,
}
fn default_true() -> bool {
true
}
+2 -14
View File
@@ -69,17 +69,9 @@ pub(crate) fn connect(
Ok(super::serve(shim, client))
}
/// What discovery found: ACP `availableCommands`, `configOptions` for the configured model,
/// and each model's effort option by model value (`None`: none).
pub(crate) struct Discovered {
pub commands: Vec<Value>,
pub options: Value,
pub efforts: Vec<(String, Option<Value>)>,
}
/// Discover the slash commands and the model, mode and effort options without a prompt,
/// ACP session, or pending session file.
pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<Discovered, String> {
pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<(Vec<Value>, Value), String> {
tokio::time::timeout(Duration::from_secs(5), async move {
let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd));
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
@@ -108,11 +100,7 @@ pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<Discove
.flatten()
.unwrap_or_default();
let settings = Settings::from_cli(&init, &applied);
Ok(Discovered {
commands: settings.commands.clone(),
options: settings.options(),
efforts: settings.efforts_by_model(),
})
Ok((settings.commands.clone(), settings.options()))
})
.await
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
+9 -38
View File
@@ -132,18 +132,19 @@ impl Settings {
}).collect::<Vec<_>>(),
}));
}
if let Some(effort) = self.current_model().and_then(|m| effort_option(m, &self.effort)) {
out.push(effort);
let efforts = self.current_model().map(|m| m.efforts.as_slice()).unwrap_or_default();
if !efforts.is_empty() {
let mut values = vec![json!({ "value": "default", "name": "Default" })];
values.extend(efforts.iter().map(|e| json!({ "value": e, "name": capitalize(e) })));
let current = if efforts.contains(&self.effort) { self.effort.as_str() } else { "default" };
out.push(json!({
"id": EFFORT, "name": "Effort", "category": "thought_level", "type": "select",
"currentValue": current, "options": values,
}));
}
Value::Array(out)
}
/// Each model's effort option by model value (`None`: it has no effort levels): what a new
/// task offers once that model is picked, before any session has run it.
pub fn efforts_by_model(&self) -> Vec<(String, Option<Value>)> {
self.models.iter().map(|m| (m.value.clone(), effort_option(m, &self.effort))).collect()
}
pub fn valid(&self, option: &str, value: &str) -> bool {
self.options()
.as_array()
@@ -155,20 +156,6 @@ impl Settings {
}
}
/// `model`'s effort option, with `effort` selected when the model offers it; `None` without efforts.
fn effort_option(model: &Model, effort: &str) -> Option<Value> {
if model.efforts.is_empty() {
return None;
}
let mut values = vec![json!({ "value": "default", "name": "Default" })];
values.extend(model.efforts.iter().map(|e| json!({ "value": e, "name": capitalize(e) })));
let current = if model.efforts.iter().any(|e| e == effort) { effort } else { "default" };
Some(json!({
"id": EFFORT, "name": "Effort", "category": "thought_level", "type": "select",
"currentValue": current, "options": values,
}))
}
/// Slash commands as ACP `availableCommands`, without those that need Claude Code's terminal.
pub(super) fn commands(list: Option<&Value>, terminal_only: &[String]) -> Vec<Value> {
list.and_then(Value::as_array)
@@ -244,20 +231,4 @@ mod tests {
let cmds = commands(init().get("commands"), &["doctor".into()]);
assert_eq!(cmds, vec![json!({"name": "review", "description": "Review", "input": {"hint": "[pr]"}})]);
}
#[test]
fn every_model_offers_its_own_efforts() {
let s = Settings::from_cli(&init(), &json!({"applied": {"model": "claude-sonnet-5-5", "effort": "high"}}));
let efforts = s.efforts_by_model();
let values = |model: &str| {
let (_, o) = efforts.iter().find(|(m, _)| m == model).unwrap();
let o = o.as_ref().unwrap();
let values: Vec<_> = o["options"].as_array().unwrap().iter().map(|v| v["value"].clone()).collect();
(values, o["currentValue"].clone())
};
assert_eq!(values("default"), (vec![json!("default"), json!("low"), json!("high")], json!("high")));
// The configured effort only where the model offers it.
assert_eq!(values("opus"), (vec![json!("default"), json!("low"), json!("max")], json!("default")));
assert!(efforts.contains(&("claude-haiku-4-5".into(), None)), "no effort option without levels");
}
}
-17
View File
@@ -185,14 +185,6 @@ pub(crate) fn builtin_spec(id: &str) -> Option<ProviderSpec> {
builtins().into_iter().find(|s| s.id == id)
}
/// The ACP adapters of Claude Code and Codex, which the native drivers replace: they still
/// launch, but are listed only once customized through an agent file or used by a task.
const UNLISTED: &[&str] = &["claude-acp", "codex-acp"];
pub(crate) fn is_unlisted(spec: &ProviderSpec) -> bool {
UNLISTED.contains(&spec.id.as_str()) && builtin_spec(&spec.id).as_ref() == Some(spec)
}
/// Public launch metadata, keeping the built-in defaults separate from agent-file overrides.
pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
let builtin = builtin_spec(&spec.id);
@@ -599,15 +591,6 @@ mod tests {
set_builtin_env(dir.path(), "not-a-builtin", env).unwrap_err();
}
#[test]
fn acp_adapters_of_native_agents_are_unlisted_until_customized() {
let dir = tempfile::tempdir().unwrap();
let unlisted = |dir: &Path| load(Some(dir)).into_iter().filter(is_unlisted).map(|s| s.id).collect::<Vec<_>>();
assert_eq!(unlisted(dir.path()), ["claude-acp", "codex-acp"]);
set_builtin_env(dir.path(), "codex-acp", BTreeMap::from([("A".to_owned(), "1".to_owned())])).unwrap();
assert_eq!(unlisted(dir.path()), ["claude-acp"]);
}
#[test]
fn set_builtin_command_overrides_and_clears_a_builtin() {
let dir = tempfile::tempdir().unwrap();
+2 -106
View File
@@ -2521,10 +2521,10 @@ async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
let saved = options("saved", "low");
let discovered = options("discovered", "high");
if discovery_first {
mgr.shared.remember_discovered("claude", &discovered, &[], &[]);
mgr.shared.remember_discovered("claude", &discovered, &[]);
}
mgr.shared.remember("claude", Some(&saved), None, Some("saved"));
mgr.shared.remember_discovered("claude", &discovered, &[], &[]);
mgr.shared.remember_discovered("claude", &discovered, &[]);
let restored = manager(tmp.path(), Arc::new(FakeAgent::default()));
for mgr in [&mgr, &restored] {
assert_eq!(model_of(&mgr.shared.known_config("claude")).as_deref(), Some("saved"));
@@ -2533,31 +2533,6 @@ async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn discovered_effort_levels_fill_models_no_session_has_reported() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let effort = |values: &[&str], current: &str| {
config::options_of(&json!({ "configOptions": [
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": current,
"options": values.iter().map(|v| json!({ "value": v })).collect::<Vec<_>>() }
] }))
};
let model = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "sonnet",
"options": [{ "value": "sonnet" }, { "value": "opus" }] }
] }));
// A session already reported sonnet's levels; discovery adds opus's.
let reported = [model.clone(), effort(&["low", "high"], "high")].concat();
mgr.shared.remember("claude", Some(&reported), None, Some("sonnet"));
let levels =
[("sonnet".to_owned(), effort(&["low"], "low")), ("opus".to_owned(), effort(&["low", "max"], "default"))];
mgr.shared.remember_discovered("claude", &model, &[], &levels);
let known = mgr.shared.known.lock();
assert_eq!(known["claude"].thought_levels["sonnet"], effort(&["low", "high"], "high"), "the session's report wins");
assert_eq!(known["claude"].thought_levels["opus"], effort(&["low", "max"], "default"));
}
#[test]
fn streamed_text_is_sent_as_appends() {
let mut streamed = HashMap::new();
@@ -2593,82 +2568,3 @@ fn streamed_text_is_sent_as_appends() {
let rewritten = entry_event("t", &mut streamed, None, 5, &agent(&format!("{}x!??", "é".repeat(4999))));
assert!(matches!(rewritten, AgentEvent::Entry { .. }), "a rewritten end is sent whole");
}
#[test]
fn error_messages_keep_the_agents_details() {
let details = json!({ "details": "spawn claude ENOENT" });
assert_eq!(with_details("Internal error".into(), Some(&details)), "Internal error: spawn claude ENOENT");
assert_eq!(
with_details("Internal error".into(), Some(&json!("model not found"))),
"Internal error: model not found"
);
assert_eq!(with_details(String::new(), Some(&details)), "spawn claude ENOENT");
assert_eq!(with_details("spawn claude ENOENT".into(), Some(&details)), "spawn claude ENOENT");
assert_eq!(with_details("Internal error".into(), Some(&json!({ "other": 1 }))), "Internal error");
assert_eq!(with_details("Internal error".into(), None), "Internal error");
}
#[tokio::test(flavor = "multi_thread")]
async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edits() {
let tmp = tempfile::tempdir().unwrap();
let dir = dunce::canonicalize(tmp.path()).unwrap();
let git = |args: &[&str]| {
let ok = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.current_dir(&dir)
.status()
.unwrap()
.success();
assert!(ok, "{args:?}");
};
let write = |rel: &str, text: &str| std::fs::write(dir.join(rel), text).unwrap();
let read = |rel: &str| std::fs::read_to_string(dir.join(rel)).unwrap();
git(&["init", "-q", "-b", "main"]);
write(".gitignore", "store/\nworktrees/\nagents/\nmodels.json\n");
write("mine.txt", "base\n");
write("other.txt", "base\n");
git(&["add", "."]);
git(&["commit", "-q", "-m", "init"]);
// The user's own uncommitted edit, before the task starts.
write("mine.txt", "my edit\n");
let mgr = manager(&dir, Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Hello"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// What the agent did.
write("mine.txt", "my edit\nand the agent's\n");
write("other.txt", "agent\n");
write("new.txt", "agent\n");
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
}
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt", "other.txt"]);
mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()])).await.unwrap();
assert_eq!(read("other.txt"), "base\n");
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt"]);
// After a restart, from the saved copy.
mgr.shutdown();
let mgr = manager(&dir, Arc::new(FakeAgent::default()));
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(read("mine.txt"), "my edit\n", "the user's edit stays");
assert!(!dir.join("new.txt").exists());
assert!(changed(&mgr, &task.id).await.is_empty());
mgr.delete(&task.id, false).await.unwrap();
assert!(!dir.join("store").join(format!("{}.before", task.id)).exists());
}
+1 -72
View File
@@ -116,7 +116,7 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
/// Size and modification time of a file, `None` once it is gone.
pub(crate) type Stamp = Option<(u64, u128)>;
pub(crate) fn stamp(path: &Path) -> Stamp {
fn stamp(path: &Path) -> Stamp {
let meta = std::fs::metadata(path).ok()?;
let modified = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?.as_nanos();
Some((meta.len(), modified))
@@ -132,77 +132,6 @@ pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<Str
changes.into_iter().filter(|c| before.get(&c.rel_path).is_none_or(|s| *s != stamp(&c.path))).collect()
}
/// Files larger than this that were changed before the task keep no copy: they can't be rolled back.
const SAVED_MAX: u64 = 8 * 1024 * 1024;
fn saved_name(rel: &str) -> String {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut hash = DefaultHasher::new();
rel.hash(&mut hash);
format!("{:016x}", hash.finish())
}
/// Copies the files in `before` (already changed in the checkout at `cwd` when the task started)
/// into `dir`, so rolling back the task's changes to them restores the user's own edits.
/// Whether anything was saved.
pub(crate) fn save_before(cwd: &Path, before: &BTreeMap<String, Stamp>, dir: &Path) -> IdeResult<bool> {
let Some(repo) = Repo::discover(cwd)? else { return Ok(false) };
let mut saved = false;
for (rel, stamp) in before {
let path = repo.root().join(rel);
if stamp.is_none() || !std::fs::metadata(&path).is_ok_and(|m| m.is_file() && m.len() <= SAVED_MAX) {
continue;
}
std::fs::create_dir_all(dir)?;
std::fs::copy(&path, dir.join(saved_name(rel)))?;
saved = true;
}
Ok(saved)
}
/// Rolls `change` back: to the copy saved when the task started when the file had changes
/// then (`before`, `saved` holding the copies), otherwise to `base` (removed when `base`
/// lacks it).
pub(crate) fn restore(
cwd: &Path,
base: &str,
change: &AgentChange,
before: &BTreeMap<String, Stamp>,
saved: Option<&Path>,
) -> IdeResult<()> {
let path = &change.path.0;
let content = match before.get(&change.rel_path) {
Some(None) => None,
Some(Some(_)) => {
let copy =
saved.map(|d| d.join(saved_name(&change.rel_path))).filter(|p| p.is_file()).ok_or_else(|| {
IdeError::invalid(format!(
"{} had changes of yours when the task started that weren't saved; roll it back in Git instead",
change.rel_path
))
})?;
Some(std::fs::read(copy)?)
}
None => {
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
repo.file_bytes_at(path, Some(base))?
}
};
match content {
Some(bytes) => {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, bytes)?;
}
None => match std::fs::remove_file(path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
_ => {}
},
}
Ok(())
}
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
let Some(repo) = Repo::discover(cwd)? else { return Ok(None) };
let encoding = match ide_vfs::load_text(path) {
+5 -46
View File
@@ -263,12 +263,7 @@ pub(super) fn maven_steps(
return Err(IdeError::not_found("no Maven project found"));
}
let step = |p: &maven::MavenProject, extra: &[String]| {
let mut s = BuildStep::new(
&p.command,
&["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG],
&subdir(root, &p.dir),
Format::Maven,
);
let mut s = BuildStep::new(&p.command, &["-B", "-Dstyle.color=never"], &subdir(root, &p.dir), Format::Maven);
s.args.extend(p.options.iter().cloned());
s.args.extend(extra.iter().cloned());
s.args.extend(goals.iter().map(|g| (*g).to_owned()));
@@ -1049,21 +1044,9 @@ mod tests {
assert_eq!(p.system, BuildSystem::Maven);
assert_eq!(p.title, "demo");
assert_eq!(p.steps.len(), 1);
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "test-compile"]);
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "test-compile"]);
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java")), None).unwrap();
assert_eq!(
p.steps[0].args,
[
"-B",
"-Dstyle.color=never",
maven::LOCAL_REPO_TRACKING_ARG,
"-pl",
"core",
"-am",
"clean",
"test-compile"
]
);
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "-pl", "core", "-am", "clean", "test-compile"]);
assert_eq!(p.steps[0].cwd, root);
assert_eq!(p.title, "demo: build module 'core'");
@@ -1337,36 +1320,12 @@ mod tests {
assert_eq!(step.cwd, root);
assert_eq!(step.program, "mvnw");
assert_eq!(step.env["JAVA_HOME"], "/jdk");
assert_eq!(
step.args,
[
"-B",
"-Dstyle.color=never",
maven::LOCAL_REPO_TRACKING_ARG,
"-s",
"settings.xml",
"-pl",
"app",
"-am",
goal
]
);
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", goal]);
// Rebuild cleans first, as IDEA's Rebuild does.
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Rebuild).unwrap();
assert_eq!(
plan.steps[0].args,
[
"-B",
"-Dstyle.color=never",
maven::LOCAL_REPO_TRACKING_ARG,
"-s",
"settings.xml",
"-pl",
"app",
"-am",
"clean",
goal
]
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
);
}
}
+1 -11
View File
@@ -528,17 +528,7 @@ mod tests {
for (step, name) in plan.steps.iter().zip(["java", "kotlin"]) {
assert_eq!(step.cwd, root.join(name));
assert_eq!(step.program, format!("{name}-mvnw"));
assert_eq!(
step.args,
[
"-B",
"-Dstyle.color=never",
crate::maven::LOCAL_REPO_TRACKING_ARG,
"-o",
"-Peditor",
"test-compile"
]
);
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-o", "-Peditor", "test-compile"]);
assert_eq!(step.env.get("JAVA_HOME").map(String::as_str), Some("/jdk"));
}
}
-8
View File
@@ -18,14 +18,6 @@ use std::sync::{Mutex, OnceLock};
pub mod sources;
/// Lets the IDE's own Maven runs (Reload, Build) use what the local repository already
/// has. Maven 3.9 re-resolves an artifact whose recorded origin repository (`_remote.repositories`)
/// isn't declared in the build, such as a vendor jar downloaded through another project or
/// installed by hand, and fails when no declared repository has it. Tracking under another
/// file name makes such artifacts count as locally installed. Maven goals the user runs keep
/// plain Maven's behaviour.
pub const LOCAL_REPO_TRACKING_ARG: &str = "-Daether.enhancedLocalRepository.trackingFilename=openidea.repositories";
/// Phases shown under "Lifecycle" (IDEA's default list).
pub const LIFECYCLE: &[&str] =
&["clean", "validate", "compile", "test", "package", "verify", "install", "site", "deploy"];
+1 -7
View File
@@ -341,13 +341,7 @@ pub fn sync_maven(
// `-T1` keeps mvnd serial: its default parallel build interleaves the
// `dependency:tree` output of concurrent modules, which the sync parses
// as one stream. mvn is serial anyway, so this only pins the daemon.
let mut args: Vec<String> = vec![
"-B".into(),
"-fae".into(),
"-Dstyle.color=never".into(),
"-T1".into(),
maven::LOCAL_REPO_TRACKING_ARG.into(),
];
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into(), "-T1".into()];
args.extend(sync_maven_options(&options.maven_options));
if options.offline {
args.push("-o".into());
-3
View File
@@ -73,8 +73,6 @@ pub struct EditorAppearanceSettings {
pub whitespace_inner: bool,
pub whitespace_trailing: bool,
pub indent_guides: bool,
/// Colour indent guides by depth (`--syn-rainbow-*`), while `indent_guides` is on.
pub rainbow_indent_guides: bool,
/// Right margin and visual guides from Code Style.
pub hard_wrap_guides: bool,
pub intention_bulb: bool,
@@ -97,7 +95,6 @@ impl Default for EditorAppearanceSettings {
whitespace_inner: true,
whitespace_trailing: true,
indent_guides: true,
rainbow_indent_guides: false,
hard_wrap_guides: true,
intention_bulb: true,
distraction_free_width: 80,
-83
View File
@@ -104,11 +104,6 @@ pub struct AgentSettings {
#[serde(deserialize_with = "agent_defaults")]
#[specta(type = BTreeMap<String, BTreeMap<String, String>>)]
pub defaults: BTreeMap<String, BTreeMap<String, String>>,
/// MCP servers passed to coding agents in `session/new`, `session/load` and `session/fork`,
/// keyed by server name (`[agents.mcpServers.<name>]`).
#[serde(deserialize_with = "or_default")]
#[specta(type = BTreeMap<String, AgentMcpServer>)]
pub mcp_servers: BTreeMap<String, AgentMcpServer>,
}
impl Default for AgentSettings {
@@ -126,58 +121,6 @@ impl Default for AgentSettings {
send_with: SendKey::Enter,
while_running: WhileRunning::Queue,
defaults: BTreeMap::new(),
mcp_servers: BTreeMap::new(),
}
}
}
/// Transport of an MCP server configured in Settings › Agents › MCP Servers.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum McpTransport {
#[default]
Stdio,
Http,
Sse,
}
/// An MCP server passed to coding agents in `session/new`, `session/load` and `session/fork`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AgentMcpServer {
#[serde(deserialize_with = "or_default")]
#[specta(type = McpTransport)]
pub transport: McpTransport,
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
/// Agent provider ids this server is enabled for (e.g. `["claude", "codex"]`);
/// empty means enabled for all agents.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agents: Vec<String>,
}
impl Default for AgentMcpServer {
fn default() -> Self {
Self {
transport: McpTransport::Stdio,
enabled: true,
command: None,
args: Vec::new(),
env: BTreeMap::new(),
url: None,
headers: BTreeMap::new(),
agents: Vec::new(),
}
}
}
@@ -1330,11 +1273,6 @@ pub struct ProjectStructureSettings {
pub unmarked: Vec<String>,
/// Content roots besides the project folder (absolute paths), shown and searched with it.
pub content_roots: Vec<String>,
/// Other projects (absolute paths) this project depends on; indexed and navigable like content roots.
pub dependencies: Vec<String>,
/// Per-workspace-member dependencies: member root (IPC path) → the roots it depends on.
/// A member not listed here (or with an empty list) navigates only into itself.
pub member_dependencies: BTreeMap<String, Vec<String>>,
/// "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth.
pub exclude_files: String,
/// Project compiler output directory (relative to project root or absolute; empty = default).
@@ -1508,27 +1446,6 @@ mod tests {
}
}
#[test]
fn double_click_opens_diff_is_unset_by_default_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"nonModal":true}}}"#).unwrap();
assert_eq!(old.vcs.commit.double_click_opens_diff, None);
// The unset value shows up as `null` in the defaults: `SettingsStore::set` only
// accepts a key path it finds there.
let json = serde_json::to_value(Settings::default()).unwrap();
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], serde_json::Value::Null);
for on in [true, false] {
let mut settings = Settings::default();
settings.vcs.commit.double_click_opens_diff = Some(on);
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], on);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.vcs.commit.double_click_opens_diff, Some(on));
}
// Explicit `null` reads back as unset.
let cleared: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"doubleClickOpensDiff":null}}}"#).unwrap();
assert_eq!(cleared.vcs.commit.double_click_opens_diff, None);
}
#[test]
fn agent_meters_default_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
-10
View File
@@ -43,7 +43,6 @@ impl Default for VcsSettings {
pub struct GitSettings {
/// Show blame after the current editor line.
pub inline_blame: bool,
pub show_branch_in_project_view: bool,
/// Path to the git executable; empty = auto-detect.
pub executable: String,
/// Default of Update Project: `merge` | `rebase`.
@@ -75,7 +74,6 @@ impl Default for GitSettings {
fn default() -> Self {
Self {
inline_blame: false,
show_branch_in_project_view: false,
executable: String::new(),
update_method: "merge".into(),
auto_update_on_push_rejected: false,
@@ -117,13 +115,6 @@ pub struct CommitSettings {
pub wrap_at_margin: bool,
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
pub disabled_plugin_checks: Vec<String>,
/// Double-click / Enter in the changes view: `None` follows the interface (the
/// Commit tool window opens the diff, Local Changes the source file).
///
/// No `skip_serializing_if`: an unset value must still appear in
/// `Settings::default()`, or `SettingsStore::set` rejects the key as unknown.
#[specta(optional)]
pub double_click_opens_diff: Option<bool>,
}
impl Default for CommitSettings {
@@ -144,7 +135,6 @@ impl Default for CommitSettings {
show_right_margin: true,
wrap_at_margin: false,
disabled_plugin_checks: Vec::new(),
double_click_opens_diff: None,
}
}
}
+7 -36
View File
@@ -246,7 +246,13 @@ impl Connection {
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
let Some(msg) = parse_message(body) else { return };
let msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("dap: unparsable message: {e}");
return;
}
};
match msg.get("type").and_then(Value::as_str) {
Some("response") => {
let Some(request_seq) = msg.get("request_seq").and_then(Value::as_i64) else {
@@ -279,25 +285,6 @@ fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u
}
}
/// A message body as JSON. Some adapters pass the debuggee's output through as it was
/// written: netcoredbg on Windows embeds console output in the ANSI code page (GBK on a
/// Chinese system), which isn't valid UTF-8 and made the whole `output` event unparsable,
/// so every line with non-ASCII text went missing. Such a body is decoded like other tool
/// output (UTF-8, else the system code page) before parsing.
fn parse_message(body: &[u8]) -> Option<Value> {
let error = match serde_json::from_slice(body) {
Ok(v) => return Some(v),
Err(e) => e,
};
if std::str::from_utf8(body).is_err()
&& let Ok(v) = serde_json::from_str(&ide_core::output_encoding::decode_tool_output(body))
{
return Some(v);
}
tracing::warn!("dap: unparsable message: {error}");
None
}
/// `message`, or the formatted `body.error` (`{format, variables}`) when present.
fn error_message(msg: &Value) -> String {
if let Some(err) = msg.pointer("/body/error")
@@ -319,22 +306,6 @@ fn error_message(msg: &Value) -> String {
mod tests {
use super::*;
#[test]
fn parses_output_events_with_non_utf8_text() {
// "中文" in GBK inside an output event, as netcoredbg sends it on a Chinese Windows.
let mut body = br#"{"seq":5,"type":"event","event":"output","body":{"category":"stdout","output":""#.to_vec();
body.extend_from_slice(&[0xd6, 0xd0, 0xce, 0xc4]);
body.extend_from_slice(br#"\n"}}"#);
let msg = parse_message(&body).expect("parsed");
assert_eq!(msg["event"], "output");
let output = msg["body"]["output"].as_str().unwrap();
assert!(output.ends_with('\n'));
// Off Windows (UTF-8 code page) the bytes decode lossily; the line still arrives.
#[cfg(not(windows))]
assert!(output.starts_with(char::REPLACEMENT_CHARACTER));
assert!(parse_message(b"{not json").is_none());
}
#[test]
fn formats_structured_errors() {
let msg =
+1 -1
View File
@@ -20,7 +20,7 @@ parking_lot.workspace = true
futures.workspace = true
sqlx.workspace = true
tiberius.workspace = true
oracledb.workspace = true
oracle.workspace = true
bb8.workspace = true
tokio-util = { workspace = true, features = ["compat"] }
dirs.workspace = true
+3 -3
View File
@@ -417,7 +417,7 @@ fn binary_type(name: &str, text: bool) -> bool {
}
/// Bytes as text: UTF-8, else hex (grids) or with invalid sequences replaced (catalog names).
pub(crate) fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
match (String::from_utf8(bytes), mode) {
(Ok(s), _) => s,
(Err(e), Cells::Display) => hex(e.as_bytes()),
@@ -1042,7 +1042,7 @@ pub enum Held {
Mysql(Box<bb8::PooledConnection<'static, MysqlManager>>),
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
Clickhouse(crate::clickhouse::ClickhousePool),
Oracle(Box<crate::oracle::OracleConnection>),
Oracle(crate::oracle::OracleConnection),
}
impl Held {
@@ -1058,7 +1058,7 @@ impl Held {
}
Held::Clickhouse(p.clone())
}
Pool::Oracle(p) => Held::Oracle(Box::new(p.get().await?)),
Pool::Oracle(p) => Held::Oracle(p.get().await?),
})
}
+3 -41
View File
@@ -57,7 +57,6 @@ impl DbTableKind {
pub enum DbObjectKind {
Function,
Procedure,
Package,
Trigger,
Sequence,
}
@@ -93,21 +92,12 @@ pub struct SequenceInfo {
pub data_type: String,
}
/// An Oracle package (its procedures and functions are not listed separately).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbPackageInfo")]
pub struct PackageInfo {
pub name: String,
}
/// A schema's routines (procedures and functions), packages and sequences.
/// A schema's routines (procedures and functions) and sequences.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbSchemaObjects")]
pub struct SchemaObjects {
pub routines: Vec<RoutineInfo>,
pub packages: Vec<PackageInfo>,
pub sequences: Vec<SequenceInfo>,
}
@@ -306,7 +296,7 @@ fn through_tunnel(e: IdeError, source: &DataSource, via: Option<&str>) -> IdeErr
/// Options › Run keep-alive query: pings the pool until it is closed or dropped
/// (a dropped SSH tunnel or an idle-killing proxy stays alive meanwhile).
pub(crate) fn keep_alive(pool: &Arc<Pool>, every: Duration) {
fn keep_alive(pool: &Arc<Pool>, every: Duration) {
let pool = Arc::downgrade(pool);
tokio::spawn(async move {
let mut tick = tokio::time::interval(every);
@@ -863,14 +853,6 @@ impl DbManager {
})
.collect();
}
if let Some(q) = sql::packages(driver, schema) {
out.packages = pool
.rows(&q, MAX_META_ROWS)
.await?
.into_iter()
.filter_map(|r| Some(PackageInfo { name: r.into_iter().next().flatten()? }))
.collect();
}
if let Some(q) = sql::sequences(driver, schema) {
out.sequences = pool
.rows(&q, MAX_META_ROWS)
@@ -888,29 +870,9 @@ impl DbManager {
Ok(out)
}
/// The definition of a routine (every overload), package (spec and body), trigger or sequence.
/// The definition of a routine (every overload), trigger or sequence.
pub async fn object_ddl(&self, id: &str, schema: &str, kind: DbObjectKind, name: &str) -> IdeResult<String> {
let pool = self.pool(id).await?;
if kind == DbObjectKind::Package {
let mut defs: Vec<String> = Vec::new();
for body in [false, true] {
let Some(q) = sql::package_ddl(pool.driver(), schema, name, body) else { continue };
match pool.rows(&q, MAX_META_ROWS).await {
Ok(rows) => defs.extend(rows.into_iter().filter_map(|r| r.into_iter().next().flatten())),
// A spec-only package has no body: the spec alone still opens.
Err(e) if body => tracing::warn!("package body {schema}.{name}: {}", e.message),
Err(e) => return Err(e),
}
}
if defs.is_empty() {
return Err(IdeError::not_found(format!("{schema}.{name}")));
}
return Ok(defs
.iter()
.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';')))
.collect::<Vec<_>>()
.join("\n"));
}
let (q, column) = sql::object_ddl(pool.driver(), schema, kind, name)
.ok_or_else(|| IdeError::invalid(format!("no {kind:?} objects in this database")))?;
let defs: Vec<String> = pool
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -20,7 +20,7 @@ pub enum DbDriver {
/// Microsoft SQL Server.
SqlServer,
Clickhouse,
/// Oracle Database (pure-Rust thin driver, no client library).
/// Oracle Database (via Oracle Instant Client).
Oracle,
}
@@ -88,6 +88,8 @@ pub struct DbOptions {
pub auto_disconnect_secs: Option<u32>,
/// PostgreSQL: show only the configured database, its schemas right under the data source.
pub single_database: bool,
/// Oracle: directory of the Oracle Instant Client (empty: `ORACLE_HOME` / system search path).
pub instant_client: String,
}
/// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`.
-39
View File
@@ -664,32 +664,6 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
}
}
/// Rows: `name` of a schema's packages (Oracle only; their procedures and functions are not
/// listed separately, as in IDEA).
pub fn packages(driver: DbDriver, schema: &str) -> Option<String> {
match driver {
DbDriver::Oracle => Some(format!(
"SELECT object_name FROM all_objects WHERE owner = {} AND object_type = 'PACKAGE' \
AND object_name NOT LIKE 'BIN$%' ORDER BY 1",
quote_literal(driver, schema)
)),
_ => None,
}
}
/// The statement reading a package's specification (`body` false) or body as DDL.
pub fn package_ddl(driver: DbDriver, schema: &str, name: &str, body: bool) -> Option<String> {
match driver {
DbDriver::Oracle => Some(format!(
"SELECT DBMS_METADATA.GET_DDL('{}', {}, {}) FROM DUAL",
if body { "PACKAGE_BODY" } else { "PACKAGE" },
quote_literal(driver, name),
quote_literal(driver, schema)
)),
_ => None,
}
}
/// Rows: `name, data type` of a schema's sequences (PostgreSQL, Oracle).
pub fn sequences(driver: DbDriver, schema: &str) -> Option<String> {
match driver {
@@ -989,7 +963,6 @@ mod tests {
assert!(completion_columns(DbDriver::Oracle).contains("all_tab_columns"));
assert!(triggers(DbDriver::Oracle, "hr", "t").contains("all_triggers"));
assert!(routines(DbDriver::Oracle, "hr").unwrap().contains("all_objects"));
assert!(packages(DbDriver::Oracle, "hr").unwrap().contains("object_type = 'PACKAGE'"));
assert!(sequences(DbDriver::Oracle, "hr").unwrap().contains("all_sequences"));
}
@@ -1011,17 +984,6 @@ mod tests {
let (q, col) = object_ddl(DbDriver::Oracle, "hr", K::Procedure, "p").unwrap();
assert!(q.contains("DBMS_METADATA.GET_DDL('PROCEDURE'") && col == 0);
assert!(object_ddl(DbDriver::Oracle, "hr", K::Sequence, "q").unwrap().0.contains("all_sequences"));
assert!(
package_ddl(DbDriver::Oracle, "hr", "pkg", false)
.unwrap()
.contains("DBMS_METADATA.GET_DDL('PACKAGE', 'pkg', 'hr')")
);
assert!(
package_ddl(DbDriver::Oracle, "hr", "pkg", true)
.unwrap()
.contains("DBMS_METADATA.GET_DDL('PACKAGE_BODY', 'pkg', 'hr')")
);
assert!(package_ddl(DbDriver::Postgres, "public", "pkg", false).is_none());
}
#[test]
@@ -1039,7 +1001,6 @@ mod tests {
fn schema_objects_per_dialect() {
use crate::DbObjectKind as K;
assert!(routines(DbDriver::Sqlite, "main").is_none());
assert!(packages(DbDriver::Sqlite, "main").is_none());
assert!(sequences(DbDriver::Mysql, "db").is_none());
assert!(routines(DbDriver::Postgres, "public").unwrap().contains("n.nspname = 'public'"));
assert_eq!(
-1
View File
@@ -18,7 +18,6 @@ semver = "1"
blake3.workspace = true
zip.workspace = true
base64.workspace = true
dunce.workspace = true
ed25519-dalek = "3"
[dev-dependencies]
+3 -25
View File
@@ -420,13 +420,7 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
return if candidate.is_file() { Ok(candidate.to_path_buf()) } else { Err(format!("{name}: no such program")) };
}
let exts: Vec<String> = if cfg!(windows) {
let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into());
let pathext: Vec<String> = pathext.split(';').filter(|e| !e.is_empty()).map(str::to_owned).collect();
// A name given with its extension (`cargo.exe`) is looked up as written.
let has_ext = candidate.extension().is_some_and(|e| {
pathext.iter().any(|x| x.trim_start_matches('.').eq_ignore_ascii_case(&e.to_string_lossy()))
});
if has_ext { vec![String::new()] } else { pathext }
std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into()).split(';').map(str::to_owned).collect()
} else {
vec![String::new()]
};
@@ -447,13 +441,11 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
/// `p` with its parent canonicalized but the file name as found: PATH
/// entries like `/var` → `/private/var` (macOS) or `~` links collapse to
/// their real directory, while a linked program (`cargo` → rustup) keeps
/// its link name so the shim still dispatches on argv[0]. `dunce` keeps the
/// path in its usual form on Windows: a `\\?\`-prefixed pin would never
/// match the path the program was actually started with.
/// its link name so the shim still dispatches on argv[0].
fn canonical_dir(p: &Path) -> PathBuf {
let Some(dir) = p.parent() else { return p.to_path_buf() };
let Some(name) = p.file_name() else { return p.to_path_buf() };
dunce::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
std::fs::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
}
fn is_executable(p: &Path) -> bool {
@@ -506,20 +498,6 @@ mod tests {
list.iter().map(|s| s.parse().unwrap()).collect()
}
#[test]
#[cfg(windows)]
fn pinned_program_path_has_no_verbatim_prefix() {
// std::fs::canonicalize returns \\?\-prefixed paths on Windows; a pin
// stored in that form would never match the plain path the program is
// started with. dunce keeps the usual form.
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("cargo.exe"), b"").unwrap();
let pinned = resolve_program("cargo.exe", Some(dir.path().as_os_str())).unwrap();
let shown = pinned.to_string_lossy();
assert!(!shown.starts_with(r"\\?\"), "verbatim prefix leaked into the pin: {shown}");
assert!(pinned.is_absolute());
}
fn req<'a>(version: &'a str, hash: &'a str, required: &[&str], optional: &[&str]) -> Request<'a> {
Request {
subject: Subject::Installed("p".into()),
+1 -8
View File
@@ -19,10 +19,8 @@ mod index;
pub mod ledger;
mod manifest;
pub mod package;
mod plugin_point;
mod point;
mod registry;
mod resolve;
mod ui;
mod when;
@@ -32,12 +30,7 @@ pub use manifest::{
ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES,
Manifest, check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
};
pub use plugin_point::{FieldType, PointDecl};
pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
pub use registry::{
ChangeListener, Contribution, ContributionInfo, DeclaredPoint, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT,
PreparedSet,
};
pub use resolve::{Blocked, Dependency, DependencyState, Node, Plan, dependency_state, dependents, plan};
pub use registry::{ContributionInfo, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT, PreparedSet};
pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
pub use when::validate_when;
+2 -136
View File
@@ -26,8 +26,6 @@
//! activated at start-up, as before.
use crate::capability::Capability;
use crate::plugin_point::PointDecl;
use crate::resolve::Dependency;
use serde::Deserialize;
use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
@@ -68,10 +66,6 @@ pub struct Manifest {
pub wasm: Option<PathBuf>,
pub activation: Vec<ActivationEvent>,
pub capabilities: Capabilities,
/// `[[depends]]`: plugins this one needs (API 1.5).
pub depends: Vec<Dependency>,
/// `[[extension-points]]`: what other plugins may contribute to this one (API 1.5).
pub extension_points: Vec<PointDecl>,
/// `contributes.<point>` → its declarations, validated by the extension points.
pub contributes: toml::Table,
}
@@ -157,7 +151,6 @@ pub const EVENT_KINDS: &[&str] = &[
"file-closed",
"settings-changed",
"input-mode-changed",
"extensions-changed",
"file-opened",
"file-saved",
"files-changed",
@@ -217,10 +210,6 @@ struct Raw {
#[serde(default)]
permissions: Option<Vec<String>>,
#[serde(default)]
depends: Vec<Dependency>,
#[serde(default)]
extension_points: Vec<PointDecl>,
#[serde(default)]
contributes: toml::Table,
}
@@ -292,8 +281,6 @@ impl Manifest {
wasm: raw.wasm,
activation,
capabilities,
depends: raw.depends,
extension_points: raw.extension_points,
contributes: raw.contributes,
};
m.validate()?;
@@ -370,9 +357,6 @@ impl Manifest {
{
self.needs_api(&format!("`{event}`"), 4)?;
}
if matches!(event, ActivationEvent::Event(kind) if kind == "extensions-changed") {
self.needs_api("`on-event:extensions-changed`", 5)?;
}
if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") {
self.needs_api("`on-event:files-changed`", 3)?;
}
@@ -383,7 +367,6 @@ impl Manifest {
for c in &self.declared_categories {
validate_item_id(c, "category")?;
}
self.validate_relations()?;
for (point, decls) in &self.contributes {
validate_point_id(point)?;
if !matches!(decls, toml::Value::Array(_) | toml::Value::Table(_)) {
@@ -393,42 +376,6 @@ impl Manifest {
Ok(())
}
/// `[[depends]]` and `[[extension-points]]`.
fn validate_relations(&self) -> Result<(), String> {
if !self.depends.is_empty() {
self.needs_api("`[[depends]]`", 5)?;
}
if !self.extension_points.is_empty() {
self.needs_api("`[[extension-points]]`", 5)?;
}
let mut required = BTreeSet::new();
for dep in &self.depends {
validate_plugin_id(&dep.id)?;
if dep.id == self.id {
return Err(format!("`depends`: {} cannot depend on itself", self.id));
}
if let Some(req) = &dep.version
&& semver::VersionReq::parse(req).is_err()
{
return Err(format!(
"`depends`: {:?} is not a version requirement like \"^1.2\" (plugin {})",
req, dep.id
));
}
if !required.insert(&dep.id) {
return Err(format!("`depends`: {} is listed twice", dep.id));
}
}
let mut declared = BTreeSet::new();
for point in &self.extension_points {
point.validate(&self.id)?;
if !declared.insert(&point.id) {
return Err(format!("extension point {} is declared twice", point.id));
}
}
Ok(())
}
/// The declarations of one extension point: `[[contributes.x]]` gives
/// several, `[contributes.x]` one.
pub fn declarations(&self, point: &str) -> Vec<toml::Value> {
@@ -525,12 +472,11 @@ pub fn validate_item_id(id: &str, what: &str) -> Result<(), String> {
}
/// Extension point ids: `camelCase` or `kebab-case` words, dots allowed (`iconThemes`, `menus`).
/// Points declared by plugins are `<plugin id>.<name>`, which may be longer and contain `_`.
pub fn validate_point_id(id: &str) -> Result<(), String> {
let ok = !id.is_empty()
&& id.len() <= 128
&& id.len() <= 64
&& id.starts_with(|c: char| c.is_ascii_lowercase())
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'));
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.'));
if ok { Ok(()) } else { Err(format!("invalid contribution point {id:?}")) }
}
@@ -765,86 +711,6 @@ mod api14_tests {
}
}
#[cfg(test)]
mod relations_tests {
use super::*;
const HEAD: &str = "id = \"acme.app\"\nname = \"App\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.5\"\n";
fn parse(rest: &str) -> Result<Manifest, String> {
Manifest::parse(&format!("{HEAD}{rest}"))
}
#[test]
fn parses_depends_and_extension_points() {
let m = parse(
r#"
[[depends]]
id = "acme.base"
version = "^0.2"
[[depends]]
id = "acme.extra"
optional = true
[[extension-points]]
id = "acme.app.hooks"
required = ["name"]
fields = { name = "string" }
[[contributes."acme.base.things"]]
name = "x"
"#,
)
.unwrap();
assert_eq!(m.depends.len(), 2);
assert_eq!(m.depends[0].version.as_deref(), Some("^0.2"));
assert!(m.depends[1].optional);
assert_eq!(m.extension_points[0].id, "acme.app.hooks");
assert_eq!(m.declarations("acme.base.things").len(), 1);
}
#[test]
fn the_new_keys_need_api_1_5() {
let older =
"id = \"acme.app\"\nname = \"App\"\nversion = \"1.0.0\"\nwasm = \"p.wasm\"\nengines.openidea = \"^1.4\"\n";
let err = Manifest::parse(&format!("{older}[[depends]]\nid = \"acme.base\"\n")).unwrap_err();
assert!(err.contains("new in plugin API 1.5"), "{err}");
let err = Manifest::parse(&format!("{older}[[extension-points]]\nid = \"acme.app.x\"\n")).unwrap_err();
assert!(err.contains("new in plugin API 1.5"), "{err}");
let err = Manifest::parse(&format!("{older}activation = [\"on-event:extensions-changed\"]\n")).unwrap_err();
assert!(err.contains("new in plugin API 1.5"), "{err}");
let current = older.replace("^1.4", "^1.5");
assert!(Manifest::parse(&format!("{current}activation = [\"on-event:extensions-changed\"]\n")).is_ok());
}
#[test]
fn refuses_bad_dependencies() {
assert!(parse("[[depends]]\nid = \"acme.app\"\n").unwrap_err().contains("itself"));
assert!(parse("[[depends]]\nid = \"Bad Id\"\n").unwrap_err().contains("invalid plugin id"));
assert!(
parse("[[depends]]\nid = \"acme.base\"\nversion = \"one\"\n").unwrap_err().contains("version requirement")
);
let twice = "[[depends]]\nid = \"acme.base\"\n[[depends]]\nid = \"acme.base\"\n";
assert!(parse(twice).unwrap_err().contains("listed twice"));
assert!(parse("[[depends]]\nid = \"acme.base\"\nunknown = 1\n").is_err());
}
#[test]
fn refuses_points_outside_the_plugins_namespace() {
let err = parse("[[extension-points]]\nid = \"other.thing\"\n").unwrap_err();
assert!(err.contains("acme.app.<name>"), "{err}");
let twice = "[[extension-points]]\nid = \"acme.app.x\"\n[[extension-points]]\nid = \"acme.app.x\"\n";
assert!(parse(twice).unwrap_err().contains("declared twice"));
}
#[test]
fn plugin_ids_with_underscores_work_as_contribution_keys() {
assert!(validate_point_id("my_tools.base.things").is_ok());
assert!(validate_point_id("Bad").is_err());
}
}
#[cfg(test)]
mod commands_api_tests {
use super::*;
-192
View File
@@ -1,192 +0,0 @@
//! Extension points a plugin declares for other plugins to contribute to
//! (`[[extension-points]]`), and the check of a contribution against them.
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
const MAX_FIELDS: usize = 32;
const MAX_ID: usize = 128;
/// Longest string a contribution may carry in one field.
const MAX_TEXT: usize = 64 * 1024;
const MAX_LIST: usize = 1000;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "kebab-case")]
pub enum FieldType {
String,
Boolean,
Integer,
Number,
StringList,
}
/// `[[extension-points]]`: a kind of contribution the declaring plugin's code reads.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(deny_unknown_fields)]
pub struct PointDecl {
/// `<plugin id>.<name>`.
pub id: String,
#[serde(default)]
pub description: String,
/// Fields a contribution must have.
#[serde(default)]
pub required: Vec<String>,
/// Every field a contribution may have; a contribution with another key is refused.
#[serde(default)]
pub fields: BTreeMap<String, FieldType>,
}
impl PointDecl {
pub fn validate(&self, plugin_id: &str) -> Result<(), String> {
let prefix = format!("{plugin_id}.");
let name = self.id.strip_prefix(&prefix).unwrap_or("");
let name_ok = name.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
&& name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'));
if !name_ok || self.id.len() > MAX_ID {
return Err(format!(
"invalid extension point {:?}: use \"{plugin_id}.<name>\" with a-z, 0-9, '.', '-', '_' in the name",
self.id
));
}
if self.description.len() > 500 {
return Err(format!("extension point {}: `description` is longer than 500 characters", self.id));
}
if self.fields.len() > MAX_FIELDS {
return Err(format!("extension point {}: at most {MAX_FIELDS} fields", self.id));
}
for field in self.fields.keys() {
let ok = field.chars().next().is_some_and(|c| c.is_ascii_lowercase())
&& field.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
&& field.len() <= 64;
if !ok {
return Err(format!("extension point {}: invalid field name {field:?}", self.id));
}
}
for field in &self.required {
if !self.fields.contains_key(field) {
return Err(format!("extension point {}: required field {field:?} is not in `fields`", self.id));
}
}
Ok(())
}
/// Checks one contribution and returns it as JSON for the declaring plugin's code.
pub fn check(&self, value: &toml::Value) -> Result<serde_json::Value, String> {
let toml::Value::Table(table) = value else {
return Err("a contribution is a table".into());
};
for key in table.keys() {
if !self.fields.contains_key(key) {
let known: Vec<&str> = self.fields.keys().map(String::as_str).collect();
return Err(format!("unknown field {key:?} (this extension point has: {})", known.join(", ")));
}
}
for key in &self.required {
if !table.contains_key(key) {
return Err(format!("missing required field {key:?}"));
}
}
let mut out = serde_json::Map::new();
for (key, value) in table {
out.insert(key.clone(), convert(key, self.fields[key], value)?);
}
Ok(serde_json::Value::Object(out))
}
}
fn convert(key: &str, ty: FieldType, value: &toml::Value) -> Result<serde_json::Value, String> {
use serde_json::Value as J;
let wrong = |expected: &str| format!("field {key:?} must be {expected}");
match (ty, value) {
(FieldType::String, toml::Value::String(s)) => text(key, s).map(J::from),
(FieldType::Boolean, toml::Value::Boolean(b)) => Ok(J::from(*b)),
(FieldType::Integer, toml::Value::Integer(i)) => Ok(J::from(*i)),
(FieldType::Number, toml::Value::Integer(i)) => Ok(J::from(*i)),
(FieldType::Number, toml::Value::Float(f)) if f.is_finite() => Ok(J::from(*f)),
(FieldType::StringList, toml::Value::Array(items)) => {
if items.len() > MAX_LIST {
return Err(format!("field {key:?} has more than {MAX_LIST} items"));
}
items
.iter()
.map(|item| match item {
toml::Value::String(s) => text(key, s).map(J::from),
_ => Err(wrong("a list of strings")),
})
.collect::<Result<Vec<_>, _>>()
.map(J::Array)
}
(FieldType::String, _) => Err(wrong("a string")),
(FieldType::Boolean, _) => Err(wrong("true or false")),
(FieldType::Integer, _) => Err(wrong("an integer")),
(FieldType::Number, _) => Err(wrong("a number")),
(FieldType::StringList, _) => Err(wrong("a list of strings")),
}
}
fn text(key: &str, s: &str) -> Result<String, String> {
if s.len() > MAX_TEXT {
return Err(format!("field {key:?} is longer than {} KiB", MAX_TEXT >> 10));
}
Ok(s.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
fn decl(src: &str) -> PointDecl {
toml::from_str(src).unwrap()
}
const COMPLETIONS: &str = r#"
id = "acme.base.completions"
description = "Extra completions"
required = ["label"]
fields = { label = "string", detail = "string", weight = "integer", scopes = "string-list" }
"#;
#[test]
fn the_id_is_namespaced_by_the_declaring_plugin() {
assert!(decl(COMPLETIONS).validate("acme.base").is_ok());
let err = decl(COMPLETIONS).validate("acme.other").unwrap_err();
assert!(err.contains("acme.other.<name>"), "{err}");
assert!(decl("id = \"acme.base.\"").validate("acme.base").is_err());
assert!(decl("id = \"acme.base.Bad Name\"").validate("acme.base").is_err());
}
#[test]
fn required_fields_must_be_declared() {
let err = decl("id = \"p.x\"\nrequired = [\"a\"]").validate("p").unwrap_err();
assert!(err.contains("required field \"a\""), "{err}");
}
#[test]
fn checks_a_contribution_and_converts_it_to_json() {
let point = decl(COMPLETIONS);
let contribution: toml::Value =
toml::from_str("label = \"ew.x\"\nweight = 3\nscopes = [\"xml\", \"java\"]").unwrap();
let json = point.check(&contribution).unwrap();
assert_eq!(json, serde_json::json!({"label": "ew.x", "weight": 3, "scopes": ["xml", "java"]}));
}
#[test]
fn refuses_what_the_point_does_not_declare() {
let point = decl(COMPLETIONS);
let check = |src: &str| point.check(&toml::from_str::<toml::Value>(src).unwrap()).unwrap_err();
assert!(check("detail = \"x\"").contains("missing required field \"label\""));
let err = check("label = \"a\"\nextra = 1");
assert!(err.contains("unknown field \"extra\"") && err.contains("label"), "{err}");
assert!(check("label = 1").contains("must be a string"));
assert!(check("label = \"a\"\nweight = \"3\"").contains("must be an integer"));
assert!(check("label = \"a\"\nscopes = [1]").contains("list of strings"));
}
#[test]
fn numbers_take_integers_and_floats() {
let point = decl("id = \"p.x\"\nfields = { ratio = \"number\" }");
assert!(point.check(&toml::from_str::<toml::Value>("ratio = 2").unwrap()).is_ok());
assert!(point.check(&toml::from_str::<toml::Value>("ratio = 0.5").unwrap()).is_ok());
}
}
+6 -281
View File
@@ -1,7 +1,6 @@
//! Extension points plus the contributions committed to them, accounted per plugin.
use crate::capability::Capability;
use crate::plugin_point::PointDecl;
use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared};
use parking_lot::{Mutex, RwLock};
use serde::{Deserialize, Serialize};
@@ -24,7 +23,7 @@ pub struct ContributionInfo {
}
struct Entry {
point: String,
point: &'static str,
key: Option<String>,
label: String,
disposable: Disposable,
@@ -39,7 +38,7 @@ pub struct PreparedSet {
pub files: Vec<PathBuf>,
/// Declarations for extension points this host does not know (skipped).
pub warnings: Vec<String>,
items: Vec<(String, Prepared)>,
items: Vec<(&'static str, Prepared)>,
}
impl PreparedSet {
@@ -62,41 +61,11 @@ impl std::fmt::Debug for PreparedSet {
}
}
/// What one plugin contributed to an extension point another plugin declared.
#[derive(Clone, Debug, PartialEq)]
pub struct Contribution {
/// The contributing plugin.
pub plugin: String,
/// The declaration as JSON, checked against the point's fields.
pub data: serde_json::Value,
}
/// An extension point a plugin declared, with who contributes to it.
#[derive(Clone, Debug, PartialEq)]
pub struct DeclaredPoint {
pub owner: String,
pub decl: PointDecl,
/// Plugin ids, in commit order, once each.
pub contributors: Vec<String>,
}
#[derive(Default)]
struct Dynamic {
points: BTreeMap<String, (String, PointDecl)>,
contributions: BTreeMap<String, Vec<Contribution>>,
}
/// Called with (point id, owner plugin) after contributions to a declared point changed.
pub type ChangeListener = Arc<dyn Fn(&str, &str) + Send + Sync>;
#[derive(Default)]
pub struct ExtensionRegistry {
points: RwLock<BTreeMap<&'static str, Arc<dyn ExtensionPoint>>>,
/// plugin id → its contributions in commit order.
entries: Mutex<BTreeMap<String, Vec<Entry>>>,
/// Points declared by plugins and what was contributed to them.
dynamic: Arc<Mutex<Dynamic>>,
listener: Arc<RwLock<Option<ChangeListener>>>,
}
impl ExtensionRegistry {
@@ -128,111 +97,6 @@ impl ExtensionRegistry {
self.points.read().get(id).cloned()
}
/// Registers the function told when contributions to a plugin-declared point change.
pub fn set_change_listener(&self, listener: ChangeListener) {
*self.listener.write() = Some(listener);
}
/// The plugin that declared `point`, while it is committed.
pub fn point_owner(&self, point: &str) -> Option<String> {
self.dynamic.lock().points.get(point).map(|(owner, _)| owner.clone())
}
/// What plugins contributed to `point`, in commit order. Empty unless a plugin declares it.
pub fn contributions_to(&self, point: &str) -> Vec<Contribution> {
let dynamic = self.dynamic.lock();
if !dynamic.points.contains_key(point) {
return Vec::new();
}
dynamic.contributions.get(point).cloned().unwrap_or_default()
}
/// The points `owner` declared, with their contributors.
pub fn declared_points(&self, owner: &str) -> Vec<DeclaredPoint> {
let dynamic = self.dynamic.lock();
dynamic
.points
.iter()
.filter(|(_, (o, _))| o == owner)
.map(|(id, (owner, decl))| {
let mut contributors: Vec<String> = Vec::new();
for c in dynamic.contributions.get(id).into_iter().flatten() {
if !contributors.contains(&c.plugin) {
contributors.push(c.plugin.clone());
}
}
DeclaredPoint { owner: owner.clone(), decl: decl.clone(), contributors }
})
.collect()
}
/// Makes `pkg`'s `[[extension-points]]` known: they take effect with the set's commit.
fn prepare_declarations(&self, pkg: &Package) -> Vec<(String, Prepared)> {
pkg.manifest
.extension_points
.iter()
.map(|decl| {
let (dynamic, owner, decl) = (self.dynamic.clone(), pkg.id().to_owned(), decl.clone());
let id = decl.id.clone();
let prepared = Prepared::new(format!("Extension point {id}"), move || {
let id = decl.id.clone();
dynamic.lock().points.insert(id.clone(), (owner.clone(), decl));
Ok(Disposable::new(move || {
dynamic.lock().points.remove(&id);
}))
})
.key(format!("extension-point:{id}"));
(id, prepared)
})
.collect()
}
/// Validates `pkg`'s contributions to a point some plugin declares, or `None`
/// when no plugin declares `point` (the caller then skips it as unknown).
fn prepare_contributions(&self, pkg: &Package, point: &str) -> Option<Result<Vec<Prepared>, String>> {
let declared = self.dynamic.lock().points.get(point).cloned();
let own =
pkg.manifest.extension_points.iter().find(|d| d.id == point).map(|d| (pkg.id().to_owned(), d.clone()));
let (owner, decl) = declared.or(own)?;
if owner != pkg.id() && !pkg.manifest.depends.iter().any(|d| d.id == owner) {
return Some(Err(format!("{point} is declared by {owner}: add it to [[depends]]")));
}
let listener = self.listener.clone();
let result = pkg
.manifest
.declarations(point)
.into_iter()
.enumerate()
.map(|(i, value)| {
let data = decl.check(&value).map_err(|e| format!("contributions[{i}]: {e}"))?;
let label = decl
.required
.first()
.and_then(|field| data.get(field))
.and_then(|v| v.as_str())
.map_or_else(|| point.to_owned(), str::to_owned);
let contribution = Contribution { plugin: pkg.id().to_owned(), data };
let (dynamic, listener, point) = (self.dynamic.clone(), listener.clone(), point.to_owned());
Ok(Prepared::new(label, move || {
dynamic.lock().contributions.entry(point.clone()).or_default().push(contribution.clone());
notify(&dynamic, &listener, &point);
Ok(Disposable::new(move || {
{
let mut guard = dynamic.lock();
if let Some(list) = guard.contributions.get_mut(&point)
&& let Some(pos) = list.iter().position(|c| *c == contribution)
{
list.remove(pos);
}
}
notify(&dynamic, &listener, &point);
}))
}))
})
.collect();
Some(result)
}
/// Validates every `contributes.<point>` declaration of `pkg`. Fails as a
/// whole on the first invalid declaration; unknown points are skipped with
/// a warning (forward compatibility).
@@ -244,24 +108,9 @@ impl ExtensionRegistry {
warnings: Vec::new(),
items: Vec::new(),
};
// Declared points first: contributions to them (the plugin's own too) commit after.
set.items.extend(self.prepare_declarations(pkg));
for point_id in pkg.manifest.contributes.keys() {
let Some(point) = self.point(point_id) else {
if let Some(result) = self.prepare_contributions(pkg, point_id) {
let prepared = result.map_err(|e| format!("contributes.{point_id}: {e}"))?;
set.items.extend(prepared.into_iter().map(|p| (point_id.clone(), p)));
continue;
}
// Point ids are `<owner id>.<name>`, so the dependency that owns it can be named.
let owner =
pkg.manifest.depends.iter().find(|d| point_id.strip_prefix(&format!("{}.", d.id)).is_some());
let msg = match owner {
Some(owner) => {
format!("contributes.{point_id}: {} is not running, so this is skipped until it is", owner.id)
}
None => format!("contributes.{point_id}: not supported by this version of the IDE; skipped"),
};
let msg = format!("contributes.{point_id}: not supported by this version of the IDE; skipped");
tracing::warn!(plugin = %pkg.id(), "{msg}");
set.warnings.push(msg);
continue;
@@ -280,7 +129,7 @@ impl ExtensionRegistry {
set.files.push(f.clone());
}
}
set.items.push((point.id().to_owned(), prepared));
set.items.push((point.id(), prepared));
}
}
if let Some(point) = self.point(LOCALES_POINT)
@@ -295,7 +144,7 @@ impl ExtensionRegistry {
set.files.push(f.clone());
}
}
set.items.push((point.id().to_owned(), prepared));
set.items.push((point.id(), prepared));
}
Ok(set)
}
@@ -352,7 +201,7 @@ impl ExtensionRegistry {
.lock()
.get(plugin)
.map(|list| {
list.iter().map(|e| ContributionInfo { point: e.point.clone(), label: e.label.clone() }).collect()
list.iter().map(|e| ContributionInfo { point: e.point.into(), label: e.label.clone() }).collect()
})
.unwrap_or_default()
}
@@ -368,14 +217,6 @@ impl ExtensionRegistry {
}
}
fn notify(dynamic: &Mutex<Dynamic>, listener: &RwLock<Option<ChangeListener>>, point: &str) {
let owner = dynamic.lock().points.get(point).map(|(owner, _)| owner.clone());
let callback = listener.read().clone();
if let (Some(owner), Some(callback)) = (owner, callback) {
callback(point, &owner);
}
}
fn strip_prefix<'a>(e: &'a str, point: &str) -> &'a str {
e.strip_prefix(&format!("contributes.{point}: ")).unwrap_or(e)
}
@@ -469,122 +310,6 @@ mod tests {
assert!(reg.contributions("p").is_empty());
}
fn pkg_of(id: &str, body: &str) -> Package {
let src = format!("id = \"{id}\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.5\"\n{body}");
Package::new(Manifest::parse(&src).unwrap(), PathBuf::from("/nonexistent"))
}
const BASE: &str = r#"
[[extension-points]]
id = "acme.base.things"
required = ["name"]
fields = { name = "string", weight = "integer" }
"#;
const PLUGIN: &str =
"[[depends]]\nid = \"acme.base\"\n\n[[contributes.\"acme.base.things\"]]\nname = \"a\"\nweight = 2\n";
fn commit(reg: &ExtensionRegistry, pkg: &Package) {
reg.commit(reg.prepare(pkg).unwrap()).unwrap();
}
#[test]
fn a_plugin_declares_a_point_and_a_dependent_contributes_to_it() {
let reg = ExtensionRegistry::new();
commit(&reg, &pkg_of("acme.base", BASE));
assert_eq!(reg.point_owner("acme.base.things").as_deref(), Some("acme.base"));
assert!(reg.contributions_to("acme.base.things").is_empty());
commit(&reg, &pkg_of("acme.more", PLUGIN));
let got = reg.contributions_to("acme.base.things");
assert_eq!(got.len(), 1);
assert_eq!(got[0].plugin, "acme.more");
assert_eq!(got[0].data, serde_json::json!({"name": "a", "weight": 2}));
assert_eq!(
reg.contributions("acme.more")[0],
ContributionInfo { point: "acme.base.things".into(), label: "a".into() }
);
let declared = reg.declared_points("acme.base");
assert_eq!(declared.len(), 1);
assert_eq!(declared[0].contributors, ["acme.more"]);
assert!(reg.dispose_plugin("acme.more"));
assert!(reg.contributions_to("acme.base.things").is_empty());
assert!(reg.dispose_plugin("acme.base"));
assert_eq!(reg.point_owner("acme.base.things"), None);
}
#[test]
fn contributions_disappear_from_view_while_the_owner_is_gone() {
let reg = ExtensionRegistry::new();
commit(&reg, &pkg_of("acme.base", BASE));
commit(&reg, &pkg_of("acme.more", PLUGIN));
reg.dispose_plugin("acme.base");
assert!(reg.contributions_to("acme.base.things").is_empty(), "nobody may read a point that is not declared");
commit(&reg, &pkg_of("acme.base", BASE));
assert_eq!(reg.contributions_to("acme.base.things").len(), 1, "back with the owner");
}
#[test]
fn contributing_needs_a_dependency_on_the_owner() {
let reg = ExtensionRegistry::new();
commit(&reg, &pkg_of("acme.base", BASE));
let err =
reg.prepare(&pkg_of("acme.rogue", "[[contributes.\"acme.base.things\"]]\nname = \"x\"\n")).unwrap_err();
assert!(err.contains("acme.base") && err.contains("[[depends]]"), "{err}");
}
#[test]
fn a_contribution_is_checked_against_the_declared_fields() {
let reg = ExtensionRegistry::new();
commit(&reg, &pkg_of("acme.base", BASE));
let bad = "[[depends]]\nid = \"acme.base\"\n[[contributes.\"acme.base.things\"]]\nweight = 1\n";
let err = reg.prepare(&pkg_of("acme.more", bad)).unwrap_err();
assert_eq!(err, "contributes.acme.base.things: contributions[0]: missing required field \"name\"");
}
#[test]
fn a_point_nobody_declares_is_skipped_with_a_warning() {
let reg = ExtensionRegistry::new();
let set = reg.prepare(&pkg_of("acme.more", PLUGIN)).unwrap();
assert!(set.is_empty());
assert!(set.warnings[0].contains("acme.base.things"), "{:?}", set.warnings);
}
#[test]
fn a_missing_owner_is_named_instead_of_blaming_the_ide_version() {
let reg = ExtensionRegistry::new();
let set = reg.prepare(&pkg_of("acme.more", PLUGIN)).unwrap();
let warning = &set.warnings[0];
assert!(warning.contains("acme.base") && warning.contains("not running"), "{warning}");
assert!(!warning.contains("this version of the IDE"), "{warning}");
let unrelated = "[[contributes.\"acme.nobody.things\"]]\nname = \"x\"\n";
let set = reg.prepare(&pkg_of("acme.more", unrelated)).unwrap();
assert!(set.warnings[0].contains("this version of the IDE"), "{:?}", set.warnings);
}
#[test]
fn a_plugin_may_contribute_to_its_own_point() {
let reg = ExtensionRegistry::new();
let own = format!("{BASE}\n[[contributes.\"acme.base.things\"]]\nname = \"builtin\"\n");
commit(&reg, &pkg_of("acme.base", &own));
assert_eq!(reg.contributions_to("acme.base.things")[0].data["name"], "builtin");
}
#[test]
fn the_owner_hears_about_changes_to_its_point() {
let reg = ExtensionRegistry::new();
let heard: Arc<Mutex<Vec<(String, String)>>> = Arc::default();
let sink = heard.clone();
reg.set_change_listener(Arc::new(move |point, owner| sink.lock().push((point.into(), owner.into()))));
commit(&reg, &pkg_of("acme.base", BASE));
assert!(heard.lock().is_empty(), "declaring is not a contribution");
commit(&reg, &pkg_of("acme.more", PLUGIN));
reg.dispose_plugin("acme.more");
let expected = ("acme.base.things".to_owned(), "acme.base".to_owned());
assert_eq!(*heard.lock(), [expected.clone(), expected]);
}
#[test]
fn invalid_declarations_fail_prepare_and_unknown_points_warn() {
let (reg, _) = setup();
-433
View File
@@ -1,433 +0,0 @@
//! Which plugins can run, and in which order, given what they depend on.
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
/// `[[depends]]` of a manifest.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(deny_unknown_fields)]
pub struct Dependency {
pub id: String,
/// Semver requirement on the dependency's version; none accepts any.
#[serde(default)]
pub version: Option<String>,
/// The plugin runs without it; when present it starts first.
#[serde(default)]
pub optional: bool,
}
impl Dependency {
/// `acme.base ^1.0`.
pub fn describe(&self) -> String {
match &self.version {
Some(v) => format!("{} {v}", self.id),
None => self.id.clone(),
}
}
}
/// A plugin as the resolver sees it.
#[derive(Clone, Copy, Debug)]
pub struct Node<'a> {
pub id: &'a str,
pub version: &'a str,
pub depends: &'a [Dependency],
/// Not switched off by the user.
pub enabled: bool,
}
/// Why an enabled plugin cannot run.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum Blocked {
/// A required plugin is not installed.
Missing { requirement: String },
/// A required plugin is switched off.
Disabled { id: String },
/// A required plugin is installed in a version that does not satisfy the requirement.
Version { requirement: String, found: String },
/// A required plugin cannot run itself.
Dependency { id: String },
/// The plugin is part of a dependency cycle (`a → b → a`).
Cycle { ring: Vec<String> },
}
impl fmt::Display for Blocked {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Missing { requirement } => write!(f, "Requires {requirement}, which is not installed"),
Self::Disabled { id } => write!(f, "Requires {id}, which is disabled"),
Self::Version { requirement, found } => {
write!(f, "Requires {requirement}, but version {found} is installed")
}
Self::Dependency { id } => write!(f, "Requires {id}, which cannot run"),
Self::Cycle { ring } => write!(f, "Part of a dependency cycle: {}", ring.join(" → ")),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Plan {
/// Plugins that can run, every plugin after the plugins it depends on.
pub order: Vec<String>,
/// Enabled plugins that cannot run. Disabled plugins are in neither list.
pub blocked: BTreeMap<String, Blocked>,
}
/// Decides, for every enabled plugin, whether its required plugins allow it to
/// run and the order to start them in (the counterpart of IntelliJ's
/// `PluginSetBuilder`: dependency graph, topological order, cycle report).
pub fn plan(nodes: &[Node<'_>]) -> Plan {
let by_id: BTreeMap<&str, &Node<'_>> = nodes.iter().map(|n| (n.id, n)).collect();
let enabled: Vec<&Node<'_>> = by_id.values().copied().filter(|n| n.enabled).collect();
let mut blocked: BTreeMap<String, Blocked> = BTreeMap::new();
for node in &enabled {
for dep in node.depends.iter().filter(|d| !d.optional) {
if let Some(reason) = direct_reason(dep, by_id.get(dep.id.as_str()).copied()) {
blocked.entry(node.id.to_owned()).or_insert(reason);
}
}
}
// Cycles among plugins whose requirements are otherwise present.
let candidates: BTreeSet<&str> = enabled.iter().map(|n| n.id).filter(|id| !blocked.contains_key(*id)).collect();
let graph: Graph<'_> = candidates
.iter()
.map(|id| {
let deps = by_id[id].depends.iter().filter(|d| !d.optional).map(|d| d.id.as_str());
(*id, deps.filter(|d| candidates.contains(d)).collect())
})
.collect();
for component in components(&graph) {
let cyclic = component.len() > 1 || graph[component[0]].contains(&component[0]);
if cyclic {
let ring = ring_of(&component, &graph);
for id in component {
blocked.insert(id.to_owned(), Blocked::Cycle { ring: ring.clone() });
}
}
}
// A plugin that needs a blocked plugin is blocked too.
loop {
let mut changed = false;
for node in &enabled {
if blocked.contains_key(node.id) {
continue;
}
if let Some(dep) = node.depends.iter().find(|d| !d.optional && blocked.contains_key(&d.id)) {
blocked.insert(node.id.to_owned(), Blocked::Dependency { id: dep.id.clone() });
changed = true;
}
}
if !changed {
break;
}
}
let runnable: BTreeSet<&str> = enabled.iter().map(|n| n.id).filter(|id| !blocked.contains_key(*id)).collect();
Plan { order: topological(&runnable, &by_id), blocked }
}
/// Plugins that need `id`, directly or through other plugins, whether or not
/// they are enabled. Optional dependencies do not count.
pub fn dependents(nodes: &[Node<'_>], id: &str) -> Vec<String> {
let mut found: BTreeSet<String> = BTreeSet::new();
let mut frontier = vec![id.to_owned()];
while let Some(current) = frontier.pop() {
for node in nodes {
if node.depends.iter().any(|d| !d.optional && d.id == current) && found.insert(node.id.to_owned()) {
frontier.push(node.id.to_owned());
}
}
}
found.remove(id);
found.into_iter().collect()
}
/// How one dependency stands right now, for the plugin details page.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DependencyState {
Satisfied,
Missing,
Disabled,
/// Installed, wrong version.
Version,
/// Installed and enabled, but cannot run itself.
Blocked,
}
pub fn dependency_state(dep: &Dependency, found: Option<&Node<'_>>, found_blocked: bool) -> DependencyState {
match direct_reason(dep, found) {
Some(Blocked::Missing { .. }) => DependencyState::Missing,
Some(Blocked::Disabled { .. }) => DependencyState::Disabled,
Some(Blocked::Version { .. }) => DependencyState::Version,
_ if found_blocked => DependencyState::Blocked,
_ => DependencyState::Satisfied,
}
}
fn direct_reason(dep: &Dependency, found: Option<&Node<'_>>) -> Option<Blocked> {
let Some(target) = found else {
return Some(Blocked::Missing { requirement: dep.describe() });
};
if !target.enabled {
return Some(Blocked::Disabled { id: dep.id.clone() });
}
let want = dep.version.as_deref()?;
let satisfied = VersionReq::parse(want)
.ok()
.zip(Version::parse(target.version).ok())
.is_some_and(|(req, have)| req.matches(&have));
(!satisfied).then(|| Blocked::Version { requirement: dep.describe(), found: target.version.to_owned() })
}
/// Plugin id → the plugin ids it requires.
type Graph<'a> = BTreeMap<&'a str, Vec<&'a str>>;
/// Strongly connected components (Tarjan) of `graph`.
fn components<'a>(graph: &Graph<'a>) -> Vec<Vec<&'a str>> {
struct State<'a, 'g> {
index: usize,
stack: Vec<&'a str>,
info: BTreeMap<&'a str, (usize, usize, bool)>,
out: Vec<Vec<&'a str>>,
graph: &'g Graph<'a>,
}
fn visit<'a>(s: &mut State<'a, '_>, v: &'a str) {
s.info.insert(v, (s.index, s.index, true));
s.index += 1;
s.stack.push(v);
for &w in &s.graph[v] {
match s.info.get(w).copied() {
None => {
visit(s, w);
let low = s.info[w].1;
let entry = s.info.get_mut(v).expect("visited");
entry.1 = entry.1.min(low);
}
Some((index, _, true)) => {
let entry = s.info.get_mut(v).expect("visited");
entry.1 = entry.1.min(index);
}
Some(_) => {}
}
}
let (index, low, _) = s.info[v];
if index == low {
let mut component = Vec::new();
while let Some(w) = s.stack.pop() {
s.info.get_mut(w).expect("visited").2 = false;
component.push(w);
if w == v {
break;
}
}
component.sort_unstable();
s.out.push(component);
}
}
let mut state = State { index: 0, stack: Vec::new(), info: BTreeMap::new(), out: Vec::new(), graph };
for &id in graph.keys() {
if !state.info.contains_key(id) {
visit(&mut state, id);
}
}
state.out
}
/// One cycle through `component`, from its smallest id back to it.
fn ring_of(component: &[&str], graph: &Graph<'_>) -> Vec<String> {
let start = component[0];
let mut ring = vec![start.to_owned()];
let mut current = start;
let mut seen: BTreeSet<&str> = BTreeSet::from([start]);
loop {
let mut next: Vec<&str> = graph[current].iter().copied().filter(|d| component.contains(d)).collect();
next.sort_unstable();
// Prefer closing the ring; otherwise take the smallest unseen member.
let step =
if next.contains(&start) { start } else { *next.iter().find(|d| !seen.contains(**d)).unwrap_or(&start) };
ring.push(step.to_owned());
if step == start {
return ring;
}
seen.insert(step);
current = step;
}
}
/// Kahn's algorithm over the runnable plugins; ties break by id. Optional
/// dependencies order too, but never block: leftovers (an optional edge
/// closing a loop) are appended by id.
fn topological(runnable: &BTreeSet<&str>, by_id: &BTreeMap<&str, &Node<'_>>) -> Vec<String> {
let mut waiting: BTreeMap<&str, BTreeSet<&str>> = runnable
.iter()
.map(|id| {
let deps = by_id[id].depends.iter().map(|d| d.id.as_str()).filter(|d| runnable.contains(d) && d != id);
(*id, deps.collect())
})
.collect();
let mut order: Vec<String> = Vec::with_capacity(runnable.len());
while let Some(next) = waiting.iter().find(|(_, deps)| deps.is_empty()).map(|(id, _)| *id) {
waiting.remove(next);
for deps in waiting.values_mut() {
deps.remove(next);
}
order.push(next.to_owned());
}
order.extend(waiting.keys().map(|id| (*id).to_owned()));
order
}
#[cfg(test)]
mod tests {
use super::*;
fn dep(id: &str) -> Dependency {
Dependency { id: id.into(), version: None, optional: false }
}
fn dep_v(id: &str, version: &str) -> Dependency {
Dependency { id: id.into(), version: Some(version.into()), optional: false }
}
fn optional(id: &str) -> Dependency {
Dependency { id: id.into(), version: None, optional: true }
}
fn node<'a>(id: &'a str, depends: &'a [Dependency]) -> Node<'a> {
Node { id, version: "1.0.0", depends, enabled: true }
}
#[test]
fn dependencies_start_first_whatever_the_ids_sort_like() {
let (a, z) = ([dep("z.base")], []);
let plan = plan(&[node("a.app", &a), node("z.base", &z)]);
assert_eq!(plan.order, ["z.base", "a.app"]);
assert!(plan.blocked.is_empty());
}
#[test]
fn independent_plugins_keep_id_order() {
let plan = plan(&[node("b", &[]), node("a", &[]), node("c", &[])]);
assert_eq!(plan.order, ["a", "b", "c"]);
}
#[test]
fn a_missing_requirement_blocks_with_the_requirement_text() {
let needs = [dep_v("acme.base", "^1.0")];
let plan = plan(&[node("acme.app", &needs)]);
assert!(plan.order.is_empty());
assert_eq!(plan.blocked["acme.app"], Blocked::Missing { requirement: "acme.base ^1.0".into() });
assert_eq!(plan.blocked["acme.app"].to_string(), "Requires acme.base ^1.0, which is not installed");
}
#[test]
fn a_disabled_requirement_blocks_but_the_disabled_plugin_itself_is_not_blocked() {
let needs = [dep("acme.base")];
let mut base = node("acme.base", &[]);
base.enabled = false;
let plan = plan(&[base, node("acme.app", &needs)]);
assert_eq!(plan.blocked["acme.app"], Blocked::Disabled { id: "acme.base".into() });
assert!(!plan.blocked.contains_key("acme.base"));
assert!(plan.order.is_empty());
}
#[test]
fn the_installed_version_must_satisfy_the_requirement() {
let needs = [dep_v("acme.base", "^0.2")];
let mut base = node("acme.base", &[]);
base.version = "0.3.0";
let plan_bad = plan(&[base, node("acme.app", &needs)]);
assert_eq!(
plan_bad.blocked["acme.app"],
Blocked::Version { requirement: "acme.base ^0.2".into(), found: "0.3.0".into() }
);
assert_eq!(plan_bad.blocked["acme.app"].to_string(), "Requires acme.base ^0.2, but version 0.3.0 is installed");
base.version = "0.2.7";
let plan_ok = plan(&[base, node("acme.app", &needs)]);
assert!(plan_ok.blocked.is_empty());
assert_eq!(plan_ok.order, ["acme.base", "acme.app"]);
}
#[test]
fn blocking_propagates_to_everything_downstream() {
let (b, c) = ([dep("a")], [dep("b")]);
let plan = plan(&[node("b", &b), node("c", &c)]);
assert_eq!(plan.blocked["b"], Blocked::Missing { requirement: "a".into() });
assert_eq!(plan.blocked["c"], Blocked::Dependency { id: "b".into() });
assert!(plan.order.is_empty());
}
#[test]
fn an_optional_requirement_never_blocks_but_orders_when_present() {
let app = [optional("acme.extra")];
let alone = plan(&[node("acme.app", &app)]);
assert_eq!(alone.order, ["acme.app"]);
assert!(alone.blocked.is_empty());
let with = plan(&[node("acme.app", &app), node("acme.extra", &[])]);
assert_eq!(with.order, ["acme.extra", "acme.app"]);
let mut off = node("acme.extra", &[]);
off.enabled = false;
let disabled = plan(&[node("acme.app", &app), off]);
assert_eq!(disabled.order, ["acme.app"]);
assert!(disabled.blocked.is_empty());
}
#[test]
fn a_cycle_blocks_its_members_and_names_the_ring() {
let (a, b, d) = ([dep("b")], [dep("a")], [dep("a")]);
let plan = plan(&[node("a", &a), node("b", &b), node("d", &d)]);
let ring = Blocked::Cycle { ring: vec!["a".into(), "b".into(), "a".into()] };
assert_eq!(plan.blocked["a"], ring);
assert_eq!(plan.blocked["b"], ring);
assert_eq!(ring.to_string(), "Part of a dependency cycle: a → b → a");
assert_eq!(plan.blocked["d"], Blocked::Dependency { id: "a".into() });
assert!(plan.order.is_empty());
}
#[test]
fn a_plugin_requiring_itself_is_a_cycle() {
let a = [dep("a")];
let plan = plan(&[node("a", &a)]);
assert_eq!(plan.blocked["a"], Blocked::Cycle { ring: vec!["a".into(), "a".into()] });
}
#[test]
fn an_optional_loop_does_not_block_and_still_yields_every_plugin() {
let (a, b) = ([optional("b")], [optional("a")]);
let plan = plan(&[node("a", &a), node("b", &b)]);
assert!(plan.blocked.is_empty());
assert_eq!(plan.order.len(), 2);
}
#[test]
fn dependents_are_transitive_and_ignore_optional_edges() {
let (b, c, d) = ([dep("a")], [dep("b")], [optional("a")]);
let nodes = [node("a", &[]), node("b", &b), node("c", &c), node("d", &d)];
assert_eq!(dependents(&nodes, "a"), ["b", "c"]);
assert_eq!(dependents(&nodes, "c"), Vec::<String>::new());
}
#[test]
fn dependency_state_tells_the_details_page_what_to_say() {
let want = dep_v("base", "^1.0");
assert_eq!(dependency_state(&want, None, false), DependencyState::Missing);
let mut base = node("base", &[]);
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Satisfied);
assert_eq!(dependency_state(&want, Some(&base), true), DependencyState::Blocked);
base.version = "2.0.0";
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Version);
base.enabled = false;
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Disabled);
}
}
+1 -111
View File
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
use ide_agent::registry::{AgentRegistry, RegistryPaths};
use ide_agent::{
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
McpScope, McpServer, McpServerInput, NewAgentTask,
NewAgentTask,
};
use ide_core::error::ErrorKind;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
@@ -277,105 +277,6 @@ pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
}
/// Rollback: the task's changed files among `paths` (all without) back as they were when it started.
pub async fn rollback_changes(host: &Host, window: &str, task: String, paths: Option<Vec<String>>) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.rollback_changes(&task, paths).await
}
type McpServerMap = std::collections::BTreeMap<String, ide_core::settings::AgentMcpServer>;
fn mcp_maps(host: &Host, window: &str) -> (McpServerMap, McpServerMap, Option<std::sync::Arc<crate::Project>>) {
let user = host.global_settings.effective().agents.mcp_servers;
let project = host.project(window).ok().filter(|p| !p.light);
let workspace = project
.as_ref()
.map(|p| p.settings.layer(ide_workspace::settings::SettingsScope::Project).agents.mcp_servers)
.unwrap_or_default();
(user, workspace, project)
}
fn persist_mcp_map(
host: &Host,
project: Option<&std::sync::Arc<crate::Project>>,
scope: McpScope,
map: &McpServerMap,
) -> IdeResult<()> {
let value = if map.is_empty() {
serde_json::Value::Null
} else {
serde_json::to_value(map).map_err(|e| IdeError::invalid(e.to_string()))?
};
let event = IdeEvent::SettingsChanged { key: "agents.mcpServers".into() };
match scope {
McpScope::User => {
host.global_settings.set(ide_workspace::settings::SettingsScope::Global, "agents.mcpServers", value)?;
for p in host.projects() {
p.settings.reload();
p.sink.emit(event.clone());
}
}
McpScope::Workspace => {
let p = project.ok_or_else(|| IdeError::invalid("Open a project to configure workspace MCP servers"))?;
p.settings.set(ide_workspace::settings::SettingsScope::Project, "agents.mcpServers", value)?;
p.sink.emit(event);
}
}
Ok(())
}
fn update_mcp_maps<F>(host: &Host, window: &str, f: F) -> IdeResult<Vec<McpServer>>
where
F: FnOnce(&mut McpServerMap, &mut McpServerMap, Option<&std::path::Path>) -> Result<(), String>,
{
let (mut user, mut workspace, project) = mcp_maps(host, window);
let prev_user = user.clone();
let prev_workspace = workspace.clone();
let project_root = project.as_ref().map(|p| p.root.as_path());
f(&mut user, &mut workspace, project_root).map_err(IdeError::invalid)?;
if workspace != prev_workspace {
persist_mcp_map(host, project.as_ref(), McpScope::Workspace, &workspace)?;
}
if user != prev_user {
persist_mcp_map(host, project.as_ref(), McpScope::User, &user)?;
}
Ok(ide_agent::mcp::servers_from_settings(&user, &workspace))
}
/// Lists configured MCP servers across User (`settings.toml`) and Workspace (`.openidea/settings.toml`) scopes.
pub fn mcp_list(host: &Host, window: &str) -> IdeResult<Vec<McpServer>> {
let (user, workspace, _) = mcp_maps(host, window);
Ok(ide_agent::mcp::servers_from_settings(&user, &workspace))
}
/// Creates or updates a User or Workspace MCP server and returns the updated list.
pub fn mcp_save(host: &Host, window: &str, input: McpServerInput) -> IdeResult<Vec<McpServer>> {
update_mcp_maps(host, window, |user, workspace, project_root| {
ide_agent::mcp::apply_save(user, workspace, project_root.is_some(), input)
})
}
/// Enables or disables an MCP server and returns the updated list.
pub fn mcp_set_enabled(host: &Host, window: &str, id: String, enabled: bool) -> IdeResult<Vec<McpServer>> {
update_mcp_maps(host, window, |user, workspace, _| {
ide_agent::mcp::apply_set_enabled(user, workspace, &id, enabled).map(|_| ())
})
}
/// Removes a User or Workspace MCP server and returns the updated list.
pub fn mcp_remove(host: &Host, window: &str, id: String) -> IdeResult<Vec<McpServer>> {
update_mcp_maps(host, window, |user, workspace, _| ide_agent::mcp::apply_remove(user, workspace, &id).map(|_| ()))
}
/// Copies MCP servers from `.mcp.json` and `.vscode/mcp.json` into the workspace settings once.
pub fn mcp_import(host: &Host, window: &str) -> IdeResult<Vec<McpServer>> {
update_mcp_maps(host, window, |_, workspace, project_root| {
let root = project_root.ok_or_else(|| "Open a project to import workspace MCP servers".to_string())?;
ide_agent::mcp::apply_import(workspace, root);
Ok(())
})
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
@@ -387,14 +288,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
registry_install(&c.host, &c.window, c.arg("id")?, c.arg("request")?, c.sender("onEvent")?).await
});
d.add("agent_registry_remove", |c| async move { registry_remove(&c.host, &c.window, c.arg("id")?).await });
d.add("agent_mcp_list", |c| async move { mcp_list(&c.host, &c.window) });
d.add("agent_mcp_save", |c| async move { mcp_save(&c.host, &c.window, c.arg("input")?) });
d.add(
"agent_mcp_set_enabled",
|c| async move { mcp_set_enabled(&c.host, &c.window, c.arg("id")?, c.arg("enabled")?) },
);
d.add("agent_mcp_remove", |c| async move { mcp_remove(&c.host, &c.window, c.arg("id")?) });
d.add("agent_mcp_import", |c| async move { mcp_import(&c.host, &c.window) });
d.add("agent_commands", |c| async move { commands(&c.host, &c.window, c.arg("provider")?, c.arg("task")?).await });
d.add("agent_entries", |c| async move { entries(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) });
d.add("agent_create", |c| async move { create(&c.host, &c.window, c.arg("request")?).await });
@@ -428,9 +321,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
);
d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await });
d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await });
d.add("agent_rollback_changes", |c| async move {
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
});
}
fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::Path>) -> IdeResult<std::path::PathBuf> {
+1 -80
View File
@@ -1,7 +1,7 @@
//! Repository version lookups for manifest inspections and completion.
use crate::{Host, rpc::Dispatcher};
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId};
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo, go, npm};
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo, go};
use std::collections::BTreeMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
@@ -25,31 +25,6 @@ impl DependencyVersionSource for CratesIo {
}
}
struct NpmRegistry;
impl DependencyVersionSource for NpmRegistry {
fn id(&self) -> &'static str {
"npm"
}
/// Scoped names (`@scope/name`) aren't file-name safe.
fn cache_key(&self, package: &str) -> Option<String> {
let mut hash = DefaultHasher::new();
package.hash(&mut hash);
Some(format!("{}-{:016x}", self.id(), hash.finish()))
}
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
let path = npm::registry_path(package).ok_or_else(|| IdeError::internal("invalid package name"))?;
let search_path = std::env::var_os("PATH").unwrap_or_default();
// The abbreviated metadata: the full document of a large package runs to tens of megabytes.
let text = ide_tools::Http::new(&search_path)?
.https_only()
.accept("application/vnd.npm.install-v1+json")
.get_text_limited(&format!("https://registry.npmjs.org/{path}"), Some(32 * 1024 * 1024), cancel)?;
npm::registry_versions(&text).ok_or_else(|| IdeError::internal("invalid registry metadata"))
}
}
struct GoModules {
root: PathBuf,
env: BTreeMap<String, String>,
@@ -147,13 +122,6 @@ pub async fn dependency_versions(
let versions = cache.versions(&source, &dependency.package, &cancel)?;
return Ok(vec![DependencyVersions { dependency, versions, update: None }]);
}
if snap.path.file_name().is_some_and(|name| name == "package.json") {
let Some(dependency) = offset.and_then(|offset| npm::dependency_at(&snap.text, offset)) else {
return Ok(Vec::new());
};
let versions = cache.versions(&NpmRegistry, &dependency.package, &cancel)?;
return Ok(vec![DependencyVersions { dependency, versions, update: None }]);
}
if snap.path.file_name().is_none_or(|name| name != "Cargo.toml") {
return Ok(Vec::new());
}
@@ -240,51 +208,4 @@ mod tests {
assert!(dependency_versions(&host, "main", info.id, None, RequestId(414)).await.unwrap().is_empty());
host.close_project("main");
}
struct CachedNpm;
impl DependencyVersionSource for CachedNpm {
fn id(&self) -> &'static str {
"npm"
}
fn cache_key(&self, package: &str) -> Option<String> {
NpmRegistry.cache_key(package)
}
fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult<Vec<String>> {
Ok(vec!["20.11.0".into(), "18.19.3".into()])
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn package_json_completes_the_version_at_the_cursor() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let text = "{\n \"devDependencies\": { \"@types/node\": \"^20\" }\n}\n";
let path = root.join("package.json");
std::fs::write(&path, text).unwrap();
let host = 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(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_project("main", &root).unwrap();
let info = project.documents.open(&path).unwrap();
host.dependency_versions.versions(&CachedNpm, "@types/node", &CancellationToken::new()).unwrap();
let offset = text.find("^20").unwrap() as u32 + 2;
let found = dependency_versions(&host, "main", info.id, Some(offset), RequestId(420)).await.unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].dependency.package, "@types/node");
assert_eq!(found[0].versions, ["20.11.0", "18.19.3"]);
// Inspections (no offset) and positions outside a version ask nothing.
assert!(dependency_versions(&host, "main", info.id, None, RequestId(421)).await.unwrap().is_empty());
assert!(dependency_versions(&host, "main", info.id, Some(2), RequestId(422)).await.unwrap().is_empty());
host.close_project("main");
}
}
-22
View File
@@ -1443,28 +1443,6 @@ pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResu
Ok(())
}
/// Restarts the language servers named `name` in every open project, so a tool installed or
/// updated from one window is what all of them run (not only that window's project).
pub async fn lsp_restart_named(host: &Host, name: &str) -> IdeResult<()> {
for project in host.projects() {
let ids: Vec<ServerId> = project.lsp.servers().into_iter().filter(|s| s.name == name).map(|s| s.id).collect();
if ids.is_empty() {
continue;
}
for id in ids {
if let Err(e) = project.lsp.restart(id).await {
tracing::warn!(server = name, root = %project.root.display(), "restart after a tool update: {e}");
}
}
if name == "jdtls"
&& let Some(bridge) = &project.kotlin_bridge
{
bridge.schedule();
}
}
Ok(())
}
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
let project = host.project(window)?;
+1 -69
View File
@@ -8,7 +8,7 @@ use ide_language::{FileReference, WebReference};
use ide_workspace::file_refs::{self, PathCompletion};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
/// A web reference no language server resolves and where it points: `class="card"` →
/// `.card` selectors, `id` → `#id` selectors, `href="#top"` → the element, `var(--x)` →
@@ -112,68 +112,6 @@ pub async fn web_goto(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeRes
.await
}
/// The file a Python import path names: `a.b.c` is `a/b/c.py(i)` or `a/b/c/__init__.py(i)`
/// in the project or one of its content roots (an attached dependency project included);
/// `.x` names it relative to `from`'s directory. Neither the language server (it analyses a
/// single root) nor the definition index resolves a package segment, so ⌘B on the `a.b` of
/// `from a.b import x` needs this.
pub async fn nav_resolve_module(host: &Host, window: &str, name: String, from: IpcPath) -> IdeResult<Option<IpcPath>> {
let project = host.project(window)?;
blocking(move || {
let dots = name.len() - name.trim_start_matches('.').len();
let parts: Vec<&str> = name.split('.').filter(|s| !s.is_empty()).collect();
if parts.is_empty() {
return Ok(None);
}
let rel = parts.iter().collect::<PathBuf>();
let bases: Vec<PathBuf> = if dots > 0 {
let dir = PathBuf::from(from);
let dir = dir.parent().map(Path::to_path_buf).unwrap_or(dir);
let base = (0..dots.saturating_sub(1)).fold(dir, |p, _| p.parent().map(Path::to_path_buf).unwrap_or(p));
vec![base]
} else {
crate::project::navigation_roots(&project, &from)
};
for base in bases {
let dir = base.join(&rel);
for candidate in
[dir.with_extension("py"), dir.with_extension("pyi"), dir.join("__init__.py"), dir.join("__init__.pyi")]
{
if candidate.is_file() {
return Ok(Some(IpcPath::from(candidate)));
}
}
}
Ok(None)
})
.await
}
/// Index definitions named `name` in the other roots `from` may navigate into (its configured
/// dependencies and extra content roots — see `navigation_roots`): the cross-root answer when
/// the language server cannot resolve an import into an attached dependency project. `from`'s
/// own root is left out: the server sees it, so a name it could not resolve there is not
/// answered by every declaration that happens to share it (any `save` for `repo.save()`).
pub async fn nav_index_definitions(
host: &Host,
window: &str,
name: String,
from: IpcPath,
) -> IdeResult<Vec<SymbolMatch>> {
let project = host.project(window)?;
blocking(move || {
let roots = crate::project::navigation_roots(&project, &from);
let Some((_own, roots)) = roots.split_first() else { return Ok(Vec::new()) };
if roots.is_empty() {
return Ok(Vec::new());
}
// Filter inside the index: a cap applied before the root filter can fill with
// own-root matches and evict the dependency roots entirely.
Ok(project.index.symbols_named_within(&name, None, roots, 500))
})
.await
}
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
pub async fn fs_resolve_reference(
host: &Host,
@@ -243,12 +181,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("remove_recent", |c| async move { remove_recent(&c.host, &c.window, c.arg("path")?) });
d.add("recent_files", |c| async move { recent_files(&c.host, &c.window) });
d.add("web_goto", |c| async move { web_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await });
d.add("nav_resolve_module", |c| async move {
nav_resolve_module(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
});
d.add("nav_index_definitions", |c| async move {
nav_index_definitions(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
});
d.add("fs_resolve_reference", |c| async move {
fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await
});
+1 -4
View File
@@ -221,10 +221,7 @@ pub fn set_settings(
|| key == "editor.codeStyle"
|| key.starts_with("editor.codeStyle.")
|| key == "inspections"
|| key.starts_with("inspections.")
|| key == "files"
|| key == "files.composeFileOverrides"
|| key.starts_with("files.composeFileOverrides.");
|| key.starts_with("inspections.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
+1 -1
View File
@@ -337,7 +337,7 @@ async fn start_inner(
// what makes the setting take effect without reopening the project.
if config.kind == "maven" {
ide_sdk::apply::prepare_maven_run(&mut config, root, &settings);
} else if matches!(config.kind.as_str(), "gradle" | "tomcat" | "junit") {
} else if matches!(config.kind.as_str(), "gradle" | "tomcat") {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
} else {
ide_sdk::apply::resolve_run_command(&mut config, root, &settings);
-12
View File
@@ -130,17 +130,6 @@ pub fn run_system_env(_host: &Host, _window: &str) -> std::collections::BTreeMap
ide_run::system_env()
}
/// Variables of the `.env` files a debug launch names, in order (later files win):
/// the same files a run loads, over the system environment debug adapters inherit.
pub fn run_env_files(
_host: &Host,
_window: &str,
files: Vec<std::path::PathBuf>,
) -> IdeResult<std::collections::BTreeMap<String, String>> {
ide_run::envfile::load(&files, &ide_run::system_env())
.map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}")))
}
/// Writes the Gradle init script that adds `jvm_args` to the JVMs the build forks
/// (`bootRun`, `run`, tests) and returns its path, for `--init-script`.
pub async fn run_gradle_jvm_args_script(_host: &Host, _window: &str, jvm_args: Vec<String>) -> IdeResult<String> {
@@ -343,7 +332,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
run_import_configs(&c.host, &c.window, c.arg("source")?, c.arg("existing")?)
});
d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) });
d.add("run_env_files", |c| async move { run_env_files(&c.host, &c.window, c.arg("files")?) });
d.add("run_gradle_jvm_args_script", |c| async move {
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
});
+2 -7
View File
@@ -25,12 +25,7 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
mod commands;
pub use commands::*;
/// Refuses (with `refusal`) to remove or move the worktree that holds the open project.
fn check_worktree_away_from_project(
project_root: &std::path::Path,
path: &std::path::Path,
refusal: &str,
) -> IdeResult<()> {
fn check_worktree_removal(project_root: &std::path::Path, path: &std::path::Path) -> IdeResult<()> {
fn canonical_path(path: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
let mut existing = path.to_path_buf();
let mut tail = Vec::new();
@@ -59,7 +54,7 @@ fn check_worktree_away_from_project(
}
if canonical_path(project_root)?.starts_with(canonical_path(path)?) {
return Err(ide_core::IdeError::invalid(refusal));
return Err(ide_core::IdeError::invalid("Cannot remove a worktree containing the open project."));
}
Ok(())
}
+1 -27
View File
@@ -1058,11 +1058,7 @@ pub async fn vcs_remove_worktree(
} else {
project.with_repo_at(repo.as_deref(), |r| Ok(r.root().join(&path)))?
};
check_worktree_away_from_project(
&project.root,
&path,
"Cannot remove a worktree containing the open project.",
)?;
check_worktree_removal(&project.root, &path)?;
let result = project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force));
project.refresh_repositories();
result
@@ -1070,25 +1066,6 @@ pub async fn vcs_remove_worktree(
.await
}
/// Move Worktree…: moves the linked worktree at `path` to `to`.
pub async fn vcs_move_worktree(
host: &Host,
window: &str,
repo: Option<PathBuf>,
path: PathBuf,
to: PathBuf,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
check_worktree_away_from_project(&project.root, &path, "Cannot move a worktree containing the open project.")?;
let result = project.with_repo_at(repo.as_deref(), |r| r.move_worktree(&path, &to));
project.refresh_repositories();
result
})
.await
}
pub async fn vcs_compare_scope(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<CompareScope> {
let project = host.project(window)?;
blocking(move || Ok(project.repos().compare_scope(&paths))).await
@@ -1510,9 +1487,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_add_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("path")?, c.arg("commitish")?, c.arg("newBranch")?)
.await
});
d.add("vcs_move_worktree", |c| async move {
vcs_move_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("path")?, c.arg("to")?).await
});
d.add("vcs_remove_worktree", |c| async move {
vcs_remove_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("path")?, c.arg("force")?).await
});
+1 -28
View File
@@ -530,8 +530,6 @@ pub(super) struct Shared {
pub event_editors: RwLock<HashMap<String, ide_plugin::EditorContext>>,
/// Validated UI declarations (settings schemas, declared views).
pub ui: Arc<ide_extension::UiStore>,
/// Extension points plugins declare and what was contributed to them.
pub registry: Arc<ide_extension::ExtensionRegistry>,
/// The UI language the windows report (BCP 47).
pub locale: RwLock<String>,
/// (plugin, window) → the log tabs its code opened there, oldest first:
@@ -553,11 +551,9 @@ impl Shared {
pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
ui: Arc<ide_extension::UiStore>,
ledger: Arc<Mutex<Ledger>>,
registry: Arc<ide_extension::ExtensionRegistry>,
) -> Arc<Self> {
Arc::new(Self {
ui,
registry,
locale: RwLock::new("en".into()),
sink,
workspace: RwLock::new(None),
@@ -1336,21 +1332,6 @@ impl HostApi for PluginApi {
host.mcp_executable.read().as_ref().map(|exe| ide_mcp::stdio_command(exe, &root))
}
fn extension_contributions(&self, point: &str) -> Result<Vec<(String, String)>, HostError> {
self.require_api("extension points", 5)?;
match self.shared.registry.point_owner(point) {
None => Err(HostError::NotFound(format!("no running plugin declares {point}"))),
Some(owner) if owner != self.id() => Err(HostError::Denied(format!("extension point {point}"))),
Some(_) => Ok(self
.shared
.registry
.contributions_to(point)
.into_iter()
.map(|c| (c.plugin, c.data.to_string()))
.collect()),
}
}
fn register_run_target(&self, root: &Path, target: ide_plugin::PluginRunTarget) -> Result<(), HostError> {
self.register_target(root, target)
}
@@ -3236,14 +3217,7 @@ mod tests {
)
.unwrap();
let sink: Arc<dyn PluginEventSink> = Arc::new(|_: Option<&str>, _: PluginEvent| {});
let shared = Shared::new(
sink,
dir.join(".data"),
pins,
ide_extension::UiStore::new(),
Arc::default(),
Arc::new(ide_extension::ExtensionRegistry::new()),
);
let shared = Shared::new(sink, dir.join(".data"), pins, ide_extension::UiStore::new(), Arc::default());
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
}
@@ -3978,7 +3952,6 @@ mod workbench_tests {
Arc::default(),
ide_extension::UiStore::new(),
Arc::default(),
Arc::new(ide_extension::ExtensionRegistry::new()),
);
*shared.workspace.write() =
Some(Arc::new(Workspace { root: root.into(), trusted, text: text.map(str::to_owned) }));
-214
View File
@@ -1,214 +0,0 @@
//! Plugin dependencies at run time: which plugins may start, in which order,
//! and what follows when a plugin they need goes away or comes back.
use super::{LoadedPlugin, PluginHost, PluginStatus, types::*};
use ide_extension::{Blocked, Node, Plan, dependency_state, dependents, plan};
use ide_plugin::WorkbenchEvent;
use std::collections::BTreeSet;
use std::sync::Arc;
/// Points whose contributions changed since the owners were last told.
pub(super) type PendingChanges = Arc<parking_lot::Mutex<BTreeSet<String>>>;
impl PluginHost {
/// The plan for the plugins as they are now. Plugins that are switched off,
/// broken or being uninstalled cannot be depended on.
fn dependency_plan(&self) -> Plan {
self.with_nodes(|_, plan| plan.clone())
}
fn with_nodes<R>(&self, f: impl FnOnce(&[Node<'_>], &Plan) -> R) -> R {
let plugins: Vec<Arc<LoadedPlugin>> = self.plugins.read().values().cloned().collect();
let disabled = self.state.lock().disabled.clone();
let nodes: Vec<Node<'_>> = plugins
.iter()
.map(|p| Node {
id: p.id(),
version: &p.manifest().version,
depends: &p.manifest().depends,
enabled: !disabled.contains(p.id()) && !p.invalid && !p.meta.lock().uninstalling,
})
.collect();
let plan = plan(&nodes);
f(&nodes, &plan)
}
/// Why `plugin` may not start now, if it may not.
pub(super) fn blocked_reason(&self, plugin: &LoadedPlugin) -> Option<Blocked> {
if let Some(reason) = self.dependency_plan().blocked.get(plugin.id()) {
return Some(reason.clone());
}
// Enabled and present, but it could not start itself (failed, waiting for consent).
let plugins = self.plugins.read();
plugin
.manifest()
.depends
.iter()
.filter(|d| !d.optional)
.find(|d| {
plugins.get(&d.id).is_some_and(|p| {
matches!(p.status(), PluginStatus::Failed | PluginStatus::NeedsConsent | PluginStatus::Blocked)
})
})
.map(|d| Blocked::Dependency { id: d.id.clone() })
}
/// Stops `plugin` and shows why it is not running.
pub(super) fn set_blocked(&self, plugin: &LoadedPlugin, reason: &Blocked) {
self.stop(plugin);
let mut meta = plugin.meta.lock();
meta.status = PluginStatus::Blocked;
meta.error = Some(reason.to_string());
meta.warnings.clear();
tracing::info!(plugin = %plugin.id(), "{reason}");
}
/// Orders `plugins` so each comes after the plugins it needs.
pub(super) fn in_start_order(&self, mut plugins: Vec<Arc<LoadedPlugin>>) -> Vec<Arc<LoadedPlugin>> {
let order = self.dependency_plan().order;
let rank = |id: &str| order.iter().position(|o| o == id).unwrap_or(usize::MAX);
plugins.sort_by(|a, b| rank(a.id()).cmp(&rank(b.id())).then_with(|| a.id().cmp(b.id())));
plugins
}
/// Brings every plugin in line with the dependency plan: plugins that lost
/// something they need stop, plugins that can run again start. Called with
/// the lifecycle lock held, after anything that adds, removes, enables or
/// disables a plugin.
pub(super) fn reconcile(&self) {
let plan = self.dependency_plan();
let get = |id: &str| self.plugins.read().get(id).cloned();
for (id, reason) in &plan.blocked {
if let Some(plugin) = get(id)
&& (plugin.status() != PluginStatus::Blocked
|| plugin.meta.lock().error.as_deref() != Some(&reason.to_string()))
{
self.set_blocked(&plugin, reason);
}
}
for id in &plan.order {
if let Some(plugin) = get(id)
&& plugin.status() == PluginStatus::Blocked
{
self.start(&plugin);
}
}
}
/// After `id` started again (reloaded, updated): plugins that need it follow,
/// and what they contributed is checked against its current extension points.
pub(super) fn settle_dependents(&self, id: &str) {
self.restart_dependents(id);
self.reconcile();
}
/// Starts again the running plugins that need `id`, and those that list it
/// as an optional requirement: the latter skipped their contributions to its
/// points while it was absent, so they take effect when it appears.
fn restart_dependents(&self, id: &str) {
let mut ids = self.with_nodes(|nodes, _| dependents(nodes, id));
for optional in self.optional_dependents(id) {
if !ids.contains(&optional) {
ids.push(optional);
}
}
for dependent in ids {
self.restart_live(&dependent);
}
}
/// After `id` was enabled: the plugins that list it as an optional requirement
/// start again (the ones that need it start through [`reconcile`](Self::reconcile)).
pub(super) fn restart_optional_dependents(&self, id: &str) {
for dependent in self.optional_dependents(id) {
self.restart_live(&dependent);
}
}
fn optional_dependents(&self, id: &str) -> Vec<String> {
self.plugins
.read()
.values()
.filter(|p| p.manifest().depends.iter().any(|d| d.optional && d.id == id))
.map(|p| p.id().to_owned())
.collect()
}
fn restart_live(&self, id: &str) {
let Some(plugin) = self.plugins.read().get(id).cloned() else { return };
if plugin.status().is_live() {
self.stop(&plugin);
self.start(&plugin);
}
}
/// Tells plugins that declare an extension point that contributions to it changed.
pub(super) fn flush_extension_changes(&self) {
let points: Vec<String> = std::mem::take(&mut *self.extension_changes.lock()).into_iter().collect();
if !points.is_empty() {
self.post_events(None, vec![WorkbenchEvent::ExtensionsChanged(points)]);
}
}
/// `[[depends]]` with their state, who needs this plugin, and its extension points.
pub(super) fn relations(
&self,
plugin: &LoadedPlugin,
) -> (Vec<DependencyInfo>, Vec<DependentInfo>, Vec<ExtensionPointInfo>) {
let disabled = self.state.lock().disabled.clone();
let plugins = self.plugins.read();
let depends = plugin
.manifest()
.depends
.iter()
.map(|dep| {
let found = plugins.get(&dep.id);
let node = found.map(|p| Node {
id: p.id(),
version: &p.manifest().version,
depends: &[],
enabled: !disabled.contains(p.id()) && !p.invalid,
});
let stuck = found.is_some_and(|p| {
matches!(p.status(), PluginStatus::Failed | PluginStatus::NeedsConsent | PluginStatus::Blocked)
});
DependencyInfo {
id: dep.id.clone(),
name: found.map(|p| p.manifest().name.clone()),
version: dep.version.clone(),
optional: dep.optional,
state: dependency_state(dep, node.as_ref(), stuck),
}
})
.collect();
let all: Vec<Node<'_>> = plugins
.values()
.map(|p| Node {
id: p.id(),
version: &p.manifest().version,
depends: &p.manifest().depends,
enabled: !disabled.contains(p.id()) && !p.invalid,
})
.collect();
let needing = dependents(&all, plugin.id())
.into_iter()
.filter_map(|id| plugins.get(&id))
.map(|p| DependentInfo {
id: p.id().to_owned(),
name: p.manifest().name.clone(),
enabled: !disabled.contains(p.id()),
})
.collect();
let points = self
.registry
.declared_points(plugin.id())
.into_iter()
.map(|p| ExtensionPointInfo {
id: p.decl.id,
description: p.decl.description,
contributors: p.contributors,
})
.collect();
(depends, needing, points)
}
}
+9 -83
View File
@@ -19,7 +19,6 @@ mod api;
mod auth;
pub mod commit;
mod database;
mod dependencies;
pub(crate) mod diagnostics;
pub mod endpoints;
mod event_paths;
@@ -370,8 +369,6 @@ pub struct PluginHost {
sink: Arc<dyn PluginEventSink>,
limits: Limits,
registry: Arc<ExtensionRegistry>,
/// Extension points (declared by plugins) whose contributions changed, until their owners are told.
extension_changes: dependencies::PendingChanges,
ui: Arc<UiStore>,
languages: Arc<PluginLanguages>,
run_detectors: Arc<run_detectors::RunDetectors>,
@@ -439,11 +436,6 @@ impl PluginHost {
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let registry = Arc::new(ExtensionRegistry::new());
let extension_changes: dependencies::PendingChanges = Arc::default();
let changes = extension_changes.clone();
registry.set_change_listener(Arc::new(move |point, _owner| {
changes.lock().insert(point.to_owned());
}));
let ui = UiStore::new();
let index = Arc::new(ContributionIndex::new(
plugins_dir.join(".cache").join("contributions"),
@@ -461,14 +453,8 @@ impl PluginHost {
policy_pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
}));
let ledger = Arc::new(Mutex::new(Ledger::load(&plugins_dir)));
let shared = api::Shared::new(
sink.clone(),
plugins_dir.join(".data"),
pins.clone(),
ui.clone(),
ledger.clone(),
registry.clone(),
);
let shared =
api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone(), ledger.clone());
let official_keys = market::built_in_official_keys();
let market = RwLock::new(Self::load_market_index(&plugins_dir, &official_keys));
let host = Self {
@@ -484,7 +470,6 @@ impl PluginHost {
sink,
limits,
registry,
extension_changes,
ui,
languages,
run_detectors,
@@ -571,22 +556,16 @@ impl PluginHost {
}
let dirs = package_dirs(&self.plugins_dir);
self.migrate_grants(&dirs);
// Every package is listed before any starts: a plugin's requirements are
// decided against all of them, and each starts after the plugins it needs.
let mut to_start = Vec::new();
for dir in dirs {
if !self.is_loaded_from(&dir) {
to_start.extend(self.register_dir(&dir, PluginSource::Installed, None).1);
self.load_dir(&dir, PluginSource::Installed, None);
}
}
for dir in self.links().paths {
if !self.is_loaded_from(&dir) && dir.join(MANIFEST_FILE).is_file() {
to_start.extend(self.register_dir(&dir, PluginSource::Linked, None).1);
self.load_dir(&dir, PluginSource::Linked, None);
}
}
for plugin in self.in_start_order(to_start) {
self.start(&plugin);
}
let (done, cv) = &self.initial_load;
*done.lock() = true;
cv.notify_all();
@@ -763,7 +742,6 @@ impl PluginHost {
}
}
if changed {
self.reconcile();
self.changed();
}
}
@@ -863,11 +841,6 @@ impl PluginHost {
} else {
plugin.meta.lock().status = PluginStatus::Disabled;
}
// Plugins that need this one stop or start with it.
self.reconcile();
if enabled {
self.restart_optional_dependents(id);
}
self.changed();
Ok(())
}
@@ -880,7 +853,6 @@ impl PluginHost {
let (dir, source, root) = (plugin.dir().to_path_buf(), plugin.source, plugin.dev_root.clone());
self.unload(id);
let new_id = self.load_dir(&dir, source, root);
self.settle_dependents(&new_id);
self.changed();
self.info_of(&new_id)
}
@@ -999,7 +971,6 @@ impl PluginHost {
.map(|p| p.meta.lock().error.clone().unwrap_or_else(|| "it failed to load".into()));
if let Some(reason) = failed {
self.roll_back(&id, &previous, &version, old_grant);
self.settle_dependents(&id);
self.changed();
return Err(IdeError::invalid(format!(
"{} {version} could not start ({reason}); version {previous} was restored",
@@ -1012,7 +983,6 @@ impl PluginHost {
}
}
install::prune(&root);
self.settle_dependents(&id);
self.changed();
self.info_of(&id)
}
@@ -1112,8 +1082,6 @@ impl PluginHost {
if !self.state.lock().disabled.contains(id) {
self.start(&plugin);
}
// Plugins that need this one follow it: running again, or blocked while it can't run.
self.settle_dependents(id);
self.changed();
self.info_of(id)
}
@@ -1134,8 +1102,6 @@ impl PluginHost {
if !self.state.lock().disabled.contains(id) {
self.start(&plugin);
}
// Plugins that need this one follow it: running again, or blocked while it can't run.
self.settle_dependents(id);
self.changed();
self.info_of(id)
}
@@ -1171,8 +1137,6 @@ impl PluginHost {
if !self.state.lock().disabled.contains(id) {
self.start(&plugin);
}
// Plugins that need this one follow it: running again, or blocked while it can't run.
self.settle_dependents(id);
self.changed();
self.info_of(id)
}
@@ -1244,8 +1208,6 @@ impl PluginHost {
if !self.state.lock().disabled.contains(id) {
self.start(&plugin);
}
// Plugins that need this one follow it: running again, or blocked while it can't run.
self.settle_dependents(id);
self.changed();
self.info_of(id)
}
@@ -1354,7 +1316,6 @@ impl PluginHost {
return Err(e.into());
}
self.unload(id);
self.reconcile();
self.changed();
Ok(())
}
@@ -1523,7 +1484,7 @@ impl PluginHost {
PluginStatus::Disabled => Some(format!("plugin {id:?} is disabled")),
PluginStatus::Static => Some(format!("plugin {id:?} has no code")),
PluginStatus::NeedsConsent => Some(format!("plugin {id:?} is waiting for your permission")),
PluginStatus::Failed | PluginStatus::Blocked => {
PluginStatus::Failed => {
Some(format!("plugin {id:?} is not running: {}", meta.error.as_deref().unwrap_or("failed")))
}
PluginStatus::Active | PluginStatus::Inactive => None,
@@ -1897,15 +1858,6 @@ impl PluginHost {
let keys = self.shared.changed_settings(&p.pkg, client);
(!keys.is_empty()).then_some(WorkbenchEvent::SettingsChanged(keys))
}
// Only the plugin that declares a point hears about its contributions.
WorkbenchEvent::ExtensionsChanged(points) => {
let own: Vec<String> = points
.iter()
.filter(|point| self.registry.point_owner(point).as_deref() == Some(p.id()))
.cloned()
.collect();
(!own.is_empty()).then_some(WorkbenchEvent::ExtensionsChanged(own))
}
_ => Some(e.clone()),
})
.collect();
@@ -2436,21 +2388,6 @@ impl PluginHost {
/// Loads one package and starts it unless disabled. Returns the plugin id.
fn load_dir(&self, dir: &Path, source: PluginSource, dev_root: Option<PathBuf>) -> String {
let (id, to_start) = self.register_dir(dir, source, dev_root);
if let Some(plugin) = to_start {
self.start(&plugin);
}
id
}
/// Reads one package and lists it as a plugin. Returns its id and the plugin
/// when it is to be started (valid, enabled, not already loaded).
fn register_dir(
&self,
dir: &Path,
source: PluginSource,
dev_root: Option<PathBuf>,
) -> (String, Option<Arc<LoadedPlugin>>) {
let (manifest, error) = match Manifest::load(dir) {
Ok(m) => (m, None),
Err(e) => {
@@ -2493,7 +2430,7 @@ impl PluginHost {
dir.display(),
existing.dir().display()
);
return (id, None);
return id;
}
plugins.insert(id.clone(), plugin.clone());
}
@@ -2502,10 +2439,10 @@ impl PluginHost {
let mut meta = plugin.meta.lock();
meta.status = PluginStatus::Failed;
meta.error = Some(e);
return (id, None);
} else if !self.state.lock().disabled.contains(&id) {
self.start(&plugin);
}
let enabled = !self.state.lock().disabled.contains(&id);
(id, enabled.then_some(plugin))
id
}
fn unload(&self, id: &str) {
@@ -2532,9 +2469,6 @@ impl PluginHost {
}
return;
}
if let Some(reason) = self.blocked_reason(plugin) {
return self.set_blocked(plugin, &reason);
}
self.registry.dispose_plugin(plugin.id());
self.clear_provider_diagnostics(plugin.id());
if plugin.source == PluginSource::Installed
@@ -2837,7 +2771,6 @@ impl PluginHost {
self.refresh_pins(&ledger);
}
self.emit(None, PluginEvent::CommandsChanged);
self.flush_extension_changes();
let languages = self.languages.revision();
if self.announced_languages.swap(languages, Ordering::AcqRel) != languages {
self.emit(None, PluginEvent::LanguagesChanged);
@@ -2996,8 +2929,6 @@ impl PluginHost {
}
fn info(&self, p: &LoadedPlugin) -> PluginInfo {
// Before `meta` below: this reads the other plugins' statuses.
let (depends, dependents, extension_points) = self.relations(p);
let m = p.manifest();
// The ledger before a plugin's `meta`, as everywhere (`refresh_pins` holds the
// ledger and locks each plugin's): the other order deadlocks.
@@ -3043,9 +2974,6 @@ impl PluginHost {
previous_version: (p.source == PluginSource::Installed)
.then(|| install::previous_version(&self.plugins_dir.join(&m.id)))
.flatten(),
depends,
dependents,
extension_points,
}
}
}
@@ -3147,8 +3075,6 @@ fn placeholder_manifest(dir: &Path) -> Manifest {
wasm: None,
activation: Vec::new(),
capabilities: Default::default(),
depends: Vec::new(),
extension_points: Vec::new(),
contributes: Default::default(),
}
}
-42
View File
@@ -43,9 +43,6 @@ pub enum PluginStatus {
NeedsConsent,
/// Failed to load, or failed repeatedly and was stopped for this session (see `error`).
Failed,
/// A plugin it requires is missing, disabled, the wrong version or cannot run
/// (`error` says which); it starts when that is resolved.
Blocked,
}
impl PluginStatus {
@@ -90,45 +87,6 @@ pub struct PluginInfo {
pub publisher: Option<String>,
/// The version an update replaced, to roll back to.
pub previous_version: Option<String>,
/// `[[depends]]`, each with how it stands now.
pub depends: Vec<DependencyInfo>,
/// Plugins that need this one, directly or through others.
pub dependents: Vec<DependentInfo>,
/// `[[extension-points]]` this plugin declares while it is live.
pub extension_points: Vec<ExtensionPointInfo>,
}
/// A plugin another one requires.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DependencyInfo {
pub id: String,
/// The installed plugin's name, when it is installed.
pub name: Option<String>,
/// The requirement as written (`^0.2`).
pub version: Option<String>,
pub optional: bool,
pub state: ide_extension::DependencyState,
}
/// A plugin that needs another one.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DependentInfo {
pub id: String,
pub name: String,
/// Switched off by the user: disabling the plugin it needs changes nothing for it.
pub enabled: bool,
}
/// An extension point a plugin declared.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ExtensionPointInfo {
pub id: String,
pub description: String,
/// Plugin ids contributing to it now.
pub contributors: Vec<String>,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, Type)]
+3 -113
View File
@@ -13,11 +13,9 @@ use ide_vfs::Watcher;
use ide_workspace::documents::DocumentObserver;
use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder};
use parking_lot::{Mutex, RwLock};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
/// Everything bound to one open project folder.
pub struct Project {
@@ -531,7 +529,7 @@ impl Host {
mark("git");
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
let (db, http_history, agents) = self.project_services(&root, &project_key, &settings);
let (db, http_history, agents) = self.project_services(&root, &project_key);
let notebooks = crate::notebook::NotebookService::new(
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
@@ -613,7 +611,6 @@ impl Host {
&self,
root: &Path,
project_key: &str,
settings: &Arc<SettingsStore>,
) -> (Arc<ide_db::DbManager>, Arc<ide_http::History>, Arc<ide_agent::AgentManager>) {
let db = Arc::new(
ide_db::DbManager::new(
@@ -643,10 +640,6 @@ impl Host {
},
self.runtime.clone(),
));
let settings = settings.clone();
agents.set_mcp_servers_hook(Box::new(move || {
ide_agent::mcp::servers_from_settings(&Default::default(), &settings.effective().agents.mcp_servers)
}));
(db, http_history, agents)
}
}
@@ -688,7 +681,7 @@ impl Host {
documents.add_observer(Arc::new(HistoryObserver(history.clone())));
let watcher = light_watcher(&dir, &sink, &documents, &history)?;
let project_key = project_key(&dir);
let (db, http_history, agents) = self.project_services(&dir, &project_key, &settings);
let (db, http_history, agents) = self.project_services(&dir, &project_key);
let notebooks = crate::notebook::NotebookService::new(
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
@@ -1204,81 +1197,9 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
extra_content_roots(settings, root, build_cache)
}
/// The roots a file may navigate into: its own root (the workspace member containing it, or
/// the project root) plus that root's configured dependencies. A workspace member depends
/// only on the roots picked per member in Settings › Project Structure › Dependencies —
/// being attached to the workspace never makes its code navigable by itself.
/// Cached navigation roots per (project, file), with the instant they were computed.
type NavigationRootsCache = HashMap<(PathBuf, PathBuf), (Instant, Vec<PathBuf>)>;
pub(crate) fn navigation_roots(project: &Project, from: &Path) -> Vec<PathBuf> {
// Hover probes and import-segment clicks repeat these lookups for the same file,
// and each uncached call re-reads the workspace file: memoize briefly. The short
// TTL keeps settings and workspace edits near-immediate.
static CACHE: Mutex<Option<NavigationRootsCache>> = Mutex::new(None);
const TTL: Duration = Duration::from_secs(1);
const CAP: usize = 256;
let key = (project.root.clone(), from.to_path_buf());
if let Some((at, roots)) = CACHE.lock().as_ref().and_then(|m| m.get(&key))
&& at.elapsed() < TTL
{
return roots.clone();
}
let roots = navigation_roots_uncached(project, from);
if let Some(map) = CACHE.lock().as_mut() {
if map.len() >= CAP {
map.retain(|_, (at, _)| at.elapsed() < TTL);
}
map.insert(key, (Instant::now(), roots.clone()));
}
roots
}
fn navigation_roots_uncached(project: &Project, from: &Path) -> Vec<PathBuf> {
let settings = project.settings.effective();
let workspace = ide_workspace::workspace::Workspace::load(&project.root).ok().flatten();
let own = workspace
.as_ref()
.and_then(|w| w.projects.iter().filter(|p| from.starts_with(p)).max_by_key(|p| p.components().count()).cloned())
.unwrap_or_else(|| project.root.clone());
let mut roots = vec![own.clone()];
let own_key = ide_core::ipc_path::to_ipc(&own);
if let Some(deps) = member_dependencies_for(&settings.project_structure.member_dependencies, &own_key) {
for dep in deps {
let dep = PathBuf::from(dep.trim());
if dep.is_absolute() && dep.is_dir() && !roots.contains(&dep) {
roots.push(dep);
}
}
}
if workspace.is_none() {
for root in content_roots(&settings, &project.root, &project.build_cache_dir()) {
if !roots.contains(&root) {
roots.push(root);
}
}
}
roots
}
/// The dependency roots configured for `own`, keyed by IPC path. The settings UI writes
/// keys as it received the member paths (IPC form); a hand-edited settings.toml may not,
/// so compare normalized rather than only by exact match.
fn member_dependencies_for<'a>(map: &'a BTreeMap<String, Vec<String>>, own_key: &str) -> Option<&'a Vec<String>> {
if let Some(deps) = map.get(own_key) {
return Some(deps);
}
map.iter().find(|(key, _)| ide_core::ipc_path::to_ipc(&PathBuf::from(key)) == own_key).map(|(_, deps)| deps)
}
fn extra_content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
let user = settings
.project_structure
.content_roots
.iter()
.chain(settings.project_structure.dependencies.iter())
.map(|r| PathBuf::from(r.trim()));
let user = settings.project_structure.content_roots.iter().map(|r| PathBuf::from(r.trim()));
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
out.push(p);
@@ -1890,34 +1811,3 @@ mod composer_tests {
assert!(!changed(&[]));
}
}
#[cfg(test)]
mod content_roots_tests {
use super::content_roots;
#[test]
#[cfg(windows)]
fn member_dependencies_match_hand_written_keys() {
let mut map = std::collections::BTreeMap::new();
map.insert("B:\\proj\\member".to_string(), vec!["B:/dep".to_string()]);
let deps = super::member_dependencies_for(&map, "B:/proj/member");
assert_eq!(deps.map(Vec::as_slice), Some(["B:/dep".to_string()].as_slice()));
}
#[test]
fn dependency_projects_join_the_content_roots() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("app");
let shared = tmp.path().join("shared");
let dep = tmp.path().join("lib");
for dir in [&root, &shared, &dep] {
std::fs::create_dir_all(dir).unwrap();
}
let mut settings = ide_core::settings::Settings::default();
settings.project_structure.content_roots = vec![shared.to_string_lossy().into_owned()];
settings.project_structure.dependencies =
vec![dep.to_string_lossy().into_owned(), root.to_string_lossy().into_owned()];
let roots = content_roots(&settings, &root, &tmp.path().join("build"));
assert_eq!(roots, vec![shared, dep]);
}
}
-5
View File
@@ -335,11 +335,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "agent_registry_list"
| "agent_registry_install"
| "agent_registry_remove"
| "agent_mcp_list"
| "agent_mcp_save"
| "agent_mcp_set_enabled"
| "agent_mcp_remove"
| "agent_mcp_import"
| "coverage_import"
| "system_font_families"
| "user_font_data"
@@ -1,284 +0,0 @@
//! Plugin dependencies and plugin-declared extension points through a real WASM owner
//! (`ide-plugin/tests/fixtures/ext-owner`) and data-only dependents.
use ide_extension::DependencyState;
use ide_host::plugins::{Consent, PluginHost, PluginInfo, PluginStatus};
use ide_plugin::{Invocation, Limits};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
const OWNER: &str = "test.ext-owner";
const POINT: &str = "test.ext-owner.items";
struct Fixture {
_tmp: tempfile::TempDir,
plugins_dir: PathBuf,
host: PluginHost,
}
impl Fixture {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let plugins_dir = root.join("plugins");
let host = Self::host_on(&plugins_dir);
host.load_all();
Self { _tmp: tmp, plugins_dir, host }
}
fn host_on(plugins_dir: &Path) -> PluginHost {
PluginHost::new(plugins_dir.to_owned(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap()
}
/// Installs the real owner plugin, optionally at another version.
fn install_owner(&self, version: &str) -> Result<PluginInfo, String> {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/ext-owner");
let pkg = tempfile::tempdir().unwrap();
std::fs::create_dir_all(pkg.path().join("dist")).unwrap();
std::fs::copy(fixture.join("dist/plugin.wasm"), pkg.path().join("dist/plugin.wasm")).unwrap();
let toml = std::fs::read_to_string(fixture.join("plugin.toml")).unwrap();
std::fs::write(
pkg.path().join("plugin.toml"),
toml.replace("version = \"0.1.0\"", &format!("version = \"{version}\"")),
)
.unwrap();
self.install_dir(pkg.path())
}
/// Installs a plugin made of a manifest only (contributions, no code).
fn install_data(&self, id: &str, version: &str, body: &str) -> Result<PluginInfo, String> {
let pkg = tempfile::tempdir().unwrap();
let manifest =
format!("id = \"{id}\"\nname = \"{id}\"\nversion = \"{version}\"\nengines.openidea = \"^1.5\"\n{body}");
std::fs::write(pkg.path().join("plugin.toml"), manifest).unwrap();
self.install_dir(pkg.path())
}
fn install_dir(&self, dir: &Path) -> Result<PluginInfo, String> {
self.host.install_from_dir(dir, &Consent::all()).map_err(|e| e.message)
}
fn contributor(&self) -> Result<PluginInfo, String> {
self.install_data(
"test.ext-contributor",
"0.2.0",
&format!(
"[[depends]]\nid = \"{OWNER}\"\nversion = \"^0.1\"\n\n[[contributes.\"{POINT}\"]]\nname = \"from-contributor\"\nweight = 7\n"
),
)
}
fn run(&self, plugin: &str, command: &str, arg: Option<&str>) -> Result<Option<String>, String> {
let inv = Invocation { client: Some("main".into()), ..Default::default() };
self.host.run_command(plugin, command, arg, inv).map_err(|e| e.message)
}
fn info(&self, id: &str) -> PluginInfo {
self.host.info_of(id).unwrap()
}
}
#[test]
fn a_dependent_contributes_data_that_reaches_the_owners_code() {
let f = Fixture::new();
f.install_owner("0.1.0").unwrap();
f.contributor().unwrap();
let dump = f.run(OWNER, "dump", None).unwrap().unwrap();
assert_eq!(dump, r#"test.ext-contributor:{"name":"from-contributor","weight":7}"#);
let owner = f.info(OWNER);
assert_eq!(owner.extension_points.len(), 1);
assert_eq!(owner.extension_points[0].id, POINT);
assert_eq!(owner.extension_points[0].contributors, ["test.ext-contributor"]);
assert_eq!(owner.dependents.iter().map(|d| d.id.as_str()).collect::<Vec<_>>(), ["test.ext-contributor"]);
let contributor = f.info("test.ext-contributor");
assert_eq!(contributor.status, PluginStatus::Static);
assert_eq!(contributor.depends.len(), 1);
assert_eq!(contributor.depends[0].state, DependencyState::Satisfied);
assert_eq!(contributor.depends[0].name.as_deref(), Some("Extension point owner"));
}
#[test]
fn a_plugin_waits_for_the_plugin_it_needs_and_starts_when_it_arrives() {
let f = Fixture::new();
let waiting = f.contributor().unwrap();
assert_eq!(waiting.status, PluginStatus::Blocked);
assert_eq!(waiting.error.as_deref(), Some("Requires test.ext-owner ^0.1, which is not installed"));
assert_eq!(waiting.depends[0].state, DependencyState::Missing);
f.install_owner("0.1.0").unwrap();
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static, "started with its dependency");
assert_eq!(f.info("test.ext-contributor").error, None);
assert!(f.run(OWNER, "dump", None).unwrap().unwrap().contains("from-contributor"));
}
#[test]
fn disabling_a_plugin_stops_what_needs_it_and_enabling_brings_it_back() {
let f = Fixture::new();
f.install_owner("0.1.0").unwrap();
f.contributor().unwrap();
f.host.set_enabled(OWNER, false).unwrap();
let blocked = f.info("test.ext-contributor");
assert_eq!(blocked.status, PluginStatus::Blocked);
assert_eq!(blocked.error.as_deref(), Some("Requires test.ext-owner, which is disabled"));
assert_eq!(blocked.depends[0].state, DependencyState::Disabled);
assert!(blocked.contributions.is_empty(), "its contributions are withdrawn");
f.host.set_enabled(OWNER, true).unwrap();
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static);
assert!(f.run(OWNER, "dump", None).unwrap().unwrap().contains("from-contributor"));
}
#[test]
fn a_version_the_requirement_does_not_accept_blocks() {
let f = Fixture::new();
f.install_owner("0.3.0").unwrap();
let info = f.contributor().unwrap();
assert_eq!(info.status, PluginStatus::Blocked);
assert_eq!(info.error.as_deref(), Some("Requires test.ext-owner ^0.1, but version 0.3.0 is installed"));
assert_eq!(info.depends[0].state, DependencyState::Version);
}
#[test]
fn updating_the_owner_checks_its_dependents_again() {
let f = Fixture::new();
f.install_owner("0.1.0").unwrap();
f.contributor().unwrap();
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static);
f.install_owner("0.3.0").unwrap();
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Blocked, "^0.1 no longer matches");
f.install_owner("0.1.5").unwrap();
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static);
}
#[test]
fn plugins_that_need_each_other_do_not_start_and_say_so() {
let f = Fixture::new();
let a = f.install_data("test.cycle-a", "1.0.0", "[[depends]]\nid = \"test.cycle-b\"\n").unwrap();
assert_eq!(a.status, PluginStatus::Blocked, "b is not installed yet");
let b = f.install_data("test.cycle-b", "1.0.0", "[[depends]]\nid = \"test.cycle-a\"\n").unwrap();
for info in [b, f.info("test.cycle-a")] {
assert_eq!(info.status, PluginStatus::Blocked, "{}", info.id);
assert_eq!(
info.error.as_deref(),
Some("Part of a dependency cycle: test.cycle-a → test.cycle-b → test.cycle-a"),
"{}",
info.id
);
}
}
#[test]
fn a_restart_of_the_app_starts_plugins_after_the_ones_they_need() {
let f = Fixture::new();
f.install_owner("0.1.0").unwrap();
f.contributor().unwrap();
f.host.shutdown();
// "test.ext-contributor" sorts before "test.ext-owner": directory order would start it first.
let again = Fixture::host_on(&f.plugins_dir);
again.load_all();
assert_eq!(again.info_of("test.ext-contributor").unwrap().status, PluginStatus::Static);
let inv = Invocation { client: Some("main".into()), ..Default::default() };
let dump = again.run_command(OWNER, "dump", None, inv).unwrap().unwrap();
assert!(dump.contains("from-contributor"), "{dump}");
}
#[test]
fn only_the_declaring_plugin_reads_a_point_and_unknown_points_are_not_found() {
let f = Fixture::new();
f.install_owner("0.1.0").unwrap();
f.install_data(
"test.other",
"1.0.0",
"[[extension-points]]\nid = \"test.other.things\"\nfields = { name = \"string\" }\n",
)
.unwrap();
assert_eq!(f.run(OWNER, "read", Some(POINT)).unwrap().as_deref(), Some("0"));
let denied = f.run(OWNER, "read", Some("test.other.things")).unwrap_err();
assert!(denied.contains("denied") || denied.contains("permission"), "{denied}");
let missing = f.run(OWNER, "read", Some("test.nobody.things")).unwrap_err();
assert!(missing.contains("no running plugin declares"), "{missing}");
}
#[test]
fn contributions_are_checked_against_the_fields_the_point_declares() {
let f = Fixture::new();
f.install_owner("0.1.0").unwrap();
let body = format!("[[depends]]\nid = \"{OWNER}\"\n\n[[contributes.\"{POINT}\"]]\nweight = 1\n");
let err = f.install_data("test.bad", "1.0.0", &body).unwrap_err();
assert!(err.contains("missing required field \"name\""), "{err}");
let body =
format!("[[depends]]\nid = \"{OWNER}\"\n\n[[contributes.\"{POINT}\"]]\nname = \"x\"\ncolour = \"red\"\n");
let err = f.install_data("test.bad", "1.0.0", &body).unwrap_err();
assert!(err.contains("unknown field \"colour\""), "{err}");
let body = format!("[[contributes.\"{POINT}\"]]\nname = \"x\"\n");
let err = f.install_data("test.bad", "1.0.0", &body).unwrap_err();
assert!(err.contains("[[depends]]"), "contributing needs the dependency: {err}");
}
/// An optional requirement never blocks, but the plugin still contributes once the plugin
/// appears (IntelliJ loads an optional `depends` part when its target is installed or enabled later).
#[test]
fn an_optional_dependent_contributes_when_its_target_appears_later() {
let f = Fixture::new();
let body = format!(
"[[depends]]\nid = \"{OWNER}\"\noptional = true\n\n[[contributes.\"{POINT}\"]]\nname = \"optional-one\"\n"
);
let early = f.install_data("test.optional", "1.0.0", &body).unwrap();
assert_eq!(early.status, PluginStatus::Static, "an optional requirement never blocks: {:?}", early.error);
assert_eq!(early.depends[0].state, DependencyState::Missing);
f.install_owner("0.1.0").unwrap();
let dump = f.run(OWNER, "dump", None).unwrap().unwrap();
assert!(dump.contains("optional-one"), "contributed once the owner appeared: {dump:?}");
// Off and on again: it is back without restarting anything.
f.host.set_enabled(OWNER, false).unwrap();
assert_eq!(f.info("test.optional").status, PluginStatus::Static, "optional: not blocked");
f.host.set_enabled(OWNER, true).unwrap();
assert!(f.run(OWNER, "dump", None).unwrap().unwrap().contains("optional-one"));
}
#[test]
fn the_owner_hears_that_contributions_changed() {
let f = Fixture::new();
f.install_owner("0.1.0").unwrap();
// The owner is running when the contribution arrives.
f.run(OWNER, "dump", None).unwrap();
f.contributor().unwrap();
f.host.flush_events();
assert!(f.host.wait_events(Duration::from_secs(10)), "the event was delivered");
let seen = f.run(OWNER, "changes", None).unwrap().unwrap();
assert!(seen.contains(POINT), "{seen}");
// Only the owner hears: a plugin that declares nothing is not woken for it.
let other = f.info("test.ext-contributor");
assert_eq!(other.stats.activations, 0);
}
#[test]
fn a_dependent_follows_its_dependency_through_permission_changes() {
let f = Fixture::new();
f.install_owner("0.1.0").unwrap();
f.contributor().unwrap();
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static);
// Revoking a permission the owner requires stops it, and what needs it with it.
f.host.set_capability(OWNER, "storage", false).unwrap();
assert_ne!(f.info(OWNER).status, PluginStatus::Active);
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Blocked);
// Granting it again starts both, without touching the dependent.
f.host.set_capability(OWNER, "storage", true).unwrap();
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static);
assert!(f.run(OWNER, "dump", None).unwrap().unwrap().contains("from-contributor"));
}
+7 -72
View File
@@ -98,44 +98,6 @@ impl PluginWorkspace for TestWorkspace {
fn projects(&self) -> Vec<PathBuf> {
std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect()
}
/// Find in Files over the fixture's files on disk.
fn search_text(
&self,
_client: Option<&str>,
root: &Path,
query: ide_plugin::TextQuery,
limit: u32,
cancel: &ide_core::CancellationToken,
) -> Result<ide_plugin::TextSearch, HostError> {
let 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,
context: Default::default(),
};
let files = Mutex::new(Vec::new());
let summary = ide_search::search(root, &query, &|_| None, cancel, &|file| files.lock().push(file))
.map_err(|e| HostError::Failed(e.to_string()))?;
let matches = files
.into_inner()
.into_iter()
.flat_map(|file| {
file.matches.into_iter().map(move |m| ide_plugin::TextMatch {
location: ide_plugin::Location {
path: PathBuf::from(&file.path.0),
range: ide_core::Range::new(ide_core::Pos::new(m.line, 0), ide_core::Pos::new(m.line, 0)),
},
line: m.text,
})
})
.collect();
Ok(ide_plugin::TextSearch { matches, limit_hit: summary.limit_hit })
}
fn buffer_text(&self, path: &Path) -> Option<String> {
if path.ends_with("event-barrier.txt") {
let gate = self.event_read_gate.lock().take();
@@ -489,29 +451,6 @@ fn read_file_is_confined_to_the_project() {
assert_eq!(f.status("misbehave"), PluginStatus::Active);
}
/// `project.read-bytes` returns the exact bytes on disk, where `read-file`
/// wants UTF-8, and follows `read-file`'s confinement rules.
#[test]
fn read_bytes_reads_the_project_file_exactly_and_stays_inside() {
let f = fixture(Limits::default());
f.install(&misbehave_pkg());
let binary = [0x25u8, 0x50, 0x44, 0x46, 0x00, 0xff, 0x1b, 0x7f];
std::fs::write(f.project.join("logo.bin"), binary).unwrap();
let hex = binary.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" ");
assert_eq!(f.run("misbehave", "read-bytes", Some("logo.bin")).unwrap().unwrap(), format!("8 {hex}"));
let abs = f.project.join("logo.bin");
assert_eq!(f.run("misbehave", "read-bytes", Some(abs.to_str().unwrap())).unwrap().unwrap(), format!("8 {hex}"));
// The same refusal and not-found behaviour as `read-file`; like every
// refusal, it is the plugin's to handle: it keeps running.
let err = f.run("misbehave", "read-bytes", Some("../secret.txt")).unwrap_err();
assert!(err.contains("outside the project root"), "{err}");
let err = f.run("misbehave", "read-bytes", Some("src/missing.bin")).unwrap_err();
assert!(err.contains("not found"), "{err}");
let err = f.run("misbehave", "read-bytes", Some("src")).unwrap_err();
assert!(err.contains("not a file"), "{err}");
assert_eq!(f.status("misbehave"), PluginStatus::Active);
}
/// `fs.read` reaches the granted paths and nothing else, through the real
/// component: the same checks the runtime makes, against a real file system.
#[test]
@@ -2801,12 +2740,9 @@ fn official_mybatis_navigates_between_interface_and_xml() {
let xml = "<mapper namespace=\"com.acme.UserMapper\">\n <select id=\"findById\">select 1</select>\n</mapper>\n";
std::fs::write(&java_path, java).unwrap();
std::fs::write(&xml_path, xml).unwrap();
// Noise: other mappers, some large, that the lookup must not read one by one.
let filler = " <select id=\"q\">select 1</select>\n".repeat(3000);
for i in 0..600 {
let body = if i % 50 == 0 { filler.as_str() } else { "" };
let other = format!("<mapper namespace=\"com.acme.Other{i}\">\n{body}</mapper>\n");
std::fs::write(f.project.join(format!("src/f{i}.xml")), other).unwrap();
// Noise the index has to skip.
for i in 0..200 {
std::fs::write(f.project.join(format!("src/f{i}.xml")), "<beans/>").unwrap();
}
let doc = |path: &Path, language: &str, text: &str| ide_plugin::ProviderDocument {
path: path.to_path_buf(),
@@ -2815,9 +2751,9 @@ fn official_mybatis_navigates_between_interface_and_xml() {
text: text.into(),
};
let jdoc = doc(&java_path, "java", java);
// The first call searches the XML files; on a slow machine that can run past the
// edit-path budget, so the answer is dropped and calls queued behind it are
// skipped. The editor asks again; so does this.
// The first call indexes the XML files; on a slow machine that runs past the
// edit-path budget, so the answer is dropped (the index is kept) and calls
// queued behind it are skipped. The editor asks again; so does this.
let deadline = Instant::now() + Duration::from_secs(10);
let markers = loop {
let markers = f.host.provide_line_markers(&jdoc);
@@ -2846,7 +2782,7 @@ fn official_mybatis_navigates_between_interface_and_xml() {
"not its language"
);
// Warm (lookup cached): well inside the edit-path budget.
// Warm (index cached): well inside the edit-path budget.
let slow = f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls;
let mut times = Vec::new();
for _ in 0..20 {
@@ -5787,7 +5723,6 @@ fn api14_denies_new_privileges_and_requires_an_invoking_window() {
("open-editors", "", "editor.read"),
("open-editor", "a.txt", "project.read"),
("search-text", "needle", "project.read"),
("read-bytes", "a.txt", "project.read"),
("problems", "", "project.read"),
("decorations", "a.txt", "project.read"),
("clear-decorations", "", "project.read"),
-19
View File
@@ -157,22 +157,3 @@ async fn gradle_init_scripts_are_written_by_the_host_that_runs_the_build() {
let escaped = ops::run::run_gradle_init_script(&host, "main", "../escape".into(), String::new()).await;
assert!(escaped.is_err(), "{escaped:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn debug_env_files_load_in_order_like_a_run() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
let first = tmp.path().join("first.env");
let second = tmp.path().join("second.env");
std::fs::write(&first, "SHARED=one\nONLY_FIRST=1\nREF=${SHARED}-suffix\n").unwrap();
std::fs::write(&second, "SHARED=two\nONLY_SECOND=2\n").unwrap();
let vars = ops::run::run_env_files(&host, "main", vec![first, second]).unwrap();
// Later files win; references resolve against earlier files, then the system environment.
assert_eq!(vars.get("SHARED").map(String::as_str), Some("two"));
assert_eq!(vars.get("ONLY_FIRST").map(String::as_str), Some("1"));
assert_eq!(vars.get("ONLY_SECOND").map(String::as_str), Some("2"));
assert_eq!(vars.get("REF").map(String::as_str), Some("one-suffix"));
// An unreadable file is an error, like a run that names a missing `.env`.
let missing = ops::run::run_env_files(&host, "main", vec![tmp.path().join("nope.env")]);
assert!(missing.is_err(), "{missing:?}");
}
-1
View File
@@ -7,7 +7,6 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
ide-vfs.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
-294
View File
@@ -1,294 +0,0 @@
//! cURL ⇄ `.http` request: Convert to cURL and Copy, and the conversion of a pasted
//! `curl …` command into a request.
use crate::PreparedRequest;
/// `req` as a cURL command line, one option per line (POSIX shell quoting).
pub fn to_curl(req: &PreparedRequest) -> String {
let mut parts = vec![format!("curl -X {} {}", req.method, quote(&req.url))];
for (k, v) in &req.headers {
parts.push(format!("-H {}", quote(&format!("{k}: {v}"))));
}
if let Some(body) = &req.body {
parts.push(format!("--data-raw {}", quote(body)));
}
parts.join(" \\\n ")
}
/// `s` in single quotes, as POSIX shells read it.
fn quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
/// The request a `curl …` command sends, as `.http` text; `None` when `cmd` is not a cURL
/// command or names no URL.
pub fn from_curl(cmd: &str) -> Option<String> {
let words = split_words(cmd)?;
let mut args = words.iter().map(String::as_str);
if args.next() != Some("curl") {
return None;
}
let mut method: Option<String> = None;
let mut url: Option<String> = None;
let mut headers: Vec<(String, String)> = Vec::new();
let mut data: Vec<String> = Vec::new();
let mut form: Vec<(String, String)> = Vec::new();
let mut get = false;
let header = |headers: &mut Vec<(String, String)>, line: &str| {
if let Some((k, v)) = line.split_once(':') {
headers.push((k.trim().to_owned(), v.trim().to_owned()));
}
};
while let Some(arg) = args.next() {
// `--opt=value` and `-Xvalue` forms.
let (flag, inline) = match arg.split_once('=') {
Some((f, v)) if arg.starts_with("--") => (f, Some(v.to_owned())),
_ if arg.len() > 2 && arg.starts_with('-') && !arg.starts_with("--") && takes_value(&arg[..2]) => {
(&arg[..2], Some(arg[2..].to_owned()))
}
_ => (arg, None),
};
let mut value = || inline.clone().or_else(|| args.next().map(str::to_owned));
match flag {
"-X" | "--request" => method = value(),
"-H" | "--header" => header(&mut headers, &value()?),
"-d" | "--data" | "--data-raw" | "--data-binary" | "--data-ascii" | "--data-urlencode" => {
data.push(value()?)
}
"--json" => {
data.push(value()?);
headers.push(("Content-Type".into(), "application/json".into()));
headers.push(("Accept".into(), "application/json".into()));
}
"-F" | "--form" | "--form-string" => {
let v = value()?;
let (k, v) = v.split_once('=')?;
form.push((k.to_owned(), v.to_owned()));
}
"-u" | "--user" => {
let v = value()?;
let (user, pass) = v.split_once(':').unwrap_or((&v, ""));
headers.push(("Authorization".into(), format!("Basic {user} {pass}").trim_end().to_owned()));
}
"-b" | "--cookie" => headers.push(("Cookie".into(), value()?)),
"-A" | "--user-agent" => headers.push(("User-Agent".into(), value()?)),
"-e" | "--referer" => headers.push(("Referer".into(), value()?)),
"--url" => url = value(),
"-G" | "--get" => get = true,
f if f.starts_with('-') => {
// Options with a value the request doesn't need (timeouts, output, proxy…).
if takes_value(f) {
value();
}
}
u => {
if url.is_none() {
url = Some(u.to_owned());
}
}
}
}
let mut url = url?;
if get && !data.is_empty() {
url.push(if url.contains('?') { '&' } else { '?' });
url.push_str(&data.join("&"));
data.clear();
}
let method = method.unwrap_or_else(|| if data.is_empty() && form.is_empty() { "GET" } else { "POST" }.into());
let mut body = None;
if !form.is_empty() {
const BOUNDARY: &str = "WebAppBoundary";
headers.retain(|(k, _)| !k.eq_ignore_ascii_case("content-type"));
headers.push(("Content-Type".into(), format!("multipart/form-data; boundary={BOUNDARY}")));
let mut text = String::new();
for (name, value) in &form {
text.push_str(&format!("--{BOUNDARY}\n"));
match value.strip_prefix('@') {
Some(file) => {
let file = file.split(';').next().unwrap_or(file);
let base = file.rsplit(['/', '\\']).next().unwrap_or(file);
text.push_str(&format!(
"Content-Disposition: form-data; name=\"{name}\"; filename=\"{base}\"\n\n< {file}\n"
));
}
None => text.push_str(&format!("Content-Disposition: form-data; name=\"{name}\"\n\n{value}\n")),
}
}
text.push_str(&format!("--{BOUNDARY}--"));
body = Some(text);
} else if !data.is_empty() {
if !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type")) {
headers.push(("Content-Type".into(), "application/x-www-form-urlencoded".into()));
}
body = Some(data.join("&"));
}
let mut out = format!("{method} {url}\n");
for (k, v) in &headers {
out.push_str(&format!("{k}: {v}\n"));
}
if let Some(body) = body {
out.push('\n');
out.push_str(&body);
out.push('\n');
}
Some(out)
}
/// Options that take a value, so it isn't read as the URL.
fn takes_value(flag: &str) -> bool {
matches!(
flag,
"-X" | "-H"
| "-d"
| "-F"
| "-u"
| "-b"
| "-A"
| "-e"
| "-o"
| "-x"
| "-m"
| "-c"
| "-E"
| "-T"
| "-w"
| "-r"
| "-K"
| "--output"
| "--proxy"
| "--max-time"
| "--connect-timeout"
| "--cookie-jar"
| "--cert"
| "--key"
| "--cacert"
| "--upload-file"
| "--write-out"
| "--range"
| "--config"
| "--retry"
| "--resolve"
| "--interface"
)
}
/// Shell words of `cmd`: single and double quotes, backslash escapes and line continuations
/// (also `^` / backtick continuations from Windows shells). `None` for an unclosed quote.
fn split_words(cmd: &str) -> Option<Vec<String>> {
let mut words = Vec::new();
let mut cur = String::new();
let mut in_word = false;
let mut chars = cmd.trim().chars().peekable();
while let Some(c) = chars.next() {
match c {
'\'' => {
in_word = true;
loop {
match chars.next()? {
'\'' => break,
c => cur.push(c),
}
}
}
'"' => {
in_word = true;
loop {
match chars.next()? {
'"' => break,
'\\' if matches!(chars.peek(), Some('"' | '\\' | '$' | '`')) => cur.push(chars.next()?),
c => cur.push(c),
}
}
}
'\\' | '^' | '`' if matches!(chars.peek(), Some('\n' | '\r')) => {
while matches!(chars.peek(), Some('\n' | '\r')) {
chars.next();
}
}
'\\' => {
in_word = true;
if let Some(n) = chars.next() {
cur.push(n);
}
}
c if c.is_whitespace() => {
if in_word {
words.push(std::mem::take(&mut cur));
in_word = false;
}
}
c => {
in_word = true;
cur.push(c);
}
}
}
if in_word {
words.push(cur);
}
Some(words)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_prepared_request_becomes_a_quoted_curl_command() {
let req = PreparedRequest {
name: None,
method: "POST".into(),
url: "https://api.example.com/users?q=a b".into(),
headers: vec![("Content-Type".into(), "application/json".into())],
body: Some("{\"name\": \"O'Brien\"}".into()),
};
assert_eq!(
to_curl(&req),
"curl -X POST 'https://api.example.com/users?q=a b' \\\n -H 'Content-Type: application/json' \\\n --data-raw '{\"name\": \"O'\\''Brien\"}'"
);
// And back.
let http = from_curl(&to_curl(&req)).unwrap();
assert_eq!(
http,
"POST https://api.example.com/users?q=a b\nContent-Type: application/json\n\n{\"name\": \"O'Brien\"}\n"
);
}
#[test]
fn a_browser_copied_command_becomes_a_request() {
let cmd = r#"curl 'https://example.com/api/items' \
-H 'accept: application/json' \
-H "authorization: Bearer abc" \
--data-raw '{"a":1}' \
--compressed -k -s"#;
assert_eq!(
from_curl(cmd).unwrap(),
"POST https://example.com/api/items\naccept: application/json\nauthorization: Bearer abc\nContent-Type: application/x-www-form-urlencoded\n\n{\"a\":1}\n"
);
}
#[test]
fn options_map_to_headers_query_and_multipart() {
assert_eq!(
from_curl("curl -G https://x.dev/s -d q=1 -d page=2 -u bob:secret -A agent --max-time 5").unwrap(),
"GET https://x.dev/s?q=1&page=2\nAuthorization: Basic bob secret\nUser-Agent: agent\n"
);
assert_eq!(
from_curl("curl -XPUT --url=https://x.dev/a --json '{}'").unwrap(),
"PUT https://x.dev/a\nContent-Type: application/json\nAccept: application/json\n\n{}\n"
);
assert_eq!(
from_curl("curl https://x.dev/up -F name=doc -F file=@/tmp/a.txt").unwrap(),
"POST https://x.dev/up\nContent-Type: multipart/form-data; boundary=WebAppBoundary\n\n--WebAppBoundary\nContent-Disposition: form-data; name=\"name\"\n\ndoc\n--WebAppBoundary\nContent-Disposition: form-data; name=\"file\"; filename=\"a.txt\"\n\n< /tmp/a.txt\n--WebAppBoundary--\n"
);
// Windows `cmd` continuation.
assert_eq!(from_curl("curl ^\n \"https://x.dev\"").unwrap(), "GET https://x.dev\n");
}
#[test]
fn other_text_is_not_a_curl_command() {
assert_eq!(from_curl("GET https://x.dev"), None);
assert_eq!(from_curl("curl -H 'a: b'"), None, "no URL");
assert_eq!(from_curl("curl 'https://x.dev"), None, "unclosed quote");
}
}
+2 -55
View File
@@ -52,8 +52,6 @@ fn search_dirs(file: Option<&Path>, root: Option<&Path>) -> Vec<PathBuf> {
}
fn parse_env(text: &str) -> IdeResult<EnvMap> {
// Editors on Windows (Notepad) save UTF-8 with a byte order mark, which serde_json rejects.
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let value: serde_json::Value =
serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("invalid environment file: {e}")))?;
let mut out = EnvMap::new();
@@ -76,35 +74,16 @@ fn parse_env(text: &str) -> IdeResult<EnvMap> {
Ok(out)
}
/// The nearest `name` in `dirs`, decoded like the editor opens it (a BOM, UTF-16, or a
/// legacy encoding such as GBK from Notepad's ANSI), so a file that shows fine in the editor
/// is never skipped as unreadable.
fn read_first(dirs: &[PathBuf], name: &str) -> IdeResult<EnvMap> {
for d in dirs {
let p = d.join(name);
if !p.is_file() {
continue;
if let Ok(text) = std::fs::read_to_string(&p) {
return parse_env(&text).map_err(|e| IdeError::invalid(format!("{}: {}", p.display(), e.message)));
}
let text = ide_vfs::load_text(&p).map_err(|e| IdeError::invalid(format!("{}: {}", p.display(), e.message)))?;
return parse_env(&text.text).map_err(|e| IdeError::invalid(format!("{}: {}", p.display(), e.message)));
}
Ok(EnvMap::new())
}
/// The public (or private) environment file that applies to `file`: the nearest existing one,
/// else a new one next to `file` holding a sample environment to fill in.
pub fn environment_file(file: &Path, root: Option<&Path>, private: bool) -> IdeResult<PathBuf> {
let name = if private { PRIVATE_FILE } else { PUBLIC_FILE };
if let Some(found) = search_dirs(Some(file), root).into_iter().map(|d| d.join(name)).find(|p| p.is_file()) {
return Ok(found);
}
let dir = file.parent().ok_or_else(|| IdeError::invalid("the request file has no folder"))?;
let path = dir.join(name);
std::fs::write(&path, "{\n \"dev\": {\n \"name\": \"value\"\n }\n}\n")
.map_err(|e| IdeError::internal(format!("cannot create {}: {e}", path.display())))?;
Ok(path)
}
impl Environments {
pub fn load(file: Option<&Path>, root: Option<&Path>) -> IdeResult<Self> {
let dirs = search_dirs(file, root);
@@ -283,36 +262,4 @@ mod tests {
let envs = Environments::load(Some(&other.path().join("s.http")), Some(root)).unwrap();
assert_eq!(envs.names(), vec!["dev"]);
}
#[test]
fn reads_env_files_in_the_encodings_windows_editors_save() {
let utf16: Vec<u8> = [0xFF, 0xFE]
.into_iter()
.chain("{\"dev\":{\"name\":\"测试环境\"}}".encode_utf16().flat_map(u16::to_le_bytes))
.collect();
// Notepad's "ANSI" on a Chinese system: GBK.
let gbk: Vec<u8> =
[&b"{\"dev\":{\"name\":\""[..], &[0xB2, 0xE2, 0xCA, 0xD4, 0xBB, 0xB7, 0xBE, 0xB3], b"\"}}"].concat();
for bytes in ["\u{feff}{\"dev\":{\"name\":\"测试环境\"}}".as_bytes().to_vec(), utf16, gbk] {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(PUBLIC_FILE), &bytes).unwrap();
let envs = Environments::load(Some(&tmp.path().join("a.http")), Some(tmp.path())).unwrap();
assert_eq!(envs.names(), vec!["dev"]);
assert_eq!(envs.variables(Some("dev"))["name"], "测试环境");
}
}
#[test]
fn an_environment_file_is_found_up_the_tree_or_created_next_to_the_request() {
let tmp = tempfile::tempdir().unwrap();
let api = tmp.path().join("api");
std::fs::create_dir_all(&api).unwrap();
let request = api.join("a.http");
std::fs::write(tmp.path().join(PUBLIC_FILE), "{}").unwrap();
assert_eq!(environment_file(&request, Some(tmp.path()), false).unwrap(), tmp.path().join(PUBLIC_FILE));
let private = environment_file(&request, Some(tmp.path()), true).unwrap();
assert_eq!(private, api.join(PRIVATE_FILE));
let envs = Environments::load(Some(&request), Some(tmp.path())).unwrap();
assert_eq!(envs.names(), vec!["dev"], "the new file is a valid environment file");
}
}
+7 -18
View File
@@ -6,7 +6,6 @@
//! of the backend, with the proxy of System Settings › HTTP Proxy.
pub mod client;
pub mod curl;
pub mod env;
pub mod exec;
pub mod history;
@@ -33,7 +32,13 @@ pub struct RunInput<'a> {
/// Prepares and sends request `index` of `text`. Preparation errors (bad
/// variables, missing includes) fail; transport errors are part of the exchange.
pub async fn run(input: RunInput<'_>, cancel: &CancellationToken) -> IdeResult<HttpExchange> {
let request = prepare(&input)?;
let parsed = parse::parse(input.text);
let raw = parsed
.requests
.get(input.index as usize)
.ok_or_else(|| IdeError::not_found(format!("no request #{}", input.index + 1)))?;
let envs = Environments::load(input.file, input.root)?;
let request = exec::prepare(raw, &parsed.variables, &envs, input.env, input.file.and_then(Path::parent))?;
let time_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64() * 1000.0)
@@ -65,22 +70,6 @@ pub async fn run(input: RunInput<'_>, cancel: &CancellationToken) -> IdeResult<H
})
}
/// Request `index` of `text` with its variables substituted and includes read.
pub fn prepare(input: &RunInput<'_>) -> IdeResult<PreparedRequest> {
let parsed = parse::parse(input.text);
let raw = parsed
.requests
.get(input.index as usize)
.ok_or_else(|| IdeError::not_found(format!("no request #{}", input.index + 1)))?;
let envs = Environments::load(input.file, input.root)?;
exec::prepare(raw, &parsed.variables, &envs, input.env, input.file.and_then(Path::parent))
}
/// Convert to cURL and Copy: request `index` of `text`, resolved, as a cURL command.
pub fn curl(input: &RunInput<'_>) -> IdeResult<String> {
prepare(input).map(|r| curl::to_curl(&r))
}
/// Environment names available to `file`.
pub fn environments(file: Option<&Path>, root: Option<&Path>) -> IdeResult<Vec<String>> {
Ok(Environments::load(file, root)?.names())
-13
View File
@@ -319,19 +319,6 @@ impl ProjectIndex {
matching::symbols_named(&self.shared.data.read(), name, kind, limit)
}
/// Symbols named exactly `name` (of `kind`, if given) whose file is inside one of `roots`
/// (the project root plus its configured dependency roots): answers bounded by the cap
/// never evict matches from a root because another root filled it first.
pub fn symbols_named_within(
&self,
name: &str,
kind: Option<SymbolKind>,
roots: &[PathBuf],
limit: usize,
) -> Vec<SymbolMatch> {
matching::symbols_named_within(&self.shared.data.read(), name, kind, roots, limit)
}
/// Incrementally re-indexes changed paths (called from the vfs watcher).
/// Cheap: the work is queued for the indexing thread.
pub fn update(&self, changed: &[PathBuf], removed: &[PathBuf]) {
-15
View File
@@ -526,26 +526,11 @@ pub(crate) fn find_symbols_until(
/// Symbols named exactly `name` (and of `kind`, when given), by path then position.
pub(crate) fn symbols_named(data: &IndexData, name: &str, kind: Option<SymbolKind>, limit: usize) -> Vec<SymbolMatch> {
symbols_named_within(data, name, kind, &[], limit)
}
/// Symbols named exactly `name` (and of `kind`, when given) whose file is inside one of
/// `roots` (empty = every indexed file), by path then position. Filtering while walking
/// entries keeps symbols outside the roots from materialising or filling the cap.
pub(crate) fn symbols_named_within(
data: &IndexData,
name: &str,
kind: Option<SymbolKind>,
roots: &[PathBuf],
limit: usize,
) -> Vec<SymbolMatch> {
let mask = char_mask(name);
let within = |e: &FileEntry| roots.is_empty() || roots.iter().any(|root| e.path.starts_with(root));
let mut out: Vec<SymbolMatch> = data
.files
.iter()
.flatten()
.filter(|e| within(e))
.flat_map(|e| {
e.symbols.iter().filter(|s| s.mask == mask && &*s.name == name && kind.is_none_or(|k| s.kind == k)).map(
move |s| SymbolMatch {
-21
View File
@@ -528,27 +528,6 @@ fn css_selectors_are_found_by_exact_name() {
assert!(idx.symbols_named(".card", Some(SymbolKind::Field), 10).is_empty());
}
#[test]
fn symbols_named_within_keeps_out_of_root_matches_from_evicting_a_root() {
let f = Fixture::new(&[
("a/w.rs", "struct Decoder;\nstruct Decoder;\n"),
("b/x.rs", "struct Decoder;\nstruct Decoder;\nstruct Decoder;\n"),
("dep/b.rs", "struct Decoder;\n"),
]);
let idx = f.open();
let own = f.root.join("a");
let dep = f.root.join("dep");
// Unfiltered, the cap fills in rel-path order — own root, then the out-of-root
// content root b/ — so dep/ is evicted before the caller's root filter runs.
let unfiltered = idx.symbols_named("Decoder", None, 3);
let unfiltered: Vec<&str> = unfiltered.iter().map(|m| m.rel_path.as_ref()).collect();
assert_eq!(unfiltered, vec!["a/w.rs", "a/w.rs", "b/x.rs"]);
let found: Vec<String> =
idx.symbols_named_within("Decoder", None, &[own, dep], 3).into_iter().map(|m| m.rel_path).collect();
assert_eq!(found, vec!["a/w.rs".to_string(), "a/w.rs".to_string(), "dep/b.rs".to_string()]);
assert!(idx.symbols_named_within("Decoder", None, &[f.root.join("none")], 3).is_empty());
}
#[test]
fn content_roots_and_excludes_change_without_reopening() {
let fx = Fixture::new(&[("src/main.rs", "fn alpha() {}\n"), ("src/gen/out.rs", "fn generated() {}\n")]);
-4
View File
@@ -10,10 +10,6 @@ use specta::Type;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Pinned Compose Specification JSON Schema URL associated with `yaml-language-server`.
pub const COMPOSE_SCHEMA_URL: &str =
"https://raw.githubusercontent.com/compose-spec/compose-go/v2.16.2/schema/compose-spec.json";
mod markers;
pub mod plugin_point;
mod registry;
+3 -14
View File
@@ -41,15 +41,11 @@ pub fn arduino_sketch_root(project: &Path, path: &Path) -> Option<PathBuf> {
}
/// `dir` contains `marker`: a relative path, a file name pattern with one
/// `*` (`*.csproj`), or a TOML or JSON file name (no pattern) and a dotted table
/// it must contain (`pyproject.toml#tool.ruff`, `package.json#eslintConfig`).
/// `*` (`*.csproj`), or a TOML file name (no pattern) and a dotted table it
/// must contain (`pyproject.toml#tool.ruff`).
fn has_marker(dir: &Path, marker: &str) -> bool {
if let Some((file, table)) = marker.split_once('#') {
return if file.ends_with(".json") {
has_json_object(&dir.join(file), table)
} else {
has_toml_table(&dir.join(file), table)
};
return has_toml_table(&dir.join(file), table);
}
let Some((prefix, suffix)) = marker.split_once('*') else {
return dir.join(marker).exists();
@@ -72,13 +68,6 @@ fn has_toml_table(file: &Path, table: &str) -> bool {
table.split('.').try_fold(&value, |v, key| v.get(key)).is_some_and(toml::Value::is_table)
}
/// `file` parses as JSON and has the dotted object `key` (`eslintConfig`).
fn has_json_object(file: &Path, key: &str) -> bool {
let Ok(text) = std::fs::read_to_string(file) else { return false };
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return false };
key.split('.').try_fold(&value, |v, key| v.get(key)).is_some_and(serde_json::Value::is_object)
}
/// `path` is (or, after its creation or removal, was) a root marker of the
/// directory above it.
pub(crate) fn is_marker_path(path: &Path, marker: &str) -> bool {
+1 -33
View File
@@ -672,10 +672,7 @@ mod tests {
};
// After the TypeScript fallback (an available server before it would hide
// it); before the HTML server (hover, rename and symbols take the first).
assert_eq!(
names("typescript"),
["vtsls", "typescript-language-server", "angular-language-server", "oxlint", "eslint"]
);
assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server", "oxlint"]);
assert_eq!(names("html"), ["angular-language-server", "vscode-html-language-server"]);
for lang in ["typescript", "html"] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
@@ -703,33 +700,6 @@ mod tests {
}
}
#[test]
fn eslint_serves_js_files_only_below_its_config() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for dir in ["apps/web/src", "apps/admin/src", "apps/api"] {
std::fs::create_dir_all(root.join(dir)).unwrap();
}
std::fs::write(root.join("apps/web/eslint.config.mjs"), "export default [];").unwrap();
std::fs::write(root.join("apps/admin/package.json"), r#"{"eslintConfig": {"root": true}}"#).unwrap();
std::fs::write(root.join("apps/api/package.json"), r#"{"name": "api"}"#).unwrap();
for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte", "astro"] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
let eslint = cfg.language_servers.iter().find(|s| s.name == "eslint").unwrap();
assert_eq!(eslint.command, "vscode-eslint-language-server", "{lang}");
assert!(eslint.require_root_marker && !eslint.fallback, "{lang}");
let settings = eslint.settings.as_ref().unwrap();
assert_eq!(settings.get("validate"), Some(&"on".into()), "{lang}");
// The server reads `experimental.useFlatConfig` and fails every request without the object.
assert!(settings.get("experimental").is_some_and(serde_json::Value::is_object), "{lang}");
let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &eslint.root_markers);
assert_eq!(served("apps/web/src/main.ts"), Some(root.join("apps/web")), "{lang}");
assert_eq!(served("apps/admin/src/main.ts"), Some(root.join("apps/admin")), "{lang}");
assert_eq!(served("apps/api/main.ts"), None, "{lang}: package.json without eslintConfig");
}
}
#[test]
fn laravel_lsp_serves_php_files_only_below_artisan() {
let reg = reg();
@@ -739,8 +709,6 @@ mod tests {
assert_eq!(names, ["intelephense", "phpactor", "laravel-lsp"]);
let lsp = cfg.language_servers.iter().find(|s| s.name == "laravel-lsp").unwrap();
assert_eq!(lsp.command.as_str(), "laravel-lsp");
// Its `lsp` command takes no options and speaks over stdio; `--stdio` stops it at startup.
assert!(lsp.args.is_empty(), "{:?}", lsp.args);
assert!(lsp.require_root_marker && !lsp.fallback);
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
-287
View File
@@ -1,287 +0,0 @@
//! Docker Compose content detection and `yaml-language-server` schema association
//! for open YAML documents whose filename does not already match `*compose*.y(a)ml`.
use ide_language::{COMPOSE_SCHEMA_URL, LanguageServerConfig};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
pub const SERVER: &str = "yaml-language-server";
const TOP_SECTIONS: &[&str] = &["version", "name", "networks", "volumes", "configs", "secrets", "include"];
const SERVICE_KEYS: &[&str] = &[
"image",
"build",
"container_name",
"hostname",
"domainname",
"ports",
"expose",
"environment",
"env_file",
"volumes",
"volumes_from",
"networks",
"network_mode",
"depends_on",
"command",
"entrypoint",
"restart",
"pull_policy",
"healthcheck",
"deploy",
"develop",
"labels",
"annotations",
"platform",
"profiles",
"user",
"working_dir",
"privileged",
"read_only",
"stdin_open",
"tty",
"init",
"cap_add",
"cap_drop",
"cgroup",
"cgroup_parent",
"configs",
"secrets",
"cpus",
"cpu_count",
"cpu_percent",
"cpu_shares",
"cpu_period",
"cpu_quota",
"cpuset",
"devices",
"dns",
"dns_opt",
"dns_search",
"extends",
"external_links",
"extra_hosts",
"group_add",
"ipc",
"isolation",
"links",
"logging",
"mac_address",
"mem_limit",
"mem_reservation",
"oom_kill_disable",
"oom_score_adj",
"pid",
"pids_limit",
"post_start",
"pre_stop",
"runtime",
"scale",
"security_opt",
"shm_size",
"stop_grace_period",
"stop_signal",
"storage_opt",
"sysctls",
"tmpfs",
"ulimits",
"userns_mode",
"uts",
];
fn parse_key(trimmed: &str) -> Option<&str> {
let colon = trimmed.find(':')?;
let after = &trimmed[colon + 1..];
if !after.is_empty() && !after.starts_with(char::is_whitespace) {
return None;
}
let key = trimmed[..colon].trim_end();
let key = key
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| key.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
.unwrap_or(key);
(!key.is_empty() && key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')))
.then_some(key)
}
/// Whether `path`'s filename already matches the Compose schema globs in `languages/yaml.toml`.
pub fn is_named_compose_file(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return false };
let Some(stem) = name.strip_suffix(".yaml").or_else(|| name.strip_suffix(".yml")) else {
return false;
};
// `<head>` or `<head>.*`, where `<head>` is `compose`, `docker-compose`, `*-compose` or `*.compose`.
let is_head = |head: &str| {
head == "compose"
|| head == "docker-compose"
|| head.strip_suffix("-compose").is_some_and(|p| !p.is_empty())
|| head.strip_suffix(".compose").is_some_and(|p| !p.is_empty())
};
is_head(stem) || stem.match_indices('.').any(|(i, _)| i + 1 < stem.len() && is_head(&stem[..i]))
}
/// Whether `path` is a YAML file whose name does not match a Compose glob, but whose content is Compose.
pub fn is_content_detected_compose_file(path: &Path, text: &str) -> bool {
!is_named_compose_file(path) && is_compose_content(text)
}
/// Whether `text` looks like a Docker Compose YAML configuration.
pub fn is_compose_content(text: &str) -> bool {
let mut has_services = false;
let mut has_compose_top = false;
let mut has_api_version = false;
let mut has_kind = false;
let mut has_on = false;
let mut has_jobs = false;
let mut in_services = false;
let mut service_indent = None;
let mut prop_indent = None;
let mut has_service_prop = false;
for raw in text.lines().take(600) {
let line = raw.trim_end_matches('\r');
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let lead = line.len() - trimmed.len();
if lead == 0 {
in_services = false;
if let Some(key) = parse_key(trimmed) {
if matches!(key, "openapi" | "swagger" | "AWSTemplateFormatVersion") {
return false;
}
has_api_version |= key == "apiVersion";
has_kind |= key == "kind";
has_on |= key == "on";
has_jobs |= key == "jobs";
if (has_api_version && has_kind) || (has_on && has_jobs) {
return false;
}
if key == "services" {
has_services = true;
in_services = true;
service_indent = None;
prop_indent = None;
} else if TOP_SECTIONS.contains(&key) || key.starts_with("x-") {
has_compose_top = true;
}
}
continue;
}
if in_services && !has_service_prop {
let svc_indent = *service_indent.get_or_insert(lead);
if lead == svc_indent {
prop_indent = None;
} else if lead > svc_indent {
let p_indent = *prop_indent.get_or_insert(lead);
if lead == p_indent
&& let Some(prop) = parse_key(trimmed)
&& (SERVICE_KEYS.contains(&prop) || prop.starts_with("x-"))
{
has_service_prop = true;
}
}
}
}
has_services && (has_compose_top || has_service_prop)
}
/// Appends open content-detected Compose files to `yaml-language-server`'s
/// `yaml.schemas[COMPOSE_SCHEMA_URL]` unless excluded by user settings.
pub fn configure_yaml_compose_schemas(cfg: &mut LanguageServerConfig, compose_files: &[PathBuf]) {
if cfg.name != SERVER || compose_files.is_empty() {
return;
}
let mut settings = cfg.settings.take().filter(Value::is_object).unwrap_or_else(|| json!({}));
let schemas = settings
.as_object_mut()
.expect("object")
.entry("yaml")
.or_insert_with(|| json!({}))
.as_object_mut()
.and_then(|yaml| yaml.entry("schemas").or_insert_with(|| json!({})).as_object_mut());
if let Some(schemas) = schemas {
let entry = schemas.entry(COMPOSE_SCHEMA_URL).or_insert_with(|| json!([]));
if let Value::String(single) = entry {
*entry = json!([single.clone()]);
}
if let Some(list) = entry.as_array_mut() {
for path in compose_files {
let p = path.to_string_lossy().replace('\\', "/");
let negated = format!("!{p}");
if list.iter().any(|v| v.as_str() == Some(&p) || v.as_str() == Some(&negated)) {
continue;
}
list.push(Value::String(p));
}
}
}
cfg.settings = Some(settings);
}
#[cfg(test)]
mod tests {
use super::*;
use ide_language::LanguageRegistry;
#[test]
fn detects_compose_content_and_ignores_other_manifests() {
assert!(is_compose_content("services:\n db:\n image: postgres:16\n"));
assert!(is_compose_content("version: \"3.9\"\nservices:\n app:\n foo: bar\n"));
assert!(!is_compose_content("apiVersion: v1\nkind: Service\nservices:\n db:\n image: x\n"));
assert!(!is_compose_content("on: push\njobs:\n build:\n runs-on: ubuntu-latest\n"));
assert!(is_named_compose_file(Path::new("/work/docker-compose.yml")));
assert!(is_named_compose_file(Path::new("/work/playground-compose.yaml")));
assert!(!is_named_compose_file(Path::new("/work/custom-stack.yml")));
for named in
["compose.yaml", "compose.override.yml", "docker-compose.prod.yaml", "a.compose.yml", "x-compose.ci.yml"]
{
assert!(is_named_compose_file(Path::new(named)), "{named}");
}
for unnamed in ["docker-compose-dev.yml", "compose-prod.yaml", "composer.yml", "decompose.yaml", "-compose.yml"]
{
assert!(!is_named_compose_file(Path::new(unnamed)), "{unnamed}");
}
assert!(is_content_detected_compose_file(
Path::new("/work/docker-compose-dev.yml"),
"services:\n db:\n image: postgres:16\n"
));
assert!(!is_content_detected_compose_file(
Path::new("/work/docker-compose.yml"),
"services:\n db:\n image: postgres:16\n"
));
assert!(is_content_detected_compose_file(
Path::new("/work/custom-stack.yml"),
"services:\n db:\n image: postgres:16\n"
));
}
#[test]
fn merges_content_detected_files_into_yaml_schemas_unless_excluded() {
let reg = LanguageRegistry::load(None).unwrap();
let yaml = reg.get(&ide_core::LanguageId::new("yaml")).unwrap();
let mut cfg = yaml.language_servers[0].clone();
let a = PathBuf::from("/work/custom-stack.yml");
let b = PathBuf::from("/work/excluded.yml");
cfg.settings.as_mut().unwrap()["yaml"]["schemas"][COMPOSE_SCHEMA_URL]
.as_array_mut()
.unwrap()
.push(json!("!/work/excluded.yml"));
configure_yaml_compose_schemas(&mut cfg, &[a, b]);
let list = cfg.settings.as_ref().unwrap()["yaml"]["schemas"][COMPOSE_SCHEMA_URL]
.as_array()
.unwrap()
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>();
assert!(list.contains(&"compose.yml"));
assert!(list.contains(&"*-compose.yml"));
assert!(list.contains(&"/work/custom-stack.yml"));
assert!(!list.contains(&"/work/excluded.yml"));
assert!(list.contains(&"!/work/excluded.yml"));
}
}
-15
View File
@@ -139,16 +139,6 @@ impl ClientHandler {
}
};
let Some(path) = uri_to_path(params.uri.as_str()) else { return };
if self.server.name == "jdtls"
&& let Some(body) = crate::jdtls::build_plan_failure(&path, &self.server.root, &params.diagnostics)
&& mgr.first_message(&self.server.name, &body)
{
mgr.sink().emit(ide_core::IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Error,
title: "Maven project could not be configured".into(),
body: Some(body),
});
}
if !mgr.accepts_injected_diagnostics(&path, params.version) {
return;
}
@@ -313,7 +303,6 @@ impl Handler for ClientHandler {
self.progress(&mgr, json!({ "token": ANGULAR_LOADING, "value": { "kind": "end" } }))
}
"telemetry/event" | "$/logTrace" | "$/cancelRequest" => {}
"eslint/status" | "eslint/probeFailed" | "eslint/exitCalled" => {}
other => tracing::trace!("[{}] unhandled notification {other}", self.server.name),
}
}
@@ -354,10 +343,6 @@ impl Handler for ClientHandler {
Ok(Value::Null)
}
"workspace/inlineValue/refresh" => Ok(Value::Null),
// vscode-eslint asks its client before using the project's eslint (approved: 4) and
// reports a missing library or configuration; diagnostics already say what's wrong.
"eslint/confirmESLintExecution" => Ok(json!(4)),
"eslint/noLibrary" | "eslint/noConfig" | "eslint/openDoc" => Ok(json!({})),
other => Err((codes::METHOD_NOT_FOUND, format!("unsupported method {other}"))),
}
}
-110
View File
@@ -478,17 +478,6 @@ pub fn parse_classpaths(v: &Value) -> JavaClasspath {
}
}
/// Folders of `cp` inside the project that don't exist: build outputs deleted outside
/// the IDE. Archives and folders outside the project (dependencies) are left out.
fn missing_outputs(cp: &JavaClasspath, project_root: &Path) -> Vec<PathBuf> {
cp.class_paths
.iter()
.chain(&cp.module_paths)
.map(PathBuf::from)
.filter(|p| p.starts_with(project_root) && p.extension().is_none() && !p.exists())
.collect()
}
/// `vscode.java.startDebugSession` answers the port as a number (or numeric string).
pub fn parse_port(v: &Value) -> Option<u16> {
v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).and_then(|p| u16::try_from(p).ok())
@@ -916,43 +905,6 @@ impl LspManager {
};
(cp, java_home)
};
// An output folder deleted outside the IDE (`mvn clean` in a terminal) leaves JDT's
// incremental state current: the build compiles nothing and the launch fails with
// NoClassDefFoundError. Rebuild fully, once per folder that stays missing.
let missing = match (build, &cp) {
(Some(_), Some(found)) if !full_build && !build_lock_held => {
let (server, _) = self.jdtls(module, cancel).await?;
let absent = server.java_absent_outputs.lock();
missing_outputs(found, &self.project_root).into_iter().filter(|p| !absent.contains(p)).collect()
}
_ => Vec::new(),
};
let (build, cp) = if missing.is_empty() {
(build, cp)
} else {
tracing::info!(?missing, "Java output folders are missing; rebuilding the workspace");
let rebuilt = self.java_build(module, true, cancel).await?;
if rebuilt == JavaBuildStatus::Cancelled {
return Err(IdeError::cancelled());
}
let (server, _) = self.jdtls(module, cancel).await?;
let fresh = {
let _lock = cancellable_wait(cancel, server.java_build_lock.lock()).await?;
self.java_classpath(module, test, cancel).await
};
let cp = match fresh {
Ok(fresh) => Some(fresh),
Err(e) if e.kind == ErrorKind::Cancelled => return Err(e),
Err(e) => {
tracing::debug!("java.project.getClasspaths after rebuild: {e}");
cp
}
};
if let Some(cp) = &cp {
server.java_absent_outputs.lock().extend(missing_outputs(cp, &self.project_root));
}
(Some(rebuilt), cp)
};
let (errors, error_count, scoped) = match (build, build_lock_held) {
(Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed), _) | (None, true) => {
let scope_module = module.to_path_buf();
@@ -1082,29 +1034,6 @@ impl LspManager {
}
}
/// m2e's error on a `pom.xml` whose build plan can't be computed: a build plugin (or one of its
/// dependencies) doesn't resolve, e.g. a version a company mirror lacks. The module is then left
/// without its Java configuration and navigation finds nothing, with only this marker to tell.
pub(crate) fn build_plan_failure(path: &Path, root: &Path, diagnostics: &[lsp_types::Diagnostic]) -> Option<String> {
if path.file_name()? != "pom.xml" {
return None;
}
let message = diagnostics
.iter()
.filter(|d| d.severity == Some(lsp_types::DiagnosticSeverity::ERROR))
.find_map(|d| d.message.split_once("Could not calculate build plan:").map(|(_, m)| m))?;
const UNRESOLVED: &str = "could not be resolved";
let reason = match message.find(UNRESOLVED) {
Some(end) => &message[..end + UNRESOLVED.len()],
None => message.lines().next().unwrap_or(message),
};
Some(format!(
"{}: {}. Java navigation and completion don't work in this module until Maven resolves it: make it available in your Maven repository or mirror, or set a version the repository has in pom.xml.",
path.strip_prefix(root).unwrap_or(path).display(),
reason.trim().trim_end_matches(':')
))
}
impl LspManager {
/// JDT LS aborted its import with `message`: when reserved Windows device names
/// below `root` are the cause, say which (once). The walk runs off the RPC thread.
@@ -1133,29 +1062,6 @@ mod tests {
language.language_servers.into_iter().next().unwrap()
}
#[test]
fn an_unresolvable_build_plugin_is_explained() {
let diagnostic = |severity, message: &str| lsp_types::Diagnostic {
severity: Some(severity),
message: message.into(),
..Default::default()
};
let error = diagnostic(
lsp_types::DiagnosticSeverity::ERROR,
"CoreException: Could not calculate build plan: Plugin org.apache.maven.plugins:maven-compiler-plugin:3.13.0 or one of its dependencies could not be resolved: Failure to find org.apache.maven.plugins:maven-compiler-plugin:jar:3.13.0 in https://nexus.corp/repository/public",
);
let info = diagnostic(
lsp_types::DiagnosticSeverity::INFORMATION,
"Plugin execution not covered by lifecycle configuration",
);
let (root, pom) = (Path::new("/w"), Path::new("/w/app/pom.xml"));
let body = build_plan_failure(pom, root, &[info.clone(), error.clone()]).unwrap();
assert!(body.starts_with("app/pom.xml: Plugin org.apache.maven.plugins:maven-compiler-plugin:3.13.0 or one of its dependencies could not be resolved. Java navigation"), "{body}");
assert!(!body.contains("nexus.corp"));
assert_eq!(build_plan_failure(pom, root, &[info]), None);
assert_eq!(build_plan_failure(Path::new("/w/app/src/Main.java"), root, &[error]), None);
}
#[test]
fn source_settings_preserve_java_defaults_and_library_resolution() {
let mut cfg = java_config();
@@ -1423,22 +1329,6 @@ mod tests {
assert_eq!(java_style.project_root, Some("/p/my demo".into()));
}
#[test]
fn missing_outputs_are_project_folders_that_no_longer_exist() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("demo");
let kept = root.join("app/target/classes");
std::fs::create_dir_all(&kept).unwrap();
let deleted = root.join("core/target/classes");
let s = |p: &Path| p.to_string_lossy().into_owned();
let cp = JavaClasspath {
project_root: None,
class_paths: vec![s(&kept), s(&deleted), s(&root.join("lib/absent.jar")), s(&dir.path().join("elsewhere"))],
module_paths: vec![s(&root.join("api/target/classes"))],
};
assert_eq!(missing_outputs(&cp, &root), [deleted, root.join("api/target/classes")]);
}
#[test]
fn ports_settings_and_status() {
assert_eq!(parse_port(&json!(53122)), Some(53122));
+3 -45
View File
@@ -10,7 +10,6 @@ mod angular;
mod arduino;
mod caps;
mod clang_tidy;
mod compose;
mod convert;
mod external_project;
mod file_ops;
@@ -108,8 +107,6 @@ struct DocState {
version: i32,
text: Arc<DocText>,
servers: Vec<Attached>,
/// Open YAML document whose filename does not match a Compose glob and whose content is Compose.
compose_detected: bool,
}
impl DocState {
@@ -1020,7 +1017,6 @@ impl LspManager {
servers: Vec<Arc<Server>>,
) {
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
let compose_detected = language.as_str() == "yaml" && compose::is_content_detected_compose_file(&path, text);
let mut state = DocState {
path,
canonical,
@@ -1029,12 +1025,9 @@ impl LspManager {
version: version as i32,
text: Arc::new(DocText::new(text)),
servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
compose_detected,
};
let compose_added = {
{
let mut docs = self.docs.lock();
let already_detected =
compose_detected && docs.values().any(|d| d.compose_detected && d.path == state.path);
let params = state.did_open_params();
for a in &mut state.servers {
if let Some(run) = a.server.running() {
@@ -1045,17 +1038,13 @@ impl LspManager {
}
}
docs.insert(doc, state);
compose_detected && !already_detected
};
}
self.schedule_pull(doc, None, Duration::ZERO);
for s in &servers {
self.ensure_started(s);
if s.running().is_some()
&& let Some(me) = self.me.upgrade()
{
if s.name == compose::SERVER && compose_added {
me.reconfigure_soon();
}
let server = s.clone();
self.runtime.spawn(async move { me.check_clang_tidy(&server).await });
}
@@ -1126,18 +1115,7 @@ impl LspManager {
tracing::debug!("{}: didChange: {e}", server.name);
}
}
let compose_changed = if d.language.as_str() == "yaml" && !compose::is_named_compose_file(&d.path) {
let now = compose::is_compose_content(text.as_str());
let changed = std::mem::replace(&mut d.compose_detected, now) != now;
let closed_path = d.path.clone();
changed && !docs.iter().any(|(id, other)| *id != doc && other.compose_detected && other.path == closed_path)
} else {
false
};
drop(docs);
if compose_changed && let Some(me) = self.me.upgrade() {
me.reconfigure_soon();
}
self.schedule_pull(doc, None, pull::AFTER_EDIT);
self.invalidate_injections(doc);
self.schedule_injections(doc);
@@ -1197,16 +1175,7 @@ impl LspManager {
}
fn close_document(&self, doc: DocId) {
let (d, compose_removed) = {
let mut docs = self.docs.lock();
let Some(d) = docs.remove(&doc) else { return };
let removed =
d.compose_detected && !docs.values().any(|other| other.compose_detected && other.path == d.path);
(d, removed)
};
if compose_removed && let Some(me) = self.me.upgrade() {
me.reconfigure_soon();
}
let Some(d) = self.docs.lock().remove(&doc) else { return };
for a in &d.servers {
self.forget_pulled(&a.server, &d.path, a.server.running().as_ref());
let Some(run) = a.server.running() else { continue };
@@ -3200,20 +3169,9 @@ impl LspManager {
if let Some(hook) = self.config_hook.read().clone() {
hook(&mut cfg, &server.root);
}
if cfg.name == compose::SERVER {
compose::configure_yaml_compose_schemas(&mut cfg, &self.open_compose_files());
}
cfg
}
fn open_compose_files(&self) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> =
self.docs.lock().values().filter(|d| d.compose_detected).map(|d| d.path.clone()).collect();
files.sort();
files.dedup();
files
}
/// Restarts running servers whose settings-derived configuration changed
/// (e.g. a new project interpreter); stopped ones pick it up on their next start.
/// A change of `settings` alone reaches a running server without a restart.
-4
View File
@@ -120,9 +120,6 @@ pub struct Server {
phase_notify: Notify,
transient: Mutex<HashMap<PathBuf, Transient>>,
pub java_build_lock: Arc<tokio::sync::Mutex<()>>,
/// Classpath folders still missing after a full build (a module without output), so a
/// launch rebuilds for a missing output folder only once.
pub java_absent_outputs: Mutex<std::collections::HashSet<PathBuf>>,
/// JDT retirement waits for launch and initialization cleanup, even before `Ready`.
pub startup_lock: Arc<tokio::sync::Mutex<()>>,
pub java_autobuild_suspended: std::sync::atomic::AtomicBool,
@@ -170,7 +167,6 @@ impl Server {
phase_notify: Notify::new(),
transient: Mutex::new(HashMap::new()),
java_build_lock: Arc::new(tokio::sync::Mutex::new(())),
java_absent_outputs: Mutex::new(Default::default()),
startup_lock: Arc::new(tokio::sync::Mutex::new(())),
java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false),
external_project: tokio::sync::Mutex::new(Default::default()),
-10
View File
@@ -104,12 +104,6 @@ pub trait HostApi: Send + Sync {
let _ = client;
None
}
/// What other plugins contributed to `point`, as (plugin id, JSON). Only the
/// plugin that declares the point may read it.
fn extension_contributions(&self, point: &str) -> Result<Vec<(String, String)>, HostError> {
let _ = point;
Err(HostError::NotFound("extension points are unavailable on this host".into()))
}
/// Registers a project-scoped wrapper after validating its process grant and pin.
fn register_run_target(&self, root: &Path, target: crate::PluginRunTarget) -> Result<(), HostError> {
@@ -1000,10 +994,6 @@ fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
kind: "settings-changed".into(),
data: serde_json::to_string(&keys).expect("keys serialize"),
}),
WorkbenchEvent::ExtensionsChanged(points) => wit_events::Event::Other(wit_types::Extension {
kind: "extensions-changed".into(),
data: serde_json::to_string(&points).expect("points 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"),
-32
View File
@@ -169,19 +169,6 @@ pub fn read_text(real: &Path) -> Result<String, String> {
String::from_utf8(bytes).map_err(|_| format!("{}: not valid UTF-8 text", real.display()))
}
/// Reads the bytes of a file that `resolve_in_root` already approved: binary
/// content is fine, the size cap is the same as [`read_text`]'s.
pub fn read_bytes(real: &Path) -> Result<Vec<u8>, String> {
let meta = std::fs::metadata(real).map_err(|e| e.to_string())?;
if !meta.is_file() {
return Err(format!("{}: not a file", real.display()));
}
if meta.len() > MAX_READ_BYTES {
return Err(format!("{}: file is larger than {} MiB", real.display(), MAX_READ_BYTES >> 20));
}
std::fs::read(real).map_err(|e| e.to_string())
}
/// Why a read outside the project was refused.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Refusal {
@@ -541,25 +528,6 @@ mod tests {
assert!(read_text(&p).unwrap_err().contains("UTF-8"));
}
#[test]
fn read_bytes_returns_the_exact_bytes_up_to_the_cap() {
let tmp = tempfile::tempdir().unwrap();
// The bytes binary files carry survive, where read_text refuses them.
let binary = [0x25, 0x50, 0x44, 0x46, 0x00, 0xff, 0xfe, 0x1b];
std::fs::write(tmp.path().join("b.bin"), binary).unwrap();
let p = resolve_in_root(tmp.path(), "b.bin").unwrap();
assert_eq!(read_bytes(&p).unwrap(), binary);
// A directory is not a file; the size cap is read_text's.
assert!(read_bytes(&resolve_in_root(tmp.path(), ".").unwrap()).unwrap_err().contains("not a file"));
let big = tmp.path().join("big.bin");
std::fs::write(&big, vec![0u8; (MAX_READ_BYTES + 1) as usize]).unwrap();
let err = read_bytes(&big).unwrap_err();
assert!(err.contains("larger than"), "{err}");
let at_cap = tmp.path().join("at-cap.bin");
std::fs::write(&at_cap, vec![7u8; MAX_READ_BYTES as usize]).unwrap();
assert_eq!(read_bytes(&at_cap).unwrap().len(), MAX_READ_BYTES as usize);
}
/// The grant a manifest author would write for `dir`: the plain path, no
/// `\\?\` prefix.
fn grant_for(dir: &Path) -> Capability {
+2 -12
View File
@@ -110,7 +110,6 @@ pub enum EventKind {
RunExited,
TerminalExited,
ContentTabAction,
ExtensionsChanged,
FileOpened,
FileSaved,
FilesChanged,
@@ -121,14 +120,13 @@ pub enum EventKind {
}
impl EventKind {
pub const ALL: [Self; 14] = [
pub const ALL: [Self; 13] = [
Self::FileClosed,
Self::SettingsChanged,
Self::InputModeChanged,
Self::RunExited,
Self::TerminalExited,
Self::ContentTabAction,
Self::ExtensionsChanged,
Self::FileOpened,
Self::FileSaved,
Self::FilesChanged,
@@ -146,7 +144,6 @@ impl EventKind {
Self::RunExited => "run-exited",
Self::TerminalExited => "terminal-exited",
Self::ContentTabAction => "content-tab-action",
Self::ExtensionsChanged => "extensions-changed",
Self::FileOpened => "file-opened",
Self::FileSaved => "file-saved",
Self::FilesChanged => "files-changed",
@@ -166,11 +163,7 @@ impl EventKind {
pub fn capability(self) -> Option<ide_extension::Capability> {
match self {
Self::ActionPerformed | Self::InputModeChanged => Some(ide_extension::Capability::InputEvents),
Self::Scheduled
| Self::RunExited
| Self::TerminalExited
| Self::ContentTabAction
| Self::ExtensionsChanged => None,
Self::Scheduled | Self::RunExited | Self::TerminalExited | Self::ContentTabAction => None,
_ => Some(ide_extension::Capability::Events),
}
}
@@ -236,8 +229,6 @@ pub enum WorkbenchEvent {
ContentTabAction(crate::ContentTabAction),
FileClosed(String),
SettingsChanged(Vec<String>),
/// Ids of extension points the receiving plugin declares whose contributions changed.
ExtensionsChanged(Vec<String>),
InputModeChanged(InputMode),
FileOpened(String),
FileSaved(String),
@@ -256,7 +247,6 @@ impl WorkbenchEvent {
Self::ContentTabAction(_) => EventKind::ContentTabAction,
Self::FileClosed(_) => EventKind::FileClosed,
Self::SettingsChanged(_) => EventKind::SettingsChanged,
Self::ExtensionsChanged(_) => EventKind::ExtensionsChanged,
Self::InputModeChanged(_) => EventKind::InputModeChanged,
Self::FileOpened(_) => EventKind::FileOpened,
Self::FileSaved(_) => EventKind::FileSaved,
-37
View File
@@ -64,7 +64,6 @@ pub(crate) mod syntax_bindings {
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
"openidea:plugin/database": super::bindings::openidea::plugin::database,
"openidea:plugin/mcp": super::bindings::openidea::plugin::mcp,
"openidea:plugin/extensions": super::bindings::openidea::plugin::extensions,
},
});
}
@@ -102,7 +101,6 @@ pub(crate) mod providers_bindings {
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
"openidea:plugin/database": super::bindings::openidea::plugin::database,
"openidea:plugin/mcp": super::bindings::openidea::plugin::mcp,
"openidea:plugin/extensions": super::bindings::openidea::plugin::extensions,
},
});
}
@@ -140,7 +138,6 @@ pub(crate) mod views_bindings {
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
"openidea:plugin/database": super::bindings::openidea::plugin::database,
"openidea:plugin/mcp": super::bindings::openidea::plugin::mcp,
"openidea:plugin/extensions": super::bindings::openidea::plugin::extensions,
},
});
}
@@ -699,22 +696,6 @@ impl workspace::HostProject for StoreData {
Ok(sandbox::read_text(&real).map_err(wit::Error::Failed))
}
fn read_bytes(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<Vec<u8>> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let root = self.table.get(&this)?.root.clone();
let real = match sandbox::resolve_in_root(&root, &path) {
Ok(real) => real,
Err(e) => {
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
return Ok(Err(e));
}
};
// The bytes on disk: an unsaved text buffer does not apply, binary files are the point.
Ok(sandbox::read_bytes(&real).map_err(wit::Error::Failed))
}
fn exists(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<bool> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
@@ -1165,11 +1146,6 @@ impl events::Host for StoreData {
{
return Ok(Err(to_wit(e)));
}
if kind == EventKind::ExtensionsChanged
&& let Err(e) = self.host.require_api(kind.name(), 5)
{
return Ok(Err(to_wit(e)));
}
if let Some(cap) = kind.capability()
&& let Err(e) = self.require(cap)
{
@@ -2380,19 +2356,6 @@ impl bindings::openidea::plugin::database::Host for StoreData {
Ok(result.map_err(to_wit))
}
}
impl bindings::openidea::plugin::extensions::Host for StoreData {
fn contributions(&mut self, point: String) -> Fallible<Vec<bindings::openidea::plugin::extensions::Contribution>> {
Ok(self
.host
.extension_contributions(&clip(point, 128))
.map(|list| {
list.into_iter()
.map(|(plugin, data)| bindings::openidea::plugin::extensions::Contribution { plugin, data })
.collect()
})
.map_err(to_wit))
}
}
impl bindings::openidea::plugin::mcp::Host for StoreData {
fn stdio_command(&mut self) -> wasmtime::Result<Option<Vec<String>>> {
Ok(self.client().and_then(|client| self.host.mcp_stdio_command(client)))
Binary file not shown.
@@ -1 +1 @@
eb7d608bf1e7379620ef7d9ce5289dd9b927f137af2aafdd0861a84d60d677a2
163429536090fe11286cce425fabad3e84c14e1ea4e4b678be88a0bad0a1285a
-4
View File
@@ -104,10 +104,6 @@ title = "reveal"
id = "search-text"
title = "search-text"
[[contributes.commands]]
id = "read-bytes"
title = "read-bytes"
[[contributes.commands]]
id = "problems"
title = "problems"
-1
View File
@@ -25,7 +25,6 @@ impl Plugin for Api14 {
.search_text(&openidea_plugin::TextQuery { pattern: arg.into(), ..Default::default() }, 10)?;
Ok(Some(format!("{result:?}")))
}
"read-bytes" => Ok(Some(format!("{:?}", ctx.project().unwrap().read_bytes(arg)?))),
"problems" => Ok(Some(format!("{:?}", host::problems(if arg.is_empty() { None } else { Some(arg) })?))),
"decorations" => {
host::set_decorations(
Binary file not shown.
@@ -1 +1 @@
195cb4025508e7af6e6fb63d81660c4a977327fa3d101a2a77e5f448f43518f8
bfdd78a242472a1279e0e393f66f0d96a856338e103aad454239a451df22cd05
Binary file not shown.
@@ -1 +1 @@
49491ad2d57592cca2451b0abde5f88a3aedd3f5d36ceaf8259c3254f59c5023
37276095967174307db4c5113d85b777138f60ee82d26b4af8ef3f2acd90aafa
Binary file not shown.
@@ -1 +1 @@
b24383f6e05ec531ef8db368b79af710433bc3782438f5a9e3578ebb730a1ce2
b9597de10e7182e1ed488f80b6c1deb9f810912797b2bf02c9cae0347ee38bc8
Binary file not shown.
@@ -1 +1 @@
ef01f199e668063b2c444e4ccf169d5b5407e567dbe8ff583cfdfc111b2438cf
3ee642e6d1f983ba5e186007e2115b0a104383510cc8acc13f3e0a83fe1ca5b6
Binary file not shown.
@@ -1 +1 @@
a4dbe2a261e00809b64039ce9c89db681b186788db8a92b7456eca84b33c5945
43f7af5d824853a3fbaa61ef408127cd2aac0134e6ec02ca2fb35813db4b0bd9
Binary file not shown.
@@ -1 +1 @@
3c9db6ffeaa9b782970c7a7a132878f862b0b5b18e9d11ad56649ee28317e3ca
d85df5d6fb5e9d746fe4b28e1f8546c30fe6dc53c1b9dfd9201bc6a9dacf2306
-336
View File
@@ -1,336 +0,0 @@
# 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.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4"
[[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-ext-owner"
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.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79"
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"
-20
View File
@@ -1,20 +0,0 @@
[package]
name = "openidea-test-ext-owner"
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"
Binary file not shown.
@@ -1 +0,0 @@
ac1dceb016b7963f44e876784ea62f776e90dd63fc6f660a5dad3dfbd7433b1c
-27
View File
@@ -1,27 +0,0 @@
id = "test.ext-owner"
name = "Extension point owner"
version = "0.1.0"
engines.openidea = "^1.5"
wasm = "dist/plugin.wasm"
activation = ["on-command:*", "on-event:extensions-changed"]
[capabilities]
required = ["storage"]
[[extension-points]]
id = "test.ext-owner.items"
description = "Things other plugins add"
required = ["name"]
fields = { name = "string", weight = "integer" }
[[contributes.commands]]
id = "dump"
title = "Dump contributions"
[[contributes.commands]]
id = "read"
title = "Read the contributions to any extension point"
[[contributes.commands]]
id = "changes"
title = "Show the change events received"
-36
View File
@@ -1,36 +0,0 @@
use openidea_plugin::{Error, Event, Invocation, Plugin, host, register_plugin};
#[derive(Default)]
struct Owner;
impl Plugin for Owner {
fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"dump" => {
let list = host::extension_contributions("test.ext-owner.items")?;
Ok(Some(list.iter().map(|c| format!("{}:{}", c.plugin, c.data)).collect::<Vec<_>>().join("\n")))
}
"read" => {
let list = host::extension_contributions(arg.unwrap_or_default())?;
Ok(Some(list.len().to_string()))
}
"changes" => host::storage::get("changes"),
_ => Err(Error::NotFound(id.into())),
}
}
fn handle_events(&mut self, events: &[Event], _ctx: &Invocation) {
let mut seen = host::storage::get("changes").unwrap().unwrap_or_default();
for event in events {
if let Event::Other { kind, data } = event
&& kind == "extensions-changed"
{
seen.push_str(data);
seen.push('\n');
}
}
host::storage::set("changes", &seen).unwrap();
}
}
register_plugin!(Owner);
Binary file not shown.
@@ -1 +1 @@
11dc1a74c0650b493ed6ec88b5d15e08421ab08c5f2320099dabd5df1bdc5096
17fcb4251b6bdd055e4e856bd8d69d7eca52693cefb818b547787907022b7943
Binary file not shown.
@@ -1 +1 @@
94c391142af832a67c694968d307217aabd7c139ae29e3299930b56ce644e45b
50d8a7304714e0810da7f8a2f723155287c26807848049b00492cb9def6c7763
Binary file not shown.
@@ -1 +1 @@
e2c1893a160a0f27af7eb9e689d703577335cce32f746713c2435ae70fb9753d
8442bac7628c5e3ddbf2af2337781016197028054c83d4589dbbcf479c77ff87
Loaded 100 of 527 files, more files were not shown because too many files have changed in this diff. Show more