дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-15 22:25:48 +02:00
Порівняти коміти
17
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
41951e2895 | ||
|
|
feb261d172 | ||
|
|
3d4a1fd387 | ||
|
|
88e4b8f79b | ||
|
|
3c9ac70cdf | ||
|
|
cc72161f92 | ||
|
|
485638cba5 | ||
|
|
e968801415 | ||
|
|
7b6962001b | ||
|
|
8535fb7645 | ||
|
|
89eacbe640 | ||
|
|
c67b336a8a | ||
|
|
90cc707d00 | ||
|
|
779080781b | ||
|
|
ccf87e2476 | ||
|
|
76f0b3fc63 | ||
|
|
a5c14a4c21 |
No files matched your search
+8
-8
@@ -38,14 +38,14 @@ Editör, kod analizi, hata ayıklayıcı (debugger), Git, terminal, veritabanı
|
||||
[freqtrade](https://github.com/freqtrade/freqtrade) projesini (Python, ~770 dosya)
|
||||
önbellekler hazırken açma, 5 çalıştırmanın ortancası:
|
||||
|
||||
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
|
||||
| ---------------------------------- | --------------------- | ---------------------- |
|
||||
| Projeyle pencerenin açılması | **0,9 sn** | 3,1 sn |
|
||||
| Hazır (CPU sakinleşti) | **1,1 sn** | 13,2 sn |
|
||||
| Bellek (USS, tüm işlemler) | **169 MB** | 2011 MB |
|
||||
| Hazır olana kadar CPU süresi | **2,4 sn** | 47,9 sn |
|
||||
| Boştayken CPU | Bir çekirdeğin **%4**'ü | Bir çekirdeğin %7'si |
|
||||
| Kapanma | **0,3 sn** | 5,9 sn |
|
||||
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
|
||||
| ---------------------------- | ----------------------- | ---------------------- |
|
||||
| Projeyle pencerenin açılması | **0,9 sn** | 3,1 sn |
|
||||
| Hazır (CPU sakinleşti) | **1,1 sn** | 13,2 sn |
|
||||
| Bellek (USS, tüm işlemler) | **169 MB** | 2011 MB |
|
||||
| Hazır olana kadar CPU süresi | **2,4 sn** | 47,9 sn |
|
||||
| Boştayken CPU | Bir çekirdeğin **%4**'ü | Bir çekirdeğin %7'si |
|
||||
| Kapanma | **0,3 sn** | 5,9 sn |
|
||||
|
||||
`main` dalından derlenmiş bir OpenIDEA sürümü ve varsayılan ayar ve eklentilerle IDEA;
|
||||
Windows 11, 32 çekirdekli AMD, 62 GB RAM.
|
||||
|
||||
@@ -10,12 +10,14 @@ use crate::provider::ProviderSpec;
|
||||
use crate::{Shared, client_info};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const DISCOVER_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(3) } else { Duration::from_secs(120) };
|
||||
|
||||
/// Opens `session/new`, reads `configOptions`, and records per-model `thought_level` choices.
|
||||
/// Opens `session/new`, reads `configOptions`, and records the `thought_level` choices of the
|
||||
/// session's model: they describe that model only, and other models may offer different ones.
|
||||
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<Discovered, String> {
|
||||
let prefer_stored = crate::provider::prefer_stored_copilot_auth();
|
||||
match run_once(shared, spec, prefer_stored).await {
|
||||
@@ -62,21 +64,17 @@ async fn run_once(shared: &Arc<Shared>, spec: &ProviderSpec, strip_ambient: bool
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut options = config::options_of(&new);
|
||||
config::add_client_options(&launch.id, &mut options);
|
||||
let model_option = options.iter().find(|o| o.category.as_deref() == Some("model"));
|
||||
let effort = levels_for_model_from_slice(&options);
|
||||
let levels: Vec<(String, Vec<AgentConfigOption>)> = match (model_option, effort) {
|
||||
(Some(model), Some(effort)) if !model.values.is_empty() => {
|
||||
model.values.iter().map(|v| (v.value.clone(), effort.clone())).collect()
|
||||
}
|
||||
(Some(model), Some(effort)) => model.current.clone().map(|m| vec![(m, effort)]).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
let model = options.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone());
|
||||
let levels = match (model, levels_for_model_from_slice(&options)) {
|
||||
(Some(model), Some(effort)) => BTreeMap::from([(model, effort)]),
|
||||
_ => BTreeMap::new(),
|
||||
};
|
||||
let commands = updates
|
||||
.commands
|
||||
.lock()
|
||||
.clone()
|
||||
.or_else(|| new.get("availableCommands").map(|_| config::commands_of(&new, &launch.id)));
|
||||
Ok::<_, String>(Discovered { config: options, commands, levels: levels.into_iter().collect() })
|
||||
Ok::<_, String>(Discovered { config: options, commands, levels })
|
||||
};
|
||||
let result = tokio::time::timeout(DISCOVER_TIMEOUT, work).await;
|
||||
peer.kill();
|
||||
@@ -108,18 +106,17 @@ impl ide_jsonrpc::Handler for DiscoveryUpdates {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_thought_level(o: &AgentConfigOption) -> bool {
|
||||
o.category.as_deref() == Some("thought_level")
|
||||
|| matches!(o.id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
|
||||
}
|
||||
|
||||
fn levels_for_model_from_slice(options: &[AgentConfigOption]) -> Option<Vec<AgentConfigOption>> {
|
||||
let levels: Vec<_> = options
|
||||
.iter()
|
||||
.filter(|o| is_thought_level(o))
|
||||
.filter(|o| config::is_per_model_option(o))
|
||||
.cloned()
|
||||
.map(|mut o| {
|
||||
if o.category.as_deref() != Some("thought_level") {
|
||||
if config::is_context_option(&o.id) {
|
||||
if o.category.is_none() {
|
||||
o.category = Some("model_config".into());
|
||||
}
|
||||
} else if o.category.as_deref() != Some("thought_level") {
|
||||
o.category = Some("thought_level".into());
|
||||
}
|
||||
o
|
||||
|
||||
@@ -58,6 +58,8 @@ fn client_capabilities() -> Value {
|
||||
"terminal": false,
|
||||
// Subagents as sessions of their own (ACP subagents RFD; v2 baseline).
|
||||
"subagents": {},
|
||||
// Forms the agent asks the user to fill in (`elicitation/create`); not URL mode.
|
||||
"elicitation": { "form": {} },
|
||||
// Cursor exposes model parameters (e.g. Composer `fast`) as separate config options
|
||||
// only when the client advertises this flag (undocumented Cursor ACP extension).
|
||||
"_meta": { "parameterizedModelPicker": true },
|
||||
@@ -320,6 +322,13 @@ impl Handler for Dialect {
|
||||
}
|
||||
self.inner.on_request(method, params)
|
||||
}
|
||||
|
||||
fn on_request_raw(&self, method: &str, params: Value, raw: &[u8]) -> Result<Value, (i64, String, Option<Value>)> {
|
||||
if self.v2() && method == "session/request_permission" {
|
||||
return self.on_request_with_data(method, params);
|
||||
}
|
||||
self.inner.on_request_raw(method, params, raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// A v2 permission request with the v1 `toolCall` (its subject's, titled with the request's title).
|
||||
|
||||
@@ -97,10 +97,13 @@ fn the_negotiating_request_carries_both_versions() {
|
||||
p["clientCapabilities"]["_meta"]["parameterizedModelPicker"], true,
|
||||
"Cursor and similar agents need parameterized model options (e.g. fast on/off)"
|
||||
);
|
||||
// Forms only: URL mode would open pages the agent names.
|
||||
assert_eq!(p["clientCapabilities"]["elicitation"], json!({ "form": {} }));
|
||||
// What we sent in the capture is what claude-agent-acp negotiated v2 and v1 from.
|
||||
let sent = frames(TURN, "out")[0]["params"]["clientCapabilities"].clone();
|
||||
let mut expected = sent;
|
||||
expected["_meta"] = p["clientCapabilities"]["_meta"].clone();
|
||||
expected["elicitation"] = p["clientCapabilities"]["elicitation"].clone();
|
||||
assert_eq!(expected, p["clientCapabilities"]);
|
||||
}
|
||||
|
||||
@@ -168,7 +171,8 @@ async fn discovery_reads_effort_and_fast_from_session_new_then_stops_the_agent()
|
||||
let fast = cursor.config.iter().find(|o| o.id == "fast").expect("fast option");
|
||||
assert_eq!(fast.category.as_deref(), Some("model_config"));
|
||||
assert_eq!(fast.current.as_deref(), Some("false"));
|
||||
assert!(cursor.thought_levels.contains_key("m1") && cursor.thought_levels.contains_key("m2"));
|
||||
// The effort option belongs to the session's model; m2 may offer other levels.
|
||||
assert_eq!(cursor.thought_levels.keys().collect::<Vec<_>>(), ["m1"]);
|
||||
assert!(cursor.thought_levels["m1"].iter().any(|o| o.category.as_deref() == Some("thought_level")));
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,16 @@ pub(crate) struct CatalogData {
|
||||
pub refreshed_at_ms: Option<f64>,
|
||||
}
|
||||
|
||||
impl CatalogData {
|
||||
fn model_option(&self) -> Option<&AgentConfigOption> {
|
||||
self.config.iter().find(|o| o.category.as_deref() == Some("model"))
|
||||
}
|
||||
|
||||
fn lists(&self, model: &str) -> bool {
|
||||
self.model_option().is_some_and(|o| o.values.iter().any(|v| v.value == model))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SavedCatalog {
|
||||
@@ -329,11 +339,17 @@ impl AgentCatalogs {
|
||||
Ok(found) => {
|
||||
let mut cache = shared.known.lock();
|
||||
let known = cache.entry(key.2.clone()).or_default();
|
||||
let catalog = CatalogData {
|
||||
let mut catalog = CatalogData {
|
||||
config: found.config,
|
||||
thought_levels: found.levels,
|
||||
refreshed_at_ms: Some(self.epoch_ms()),
|
||||
};
|
||||
// Efforts sessions reported for models discovery doesn't read stay while listed.
|
||||
for (model, levels) in entry.catalog.take().map(|old| old.thought_levels).unwrap_or_default() {
|
||||
if catalog.lists(&model) && !catalog.thought_levels.contains_key(&model) {
|
||||
catalog.thought_levels.insert(model, levels);
|
||||
}
|
||||
}
|
||||
known.config = catalog.config.clone();
|
||||
known.thought_levels = catalog.thought_levels.clone();
|
||||
if let Some(commands) = found.commands {
|
||||
@@ -366,6 +382,42 @@ impl AgentCatalogs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the efforts a session reported for a listed `model` other than the one discovery
|
||||
/// read: ACP discovery opens one session, so it knows only that model's efforts.
|
||||
pub(crate) fn learn_levels(&self, shared: &Shared, provider: &str, model: &str, levels: Vec<AgentConfigOption>) {
|
||||
let lacks = |catalog: &CatalogData| {
|
||||
catalog.lists(model)
|
||||
&& catalog.model_option().and_then(|o| o.current.as_deref()) != Some(model)
|
||||
&& catalog.thought_levels.get(model) != Some(&levels)
|
||||
};
|
||||
let key = Self::key(shared, provider);
|
||||
let mut state = self.state.lock();
|
||||
let mut learned = false;
|
||||
if let Some(catalog) = state.entries.get_mut(&key).and_then(|e| e.catalog.as_mut()).filter(|c| lacks(c)) {
|
||||
catalog.thought_levels.insert(model.to_owned(), levels.clone());
|
||||
learned = true;
|
||||
}
|
||||
let mut cache = shared.known.lock();
|
||||
if let Some(saved) = cache
|
||||
.get_mut(provider)
|
||||
.and_then(|known| known.catalogs.get_mut(&shared.root_key()))
|
||||
.filter(|saved| lacks(&saved.catalog))
|
||||
{
|
||||
saved.catalog.thought_levels.insert(model.to_owned(), levels);
|
||||
learned = true;
|
||||
}
|
||||
if !learned {
|
||||
return;
|
||||
}
|
||||
shared.persist_known(&cache);
|
||||
drop(cache);
|
||||
let snapshot = state.entries.get(&key).map(|entry| self.snapshot(shared, provider, entry));
|
||||
drop(state);
|
||||
if let Some(snapshot) = snapshot {
|
||||
self.broadcast(shared, &snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribers(&self, shared: &Shared) -> Vec<Arc<Shared>> {
|
||||
self.state
|
||||
.lock()
|
||||
|
||||
@@ -199,7 +199,8 @@ async fn successful_refresh_replaces_models_and_efforts_and_old_sessions_cannot_
|
||||
pending.reply.send(Ok(response(&["retained", "new"], &["medium"]))).unwrap();
|
||||
let fresh = completed(&mut events).await;
|
||||
assert_eq!(model_values(&fresh), ["retained", "new"]);
|
||||
assert_eq!(fresh.thought_levels.keys().map(String::as_str).collect::<Vec<_>>(), ["new", "retained"]);
|
||||
// The session's efforts describe its own model, not "new".
|
||||
assert_eq!(fresh.thought_levels.keys().map(String::as_str).collect::<Vec<_>>(), ["retained"]);
|
||||
assert_eq!(fresh.thought_levels["retained"][0].values[0].value, "medium");
|
||||
assert!(fresh.refreshed_at_ms.is_some() && fresh.retry_at_ms.is_some());
|
||||
manager.shared.remember(PROVIDER, Some(&old), None, Some("old"));
|
||||
@@ -220,6 +221,42 @@ async fn successful_refresh_replaces_models_and_efforts_and_old_sessions_cannot_
|
||||
assert!(!tmp.path().join("store").exists(), "refresh never creates tasks");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_session_adds_the_efforts_of_a_model_discovery_did_not_read() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
configure(tmp.path(), "one");
|
||||
let mut harness = Harness::new();
|
||||
let catalogs = Arc::new(AgentCatalogs::default());
|
||||
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
|
||||
let mut events = events(&manager);
|
||||
manager.refresh_models(PROVIDER, false).unwrap();
|
||||
harness.next().await.reply.send(Ok(response(&["first", "second"], &["low", "high"]))).unwrap();
|
||||
completed(&mut events).await;
|
||||
// A session on "second" reports its own efforts; one on a model the catalog lacks, or on
|
||||
// "first" with other efforts, changes nothing.
|
||||
let second = config::options_of(&response(&["second"], &["off", "auto"]));
|
||||
manager.shared.remember(PROVIDER, Some(&second), None, Some("second"));
|
||||
manager.shared.remember(PROVIDER, Some(&config::options_of(&response(&["gone"], &["x"]))), None, Some("gone"));
|
||||
manager.shared.remember(PROVIDER, Some(&config::options_of(&response(&["first"], &["max"]))), None, Some("first"));
|
||||
let efforts = |catalog: &AgentModelCatalog, model: &str| -> Vec<String> {
|
||||
catalog.thought_levels[model][0].values.iter().map(|v| v.value.clone()).collect()
|
||||
};
|
||||
let catalog = manager.refresh_models(PROVIDER, false).unwrap();
|
||||
assert_eq!(catalog.thought_levels.keys().map(String::as_str).collect::<Vec<_>>(), ["first", "second"]);
|
||||
assert_eq!(efforts(&catalog, "first"), ["low", "high"]);
|
||||
assert_eq!(efforts(&catalog, "second"), ["off", "auto"]);
|
||||
let restored = make_manager(tmp.path(), Arc::default(), &harness).refresh_models(PROVIDER, false).unwrap();
|
||||
assert_eq!(efforts(&restored, "second"), ["off", "auto"], "persisted with the project's catalog");
|
||||
// Discovery again reads "first" only; "second" keeps what its session reported.
|
||||
advance(&catalogs, 60);
|
||||
while events.try_recv().is_ok() {}
|
||||
manager.refresh_models(PROVIDER, true).unwrap();
|
||||
harness.next().await.reply.send(Ok(response(&["first", "second"], &["medium"]))).unwrap();
|
||||
let fresh = completed(&mut events).await;
|
||||
assert_eq!(efforts(&fresh, "first"), ["medium"]);
|
||||
assert_eq!(efforts(&fresh, "second"), ["off", "auto"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_cache_loads_without_claiming_authoritative_freshness() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -20,17 +20,22 @@ pub(crate) fn add_client_options(provider: &str, config: &mut Vec<AgentConfigOpt
|
||||
description: Some("Tool approvals for this Cursor conversation".into()),
|
||||
category: Some("approval".into()),
|
||||
current: Some("ask".into()),
|
||||
default_value: Some("ask".into()),
|
||||
via: AgentConfigVia::Option,
|
||||
values: vec![
|
||||
AgentConfigValue {
|
||||
value: "ask".into(),
|
||||
name: "Ask for approval".into(),
|
||||
description: Some("Show permission requests from Cursor".into()),
|
||||
cost_category: None,
|
||||
usage_multiplier: None,
|
||||
},
|
||||
AgentConfigValue {
|
||||
value: "bypass".into(),
|
||||
name: "Bypass approvals".into(),
|
||||
description: Some("Automatically approve tool requests in this conversation".into()),
|
||||
cost_category: None,
|
||||
usage_multiplier: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -118,6 +123,18 @@ pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn is_context_option(id: &str) -> bool {
|
||||
matches!(id, "context_tier" | "contextTier")
|
||||
}
|
||||
|
||||
/// Whether an option's available values and default belong to the selected model
|
||||
/// (`thought_level` and model context-tier options) rather than the provider as a whole.
|
||||
pub(crate) fn is_per_model_option(o: &AgentConfigOption) -> bool {
|
||||
matches!(o.category.as_deref(), Some("thought_level" | "context_tier"))
|
||||
|| matches!(o.id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
|
||||
|| is_context_option(&o.id)
|
||||
}
|
||||
|
||||
/// A `configOptions` array; only select options are kept (the UI shows them as dropdowns).
|
||||
pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
|
||||
let Some(list) = options.as_array() else { return Vec::new() };
|
||||
@@ -133,6 +150,8 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
|
||||
None => values.extend(value_of(v)),
|
||||
}
|
||||
}
|
||||
let current = str_of(o, "currentValue");
|
||||
let default_value = str_of(o, "defaultValue").or_else(|| current.clone());
|
||||
Some(AgentConfigOption {
|
||||
name: str_of(o, "name").unwrap_or_else(|| id.clone()),
|
||||
description: str_of(o, "description"),
|
||||
@@ -143,9 +162,10 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
|
||||
matches!(id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
|
||||
.then(|| "thought_level".into())
|
||||
})
|
||||
.or_else(|| matches!(id.as_str(), "fast").then(|| "model_config".into()))
|
||||
.or_else(|| (id == "fast" || is_context_option(&id)).then(|| "model_config".into()))
|
||||
}),
|
||||
current: str_of(o, "currentValue"),
|
||||
current,
|
||||
default_value,
|
||||
values,
|
||||
via: AgentConfigVia::Option,
|
||||
id,
|
||||
@@ -155,9 +175,24 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
|
||||
}
|
||||
|
||||
/// A `config_option_update`: its options replace the config-option ones; `modes` / `models`
|
||||
/// stand-ins stay.
|
||||
/// stand-ins stay, and initial `default_value`s survive same-model option edits for Reset.
|
||||
pub(crate) fn merge_update(config: &mut Vec<AgentConfigOption>, options: &Value) {
|
||||
let fresh = parse_options(options);
|
||||
let mut fresh = parse_options(options);
|
||||
let old_model = config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.as_deref());
|
||||
let new_model = fresh.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.as_deref());
|
||||
let same_model = old_model == new_model;
|
||||
for f in &mut fresh {
|
||||
if f.default_value == f.current
|
||||
&& (same_model || !is_per_model_option(f))
|
||||
&& let Some(prev_default) = config
|
||||
.iter()
|
||||
.find(|p| p.id == f.id)
|
||||
.and_then(|p| p.default_value.as_ref())
|
||||
.filter(|def| f.values.iter().any(|v| v.value == **def))
|
||||
{
|
||||
f.default_value = Some(prev_default.clone());
|
||||
}
|
||||
}
|
||||
let covered = |o: &AgentConfigOption| fresh.iter().any(|f| f.category.is_some() && f.category == o.category);
|
||||
config.retain(|o| o.id == CURSOR_APPROVAL || (o.via != AgentConfigVia::Option && !covered(o)));
|
||||
let legacy = std::mem::take(config);
|
||||
@@ -251,16 +286,20 @@ fn legacy(
|
||||
Some(AgentConfigValue {
|
||||
name: str_of(m, "name").unwrap_or_else(|| value.clone()),
|
||||
description: str_of(m, "description"),
|
||||
cost_category: None,
|
||||
usage_multiplier: None,
|
||||
value,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let current = str_of(v, current);
|
||||
AgentConfigOption {
|
||||
id: id.to_owned(),
|
||||
name: name.to_owned(),
|
||||
description: None,
|
||||
category: Some(id.to_owned()),
|
||||
current: str_of(v, current),
|
||||
default_value: current.clone(),
|
||||
current,
|
||||
values,
|
||||
via,
|
||||
}
|
||||
@@ -268,9 +307,23 @@ fn legacy(
|
||||
|
||||
fn value_of(v: &Value) -> Option<AgentConfigValue> {
|
||||
let value = v.get("value")?.as_str()?.to_owned();
|
||||
let meta = v.get("_meta");
|
||||
let cost_category = meta
|
||||
.and_then(|m| m.get("copilotPriceCategory").or_else(|| m.get("priceCategory")))
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.trim().to_ascii_lowercase())
|
||||
.filter(|s| !s.is_empty());
|
||||
let usage_multiplier = meta
|
||||
.and_then(|m| m.get("copilotUsage").or_else(|| m.get("usageMultiplier")))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned);
|
||||
Some(AgentConfigValue {
|
||||
name: str_of(v, "name").unwrap_or_else(|| value.clone()),
|
||||
description: str_of(v, "description"),
|
||||
cost_category,
|
||||
usage_multiplier,
|
||||
value,
|
||||
})
|
||||
}
|
||||
@@ -402,17 +455,53 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn effort_and_fast_categories_are_inferred_from_ids() {
|
||||
let c = options_of(&json!({
|
||||
let mut c = options_of(&json!({
|
||||
"configOptions": [
|
||||
{"id": "effort", "type": "select", "currentValue": "high",
|
||||
"options": [{"value": "low"}, {"value": "high"}]},
|
||||
{"id": "model", "type": "select", "currentValue": "gemini-4-carbon",
|
||||
"options": [{"value": "gemini-4-carbon", "name": "Gemini 4 Carbon",
|
||||
"description": "Powerful · Medium cost",
|
||||
"_meta": {"copilotPriceCategory": "Medium", "copilotUsage": "1x"}}]},
|
||||
{"id": "effort", "type": "select", "currentValue": "medium",
|
||||
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]},
|
||||
{"id": "contextTier", "type": "select", "currentValue": "default",
|
||||
"options": [{"value": "default", "name": "200K"}, {"value": "long_context", "name": "1M"}]},
|
||||
{"id": "fast", "type": "select", "currentValue": "false",
|
||||
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}
|
||||
]
|
||||
}));
|
||||
assert_eq!(c.len(), 2);
|
||||
assert_eq!(c[0].category.as_deref(), Some("thought_level"));
|
||||
assert_eq!(c[1].category.as_deref(), Some("model_config"));
|
||||
assert_eq!(c.len(), 4);
|
||||
assert_eq!(c[0].values[0].cost_category.as_deref(), Some("medium"));
|
||||
assert_eq!(c[0].values[0].usage_multiplier.as_deref(), Some("1x"));
|
||||
assert_eq!(c[1].category.as_deref(), Some("thought_level"));
|
||||
assert_eq!(c[1].default_value.as_deref(), Some("medium"));
|
||||
assert_eq!(c[2].category.as_deref(), Some("model_config"));
|
||||
assert!(is_per_model_option(&c[1]) && is_per_model_option(&c[2]) && !is_per_model_option(&c[3]));
|
||||
|
||||
// Editing effort on the same model preserves its initial `default_value` for Reset.
|
||||
merge_update(
|
||||
&mut c,
|
||||
&json!([
|
||||
{"id": "model", "type": "select", "currentValue": "gemini-4-carbon",
|
||||
"options": [{"value": "gemini-4-carbon"}]},
|
||||
{"id": "effort", "type": "select", "currentValue": "high",
|
||||
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]}
|
||||
]),
|
||||
);
|
||||
let effort = c.iter().find(|o| o.id == "effort").unwrap();
|
||||
assert_eq!((effort.current.as_deref(), effort.default_value.as_deref()), (Some("high"), Some("medium")));
|
||||
|
||||
// Switching models refreshes per-model `default_value` to the new model's default.
|
||||
merge_update(
|
||||
&mut c,
|
||||
&json!([
|
||||
{"id": "model", "type": "select", "currentValue": "gpt-6-sol",
|
||||
"options": [{"value": "gemini-4-carbon"}, {"value": "gpt-6-sol"}]},
|
||||
{"id": "effort", "type": "select", "currentValue": "low",
|
||||
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]}
|
||||
]),
|
||||
);
|
||||
let effort = c.iter().find(|o| o.id == "effort").unwrap();
|
||||
assert_eq!((effort.current.as_deref(), effort.default_value.as_deref()), (Some("low"), Some("low")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! ACP elicitation in form mode: the agent's restricted JSON Schema as form fields, and the
|
||||
//! user's answer as the `elicitation/create` response.
|
||||
|
||||
use crate::model::{AgentForm, AgentFormField, AgentFormFieldKind, AgentFormOption, AgentPermissionOption};
|
||||
use serde::Deserialize;
|
||||
use serde::de::{Deserializer, IgnoredAny, MapAccess, Visitor};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// The reply that declines the form; a JSON object of field name to values accepts it, and
|
||||
/// anything else cancels it.
|
||||
const DECLINE: &str = "decline";
|
||||
|
||||
/// The form `elicitation/create` asks for; `Err` when it asks for something else.
|
||||
pub(crate) fn form_of(params: &Value) -> Result<AgentForm, String> {
|
||||
if params.get("mode").and_then(Value::as_str) != Some("form") {
|
||||
return Err("only form elicitation is supported".to_owned());
|
||||
}
|
||||
let schema = params.get("requestedSchema").ok_or("requestedSchema is missing")?;
|
||||
let required: Vec<&str> = schema
|
||||
.get("required")
|
||||
.and_then(Value::as_array)
|
||||
.map(|names| names.iter().filter_map(Value::as_str).collect())
|
||||
.unwrap_or_default();
|
||||
let properties = schema.get("properties").and_then(Value::as_object).ok_or("requestedSchema has no properties")?;
|
||||
let fields = properties
|
||||
.iter()
|
||||
.map(|(name, property)| field(name, property, required.contains(&name.as_str())))
|
||||
.collect::<Result<_, _>>()?;
|
||||
Ok(AgentForm { fields })
|
||||
}
|
||||
|
||||
/// Orders the fields as the request's schema lists them: `Value` keeps object keys sorted, and
|
||||
/// the agent's order is the one its form reads in.
|
||||
pub(crate) fn in_schema_order(form: &mut AgentForm, raw: &[u8]) {
|
||||
#[derive(Deserialize)]
|
||||
struct Request {
|
||||
params: Params,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Params {
|
||||
#[serde(rename = "requestedSchema")]
|
||||
schema: Schema,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Schema {
|
||||
properties: Keys,
|
||||
}
|
||||
struct Keys(Vec<String>);
|
||||
impl<'de> Deserialize<'de> for Keys {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct Visit;
|
||||
impl<'de> Visitor<'de> for Visit {
|
||||
type Value = Keys;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("an object")
|
||||
}
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Keys, A::Error> {
|
||||
let mut keys = Vec::new();
|
||||
while let Some((key, IgnoredAny)) = map.next_entry::<String, IgnoredAny>()? {
|
||||
keys.push(key);
|
||||
}
|
||||
Ok(Keys(keys))
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_map(Visit)
|
||||
}
|
||||
}
|
||||
let Ok(request) = serde_json::from_slice::<Request>(raw) else { return };
|
||||
let order = request.params.schema.properties.0;
|
||||
form.fields.sort_by_key(|field| order.iter().position(|name| *name == field.name).unwrap_or(usize::MAX));
|
||||
}
|
||||
|
||||
fn field(name: &str, property: &Value, required: bool) -> Result<AgentFormField, String> {
|
||||
let text = |key: &str| property.get(key).and_then(Value::as_str).unwrap_or_default().trim().to_owned();
|
||||
let number = |key: &str| property.get(key).and_then(Value::as_f64);
|
||||
let unsupported = || format!("field {name} has an unsupported schema");
|
||||
let (kind, options, min, max) = match property.get("type").and_then(Value::as_str) {
|
||||
Some("string") => match choices(property) {
|
||||
Some(options) => (AgentFormFieldKind::Choice, options, None, None),
|
||||
None => (AgentFormFieldKind::Text, Vec::new(), number("minLength"), number("maxLength")),
|
||||
},
|
||||
Some("number") => (AgentFormFieldKind::Number, Vec::new(), number("minimum"), number("maximum")),
|
||||
Some("integer") => (AgentFormFieldKind::Integer, Vec::new(), number("minimum"), number("maximum")),
|
||||
Some("boolean") => (AgentFormFieldKind::Boolean, Vec::new(), None, None),
|
||||
Some("array") => {
|
||||
let options = property.get("items").and_then(choices).ok_or_else(unsupported)?;
|
||||
(AgentFormFieldKind::Choices, options, number("minItems"), number("maxItems"))
|
||||
}
|
||||
_ => return Err(unsupported()),
|
||||
};
|
||||
let default = match property.get("default") {
|
||||
Some(Value::Array(values)) => values.iter().filter_map(scalar).collect(),
|
||||
Some(value) => scalar(value).into_iter().collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let title = text("title");
|
||||
Ok(AgentFormField {
|
||||
name: name.to_owned(),
|
||||
title: if title.is_empty() { name.to_owned() } else { title },
|
||||
description: text("description"),
|
||||
required,
|
||||
kind,
|
||||
options,
|
||||
default,
|
||||
min,
|
||||
max,
|
||||
})
|
||||
}
|
||||
|
||||
/// The values of a choice: `enum` (titled by the legacy `enumNames`), or `oneOf` / `anyOf`
|
||||
/// of `{ const, title }`.
|
||||
fn choices(schema: &Value) -> Option<Vec<AgentFormOption>> {
|
||||
let option = |value: &str, label: Option<&str>, description: Option<&str>| AgentFormOption {
|
||||
value: value.to_owned(),
|
||||
label: label.filter(|l| !l.trim().is_empty()).unwrap_or(value).to_owned(),
|
||||
description: description.unwrap_or_default().trim().to_owned(),
|
||||
};
|
||||
if let Some(values) = schema.get("enum").and_then(Value::as_array) {
|
||||
let names = schema.get("enumNames").and_then(Value::as_array);
|
||||
return Some(
|
||||
values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, value)| {
|
||||
Some(option(value.as_str()?, names.and_then(|n| n.get(i)).and_then(Value::as_str), None))
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
let listed = schema.get("oneOf").or_else(|| schema.get("anyOf"))?.as_array()?;
|
||||
Some(
|
||||
listed
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
let text = |key: &str| o.get(key).and_then(Value::as_str);
|
||||
Some(option(text("const")?, text("title"), text("description")))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn scalar(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(text) => Some(text.clone()),
|
||||
Value::Number(number) => Some(number.to_string()),
|
||||
Value::Bool(flag) => Some(flag.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A form is submitted from its own card; this option only names a declined form.
|
||||
pub(crate) fn options() -> Vec<AgentPermissionOption> {
|
||||
vec![AgentPermissionOption { id: DECLINE.to_owned(), name: "Decline".to_owned(), kind: "reject_once".to_owned() }]
|
||||
}
|
||||
|
||||
/// The `elicitation/create` response to the user's `reply`, and what the conversation
|
||||
/// records as chosen.
|
||||
pub(crate) fn answer(form: &AgentForm, reply: Option<&str>) -> (Value, String) {
|
||||
let values = match reply {
|
||||
Some(DECLINE) => return (json!({ "action": "decline" }), "Decline".to_owned()),
|
||||
Some(reply) => serde_json::from_str::<Map<String, Value>>(reply).ok(),
|
||||
None => None,
|
||||
};
|
||||
let Some(values) = values else { return (json!({ "action": "cancel" }), "Cancelled".to_owned()) };
|
||||
let mut content = Map::new();
|
||||
let mut shown = Vec::new();
|
||||
for field in &form.fields {
|
||||
let given: Vec<&str> = match values.get(&field.name) {
|
||||
Some(Value::Array(items)) => items.iter().filter_map(Value::as_str).collect(),
|
||||
Some(Value::String(item)) => vec![item.as_str()],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let given: Vec<&str> = given.into_iter().map(str::trim).filter(|v| !v.is_empty()).collect();
|
||||
let Some(&first) = given.first() else { continue };
|
||||
let value = match field.kind {
|
||||
AgentFormFieldKind::Text | AgentFormFieldKind::Choice => json!(first),
|
||||
AgentFormFieldKind::Number => match first.parse::<f64>() {
|
||||
Ok(number) => json!(number),
|
||||
Err(_) => continue,
|
||||
},
|
||||
AgentFormFieldKind::Integer => match first.parse::<i64>() {
|
||||
Ok(number) => json!(number),
|
||||
Err(_) => continue,
|
||||
},
|
||||
AgentFormFieldKind::Boolean => json!(first == "true"),
|
||||
AgentFormFieldKind::Choices => json!(given),
|
||||
};
|
||||
let label = |value: &str| {
|
||||
field.options.iter().find(|o| o.value == value).map_or_else(|| value.to_owned(), |o| o.label.clone())
|
||||
};
|
||||
let text = match field.kind {
|
||||
AgentFormFieldKind::Boolean => (if first == "true" { "yes" } else { "no" }).to_owned(),
|
||||
AgentFormFieldKind::Choices => given.iter().map(|v| label(v)).collect::<Vec<_>>().join(", "),
|
||||
_ => label(first),
|
||||
};
|
||||
shown.push(format!("{}: {text}", field.title));
|
||||
content.insert(field.name.clone(), value);
|
||||
}
|
||||
let chosen = if shown.is_empty() { "Submitted".to_owned() } else { shown.join("; ") };
|
||||
(json!({ "action": "accept", "content": content }), chosen)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The form OMP's `ask` tool sends: a choice with a recommended default, then an
|
||||
/// optional typed answer beside it.
|
||||
fn ask() -> Value {
|
||||
json!({
|
||||
"sessionId": "s", "mode": "form", "message": "Which database?",
|
||||
"requestedSchema": { "type": "object", "properties": {
|
||||
"q0": { "type": "string", "title": "Which database?", "description": "Storage",
|
||||
"oneOf": [{ "const": "pg", "title": "Postgres", "description": "Relational" },
|
||||
{ "const": "sqlite", "title": "SQLite" }],
|
||||
"default": "pg" },
|
||||
"q0__other": { "type": "string", "title": "Other (type your own)" }
|
||||
} }
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_follow_the_schema_order() {
|
||||
let raw = br#"{ "jsonrpc": "2.0", "id": 3, "method": "elicitation/create", "params": { "mode": "form",
|
||||
"requestedSchema": { "type": "object", "properties": {
|
||||
"strategy": { "type": "string" }, "tests": { "type": "boolean" }, "note": { "type": "string" }
|
||||
} } } }"#;
|
||||
let params = serde_json::from_slice::<Value>(raw).unwrap()["params"].take();
|
||||
let mut form = form_of(¶ms).unwrap();
|
||||
in_schema_order(&mut form, raw);
|
||||
assert_eq!(form.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["strategy", "tests", "note"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_choices_and_text_fields() {
|
||||
let form = form_of(&ask()).unwrap();
|
||||
let [choice, other] = form.fields.as_slice() else { panic!("{form:?}") };
|
||||
assert_eq!((choice.name.as_str(), choice.kind, choice.required), ("q0", AgentFormFieldKind::Choice, false));
|
||||
assert_eq!(choice.options[0].label, "Postgres");
|
||||
assert_eq!(choice.options[0].description, "Relational");
|
||||
assert_eq!(choice.default, ["pg"]);
|
||||
assert_eq!((other.kind, other.title.as_str()), (AgentFormFieldKind::Text, "Other (type your own)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_every_restricted_schema_kind() {
|
||||
let form = form_of(&json!({ "mode": "form", "requestedSchema": {
|
||||
"type": "object", "required": ["count"],
|
||||
"properties": {
|
||||
"count": { "type": "integer", "minimum": 1, "maximum": 5 },
|
||||
"ratio": { "type": "number" },
|
||||
"ok": { "type": "boolean", "default": true },
|
||||
"size": { "type": "string", "enum": ["s", "l"], "enumNames": ["Small", "Large"] },
|
||||
"tags": { "type": "array", "items": { "anyOf": [{ "const": "a" }, { "const": "b" }] },
|
||||
"default": ["b"], "maxItems": 2 }
|
||||
}
|
||||
} }))
|
||||
.unwrap();
|
||||
let field = |name: &str| form.fields.iter().find(|f| f.name == name).unwrap();
|
||||
use AgentFormFieldKind::*;
|
||||
let kinds: Vec<_> = ["count", "ratio", "ok", "size", "tags"].map(|name| field(name).kind).into();
|
||||
assert_eq!(kinds, [Integer, Number, Boolean, Choice, Choices]);
|
||||
assert!(field("count").required && !field("ratio").required);
|
||||
assert_eq!((field("count").min, field("count").max), (Some(1.0), Some(5.0)));
|
||||
assert_eq!(field("ok").default, ["true"]);
|
||||
assert_eq!(field("size").options[1].label, "Large");
|
||||
assert_eq!((field("tags").default.as_slice(), field("tags").max), (["b".to_owned()].as_slice(), Some(2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_mode_and_unsupported_fields_are_refused() {
|
||||
assert!(form_of(&json!({ "mode": "url", "url": "https://example.com" })).is_err());
|
||||
let nested = json!({ "mode": "form", "requestedSchema": { "type": "object", "properties": {
|
||||
"address": { "type": "object" }
|
||||
} } });
|
||||
assert!(form_of(&nested).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_is_typed_by_its_field() {
|
||||
let form = form_of(&json!({ "mode": "form", "requestedSchema": { "type": "object", "properties": {
|
||||
"count": { "type": "integer" },
|
||||
"ok": { "type": "boolean" },
|
||||
"size": { "type": "string", "oneOf": [{ "const": "s", "title": "Small" }] },
|
||||
"tags": { "type": "array", "items": { "enum": ["a", "b"] } },
|
||||
"note": { "type": "string" }
|
||||
} } }))
|
||||
.unwrap();
|
||||
let reply = json!({ "count": ["3"], "ok": ["false"], "size": ["s"], "tags": ["a", "b"], "note": [" "] });
|
||||
let (response, chosen) = answer(&form, Some(&reply.to_string()));
|
||||
assert_eq!(
|
||||
response,
|
||||
json!({ "action": "accept", "content": { "count": 3, "ok": false, "size": "s", "tags": ["a", "b"] } })
|
||||
);
|
||||
assert_eq!(chosen, "count: 3; ok: no; size: Small; tags: a, b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declining_and_dismissing_send_no_content() {
|
||||
let form = form_of(&ask()).unwrap();
|
||||
assert_eq!(answer(&form, Some(DECLINE)), (json!({ "action": "decline" }), "Decline".to_owned()));
|
||||
assert_eq!(answer(&form, None).0, json!({ "action": "cancel" }));
|
||||
assert_eq!(answer(&form, Some("cancel")).0, json!({ "action": "cancel" }));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod acp;
|
||||
mod catalogs;
|
||||
mod config;
|
||||
mod conversation;
|
||||
mod elicitation;
|
||||
mod external;
|
||||
pub mod mcp;
|
||||
mod model;
|
||||
@@ -187,7 +188,7 @@ impl Shared {
|
||||
|
||||
fn config_for_new_task(&self, mut config: Vec<AgentConfigOption>, provider: &str) -> Vec<AgentConfigOption> {
|
||||
// Model-specific options are offered separately, never as provider-wide defaults.
|
||||
config.retain(|o| o.category.as_deref() != Some("thought_level"));
|
||||
config.retain(|o| !config::is_per_model_option(o));
|
||||
config::add_client_options(provider, &mut config);
|
||||
let hook = self.new_task_approvals.read().clone();
|
||||
if hook.is_some_and(|ask| ask() == ide_core::settings::NewTaskApprovals::Ask) {
|
||||
@@ -223,6 +224,7 @@ impl Shared {
|
||||
commands: Option<&[AgentCommand]>,
|
||||
model: Option<&str>,
|
||||
) {
|
||||
let acp = self.spec(provider).is_some_and(|spec| spec.driver == provider::Driver::Acp);
|
||||
let mut cache = self.known.lock();
|
||||
let known = cache.entry(provider.to_owned()).or_default();
|
||||
let mut changed = false;
|
||||
@@ -232,8 +234,7 @@ impl Shared {
|
||||
&& let Some(config) = config.filter(|c| !c.is_empty())
|
||||
{
|
||||
if let Some(model) = model {
|
||||
let levels: Vec<_> =
|
||||
config.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
|
||||
let levels: Vec<_> = config.iter().filter(|o| config::is_per_model_option(o)).cloned().collect();
|
||||
if known.thought_levels.get(model) != Some(&levels) {
|
||||
known.thought_levels.insert(model.to_owned(), levels);
|
||||
changed = true;
|
||||
@@ -244,6 +245,11 @@ impl Shared {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// Discovery reads one model's options; a session on another model reports that model's.
|
||||
let learned = model.filter(|_| acp && known.authoritative).zip(config).map(|(model, config)| {
|
||||
let levels: Vec<_> = config.iter().filter(|o| config::is_per_model_option(o)).cloned().collect();
|
||||
(model, levels)
|
||||
});
|
||||
if let Some(commands) = commands.filter(|c| !c.is_empty())
|
||||
&& known.commands.get(&self.root_key()).map(Vec::as_slice) != Some(commands)
|
||||
{
|
||||
@@ -254,6 +260,9 @@ impl Shared {
|
||||
self.persist_known(&cache);
|
||||
}
|
||||
drop(cache);
|
||||
if let Some((model, levels)) = learned.filter(|(_, levels)| !levels.is_empty()) {
|
||||
self.paths.catalogs.learn_levels(self, provider, model, levels);
|
||||
}
|
||||
if changed {
|
||||
self.emit(AgentEvent::Providers);
|
||||
}
|
||||
@@ -1000,8 +1009,11 @@ impl AgentManager {
|
||||
task.emit_task();
|
||||
|
||||
let live = task.live.lock().clone().filter(|l| !l.peer.is_closed());
|
||||
let mut model_options =
|
||||
target.category.as_deref() != Some("model") || previous.as_deref() == Some(value.as_str());
|
||||
// The task's copy holds its model's per-model options unless a model switch the agent
|
||||
// never reported dropped them: caching that copy would erase the model's known levels.
|
||||
let mut model_options = (target.category.as_deref() != Some("model")
|
||||
|| previous.as_deref() == Some(value.as_str()))
|
||||
&& task.state.lock().task.config.iter().any(config::is_per_model_option);
|
||||
if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) {
|
||||
let (method, mut params) = config::set_request(&target, &value);
|
||||
params["sessionId"] = json!(live.session);
|
||||
@@ -1032,7 +1044,7 @@ impl AgentManager {
|
||||
{
|
||||
let mut s = task.state.lock();
|
||||
if !model_options {
|
||||
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
|
||||
s.task.config.retain(|o| !config::is_per_model_option(o));
|
||||
}
|
||||
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
|
||||
o.current = Some(value);
|
||||
@@ -2110,10 +2122,10 @@ impl Task {
|
||||
(if known { reported } else { s.task.config.clone() }, s.wanted.clone())
|
||||
};
|
||||
config::add_client_options(&provider_id, &mut options);
|
||||
// Changing model can replace the effort choices; validate those only afterwards.
|
||||
wanted.sort_by_key(|c| match options.iter().find(|o| o.id == c.id).and_then(|o| o.category.as_deref()) {
|
||||
Some("model") => 0,
|
||||
Some("thought_level") => 2,
|
||||
// Changing model can replace the effort / context-tier choices; validate those only afterwards.
|
||||
wanted.sort_by_key(|c| match options.iter().find(|o| o.id == c.id) {
|
||||
Some(o) if o.category.as_deref() == Some("model") => 0,
|
||||
Some(o) if config::is_per_model_option(o) => 2,
|
||||
_ => 1,
|
||||
});
|
||||
let mut model_options = known;
|
||||
@@ -2126,7 +2138,7 @@ impl Task {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if o.category.as_deref() == Some("thought_level") && !model_options {
|
||||
if config::is_per_model_option(&o) && !model_options {
|
||||
continue;
|
||||
}
|
||||
let already = known && o.current.as_deref() == Some(choice.value.as_str());
|
||||
@@ -2143,7 +2155,7 @@ impl Task {
|
||||
model_options = true;
|
||||
} else if o.category.as_deref() == Some("model") {
|
||||
if o.current.as_deref() != Some(choice.value.as_str()) {
|
||||
options.retain(|o| o.category.as_deref() != Some("thought_level"));
|
||||
options.retain(|o| !config::is_per_model_option(o));
|
||||
}
|
||||
model_options = false;
|
||||
}
|
||||
@@ -2207,14 +2219,17 @@ impl Task {
|
||||
if let Some(fresh) = reply.get("configOptions") {
|
||||
config::merge_update(&mut s.task.config, fresh);
|
||||
} else if option.category.as_deref() == Some("model") {
|
||||
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
|
||||
s.task.config.retain(|o| !config::is_per_model_option(o));
|
||||
}
|
||||
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option.id) {
|
||||
o.current = Some(value);
|
||||
}
|
||||
s.task.model = model_of(&s.task.config).or(s.task.model.take());
|
||||
// As in `set_config`: a copy without per-model options can't vouch for them.
|
||||
let model = s.task.model.clone().filter(|_| {
|
||||
reply.get("configOptions").is_some() || option.category.as_deref() != Some("model")
|
||||
reply.get("configOptions").is_some()
|
||||
|| (option.category.as_deref() != Some("model")
|
||||
&& s.task.config.iter().any(config::is_per_model_option))
|
||||
});
|
||||
let (provider, options) = (s.task.provider.clone(), s.task.config.clone());
|
||||
drop(s);
|
||||
@@ -2378,12 +2393,7 @@ impl Task {
|
||||
// first question says what it wants, as the Claude Code adapter titles its own.
|
||||
let title =
|
||||
if by_index { questions.first().map(|question| question.question.clone()).unwrap_or(title) } else { title };
|
||||
// A subagent's request is answered in the task's conversation, named after the subagent.
|
||||
let session = params.get("sessionId").and_then(Value::as_str);
|
||||
let title = match session.and_then(|s| self.state.lock().conversation.label(s)) {
|
||||
Some(label) => format!("{label}: {title}"),
|
||||
None => title,
|
||||
};
|
||||
let title = self.labelled(params, title);
|
||||
let options: Vec<AgentPermissionOption> = params
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
@@ -2415,6 +2425,7 @@ impl Task {
|
||||
command: None,
|
||||
options,
|
||||
questions: Vec::new(),
|
||||
form: None,
|
||||
chosen: Some("Denied: side conversations are read-only".to_owned()),
|
||||
});
|
||||
return match reject {
|
||||
@@ -2441,6 +2452,7 @@ impl Task {
|
||||
command: shown.clone(),
|
||||
options,
|
||||
questions: Vec::new(),
|
||||
form: None,
|
||||
chosen: Some(format!("Blocked by policy ({pattern})")),
|
||||
});
|
||||
return match reject {
|
||||
@@ -2461,6 +2473,7 @@ impl Task {
|
||||
command: shown.clone(),
|
||||
options,
|
||||
questions: Vec::new(),
|
||||
form: None,
|
||||
chosen: Some(format!("{} (whitelisted)", allow.name)),
|
||||
});
|
||||
return json!({ "outcome": { "outcome": "selected", "optionId": allow.id } });
|
||||
@@ -2476,6 +2489,7 @@ impl Task {
|
||||
command: shown,
|
||||
options: options.clone(),
|
||||
questions,
|
||||
form: None,
|
||||
chosen: None,
|
||||
});
|
||||
self.permissions.lock().insert(id, tx);
|
||||
@@ -2502,17 +2516,7 @@ impl Task {
|
||||
.and_then(|reply| options.iter().find(|option| &option.id == reply))
|
||||
.map_or_else(|| "Cancelled".to_owned(), |option| option.name.clone()),
|
||||
};
|
||||
{
|
||||
let mut s = self.state.lock();
|
||||
if let Some(AgentEntry::Permission { chosen, .. }) = s.conversation.main.entries.get_mut(entry) {
|
||||
*chosen = Some(name);
|
||||
}
|
||||
}
|
||||
self.mark_dirty([(None, entry)]);
|
||||
let still_asking = !self.permissions.lock().is_empty();
|
||||
if !still_asking && self.state.lock().task.status == AgentStatus::NeedsInput {
|
||||
self.set_status(AgentStatus::Running, None);
|
||||
}
|
||||
self.settle(entry, name);
|
||||
// An agent that answers by index reads the answers beside the outcome, keyed by
|
||||
// question index, with its Submit option chosen; anything less cancels the request.
|
||||
if by_index {
|
||||
@@ -2544,6 +2548,57 @@ impl Task {
|
||||
}
|
||||
}
|
||||
|
||||
/// A request from a subagent is answered in the task's conversation, named after the subagent.
|
||||
fn labelled(&self, params: &Value, title: String) -> String {
|
||||
let session = params.get("sessionId").and_then(Value::as_str);
|
||||
match session.and_then(|s| self.state.lock().conversation.label(s)) {
|
||||
Some(label) => format!("{label}: {title}"),
|
||||
None => title,
|
||||
}
|
||||
}
|
||||
|
||||
/// An agent's form (`elicitation/create`): asked in the conversation like a permission
|
||||
/// request, and answered with its values, declined or cancelled. Blocks like `ask_permission`.
|
||||
fn elicit(self: &Arc<Self>, params: &Value, raw: Option<&[u8]>) -> Result<Value, String> {
|
||||
let mut form = elicitation::form_of(params)?;
|
||||
if let Some(raw) = raw {
|
||||
elicitation::in_schema_order(&mut form, raw);
|
||||
}
|
||||
let id = self.next_permission.fetch_add(1, Ordering::Relaxed);
|
||||
let message = params.get("message").and_then(Value::as_str).unwrap_or_default().trim().to_owned();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let entry = self.push_entry(AgentEntry::Permission {
|
||||
id,
|
||||
title: self.labelled(params, message),
|
||||
command: None,
|
||||
options: elicitation::options(),
|
||||
questions: Vec::new(),
|
||||
form: Some(form.clone()),
|
||||
chosen: None,
|
||||
});
|
||||
self.permissions.lock().insert(id, tx);
|
||||
self.set_status(AgentStatus::NeedsInput, None);
|
||||
let reply = rx.recv().ok().flatten();
|
||||
let (response, chosen) = elicitation::answer(&form, reply.as_deref());
|
||||
self.settle(entry, chosen);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Records the answer to the request at `entry`; the turn runs on once nothing else waits.
|
||||
fn settle(self: &Arc<Self>, entry: usize, chosen: String) {
|
||||
{
|
||||
let mut s = self.state.lock();
|
||||
if let Some(AgentEntry::Permission { chosen: answer, .. }) = s.conversation.main.entries.get_mut(entry) {
|
||||
*answer = Some(chosen);
|
||||
}
|
||||
}
|
||||
self.mark_dirty([(None, entry)]);
|
||||
let still_asking = !self.permissions.lock().is_empty();
|
||||
if !still_asking && self.state.lock().task.status == AgentStatus::NeedsInput {
|
||||
self.set_status(AgentStatus::Running, None);
|
||||
}
|
||||
}
|
||||
|
||||
fn approve_pending(&self) {
|
||||
let s = self.state.lock();
|
||||
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.config) {
|
||||
@@ -2608,9 +2663,18 @@ impl Handler for Client {
|
||||
let task = self.task.upgrade().ok_or((codes::INTERNAL_ERROR, "task closed".to_owned()))?;
|
||||
match method {
|
||||
"session/request_permission" => Ok(task.ask_permission(¶ms)),
|
||||
"elicitation/create" => task.elicit(¶ms, None).map_err(|e| (codes::INVALID_PARAMS, e)),
|
||||
_ => Err((codes::METHOD_NOT_FOUND, format!("unsupported method {method}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_request_raw(&self, method: &str, params: Value, raw: &[u8]) -> Result<Value, (i64, String, Option<Value>)> {
|
||||
if method != "elicitation/create" {
|
||||
return self.on_request_with_data(method, params);
|
||||
}
|
||||
let task = self.task.upgrade().ok_or((codes::INTERNAL_ERROR, "task closed".to_owned(), None))?;
|
||||
task.elicit(¶ms, Some(raw)).map_err(|e| (codes::INVALID_PARAMS, e, None))
|
||||
}
|
||||
}
|
||||
|
||||
/// The prompt trimmed, or `None` when there is nothing to send.
|
||||
|
||||
@@ -130,6 +130,14 @@ pub struct AgentConfigValue {
|
||||
pub value: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Relative cost category from ACP `_meta` (`copilotPriceCategory`, e.g. `low`, `medium`, `high`, `very_high`).
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub cost_category: Option<String>,
|
||||
/// Usage multiplier from ACP `_meta` (`copilotUsage`, e.g. `1x`, `0x`, `3x`).
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub usage_multiplier: Option<String>,
|
||||
}
|
||||
|
||||
/// How a setting is changed.
|
||||
@@ -155,6 +163,10 @@ pub struct AgentConfigOption {
|
||||
/// ACP category: `model`, `mode`, `thought_level`, or agent-specific.
|
||||
pub category: Option<String>,
|
||||
pub current: Option<String>,
|
||||
/// Initial default value reported by the agent, preserved across session updates for Reset.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub default_value: Option<String>,
|
||||
pub values: Vec<AgentConfigValue>,
|
||||
pub via: AgentConfigVia,
|
||||
}
|
||||
@@ -316,6 +328,59 @@ pub struct AgentQuestionOption {
|
||||
pub preview: Option<String>,
|
||||
}
|
||||
|
||||
/// A form the agent asks the user to fill in (ACP `elicitation/create`, form mode).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentForm {
|
||||
pub fields: Vec<AgentFormField>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentFormField {
|
||||
/// The property name the answer is keyed by.
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
pub kind: AgentFormFieldKind,
|
||||
/// The choices of a `choice` or `choices` field.
|
||||
#[serde(default)]
|
||||
pub options: Vec<AgentFormOption>,
|
||||
/// The initial answer: option values, a text, a number, or `true` / `false`.
|
||||
#[serde(default)]
|
||||
pub default: Vec<String>,
|
||||
/// Bounds: the number of a `number` / `integer`, the length of a `text`, the count of `choices`.
|
||||
#[serde(default)]
|
||||
pub min: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub max: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentFormFieldKind {
|
||||
Text,
|
||||
Number,
|
||||
Integer,
|
||||
Boolean,
|
||||
/// One of `options`.
|
||||
Choice,
|
||||
/// Any of `options`.
|
||||
Choices,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentFormOption {
|
||||
pub value: String,
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// One item of a task's conversation.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
@@ -396,6 +461,10 @@ pub enum AgentEntry {
|
||||
/// of question to answer instead of an option.
|
||||
#[serde(default)]
|
||||
questions: Vec<AgentQuestion>,
|
||||
/// Set when the agent asks for a form (ACP elicitation); answered with a JSON object of
|
||||
/// field name to its values, `decline`, or nothing to cancel.
|
||||
#[serde(default)]
|
||||
form: Option<AgentForm>,
|
||||
/// Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled".
|
||||
chosen: Option<String>,
|
||||
},
|
||||
|
||||
@@ -295,6 +295,23 @@ impl Handler for FakeAgent {
|
||||
self.say(&session, &format!("escalation chose {chosen}"));
|
||||
return Ok(json!({"stopReason": "end_turn"}));
|
||||
}
|
||||
if text == "form" {
|
||||
let peer = self.latest();
|
||||
let answer: Value = tokio::runtime::Handle::current()
|
||||
.block_on(peer.request(
|
||||
"elicitation/create",
|
||||
json!({"sessionId": session, "mode": "form", "message": "How should I refactor?",
|
||||
"requestedSchema": {"type": "object", "required": ["strategy"], "properties": {
|
||||
"strategy": {"type": "string", "title": "Strategy",
|
||||
"oneOf": [{"const": "safe", "title": "Conservative"},
|
||||
{"const": "bold", "title": "Aggressive"}]},
|
||||
"tests": {"type": "boolean", "title": "Run tests"}}}}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
self.say(&session, &format!("form {answer}"));
|
||||
return Ok(json!({ "stopReason": "end_turn" }));
|
||||
}
|
||||
if text == "ask" {
|
||||
let peer = self.latest();
|
||||
let answer: Value = tokio::runtime::Handle::current()
|
||||
@@ -1778,6 +1795,45 @@ async fn thinking_levels_are_remembered_per_provider_and_model() {
|
||||
restored.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn changing_an_idle_tasks_settings_keeps_its_models_effort_levels() {
|
||||
// After a model switch no session reported, picking that model again or changing
|
||||
// another setting must not record the task's effort-less copy as the model's levels.
|
||||
for (option, value) in [("mode", "code"), ("model", "big")] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
*agent.model_options.lock() = HashMap::from([
|
||||
("big".into(), effort_options(&["low", "high"], "high")),
|
||||
("small".into(), effort_options(&["minimal", "medium"], "minimal")),
|
||||
]);
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("again"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
mgr.shutdown();
|
||||
let restored = manager(tmp.path(), agent);
|
||||
restored.set_config(&task.id, "model", "small".into()).await.unwrap();
|
||||
restored.set_config(&task.id, "model", "big".into()).await.unwrap();
|
||||
restored.set_config(&task.id, option, value.into()).await.unwrap();
|
||||
let p = restored.providers().into_iter().find(|p| p.id == "claude").unwrap();
|
||||
let big = p.thought_levels.get("big").and_then(|levels| levels.first());
|
||||
assert_eq!(big.map(|o| o.values.len()), Some(2), "{option}: {:?}", p.thought_levels);
|
||||
let saved: HashMap<String, Known> =
|
||||
serde_json::from_slice(&std::fs::read(&restored.shared.paths.models_file).unwrap()).unwrap();
|
||||
assert_eq!(saved["claude"].thought_levels, p.thought_levels, "{option}");
|
||||
restored.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn upfront_effort_is_validated_after_changing_model_before_the_prompt() {
|
||||
for (effort, applied) in [("medium", true), ("high", false)] {
|
||||
@@ -2035,6 +2091,58 @@ async fn bypass_approvals_keeps_both_question_dialects_pending_but_allows_permis
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_agent_form_is_answered_declined_or_cancelled_even_when_approvals_are_bypassed() {
|
||||
let accept = json!({"strategy": ["bold"], "tests": ["true"]}).to_string();
|
||||
for (reply, said, chosen) in [
|
||||
(
|
||||
Some(accept.as_str()),
|
||||
r#"form {"action":"accept","content":{"strategy":"bold","tests":true}}"#,
|
||||
"Strategy: Aggressive; Run tests: yes",
|
||||
),
|
||||
(Some("decline"), r#"form {"action":"decline"}"#, "Decline"),
|
||||
(None, r#"form {"action":"cancel"}"#, "Cancelled"),
|
||||
] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("form"),
|
||||
provider: "cursor".into(),
|
||||
config: vec![AgentConfigChoice { id: config::CURSOR_APPROVAL.into(), value: "bypass".into() }],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
let (id, form) = mgr
|
||||
.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
AgentEntry::Permission { id, title, form: Some(form), chosen: None, .. }
|
||||
if title == "How should I refactor?" =>
|
||||
{
|
||||
Some((id, form))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(form.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["strategy", "tests"]);
|
||||
mgr.respond(&task.id, id, reply.map(str::to_owned)).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
assert!(entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text == said)), "{entries:?}");
|
||||
assert!(
|
||||
entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == chosen)),
|
||||
"{entries:?}"
|
||||
);
|
||||
mgr.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cursor_approval_choice_applies_before_first_prompt() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -441,6 +441,7 @@ mod tests {
|
||||
command: None,
|
||||
options: vec![],
|
||||
questions: vec![],
|
||||
form: None,
|
||||
chosen: None,
|
||||
});
|
||||
assert_eq!(t.end_turn(None), vec![2, 3]);
|
||||
|
||||
@@ -218,16 +218,20 @@ where
|
||||
Ok(serde_json::from_value(serde_json::Value::deserialize(d)?).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The built-in themes that were removed (#2981; the One Dark Pro plugin
|
||||
/// maintains their look) and the built-in that stands in for each: read by
|
||||
/// [`theme_id`], and the removal notice looks for them in settings files.
|
||||
pub const REMOVED_THEMES: [(&str, &str); 2] = [("one-light", "light"), ("one-dark", "dark")];
|
||||
|
||||
/// A theme id, with the built-in themes that were removed read as the Islands theme of their kind.
|
||||
fn theme_id<'de, D>(d: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let id = String::deserialize(d)?;
|
||||
Ok(match id.as_str() {
|
||||
"one-light" => "light".into(),
|
||||
"one-dark" => "dark".into(),
|
||||
_ => id,
|
||||
Ok(match REMOVED_THEMES.iter().find(|(removed, _)| *removed == id.as_str()) {
|
||||
Some((_, stand_in)) => (*stand_in).to_owned(),
|
||||
None => id,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ pub enum Tool {
|
||||
Maven,
|
||||
Gradle,
|
||||
Ruby,
|
||||
Zig,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
@@ -32,6 +33,7 @@ impl Tool {
|
||||
Tool::Maven => &["maven"],
|
||||
Tool::Gradle => &["gradle"],
|
||||
Tool::Ruby => &["ruby"],
|
||||
Tool::Zig => &["zig"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ pub enum Capability {
|
||||
Ipc(String),
|
||||
/// Run one external program. Full code execution: never fine-grained.
|
||||
Process(String),
|
||||
/// Send a running language server (its name in the language definition) its own
|
||||
/// requests. The server reads and edits project files on the plugin's behalf.
|
||||
Lsp(String),
|
||||
/// Read files outside the project under one path.
|
||||
FsRead(String),
|
||||
/// Write files outside the project under one path.
|
||||
@@ -85,6 +88,7 @@ impl Capability {
|
||||
| Self::IdeCommands
|
||||
| Self::Ipc(_)
|
||||
| Self::Process(_)
|
||||
| Self::Lsp(_)
|
||||
| Self::FsRead(_)
|
||||
| Self::FsWrite(_) => Risk::High,
|
||||
}
|
||||
@@ -126,6 +130,9 @@ impl Capability {
|
||||
format!("Connect to local socket {path} — equivalent to running arbitrary code on the project machine")
|
||||
}
|
||||
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
|
||||
Self::Lsp(server) => {
|
||||
format!("Send requests to the {server} language server, which reads and changes the project's files")
|
||||
}
|
||||
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
|
||||
Self::FsWrite(path) => format!("Change files under {path}, outside the project"),
|
||||
}
|
||||
@@ -249,6 +256,7 @@ impl fmt::Display for Capability {
|
||||
Self::NetHttp(h) => write!(f, "net.http:{h}"),
|
||||
Self::Ipc(p) => write!(f, "ipc:{p}"),
|
||||
Self::Process(b) => write!(f, "process:{b}"),
|
||||
Self::Lsp(s) => write!(f, "lsp:{s}"),
|
||||
Self::FsRead(p) => write!(f, "fs.read:{p}"),
|
||||
Self::FsWrite(p) => write!(f, "fs.write:{p}"),
|
||||
}
|
||||
@@ -298,6 +306,12 @@ impl FromStr for Capability {
|
||||
}
|
||||
Ok(Self::Process(arg.to_owned()))
|
||||
}
|
||||
"lsp" => {
|
||||
if arg.len() > 128 || arg.chars().any(|c| c.is_whitespace() || c.is_control() || c == ':') {
|
||||
return Err(format!("{s:?}: use the server's name in its language definition"));
|
||||
}
|
||||
Ok(Self::Lsp(arg.to_owned()))
|
||||
}
|
||||
"fs.read" | "fs.write" => {
|
||||
if !std::path::Path::new(arg).is_absolute() && !arg.starts_with('~') {
|
||||
return Err(format!("{s:?}: use an absolute path (or ~/…)"));
|
||||
@@ -376,6 +390,8 @@ mod tests {
|
||||
"net:crates.io",
|
||||
"net:*.github.com",
|
||||
"process:cargo",
|
||||
"lsp:phoxite",
|
||||
"lsp:rust-analyzer",
|
||||
] {
|
||||
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
|
||||
}
|
||||
@@ -392,6 +408,9 @@ mod tests {
|
||||
assert!("net:".parse::<Capability>().is_err());
|
||||
assert!("net:a b".parse::<Capability>().is_err());
|
||||
assert!("process:./evil".parse::<Capability>().is_err());
|
||||
assert!("lsp:".parse::<Capability>().is_err());
|
||||
assert!("lsp:a b".parse::<Capability>().is_err());
|
||||
assert!("lsp:phoxite:phoxite/laravel".parse::<Capability>().is_err());
|
||||
assert!("fs.read:rel".parse::<Capability>().is_err());
|
||||
assert!("fs.write:rel".parse::<Capability>().is_err());
|
||||
assert!("network".parse::<Capability>().is_err());
|
||||
@@ -405,6 +424,8 @@ mod tests {
|
||||
assert_eq!("clipboard".parse::<Capability>().unwrap(), Capability::Clipboard);
|
||||
assert_eq!(Capability::Clipboard.risk(), Risk::Medium);
|
||||
assert_eq!(Capability::Process("cargo".into()).risk(), Risk::High);
|
||||
assert_eq!(Capability::Lsp("phoxite".into()).risk(), Risk::High);
|
||||
assert!(!Capability::Lsp("phoxite".into()).covers(&Capability::Lsp("intelephense".into())));
|
||||
assert!(Capability::Storage.auto_migrates() && !Capability::EditorWrite.auto_migrates());
|
||||
}
|
||||
|
||||
|
||||
@@ -357,6 +357,9 @@ impl Manifest {
|
||||
if matches!(cap, Capability::NetAsk) {
|
||||
self.needs_api(&format!("capability `{cap}`"), 5)?;
|
||||
}
|
||||
if matches!(cap, Capability::Lsp(_)) {
|
||||
self.needs_api(&format!("capability `{cap}`"), 9)?;
|
||||
}
|
||||
}
|
||||
if let Some(dark) = &self.icon_dark {
|
||||
if self.icon.is_none() {
|
||||
@@ -712,6 +715,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_server_capabilities_need_api_1_9() {
|
||||
let caps = "[capabilities]
|
||||
required = [\"lsp:phoxite\"]
|
||||
";
|
||||
for engine in ["^1.2", "^1.8"] {
|
||||
assert!(
|
||||
err(&format!("{}{caps}", BASE.replace("^1.2", engine))).contains("new in plugin API 1.9"),
|
||||
"{engine}"
|
||||
);
|
||||
}
|
||||
let m = Manifest::parse(&format!("{}{caps}", BASE.replace("^1.2", "^1.9"))).unwrap();
|
||||
assert_eq!(m.capabilities.required, [Capability::Lsp("phoxite".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_icons() {
|
||||
let m = Manifest::parse(&format!("{BASE}icon = \"icon.svg\"\nicon-dark = \"icon_dark.svg\"\n")).unwrap();
|
||||
|
||||
@@ -65,6 +65,15 @@ pub fn settings(host: &Host, window: &str) -> Settings {
|
||||
})
|
||||
}
|
||||
|
||||
/// The removed built-in theme a settings file of this window still names, if any
|
||||
/// (`settings` reports its stand-in): the "provided by a plugin now" notice targets these.
|
||||
pub fn settings_removed_theme(host: &Host, window: &str) -> Option<String> {
|
||||
match host.project(window) {
|
||||
Ok(p) => p.settings.named_removed_theme().or_else(|| host.global_settings.named_removed_theme()),
|
||||
Err(_) => host.global_settings.named_removed_theme(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots> {
|
||||
Ok(host.project(window)?.roots())
|
||||
}
|
||||
|
||||
@@ -97,6 +97,21 @@ pub trait PluginWorkspace: Send + Sync {
|
||||
fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option<Vec<PathBuf>> {
|
||||
None
|
||||
}
|
||||
/// Sends `method` to the running language server named `server` of the project at
|
||||
/// `root`: a request, or with `notify` a notification (the result is null).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn lsp_request(
|
||||
&self,
|
||||
_root: &Path,
|
||||
server: &str,
|
||||
_method: &str,
|
||||
_params: Option<serde_json::Value>,
|
||||
_notify: bool,
|
||||
_cancel: &CancellationToken,
|
||||
_timeout: Duration,
|
||||
) -> Result<serde_json::Value, HostError> {
|
||||
Err(HostError::NotFound(format!("no language server `{server}` is running for this project")))
|
||||
}
|
||||
/// Current index of the project rooted at `root`; never reads disk or calls LSP.
|
||||
fn symbols(
|
||||
&self,
|
||||
@@ -2180,6 +2195,35 @@ impl HostApi for PluginApi {
|
||||
)
|
||||
}
|
||||
|
||||
fn lsp_request(
|
||||
&self,
|
||||
root: &Path,
|
||||
server: &str,
|
||||
method: &str,
|
||||
params: Option<String>,
|
||||
notify: bool,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<String, HostError> {
|
||||
self.require_api("`language-servers`", 9)?;
|
||||
if !ide_lsp::plugin_method_allowed(method) {
|
||||
return Err(HostError::Invalid(format!("`{method}` is not one of the server's own methods")));
|
||||
}
|
||||
let workspace = self
|
||||
.shared
|
||||
.workspace()
|
||||
.ok_or_else(|| HostError::NotFound(format!("no language server `{server}` is running for this project")))?;
|
||||
// The server reads and edits the project on the plugin's behalf, as a process would.
|
||||
if !workspace.trusted(root) {
|
||||
return Err(HostError::Denied("project is not trusted".into()));
|
||||
}
|
||||
let params = params
|
||||
.map(|p| serde_json::from_str(&p).map_err(|e| HostError::Invalid(format!("params are not JSON: {e}"))))
|
||||
.transpose()?;
|
||||
let result = workspace.lsp_request(root, server, method, params, notify, cancel, timeout)?;
|
||||
Ok(result.to_string())
|
||||
}
|
||||
|
||||
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError> {
|
||||
let matcher = globset::GlobBuilder::new(glob)
|
||||
.literal_separator(true)
|
||||
@@ -3302,6 +3346,34 @@ impl PluginWorkspace for HostWorkspace {
|
||||
}
|
||||
}
|
||||
|
||||
fn lsp_request(
|
||||
&self,
|
||||
root: &Path,
|
||||
server: &str,
|
||||
method: &str,
|
||||
params: Option<serde_json::Value>,
|
||||
notify: bool,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<serde_json::Value, HostError> {
|
||||
use ide_core::error::ErrorKind;
|
||||
let host = self.0.upgrade().ok_or(HostError::Cancelled)?;
|
||||
let project = HostWorkspace::project_covering(self, root)
|
||||
.ok_or_else(|| HostError::NotFound("the project is no longer open".into()))?;
|
||||
let result = if notify {
|
||||
project.lsp.server_notify(root, server, method, params).map(|()| serde_json::Value::Null)
|
||||
} else {
|
||||
host.runtime.block_on(project.lsp.server_request(root, server, method, params, cancel, timeout))
|
||||
};
|
||||
result.map_err(|e| match e.kind {
|
||||
// A server that stopped while the request was on its way is as absent as one never started.
|
||||
ErrorKind::NotFound | ErrorKind::Unavailable => HostError::NotFound(e.message),
|
||||
ErrorKind::InvalidInput => HostError::Invalid(e.message),
|
||||
ErrorKind::Cancelled => HostError::Cancelled,
|
||||
_ => HostError::Failed(e.message),
|
||||
})
|
||||
}
|
||||
|
||||
fn trusted(&self, root: &Path) -> bool {
|
||||
// A workspace member is as trusted as the group's project it was opened with.
|
||||
HostWorkspace::project_covering(self, root).is_some_and(|p| p.is_trusted())
|
||||
|
||||
@@ -165,6 +165,7 @@ impl Dispatcher {
|
||||
})
|
||||
.add("project_roots", |c| async move { ops::project::roots(&c.host, &c.window) })
|
||||
.add("settings_get", |c| async move { Ok(ops::project::settings(&c.host, &c.window)) })
|
||||
.add("settings_removed_theme", |c| async move { Ok(ops::project::settings_removed_theme(&c.host, &c.window)) })
|
||||
.add("settings_set", |c| async move {
|
||||
let key: String = c.arg("key")?;
|
||||
let scope = c.arg("scope")?;
|
||||
|
||||
@@ -408,6 +408,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "settings_export"
|
||||
| "settings_get"
|
||||
| "settings_get_global"
|
||||
| "settings_removed_theme"
|
||||
| "settings_import"
|
||||
| "settings_list_jetbrains_ides"
|
||||
| "settings_read_jetbrains"
|
||||
|
||||
@@ -31,6 +31,8 @@ struct TestWorkspace {
|
||||
/// plugin → its settings as the user set them.
|
||||
settings: Mutex<std::collections::BTreeMap<String, serde_json::Map<String, serde_json::Value>>>,
|
||||
global_settings: Mutex<std::collections::BTreeMap<String, serde_json::Map<String, serde_json::Value>>>,
|
||||
/// What plugins sent the language server `mock`: (method, params, whether a notification).
|
||||
server_messages: Mutex<Vec<(String, Option<serde_json::Value>, bool)>>,
|
||||
}
|
||||
|
||||
impl TestWorkspace {
|
||||
@@ -49,6 +51,7 @@ impl TestWorkspace {
|
||||
diagnostics: Mutex::default(),
|
||||
settings: Mutex::default(),
|
||||
global_settings: Mutex::default(),
|
||||
server_messages: Mutex::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -176,6 +179,23 @@ impl PluginWorkspace for TestWorkspace {
|
||||
fn trusted(&self, _root: &Path) -> bool {
|
||||
self.trusted
|
||||
}
|
||||
/// One language server runs for the project, `mock`: it answers with what it was sent.
|
||||
fn lsp_request(
|
||||
&self,
|
||||
_root: &Path,
|
||||
server: &str,
|
||||
method: &str,
|
||||
params: Option<serde_json::Value>,
|
||||
notify: bool,
|
||||
_cancel: &ide_core::CancellationToken,
|
||||
_timeout: Duration,
|
||||
) -> Result<serde_json::Value, HostError> {
|
||||
if server != "mock" {
|
||||
return Err(HostError::NotFound(format!("no language server `{server}` is running for this project")));
|
||||
}
|
||||
self.server_messages.lock().push((method.into(), params.clone(), notify));
|
||||
Ok(if notify { serde_json::Value::Null } else { serde_json::json!({ "method": method, "params": params }) })
|
||||
}
|
||||
fn project_windows(&self, root: &Path) -> Vec<String> {
|
||||
// `main` shows the fixture project; other open roots have no window left.
|
||||
if root == self.root { vec!["closing".into(), "main".into()] } else { Vec::new() }
|
||||
@@ -2606,6 +2626,7 @@ fn git_toolbox_does_not_fetch_untrusted_projects() {
|
||||
diagnostics: Mutex::default(),
|
||||
settings: Mutex::default(),
|
||||
global_settings: Mutex::default(),
|
||||
server_messages: Mutex::default(),
|
||||
});
|
||||
f.host.set_workspace(Some(untrusted));
|
||||
let err = f.run("openidea.git-toolbox", "fetch", None).unwrap_err();
|
||||
@@ -6930,3 +6951,67 @@ fn project_write_judges_a_link_by_its_target() {
|
||||
assert_eq!(std::fs::read_to_string(&secret).unwrap(), "top secret", "{rel}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A plugin reaches a running language server only with `lsp:<server>`, in a trusted
|
||||
/// project, and only with the server's own methods (API 1.9).
|
||||
#[test]
|
||||
fn language_server_requests_need_the_servers_grant_and_its_own_methods() {
|
||||
use serde_json::json;
|
||||
let f = fixture(Limits::default());
|
||||
let pkg = repo().join("crates/ide-plugin/tests/fixtures/lsp-requests");
|
||||
let id = "test.lsp-requests";
|
||||
let info = f.install(&pkg);
|
||||
assert!(info.capabilities.iter().any(|c| c.id == "lsp:mock" && c.granted && c.risk == ide_extension::Risk::High));
|
||||
|
||||
// The request and its JSON params reach the server; its answer comes back as JSON.
|
||||
let answer = f.run(id, "request", Some(r#"{"verb":"GET"}"#)).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&answer).unwrap(),
|
||||
json!({ "method": "mock/routes", "params": { "verb": "GET" } })
|
||||
);
|
||||
assert_eq!(f.run(id, "notify", None).unwrap(), None);
|
||||
assert_eq!(
|
||||
*f.workspace.server_messages.lock(),
|
||||
[("mock/routes".to_owned(), Some(json!({ "verb": "GET" })), false), ("mock/changed".to_owned(), None, true)]
|
||||
);
|
||||
|
||||
// Params that are not JSON and LSP's own methods stop at the host.
|
||||
assert!(f.run(id, "request", Some("{")).unwrap_err().contains("params are not JSON"));
|
||||
assert!(f.run(id, "standard", None).unwrap_err().contains("not one of the server's own methods"));
|
||||
assert_eq!(f.workspace.server_messages.lock().len(), 2);
|
||||
|
||||
// The grant names one server: another one is denied, not merely absent.
|
||||
let err = f.host.run_command(id, "ungranted", None, f.inv()).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{err:?}");
|
||||
assert!(err.message.contains("lsp:other"), "{err:?}");
|
||||
|
||||
// Without the grant the call is denied and nothing is sent.
|
||||
f.host.set_capability(id, "lsp:mock", false).unwrap();
|
||||
let err = f.host.run_command(id, "request", None, f.inv()).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{err:?}");
|
||||
assert!(err.message.contains("lsp:mock"), "{err:?}");
|
||||
f.host.set_capability(id, "lsp:mock", true).unwrap();
|
||||
f.run(id, "request", None).unwrap();
|
||||
|
||||
// A server acts on the project's files: not in an untrusted project.
|
||||
let mut untrusted = TestWorkspace::new(&f.project);
|
||||
Arc::get_mut(&mut untrusted).unwrap().trusted = false;
|
||||
f.host.set_workspace(Some(untrusted.clone()));
|
||||
let err = f.host.run_command(id, "request", None, f.inv()).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{err:?}");
|
||||
assert!(untrusted.server_messages.lock().is_empty());
|
||||
assert_eq!(f.host.info_of(id).unwrap().stats.crashes, 0);
|
||||
}
|
||||
|
||||
/// The grant is new in API 1.9: an older `engines.openidea` can't ask for it.
|
||||
#[test]
|
||||
fn the_language_server_grant_needs_api_1_9() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = tmp.path().join("lsp-requests");
|
||||
copy_dir(&repo().join("crates/ide-plugin/tests/fixtures/lsp-requests"), &pkg);
|
||||
let manifest = std::fs::read_to_string(pkg.join("plugin.toml")).unwrap();
|
||||
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.9\"", "\"^1.8\"")).unwrap();
|
||||
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains("new in plugin API 1.9"), "{err:?}");
|
||||
}
|
||||
@@ -215,12 +215,18 @@ pub trait Handler: Send + Sync + 'static {
|
||||
/// Respond to a server→client request (e.g. `workspace/configuration`).
|
||||
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)>;
|
||||
|
||||
/// [`on_request`](Self::on_request) for handlers whose errors carry `data`. This is the
|
||||
/// one the connection calls.
|
||||
/// [`on_request`](Self::on_request) for handlers whose errors carry `data`.
|
||||
fn on_request_with_data(&self, method: &str, params: Value) -> Result<Value, (i64, String, Option<Value>)> {
|
||||
self.on_request(method, params).map_err(|(code, message)| (code, message, None))
|
||||
}
|
||||
|
||||
/// [`on_request_with_data`](Self::on_request_with_data) with the request as received, for
|
||||
/// handlers that need what `Value` drops, such as the order of an object's keys. This is the
|
||||
/// one the connection calls.
|
||||
fn on_request_raw(&self, method: &str, params: Value, _raw: &[u8]) -> Result<Value, (i64, String, Option<Value>)> {
|
||||
self.on_request_with_data(method, params)
|
||||
}
|
||||
|
||||
/// Called once the response to a `method` request is queued: what it sends follows the response.
|
||||
fn after_response(&self, _method: &str) {}
|
||||
|
||||
@@ -577,8 +583,8 @@ impl Peer {
|
||||
tracing::error!("jsonrpc notification handler panicked on {method}");
|
||||
}
|
||||
}
|
||||
Incoming::Request(id, method, params) => {
|
||||
runtime.spawn(handle_request(inner.clone(), handler.clone(), id, method, params));
|
||||
Incoming::Request(id, method, params, raw) => {
|
||||
runtime.spawn(handle_request(inner.clone(), handler.clone(), id, method, params, raw));
|
||||
}
|
||||
Incoming::Response(tx, result) => {
|
||||
let _ = tx.send(result);
|
||||
@@ -761,14 +767,21 @@ fn parse_id(id: &Value) -> Option<i64> {
|
||||
|
||||
enum Incoming {
|
||||
Notification(String, Value),
|
||||
Request(Value, String, Value),
|
||||
Request(Value, String, Value, Arc<[u8]>),
|
||||
Response(oneshot::Sender<Result<Value, RpcError>>, Result<Value, RpcError>),
|
||||
}
|
||||
|
||||
async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value, method: String, params: Value) {
|
||||
async fn handle_request(
|
||||
inner: Arc<Inner>,
|
||||
handler: Arc<dyn Handler>,
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
raw: Arc<[u8]>,
|
||||
) {
|
||||
let m = method.clone();
|
||||
let h = handler.clone();
|
||||
let result = tokio::task::spawn_blocking(move || h.on_request_with_data(&m, params)).await;
|
||||
let result = tokio::task::spawn_blocking(move || h.on_request_raw(&m, params, &raw)).await;
|
||||
let reply = match result {
|
||||
Ok(Ok(result)) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
|
||||
Ok(Err((code, message, data))) => {
|
||||
@@ -808,7 +821,7 @@ fn dispatch(inner: &Arc<Inner>, handler: &dyn Handler, notif_tx: &std::sync::mps
|
||||
let params = obj.remove("params").unwrap_or(Value::Null);
|
||||
match id {
|
||||
Some(id) if !id.is_null() => {
|
||||
let _ = notif_tx.send(Incoming::Request(id, method, params));
|
||||
let _ = notif_tx.send(Incoming::Request(id, method, params, Arc::from(body)));
|
||||
}
|
||||
_ => {
|
||||
let _ = notif_tx.send(Incoming::Notification(method, params));
|
||||
|
||||
@@ -26,6 +26,7 @@ mod launch;
|
||||
mod library_source;
|
||||
mod loading;
|
||||
mod notebook;
|
||||
mod plugin_requests;
|
||||
mod pull;
|
||||
mod scope;
|
||||
mod server;
|
||||
@@ -45,6 +46,7 @@ pub use jdtls::jvm_inspect::JdtJvmInspector;
|
||||
pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
|
||||
pub use launch::{LaunchError, Launcher, ProcessLauncher};
|
||||
pub use notebook::{NotebookCell, NotebookCellDoc, cell_uri};
|
||||
pub use plugin_requests::plugin_method_allowed;
|
||||
pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES};
|
||||
pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path};
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Requests a plugin sends to a running server (`language-servers` in the plugin API):
|
||||
//! the server's own methods only, and only to a server that already runs.
|
||||
|
||||
use crate::{LspManager, Target, rpc_err};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The first segment of the methods LSP itself defines.
|
||||
const LSP_NAMESPACES: [&str; 14] = [
|
||||
"textDocument",
|
||||
"workspace",
|
||||
"window",
|
||||
"notebookDocument",
|
||||
"callHierarchy",
|
||||
"typeHierarchy",
|
||||
"completionItem",
|
||||
"codeAction",
|
||||
"codeLens",
|
||||
"documentLink",
|
||||
"inlayHint",
|
||||
"workspaceSymbol",
|
||||
"client",
|
||||
"telemetry",
|
||||
];
|
||||
const LIFECYCLE: [&str; 4] = ["initialize", "initialized", "shutdown", "exit"];
|
||||
|
||||
/// Whether a plugin may send `method`: only a server's own namespace passes
|
||||
/// (`phoxite/laravel/routes`), so a plugin can't interfere with document sync, the
|
||||
/// features the IDE asks for itself, or the server's lifecycle.
|
||||
pub fn plugin_method_allowed(method: &str) -> bool {
|
||||
let namespace = method.split('/').next().unwrap_or_default();
|
||||
!(method.is_empty()
|
||||
|| method.starts_with("$/")
|
||||
|| LIFECYCLE.contains(&method)
|
||||
|| LSP_NAMESPACES.contains(&namespace))
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
/// The running server named `server`, the one rooted at `root` first. Never starts one.
|
||||
fn plugin_target(&self, root: &Path, server: &str, method: &str) -> IdeResult<Target> {
|
||||
if !plugin_method_allowed(method) {
|
||||
return Err(IdeError::invalid(format!("`{method}` is not one of the server's own methods")));
|
||||
}
|
||||
let mut running: Vec<Target> = self
|
||||
.servers
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|s| s.name == server)
|
||||
.filter_map(|s| s.running().map(|run| Target { server: s.clone(), run }))
|
||||
.collect();
|
||||
let at = running.iter().position(|t| t.server.root == root).unwrap_or(0);
|
||||
if running.is_empty() {
|
||||
return Err(IdeError::not_found(format!("no language server `{server}` is running for this project")));
|
||||
}
|
||||
Ok(running.swap_remove(at))
|
||||
}
|
||||
|
||||
/// Sends a plugin's request to the running server named `server` (its name in the
|
||||
/// language definition) and returns the result. `not_found` when it is not running,
|
||||
/// `invalid` for a method that is not the server's own.
|
||||
pub async fn server_request(
|
||||
&self,
|
||||
root: &Path,
|
||||
server: &str,
|
||||
method: &str,
|
||||
params: Option<Value>,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> IdeResult<Value> {
|
||||
let t = self.plugin_target(root, server, method)?;
|
||||
self.call(&t, method, params.unwrap_or(Value::Null), Some(cancel), timeout).await
|
||||
}
|
||||
|
||||
/// The same for a notification.
|
||||
pub fn server_notify(&self, root: &Path, server: &str, method: &str, params: Option<Value>) -> IdeResult<()> {
|
||||
let t = self.plugin_target(root, server, method)?;
|
||||
t.run.peer.notify(method, params.unwrap_or(Value::Null)).map_err(|e| rpc_err(server, method, e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::plugin_method_allowed;
|
||||
|
||||
#[test]
|
||||
fn only_a_servers_own_methods_pass() {
|
||||
for method in ["phoxite/laravel/routes", "phoxite/refactor/hierarchy", "rust-analyzer/expandMacro", "custom"] {
|
||||
assert!(plugin_method_allowed(method), "{method}");
|
||||
}
|
||||
for method in [
|
||||
"",
|
||||
"initialize",
|
||||
"initialized",
|
||||
"shutdown",
|
||||
"exit",
|
||||
"$/cancelRequest",
|
||||
"$/progress",
|
||||
"textDocument/hover",
|
||||
"textDocument/didChange",
|
||||
"workspace/executeCommand",
|
||||
"window/showMessage",
|
||||
"notebookDocument/didOpen",
|
||||
"callHierarchy/incomingCalls",
|
||||
"typeHierarchy/supertypes",
|
||||
"completionItem/resolve",
|
||||
"codeAction/resolve",
|
||||
"codeLens/resolve",
|
||||
"documentLink/resolve",
|
||||
"inlayHint/resolve",
|
||||
"workspaceSymbol/resolve",
|
||||
"client/registerCapability",
|
||||
"telemetry/event",
|
||||
] {
|
||||
assert!(!plugin_method_allowed(method), "{method}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7372,3 +7372,59 @@ async fn notebook_edits_change_cell_text_and_reorders_reopen_it() {
|
||||
.await;
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
/// A plugin's request (`language-servers` in the plugin API) reaches the running server
|
||||
/// named in the language definition: only the server's own methods, and never a start.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_plugin_request_reaches_only_a_running_servers_own_methods() {
|
||||
let servers = [("main", incremental_caps()), ("alt", incremental_caps())];
|
||||
let h = harness_for("mock", "mk", &servers, &["alt"]);
|
||||
let _ = h.open("a.mk", "x");
|
||||
h.wait_status("main", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("main");
|
||||
s.respond("mock/routes", json!([{ "uri": "/users", "name": "users.index" }]));
|
||||
let cancel = CancellationToken::new();
|
||||
let timeout = Duration::from_secs(5);
|
||||
|
||||
// A request: the params arrive, the result comes back.
|
||||
let routes = h
|
||||
.mgr
|
||||
.server_request(&h.root, "main", "mock/routes", Some(json!({ "method": "GET" })), &cancel, timeout)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(routes, json!([{ "uri": "/users", "name": "users.index" }]));
|
||||
assert_eq!(s.requested("mock/routes"), [json!({ "method": "GET" })]);
|
||||
|
||||
// A notification.
|
||||
h.mgr.server_notify(&h.root, "main", "mock/changed", Some(json!({ "what": "routes" }))).unwrap();
|
||||
eventually(|| s.notified("mock/changed") == [json!({ "what": "routes" })]).await;
|
||||
|
||||
// LSP's own methods are refused before anything is sent.
|
||||
for method in ["textDocument/hover", "workspace/executeCommand", "$/cancelRequest", "shutdown", "initialize"] {
|
||||
let before = (s.requested(method).len(), s.notified(method).len());
|
||||
let e = h.mgr.server_request(&h.root, "main", method, None, &cancel, timeout).await.unwrap_err();
|
||||
assert_eq!(e.kind, ide_core::error::ErrorKind::InvalidInput, "{method}");
|
||||
assert_eq!(
|
||||
h.mgr.server_notify(&h.root, "main", method, None).unwrap_err().kind,
|
||||
ide_core::error::ErrorKind::InvalidInput
|
||||
);
|
||||
assert_eq!((s.requested(method).len(), s.notified(method).len()), before, "{method}");
|
||||
}
|
||||
|
||||
// A server that is defined but not running is not started; an unknown name is the same.
|
||||
for server in ["alt", "nobody"] {
|
||||
let e = h.mgr.server_request(&h.root, server, "mock/routes", None, &cancel, timeout).await.unwrap_err();
|
||||
assert_eq!(e.kind, ide_core::error::ErrorKind::NotFound, "{server}");
|
||||
assert_eq!(
|
||||
h.mgr.server_notify(&h.root, server, "mock/changed", None).unwrap_err().kind,
|
||||
ide_core::error::ErrorKind::NotFound
|
||||
);
|
||||
}
|
||||
assert_eq!(h.launcher.state("alt").launches.load(Ordering::SeqCst), 0);
|
||||
assert!(h.statuses("alt").is_empty());
|
||||
|
||||
// A cancelled invocation ends the request.
|
||||
cancel.cancel();
|
||||
let e = h.mgr.server_request(&h.root, "main", "mock/routes", None, &cancel, timeout).await.unwrap_err();
|
||||
assert_eq!(e.kind, ide_core::error::ErrorKind::Cancelled);
|
||||
}
|
||||
@@ -100,6 +100,23 @@ pub trait HostApi: Send + Sync {
|
||||
let _ = (root, client, id, cancel, timeout);
|
||||
Err(HostError::Invalid("Database subsystem is unavailable on this host".into()))
|
||||
}
|
||||
/// Sends `method` to the running language server named `server` of the project at
|
||||
/// `root`: a request (the result as JSON), or with `notify` a notification (`null`).
|
||||
/// `params` is JSON. Never starts a server.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn lsp_request(
|
||||
&self,
|
||||
root: &Path,
|
||||
server: &str,
|
||||
method: &str,
|
||||
params: Option<String>,
|
||||
notify: bool,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<String, HostError> {
|
||||
let _ = (root, method, params, notify, cancel, timeout);
|
||||
Err(HostError::NotFound(format!("no language server `{server}` is running for this project")))
|
||||
}
|
||||
/// Stdio argv only; no URL or bearer token.
|
||||
fn mcp_stdio_command(&self, client: &str) -> Option<Vec<String>> {
|
||||
let _ = client;
|
||||
|
||||
@@ -59,6 +59,7 @@ pub(crate) mod syntax_bindings {
|
||||
"openidea:plugin/output": super::bindings::openidea::plugin::output,
|
||||
"openidea:plugin/auth": super::bindings::openidea::plugin::auth,
|
||||
"openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner,
|
||||
"openidea:plugin/language-servers": super::bindings::openidea::plugin::language_servers,
|
||||
"openidea:plugin/ipc": super::bindings::openidea::plugin::ipc,
|
||||
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
|
||||
"openidea:plugin/views": super::bindings::openidea::plugin::views,
|
||||
@@ -97,6 +98,7 @@ pub(crate) mod providers_bindings {
|
||||
"openidea:plugin/output": super::bindings::openidea::plugin::output,
|
||||
"openidea:plugin/auth": super::bindings::openidea::plugin::auth,
|
||||
"openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner,
|
||||
"openidea:plugin/language-servers": super::bindings::openidea::plugin::language_servers,
|
||||
"openidea:plugin/ipc": super::bindings::openidea::plugin::ipc,
|
||||
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
|
||||
"openidea:plugin/views": super::bindings::openidea::plugin::views,
|
||||
@@ -135,6 +137,7 @@ pub(crate) mod views_bindings {
|
||||
"openidea:plugin/output": super::bindings::openidea::plugin::output,
|
||||
"openidea:plugin/auth": super::bindings::openidea::plugin::auth,
|
||||
"openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner,
|
||||
"openidea:plugin/language-servers": super::bindings::openidea::plugin::language_servers,
|
||||
"openidea:plugin/ipc": super::bindings::openidea::plugin::ipc,
|
||||
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
|
||||
"openidea:plugin/views": super::bindings::openidea::plugin::views,
|
||||
@@ -2648,6 +2651,57 @@ impl bindings::openidea::plugin::database::Host for StoreData {
|
||||
Ok(result.map_err(to_wit))
|
||||
}
|
||||
}
|
||||
/// The longest method name and params a plugin may send a language server.
|
||||
const MAX_LSP_METHOD: usize = 256;
|
||||
const MAX_LSP_PARAMS: usize = 1 << 20;
|
||||
|
||||
impl StoreData {
|
||||
fn lsp_call(
|
||||
&mut self,
|
||||
project: Resource<ProjectRes>,
|
||||
server: String,
|
||||
method: String,
|
||||
params: Option<String>,
|
||||
notify: bool,
|
||||
) -> Fallible<String> {
|
||||
if let Err(e) = self.require(Capability::Lsp(server.clone())) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if method.len() > MAX_LSP_METHOD || params.as_ref().is_some_and(|p| p.len() > MAX_LSP_PARAMS) {
|
||||
return Ok(Err(wit::Error::Invalid("method exceeds 256 bytes or params exceed 1 MiB".into())));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
let token = self.cancel.token();
|
||||
let timeout = self.io_timeout();
|
||||
let outcome = self.host.lsp_request(&root, &server, &method, params, notify, &token, timeout);
|
||||
if outcome.is_err() {
|
||||
self.grant_io_error_grace();
|
||||
}
|
||||
Ok(outcome.map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl bindings::openidea::plugin::language_servers::Host for StoreData {
|
||||
fn request(
|
||||
&mut self,
|
||||
project: Resource<ProjectRes>,
|
||||
server: String,
|
||||
method: String,
|
||||
params: Option<String>,
|
||||
) -> Fallible<String> {
|
||||
self.lsp_call(project, server, method, params, false)
|
||||
}
|
||||
fn notify(
|
||||
&mut self,
|
||||
project: Resource<ProjectRes>,
|
||||
server: String,
|
||||
method: String,
|
||||
params: Option<String>,
|
||||
) -> Fallible<()> {
|
||||
Ok(self.lsp_call(project, server, method, params, true)?.map(|_| ()))
|
||||
}
|
||||
}
|
||||
|
||||
impl bindings::openidea::plugin::extensions::Host for StoreData {
|
||||
fn contributions(&mut self, point: String) -> Fallible<Vec<bindings::openidea::plugin::extensions::Contribution>> {
|
||||
Ok(self
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
a3d2511f30277245a8cb9673c4d8a948a8a025b8b9149d5015bed5137a98ccdd
|
||||
75123cf60f87ec9bdd36e0b7f75efb69d000136d74c3e9da893ecd1534bdf534
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
dad3edad99ad85234aa612bbc1f3a66e50361189e54038b91674703815f049cc
|
||||
60d66ec578e3adc7adaebbcd01aa92a74a8a7e4cfd4d1aa778f9b033c75234a6
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
918af7d4d8bc302621b9b68070a56930813b1fc2ba235d89de4e56c53eaba5bd
|
||||
df55973ed77e734e48fe1d2b7bb5d1e4eb68153b17452e4385599145427292b6
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
7a140c2255a3d251a5ab9079bec953cccc4408727b067b49da47cf8fb27d8cb8
|
||||
94f1c4a8192e15961bd136cf9dc3416fca7b295bce0d563608a3ee9fdf81c28e
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
ef41934bd0b67f76a17ef321b44c172496314c9fea6b46b4cd2f1d0f4b96907d
|
||||
db140480eaf4352fa8db8d87aab95cb07944e19c28d2cc08e299b37c5deb4230
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
c6dc1ae00d9f3d936f4bf5ff110915050cb503ac06982bdc48e967deac84a913
|
||||
5fd76f6168d3a7b3259281171f375cf01da6ceabdf4ffd382c5fe7034b2e94f2
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
25a782556dd6bbf8ed1ad7b50b9b55685f074d80220af08f3bd9cb12cf07f74e
|
||||
5d9049fa006194e92e06de47ab933d249118706a8ba92100493318a1582f2b65
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
f58b5f68d56611c3d15f69ff8e73e6e65d84e55d14c9bae00ed50b346460adfa
|
||||
c723d915b69292740a863b3a94db6f6f8c218ecda46c527229d621084f2df3b1
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
749bff2d051984edf65590494ef803225dfccd03d032de140974d8e984683ac1
|
||||
2dbab98cfc473650675b2691c7d0f2d5b088ffd6208e31ef30aaa54dd3b1b4b0
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
50c609ebf387b400a01ea28e6ccf0f3de2ff7a5eedbb47c0a4bc40d05e3aaade
|
||||
1fa1515aae810e588f2c1d25728755c25fd51c78d35d96d823583052372c34dd
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
c2350d0277636285b182d1484d01d706c3116ef5e28917ddeb24ca78bac251ba
|
||||
69eaf24b0ed3260a6b3656563b1d0b8b268de81899f05bc984c668b0765e361f
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
3aaa2a7655189a1e4da55a86fb30fdfde29a5b6ad65de53a78b18df30eeb97f3
|
||||
c6bd0a45a6cc3b09114be55c8b63563bea4ca6b748ee27c7843bb4ab05d7ce26
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
975ead22afb4c5bc5c79799b76c1d3a683b572b906e3bade94944319920f7f84
|
||||
f870210add7c920df42e276a4cefcd1eaf01b3869f238c0a366beeb052e4adc3
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
b849aeca8e5d6b6ede216e372cfc019bac08774006e11ed81d272f67770adc17
|
||||
3a3c0d4e5a1564eaa2ca35b698cd4b553722476cf68995495b9f277631b08266
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
79505f2677e5f19374a3ed46cccdc1ed8d3092a3c43e3154e8cfd73063533251
|
||||
aab9d674d5d94b7488fe088e5c355511403edf7878c6fe124e2a2be86598ead6
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
f7f43d46ebd545cc4c52d859c4307d08bb95b3187a4b947704e37c27cf0f4fa0
|
||||
391ce453aa40de84ca4a6ed5f5b8ee88f5d659e8699029fa3a8940dac7d6dc9b
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
491e8e713e51045ebef81c4f07ed5475ed41688e88ac47ee26d13f2bc5c53b49
|
||||
ccbb5abb90a0bb49c0f51d0e2865d1c4092e28e9168e4c57e5f7b6ea971721ac
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-lsp-requests"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-lsp-requests"
|
||||
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"
|
||||
Бінарний файл не відображається.
@@ -0,0 +1 @@
|
||||
4788f82b35966e155dbf51af520acb2d5e7773ddf9f36f4239b9bd51d51b0fb3
|
||||
@@ -0,0 +1,24 @@
|
||||
id = "test.lsp-requests"
|
||||
name = "Language server requests fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.9"
|
||||
wasm = "dist/plugin.wasm"
|
||||
|
||||
[capabilities]
|
||||
optional = ["lsp:mock"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "request"
|
||||
title = "Ask the mock server for its routes"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "notify"
|
||||
title = "Tell the mock server something changed"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "standard"
|
||||
title = "Send a method of LSP itself"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "ungranted"
|
||||
title = "Ask a server the manifest does not name"
|
||||
@@ -0,0 +1,19 @@
|
||||
use openidea_plugin::{Error, Invocation, Plugin, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct LspRequests;
|
||||
|
||||
impl Plugin for LspRequests {
|
||||
fn run_command(&mut self, id: &str, inv: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
let project = inv.project().ok_or("no project")?;
|
||||
match id {
|
||||
"request" => project.language_server_request("mock", "mock/routes", arg).map(Some),
|
||||
"notify" => project.language_server_notify("mock", "mock/changed", arg).map(|()| None),
|
||||
"standard" => project.language_server_request("mock", "textDocument/hover", arg).map(Some),
|
||||
"ungranted" => project.language_server_request("other", "other/status", arg).map(Some),
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(LspRequests);
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
63b2f6007782c9d3498c86505a6c107b70f09d90678c79cf16c859ecbf349e70
|
||||
6e1d2ff774cc8088b4b8a6766914a7f752d05657634e6934503cebd2aa241fa2
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
3d1f2451dee8bba3cc874f497bcea909fa9bd58ae605dc1d74b4298b4b11ed94
|
||||
a11bc7b7151a62e98d665a6459b3c84e4cb836b734fd23e7bad3adc533655f81
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
26a2b15b9354fa0ba2bf5934114bfdd20f7906c4a67497d013146cc6b3fc9643
|
||||
aad4871073c7c7f22ab84f1e7965d1a861b7e5ddd9f86408e95eba4c3c021411
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
c9ef2cf7b6e8f3204f8ee382d7a381a5190b1e812e11d51bd1262c3d429a315f
|
||||
f2e0df07f6d7a501e96caf43efcfdaa0596aa2b9dad5c46ef71f5ad2eddc505b
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
b07cc854c1302fd5bfd29fb3037732c7439efde606fc57295b2c0f94602a1077
|
||||
253f93d1254310669150f6fd629ae88223d7d6f1cd642803ec57382e5acd4451
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
12c4004f118070d0f37125ac32639ab9f332d59cc4585e74a8af61e3e6c5c1e8
|
||||
9351d3248252ca35a40ce557ada29e8aa4f3135273e988235bc22ebcfd0505ec
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
7517da988ab8cacaa3abf7645274a84e364de582ea9edf60a63926b1442d1581
|
||||
7f4ad5bc15d70de0ae7a0e96c584edcd6caef1cb18b5450685a83d8a8de16d24
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
6e045ee3fcaa655bae773774e48e3eeb9d97ae9d8db1f52bf76c38459dcecb3f
|
||||
7d18591aa915fd6dd71fd8f8a4a0c7d8abb2cc5ff9252a56fb0d6b7458562466
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
69bb92e6e24e54458978a316c465d17b93d9ce9613cd26d7d6de9a1cef9b9447
|
||||
55e59a27457e8f25e9bd0e727ebbc6276b0f45d210732a88ede4954ec2dc777f
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
fc64a30e9d3b46275a6480641454b367b5082aea82da23a11b57106aa02387e1
|
||||
8ce7d4a12f8bd393b8b32e695d3dd2994c30ba27fb8419b8af900a550cb2da7c
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
e6f3b5a4d81e62240fa1de4d1624b3706f145d47c014a1a2ed2b899cab24e0e4
|
||||
f654b73781d88c25dddddcdb25e8c02195b36dc9e008dd9eafafd632a11c48c5
|
||||
@@ -25,6 +25,7 @@ pub fn detect(kind: SdkKind, root: Option<&Path>) -> Vec<Sdk> {
|
||||
SdkKind::Jdk => jdk(),
|
||||
SdkKind::Maven => maven(),
|
||||
SdkKind::Ruby => crate::ruby::detect(root),
|
||||
SdkKind::Zig => zig(),
|
||||
};
|
||||
if let Some(root) = root {
|
||||
pinned_first(&mut out, kind, root);
|
||||
@@ -40,6 +41,7 @@ fn tool_of(kind: SdkKind) -> Option<Tool> {
|
||||
SdkKind::Jdk => Some(Tool::Java),
|
||||
SdkKind::Maven => Some(Tool::Maven),
|
||||
SdkKind::Ruby => Some(Tool::Ruby),
|
||||
SdkKind::Zig => Some(Tool::Zig),
|
||||
SdkKind::Php | SdkKind::PackageManager | SdkKind::Rust | SdkKind::TypeScript => None,
|
||||
}
|
||||
}
|
||||
@@ -739,6 +741,86 @@ fn go() -> Vec<Sdk> {
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ zig
|
||||
|
||||
/// The directory holding the `zig` executable of the install rooted at `base`,
|
||||
/// the way the common distributions lay it out: `zig` at the root (official
|
||||
/// tarballs, mise / asdf installs), `<base>/bin/zig` (Homebrew) or one
|
||||
/// directory down (zvm's nested version folders).
|
||||
fn zig_dir(base: &Path) -> Option<PathBuf> {
|
||||
if base.join(exe("zig")).is_file() {
|
||||
return Some(base.to_path_buf());
|
||||
}
|
||||
let bin = base.join(bin_dir());
|
||||
if bin.join(exe("zig")).is_file() {
|
||||
return Some(bin);
|
||||
}
|
||||
subdirs(base).into_iter().find(|d| d.join(exe("zig")).is_file())
|
||||
}
|
||||
|
||||
/// Zig toolchains: PATH, Homebrew, zvm and mise / asdf installs. `path` is the
|
||||
/// directory holding `zig`, which is what `languages.zig.zig` stores.
|
||||
fn zig() -> Vec<Sdk> {
|
||||
let mut roots: Vec<(PathBuf, &'static str)> = Vec::new();
|
||||
if let Some(bin) = which("zig").filter(|p| !version_managers::is_shim(p))
|
||||
&& let Some(dir) = bin.parent()
|
||||
{
|
||||
roots.push((dir.to_path_buf(), "path"));
|
||||
}
|
||||
for p in ["/opt/homebrew/opt/zig", "/usr/local/opt/zig"] {
|
||||
roots.push((PathBuf::from(p), "system"));
|
||||
}
|
||||
if let Some(home) = home_dir() {
|
||||
for d in subdirs(&home.join(".zvm")) {
|
||||
roots.push((d, "zvm"));
|
||||
}
|
||||
}
|
||||
for (d, manager) in version_managers::installs(Tool::Zig) {
|
||||
roots.push((d, manager));
|
||||
}
|
||||
let mut dirs: Vec<(PathBuf, &'static str)> =
|
||||
roots.into_iter().filter_map(|(base, source)| zig_dir(&base).map(|dir| (dir, source))).collect();
|
||||
let mut seen = HashSet::new();
|
||||
dirs.retain(|(dir, _)| seen.insert(dir.canonicalize().unwrap_or_else(|_| dir.clone())));
|
||||
par_map(&dirs, |(dir, source)| {
|
||||
let version = query(&dir.join(exe("zig")), &["version"], VERSION_TIMEOUT).map(|v| v.trim().to_owned());
|
||||
Some(Sdk {
|
||||
path: dir.clone().into(),
|
||||
name: format!("Zig {}", version.as_deref().unwrap_or_default()).trim().to_owned(),
|
||||
version,
|
||||
source: (*source).to_owned(),
|
||||
home: Some(dir.clone().into()),
|
||||
extra: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod zig_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn finds_the_zig_directory_of_each_layout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let install = |rel: &str| {
|
||||
let dir = tmp.path().join(rel);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(exe("zig")), "").unwrap();
|
||||
dir
|
||||
};
|
||||
// Official tarballs and mise / asdf installs: `zig` at the root.
|
||||
let root = install("0.14.1");
|
||||
assert_eq!(zig_dir(&root), Some(root));
|
||||
// Homebrew: <opt>/bin/zig.
|
||||
let brew = install("brew/bin");
|
||||
assert_eq!(zig_dir(brew.parent().unwrap()), Some(brew));
|
||||
// zvm: one directory down.
|
||||
let zvm = install("zvm/x");
|
||||
assert_eq!(zig_dir(&tmp.path().join("zvm")), Some(zvm));
|
||||
assert_eq!(zig_dir(&tmp.path().join("missing")), None);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ typescript
|
||||
|
||||
/// `node_modules/typescript/lib` of the project (root or a direct workspace member).
|
||||
|
||||
@@ -52,6 +52,8 @@ pub enum SdkKind {
|
||||
/// Maven homes.
|
||||
Maven,
|
||||
Ruby,
|
||||
/// Zig toolchain directories.
|
||||
Zig,
|
||||
}
|
||||
|
||||
/// One installed interpreter / toolchain.
|
||||
|
||||
@@ -61,6 +61,9 @@ fn mise_and_asdf_installs_are_detected_and_pinned_versions_used() {
|
||||
script(&maven.join("bin/mvn"), "Apache Maven 3.9.6");
|
||||
write(&maven.join("lib/maven-core-3.9.6.jar"), "");
|
||||
script(&installs.join("gradle/8.7/bin/gradle"), "Gradle 8.7");
|
||||
// Zig: asdf keeps the executable at the install root, zvm under ~/.zvm.
|
||||
script(&asdf.join("installs/zig/0.14.1/zig"), "0.14.1");
|
||||
script(&tmp.join("home/.zvm/0.13.0/zig"), "0.13.0");
|
||||
|
||||
let project = tmp.join("project");
|
||||
write(
|
||||
@@ -91,6 +94,13 @@ fn mise_and_asdf_installs_are_detected_and_pinned_versions_used() {
|
||||
let mvn = find(&mavens, &maven);
|
||||
assert_eq!((mvn.source.as_str(), mvn.version.as_deref()), ("mise", Some("3.9.6")));
|
||||
|
||||
let zigs = detect(SdkKind::Zig, Some(&project));
|
||||
let zig = find(&zigs, &asdf.join("installs/zig"));
|
||||
assert_eq!((zig.source.as_str(), zig.version.as_deref()), ("asdf", Some("0.14.1")));
|
||||
assert_eq!(zig.path, asdf.join("installs/zig/0.14.1"), "the setting stores the directory");
|
||||
let zvm = find(&zigs, &tmp.join("home/.zvm"));
|
||||
assert_eq!((zvm.source.as_str(), zvm.version.as_deref()), ("zvm", Some("0.13.0")));
|
||||
|
||||
// Nothing configured in Settings: the pinned versions apply.
|
||||
let s = Settings::default();
|
||||
let jdk = ide_sdk::apply::project_jdk(&project, &s).expect("pinned JDK");
|
||||
|
||||
@@ -147,6 +147,19 @@ fn read_layer(file: &Path) -> Option<Json> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The first appearance theme key of `file` naming a removed theme, if any. Plugin themes
|
||||
/// (`plugin:<id>/<theme>`) carry their plugin's id, so a plain id can only be a built-in's.
|
||||
fn removed_theme_in(file: &Path) -> Option<String> {
|
||||
const KEYS: [&str; 3] = ["theme", "preferredLightTheme", "preferredDarkTheme"];
|
||||
let layer = read_layer(file)?;
|
||||
KEYS.iter()
|
||||
.filter_map(|key| match layer.pointer(&format!("/appearance/{key}"))? {
|
||||
Json::String(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.find(|id| ide_core::settings::REMOVED_THEMES.iter().any(|(removed, _)| id.as_str() == *removed))
|
||||
}
|
||||
|
||||
/// Merges `layer` onto `merged` if the result still deserializes.
|
||||
fn try_merge(merged: &mut Json, layer: Json) -> Result<(), String> {
|
||||
// Migrate the legacy boolean every layer carries it, before it can clobber
|
||||
@@ -264,6 +277,13 @@ impl SettingsStore {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The removed built-in theme id (`REMOVED_THEMES`) a settings file still names, if any.
|
||||
/// `Settings` reports its stand-in, so this reads the files again: it is what a settings
|
||||
/// file named, not what the theme rows show now.
|
||||
pub fn named_removed_theme(&self) -> Option<String> {
|
||||
self.layers().into_iter().rev().find_map(|(_, file)| removed_theme_in(&file))
|
||||
}
|
||||
|
||||
fn compute(&self) -> Settings {
|
||||
let mut merged = defaults_json();
|
||||
for (scope, file) in self.layers() {
|
||||
@@ -525,6 +545,30 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn removed_theme_named_in_a_settings_file_is_reported_for_the_notice() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cfg = tmp.path().join("cfg");
|
||||
let proj = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(&cfg).unwrap();
|
||||
std::fs::create_dir_all(proj.join(".openidea")).unwrap();
|
||||
// Nothing to report without a removed id.
|
||||
std::fs::write(cfg.join("settings.toml"), "[appearance]\ntheme = \"dark\"\n").unwrap();
|
||||
assert_eq!(SettingsStore::load(&cfg, Some(&proj)).named_removed_theme(), None);
|
||||
// The theme the notice is about, and the light one for good measure.
|
||||
std::fs::write(cfg.join("settings.toml"), "[appearance]\ntheme = \"one-dark\"\n").unwrap();
|
||||
assert_eq!(SettingsStore::load(&cfg, Some(&proj)).named_removed_theme().as_deref(), Some("one-dark"));
|
||||
std::fs::write(
|
||||
cfg.join("settings.toml"),
|
||||
"[appearance]\npreferredDarkTheme = \"one-dark\"\ntheme = \"plugin:acme.themes/night\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(SettingsStore::load(&cfg, Some(&proj)).named_removed_theme().as_deref(), Some("one-dark"));
|
||||
// A project layer naming it wins over the global one.
|
||||
std::fs::write(proj.join(".openidea/settings.toml"), "[appearance]\ntheme = \"one-light\"\n").unwrap();
|
||||
assert_eq!(SettingsStore::load(&cfg, Some(&proj)).named_removed_theme().as_deref(), Some("one-light"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_file_choices_are_project_settings_and_survive_reload() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -43,7 +43,7 @@ User-visible text is written in English at the call site and goes through the te
|
||||
|
||||
## WebKit compatibility
|
||||
|
||||
On macOS and Linux the app runs in WebKit, and the supported floor is Safari 16, the engine of macOS 13 (on macOS 11 and 12 a Safari update doesn't update the engine other apps use). `pnpm build` runs `scripts/check-webkit.ts` on the bundle, and `src/platform/webkitCompatibility.test.ts` checks application regexes. Avoid:
|
||||
On macOS and Linux the app runs in WebKit, and the supported floor is Safari 15.4, the engine of macOS 12 (the app's minimum). `pnpm build` runs `scripts/check-webkit.ts` on the bundle, and `src/platform/webkitCompatibility.test.ts` checks application regexes. Avoid:
|
||||
|
||||
- regex lookbehind (`(?<=…)`, `(?<!…)`), which needs Safari 16.4, and the regex `v` flag;
|
||||
- import attributes;
|
||||
|
||||
@@ -117,7 +117,7 @@ With tabs placed left or right, the tab column is as wide as its longest name, u
|
||||
| **Go to Next Splitter** | `⌥⇥` | `Alt+Tab` |
|
||||
| **Go to Previous Splitter** | `⌥⇧⇥` | `Alt+Shift+Tab` |
|
||||
|
||||
**Window › Editor Tabs** also offers **Split Down**, **Unsplit**, **Unsplit All**, **Move to Opposite Group**, **Open in Opposite Group** and **Change Splitter Orientation**. You can also drag a tab to an edge of the editor area to split it.
|
||||
**Window › Editor Tabs** also offers **Split Down**, **Unsplit**, **Unsplit All**, **Move to Opposite Group**, **Open in Opposite Group** and **Change Splitter Orientation**. You can also drag a tab to an edge of the editor area to split it. In the Project view, **Open in Right Split** (`⇧↵` / `Shift+Enter`, also in the context menu) opens the selected file in the split to the right.
|
||||
|
||||
## View options
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ In the file names below, `<version>` is the release version, for example `0.1.40
|
||||
|
||||
## macOS
|
||||
|
||||
macOS 13 Ventura or later.
|
||||
macOS 12 Monterey or later.
|
||||
|
||||
| Mac | File |
|
||||
| ------------- | -------------------------------- |
|
||||
|
||||
@@ -20,37 +20,38 @@ The plugin API is the WIT package `openidea:[email protected]` in [`wit/`](https://gi
|
||||
|
||||
The plugin calls these. Most are gated by a [capability](/en/plugins/capabilities).
|
||||
|
||||
| Interface | File | Summary |
|
||||
| ---------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `types` | [types.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/types.wit) | Shared types: positions (0-based lines, UTF-16 columns), ranges, errors, the `extension` carrier for later additions |
|
||||
| `log` | [host.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/host.wit) | Writes to the IDE log, tagged with the plugin id |
|
||||
| `ui` | [host.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/host.wit) | Notifications (also for one project, API 1.5), status bar items, context keys, pickers, input boxes, confirmations, folder chooser, clipboard, opening URLs, the UI language |
|
||||
| `storage` | [host.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/host.wit) | The plugin's persistent key-value storage (64 MiB) |
|
||||
| `command-runner` | [host.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/host.wit) | Runs IDE actions and other plugins' commands (API 1.4, `ide.commands`) |
|
||||
| `output` | [host.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/host.wit) | Named, read-only Output tabs per plugin (API 1.4) |
|
||||
| `workspace` | [workspace.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/workspace.wit) | Open projects: reading, writing, finding and searching files, symbols, the project of a path, opening a project |
|
||||
| `editor` | [workspace.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/workspace.wit) | Text editors: document, selections, versioned edits, reveal; open editors and opening files |
|
||||
| `invocation` | [workspace.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/workspace.wit) | Where a command or event came from: its project, editor and selected paths |
|
||||
| `vcs` | [workspace.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/workspace.wit) | Git for a project's repository: status, remotes, fetch, log, blame, diff |
|
||||
| `fs` | [fs.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/fs.wit) | Files outside the open projects (`fs.read:` / `fs.write:`) and the plugin's private directory |
|
||||
| `decorations` | [decorations.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/decorations.wit) | Editor annotations and project-tree badges (API 1.4) |
|
||||
| `commit` | [commit.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/commit.wit) | The commit being prepared and its message |
|
||||
| `events` | [events.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/events.wit) | Subscribing to workbench events, optionally filtered by path |
|
||||
| `tasks` | [events.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/events.wit) | Long-running work with a progress indicator and a longer time budget |
|
||||
| `diagnostics` | [io.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/io.wit) | Publishing problems for files and reading the Problems view |
|
||||
| `net` | [io.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/io.wit) | HTTP(S) requests (`net:` / `net.http:` / `net:ask`) |
|
||||
| `secrets` | [io.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/io.wit) | Secrets in the OS keychain, used only through `{{secret:<name>}}` in requests |
|
||||
| `process` | [io.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/io.wit) | Running approved programs once, with an environment, or behind a handle |
|
||||
| `ipc` | [io.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/io.wit) | Local sockets and named pipes on the project machine (API 1.4) |
|
||||
| `auth` | [io.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/io.wit) | OAuth loopback redirects received on the desktop (API 1.4) |
|
||||
| `views` | [views.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/views.wit) | Refreshing and revealing views, messages to web views, terminals, content tabs, opening locations |
|
||||
| `view-types` | [views.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/views.wit) | The declarative view model: trees, lists, forms, tables, messages, `html` bodies, view events |
|
||||
| `settings` | [views.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/views.wit) | The plugin's own settings, project over global, or the schema default |
|
||||
| `run` | [run.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/run.wit) | Starting the plugin's run configurations in the Run tool window (API 1.4) |
|
||||
| `run-targets` | [run-targets.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/run-targets.wit) | Project run targets that wrap commands, owned by the plugin (API 1.4) |
|
||||
| `database` | [database.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/database.wit) | The plugin's own project data sources and their credentials (API 1.4) |
|
||||
| `mcp` | [mcp.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/mcp.wit) | The command line of the built-in MCP server's stdio bridge, never its URL or token (API 1.4) |
|
||||
| `detail-types` | [details.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/details.wit) | What the pane beside the Services tree shows for a plugin's node |
|
||||
| Interface | File | Summary |
|
||||
| ------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `types` | [types.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/types.wit) | Shared types: positions (0-based lines, UTF-16 columns), ranges, errors, the `extension` carrier for later additions |
|
||||
| `log` | [host.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/host.wit) | Writes to the IDE log, tagged with the plugin id |
|
||||
| `ui` | [host.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/host.wit) | Notifications (also for one project, API 1.5), status bar items, context keys, pickers, input boxes, confirmations, folder chooser, clipboard, opening URLs, the UI language |
|
||||
| `storage` | [host.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/host.wit) | The plugin's persistent key-value storage (64 MiB) |
|
||||
| `command-runner` | [host.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/host.wit) | Runs IDE actions and other plugins' commands (API 1.4, `ide.commands`) |
|
||||
| `language-servers` | [host.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/host.wit) | Requests and notifications to a running language server, its own methods only (API 1.9, `lsp:<server>`) |
|
||||
| `output` | [host.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/host.wit) | Named, read-only Output tabs per plugin (API 1.4) |
|
||||
| `workspace` | [workspace.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/workspace.wit) | Open projects: reading, writing, finding and searching files, symbols, the project of a path, opening a project |
|
||||
| `editor` | [workspace.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/workspace.wit) | Text editors: document, selections, versioned edits, reveal; open editors and opening files |
|
||||
| `invocation` | [workspace.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/workspace.wit) | Where a command or event came from: its project, editor and selected paths |
|
||||
| `vcs` | [workspace.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/workspace.wit) | Git for a project's repository: status, remotes, fetch, log, blame, diff |
|
||||
| `fs` | [fs.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/fs.wit) | Files outside the open projects (`fs.read:` / `fs.write:`) and the plugin's private directory |
|
||||
| `decorations` | [decorations.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/decorations.wit) | Editor annotations and project-tree badges (API 1.4) |
|
||||
| `commit` | [commit.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/commit.wit) | The commit being prepared and its message |
|
||||
| `events` | [events.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/events.wit) | Subscribing to workbench events, optionally filtered by path |
|
||||
| `tasks` | [events.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/events.wit) | Long-running work with a progress indicator and a longer time budget |
|
||||
| `diagnostics` | [io.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/io.wit) | Publishing problems for files and reading the Problems view |
|
||||
| `net` | [io.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/io.wit) | HTTP(S) requests (`net:` / `net.http:` / `net:ask`) |
|
||||
| `secrets` | [io.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/io.wit) | Secrets in the OS keychain, used only through `{{secret:<name>}}` in requests |
|
||||
| `process` | [io.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/io.wit) | Running approved programs once, with an environment, or behind a handle |
|
||||
| `ipc` | [io.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/io.wit) | Local sockets and named pipes on the project machine (API 1.4) |
|
||||
| `auth` | [io.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/io.wit) | OAuth loopback redirects received on the desktop (API 1.4) |
|
||||
| `views` | [views.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/views.wit) | Refreshing and revealing views, messages to web views, terminals, content tabs, opening locations |
|
||||
| `view-types` | [views.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/views.wit) | The declarative view model: trees, lists, forms, tables, messages, `html` bodies, view events |
|
||||
| `settings` | [views.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/views.wit) | The plugin's own settings, project over global, or the schema default |
|
||||
| `run` | [run.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/run.wit) | Starting the plugin's run configurations in the Run tool window (API 1.4) |
|
||||
| `run-targets` | [run-targets.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/run-targets.wit) | Project run targets that wrap commands, owned by the plugin (API 1.4) |
|
||||
| `database` | [database.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/database.wit) | The plugin's own project data sources and their credentials (API 1.4) |
|
||||
| `mcp` | [mcp.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/mcp.wit) | The command line of the built-in MCP server's stdio bridge, never its URL or token (API 1.4) |
|
||||
| `detail-types` | [details.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/details.wit) | What the pane beside the Services tree shows for a plugin's node |
|
||||
|
||||
## Plugin interfaces (exports)
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ Later:
|
||||
| `fs.read:<path>` | High | `host::fs` reads outside the projects, under that path | 1.0 (the `fs` interface: 1.3) |
|
||||
| `fs.write:<path>` | High | `host::fs` writes outside the projects, under that path; does not imply reading | 1.3 |
|
||||
| `process:<program>` | High | Run that program: `run_process`, `run_command`, `start`, terminals, language servers, debug adapters, runs | 1.0 |
|
||||
| `lsp:<server>` | High | Send that running language server its own requests (`language_server_request`); trusted projects only | 1.9 |
|
||||
| `ipc:<path>` | High | Local Unix sockets or Windows named pipes on the project machine | 1.4 |
|
||||
| `ide.commands` | High | Run any IDE action or another plugin's command as if the user chose it, including running programs, committing and pushing. The plugin's own commands are exempt. | 1.4 |
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ description: "前端 UI 代码的规则:设计 token、CSS Modules、图标、
|
||||
|
||||
## WebKit 兼容性
|
||||
|
||||
在 macOS 和 Linux 上应用运行于 WebKit,最低支持 Safari 16,即 macOS 13 的引擎(macOS 11、12 上更新 Safari 不会更新其他应用使用的引擎)。`pnpm build` 会对产物运行 `scripts/check-webkit.ts`,`src/platform/webkitCompatibility.test.ts` 会检查应用中的正则。请避免:
|
||||
在 macOS 和 Linux 上应用运行于 WebKit,最低支持 Safari 15.4,即 macOS 12 的引擎(应用的最低系统版本)。`pnpm build` 会对产物运行 `scripts/check-webkit.ts`,`src/platform/webkitCompatibility.test.ts` 会检查应用中的正则。请避免:
|
||||
|
||||
- 正则后行断言(`(?<=…)`、`(?<!…)`,需要 Safari 16.4)和正则 `v` 标志;
|
||||
- import attributes;
|
||||
|
||||
@@ -117,7 +117,7 @@ description: "多光标与选区、行编辑、剪贴板历史、代码折叠、
|
||||
| **转到下一个拆分器** | `⌥⇥` | `Alt+Tab` |
|
||||
| **转到上一个拆分器** | `⌥⇧⇥` | `Alt+Shift+Tab` |
|
||||
|
||||
**窗口 › 编辑器标签页** 中还有 **向下拆分**、**取消拆分**、**全部取消拆分**、**移动到另一组**、**在相对组中打开** 和 **更改拆分方向**。也可以把标签页拖到编辑区的某一侧边缘来拆分。
|
||||
**窗口 › 编辑器标签页** 中还有 **向下拆分**、**取消拆分**、**全部取消拆分**、**移动到另一组**、**在相对组中打开** 和 **更改拆分方向**。也可以把标签页拖到编辑区的某一侧边缘来拆分。在 Project 视图中,**在右侧拆分中打开**(`⇧↵` / `Shift+Enter`,右键菜单中也有)会把选中的文件打开到右侧拆分中。
|
||||
|
||||
## 显示选项
|
||||
|
||||
|
||||
Loaded 100 of 241 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача