mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-11 10:41:28 -10:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbcd1818a7 | ||
|
|
c73d858895 | ||
|
|
6e5486586c | ||
|
|
93642b3741 | ||
|
|
b06f2e27ea | ||
|
|
86f69b7fca | ||
|
|
fefa135f5a | ||
|
|
8fe2d35d62 | ||
|
|
21046c3047 | ||
|
|
017561a20b | ||
|
|
d3a68b2754 | ||
|
|
e98a560523 | ||
|
|
6983e8ec2f | ||
|
|
dc355602a9 | ||
|
|
924fa9259d | ||
|
|
24deeb0dbb | ||
|
|
0ce7f6e430 | ||
|
|
84fd0d0a60 | ||
|
|
96c99e0396 | ||
|
|
fd4838fc36 | ||
|
|
4873905884 | ||
|
|
6b37788971 | ||
|
|
44bf648bee |
No files matched your search
@@ -0,0 +1,102 @@
|
||||
//! A short-lived ACP session to learn models, effort levels and other config options
|
||||
//! before the user's first task (Cursor and other ACP agents do not expose these in
|
||||
//! `initialize` alone).
|
||||
|
||||
use super::{Conn, Dialect};
|
||||
use crate::config;
|
||||
use crate::model::AgentConfigOption;
|
||||
use crate::provider::ProviderSpec;
|
||||
use crate::{Shared, client_info};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{Value, json};
|
||||
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.
|
||||
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<(), String> {
|
||||
let root = shared.paths.project_root.clone();
|
||||
let launch = spec.clone();
|
||||
let launch = tokio::task::spawn_blocking({
|
||||
let root = root.clone();
|
||||
move || launch.resolve_for_launch(&root)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let cwd = dunce::canonicalize(&root).unwrap_or(root);
|
||||
let stderr: Arc<Mutex<Vec<String>>> = Arc::default();
|
||||
let lines = stderr.clone();
|
||||
let on_stderr: Box<dyn Fn(String) + Send + Sync> = Box::new(move |l| lines.lock().push(l));
|
||||
let client = Dialect::wrap(Arc::new(ide_jsonrpc::NoopHandler));
|
||||
let peer = (shared.connector)(&launch, &cwd, client.clone(), on_stderr)
|
||||
.map_err(|e| format!("Couldn't start {} (`{}`): {e}", launch.name, launch.command))?;
|
||||
let conn = Conn::new(peer.clone(), client);
|
||||
let work = async {
|
||||
conn.initialize(&client_info()).await.map_err(|e| e.to_string())?;
|
||||
let cwd_s = cwd.to_string_lossy().into_owned();
|
||||
// No project MCP during discovery: Cursor can spend a long time spawning servers.
|
||||
let new: Value = conn
|
||||
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": [] }), None)
|
||||
.await
|
||||
.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 option_ids: Vec<_> = options.iter().map(|o| o.id.as_str()).collect();
|
||||
tracing::info!(
|
||||
"agent {}: settings discovery got {} config option(s) {:?}, {} model effort list(s)",
|
||||
launch.id,
|
||||
options.len(),
|
||||
option_ids,
|
||||
levels.len()
|
||||
);
|
||||
shared.remember_discovered(&launch.id, &options, &[], &levels);
|
||||
Ok::<(), String>(())
|
||||
};
|
||||
let result = tokio::time::timeout(DISCOVER_TIMEOUT, work).await;
|
||||
peer.kill();
|
||||
match result {
|
||||
Ok(Ok(())) => Ok(()),
|
||||
Ok(Err(e)) => Err(match stderr.lock().last() {
|
||||
Some(line) => format!("{e}: {line}"),
|
||||
None => e,
|
||||
}),
|
||||
Err(_) => {
|
||||
let tail = stderr.lock().join("\n");
|
||||
if tail.is_empty() {
|
||||
Err("the agent did not answer in time".into())
|
||||
} else {
|
||||
Err(format!("the agent did not answer in time:\n{tail}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
.cloned()
|
||||
.map(|mut o| {
|
||||
if o.category.as_deref() != Some("thought_level") {
|
||||
o.category = Some("thought_level".into());
|
||||
}
|
||||
o
|
||||
})
|
||||
.collect();
|
||||
(!levels.is_empty()).then_some(levels)
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
//! - Agent → client ([`Dialect`]): `session/update`s are normalized ([`normalize`]) and
|
||||
//! permission requests get the v1 `toolCall`.
|
||||
|
||||
pub(crate) mod discover;
|
||||
mod normalize;
|
||||
|
||||
use ide_jsonrpc::{Handler, Peer, RpcError, codes};
|
||||
@@ -57,6 +58,9 @@ fn client_capabilities() -> Value {
|
||||
"terminal": false,
|
||||
// Subagents as sessions of their own (ACP subagents RFD; v2 baseline).
|
||||
"subagents": {},
|
||||
// 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 },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -93,9 +93,81 @@ fn the_negotiating_request_carries_both_versions() {
|
||||
assert!(p["capabilities"].is_object());
|
||||
assert_eq!(p["clientCapabilities"]["subagents"], json!({}));
|
||||
assert_eq!(p["clientCapabilities"]["terminal"], false);
|
||||
assert_eq!(
|
||||
p["clientCapabilities"]["_meta"]["parameterizedModelPicker"], true,
|
||||
"Cursor and similar agents need parameterized model options (e.g. fast on/off)"
|
||||
);
|
||||
// What we sent in the capture is what claude-agent-acp negotiated v2 and v1 from.
|
||||
let sent = &frames(TURN, "out")[0]["params"];
|
||||
assert_eq!(sent["clientCapabilities"], p["clientCapabilities"]);
|
||||
let sent = frames(TURN, "out")[0]["params"]["clientCapabilities"].clone();
|
||||
let mut expected = sent;
|
||||
expected["_meta"] = p["clientCapabilities"]["_meta"].clone();
|
||||
assert_eq!(expected, p["clientCapabilities"]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn discovery_reads_effort_and_fast_from_session_new_then_stops_the_agent() {
|
||||
#[derive(Default)]
|
||||
struct DiscoveryAgent {
|
||||
peer: std::sync::OnceLock<Peer>,
|
||||
calls: parking_lot::Mutex<Vec<(String, Value)>>,
|
||||
}
|
||||
impl Handler for DiscoveryAgent {
|
||||
fn on_notification(&self, _: &str, _: Value) {}
|
||||
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
||||
self.calls.lock().push((method.to_owned(), params));
|
||||
match method {
|
||||
"initialize" => Ok(json!({
|
||||
"protocolVersion": 1,
|
||||
"agentCapabilities": { "loadSession": true }
|
||||
})),
|
||||
"session/new" => Ok(json!({
|
||||
"sessionId": "discover",
|
||||
"configOptions": [
|
||||
{"id": "model", "category": "model", "type": "select", "currentValue": "m1",
|
||||
"options": [{"value": "m1", "name": "One"}, {"value": "m2", "name": "Two"}]},
|
||||
{"id": "effort", "type": "select", "currentValue": "medium",
|
||||
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]},
|
||||
{"id": "fast", "type": "select", "currentValue": "false",
|
||||
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}
|
||||
]
|
||||
})),
|
||||
_ => Ok(json!({})),
|
||||
}
|
||||
}
|
||||
}
|
||||
let agent = Arc::new(DiscoveryAgent::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent_for_connector = agent.clone();
|
||||
let connector: crate::Connector = Arc::new(move |_, _, client, _| {
|
||||
let (ca, cb) = tokio::io::duplex(1 << 20);
|
||||
let (car, caw) = tokio::io::split(ca);
|
||||
let (cbr, cbw) = tokio::io::split(cb);
|
||||
let agent_peer = Peer::connect_framed(cbr, cbw, agent_for_connector.clone(), Framing::Lines);
|
||||
let _ = agent_for_connector.peer.set(agent_peer);
|
||||
Ok(Peer::connect_framed(car, caw, client, Framing::Lines))
|
||||
});
|
||||
let mgr = crate::AgentManager::with_connector(
|
||||
crate::AgentPaths {
|
||||
project_root: tmp.path().to_path_buf(),
|
||||
store_dir: tmp.path().join("store"),
|
||||
worktrees_dir: tmp.path().join("worktrees"),
|
||||
models_file: tmp.path().join("models.json"),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
);
|
||||
mgr.commands("cursor", None).await.unwrap();
|
||||
let calls = agent.calls.lock();
|
||||
assert_eq!(calls.iter().map(|(m, _)| m.as_str()).collect::<Vec<_>>(), ["initialize", "session/new"]);
|
||||
assert_eq!(calls[1].1["mcpServers"], json!([]));
|
||||
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
|
||||
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"));
|
||||
assert!(cursor.thought_levels["m1"].iter().any(|o| o.category.as_deref() == Some("thought_level")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -107,7 +107,15 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
|
||||
Some(AgentConfigOption {
|
||||
name: str_of(o, "name").unwrap_or_else(|| id.clone()),
|
||||
description: str_of(o, "description"),
|
||||
category: str_of(o, "category").or_else(|| matches!(id.as_str(), "model" | "mode").then(|| id.clone())),
|
||||
category: str_of(o, "category").or_else(|| {
|
||||
matches!(id.as_str(), "model" | "mode")
|
||||
.then(|| id.clone())
|
||||
.or_else(|| {
|
||||
matches!(id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
|
||||
.then(|| "thought_level".into())
|
||||
})
|
||||
.or_else(|| matches!(id.as_str(), "fast").then(|| "model_config".into()))
|
||||
}),
|
||||
current: str_of(o, "currentValue"),
|
||||
values,
|
||||
via: AgentConfigVia::Option,
|
||||
@@ -333,6 +341,21 @@ mod tests {
|
||||
assert!(bypass_approvals(&url_mode));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_and_fast_categories_are_inferred_from_ids() {
|
||||
let c = options_of(&json!({
|
||||
"configOptions": [
|
||||
{"id": "effort", "type": "select", "currentValue": "high",
|
||||
"options": [{"value": "low"}, {"value": "high"}]},
|
||||
{"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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_sources_in_updates_and_stored_tasks() {
|
||||
let update = json!({"availableCommands": [
|
||||
|
||||
+176
-15
@@ -16,6 +16,7 @@ mod model;
|
||||
mod native;
|
||||
mod provider;
|
||||
pub mod registry;
|
||||
pub mod security;
|
||||
mod skills;
|
||||
mod timeline;
|
||||
mod worktree;
|
||||
@@ -39,6 +40,9 @@ use std::time::Duration;
|
||||
/// Receives the events of one project's tasks.
|
||||
pub type AgentSink = Arc<dyn Fn(AgentEvent) + Send + Sync>;
|
||||
|
||||
/// Returns the active command execution security policy for the project.
|
||||
pub type SecurityHook = Arc<dyn Fn() -> ide_core::settings::AgentSecuritySettings + Send + Sync>;
|
||||
|
||||
/// Entry changes are batched for this long before they are sent.
|
||||
const FLUSH_DELAY: Duration = Duration::from_millis(40);
|
||||
/// Updates that arrive this late after `session/load` returns are still replayed history.
|
||||
@@ -102,6 +106,7 @@ struct Shared {
|
||||
runtime: tokio::runtime::Handle,
|
||||
connector: Connector,
|
||||
sink: RwLock<Option<AgentSink>>,
|
||||
security: RwLock<Option<SecurityHook>>,
|
||||
known: Mutex<HashMap<String, Known>>,
|
||||
providers: RwLock<Vec<provider::ProviderSpec>>,
|
||||
/// Last probe of each provider.
|
||||
@@ -177,9 +182,15 @@ impl Shared {
|
||||
let shared = self.clone();
|
||||
// Keep the lookup alive if a composer unmounts while it is waiting.
|
||||
self.runtime.spawn(async move {
|
||||
if let Ok(found) =
|
||||
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
|
||||
{
|
||||
tracing::info!("agent {}: settings discovery started", spec.id);
|
||||
let discovered = if spec.driver == provider::Driver::Acp {
|
||||
acp::discover::run(&shared, &spec).await.map(|_| None)
|
||||
} else if spec.driver == provider::Driver::Claude {
|
||||
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await.map(Some)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
if let Ok(Some(found)) = discovered {
|
||||
let commands =
|
||||
config::commands_of(&json!({ "availableCommands": found.commands }), &spec.id);
|
||||
let options = config::options_of(&json!({ "configOptions": found.options }));
|
||||
@@ -198,6 +209,8 @@ impl Shared {
|
||||
shared.remember_discovered(&spec.id, &options, &commands, &levels);
|
||||
})
|
||||
.await;
|
||||
} else if let Err(e) = discovered {
|
||||
tracing::info!("agent {}: settings discovery failed: {e}", spec.id);
|
||||
}
|
||||
let _ = done.send(true);
|
||||
});
|
||||
@@ -220,12 +233,17 @@ impl Shared {
|
||||
fn knows_all_levels(&self, provider: &str) -> bool {
|
||||
let cache = self.known.lock();
|
||||
let Some(known) = cache.get(provider) else { return false };
|
||||
known
|
||||
let models: Vec<&str> = known
|
||||
.config
|
||||
.iter()
|
||||
.filter(|o| o.category.as_deref() == Some("model"))
|
||||
.flat_map(|o| &o.values)
|
||||
.all(|v| known.thought_levels.contains_key(&v.value))
|
||||
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
|
||||
.collect();
|
||||
// No advertised models yet: nothing to attach effort levels to.
|
||||
if models.is_empty() {
|
||||
return false;
|
||||
}
|
||||
models.iter().all(|id| known.thought_levels.contains_key(*id))
|
||||
}
|
||||
|
||||
fn root_key(&self) -> String {
|
||||
@@ -434,6 +452,7 @@ impl AgentManager {
|
||||
runtime,
|
||||
connector,
|
||||
sink: RwLock::new(None),
|
||||
security: RwLock::new(None),
|
||||
known: Mutex::new(known),
|
||||
providers,
|
||||
probes: Mutex::new(HashMap::new()),
|
||||
@@ -477,6 +496,11 @@ impl AgentManager {
|
||||
tasks
|
||||
}
|
||||
|
||||
/// Sets the callback used to read the active command execution security policy.
|
||||
pub fn set_security_hook(&self, hook: SecurityHook) {
|
||||
*self.shared.security.write() = Some(hook);
|
||||
}
|
||||
|
||||
/// The agents (custom ones re-read), whether each is installed, and what they offered last time.
|
||||
pub fn providers(&self) -> Vec<AgentProvider> {
|
||||
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
|
||||
@@ -501,7 +525,9 @@ impl AgentManager {
|
||||
let new = specs.iter().find(|p| &p.id == id);
|
||||
matches!((old, new), (Some(a), Some(b)) if a.command == b.command && a.args == b.args && a.env == b.env)
|
||||
});
|
||||
*prev = specs;
|
||||
*prev = specs.clone();
|
||||
drop(probes);
|
||||
drop(prev);
|
||||
list
|
||||
}
|
||||
|
||||
@@ -522,6 +548,30 @@ impl AgentManager {
|
||||
provider::set_builtin_env(&dir, id, env)
|
||||
}
|
||||
|
||||
/// Saves a new custom ACP agent through its agent file.
|
||||
pub fn save_custom_agent(&self, input: CustomAgentInput) -> IdeResult<()> {
|
||||
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
|
||||
return Err(IdeError::unavailable("there is no custom agents directory"));
|
||||
};
|
||||
provider::save_custom(&dir, &input)
|
||||
}
|
||||
|
||||
/// Removes a custom agent's file (registry installs go through `registry_remove`).
|
||||
pub fn remove_custom_agent(&self, id: &str) -> IdeResult<()> {
|
||||
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
|
||||
return Err(IdeError::unavailable("there is no custom agents directory"));
|
||||
};
|
||||
provider::remove_custom(&dir, id)
|
||||
}
|
||||
|
||||
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom agents.
|
||||
pub fn import_acp_agents(&self, text: &str) -> IdeResult<Vec<AcpImportOutcome>> {
|
||||
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
|
||||
return Err(IdeError::unavailable("there is no custom agents directory"));
|
||||
};
|
||||
provider::import_acp(&dir, text)
|
||||
}
|
||||
|
||||
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
|
||||
pub fn reload_providers(&self) {
|
||||
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
|
||||
@@ -552,10 +602,16 @@ impl AgentManager {
|
||||
let mut offered = offered;
|
||||
if task.is_none()
|
||||
&& (offered.is_empty() || !self.shared.has_config(&provider) || !self.shared.knows_all_levels(&provider))
|
||||
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
|
||||
&& let Some(spec) = self
|
||||
.shared
|
||||
.spec(&provider)
|
||||
.filter(|s| matches!(s.driver, provider::Driver::Claude | provider::Driver::Acp))
|
||||
{
|
||||
let claude = spec.driver == provider::Driver::Claude;
|
||||
self.shared.fetch_settings(spec).await;
|
||||
offered = self.shared.known_commands(&provider);
|
||||
if claude {
|
||||
offered = self.shared.known_commands(&provider);
|
||||
}
|
||||
}
|
||||
for &(name, description) in builtin_commands(&provider) {
|
||||
if !offered.iter().any(|c| c.name == name) {
|
||||
@@ -888,14 +944,38 @@ impl AgentManager {
|
||||
}
|
||||
o.clone()
|
||||
};
|
||||
let previous = target.current.clone();
|
||||
// Cursor's `session/set_config_option` (Fast, effort) can take seconds. Show the
|
||||
// choice first so the switch and slider move immediately.
|
||||
apply_config_choice(&task, option, &value);
|
||||
task.persist();
|
||||
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") || target.current.as_deref() == Some(value.as_str());
|
||||
target.category.as_deref() != Some("model") || previous.as_deref() == Some(value.as_str());
|
||||
if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) {
|
||||
let (method, mut params) = config::set_request(&target, &value);
|
||||
params["sessionId"] = json!(live.session);
|
||||
let reply: Value =
|
||||
live.peer.request(method, params, None).await.map_err(|e| IdeError::internal(task.describe(e)))?;
|
||||
let reply: Value = match live.peer.request(method, params, None).await {
|
||||
Ok(reply) => reply,
|
||||
Err(e) => {
|
||||
{
|
||||
let mut s = task.state.lock();
|
||||
s.wanted.retain(|c| c.id != option);
|
||||
if let Some(prev) = previous.clone() {
|
||||
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: prev });
|
||||
}
|
||||
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
|
||||
o.current = previous.clone();
|
||||
}
|
||||
s.task.model = model_of(&s.task.config).or(s.task.model.take());
|
||||
}
|
||||
task.persist();
|
||||
task.emit_task();
|
||||
return Err(IdeError::internal(task.describe(e)));
|
||||
}
|
||||
};
|
||||
if let Some(options) = reply.get("configOptions") {
|
||||
config::merge_update(&mut task.state.lock().task.config, options);
|
||||
model_options = true;
|
||||
@@ -906,8 +986,6 @@ impl AgentManager {
|
||||
if !model_options {
|
||||
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
|
||||
}
|
||||
s.wanted.retain(|c| c.id != option);
|
||||
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.clone() });
|
||||
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
|
||||
o.current = Some(value);
|
||||
}
|
||||
@@ -1306,6 +1384,41 @@ impl AgentManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accept: copies the task's changed files among `rel_paths` (all with `None`) from its
|
||||
/// worktree into the local checkout, uncommitted, for the user to review and commit.
|
||||
/// Files the checkout changed itself are left alone and reported. Only worktree tasks
|
||||
/// have anything to accept.
|
||||
pub async fn accept_changes(&self, id: &str, rel_paths: Option<Vec<String>>) -> IdeResult<AgentAcceptOutcome> {
|
||||
let task = self.task(id)?;
|
||||
if task.state.lock().busy {
|
||||
return Err(IdeError::invalid("the agent is working: stop it before accepting its changes"));
|
||||
}
|
||||
let (isolation, cwd, base, root, before) = {
|
||||
let s = task.state.lock();
|
||||
(
|
||||
s.task.isolation,
|
||||
s.task.cwd.0.clone(),
|
||||
s.task.base_commit.clone(),
|
||||
s.task.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone()),
|
||||
(*s.before).clone(),
|
||||
)
|
||||
};
|
||||
if isolation != Isolation::Worktree {
|
||||
return Err(IdeError::invalid("the task works in the local checkout: there is nothing to accept"));
|
||||
}
|
||||
let Some(base) = base else {
|
||||
return Err(IdeError::invalid("the task has no base revision to accept from"));
|
||||
};
|
||||
blocking(move || {
|
||||
let wanted: Vec<AgentChange> = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before)
|
||||
.into_iter()
|
||||
.filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path)))
|
||||
.collect();
|
||||
worktree::accept(&root, &cwd, &base, &wanted)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Stops every agent (project closed).
|
||||
pub fn shutdown(&self) {
|
||||
for task in self.tasks.read().values() {
|
||||
@@ -2124,6 +2237,44 @@ impl Task {
|
||||
.iter()
|
||||
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
|
||||
.collect();
|
||||
if let Some(hook) = self.shared.security.read().clone()
|
||||
&& let Some(command) = security::extract_command(params)
|
||||
{
|
||||
match security::evaluate_command(&command, &hook()) {
|
||||
security::PolicyDecision::Blocked(pattern) => {
|
||||
let reject = options.iter().find(|o| o.kind.starts_with("reject")).cloned();
|
||||
let _ = self.push_entry(AgentEntry::Permission {
|
||||
id,
|
||||
title,
|
||||
options,
|
||||
questions: Vec::new(),
|
||||
chosen: Some(format!("Blocked by policy ({pattern})")),
|
||||
});
|
||||
return match reject {
|
||||
Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option.id } }),
|
||||
None => json!({ "outcome": { "outcome": "cancelled" } }),
|
||||
};
|
||||
}
|
||||
security::PolicyDecision::Allowed => {
|
||||
if let Some(allow) = options
|
||||
.iter()
|
||||
.find(|o| o.kind == "allow_once")
|
||||
.or_else(|| options.iter().find(|o| o.kind.starts_with("allow")))
|
||||
.cloned()
|
||||
{
|
||||
let _ = self.push_entry(AgentEntry::Permission {
|
||||
id,
|
||||
title,
|
||||
options,
|
||||
questions: Vec::new(),
|
||||
chosen: Some(format!("{} (whitelisted)", allow.name)),
|
||||
});
|
||||
return json!({ "outcome": { "outcome": "selected", "optionId": allow.id } });
|
||||
}
|
||||
}
|
||||
security::PolicyDecision::AskUser => {}
|
||||
}
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let entry =
|
||||
self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None });
|
||||
@@ -2437,7 +2588,7 @@ fn with_details(message: String, data: Option<&Value>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn client_info() -> Value {
|
||||
pub(crate) fn client_info() -> Value {
|
||||
json!({ "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") })
|
||||
}
|
||||
|
||||
@@ -2496,6 +2647,16 @@ fn retry_of(update: &Value, now: f64) -> Option<AgentRetry> {
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_config_choice(task: &Task, option: &str, value: &str) {
|
||||
let mut s = task.state.lock();
|
||||
s.wanted.retain(|c| c.id != option);
|
||||
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.to_owned() });
|
||||
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
|
||||
o.current = Some(value.to_owned());
|
||||
}
|
||||
s.task.model = model_of(&s.task.config).or(s.task.model.take());
|
||||
}
|
||||
|
||||
fn model_of(config: &[AgentConfigOption]) -> Option<String> {
|
||||
config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone())
|
||||
}
|
||||
|
||||
@@ -602,3 +602,49 @@ pub struct McpServerInput {
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Payload for creating a custom ACP agent (always the ACP driver); the id
|
||||
/// becomes the `<config>/agents/<id>.toml` file name.
|
||||
#[derive(Clone, Debug, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CustomAgentInput {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// What importing one entry of a JetBrains `acp.json` file did.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AcpImportOutcome {
|
||||
/// The `agent_servers` key the entry was imported from.
|
||||
pub name: String,
|
||||
/// The id its agent file would get (also when skipped).
|
||||
pub id: String,
|
||||
pub saved: bool,
|
||||
/// Why the entry was skipped; empty when it was saved.
|
||||
#[serde(default)]
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// What accepting a worktree task's changes copied into the local checkout.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentAcceptOutcome {
|
||||
/// Relative paths copied into the checkout (uncommitted), including ones already identical.
|
||||
pub accepted: Vec<String>,
|
||||
/// Files left alone because the checkout has its own edits to them.
|
||||
pub skipped: Vec<AgentAcceptSkipped>,
|
||||
}
|
||||
|
||||
/// A file accept left alone, with why.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentAcceptSkipped {
|
||||
pub path: String,
|
||||
pub reason: String,
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `<config>/agents/<id>.toml` (written by hand, or by installing one from the ACP
|
||||
//! Registry, see `registry`).
|
||||
|
||||
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
|
||||
use crate::{AcpImportOutcome, AgentCommandReadOnlyReason, AgentProvider, AgentSource, CustomAgentInput};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -278,6 +278,127 @@ fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAg
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Saves a new custom agent; refuses built-in and ACP Registry ids, taken ids
|
||||
/// and an empty command. The id is also the agent file's name, so it must be
|
||||
/// a safe file stem.
|
||||
pub(crate) fn save_custom(custom_dir: &Path, input: &CustomAgentInput) -> IdeResult<()> {
|
||||
let id = validated_custom_id(&input.id)?;
|
||||
if builtin_spec(&id).is_some() {
|
||||
return Err(IdeError::invalid(format!("{id} is a built-in agent; change its command instead")));
|
||||
}
|
||||
if input.command.trim().is_empty() {
|
||||
return Err(IdeError::invalid("the agent command must not be empty"));
|
||||
}
|
||||
let file = custom_dir.join(format!("{id}.toml"));
|
||||
if file.try_exists()? {
|
||||
if CustomAgent::read(&file).is_ok_and(|a| a.registry.is_some()) {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{id} is installed from the ACP Registry; uninstall it there to replace it"
|
||||
)));
|
||||
}
|
||||
return Err(IdeError::invalid(format!("there is already a custom agent '{id}'")));
|
||||
}
|
||||
CustomAgent {
|
||||
name: Some(input.name.clone()),
|
||||
driver: Driver::Acp,
|
||||
command: input.command.clone(),
|
||||
args: input.args.clone(),
|
||||
env: input.env.clone(),
|
||||
registry: None,
|
||||
}
|
||||
.write(&file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a custom agent's file; refuses built-in, ACP Registry and missing ids.
|
||||
pub(crate) fn remove_custom(custom_dir: &Path, id: &str) -> IdeResult<()> {
|
||||
let id = validated_custom_id(id)?;
|
||||
let id = id.as_str();
|
||||
if builtin_spec(id).is_some() {
|
||||
return Err(IdeError::invalid(format!("{id} is a built-in agent and cannot be removed")));
|
||||
}
|
||||
let file = custom_dir.join(format!("{id}.toml"));
|
||||
let agent =
|
||||
CustomAgent::read(&file).map_err(|_| IdeError::not_found(format!("there is no custom agent '{id}'")))?;
|
||||
if agent.registry.is_some() {
|
||||
return Err(IdeError::invalid(format!("{id} is installed from the ACP Registry; uninstall it there")));
|
||||
}
|
||||
match std::fs::remove_file(&file) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom
|
||||
/// agents, one outcome per entry; entries that collide, lack a command or
|
||||
/// have no usable name are skipped with a reason.
|
||||
pub(crate) fn import_acp(custom_dir: &Path, text: &str) -> IdeResult<Vec<AcpImportOutcome>> {
|
||||
#[derive(Deserialize)]
|
||||
struct AcpJson {
|
||||
#[serde(default)]
|
||||
agent_servers: BTreeMap<String, AcpServer>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct AcpServer {
|
||||
#[serde(default)]
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: BTreeMap<String, String>,
|
||||
}
|
||||
let parsed: AcpJson =
|
||||
serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("not an acp.json file: {e}")))?;
|
||||
let mut outcomes = Vec::with_capacity(parsed.agent_servers.len());
|
||||
for (name, server) in parsed.agent_servers {
|
||||
let id = slugify_id(&name);
|
||||
let mut outcome = AcpImportOutcome { name, id: id.clone(), saved: false, reason: String::new() };
|
||||
if id.is_empty() {
|
||||
outcome.reason = "the name has no usable characters for an id".to_owned();
|
||||
} else if builtin_spec(&id).is_some() {
|
||||
outcome.reason = format!("{id} is a built-in agent");
|
||||
} else if server.command.trim().is_empty() {
|
||||
outcome.reason = "the entry has no command".to_owned();
|
||||
} else if custom_dir.join(format!("{id}.toml")).try_exists()? {
|
||||
outcome.reason = format!("there is already a custom agent '{id}'");
|
||||
} else {
|
||||
CustomAgent {
|
||||
name: Some(outcome.name.clone()),
|
||||
driver: Driver::Acp,
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env,
|
||||
registry: None,
|
||||
}
|
||||
.write(&custom_dir.join(format!("{id}.toml")))?;
|
||||
outcome.saved = true;
|
||||
}
|
||||
outcomes.push(outcome);
|
||||
}
|
||||
Ok(outcomes)
|
||||
}
|
||||
|
||||
/// A custom agent id doubles as a file stem: letters, digits, `-` and `_` only.
|
||||
fn validated_custom_id(id: &str) -> IdeResult<String> {
|
||||
if !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||
return Ok(id.to_owned());
|
||||
}
|
||||
Err(IdeError::invalid(format!("'{id}' is not a usable agent id (letters, digits, '-' and '_' only)")))
|
||||
}
|
||||
|
||||
/// Turns an `acp.json` server name into a file-stem id (`"My Agent!"` → `"my-agent"`).
|
||||
fn slugify_id(name: &str) -> String {
|
||||
let mut id = String::with_capacity(name.len());
|
||||
for c in name.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
id.push(c.to_ascii_lowercase());
|
||||
} else if !id.ends_with('-') {
|
||||
id.push('-');
|
||||
}
|
||||
}
|
||||
id.trim_matches('-').to_owned()
|
||||
}
|
||||
|
||||
/// An agent file, `<config>/agents/<id>.toml`.
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -753,4 +874,80 @@ mod tests {
|
||||
assert_eq!(spec.source, AgentSource::Registry { version: "0.10.0".into(), update: None });
|
||||
assert!(toml::from_str::<CustomAgent>("command = \"x\"\n[registry]\nid = \"x\"\n").is_err());
|
||||
}
|
||||
|
||||
fn custom_input(id: &str) -> CustomAgentInput {
|
||||
CustomAgentInput {
|
||||
id: id.into(),
|
||||
name: "Mine".into(),
|
||||
command: "/opt/mine".into(),
|
||||
args: vec!["acp".into()],
|
||||
env: BTreeMap::from([("K".to_owned(), "v".to_owned())]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_agents_save_list_and_remove() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save_custom(dir.path(), &custom_input("mine")).unwrap();
|
||||
let mine = load(Some(dir.path())).into_iter().find(|s| s.id == "mine").unwrap();
|
||||
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine"));
|
||||
assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]);
|
||||
assert_eq!(mine.source, AgentSource::Custom);
|
||||
remove_custom(dir.path(), "mine").unwrap();
|
||||
assert!(!load(Some(dir.path())).iter().any(|s| s.id == "mine"));
|
||||
remove_custom(dir.path(), "mine").unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_agents_refuse_builtin_registry_and_bad_ids() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save_custom(dir.path(), &custom_input("claude")).unwrap_err();
|
||||
save_custom(dir.path(), &custom_input("../evil")).unwrap_err();
|
||||
save_custom(dir.path(), &custom_input("")).unwrap_err();
|
||||
let mut empty_command = custom_input("mine");
|
||||
empty_command.command = " ".into();
|
||||
save_custom(dir.path(), &empty_command).unwrap_err();
|
||||
save_custom(dir.path(), &custom_input("mine")).unwrap();
|
||||
save_custom(dir.path(), &custom_input("mine")).unwrap_err();
|
||||
remove_custom(dir.path(), "claude").unwrap_err();
|
||||
// An id that isn't a plain file stem never reaches outside the agents directory.
|
||||
let outside = dir.path().join("outside.toml");
|
||||
std::fs::write(&outside, "command = \"/bin/sh\"\n").unwrap();
|
||||
let agents = dir.path().join("agents");
|
||||
std::fs::create_dir(&agents).unwrap();
|
||||
remove_custom(&agents, "../outside").unwrap_err();
|
||||
assert!(outside.exists());
|
||||
std::fs::write(
|
||||
dir.path().join("amp-acp.toml"),
|
||||
"command = \"/registry/amp\"\n[registry]\nid = \"amp-acp\"\nversion = \"1.0\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
save_custom(dir.path(), &custom_input("amp-acp")).unwrap_err();
|
||||
remove_custom(dir.path(), "amp-acp").unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_json_imports_servers_and_skips_collisions() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save_custom(dir.path(), &custom_input("taken")).unwrap();
|
||||
let outcomes = import_acp(
|
||||
dir.path(),
|
||||
r#"{"agent_servers": {
|
||||
"My Agent!": {"command": "/opt/agent", "args": ["--acp"], "env": {"K": "v"}},
|
||||
"taken": {"command": "/opt/taken"},
|
||||
"claude": {"command": "/opt/claude"},
|
||||
"!!!": {"command": "/opt/noname"},
|
||||
"silent": {"args": []}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let saved: Vec<&str> = outcomes.iter().filter(|o| o.saved).map(|o| o.id.as_str()).collect();
|
||||
assert_eq!(saved, ["my-agent"]);
|
||||
assert!(outcomes.iter().filter(|o| !o.saved).all(|o| !o.reason.is_empty()));
|
||||
let mine = load(Some(dir.path())).into_iter().find(|s| s.id == "my-agent").unwrap();
|
||||
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("My Agent!", "/opt/agent"));
|
||||
assert_eq!(mine.source, AgentSource::Custom);
|
||||
import_acp(dir.path(), "not json").unwrap_err();
|
||||
assert!(import_acp(dir.path(), "{}").unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
//! Command execution security policy (Whitelist / Blacklist) for coding agents.
|
||||
|
||||
use ide_core::settings::AgentSecuritySettings;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PolicyDecision {
|
||||
/// Command matched a blacklist pattern (`pattern`).
|
||||
Blocked(String),
|
||||
/// Command matched the whitelist and no blacklist pattern.
|
||||
Allowed,
|
||||
/// Neither matched; prompt the user as usual.
|
||||
AskUser,
|
||||
}
|
||||
|
||||
/// Extracts the shell command string from an ACP `session/request_permission` payload
|
||||
/// when the request is for executing a command.
|
||||
pub fn extract_command(params: &Value) -> Option<String> {
|
||||
let tool = params.get("toolCall").or_else(|| params.pointer("/subject/toolCall"))?;
|
||||
let raw_cmd = tool.pointer("/rawInput/command").or_else(|| tool.pointer("/rawInput/cmd"));
|
||||
if let Some(cmd) = raw_cmd.and_then(command_from_value) {
|
||||
return Some(cmd);
|
||||
}
|
||||
let kind = tool.get("kind").and_then(Value::as_str).unwrap_or_default();
|
||||
let name = tool.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
let title =
|
||||
tool.get("title").and_then(Value::as_str).or_else(|| params.get("title").and_then(Value::as_str))?.trim();
|
||||
if let Some(cmd) = title.strip_prefix("Run ").map(str::trim).filter(|s| !s.is_empty()) {
|
||||
return Some(cmd.to_owned());
|
||||
}
|
||||
if (kind == "execute" || matches!(name, "Bash" | "PowerShell" | "Execute" | "Run" | "terminal"))
|
||||
&& !title.is_empty()
|
||||
&& title != "Terminal"
|
||||
&& title != "Run a command"
|
||||
{
|
||||
return Some(title.to_owned());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn command_from_value(val: &Value) -> Option<String> {
|
||||
match val {
|
||||
Value::String(s) => {
|
||||
let trimmed = s.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_owned())
|
||||
}
|
||||
Value::Array(items) => {
|
||||
let words: Vec<&str> = items.iter().filter_map(Value::as_str).collect();
|
||||
if words.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cmd = match words.as_slice() {
|
||||
[_, flag, script] if flag.starts_with('-') && flag.ends_with('c') => (*script).trim().to_owned(),
|
||||
_ => words.join(" ").trim().to_owned(),
|
||||
};
|
||||
(!cmd.is_empty()).then_some(cmd)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks whether a single pattern matches `command` using prefix + glob rules:
|
||||
/// - If `pattern` contains `*` or `?`, matches using glob wildcards (where `*` matches
|
||||
/// any sequence of characters). A trailing ` *` in the pattern also matches the bare
|
||||
/// command without arguments (e.g. `git status *` matches `git status`).
|
||||
/// - Otherwise, matches when `command` equals `pattern` or starts with `pattern` followed
|
||||
/// by whitespace (prefix match at word boundary).
|
||||
pub fn matches_command(pattern: &str, command: &str) -> bool {
|
||||
let pat = pattern.trim();
|
||||
let cmd = command.trim();
|
||||
if pat.is_empty() || cmd.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if pat.contains('*') || pat.contains('?') {
|
||||
if let Some(stem) = pat.strip_suffix(" *")
|
||||
&& cmd == stem.trim()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
wildcard_match(pat.as_bytes(), cmd.as_bytes())
|
||||
} else {
|
||||
cmd == pat || cmd.strip_prefix(pat).is_some_and(|rest| rest.starts_with(char::is_whitespace))
|
||||
}
|
||||
}
|
||||
|
||||
fn wildcard_match(pat: &[u8], text: &[u8]) -> bool {
|
||||
let (mut p, mut t) = (0, 0);
|
||||
let (mut star_p, mut star_t) = (None, 0);
|
||||
while t < text.len() {
|
||||
if p < pat.len() && (pat[p] == b'?' || pat[p] == text[t]) {
|
||||
p += 1;
|
||||
t += 1;
|
||||
} else if p < pat.len() && pat[p] == b'*' {
|
||||
star_p = Some(p);
|
||||
p += 1;
|
||||
star_t = t;
|
||||
} else if let Some(sp) = star_p {
|
||||
p = sp + 1;
|
||||
star_t += 1;
|
||||
t = star_t;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while p < pat.len() && pat[p] == b'*' {
|
||||
p += 1;
|
||||
}
|
||||
p == pat.len()
|
||||
}
|
||||
|
||||
/// Matches an allowed command entry against a sub-command.
|
||||
/// An allow entry is a whole command or a prefix only up to a word boundary:
|
||||
/// e.g. `git status` matches `git status` and `git status -s`, but NOT `git statusx`.
|
||||
/// Any accidental trailing `*` in the allow entry is stripped so `git status*` behaves
|
||||
/// cleanly as a prefix at word boundaries instead of a glob wildcard.
|
||||
pub fn matches_allow_entry(entry: &str, command: &str) -> bool {
|
||||
let mut entry = entry.trim();
|
||||
if let Some(stripped) = entry.strip_suffix('*') {
|
||||
entry = stripped.trim_end();
|
||||
}
|
||||
let cmd = command.trim();
|
||||
if entry.is_empty() || cmd.is_empty() {
|
||||
return false;
|
||||
}
|
||||
cmd == entry || cmd.strip_prefix(entry).is_some_and(|rest| rest.starts_with(char::is_whitespace))
|
||||
}
|
||||
|
||||
/// Checks whether a word is an environment variable assignment (`IDENTIFIER=...`).
|
||||
fn is_env_assignment_word(word: &str) -> bool {
|
||||
let Some((name, _)) = word.split_once('=') else {
|
||||
return false;
|
||||
};
|
||||
if name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut chars = name.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
/// Returns true if the sub-command starts with an environment variable assignment,
|
||||
/// e.g. `FOO=bar cmd` or `PORT=3000`.
|
||||
fn has_leading_env_assignment(cmd: &str) -> bool {
|
||||
let trimmed = cmd.trim_start();
|
||||
let bytes = trimmed.as_bytes();
|
||||
let mut i = 0;
|
||||
let mut quote: Option<u8> = None;
|
||||
while i < bytes.len() {
|
||||
let b = bytes[i];
|
||||
if b == b'\\' && quote != Some(b'\'') && i + 1 < bytes.len() {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if let Some(q) = quote {
|
||||
if b == q {
|
||||
quote = None;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if b == b'\'' || b == b'"' {
|
||||
quote = Some(b);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if b.is_ascii_whitespace() {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let first_word = &trimmed[..i];
|
||||
is_env_assignment_word(first_word)
|
||||
}
|
||||
|
||||
/// Checks whether `command` contains shell constructs that must never be auto-approved:
|
||||
/// - Command substitution: `$(...)` or backticks
|
||||
/// - Redirection: `>`, `>>`, `<`
|
||||
/// - Here-docs: `<<`
|
||||
/// - Subshells: `(...)`
|
||||
/// - Leading environment assignments: `VAR=val cmd`
|
||||
pub fn has_unsafe_shell_syntax(command: &str) -> bool {
|
||||
let cmd = command.trim();
|
||||
if cmd.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if has_leading_env_assignment(cmd) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let bytes = cmd.as_bytes();
|
||||
let mut i = 0;
|
||||
let mut quote: Option<u8> = None;
|
||||
|
||||
while i < bytes.len() {
|
||||
let b = bytes[i];
|
||||
|
||||
if b == b'\\' && quote != Some(b'\'') && i + 1 < bytes.len() {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(q) = quote {
|
||||
if b == q {
|
||||
quote = None;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Inside double quotes: $(...) and `...` are evaluated by the shell.
|
||||
if q == b'"' {
|
||||
if b == b'`' {
|
||||
return true;
|
||||
}
|
||||
if b == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'(' {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
match b {
|
||||
b'\'' | b'"' => {
|
||||
quote = Some(b);
|
||||
i += 1;
|
||||
}
|
||||
b'`' => return true,
|
||||
b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'(' => return true,
|
||||
b'<' | b'>' => return true,
|
||||
b'(' | b')' => return true,
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Splits a shell command into sub-commands separated by `;`, `&&`, `||`, `|`, or `\n`
|
||||
/// (outside single/double quotes) so compound commands cannot bypass policy checks.
|
||||
fn split_subcommands(command: &str) -> Vec<&str> {
|
||||
let bytes = command.as_bytes();
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut i = 0;
|
||||
let mut quote: Option<u8> = None;
|
||||
|
||||
while i < bytes.len() {
|
||||
let b = bytes[i];
|
||||
if let Some(q) = quote {
|
||||
if b == b'\\' && q == b'"' && i + 1 < bytes.len() {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if b == q {
|
||||
quote = None;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
match b {
|
||||
b'\'' | b'"' => {
|
||||
quote = Some(b);
|
||||
i += 1;
|
||||
}
|
||||
b'\\' if i + 1 < bytes.len() => {
|
||||
i += 2;
|
||||
}
|
||||
b';' | b'\n' => {
|
||||
let seg = command[start..i].trim();
|
||||
if !seg.is_empty() {
|
||||
out.push(seg);
|
||||
}
|
||||
i += 1;
|
||||
start = i;
|
||||
}
|
||||
b'&' | b'|' => {
|
||||
let seg = command[start..i].trim();
|
||||
if !seg.is_empty() {
|
||||
out.push(seg);
|
||||
}
|
||||
if i + 1 < bytes.len() && bytes[i + 1] == b {
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
start = i;
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
let tail = command[start..].trim();
|
||||
if !tail.is_empty() {
|
||||
out.push(tail);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Evaluates `command` against `settings`:
|
||||
/// 1. Blacklist takes precedence: if the full command or any sub-command matches a
|
||||
/// `blocked_commands` entry, returns [`PolicyDecision::Blocked`].
|
||||
/// 2. Whitelist: allowed_commands cannot auto-approve when empty.
|
||||
/// 3. Every sub-command in a chain must be safe (no command substitution, redirection,
|
||||
/// subshell, or env assignments) and match at least one `allowed_commands` entry
|
||||
/// exactly or up to word boundary; otherwise falls back to [`PolicyDecision::AskUser`].
|
||||
pub fn evaluate_command(command: &str, settings: &AgentSecuritySettings) -> PolicyDecision {
|
||||
let cmd = command.trim();
|
||||
if cmd.is_empty() {
|
||||
return PolicyDecision::AskUser;
|
||||
}
|
||||
let subcommands = split_subcommands(cmd);
|
||||
|
||||
for pat in &settings.blocked_commands {
|
||||
let pat = pat.trim();
|
||||
if pat.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if matches_command(pat, cmd) || subcommands.iter().any(|sub| matches_command(pat, sub)) {
|
||||
return PolicyDecision::Blocked(pat.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
if settings.allowed_commands.is_empty() || subcommands.is_empty() {
|
||||
return PolicyDecision::AskUser;
|
||||
}
|
||||
|
||||
let all_allowed = subcommands.iter().all(|sub| {
|
||||
let s = sub.trim();
|
||||
if s.is_empty() || has_unsafe_shell_syntax(s) {
|
||||
return false;
|
||||
}
|
||||
settings.allowed_commands.iter().any(|entry| matches_allow_entry(entry, s))
|
||||
});
|
||||
|
||||
if all_allowed {
|
||||
return PolicyDecision::Allowed;
|
||||
}
|
||||
|
||||
PolicyDecision::AskUser
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn prefix_and_glob_matching() {
|
||||
assert!(matches_command("git status", "git status"));
|
||||
assert!(matches_command("git status", "git status -s"));
|
||||
assert!(!matches_command("git status", "git statusbar"));
|
||||
assert!(matches_command("git status*", "git status"));
|
||||
assert!(matches_command("git status*", "git status --short"));
|
||||
assert!(matches_command("npm test *", "npm test"));
|
||||
assert!(matches_command("npm test *", "npm test unit"));
|
||||
assert!(matches_command("rm -rf *", "rm -rf /tmp/foo"));
|
||||
assert!(matches_command("rm -rf *", "rm -rf /"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blacklist_takes_precedence_and_catches_chained_commands() {
|
||||
let settings = AgentSecuritySettings::default();
|
||||
assert_eq!(evaluate_command("rm -rf /tmp/build", &settings), PolicyDecision::Blocked("rm -rf *".into()));
|
||||
assert_eq!(evaluate_command("git status && rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
|
||||
assert_eq!(evaluate_command(":(){ :|:& };:", &settings), PolicyDecision::Blocked(":(){ :|:& };:".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_entry_matching_word_boundary() {
|
||||
assert!(matches_allow_entry("git status", "git status"));
|
||||
assert!(matches_allow_entry("git status", "git status -s"));
|
||||
assert!(!matches_allow_entry("git status", "git statusbar"));
|
||||
assert!(!matches_allow_entry("git status", "git statusx"));
|
||||
// Trailing asterisk in allow entry normalizes cleanly to word boundary prefix
|
||||
assert!(matches_allow_entry("git status*", "git status"));
|
||||
assert!(matches_allow_entry("git status*", "git status --short"));
|
||||
assert!(!matches_allow_entry("git status*", "git statusbar"));
|
||||
assert!(matches_allow_entry("ls", "ls"));
|
||||
assert!(matches_allow_entry("ls", "ls -la"));
|
||||
assert!(!matches_allow_entry("ls", "lsof"));
|
||||
assert!(matches_allow_entry("cargo check", "cargo check"));
|
||||
assert!(matches_allow_entry("cargo check", "cargo check --workspace"));
|
||||
assert!(!matches_allow_entry("cargo check", "cargo checkmate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_shell_syntax_detection() {
|
||||
// Command substitutions: $(...) and `...`
|
||||
assert!(has_unsafe_shell_syntax("echo $(whoami)"));
|
||||
assert!(has_unsafe_shell_syntax("echo \"user: $(whoami)\""));
|
||||
assert!(has_unsafe_shell_syntax("echo `whoami`"));
|
||||
assert!(has_unsafe_shell_syntax("echo \"`whoami`\""));
|
||||
|
||||
// Redirections: >, >>, <
|
||||
assert!(has_unsafe_shell_syntax("git status > /tmp/out"));
|
||||
assert!(has_unsafe_shell_syntax("git status >> /tmp/out"));
|
||||
assert!(has_unsafe_shell_syntax("cat < /tmp/in"));
|
||||
assert!(has_unsafe_shell_syntax("cat << 'EOF'"));
|
||||
|
||||
// Subshells: (...)
|
||||
assert!(has_unsafe_shell_syntax("(cd /tmp && ls)"));
|
||||
assert!(has_unsafe_shell_syntax("git status && (exit 1)"));
|
||||
|
||||
// Environment assignments: VAR=val ...
|
||||
assert!(has_unsafe_shell_syntax("FOO=bar git status"));
|
||||
assert!(has_unsafe_shell_syntax("PORT=3000 npm test"));
|
||||
assert!(has_unsafe_shell_syntax("_SECRET=123 cargo test"));
|
||||
assert!(has_unsafe_shell_syntax("A=\"b c\" git status"));
|
||||
|
||||
// Safe constructs with characters inside quotes or command flags
|
||||
assert!(!has_unsafe_shell_syntax("git commit -m \"fix <bug> and (issue)\""));
|
||||
assert!(!has_unsafe_shell_syntax("git commit -m 'fix <bug>'"));
|
||||
assert!(!has_unsafe_shell_syntax("cargo test --test=unit"));
|
||||
assert!(!has_unsafe_shell_syntax("git status -s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_settings_has_no_auto_approval_and_blocks_dangerous_defaults() {
|
||||
let settings = AgentSecuritySettings::default();
|
||||
assert!(settings.allowed_commands.is_empty(), "allowed_commands must start empty");
|
||||
// Safe commands ask user by default
|
||||
assert_eq!(evaluate_command("git status", &settings), PolicyDecision::AskUser);
|
||||
assert_eq!(evaluate_command("ls", &settings), PolicyDecision::AskUser);
|
||||
assert_eq!(evaluate_command("cargo check", &settings), PolicyDecision::AskUser);
|
||||
// Blacklist patterns are still blocked by default
|
||||
assert_eq!(evaluate_command("rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
|
||||
assert_eq!(evaluate_command("rm -rf /tmp/test", &settings), PolicyDecision::Blocked("rm -rf *".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitelist_allows_safe_single_and_chained_commands_only() {
|
||||
let settings = AgentSecuritySettings {
|
||||
allowed_commands: vec!["git status".into(), "git diff".into(), "cargo test".into()],
|
||||
blocked_commands: vec!["rm -rf *".into()],
|
||||
};
|
||||
assert_eq!(evaluate_command("git status -s", &settings), PolicyDecision::Allowed);
|
||||
assert_eq!(evaluate_command("git diff HEAD~1", &settings), PolicyDecision::Allowed);
|
||||
assert_eq!(evaluate_command("git status && cargo test", &settings), PolicyDecision::Allowed);
|
||||
|
||||
// Word boundary: git statusx must not match git status
|
||||
assert_eq!(evaluate_command("git statusx", &settings), PolicyDecision::AskUser);
|
||||
|
||||
// Unsafe syntax (redirection, command substitution, env assignment) falls back to AskUser
|
||||
assert_eq!(evaluate_command("git status > out.txt", &settings), PolicyDecision::AskUser);
|
||||
assert_eq!(evaluate_command("git status $(whoami)", &settings), PolicyDecision::AskUser);
|
||||
assert_eq!(evaluate_command("FOO=bar git status", &settings), PolicyDecision::AskUser);
|
||||
|
||||
// Chaining an unapproved command must still ask the user.
|
||||
assert_eq!(evaluate_command("git status && curl https://example.com", &settings), PolicyDecision::AskUser);
|
||||
assert_eq!(evaluate_command("cargo publish", &settings), PolicyDecision::AskUser);
|
||||
|
||||
// Blacklist still takes precedence over whitelist
|
||||
assert_eq!(evaluate_command("git status && rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_command_from_v1_v2_and_codex_payloads() {
|
||||
let claude = json!({
|
||||
"toolCall": {
|
||||
"title": "git status",
|
||||
"kind": "execute",
|
||||
"rawInput": { "command": "git status --short" }
|
||||
}
|
||||
});
|
||||
assert_eq!(extract_command(&claude).as_deref(), Some("git status --short"));
|
||||
|
||||
let codex = json!({
|
||||
"toolCall": {
|
||||
"title": "Run cargo test",
|
||||
"kind": "execute",
|
||||
"rawInput": { "command": ["/bin/zsh", "-lc", "cargo test --workspace"] }
|
||||
}
|
||||
});
|
||||
assert_eq!(extract_command(&codex).as_deref(), Some("cargo test --workspace"));
|
||||
|
||||
let write = json!({
|
||||
"toolCall": {
|
||||
"title": "Write foo.txt",
|
||||
"kind": "edit",
|
||||
"rawInput": { "file_path": "foo.txt" }
|
||||
}
|
||||
});
|
||||
assert_eq!(extract_command(&write), None);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,11 @@ struct FakeAgent {
|
||||
/// Hold the late mode reply until the prompt's permission request is already waiting.
|
||||
hold_late_mode: bool,
|
||||
empty_config: AtomicBool,
|
||||
/// Blocks `session/new` until discovery times out (see `acp::discover` test timeout).
|
||||
hold_session_new: AtomicBool,
|
||||
/// Sleeps this long at the start of `session/set_config_option`, after noting the time.
|
||||
delay_config: Duration,
|
||||
config_entered: Mutex<Option<std::time::Instant>>,
|
||||
/// Config options `session/new` reports after the model one (per-model efforts, say).
|
||||
extra_options: Mutex<Vec<Value>>,
|
||||
model_options: Mutex<HashMap<String, Vec<Value>>>,
|
||||
@@ -157,6 +162,9 @@ impl Handler for FakeAgent {
|
||||
)
|
||||
}
|
||||
"session/new" => {
|
||||
if self.hold_session_new.load(Ordering::Acquire) {
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
}
|
||||
if self.empty_config.load(Ordering::Acquire) {
|
||||
return Ok(json!({"sessionId": "s1"}));
|
||||
}
|
||||
@@ -190,6 +198,10 @@ impl Handler for FakeAgent {
|
||||
}
|
||||
}
|
||||
"session/set_config_option" | "session/set_mode" => {
|
||||
if method == "session/set_config_option" && !self.delay_config.is_zero() {
|
||||
*self.config_entered.lock() = Some(std::time::Instant::now());
|
||||
std::thread::sleep(self.delay_config);
|
||||
}
|
||||
self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap()));
|
||||
if params["configId"] == "model" && !self.model_options.lock().is_empty() {
|
||||
return Ok(json!({"configOptions": self.options_for(params["value"].as_str().unwrap())}));
|
||||
@@ -2631,6 +2643,92 @@ async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn provider_reload_does_not_start_acp_discovery() {
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
|
||||
mgr.reload_providers();
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert!(agent.calls.lock().is_empty(), "reload must not spawn cursor-agent");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn composer_commands_discover_cursor_effort_and_fast() {
|
||||
let agent = Arc::new(FakeAgent {
|
||||
extra_options: Mutex::new(vec![
|
||||
json!({"id": "effort", "type": "select", "currentValue": "medium",
|
||||
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]}),
|
||||
json!({"id": "fast", "type": "select", "currentValue": "false",
|
||||
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}),
|
||||
]),
|
||||
..Default::default()
|
||||
});
|
||||
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
|
||||
mgr.commands("cursor", None).await.unwrap();
|
||||
let calls = agent.calls.lock();
|
||||
assert!(calls.iter().any(|m| m.as_str() == "session/new"));
|
||||
assert_eq!(calls.iter().filter(|m| m.as_str() == "initialize").count(), 1);
|
||||
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
|
||||
assert_eq!(cursor.config.iter().find(|o| o.id == "fast").unwrap().current.as_deref(), Some("false"));
|
||||
assert!(
|
||||
cursor
|
||||
.thought_levels
|
||||
.values()
|
||||
.next()
|
||||
.is_some_and(|levels| { levels.iter().any(|o| o.category.as_deref() == Some("thought_level")) })
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn config_choice_shows_before_the_agent_answers() {
|
||||
let agent = Arc::new(FakeAgent {
|
||||
extra_options: Mutex::new(vec![json!({
|
||||
"id": "fast", "category": "model_config", "type": "select", "currentValue": "true",
|
||||
"options": [{"value": "true", "name": "On"}, {"value": "false", "name": "Off"}]
|
||||
})]),
|
||||
delay_config: Duration::from_millis(300),
|
||||
..Default::default()
|
||||
});
|
||||
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
|
||||
let shown: Arc<Mutex<Option<std::time::Instant>>> = Arc::default();
|
||||
let mark = shown.clone();
|
||||
mgr.subscribe(Arc::new(move |e| {
|
||||
if let AgentEvent::Task { task } = e
|
||||
&& task.config.iter().any(|o| o.id == "fast" && o.current.as_deref() == Some("false"))
|
||||
&& mark.lock().is_none()
|
||||
{
|
||||
*mark.lock() = Some(std::time::Instant::now());
|
||||
}
|
||||
}));
|
||||
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.set_config(&task.id, "fast", "false".into()).await.unwrap();
|
||||
let shown = (*shown.lock()).expect("the switch moved");
|
||||
let entered = (*agent.config_entered.lock()).expect("the agent received the change");
|
||||
assert!(shown < entered, "the composer updates before the agent answers");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn discovery_times_out_when_session_new_hangs() {
|
||||
let agent = Arc::new(FakeAgent { hold_session_new: AtomicBool::new(true), ..Default::default() });
|
||||
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
|
||||
let started = std::time::Instant::now();
|
||||
mgr.commands("cursor", None).await.unwrap();
|
||||
assert!(started.elapsed() < Duration::from_secs(10), "discovery should time out quickly in tests");
|
||||
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
|
||||
assert!(!cursor.config.iter().any(|o| o.category.as_deref() == Some("model")));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn discovered_effort_levels_fill_models_no_session_has_reported() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -2770,3 +2868,71 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
|
||||
mgr.delete(&task.id, false).await.unwrap();
|
||||
assert!(!dir.join("store").join(format!("{}.before", task.id)).exists());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn security_policy_auto_approves_whitelist_and_rejects_blacklist() {
|
||||
use ide_core::settings::AgentSecuritySettings;
|
||||
|
||||
// 1. Whitelist auto-approves "cargo test" without waiting for manual respond().
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent);
|
||||
mgr.set_security_hook(Arc::new(|| AgentSecuritySettings {
|
||||
allowed_commands: vec!["cargo test".into()],
|
||||
blocked_commands: vec!["rm -rf *".into()],
|
||||
}));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("run tests"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.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.contains("chose yes"))),
|
||||
"{entries:?}"
|
||||
);
|
||||
assert!(
|
||||
entries.iter().any(|e| matches!(
|
||||
e,
|
||||
AgentEntry::Permission { chosen: Some(c), .. } if c.contains("whitelisted")
|
||||
)),
|
||||
"{entries:?}"
|
||||
);
|
||||
|
||||
// 2. Blacklist auto-rejects "cargo test" when matched by blocked_commands.
|
||||
mgr.set_security_hook(Arc::new(|| AgentSecuritySettings {
|
||||
allowed_commands: vec!["cargo test".into()],
|
||||
blocked_commands: vec!["cargo test*".into()],
|
||||
}));
|
||||
let blocked_task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("run blocked"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &blocked_task.id, AgentStatus::Ready).await;
|
||||
let blocked_entries = mgr.entries(&blocked_task.id, None).unwrap();
|
||||
assert!(
|
||||
blocked_entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text.contains("chose no"))),
|
||||
"{blocked_entries:?}"
|
||||
);
|
||||
assert!(
|
||||
blocked_entries.iter().any(|e| matches!(
|
||||
e,
|
||||
AgentEntry::Permission { chosen: Some(c), .. } if c.contains("Blocked by policy")
|
||||
)),
|
||||
"{blocked_entries:?}"
|
||||
);
|
||||
}
|
||||
@@ -125,6 +125,50 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Copies the task's `changes` (against `base`) from the worktree `cwd` into the
|
||||
/// local checkout `root`, uncommitted: the user reviews and commits them with the
|
||||
/// VCS tools. Files the checkout changed itself are left alone and reported, so
|
||||
/// accepting never destroys the user's own edits. Returns accepted and skipped paths.
|
||||
pub(crate) fn accept(
|
||||
root: &Path,
|
||||
cwd: &Path,
|
||||
base: &str,
|
||||
changes: &[AgentChange],
|
||||
) -> IdeResult<crate::AgentAcceptOutcome> {
|
||||
let repo = repo_at(cwd)?;
|
||||
let mut accepted = Vec::with_capacity(changes.len());
|
||||
let mut skipped = Vec::new();
|
||||
for change in changes {
|
||||
let local = root.join(&change.rel_path);
|
||||
let base_bytes = repo.file_bytes_at(&change.path.0, Some(base))?;
|
||||
let current = std::fs::read(&change.path.0).ok();
|
||||
let local_bytes = std::fs::read(&local).ok();
|
||||
if local_bytes == current {
|
||||
accepted.push(change.rel_path.clone());
|
||||
} else if local_bytes == base_bytes {
|
||||
match current {
|
||||
Some(bytes) => {
|
||||
if let Some(parent) = local.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&local, bytes)?;
|
||||
}
|
||||
None => match std::fs::remove_file(&local) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
accepted.push(change.rel_path.clone());
|
||||
} else {
|
||||
skipped.push(crate::AgentAcceptSkipped {
|
||||
path: change.rel_path.clone(),
|
||||
reason: "the checkout has changes of its own to it; accept it by hand".to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(crate::AgentAcceptOutcome { accepted, skipped })
|
||||
}
|
||||
|
||||
/// Size and modification time of a file, `None` once it is gone.
|
||||
pub(crate) type Stamp = Option<(u64, u128)>;
|
||||
|
||||
@@ -406,4 +450,61 @@ mod tests {
|
||||
assert!(remove(&repo, &tmp.path().join("wt"), &elsewhere).is_err());
|
||||
assert!(elsewhere.join("keep.txt").exists());
|
||||
}
|
||||
|
||||
fn accept_repo() -> (tempfile::TempDir, PathBuf, Created) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\ntwo\n").unwrap();
|
||||
std::fs::write(repo.join("gone.txt"), "bye\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
|
||||
(tmp, repo, created)
|
||||
}
|
||||
|
||||
fn listed(cwd: &Path, base: &str) -> Vec<AgentChange> {
|
||||
without_untouched(changes(cwd, base).unwrap(), &BTreeMap::new())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_copies_new_modified_and_deleted_files_into_the_checkout() {
|
||||
let (_tmp, repo, created) = accept_repo();
|
||||
std::fs::write(created.path.join("a.txt"), "one\nTWO\n").unwrap();
|
||||
std::fs::write(created.path.join("new.txt"), "x\n").unwrap();
|
||||
std::fs::remove_file(created.path.join("gone.txt")).unwrap();
|
||||
|
||||
let outcome =
|
||||
accept(&repo, &created.path, &created.base_commit, &listed(&created.path, &created.base_commit)).unwrap();
|
||||
assert!(outcome.skipped.is_empty());
|
||||
assert_eq!(outcome.accepted, ["a.txt", "gone.txt", "new.txt"]);
|
||||
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nTWO\n");
|
||||
assert_eq!(std::fs::read_to_string(repo.join("new.txt")).unwrap(), "x\n");
|
||||
assert!(!repo.join("gone.txt").exists());
|
||||
|
||||
// Accepting again changes nothing: the checkout already matches.
|
||||
let again =
|
||||
accept(&repo, &created.path, &created.base_commit, &listed(&created.path, &created.base_commit)).unwrap();
|
||||
assert!(again.skipped.is_empty());
|
||||
assert_eq!(again.accepted, ["a.txt", "gone.txt", "new.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_leaves_files_the_checkout_changed_itself() {
|
||||
let (_tmp, repo, created) = accept_repo();
|
||||
std::fs::write(created.path.join("a.txt"), "one\nTWO\n").unwrap();
|
||||
std::fs::write(created.path.join("gone.txt"), "bye\nbye\n").unwrap();
|
||||
std::fs::write(repo.join("a.txt"), "one\nMINE\n").unwrap();
|
||||
|
||||
let outcome =
|
||||
accept(&repo, &created.path, &created.base_commit, &listed(&created.path, &created.base_commit)).unwrap();
|
||||
assert_eq!(outcome.accepted, ["gone.txt"]);
|
||||
assert_eq!(outcome.skipped.len(), 1);
|
||||
assert_eq!(outcome.skipped[0].path, "a.txt");
|
||||
assert!(!outcome.skipped[0].reason.is_empty());
|
||||
// The user's own edit survives; the other file comes over.
|
||||
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nMINE\n");
|
||||
assert_eq!(std::fs::read_to_string(repo.join("gone.txt")).unwrap(), "bye\nbye\n");
|
||||
}
|
||||
}
|
||||
@@ -266,6 +266,11 @@ pub(super) fn maven_steps(
|
||||
BuildKind::Rebuild => &["clean", "test-compile"],
|
||||
BuildKind::Artifacts => &["package", "-DskipTests"],
|
||||
};
|
||||
// The IDE's compile builds are not coverage runs. JaCoCo's offline `instrument`
|
||||
// rewrites target/classes in `process-classes`; `jacoco.skip` prevents that in this
|
||||
// build. It does not restore classes instrumented by an earlier build: Rebuild's
|
||||
// `clean` removes those, while an incremental Build can still reuse them.
|
||||
let skip_jacoco = matches!(kind, BuildKind::Build | BuildKind::Rebuild);
|
||||
let tops: Vec<&maven::MavenProject> = m.projects.iter().filter(|p| !p.aggregated).collect();
|
||||
if tops.is_empty() {
|
||||
return Err(IdeError::not_found("no Maven project found"));
|
||||
@@ -278,6 +283,9 @@ pub(super) fn maven_steps(
|
||||
Format::Maven,
|
||||
);
|
||||
s.args.extend(p.options.iter().cloned());
|
||||
if skip_jacoco {
|
||||
s.args.push("-Djacoco.skip=true".into());
|
||||
}
|
||||
s.args.extend(extra.iter().cloned());
|
||||
s.args.extend(goals.iter().map(|g| (*g).to_owned()));
|
||||
s.env = p.env.clone();
|
||||
@@ -1041,6 +1049,22 @@ mod tests {
|
||||
std::fs::write(p, text).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_compile_steps_skip_jacoco_but_artifacts_do_not() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("demo");
|
||||
write(&root, "pom.xml", "<project><artifactId>demo</artifactId></project>");
|
||||
let model = crate::load(&root, &tmp.path().join("cache"), None);
|
||||
for (kind, expected) in [(BuildKind::Build, true), (BuildKind::Rebuild, true), (BuildKind::Artifacts, false)] {
|
||||
let plan = plan(&root, &model, kind, None, None).unwrap();
|
||||
assert_eq!(
|
||||
plan.steps[0].args.iter().any(|arg| arg == "-Djacoco.skip=true"),
|
||||
expected,
|
||||
"{kind:?}: {plan:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plans_per_build_system() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1057,7 +1081,10 @@ mod tests {
|
||||
assert_eq!(p.system, BuildSystem::Maven);
|
||||
assert_eq!(p.title, "demo");
|
||||
assert_eq!(p.steps.len(), 1);
|
||||
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "test-compile"]);
|
||||
assert_eq!(
|
||||
p.steps[0].args,
|
||||
["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "-Djacoco.skip=true", "test-compile"]
|
||||
);
|
||||
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java")), None).unwrap();
|
||||
assert_eq!(
|
||||
p.steps[0].args,
|
||||
@@ -1065,6 +1092,7 @@ mod tests {
|
||||
"-B",
|
||||
"-Dstyle.color=never",
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-Djacoco.skip=true",
|
||||
"-pl",
|
||||
"core",
|
||||
"-am",
|
||||
@@ -1353,6 +1381,7 @@ mod tests {
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-s",
|
||||
"settings.xml",
|
||||
"-Djacoco.skip=true",
|
||||
"-pl",
|
||||
"app",
|
||||
"-am",
|
||||
@@ -1369,6 +1398,7 @@ mod tests {
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-s",
|
||||
"settings.xml",
|
||||
"-Djacoco.skip=true",
|
||||
"-pl",
|
||||
"app",
|
||||
"-am",
|
||||
|
||||
@@ -536,6 +536,7 @@ mod tests {
|
||||
crate::maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-o",
|
||||
"-Peditor",
|
||||
"-Djacoco.skip=true",
|
||||
"test-compile"
|
||||
]
|
||||
);
|
||||
|
||||
@@ -275,6 +275,11 @@ pub enum DebugEvent {
|
||||
},
|
||||
/// The session's capabilities changed after it started: fetch them again.
|
||||
Capabilities,
|
||||
/// The adapter's `invalidated` event: what it shows for `areas` (`all`, `stacks`, `threads`,
|
||||
/// `variables`; empty means all) is stale and has to be fetched again.
|
||||
Invalidated {
|
||||
areas: Vec<String>,
|
||||
},
|
||||
JvmBackend {
|
||||
state: JvmBackendState,
|
||||
},
|
||||
@@ -330,4 +335,12 @@ mod tests {
|
||||
);
|
||||
assert_eq!(serde_json::to_value(DebugEvent::Capabilities).unwrap(), json!({"kind": "capabilities"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidated_event_json_shape() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(DebugEvent::Invalidated { areas: vec!["variables".into()] }).unwrap(),
|
||||
json!({"kind": "invalidated", "areas": ["variables"]})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,8 @@ pub struct Settings {
|
||||
pub agents: AgentSettings,
|
||||
/// Agents › MCP Server (global only).
|
||||
pub mcp_server: McpServerSettings,
|
||||
/// Tools › Agents: Command Execution Policy.
|
||||
pub agent_security: AgentSecuritySettings,
|
||||
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
|
||||
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
|
||||
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
|
||||
@@ -411,6 +413,7 @@ impl Default for Settings {
|
||||
cmake: CMakeSettings::default(),
|
||||
agents: AgentSettings::default(),
|
||||
mcp_server: McpServerSettings::default(),
|
||||
agent_security: AgentSecuritySettings::default(),
|
||||
plugins: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,3 +412,30 @@ impl Default for ExternalTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tools › Agents: Command Execution Policy (whitelist and blacklist patterns).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AgentSecuritySettings {
|
||||
/// Commands or patterns (`git status*`, `pwd`) automatically approved without prompting.
|
||||
pub allowed_commands: Vec<String>,
|
||||
/// Commands or patterns (`rm -rf *`, `mkfs*`) automatically rejected.
|
||||
pub blocked_commands: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for AgentSecuritySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allowed_commands: Vec::new(),
|
||||
blocked_commands: vec![
|
||||
"rm -rf *".into(),
|
||||
"rm -rf /".into(),
|
||||
"mkfs*".into(),
|
||||
"dd *".into(),
|
||||
":(){ :|:& };:".into(),
|
||||
"shutdown*".into(),
|
||||
"reboot*".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,13 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"stdio",
|
||||
"Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers",
|
||||
),
|
||||
// Inside the language server: reached through `debugServer`, never started.
|
||||
"kotlin-lsp" => cfg(
|
||||
"kotlin-lsp",
|
||||
&[],
|
||||
"tcp",
|
||||
"Install JetBrains Kotlin LSP in Settings › Languages & Frameworks › Language Servers",
|
||||
),
|
||||
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
|
||||
"debugpy" => cfg(
|
||||
if cfg!(windows) { "python" } else { "python3" },
|
||||
|
||||
@@ -29,7 +29,7 @@ pub(crate) use scan::watch_for_attached_jvm;
|
||||
pub(crate) use scan::{JvmFinder, attach_target, find_attached_jvm, find_local_jvm, local_jdwp_port};
|
||||
use snapshot::{AttachProbe, JdkProbe, take_snapshot};
|
||||
|
||||
pub const JVM_MEMORY_ADAPTERS: &[&str] = &["java-debug", crate::kotlin::ADAPTER];
|
||||
pub const JVM_MEMORY_ADAPTERS: &[&str] = &["java-debug", crate::kotlin::ADAPTER, crate::kotlin_lsp::ADAPTER];
|
||||
pub const HISTOGRAM_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -9,11 +9,15 @@ use std::{
|
||||
|
||||
pub const ADAPTER: &str = "kotlin-debug-adapter";
|
||||
|
||||
/// The adapter attaches to a JVM the IDE starts, through the JDWP link. It resumes the VM while
|
||||
/// constructing its debuggee, before its breakpoint hooks exist, so the link holds resumes back
|
||||
/// until the attach is done.
|
||||
pub(crate) fn link_options() -> ide_jdwp::LinkOptions {
|
||||
ide_jdwp::LinkOptions { hold_resumes: true }
|
||||
/// fwcd's adapter or kotlin-lsp's: both debug a JVM the IDE starts, through the JDWP link.
|
||||
pub fn is_kotlin(adapter: &str) -> bool {
|
||||
adapter == ADAPTER || adapter == crate::kotlin_lsp::ADAPTER
|
||||
}
|
||||
|
||||
/// fwcd's adapter resumes the VM while constructing its debuggee, before its breakpoint hooks
|
||||
/// exist, so only its link holds resumes back until the attach is done.
|
||||
pub(crate) fn link_options(adapter: &str) -> ide_jdwp::LinkOptions {
|
||||
ide_jdwp::LinkOptions { hold_resumes: adapter == ADAPTER }
|
||||
}
|
||||
|
||||
pub struct SourceMirror {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! JetBrains kotlin-lsp's debugger: a DAP server inside the language server, reached through
|
||||
//! the `debugServer` port it opens once the project is imported.
|
||||
//!
|
||||
//! Known limits, with no guard here: an evaluate that calls a function with a breakpoint, right
|
||||
//! after a step over a suspension point, loses every later stop at that breakpoint; while
|
||||
//! stepping, breakpoints hit by other coroutines are skipped.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
pub const ADAPTER: &str = "kotlin-lsp";
|
||||
/// The `adapterID` its debug server expects in `initialize`.
|
||||
pub const DAP_ADAPTER_ID: &str = "intellij_jvm";
|
||||
|
||||
/// The path of a `file:` URI, as kotlin-lsp reports some sources (percent-encoded); `None` for
|
||||
/// anything else, including a URI naming another host.
|
||||
pub(crate) fn source_path(path: &str) -> Option<PathBuf> {
|
||||
let rest = path.strip_prefix("file:")?;
|
||||
let path = match rest.strip_prefix("//") {
|
||||
Some(authority) => {
|
||||
let slash = authority.find('/')?;
|
||||
if !matches!(&authority[..slash], "" | "localhost") {
|
||||
return None;
|
||||
}
|
||||
&authority[slash..]
|
||||
}
|
||||
None => rest,
|
||||
};
|
||||
let decoded = percent_encoding::percent_decode_str(path).decode_utf8().ok()?;
|
||||
// `/C:/p/App.kt` on Windows.
|
||||
let decoded = match decoded.strip_prefix('/') {
|
||||
Some(drive) if cfg!(windows) && drive.as_bytes().get(1) == Some(&b':') => drive,
|
||||
_ => &decoded,
|
||||
};
|
||||
Some(PathBuf::from(decoded))
|
||||
}
|
||||
|
||||
pub(crate) fn frame_name(name: &str) -> &str {
|
||||
name.strip_prefix("at ").unwrap_or(name)
|
||||
}
|
||||
|
||||
/// What kotlin-lsp's DAP server does unlike other adapters.
|
||||
///
|
||||
/// It never answers a request that carries a thread id from an earlier stop (K1 G4), so such a
|
||||
/// request would wait out `REQUEST_TIMEOUT`: [`Self::check_thread`] refuses it first. Only thread
|
||||
/// ids were measured; a stale `frameId` or `variablesReference` may wait the same way. A step
|
||||
/// ends with `reason: "breakpoint"`; a real breakpoint hit during a step is then reported as a
|
||||
/// step too, since the stop carries neither `hitBreakpointIds` nor a description.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Quirks {
|
||||
step_pending: AtomicBool,
|
||||
/// The stop (a `Suspension` ticket) and the thread ids seen in it.
|
||||
threads: Mutex<(u64, HashSet<i64>)>,
|
||||
}
|
||||
|
||||
impl Quirks {
|
||||
/// Before `next`, `stepIn`, `stepOut` or a forced step.
|
||||
pub fn step_sent(&self) {
|
||||
self.step_pending.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
/// Before `continue` or run to cursor, or after a step request failed.
|
||||
pub fn resume_sent(&self) {
|
||||
self.step_pending.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
/// The reason to report for a `stopped` event.
|
||||
pub fn stop_reason(&self, reason: String) -> String {
|
||||
let stepped = self.step_pending.swap(false, Ordering::AcqRel);
|
||||
if stepped && reason == "breakpoint" { "step".into() } else { reason }
|
||||
}
|
||||
|
||||
/// Thread ids seen in stop `stop`: a `stopped` event's, or a `threads` reply to a request sent
|
||||
/// during that stop. A reply from an older stop changes nothing.
|
||||
pub fn saw_threads(&self, stop: u64, ids: impl IntoIterator<Item = i64>) {
|
||||
let mut threads = self.threads.lock();
|
||||
if stop > threads.0 {
|
||||
*threads = (stop, ids.into_iter().collect());
|
||||
} else if stop == threads.0 {
|
||||
threads.1.extend(ids);
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses a thread id that is not one of current stop `stop`'s, once some are known.
|
||||
pub fn check_thread(&self, stop: u64, suspended: bool, id: i64) -> IdeResult<()> {
|
||||
let threads = self.threads.lock();
|
||||
if suspended && threads.0 == stop && !threads.1.is_empty() && !threads.1.contains(&id) {
|
||||
return Err(IdeError::invalid(format!("thread {id} belongs to an earlier stop")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn file_uris_become_paths() {
|
||||
assert_eq!(source_path("file:///p/a%20b/App.kt").as_deref(), Some(Path::new("/p/a b/App.kt")));
|
||||
assert_eq!(source_path("file://localhost/p/x.kt").as_deref(), Some(Path::new("/p/x.kt")));
|
||||
assert_eq!(source_path("/p/x.kt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_names_lose_the_at_prefix() {
|
||||
assert_eq!(frame_name("at com.x.AppKt.main(App.kt:13)"), "com.x.AppKt.main(App.kt:13)");
|
||||
assert_eq!(frame_name("main"), "main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_pending_step_turns_a_breakpoint_into_a_step() {
|
||||
let quirks = Quirks::default();
|
||||
quirks.step_sent();
|
||||
assert_eq!(quirks.stop_reason("breakpoint".into()), "step");
|
||||
assert_eq!(quirks.stop_reason("breakpoint".into()), "breakpoint");
|
||||
quirks.step_sent();
|
||||
quirks.resume_sent();
|
||||
assert_eq!(quirks.stop_reason("breakpoint".into()), "breakpoint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threads_are_checked_against_the_current_stop() {
|
||||
let quirks = Quirks::default();
|
||||
quirks.saw_threads(3, [18]);
|
||||
assert!(quirks.check_thread(3, true, 18).is_ok());
|
||||
assert!(quirks.check_thread(3, true, 6).is_err());
|
||||
assert!(quirks.check_thread(4, true, 6).is_ok(), "nothing known about stop 4 yet");
|
||||
assert!(quirks.check_thread(3, false, 6).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_older_threads_reply_does_not_change_a_newer_stop() {
|
||||
let quirks = Quirks::default();
|
||||
quirks.saw_threads(4, [2]);
|
||||
quirks.saw_threads(3, [1]);
|
||||
assert!(quirks.check_thread(4, true, 1).is_err());
|
||||
quirks.saw_threads(4, [7]);
|
||||
assert!(quirks.check_thread(4, true, 2).is_ok() && quirks.check_thread(4, true, 7).is_ok());
|
||||
quirks.saw_threads(5, [9]);
|
||||
assert!(quirks.check_thread(5, true, 2).is_err());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod connection;
|
||||
pub mod jdwp;
|
||||
mod jvm_memory;
|
||||
pub mod kotlin;
|
||||
pub mod kotlin_lsp;
|
||||
mod session;
|
||||
|
||||
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
|
||||
@@ -610,6 +611,16 @@ fn unverified(enabled: impl Iterator<Item = bool>) -> Vec<BreakpointState> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A step request's outcome; a failed one leaves no step pending, so the next stop keeps its reason.
|
||||
fn step_settled(session: &Session, sent: Result<Value, DapError>) -> Result<Value, DapError> {
|
||||
if sent.is_err()
|
||||
&& let Some(quirks) = session.kotlin_lsp_quirks()
|
||||
{
|
||||
quirks.resume_sent();
|
||||
}
|
||||
sent
|
||||
}
|
||||
|
||||
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (on Windows
|
||||
/// `__debug_bin.exe` plus `__debug_bin.exe<digits>` in DAP mode) and removes it on a
|
||||
/// graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
|
||||
@@ -981,6 +992,7 @@ impl DebugManager {
|
||||
arguments["projectRoot"] = json!(mirror.root());
|
||||
*s.kotlin_mirror.lock() = Some(mirror);
|
||||
}
|
||||
let is_kotlin_lsp = s.adapter == kotlin_lsp::ADAPTER;
|
||||
let caps = s
|
||||
.conn
|
||||
.request_timeout(
|
||||
@@ -988,7 +1000,7 @@ impl DebugManager {
|
||||
json!({
|
||||
"clientID": "openidea",
|
||||
"clientName": "OpenIDEA",
|
||||
"adapterID": s.adapter,
|
||||
"adapterID": if is_kotlin_lsp { kotlin_lsp::DAP_ADAPTER_ID } else { s.adapter.as_str() },
|
||||
"locale": "en",
|
||||
"linesStartAt1": true,
|
||||
"columnsStartAt1": true,
|
||||
@@ -998,7 +1010,8 @@ impl DebugManager {
|
||||
"supportsRunInTerminalRequest": true,
|
||||
"supportsStartDebuggingRequest": true,
|
||||
"supportsProgressReporting": true,
|
||||
"supportsInvalidatedEvent": false,
|
||||
// kotlin-lsp sends `invalidated` either way.
|
||||
"supportsInvalidatedEvent": is_kotlin_lsp,
|
||||
"supportsMemoryReferences": false,
|
||||
"supportsArgsCanBeInterpretedByShell": false,
|
||||
}),
|
||||
@@ -1008,7 +1021,7 @@ impl DebugManager {
|
||||
*s.caps.lock() = if caps.is_object() { caps } else { json!({}) };
|
||||
|
||||
// The Kotlin adapters ignore the resolved classpath and project runtime on launch.
|
||||
let request = if s.adapter == kotlin::ADAPTER && s.request == "launch" {
|
||||
let request = if kotlin::is_kotlin(&s.adapter) && s.request == "launch" {
|
||||
session::start_kotlin_debuggee(s, &mut arguments).await?;
|
||||
"attach"
|
||||
} else {
|
||||
@@ -1032,7 +1045,7 @@ impl DebugManager {
|
||||
jvm_memory::find_attached_jvm(s, port, finder).await;
|
||||
}
|
||||
}
|
||||
if s.adapter == kotlin::ADAPTER {
|
||||
if kotlin::is_kotlin(&s.adapter) {
|
||||
let host = arguments
|
||||
.get("hostName")
|
||||
.and_then(Value::as_str)
|
||||
@@ -1044,7 +1057,7 @@ impl DebugManager {
|
||||
.and_then(|p| u16::try_from(p).ok())
|
||||
.filter(|p| *p != 0)
|
||||
.ok_or_else(|| IdeError::invalid("Invalid Kotlin JDWP port"))?;
|
||||
let (link, port) = ide_jdwp::JdwpLink::start(host, port, kotlin::link_options()).await?;
|
||||
let (link, port) = ide_jdwp::JdwpLink::start(host, port, kotlin::link_options(&s.adapter)).await?;
|
||||
*s.jvm_inspector.lock() = Some(Arc::new(ide_jdwp::LinkInspector::new(link.clone())));
|
||||
jvm_memory::watch_link(s, &link);
|
||||
*s.jdwp_link.lock() = Some(link);
|
||||
@@ -1381,6 +1394,8 @@ impl DebugManager {
|
||||
|
||||
pub async fn threads(&self, s: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
|
||||
let session = self.session(s)?;
|
||||
// Before sending: a stop that overtakes the reply must not take the reply's ids as its own.
|
||||
let stop = session.suspended.ticket();
|
||||
let body = session.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?;
|
||||
let threads: Vec<ThreadInfo> = body
|
||||
.get("threads")
|
||||
@@ -1394,11 +1409,15 @@ impl DebugManager {
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(quirks) = session.kotlin_lsp_quirks() {
|
||||
quirks.saw_threads(stop, threads.iter().map(|t| t.id));
|
||||
}
|
||||
Ok(threads)
|
||||
}
|
||||
|
||||
pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<Vec<StackFrame>> {
|
||||
let session = self.session(s)?;
|
||||
session.check_thread(thread_id)?;
|
||||
let body = session
|
||||
.conn
|
||||
.request_timeout(
|
||||
@@ -1407,11 +1426,15 @@ impl DebugManager {
|
||||
REQUEST_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
let kotlin_lsp = session.kotlin_lsp_quirks().is_some();
|
||||
let frame = |f: &Value| {
|
||||
let mut frame = frame_from(f);
|
||||
if let Some(client) = frame.path.as_deref().and_then(|p| session.client_path(&p.to_string_lossy())) {
|
||||
frame.path = Some(client.into());
|
||||
}
|
||||
if kotlin_lsp {
|
||||
frame.name = kotlin_lsp::frame_name(&frame.name).to_owned();
|
||||
}
|
||||
frame
|
||||
};
|
||||
Ok(body.get("stackFrames").and_then(Value::as_array).map(|f| f.iter().map(frame).collect()).unwrap_or_default())
|
||||
@@ -1523,6 +1546,10 @@ impl DebugManager {
|
||||
|
||||
pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
session.check_thread(thread_id)?;
|
||||
if let Some(quirks) = session.kotlin_lsp_quirks() {
|
||||
quirks.resume_sent();
|
||||
}
|
||||
let ticket = session.suspended.ticket();
|
||||
let body = session.conn.request_timeout("continue", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
|
||||
// DAP: a response without the flag continued every thread.
|
||||
@@ -1567,8 +1594,13 @@ impl DebugManager {
|
||||
StepKind::Out => "stepOut",
|
||||
};
|
||||
let session = self.session(s)?;
|
||||
session.check_thread(thread_id)?;
|
||||
if let Some(quirks) = session.kotlin_lsp_quirks() {
|
||||
quirks.step_sent();
|
||||
}
|
||||
let ticket = session.suspended.ticket();
|
||||
session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
|
||||
let sent = session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await;
|
||||
step_settled(&session, sent)?;
|
||||
// A step runs its thread; other threads stopped by their own events stay stopped.
|
||||
session.suspended.resumed(ticket, thread_id, false);
|
||||
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
|
||||
@@ -1587,6 +1619,8 @@ impl DebugManager {
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
// Before touching breakpoints: a refused `continue` would leave them replaced.
|
||||
session.check_thread(thread_id)?;
|
||||
if force {
|
||||
self.suspend_breakpoints(&session, Some(&path)).await?;
|
||||
}
|
||||
@@ -1661,6 +1695,7 @@ impl DebugManager {
|
||||
/// Force Step Over: `next` with every breakpoint suspended until the step ends.
|
||||
pub async fn force_step_over(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
session.check_thread(thread_id)?;
|
||||
self.suspend_breakpoints(&session, None).await?;
|
||||
self.step(s, thread_id, StepKind::Over).await
|
||||
}
|
||||
@@ -1668,11 +1703,16 @@ impl DebugManager {
|
||||
/// Step into a specific call of the current line (Smart Step Into).
|
||||
pub async fn step_in_target(&self, s: DebugSessionId, thread_id: i64, target_id: i64) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
session.check_thread(thread_id)?;
|
||||
if let Some(quirks) = session.kotlin_lsp_quirks() {
|
||||
quirks.step_sent();
|
||||
}
|
||||
let ticket = session.suspended.ticket();
|
||||
session
|
||||
let sent = session
|
||||
.conn
|
||||
.request_timeout("stepIn", json!({ "threadId": thread_id, "targetId": target_id }), REQUEST_TIMEOUT)
|
||||
.await?;
|
||||
.await;
|
||||
step_settled(&session, sent)?;
|
||||
session.suspended.resumed(ticket, thread_id, false);
|
||||
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
|
||||
Ok(())
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use crate::adapter::AdapterSpec;
|
||||
use crate::connection::{Connection, DapError, Incoming};
|
||||
use crate::kotlin_lsp;
|
||||
use crate::{Breakpoint, BreakpointState, DataBreakpoint, DebugManager, FunctionBreakpoint, SessionOwner};
|
||||
use ide_core::event::DebugEvent;
|
||||
#[cfg(unix)]
|
||||
@@ -103,6 +104,8 @@ pub(crate) struct Session {
|
||||
pub jvm_located: watch::Sender<bool>,
|
||||
pub kotlin_mirror: Mutex<Option<crate::kotlin::SourceMirror>>,
|
||||
pub jvm_memory: tokio::sync::Mutex<crate::JvmMemoryState>,
|
||||
/// Used only by a kotlin-lsp session: see [`Session::kotlin_lsp_quirks`].
|
||||
pub(crate) quirks: kotlin_lsp::Quirks,
|
||||
}
|
||||
|
||||
/// Which threads of the program are suspended: each from its `stopped` event until a
|
||||
@@ -252,6 +255,19 @@ impl Session {
|
||||
jvm_located: watch::channel(false).0,
|
||||
kotlin_mirror: Mutex::new(None),
|
||||
jvm_memory: tokio::sync::Mutex::default(),
|
||||
quirks: kotlin_lsp::Quirks::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kotlin_lsp_quirks(&self) -> Option<&kotlin_lsp::Quirks> {
|
||||
(self.adapter == kotlin_lsp::ADAPTER).then_some(&self.quirks)
|
||||
}
|
||||
|
||||
/// Call before sending a request for thread `id`.
|
||||
pub fn check_thread(&self, id: i64) -> ide_core::IdeResult<()> {
|
||||
match self.kotlin_lsp_quirks() {
|
||||
Some(quirks) => quirks.check_thread(self.suspended.ticket(), self.suspended.get(), id),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +299,11 @@ impl Session {
|
||||
|
||||
/// A path from the adapter as the IDE knows it (undoes [`Session::adapter_path`]).
|
||||
pub fn client_path(&self, path: &str) -> Option<PathBuf> {
|
||||
if self.adapter == kotlin_lsp::ADAPTER
|
||||
&& let Some(path) = kotlin_lsp::source_path(path)
|
||||
{
|
||||
return Some(path);
|
||||
}
|
||||
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
|
||||
return Some(real);
|
||||
}
|
||||
@@ -697,7 +718,11 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
|
||||
"stopped" => {
|
||||
let all = body.get("allThreadsStopped").and_then(Value::as_bool).unwrap_or(false);
|
||||
s.suspended.stopped(int("threadId"), all);
|
||||
let reason = str_("reason").unwrap_or_else(|| "pause".into());
|
||||
let mut reason = str_("reason").unwrap_or_else(|| "pause".into());
|
||||
if let Some(quirks) = s.kotlin_lsp_quirks() {
|
||||
quirks.saw_threads(s.suspended.ticket(), int("threadId"));
|
||||
reason = quirks.stop_reason(reason);
|
||||
}
|
||||
mgr.restore_breakpoints(s).await;
|
||||
s.emit(DebugEvent::Stopped {
|
||||
thread_id: int("threadId").unwrap_or(0),
|
||||
@@ -799,6 +824,14 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
|
||||
change_type: crate::hot_code_replace_type(body.get("changeType")),
|
||||
message: str_("message").unwrap_or_default(),
|
||||
}),
|
||||
// Sent only by adapters told `supportsInvalidatedEvent` (kotlin-lsp).
|
||||
"invalidated" => s.emit(DebugEvent::Invalidated {
|
||||
areas: body
|
||||
.get("areas")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
_ => tracing::trace!("dap: ignored event {event}"),
|
||||
}
|
||||
}
|
||||
|
||||
+286
-2
@@ -126,8 +126,22 @@ struct Script {
|
||||
continued_after_stop: bool,
|
||||
/// Close the connection when a `pause` request arrives (the adapter dies).
|
||||
hang_up_on_pause: bool,
|
||||
/// Close the connection at the first request after the first `stopped` event.
|
||||
hang_up_after_stop: bool,
|
||||
/// Answer `continue` with this body and keep the program running, instead of letting it exit.
|
||||
continue_reply: Option<Value>,
|
||||
/// Answer `continue` and stop at a breakpoint again, instead of letting the program exit.
|
||||
stop_after_continue: bool,
|
||||
/// The `reason` of the stop a `next` ends with (`step` when unset; kotlin-lsp says `breakpoint`).
|
||||
step_reason: Option<&'static str>,
|
||||
/// Refuse `next`, then stop at a breakpoint (one the program hit meanwhile).
|
||||
refuse_next: bool,
|
||||
/// Send `invalidated { areas: ["variables"] }` right after the first `stopped` event.
|
||||
invalidated_after_stop: bool,
|
||||
/// On `threads`: stop again on thread 2 first, then answer with thread 1 of the earlier stop.
|
||||
stop_during_threads: bool,
|
||||
/// Name of the first stack frame (`main` when unset).
|
||||
frame_name: Option<&'static str>,
|
||||
/// Answer `initialize` only after this long (a slow adapter keeps the session connecting).
|
||||
delay_initialize: Option<Duration>,
|
||||
initialized_delay: Duration,
|
||||
@@ -156,7 +170,14 @@ impl Default for Script {
|
||||
hold_set_breakpoints: 0,
|
||||
continued_after_stop: false,
|
||||
hang_up_on_pause: false,
|
||||
hang_up_after_stop: false,
|
||||
continue_reply: None,
|
||||
stop_after_continue: false,
|
||||
step_reason: None,
|
||||
refuse_next: false,
|
||||
invalidated_after_stop: false,
|
||||
stop_during_threads: false,
|
||||
frame_name: None,
|
||||
delay_initialize: None,
|
||||
initialized_delay: Duration::ZERO,
|
||||
launch_delay: Duration::ZERO,
|
||||
@@ -202,6 +223,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
let mut seq = 1000;
|
||||
let mut pending_launch: Option<i64> = None;
|
||||
let mut held: Vec<Value> = Vec::new();
|
||||
let mut stopped = false;
|
||||
let mut running = false;
|
||||
macro_rules! send {
|
||||
($v:expr) => {{
|
||||
@@ -222,6 +244,9 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
if msg["type"] == "response" {
|
||||
continue;
|
||||
}
|
||||
if script.hang_up_after_stop && stopped {
|
||||
break;
|
||||
}
|
||||
match msg["command"].as_str().unwrap_or_default() {
|
||||
"initialize" => {
|
||||
if let Some(delay) = script.delay_initialize {
|
||||
@@ -320,9 +345,18 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
ok!(msg, Value::Null);
|
||||
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "restart", "threadId": 1}}));
|
||||
}
|
||||
"next" if script.refuse_next => {
|
||||
send!(
|
||||
json!({"type": "response", "request_seq": msg["seq"], "command": "next", "success": false, "message": "refused"})
|
||||
);
|
||||
// Lets the client handle the refusal before the stop.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1}}));
|
||||
}
|
||||
"next" => {
|
||||
ok!(msg, Value::Null);
|
||||
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "step", "threadId": 1}}));
|
||||
let reason = script.step_reason.unwrap_or("step");
|
||||
send!(json!({"type": "event", "event": "stopped", "body": {"reason": reason, "threadId": 1}}));
|
||||
}
|
||||
"setVariable" => {
|
||||
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
|
||||
@@ -358,6 +392,12 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
send!(
|
||||
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
|
||||
);
|
||||
stopped = true;
|
||||
if script.invalidated_after_stop {
|
||||
send!(
|
||||
json!({"type": "event", "event": "invalidated", "body": {"areas": ["variables"], "threadId": 1}})
|
||||
);
|
||||
}
|
||||
if script.continued_after_stop {
|
||||
send!(json!({"type": "event", "event": "continued", "body": {"threadId": 1}}));
|
||||
}
|
||||
@@ -365,11 +405,19 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9, "source": {"path": script.source_path}}}})
|
||||
);
|
||||
}
|
||||
"threads" if script.stop_during_threads => {
|
||||
send!(
|
||||
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 2, "allThreadsStopped": true}})
|
||||
);
|
||||
// Lets the client handle the stop before it reads the reply.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
ok!(msg, json!({"threads": [{"id": 1, "name": "\"main\"@1"}]}));
|
||||
}
|
||||
"threads" => ok!(msg, json!({"threads": [{"id": 1, "name": "MainThread"}]})),
|
||||
"stackTrace" => ok!(
|
||||
msg,
|
||||
json!({"stackFrames": [
|
||||
{"id": 10, "name": "main", "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
|
||||
{"id": 10, "name": script.frame_name.unwrap_or("main"), "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
|
||||
{"id": 11, "name": "lib", "line": 1, "column": 1, "source": {"path": "/usr/lib/x.py", "presentationHint": "deemphasize"}},
|
||||
]})
|
||||
),
|
||||
@@ -393,6 +441,12 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
]})
|
||||
),
|
||||
"continue" if script.continue_reply.is_some() => ok!(msg, script.continue_reply.clone().unwrap()),
|
||||
"continue" if script.stop_after_continue => {
|
||||
ok!(msg, json!({"allThreadsContinued": true}));
|
||||
send!(
|
||||
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
|
||||
);
|
||||
}
|
||||
"continue" => {
|
||||
ok!(msg, json!({"allThreadsContinued": true}));
|
||||
send!(json!({"type": "event", "event": "exited", "body": {"exitCode": 3}}));
|
||||
@@ -2401,6 +2455,13 @@ fn kotlin_attach(project: &std::path::Path, host: &str, port: u16) -> LaunchConf
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_options_hold_only_for_fwcd() {
|
||||
assert!(kotlin::link_options(kotlin::ADAPTER).hold_resumes);
|
||||
assert!(!kotlin::link_options(kotlin_lsp::ADAPTER).hold_resumes);
|
||||
assert!(!kotlin::link_options("x").hold_resumes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kotlin_session_installs_link_inspector() {
|
||||
let h = harness(Script::default());
|
||||
@@ -2472,6 +2533,229 @@ async fn closing_the_session_drops_the_link() {
|
||||
assert!(session.jvm_inspector.lock().is_none());
|
||||
}
|
||||
|
||||
/// kotlin-lsp's debugger, reached through the language server's `debugServer` port (the fake ignores it).
|
||||
fn kotlin_lsp_attach(project: &std::path::Path) -> LaunchConfig {
|
||||
LaunchConfig {
|
||||
adapter: kotlin_lsp::ADAPTER.into(),
|
||||
request: "attach".into(),
|
||||
cwd: Some(project.to_owned().into()),
|
||||
extra_json: Some(
|
||||
json!({"debugServer": 4711, "hostName": "127.0.0.1", "port": 5005, "projectRoot": project}).to_string(),
|
||||
),
|
||||
..config()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kotlin_lsp_initializes_as_intellij_jvm() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
for (config, adapter_id, invalidated) in [
|
||||
(kotlin_lsp_attach(project.path()), "intellij_jvm", true),
|
||||
(kotlin_attach(project.path(), "127.0.0.1", 5005), "kotlin-debug-adapter", false),
|
||||
] {
|
||||
let h = harness(Script::default());
|
||||
let id = h.start(config).await.unwrap();
|
||||
let init = &h.request("initialize")["arguments"];
|
||||
assert_eq!(init["adapterID"], adapter_id);
|
||||
assert_eq!(init["supportsInvalidatedEvent"], invalidated, "{adapter_id}");
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kotlin_lsp_gets_real_paths_and_its_link_holds_nothing() {
|
||||
let h = harness(Script::default());
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let path = project.path().join("Main.kt");
|
||||
std::fs::write(&path, "package org.example\nfun main() {}\n").unwrap();
|
||||
h.manager.set_breakpoints(path.clone(), vec![bp(1)]).await.unwrap();
|
||||
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
|
||||
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], json!(path));
|
||||
let sent = &h.request("attach")["arguments"];
|
||||
assert_eq!(sent["projectRoot"], json!(project.path()));
|
||||
assert!(sent.get("debugServer").is_none());
|
||||
assert_eq!(sent["hostName"], "127.0.0.1");
|
||||
let link_port = sent["port"].as_u64().unwrap();
|
||||
assert!(link_port != 0 && link_port != 5005);
|
||||
let session = h.manager.session(id).unwrap();
|
||||
assert!(session.kotlin_mirror.lock().is_none());
|
||||
assert!(session.jdwp_link.lock().is_some() && session.jvm_inspector.lock().is_some());
|
||||
assert_eq!(session.attach_target.get(), Some(&("127.0.0.1".to_owned(), 5005)));
|
||||
assert!(!kotlin::link_options(&session.adapter).hold_resumes);
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kotlin_lsp_waits_for_the_configuration_done_reply() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
// fwcd never answers it (`kotlin_configures_without_configuration_done_response`).
|
||||
let h = harness(Script { ignore_configuration_done: true, ..Default::default() });
|
||||
let fwcd = h.start(kotlin_attach(project.path(), "127.0.0.1", 5005));
|
||||
let id = tokio::time::timeout(Duration::from_secs(2), fwcd).await.unwrap().unwrap();
|
||||
h.manager.stop(id).await.unwrap();
|
||||
|
||||
tokio::time::pause();
|
||||
let h = harness(Script { ignore_configuration_done: true, ..Default::default() });
|
||||
let started = tokio::time::Instant::now();
|
||||
let err = h.start(kotlin_lsp_attach(project.path())).await.unwrap_err();
|
||||
assert!(started.elapsed() >= REQUEST_TIMEOUT, "{:?}", started.elapsed());
|
||||
assert!(err.message.contains("configurationDone"), "{err:?}");
|
||||
assert!(h.manager.sessions(None).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kotlin_lsp_sessions_have_the_memory_tab() {
|
||||
let h = harness(Script::default());
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
|
||||
assert!(h.manager.capabilities(id).unwrap().jvm_memory);
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn losing_the_kotlin_lsp_connection_ends_the_session() {
|
||||
let h = harness(Script { hang_up_after_stop: true, ..Script::default() });
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
let session = h.manager.session(id).unwrap();
|
||||
assert!(session.jdwp_link.lock().is_some());
|
||||
let _ = session.conn.request_timeout("threads", Value::Null, Duration::from_secs(2)).await;
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await;
|
||||
assert!(h.manager.sessions(None).is_empty());
|
||||
assert!(session.jdwp_link.lock().is_none());
|
||||
assert!(session.jvm_inspector.lock().is_none());
|
||||
}
|
||||
|
||||
/// The reasons of the first `count` stops, once that many arrived.
|
||||
async fn stop_reasons(h: &Harness, count: usize) -> Vec<String> {
|
||||
for _ in 0..200 {
|
||||
let reasons: Vec<String> = h
|
||||
.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Debug { event: DebugEvent::Stopped { reason, .. }, .. } => Some(reason.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if reasons.len() >= count {
|
||||
return reasons;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("{count} stops not received; got {:?}", h.events.lock());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_step_stop_is_reported_as_a_step() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
for (config, reason) in [(kotlin_lsp_attach(project.path()), "step"), (config(), "breakpoint")] {
|
||||
let h = harness(Script { step_reason: Some("breakpoint"), ..Script::default() });
|
||||
let id = h.start(config).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
h.manager.step(id, 1, StepKind::Over).await.unwrap();
|
||||
assert_eq!(stop_reasons(&h, 2).await, ["breakpoint", reason]);
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_breakpoint_after_continue_stays_a_breakpoint() {
|
||||
let h = harness(Script { step_reason: Some("breakpoint"), stop_after_continue: true, ..Script::default() });
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
h.manager.step(id, 1, StepKind::Over).await.unwrap();
|
||||
stop_reasons(&h, 2).await;
|
||||
// The fake ends `stepIn` with no stop: the `continue` after it decides the next one.
|
||||
h.manager.step(id, 1, StepKind::Into).await.unwrap();
|
||||
h.manager.resume(id, 1).await.unwrap();
|
||||
assert_eq!(stop_reasons(&h, 3).await, ["breakpoint", "step", "breakpoint"]);
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_breakpoint_after_a_refused_step_stays_a_breakpoint() {
|
||||
let h = harness(Script { refuse_next: true, ..Script::default() });
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
assert!(h.manager.step(id, 1, StepKind::Over).await.is_err());
|
||||
assert_eq!(stop_reasons(&h, 2).await, ["breakpoint", "breakpoint"]);
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_thread_from_an_earlier_stop_is_refused() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
for (config, kotlin_lsp) in [(kotlin_lsp_attach(project.path()), true), (config(), false)] {
|
||||
let h = harness(Script::default());
|
||||
let id = h.start(config).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
assert_eq!(h.manager.threads(id).await.unwrap().len(), 1);
|
||||
let started = tokio::time::Instant::now();
|
||||
let stale = h.manager.stack_trace(id, 99).await;
|
||||
let asked =
|
||||
|command: &str| h.log.lock().iter().any(|m| m["command"] == command && m["arguments"]["threadId"] == 99);
|
||||
if kotlin_lsp {
|
||||
assert!(stale.is_err() && started.elapsed() < Duration::from_millis(100), "{stale:?}");
|
||||
assert!(h.manager.step(id, 99, StepKind::Over).await.is_err());
|
||||
assert!(h.manager.resume(id, 99).await.is_err());
|
||||
assert!(!asked("stackTrace") && !asked("next") && !asked("continue"));
|
||||
} else {
|
||||
assert!(stale.is_ok() && asked("stackTrace"), "{stale:?}");
|
||||
}
|
||||
assert!(h.manager.stack_trace(id, 1).await.is_ok());
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_threads_reply_from_before_a_stop_does_not_vouch_for_its_ids() {
|
||||
let h = harness(Script { stop_during_threads: true, ..Script::default() });
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
let threads = h.manager.threads(id).await.unwrap();
|
||||
// M3 maps kotlin-lsp's threads by these names.
|
||||
assert_eq!((threads[0].id, threads[0].name.as_str()), (1, "\"main\"@1"));
|
||||
stop_reasons(&h, 2).await;
|
||||
assert!(h.manager.stack_trace(id, 1).await.is_err());
|
||||
assert!(h.manager.stack_trace(id, 2).await.is_ok());
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kotlin_lsp_frames_get_real_paths() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let at = "at com.x.AppKt.main(App.kt:13)";
|
||||
for (config, path, name) in [
|
||||
(kotlin_lsp_attach(project.path()), "/p/App.kt", "com.x.AppKt.main(App.kt:13)"),
|
||||
(java_config("launch"), "file:///p/App.kt", at),
|
||||
] {
|
||||
let script = Script { source_path: Some("file:///p/App.kt".into()), frame_name: Some(at), ..Script::default() };
|
||||
let h = harness(script);
|
||||
let id = h.start(config).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
let frames = h.manager.stack_trace(id, 1).await.unwrap();
|
||||
assert_eq!(frames[0].path.as_ref().map(|p| p.0.clone()), Some(PathBuf::from(path)));
|
||||
assert_eq!(frames[0].name, name);
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalidated_events_reach_the_client() {
|
||||
let h = harness(Script { invalidated_after_stop: true, ..Script::default() });
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
|
||||
match h.wait_event(|e| matches!(e, DebugEvent::Invalidated { .. })).await {
|
||||
DebugEvent::Invalidated { areas } => assert_eq!(areas, ["variables"]),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
|
||||
|
||||
@@ -9,6 +9,14 @@ use std::path::{Path, PathBuf};
|
||||
/// configuration and roaming data keep following the OS.
|
||||
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
|
||||
|
||||
/// A custom local-data root chosen in Settings › System Settings › Caches,
|
||||
/// stored in the configuration directory. The environment variable still wins.
|
||||
pub const OVERRIDE_FILE: &str = "local-data-dir.txt";
|
||||
|
||||
/// A pending move of the local data to a newly chosen directory, carried out
|
||||
/// at the next start (before any project opens), like File › Invalidate Caches.
|
||||
const MIGRATION_FILE: &str = "local-data-migration.json";
|
||||
|
||||
/// `$OPENIDEA_LOCAL_DATA_DIR/<identifier>` when set, else the OS local data directory
|
||||
/// (Tauri's `app_local_data_dir`). The identifier keeps release and per-worktree
|
||||
/// debug builds apart under one root. A set but unusable path is an error, never a
|
||||
@@ -17,6 +25,203 @@ pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
|
||||
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
|
||||
}
|
||||
|
||||
/// The directory to actually start with: the environment override, else a valid
|
||||
/// custom directory from Settings, else the OS default. A stored but unusable
|
||||
/// custom directory is an error, never a silent fallback.
|
||||
pub fn startup_local_data_dir(identifier: &str) -> Result<PathBuf, String> {
|
||||
startup_resolve(std::env::var_os(LOCAL_DATA_ENV), stored_override(identifier), dirs::data_local_dir(), identifier)
|
||||
}
|
||||
|
||||
fn startup_resolve(
|
||||
var: Option<OsString>,
|
||||
stored: Option<PathBuf>,
|
||||
os: Option<PathBuf>,
|
||||
identifier: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
if var.as_ref().is_some_and(|v| !v.is_empty()) {
|
||||
return resolve_local(var, os, identifier);
|
||||
}
|
||||
let Some(root) = stored else { return resolve_local(None, os, identifier) };
|
||||
if !root.is_absolute() {
|
||||
return Err(format!("custom local data directory must be an absolute path: {}", root.display()));
|
||||
}
|
||||
resolve_root(root, identifier, "custom local data directory")
|
||||
}
|
||||
|
||||
/// The file holding the custom local-data root, if the platform exposes its
|
||||
/// configuration directory. Both startup and Settings use this one path.
|
||||
pub fn override_path(identifier: &str) -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|dir| dir.join(identifier).join(OVERRIDE_FILE))
|
||||
}
|
||||
|
||||
fn migration_path(identifier: &str) -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|dir| dir.join(identifier).join(MIGRATION_FILE))
|
||||
}
|
||||
|
||||
/// The custom root stored by Settings, if any (validated when it is used).
|
||||
pub fn stored_override(identifier: &str) -> Option<PathBuf> {
|
||||
stored_override_in(&override_path(identifier)?)
|
||||
}
|
||||
|
||||
fn stored_override_in(path: &Path) -> Option<PathBuf> {
|
||||
let text = fs::read_to_string(path).ok()?;
|
||||
let trimmed = text.trim();
|
||||
(!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
|
||||
}
|
||||
|
||||
/// The OS default local data directory (what clearing the custom directory
|
||||
/// returns to), ignoring any stored override. The environment still applies.
|
||||
pub fn default_local_data_dir(identifier: &str) -> Result<PathBuf, String> {
|
||||
local_data_dir(identifier)
|
||||
}
|
||||
|
||||
/// Stores (`Some`) or clears (`None`) the custom local-data root, returning the
|
||||
/// directory that takes effect after a restart. A pending custom migration to
|
||||
/// anywhere else is dropped: only the caller schedules the matching one.
|
||||
pub fn set_override(identifier: &str, root: Option<&Path>) -> Result<PathBuf, String> {
|
||||
let Some(path) = override_path(identifier) else {
|
||||
return Err("cannot find the configuration directory".into());
|
||||
};
|
||||
set_override_in(&path, &migration_path(identifier), root, identifier)
|
||||
}
|
||||
|
||||
fn set_override_in(
|
||||
path: &Path,
|
||||
migration: &Option<PathBuf>,
|
||||
root: Option<&Path>,
|
||||
identifier: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
if let Some(migration) = migration {
|
||||
let _ = fs::remove_file(migration);
|
||||
}
|
||||
let Some(root) = root else {
|
||||
if let Err(error) = fs::remove_file(path)
|
||||
&& error.kind() != io::ErrorKind::NotFound
|
||||
{
|
||||
return Err(format!("cannot clear the custom local data directory: {error}"));
|
||||
}
|
||||
return local_data_dir(identifier);
|
||||
};
|
||||
if !root.is_absolute() {
|
||||
return Err(format!("custom local data directory must be an absolute path: {}", root.display()));
|
||||
}
|
||||
let dir = resolve_root(root.to_path_buf(), identifier, "custom local data directory")?;
|
||||
if let Some(parent) = path.parent()
|
||||
&& let Err(error) = fs::create_dir_all(parent)
|
||||
{
|
||||
return Err(format!("cannot store the custom local data directory: {error}"));
|
||||
}
|
||||
if let Err(error) = fs::write(path, format!("{}\n", root.display())) {
|
||||
return Err(format!("cannot store the custom local data directory: {error}"));
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
struct CustomMigration {
|
||||
from: PathBuf,
|
||||
to: PathBuf,
|
||||
}
|
||||
|
||||
/// Schedules a move of the local data from `from` to `to` at the next start.
|
||||
/// Returns false when there is nothing to move. The caller restarts the IDE.
|
||||
pub fn schedule_custom_migration(identifier: &str, from: &Path, to: &Path) -> Result<bool, String> {
|
||||
let Some(path) = migration_path(identifier) else {
|
||||
return Err("cannot find the configuration directory".into());
|
||||
};
|
||||
schedule_custom_migration_in(&path, from, to)
|
||||
}
|
||||
|
||||
fn schedule_custom_migration_in(path: &Path, from: &Path, to: &Path) -> Result<bool, String> {
|
||||
if from == to {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(parent) = path.parent()
|
||||
&& let Err(error) = fs::create_dir_all(parent)
|
||||
{
|
||||
return Err(format!("cannot schedule the data move: {error}"));
|
||||
}
|
||||
let json = serde_json::to_vec(&CustomMigration { from: from.into(), to: to.into() })
|
||||
.map_err(|error| format!("cannot schedule the data move: {error}"))?;
|
||||
if let Err(error) = fs::write(path, json) {
|
||||
return Err(format!("cannot schedule the data move: {error}"));
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Carries out a scheduled custom migration aimed at `local` (call at start-up,
|
||||
/// before projects open). Entries that appear in the meantime are never
|
||||
/// overwritten; a partially moved tree is retried at the next start.
|
||||
pub fn apply_custom_migration(identifier: &str, local: &Path) {
|
||||
let Some(path) = migration_path(identifier) else { return };
|
||||
apply_custom_migration_in(&path, local);
|
||||
}
|
||||
|
||||
fn apply_custom_migration_in(path: &Path, local: &Path) {
|
||||
let Ok(bytes) = fs::read(path) else { return };
|
||||
let Ok(job) = serde_json::from_slice::<CustomMigration>(&bytes) else {
|
||||
let _ = fs::remove_file(path);
|
||||
return;
|
||||
};
|
||||
if job.to != local || job.from == local {
|
||||
let _ = fs::remove_file(path);
|
||||
return;
|
||||
}
|
||||
move_entries(&job.from, local, &custom_entries(&job.from));
|
||||
let leftover = custom_entries(&job.from).into_iter().any(|entry| job.from.join(entry).exists());
|
||||
if !leftover {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Entries of a local-data tree that move to a newly chosen directory.
|
||||
/// Non-relocatable tool installs (virtual environments with absolute interpreter
|
||||
/// paths) stay behind and are reinstalled on demand, as in `migrate_entries`.
|
||||
fn custom_entries(root: &Path) -> Vec<PathBuf> {
|
||||
let mut entries = local_entries(root);
|
||||
for extra in ["logs", "agents/tools"] {
|
||||
let entry = PathBuf::from(extra);
|
||||
if !entries.contains(&entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// Moves the local-data tree from `from` to `to`, entry by entry. Same-volume
|
||||
/// entries rename at once; cross-volume copies run in the background. Targets
|
||||
/// created in the meantime are never merged into or overwritten.
|
||||
fn move_entries(from: &Path, to: &Path, entries: &[PathBuf]) {
|
||||
if from == to {
|
||||
return;
|
||||
}
|
||||
let mut copies = Vec::new();
|
||||
for entry in entries {
|
||||
let source = from.join(entry);
|
||||
let target = to.join(entry);
|
||||
match try_rename(&source, &target) {
|
||||
Ok(true) => copies.push((source, target)),
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
tracing::warn!(?source, ?target, %error, "data move failed; keeping old data, using new location")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !copies.is_empty() {
|
||||
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
|
||||
for (source, target) in copies {
|
||||
tracing::info!(?source, ?target, "copying data to the new location in background");
|
||||
if let Err(error) = copy_then_remove(&source, &target) {
|
||||
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data");
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(error) = result {
|
||||
tracing::warn!(%error, "cannot start data migration worker; keeping old data");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
|
||||
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
|
||||
return os.map(|dir| dir.join(identifier)).ok_or_else(|| "cannot find the local data directory".into());
|
||||
@@ -25,10 +230,14 @@ fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -
|
||||
if !root.is_absolute() {
|
||||
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
|
||||
}
|
||||
resolve_root(root, identifier, LOCAL_DATA_ENV)
|
||||
}
|
||||
|
||||
fn resolve_root(root: PathBuf, identifier: &str, what: &str) -> Result<PathBuf, String> {
|
||||
let dir = root.join(identifier);
|
||||
fs::create_dir_all(&dir)
|
||||
.and_then(|()| tempfile::NamedTempFile::new_in(&dir).map(drop))
|
||||
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
|
||||
.map_err(|error| format!("{what}: cannot write to {}: {error}", dir.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
@@ -77,6 +286,11 @@ pub fn migrate(data: &Path, local: &Path) {
|
||||
}
|
||||
|
||||
fn migrate_entries(data: &Path, local: &Path) {
|
||||
move_entries(data, local, &local_entries(data));
|
||||
}
|
||||
|
||||
/// The rebuildable entries of the legacy roaming tree that move to Local.
|
||||
fn local_entries(data: &Path) -> Vec<PathBuf> {
|
||||
let mut entries: Vec<PathBuf> = [
|
||||
"language-server-runtimes",
|
||||
"index",
|
||||
@@ -118,31 +332,7 @@ fn migrate_entries(data: &Path, local: &Path) {
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"),
|
||||
}
|
||||
let mut copies = Vec::new();
|
||||
for entry in entries {
|
||||
let source = data.join(&entry);
|
||||
let target = data_path(data, local, &entry);
|
||||
match try_rename(&source, &target) {
|
||||
Ok(true) => copies.push((source, target)),
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !copies.is_empty() {
|
||||
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
|
||||
for (source, target) in copies {
|
||||
tracing::info!(?source, ?target, "copying legacy data in background");
|
||||
if let Err(error) = copy_then_remove(&source, &target) {
|
||||
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local");
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(error) = result {
|
||||
tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local");
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// Returns true when a cross-volume copy is needed.
|
||||
@@ -597,4 +787,108 @@ mod tests {
|
||||
.any(|e| e.file_name().to_string_lossy().starts_with(".migration-"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_override_round_trips_and_validates() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("local-data-dir.txt");
|
||||
let migration = tmp.path().join("local-data-migration.json");
|
||||
let id = "ai.test.openidea";
|
||||
assert_eq!(stored_override_in(&file), None);
|
||||
|
||||
let root = tmp.path().join("custom-root");
|
||||
let dir = set_override_in(&file, &Some(migration.clone()), Some(&root), id).unwrap();
|
||||
assert_eq!(dir, root.join(id));
|
||||
assert!(dir.is_dir());
|
||||
assert_eq!(stored_override_in(&file), Some(root.clone()));
|
||||
assert_eq!(startup_resolve(None, stored_override_in(&file), Some(tmp.path().join("os")), id).unwrap(), dir);
|
||||
|
||||
// A relative root is rejected before anything is written.
|
||||
let before = fs::read_to_string(&file).unwrap();
|
||||
let error = set_override_in(&file, &Some(migration.clone()), Some(Path::new("relative")), id).unwrap_err();
|
||||
assert!(error.contains("absolute"), "{error}");
|
||||
assert_eq!(fs::read_to_string(&file).unwrap(), before);
|
||||
|
||||
// An unwritable root (an existing file) is rejected too.
|
||||
let blocker = tmp.path().join("blocker");
|
||||
fs::write(&blocker, "").unwrap();
|
||||
let error = set_override_in(&file, &Some(migration.clone()), Some(&blocker), id).unwrap_err();
|
||||
assert!(error.contains("cannot write"), "{error}");
|
||||
|
||||
// Clearing removes the file and returns the default.
|
||||
let dir = set_override_in(&file, &Some(migration), None, id).unwrap();
|
||||
assert!(!file.exists());
|
||||
assert_eq!(dir, local_data_dir(id).unwrap());
|
||||
assert_eq!(stored_override_in(&file), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_prefers_the_environment_then_the_override() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let os = tmp.path().join("os");
|
||||
let custom = tmp.path().join("custom");
|
||||
let env = tmp.path().join("env");
|
||||
let id = "ai.test.openidea";
|
||||
|
||||
let error = startup_resolve(None, Some(PathBuf::from("relative")), Some(os.clone()), id).unwrap_err();
|
||||
assert!(error.contains("absolute"), "{error}");
|
||||
|
||||
// The environment wins over a stored override.
|
||||
let dir = startup_resolve(Some(env.clone().into()), Some(custom.clone()), Some(os.clone()), id).unwrap();
|
||||
assert_eq!(dir, env.join(id));
|
||||
|
||||
// Without the environment the override wins over the OS default.
|
||||
let dir = startup_resolve(None, Some(custom.clone()), Some(os.clone()), id).unwrap();
|
||||
assert_eq!(dir, custom.join(id));
|
||||
|
||||
// And without either the OS default applies.
|
||||
let dir = startup_resolve(None, None, Some(os.clone()), id).unwrap();
|
||||
assert_eq!(dir, os.join(id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_migration_moves_the_tree_and_clears_itself() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let marker = tmp.path().join("local-data-migration.json");
|
||||
let from = tmp.path().join("old-local");
|
||||
let to = tmp.path().join("new-local");
|
||||
fs::create_dir_all(from.join("index/p1")).unwrap();
|
||||
fs::write(from.join("index/p1/symbols"), "index").unwrap();
|
||||
fs::create_dir_all(from.join("cache/p1")).unwrap();
|
||||
fs::write(from.join("cache/p1/build"), "cache").unwrap();
|
||||
fs::create_dir_all(from.join("tools/demo/bin")).unwrap();
|
||||
fs::write(from.join("tools/demo/bin/demo.cmd"), "payload").unwrap();
|
||||
fs::write(from.join("history"), "roaming stays").unwrap();
|
||||
|
||||
// Nothing to move: no marker is written.
|
||||
assert!(!schedule_custom_migration_in(&marker, &from, &from).unwrap());
|
||||
assert!(!marker.exists());
|
||||
|
||||
assert!(schedule_custom_migration_in(&marker, &from, &to).unwrap());
|
||||
apply_custom_migration_in(&marker, &to);
|
||||
assert!(!marker.exists());
|
||||
assert_eq!(fs::read_to_string(to.join("index/p1/symbols")).unwrap(), "index");
|
||||
assert_eq!(fs::read_to_string(to.join("cache/p1/build")).unwrap(), "cache");
|
||||
assert!(to.join("tools/demo/bin/demo.cmd").exists());
|
||||
assert!(!from.join("index").exists());
|
||||
// Roaming content is not local data and stays behind.
|
||||
assert_eq!(fs::read_to_string(from.join("history")).unwrap(), "roaming stays");
|
||||
|
||||
// A migration aimed elsewhere is dropped without touching anything.
|
||||
fs::create_dir_all(from.join("lsp")).unwrap();
|
||||
assert!(schedule_custom_migration_in(&marker, &from, &tmp.path().join("elsewhere")).unwrap());
|
||||
apply_custom_migration_in(&marker, &to);
|
||||
assert!(!marker.exists());
|
||||
assert!(from.join("lsp").exists());
|
||||
assert!(!to.join("lsp").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_entries_cover_logs_and_agent_tools() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let entries = custom_entries(tmp.path());
|
||||
for expected in ["logs", "agents/tools", "index", "cache", "lsp", "agents/registry.json"] {
|
||||
assert!(entries.contains(&PathBuf::from(expected)), "{entries:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_agent::registry::{AgentRegistry, RegistryPaths};
|
||||
use ide_agent::{
|
||||
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
|
||||
ExternalSession, McpScope, McpServer, McpServerInput, NewAgentTask,
|
||||
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt,
|
||||
AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpScope, McpServer, McpServerInput,
|
||||
NewAgentTask,
|
||||
};
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
@@ -44,6 +45,24 @@ pub fn set_env(
|
||||
host.project(window)?.agents.set_builtin_env(&id, env)
|
||||
}
|
||||
|
||||
/// Saves a new custom ACP agent through its agent file.
|
||||
pub fn save_custom_agent(host: &Host, window: &str, input: CustomAgentInput) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.save_custom_agent(input)
|
||||
}
|
||||
|
||||
/// Removes a custom agent's file (registry installs go through `registry_remove`).
|
||||
pub fn remove_custom_agent(host: &Host, window: &str, id: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.remove_custom_agent(&id)
|
||||
}
|
||||
|
||||
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom agents.
|
||||
pub fn import_acp_agents(host: &Host, window: &str, text: String) -> IdeResult<Vec<AcpImportOutcome>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.import_acp_agents(&text)
|
||||
}
|
||||
|
||||
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
|
||||
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
|
||||
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
|
||||
@@ -297,6 +316,18 @@ pub async fn rollback_changes(host: &Host, window: &str, task: String, paths: Op
|
||||
host.project(window)?.agents.rollback_changes(&task, paths).await
|
||||
}
|
||||
|
||||
/// Accept: copies a worktree task's changed files among `paths` (all without) into the
|
||||
/// local checkout, uncommitted; files the checkout changed itself are left alone and reported.
|
||||
pub async fn accept_changes(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
task: String,
|
||||
paths: Option<Vec<String>>,
|
||||
) -> IdeResult<AgentAcceptOutcome> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.accept_changes(&task, paths).await
|
||||
}
|
||||
|
||||
type McpServerMap = std::collections::BTreeMap<String, ide_core::settings::AgentMcpServer>;
|
||||
|
||||
fn mcp_maps(host: &Host, window: &str) -> (McpServerMap, McpServerMap, Option<std::sync::Arc<crate::Project>>) {
|
||||
@@ -449,6 +480,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("agent_rollback_changes", |c| async move {
|
||||
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
|
||||
});
|
||||
d.add("agent_accept_changes", |c| async move {
|
||||
accept_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
|
||||
});
|
||||
}
|
||||
|
||||
fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::Path>) -> IdeResult<std::path::PathBuf> {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
mod kotlin;
|
||||
#[cfg(test)]
|
||||
mod kotlin_tests;
|
||||
mod output;
|
||||
|
||||
use crate::Host;
|
||||
@@ -41,6 +44,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
config = kotlin::choose_kotlin_debugger(host, window, config).await?;
|
||||
if config.adapter == ide_dap::kotlin::ADAPTER {
|
||||
let data_dir = host.local_data_dir.clone();
|
||||
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
//! Kotlin debugging: kotlin-lsp's debugger when the project's kotlin-lsp can serve it, else
|
||||
//! kotlin-debug-adapter with a notice saying why.
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::event::NotifyLevel;
|
||||
use ide_core::task::CancelHandle;
|
||||
use ide_core::{CancellationToken, IdeEvent, IdeResult, SharedSink, TaskId};
|
||||
use ide_dap::LaunchConfig;
|
||||
use ide_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(super) const KOTLIN_IMPORT_WAIT: Duration = Duration::from_secs(120);
|
||||
/// A ready kotlin-lsp answers well within this; only a longer wait shows a task.
|
||||
const TASK_DELAY: Duration = Duration::from_millis(500);
|
||||
|
||||
// Catalogued as written (`{detail}` replaced at runtime): the text catalog test counts these
|
||||
// literals as the use of its entries.
|
||||
const TASK_TITLE: &str = "Waiting for kotlin-lsp to import the project";
|
||||
const NOTICE_TITLE: &str = "Debugging Kotlin with kotlin-debug-adapter";
|
||||
const NOT_CONFIGURED: &str = "kotlin-lsp is not the Kotlin language server of this project.";
|
||||
const NOT_INSTALLED: &str = "kotlin-lsp is not installed.";
|
||||
const NOT_STARTED: &str = "kotlin-lsp did not start: {detail}";
|
||||
const STILL_IMPORTING: &str =
|
||||
"kotlin-lsp is still importing the project. Debug again once the import has finished to use its debugger.";
|
||||
const IMPORT_FAILED: &str = "kotlin-lsp could not import the project.";
|
||||
const FAILED: &str = "kotlin-lsp could not start its debugger: {detail}";
|
||||
|
||||
/// `config` for kotlin-lsp's debug server on `port`; the rest of the launch stays as it is.
|
||||
pub(super) fn use_kotlin_lsp(mut config: LaunchConfig, port: u16) -> IdeResult<LaunchConfig> {
|
||||
let mut extra = extra_object(&config)?;
|
||||
extra.insert("debugServer".into(), port.into());
|
||||
config.adapter = ide_dap::kotlin_lsp::ADAPTER.into();
|
||||
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// The dedupe key of `reason` and the notice body (English, translated by the UI's catalog).
|
||||
pub(super) fn fallback_notice(reason: &KotlinDebugUnavailable) -> (&'static str, String) {
|
||||
match reason {
|
||||
KotlinDebugUnavailable::NotConfigured => ("notConfigured", NOT_CONFIGURED.into()),
|
||||
KotlinDebugUnavailable::NotInstalled => ("notInstalled", NOT_INSTALLED.into()),
|
||||
KotlinDebugUnavailable::NotStarted(detail) => ("notStarted", NOT_STARTED.replace("{detail}", detail)),
|
||||
KotlinDebugUnavailable::StillImporting => ("stillImporting", STILL_IMPORTING.into()),
|
||||
KotlinDebugUnavailable::ImportFailed => ("importFailed", IMPORT_FAILED.into()),
|
||||
KotlinDebugUnavailable::Failed(detail) => ("failed", FAILED.replace("{detail}", detail)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A kotlin-debug-adapter launch becomes a kotlin-lsp one when the project's kotlin-lsp has
|
||||
/// imported the module and started its debug server; otherwise it stays on
|
||||
/// kotlin-debug-adapter and the window is told why (once per reason).
|
||||
pub(super) async fn choose_kotlin_debugger(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<LaunchConfig> {
|
||||
if config.adapter == ide_dap::kotlin_lsp::ADAPTER {
|
||||
require_debug_server(&config)?;
|
||||
return Ok(config);
|
||||
}
|
||||
if config.adapter != ide_dap::kotlin::ADAPTER {
|
||||
return Ok(config);
|
||||
}
|
||||
let Ok(project) = host.project(window) else {
|
||||
tracing::info!(
|
||||
"debugging Kotlin with kotlin-debug-adapter: {}",
|
||||
fallback_notice(&KotlinDebugUnavailable::NotConfigured).1
|
||||
);
|
||||
return Ok(config);
|
||||
};
|
||||
let path = debug_path(&config).unwrap_or_else(|| project.root.clone());
|
||||
let lsp = project.lsp.clone();
|
||||
let chosen = wait_for_debug_server(&project.sink, |cancel| async move {
|
||||
lsp.kotlin_debug_server(&path, KOTLIN_IMPORT_WAIT, &cancel).await
|
||||
})
|
||||
.await;
|
||||
match chosen {
|
||||
Ok(server) => use_kotlin_lsp(config, server.port),
|
||||
Err(reason) => {
|
||||
report_fallback(&project.kotlin_debug_notices, &project.sink, &reason);
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// kotlin-lsp is only reached through the port its language server opened; without one
|
||||
/// ide-dap would try to spawn a `kotlin-lsp` adapter that does not exist.
|
||||
fn require_debug_server(config: &LaunchConfig) -> IdeResult<()> {
|
||||
match extra_object(config)?.get("debugServer") {
|
||||
None | Some(serde_json::Value::Null) => Err(ide_core::IdeError::invalid(
|
||||
"A kotlin-lsp debug configuration needs \"debugServer\": the port of kotlin-lsp's debug server. \
|
||||
Use kotlin-debug-adapter to let the IDE start it.",
|
||||
)),
|
||||
Some(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn extra_object(config: &LaunchConfig) -> IdeResult<serde_json::Map<String, serde_json::Value>> {
|
||||
match config.extra_json.as_deref().map(serde_json::from_str).transpose() {
|
||||
Ok(None) => Ok(serde_json::Map::new()),
|
||||
Ok(Some(serde_json::Value::Object(extra))) => Ok(extra),
|
||||
Ok(Some(other)) => Err(ide_core::IdeError::invalid(format!("launch arguments are not an object: {other}"))),
|
||||
Err(e) => Err(ide_core::IdeError::invalid(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The module to debug: the launch's `projectRoot`, else its working directory.
|
||||
fn debug_path(config: &LaunchConfig) -> Option<PathBuf> {
|
||||
let extra: Option<serde_json::Value> = config.extra_json.as_deref().and_then(|e| serde_json::from_str(e).ok());
|
||||
extra
|
||||
.as_ref()
|
||||
.and_then(|e| e.get("projectRoot")?.as_str())
|
||||
.filter(|root| !root.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| config.cwd.as_deref().map(PathBuf::from))
|
||||
}
|
||||
|
||||
/// Runs `debug_server` with a token the user can cancel; a wait longer than [`TASK_DELAY`]
|
||||
/// shows as a cancellable task, which ends however the wait does (also when dropped).
|
||||
async fn wait_for_debug_server<F, Fut>(
|
||||
sink: &SharedSink,
|
||||
debug_server: F,
|
||||
) -> Result<KotlinDebugServer, KotlinDebugUnavailable>
|
||||
where
|
||||
F: FnOnce(CancellationToken) -> Fut,
|
||||
Fut: Future<Output = Result<KotlinDebugServer, KotlinDebugUnavailable>>,
|
||||
{
|
||||
let cancel = CancellationToken::new();
|
||||
let id = TaskId::next();
|
||||
let handle = ide_core::task::on_cancel(id, {
|
||||
let cancel = cancel.clone();
|
||||
move || cancel.cancel()
|
||||
});
|
||||
let wait = debug_server(cancel);
|
||||
tokio::pin!(wait);
|
||||
if let Ok(answer) = tokio::time::timeout(TASK_DELAY, &mut wait).await {
|
||||
return answer;
|
||||
}
|
||||
let _task = WaitTask::start(sink.clone(), id, handle);
|
||||
wait.await
|
||||
}
|
||||
|
||||
/// The visible import wait; dropping it ends the task.
|
||||
struct WaitTask {
|
||||
sink: SharedSink,
|
||||
id: TaskId,
|
||||
_cancel: CancelHandle,
|
||||
}
|
||||
|
||||
impl WaitTask {
|
||||
fn start(sink: SharedSink, id: TaskId, cancel: CancelHandle) -> Self {
|
||||
let task = Self { sink, id, _cancel: cancel };
|
||||
task.emit(false);
|
||||
task
|
||||
}
|
||||
|
||||
fn emit(&self, done: bool) {
|
||||
self.sink.emit(IdeEvent::Task {
|
||||
id: self.id,
|
||||
title: TASK_TITLE.into(),
|
||||
message: None,
|
||||
fraction: None,
|
||||
done,
|
||||
cancellable: !done,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WaitTask {
|
||||
fn drop(&mut self) {
|
||||
self.emit(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Logs every fallback; notifies the window once per reason.
|
||||
fn report_fallback(notices: &Mutex<HashSet<&'static str>>, sink: &SharedSink, reason: &KotlinDebugUnavailable) {
|
||||
let (key, body) = fallback_notice(reason);
|
||||
tracing::info!("debugging Kotlin with kotlin-debug-adapter: {body}");
|
||||
if notices.lock().insert(key) {
|
||||
sink.emit(IdeEvent::Notify { level: NotifyLevel::Info, title: NOTICE_TITLE.into(), body: Some(body) });
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn launch(adapter: &str, extra: serde_json::Value) -> LaunchConfig {
|
||||
LaunchConfig {
|
||||
name: "AppKt".into(),
|
||||
adapter: adapter.into(),
|
||||
request: "launch".into(),
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: Some("/cwd".into()),
|
||||
env: Default::default(),
|
||||
extra_json: Some(extra.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn extra(config: &LaunchConfig) -> serde_json::Value {
|
||||
serde_json::from_str(config.extra_json.as_deref().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn reasons() -> Vec<KotlinDebugUnavailable> {
|
||||
vec![
|
||||
KotlinDebugUnavailable::NotConfigured,
|
||||
KotlinDebugUnavailable::NotInstalled,
|
||||
KotlinDebugUnavailable::NotStarted("needs JDK 21".into()),
|
||||
KotlinDebugUnavailable::StillImporting,
|
||||
KotlinDebugUnavailable::ImportFailed,
|
||||
KotlinDebugUnavailable::Failed("boom".into()),
|
||||
]
|
||||
}
|
||||
|
||||
fn recording_sink() -> (SharedSink, Arc<Mutex<Vec<IdeEvent>>>) {
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let recorded = events.clone();
|
||||
(Arc::new(move |event| recorded.lock().push(event)), events)
|
||||
}
|
||||
|
||||
/// `(done, cancellable)` of each task event, with the task id.
|
||||
fn tasks(events: &Mutex<Vec<IdeEvent>>) -> Vec<(ide_core::TaskId, String, bool, bool)> {
|
||||
events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Task { id, title, done, cancellable, .. } => Some((*id, title.clone(), *done, *cancellable)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn server() -> KotlinDebugServer {
|
||||
KotlinDebugServer { port: 4711, root: PathBuf::from("/p") }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kotlin_lsp_takes_the_debug_server_port() {
|
||||
let config = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({"mainClass": "a.AppKt", "projectRoot": "/p"}));
|
||||
let c = use_kotlin_lsp(config, 4711).unwrap();
|
||||
assert_eq!(c.adapter, ide_dap::kotlin_lsp::ADAPTER);
|
||||
let args = extra(&c);
|
||||
assert_eq!(args["debugServer"], 4711);
|
||||
assert_eq!(args["mainClass"], "a.AppKt");
|
||||
assert_eq!(args["projectRoot"], "/p");
|
||||
assert!(args.get("__adapterJavaHome").is_none());
|
||||
assert_eq!(
|
||||
(c.name.as_str(), c.request.as_str(), c.cwd.as_deref()),
|
||||
("AppKt", "launch", Some(Path::new("/cwd")))
|
||||
);
|
||||
let mut bare = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({}));
|
||||
bare.extra_json = None;
|
||||
assert_eq!(extra(&use_kotlin_lsp(bare, 4711).unwrap())["debugServer"], 4711);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_fallback_reason_has_a_catalogued_text() {
|
||||
let catalogs: [(&str, serde_json::Value); 2] = [
|
||||
("en", serde_json::from_str(include_str!("../../../../../src/platform/locales/text/en.json")).unwrap()),
|
||||
("zh", serde_json::from_str(include_str!("../../../../../src/platform/locales/text/zh.json")).unwrap()),
|
||||
];
|
||||
let templates = [NOT_CONFIGURED, NOT_INSTALLED, NOT_STARTED, STILL_IMPORTING, IMPORT_FAILED, FAILED];
|
||||
for reason in reasons() {
|
||||
let (_, body) = fallback_notice(&reason);
|
||||
let detail = match &reason {
|
||||
KotlinDebugUnavailable::NotStarted(d) | KotlinDebugUnavailable::Failed(d) => d.as_str(),
|
||||
_ => "",
|
||||
};
|
||||
assert!(
|
||||
templates.iter().any(|t| t.replace("{detail}", detail) == body),
|
||||
"{reason:?}: {body:?} is not built from a catalogued template"
|
||||
);
|
||||
}
|
||||
for text in templates.iter().chain([&NOTICE_TITLE, &TASK_TITLE]) {
|
||||
for (locale, catalog) in &catalogs {
|
||||
let translated = catalog[*text].as_str().unwrap_or_else(|| panic!("{locale}.json lacks {text:?}"));
|
||||
if *locale == "zh" {
|
||||
assert_ne!(translated, *text, "zh.json leaves {text:?} untranslated");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_reasons_have_distinct_keys() {
|
||||
let keys: HashSet<_> = reasons().iter().map(|r| fallback_notice(r).0).collect();
|
||||
assert_eq!(keys.len(), reasons().len());
|
||||
// The detail does not make another reason.
|
||||
assert_eq!(
|
||||
fallback_notice(&KotlinDebugUnavailable::Failed("a".into())).0,
|
||||
fallback_notice(&KotlinDebugUnavailable::Failed("b".into())).0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kotlin_lsp_config_without_a_debug_server_is_refused() {
|
||||
let refused = require_debug_server(&launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({}))).unwrap_err();
|
||||
assert_eq!(refused.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
assert!(refused.message.contains("debugServer"), "{}", refused.message);
|
||||
let mut bare = launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({}));
|
||||
bare.extra_json = None;
|
||||
assert!(require_debug_server(&bare).is_err());
|
||||
assert!(
|
||||
require_debug_server(&launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({"debugServer": null})))
|
||||
.is_err()
|
||||
);
|
||||
for port in [serde_json::json!(4711), serde_json::json!("10.0.0.5:4711")] {
|
||||
require_debug_server(&launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({"debugServer": port})))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_module_is_the_project_root_else_the_working_directory() {
|
||||
let with_root = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({"projectRoot": "/p/app"}));
|
||||
assert_eq!(debug_path(&with_root), Some(PathBuf::from("/p/app")));
|
||||
assert_eq!(debug_path(&launch(ide_dap::kotlin::ADAPTER, serde_json::json!({}))), Some(PathBuf::from("/cwd")));
|
||||
let mut neither = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({}));
|
||||
neither.cwd = None;
|
||||
assert_eq!(debug_path(&neither), None);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_quick_answer_shows_no_task() {
|
||||
let (sink, events) = recording_sink();
|
||||
assert_eq!(wait_for_debug_server(&sink, |_| async { Ok(server()) }).await, Ok(server()));
|
||||
assert!(events.lock().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_longer_wait_shows_a_task_that_ends_on_every_outcome() {
|
||||
for outcome in [Ok(server()), Err(KotlinDebugUnavailable::ImportFailed)] {
|
||||
let (sink, events) = recording_sink();
|
||||
let answer = outcome.clone();
|
||||
let result = wait_for_debug_server(&sink, |_| async move {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
answer
|
||||
})
|
||||
.await;
|
||||
assert_eq!(result, outcome);
|
||||
let tasks = tasks(&events);
|
||||
assert_eq!(tasks.len(), 2, "{tasks:?}");
|
||||
assert_eq!(tasks[0].0, tasks[1].0);
|
||||
assert_eq!(tasks[0].1, TASK_TITLE);
|
||||
assert_eq!((tasks[0].2, tasks[0].3), (false, true));
|
||||
assert_eq!((tasks[1].2, tasks[1].3), (true, false));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cancelling_the_task_stops_the_wait() {
|
||||
let (sink, events) = recording_sink();
|
||||
let wait = tokio::spawn(async move {
|
||||
wait_for_debug_server(&sink, |cancel| async move {
|
||||
cancel.cancelled().await;
|
||||
Err(KotlinDebugUnavailable::StillImporting)
|
||||
})
|
||||
.await
|
||||
});
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
let id = tasks(&events)[0].0;
|
||||
assert!(ide_core::task::cancel(id));
|
||||
assert_eq!(wait.await.unwrap(), Err(KotlinDebugUnavailable::StillImporting));
|
||||
assert_eq!(tasks(&events).last().map(|t| t.2), Some(true));
|
||||
assert!(!ide_core::task::cancel(id), "an ended wait is no longer cancellable");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_dropped_wait_ends_its_task() {
|
||||
let (sink, events) = recording_sink();
|
||||
let wait = tokio::spawn(async move {
|
||||
wait_for_debug_server(&sink, |_| std::future::pending::<Result<KotlinDebugServer, _>>()).await
|
||||
});
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
wait.abort();
|
||||
let _ = wait.await;
|
||||
assert_eq!(tasks(&events).iter().map(|t| t.2).collect::<Vec<_>>(), [false, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_notice_shows_once_per_reason() {
|
||||
let (sink, events) = recording_sink();
|
||||
let notices = Mutex::new(HashSet::new());
|
||||
for reason in [
|
||||
KotlinDebugUnavailable::StillImporting,
|
||||
KotlinDebugUnavailable::StillImporting,
|
||||
KotlinDebugUnavailable::NotStarted("needs JDK 21".into()),
|
||||
KotlinDebugUnavailable::NotStarted("crashed".into()),
|
||||
] {
|
||||
report_fallback(¬ices, &sink, &reason);
|
||||
}
|
||||
let notes: Vec<_> = events
|
||||
.lock()
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
IdeEvent::Notify { level, title, body } => (*level, title.clone(), body.clone()),
|
||||
other => panic!("{other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
notes,
|
||||
[
|
||||
(NotifyLevel::Info, NOTICE_TITLE.to_string(), Some(STILL_IMPORTING.to_string())),
|
||||
(
|
||||
NotifyLevel::Info,
|
||||
NOTICE_TITLE.to_string(),
|
||||
Some("kotlin-lsp did not start: needs JDK 21".to_string())
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Kotlin's debugger choice through a real `Host` and project: the fallback notice and the
|
||||
//! refusal of a kotlin-lsp configuration without its debug server.
|
||||
|
||||
use super::*;
|
||||
use crate::HostConfig;
|
||||
use ide_core::IdeEvent;
|
||||
use ide_core::event::NotifyLevel;
|
||||
use ide_lsp::KotlinDebugUnavailable;
|
||||
use ide_workspace::SettingsScope;
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
|
||||
const NOTICE_TITLE: &str = "Debugging Kotlin with kotlin-debug-adapter";
|
||||
|
||||
struct Fixture {
|
||||
host: Host,
|
||||
root: PathBuf,
|
||||
events: Arc<Mutex<Vec<IdeEvent>>>,
|
||||
_temp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
/// A trusted Gradle project opened with these global settings; kotlin-debug-adapter is a
|
||||
/// missing executable, so a session fails right after the debugger is chosen.
|
||||
async fn new(settings: &[(&str, serde_json::Value)]) -> Self {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("settings.gradle.kts"), "rootProject.name = \"app\"\n").unwrap();
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let captured = events.clone();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |_| {
|
||||
let events = captured.clone();
|
||||
Arc::new(move |event| events.lock().push(event))
|
||||
}),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let missing = base.join("missing").join("kotlin-debug-adapter");
|
||||
host.global_settings
|
||||
.set(SettingsScope::Global, "tools", serde_json::json!({"kotlin-debug-adapter": {"path": missing}}))
|
||||
.unwrap();
|
||||
for (key, value) in settings {
|
||||
host.global_settings.set(SettingsScope::Global, key, value.clone()).unwrap();
|
||||
}
|
||||
host.open_project("main", &root).unwrap();
|
||||
host.set_project_trust(&root, true, false).await.unwrap();
|
||||
Self { host, root, events, _temp: temp }
|
||||
}
|
||||
|
||||
fn launch(&self, adapter: &str, extra: serde_json::Value) -> LaunchConfig {
|
||||
LaunchConfig {
|
||||
name: "AppKt".into(),
|
||||
adapter: adapter.into(),
|
||||
request: "launch".into(),
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: Some(self.root.clone().into()),
|
||||
env: Default::default(),
|
||||
extra_json: Some(extra.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn kotlin_launch(&self) -> LaunchConfig {
|
||||
let extra = serde_json::json!({"mainClass": "com.example.AppKt", "projectRoot": self.root});
|
||||
self.launch(ide_dap::kotlin::ADAPTER, extra)
|
||||
}
|
||||
|
||||
/// `(level, body)` of each fallback notice.
|
||||
fn notices(&self) -> Vec<(NotifyLevel, Option<String>)> {
|
||||
self.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Notify { level, title, body } if title == NOTICE_TITLE => Some((*level, body.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Two Debugs, each failing only at kotlin-debug-adapter's missing executable.
|
||||
async fn debug_twice(f: &Fixture) {
|
||||
for _ in 0..2 {
|
||||
let error = debug_start(&f.host, "main", f.kotlin_launch()).await.unwrap_err();
|
||||
assert!(!error.message.contains("Restricted Mode"), "{}", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kotlin_lsp_turned_off_gives_one_notice_over_two_debugs() {
|
||||
let f = Fixture::new(&[("languageServers", serde_json::json!({"kotlin-lsp": {"enabled": false}}))]).await;
|
||||
debug_twice(&f).await;
|
||||
let (_, body) = kotlin::fallback_notice(&KotlinDebugUnavailable::NotConfigured);
|
||||
assert_eq!(f.notices(), [(NotifyLevel::Info, Some(body))]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_missing_kotlin_lsp_gives_one_notice_over_two_debugs() {
|
||||
let missing = serde_json::json!({"kotlin-lsp": {"command": "/nonexistent/kotlin-lsp"}});
|
||||
let f = Fixture::new(&[("languageServers", missing)]).await;
|
||||
debug_twice(&f).await;
|
||||
let (_, body) = kotlin::fallback_notice(&KotlinDebugUnavailable::NotInstalled);
|
||||
assert_eq!(f.notices(), [(NotifyLevel::Info, Some(body))]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_kotlin_lsp_config_without_its_debug_server_is_refused() {
|
||||
let f = Fixture::new(&[]).await;
|
||||
let config = f.launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({"mainClass": "com.example.AppKt"}));
|
||||
let refused = debug_start(&f.host, "main", config).await.unwrap_err();
|
||||
assert_eq!(refused.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
assert!(refused.message.contains("debugServer"), "{}", refused.message);
|
||||
assert!(f.notices().is_empty());
|
||||
}
|
||||
@@ -231,8 +231,16 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
|
||||
.ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?;
|
||||
let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?;
|
||||
// "Build before launch" is skipped when the built WAR is already newer than the
|
||||
// module's sources, so a repeated run does not rebuild (IDEA's up-to-date check).
|
||||
if ide_run::tomcat::artifact_is_current(&project.root, &base, &config) {
|
||||
// sources of every module in the build, so a repeated run does not rebuild (IDEA's
|
||||
// up-to-date check) but a change in a dependency module does.
|
||||
let modules: Vec<std::path::PathBuf> = model
|
||||
.maven
|
||||
.iter()
|
||||
.flat_map(|m| m.projects.iter().map(|p| p.dir.as_str()))
|
||||
.chain(model.gradle.iter().flat_map(|g| g.projects.iter().map(|p| p.dir.as_str())))
|
||||
.map(std::path::PathBuf::from)
|
||||
.collect();
|
||||
if ide_run::tomcat::artifact_is_current(&project.root, &base, &config, &modules) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut plan = ide_build::web::build(&project.root, &model, id, &base)?;
|
||||
|
||||
@@ -896,6 +896,13 @@ impl Shared {
|
||||
self.prompts_blocked.lock().remove(plugin);
|
||||
}
|
||||
|
||||
/// A window other than `client` where `plugin` waits for the user's answer (a prompt or a
|
||||
/// Commit request). The plugin runs one call at a time, so anything else asked of it waits
|
||||
/// behind that answer.
|
||||
pub fn waiting_elsewhere(&self, plugin: &str, client: &str) -> Option<String> {
|
||||
self.prompts.lock().values().find(|p| p.plugin == plugin && p.client != client).map(|p| p.client.clone())
|
||||
}
|
||||
|
||||
/// The user answered (`Some`) or dismissed (`None`) a prompt.
|
||||
pub fn answer_prompt(&self, id: u64, answer: Option<Vec<String>>) -> bool {
|
||||
let mut prompts = self.prompts.lock();
|
||||
|
||||
@@ -1381,6 +1381,17 @@ impl PluginHost {
|
||||
invocation: Invocation,
|
||||
cancel: Option<&ide_core::CancellationToken>,
|
||||
) -> IdeResult<Option<String>> {
|
||||
// Queued behind a prompt open in another window, the command would sit there with no
|
||||
// sign of life until that prompt is answered: point the user at it instead.
|
||||
if let Some(client) = invocation.client.as_deref()
|
||||
&& let Some(waiting) = self.shared.waiting_elsewhere(id, client)
|
||||
{
|
||||
self.sink.emit(Some(&waiting), PluginEvent::PromptWaiting { plugin_id: id.to_owned() });
|
||||
let name = self.get(id).map(|p| p.manifest().name.clone()).unwrap_or_else(|_| id.to_owned());
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{name} is waiting for your answer in another window. Answer or close it there, then try again."
|
||||
)));
|
||||
}
|
||||
let (cmd, arg) = (command.to_owned(), arg.map(str::to_owned));
|
||||
self.code_call_cancellable(id, invocation, "running the command", cancel, move |rt, inv| {
|
||||
rt.run_command(&cmd, arg.as_deref(), inv)
|
||||
|
||||
@@ -121,9 +121,14 @@ pub(crate) struct NavigationIndex {
|
||||
fn contains(range: Range, pos: Pos) -> bool {
|
||||
(range.start.line, range.start.col) <= (pos.line, pos.col) && (pos.line, pos.col) < (range.end.line, range.end.col)
|
||||
}
|
||||
/// Inside the document, start not after end. An empty range marks a position (a template file's
|
||||
/// definition at its start, say): navigation lands there, but it has no text to rename.
|
||||
fn valid_range(lines: &[usize], range: Range) -> bool {
|
||||
let pos = |p: Pos| lines.get(p.line as usize).is_some_and(|&length| p.col as usize <= length);
|
||||
pos(range.start) && pos(range.end) && (range.start.line, range.start.col) < (range.end.line, range.end.col)
|
||||
pos(range.start) && pos(range.end) && (range.start.line, range.start.col) <= (range.end.line, range.end.col)
|
||||
}
|
||||
fn is_empty(range: Range) -> bool {
|
||||
(range.start.line, range.start.col) == (range.end.line, range.end.col)
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
@@ -280,12 +285,20 @@ impl PluginHost {
|
||||
.chain(&edges.references)
|
||||
.any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
|
||||
{
|
||||
return Err(IdeError::invalid("invalid navigation anchors"));
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{}: invalid navigation anchors in {}",
|
||||
plugin.id(),
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
if edges.markers.len() > MAX_EDGES
|
||||
|| edges.markers.iter().any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
|
||||
{
|
||||
return Err(IdeError::invalid("invalid navigation markers"));
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{}: invalid navigation markers in {}",
|
||||
plugin.id(),
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let mut index = self.navigation.lock();
|
||||
if index.epochs.get(root).copied() != generation {
|
||||
@@ -406,7 +419,7 @@ impl PluginHost {
|
||||
let answer = result.get_or_insert_with(|| IndexedSymbol { range: primary.range, files: vec![] });
|
||||
for (path, file) in &graph.files {
|
||||
let mut ranges = std::collections::BTreeSet::new();
|
||||
for candidate in &file.edges.definitions {
|
||||
for candidate in file.edges.definitions.iter().filter(|a| !is_empty(a.range)) {
|
||||
if targets.contains(&Location::file(path.clone(), candidate.range))
|
||||
|| (targets.is_empty() && file.scope == source.scope && keys.contains(candidate.key.as_str()))
|
||||
{
|
||||
@@ -414,7 +427,7 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
let mut matches = HashMap::new();
|
||||
for candidate in &file.edges.references {
|
||||
for candidate in file.edges.references.iter().filter(|a| !is_empty(a.range)) {
|
||||
let same = *matches.entry(candidate.key.as_str()).or_insert_with(|| {
|
||||
let resolved = graph.scoped_targets(&candidate.key, &file.scope);
|
||||
resolved.iter().any(|target| targets.contains(target))
|
||||
@@ -580,3 +593,21 @@ impl ExtensionPoint for IndexersPoint {
|
||||
Ok(Prepared::new(d.extensions.join(", "), || Ok(Disposable::new(|| {}))).capability(Capability::ProjectRead))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_ranges_inside_the_document_are_valid_positions() {
|
||||
let lines = [3, 0];
|
||||
let range = |a: (u32, u32), b: (u32, u32)| Range::new(Pos::new(a.0, a.1), Pos::new(b.0, b.1));
|
||||
assert!(valid_range(&lines, range((0, 0), (0, 0))));
|
||||
assert!(valid_range(&lines, range((1, 0), (1, 0))));
|
||||
assert!(valid_range(&lines, range((0, 1), (0, 3))));
|
||||
assert!(!valid_range(&lines, range((0, 2), (0, 1))), "reversed");
|
||||
assert!(!valid_range(&lines, range((0, 4), (0, 4))), "past the line end");
|
||||
assert!(!valid_range(&lines, range((2, 0), (2, 0))), "past the last line");
|
||||
assert!(is_empty(range((0, 0), (0, 0))) && !is_empty(range((0, 0), (0, 1))));
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::OpenProject { .. }
|
||||
| PluginEvent::PromptRequested { .. }
|
||||
| PluginEvent::PromptClosed { .. }
|
||||
| PluginEvent::PromptWaiting { .. }
|
||||
| PluginEvent::RevealView { .. }
|
||||
| PluginEvent::CommitRequested { .. }
|
||||
| PluginEvent::EndpointsChanged { .. }
|
||||
|
||||
@@ -321,6 +321,10 @@ pub enum PluginEvent {
|
||||
/// The call that asked gave up (cancelled, or its plugin stopped): close the prompt.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PromptClosed { prompt_id: String },
|
||||
/// A command of this window's prompting plugin was run from another window and refused
|
||||
/// while the plugin waits for the answer here: bring this window to the front.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PromptWaiting { plugin_id: String },
|
||||
/// Bring the tool window of the plugin's view `view` to the front.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
RevealView { plugin_id: String, view: String },
|
||||
|
||||
@@ -13,7 +13,7 @@ use ide_vfs::Watcher;
|
||||
use ide_workspace::documents::DocumentObserver;
|
||||
use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -50,6 +50,8 @@ pub struct Project {
|
||||
pub cache_dir: PathBuf,
|
||||
/// Superseded launch snapshots retained until this project session ends.
|
||||
pub(crate) java_launch_snapshots: Mutex<crate::ops::java::cache::Session>,
|
||||
/// Reasons this window was already told why Kotlin is debugged with kotlin-debug-adapter.
|
||||
pub(crate) kotlin_debug_notices: Mutex<HashSet<&'static str>>,
|
||||
/// Database tool window: data sources and their connections.
|
||||
pub db: Arc<ide_db::DbManager>,
|
||||
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
|
||||
@@ -563,6 +565,7 @@ impl Host {
|
||||
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
|
||||
self.local_data_dir.join("cache").join(&project_key).join("build"),
|
||||
)),
|
||||
kotlin_debug_notices: Mutex::default(),
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
@@ -596,6 +599,12 @@ impl Host {
|
||||
weak.upgrade().map(|p| p.exclude_globs()).unwrap_or_default()
|
||||
}));
|
||||
}
|
||||
{
|
||||
let weak = Arc::downgrade(&project);
|
||||
project.agents.set_security_hook(Arc::new(move || {
|
||||
weak.upgrade().map(|p| p.settings.effective().agent_security).unwrap_or_default()
|
||||
}));
|
||||
}
|
||||
self.insert_project(window, project.clone());
|
||||
project.lsp.prewarm_java_configuration();
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
@@ -718,6 +727,7 @@ impl Host {
|
||||
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
|
||||
self.local_data_dir.join("cache").join(&project_key).join("build"),
|
||||
)),
|
||||
kotlin_debug_notices: Mutex::default(),
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
|
||||
@@ -4157,6 +4157,35 @@ fn code_action_resolution_prompts_in_the_editor_window_under_the_command_budget(
|
||||
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_from_another_window_is_refused_while_the_plugin_waits_for_an_answer() {
|
||||
let (f, doc) = interactive_actions_fixture();
|
||||
let action = f
|
||||
.host
|
||||
.provide_code_actions(&doc, ide_core::Range::default())
|
||||
.into_iter()
|
||||
.find(|(_, action)| action.data.as_deref() == Some("pick"))
|
||||
.unwrap()
|
||||
.1;
|
||||
std::thread::scope(|scope| {
|
||||
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
|
||||
let (id, _) = action_prompt(&f);
|
||||
// Queued behind the open prompt it would not run until the prompt is answered.
|
||||
let other = Invocation { client: Some("other-window".into()), ..Default::default() };
|
||||
let err = f.host.run_command("misbehave", "anything", None, other).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::InvalidInput);
|
||||
assert!(err.message.contains("waiting for your answer in another window"), "{}", err.message);
|
||||
let events = f.take_events();
|
||||
assert!(
|
||||
events.iter().any(|(client, e)| client.as_deref() == Some("editor-window")
|
||||
&& matches!(e, PluginEvent::PromptWaiting { plugin_id } if plugin_id == "misbehave")),
|
||||
"the prompting window is brought forward: {events:?}"
|
||||
);
|
||||
assert!(f.host.answer_prompt(&id, Some(vec!["b".into()])));
|
||||
running.join().unwrap().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_action_prompts_dismiss_cancel_and_close_without_edits_or_hanging() {
|
||||
for (how, kind) in [
|
||||
|
||||
@@ -105,6 +105,89 @@ pub fn environment_file(file: &Path, root: Option<&Path>, private: bool) -> IdeR
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Where `{{name}}` is defined in the environment files that apply to `file`: the key `name`
|
||||
/// in each environment (and `$shared`) of the nearest private file, then of the nearest public
|
||||
/// file — the lookup order. Positions are 0-based lines and UTF-16 columns of the key, quotes
|
||||
/// included.
|
||||
pub fn variable_definitions(file: &Path, root: Option<&Path>, name: &str) -> Vec<ide_core::Location> {
|
||||
let dirs = search_dirs(Some(file), root);
|
||||
let mut out = Vec::new();
|
||||
for file_name in [PRIVATE_FILE, PUBLIC_FILE] {
|
||||
let Some(path) = dirs.iter().map(|d| d.join(file_name)).find(|p| p.is_file()) else { continue };
|
||||
let Ok(text) = ide_vfs::load_text(&path) else { continue };
|
||||
for range in environment_keys(&text.text, name) {
|
||||
out.push(ide_core::Location { path: path.clone().into(), range, uri: None });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The ranges of keys equal to `name` one level inside the top-level object (an
|
||||
/// environment's variables), found by scanning the JSON text without building a value.
|
||||
fn environment_keys(text: &str, name: &str) -> Vec<ide_core::Range> {
|
||||
use ide_core::{Pos, Range};
|
||||
let mut found = Vec::new();
|
||||
let (mut line, mut col) = (0u32, 0u32);
|
||||
let mut depth = 0usize;
|
||||
let mut chars = text.char_indices().peekable();
|
||||
// Whether the last string closed at depth 2 may be a key: set after `{` or `,`.
|
||||
let mut expect_key = false;
|
||||
while let Some((_, c)) = chars.next() {
|
||||
match c {
|
||||
'"' => {
|
||||
let start = Pos::new(line, col);
|
||||
col += 1;
|
||||
let mut value = String::new();
|
||||
let mut escaped = false;
|
||||
for (_, c) in chars.by_ref() {
|
||||
col += c.len_utf16() as u32;
|
||||
if escaped {
|
||||
value.push(match c {
|
||||
'n' => '\n',
|
||||
't' => '\t',
|
||||
other => other,
|
||||
});
|
||||
escaped = false;
|
||||
} else if c == '\\' {
|
||||
escaped = true;
|
||||
} else if c == '"' {
|
||||
break;
|
||||
} else {
|
||||
value.push(c);
|
||||
}
|
||||
}
|
||||
let end = Pos::new(line, col);
|
||||
if depth == 2 && expect_key && value == name {
|
||||
let mut rest = chars.clone();
|
||||
while rest.peek().is_some_and(|(_, c)| c.is_whitespace()) {
|
||||
rest.next();
|
||||
}
|
||||
if rest.peek().is_some_and(|(_, c)| *c == ':') {
|
||||
found.push(Range { start, end });
|
||||
}
|
||||
}
|
||||
expect_key = false;
|
||||
continue;
|
||||
}
|
||||
'{' | '[' => {
|
||||
depth += 1;
|
||||
expect_key = c == '{';
|
||||
}
|
||||
'}' | ']' => depth = depth.saturating_sub(1),
|
||||
',' => expect_key = true,
|
||||
':' => expect_key = false,
|
||||
'\n' => {
|
||||
line += 1;
|
||||
col = 0;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
col += c.len_utf16() as u32;
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
impl Environments {
|
||||
pub fn load(file: Option<&Path>, root: Option<&Path>) -> IdeResult<Self> {
|
||||
let dirs = search_dirs(file, root);
|
||||
@@ -221,6 +304,44 @@ impl Resolver<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn finds_variable_keys_in_every_environment_private_first() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("api")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(PUBLIC_FILE),
|
||||
"{\n \"$shared\": { \"host\": \"shared\" },\n \"dev\": {\n \"name\": \"host\",\n \"host\": \"localhost\",\n \"ssl\": { \"host\": \"nested\" }\n },\n \"prod\": { \"hostname\": \"x\", \"host\": \"example.com\" }\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("api").join(PRIVATE_FILE), "{ \"dev\": { \"😀\": 1, \"host\": \"secret\" } }")
|
||||
.unwrap();
|
||||
let file = root.join("api").join("requests.http");
|
||||
let found = variable_definitions(&file, Some(root), "host");
|
||||
let at: Vec<_> = found
|
||||
.iter()
|
||||
.map(|l| {
|
||||
(
|
||||
l.path.0.file_name().unwrap().to_string_lossy().into_owned(),
|
||||
l.range.start.line,
|
||||
l.range.start.col,
|
||||
l.range.end.col,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
at,
|
||||
vec![
|
||||
(PRIVATE_FILE.to_owned(), 0, 20, 26),
|
||||
(PUBLIC_FILE.to_owned(), 1, 15, 21),
|
||||
(PUBLIC_FILE.to_owned(), 4, 4, 10),
|
||||
(PUBLIC_FILE.to_owned(), 7, 29, 35),
|
||||
],
|
||||
"values and nested objects are not definitions"
|
||||
);
|
||||
assert!(variable_definitions(&file, Some(root), "missing").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_private_public_and_shared() {
|
||||
let envs = Environments::from_json(
|
||||
|
||||
@@ -398,6 +398,9 @@ impl Handler for ClientHandler {
|
||||
}
|
||||
}
|
||||
"tsserver/request" => mgr.forward_tsserver_requests(self.server.clone(), self.generation, params),
|
||||
"intellij/workspaceImportState" if self.server.name == crate::kotlin_lsp::KOTLIN_LSP => {
|
||||
crate::kotlin_lsp::record_import_state(&self.server, self.generation, ¶ms)
|
||||
}
|
||||
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
|
||||
mgr.java_import_ready(self.server.clone(), self.generation);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! kotlin-lsp's built-in debug adapter: `start_debug_server` once the server has imported
|
||||
//! the project (breakpoints set before that are "verified" but never hit).
|
||||
|
||||
use crate::LspManager;
|
||||
use crate::server::Server;
|
||||
use ide_core::event::ServerStatus;
|
||||
use ide_core::{CancellationToken, LanguageId};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
/// The server's name in `languages/kotlin.toml`.
|
||||
pub const KOTLIN_LSP: &str = "kotlin-lsp";
|
||||
/// A cold start imports the Gradle model before answering `initialize`.
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(240);
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// The last `intellij/workspaceImportState` of the current instance.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct ImportState {
|
||||
pub finished: bool,
|
||||
/// Workspace folders whose import did not succeed.
|
||||
pub failed: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct KotlinDebugServer {
|
||||
/// Local TCP port of the DAP server.
|
||||
pub port: u16,
|
||||
/// Root of the kotlin-lsp instance that serves it.
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
/// Why kotlin-lsp cannot debug the module, for the fallback notice.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum KotlinDebugUnavailable {
|
||||
/// No `kotlin-lsp` among the module's servers (disabled in settings, another server chosen).
|
||||
NotConfigured,
|
||||
NotInstalled,
|
||||
/// The status message, else the status.
|
||||
NotStarted(String),
|
||||
/// The import did not finish in time, or the wait was cancelled.
|
||||
StillImporting,
|
||||
ImportFailed,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Records `intellij/workspaceImportState` from instance `generation`. Completion is this
|
||||
/// notification, not "no `$/progress` left": kotlin-lsp reports "Indexing" every few seconds.
|
||||
pub(crate) fn record_import_state(server: &Server, generation: u64, params: &Value) {
|
||||
let failed = params["folders"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|folder| folder["status"] != "SUCCESS")
|
||||
.filter_map(|folder| folder["folderUri"].as_str().and_then(crate::uri::uri_to_path))
|
||||
.collect();
|
||||
server.set_import_state(generation, ImportState { finished: params["phase"] == "FINISHED", failed });
|
||||
}
|
||||
|
||||
fn importing(server: &Server) -> bool {
|
||||
server.progress.lock().values().any(|p| p.title.contains("Importing"))
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
/// Starts kotlin-lsp's debug server for `path` (a module directory or source file) once
|
||||
/// the project is imported, starting kotlin-lsp if needed. `import_wait` bounds the wait
|
||||
/// for the import after the server is up; cancelling `cancel` ends it as
|
||||
/// [`KotlinDebugUnavailable::StillImporting`].
|
||||
pub async fn kotlin_debug_server(
|
||||
&self,
|
||||
path: &Path,
|
||||
import_wait: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<KotlinDebugServer, KotlinDebugUnavailable> {
|
||||
let server = self
|
||||
.servers_for(&LanguageId::new("kotlin"), path)
|
||||
.into_iter()
|
||||
.find(|s| s.name == KOTLIN_LSP)
|
||||
.ok_or(KotlinDebugUnavailable::NotConfigured)?;
|
||||
self.ensure_started(&server);
|
||||
let ready = tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => return Err(KotlinDebugUnavailable::StillImporting),
|
||||
ready = server.wait_ready(READY_TIMEOUT) => ready,
|
||||
};
|
||||
if ready.is_none() {
|
||||
let status = server.status();
|
||||
return Err(match status {
|
||||
ServerStatus::Missing => KotlinDebugUnavailable::NotInstalled,
|
||||
ServerStatus::Misconfigured | ServerStatus::Crashed => {
|
||||
KotlinDebugUnavailable::NotStarted(server.status_message().unwrap_or_else(|| format!("{status:?}")))
|
||||
}
|
||||
_ => KotlinDebugUnavailable::NotStarted(format!("{status:?}")),
|
||||
});
|
||||
}
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => return Err(KotlinDebugUnavailable::StillImporting),
|
||||
imported = tokio::time::timeout(import_wait, wait_for_import(&server, path)) => {
|
||||
imported.unwrap_or(Err(KotlinDebugUnavailable::StillImporting))?
|
||||
}
|
||||
}
|
||||
// The instance that finished importing, also when it replaced the one waited for.
|
||||
let Some(run) = server.running() else {
|
||||
return Err(KotlinDebugUnavailable::NotStarted(format!("{:?}", server.status())));
|
||||
};
|
||||
let params =
|
||||
json!({ "command": "start_debug_server", "arguments": [crate::uri::path_to_uri_string(&server.root)] });
|
||||
let request = run.peer.request::<Value, Value>("workspace/executeCommand", params, Some(cancel.clone()));
|
||||
let port = match tokio::time::timeout(COMMAND_TIMEOUT, request).await {
|
||||
Ok(Ok(port)) => port,
|
||||
Ok(Err(e)) => return Err(KotlinDebugUnavailable::Failed(e.to_string())),
|
||||
Err(_) => {
|
||||
return Err(KotlinDebugUnavailable::Failed(format!(
|
||||
"start_debug_server timed out after {} s",
|
||||
COMMAND_TIMEOUT.as_secs()
|
||||
)));
|
||||
}
|
||||
};
|
||||
match port.as_u64().and_then(|p| u16::try_from(p).ok()).filter(|p| *p != 0) {
|
||||
Some(port) => Ok(KotlinDebugServer { port, root: server.root.clone() }),
|
||||
None => Err(KotlinDebugUnavailable::Failed(format!("unexpected debug server port: {port}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until the current instance reports its import finished and its "Importing" task
|
||||
/// ended, which arrive in either order.
|
||||
async fn wait_for_import(server: &Server, path: &Path) -> Result<(), KotlinDebugUnavailable> {
|
||||
let mut state = server.import_state.subscribe();
|
||||
loop {
|
||||
// Created before the check so a progress end in between still wakes us.
|
||||
let progress_ended = server.phase_notified();
|
||||
let current = state.borrow_and_update().clone();
|
||||
if let Some(current) = current
|
||||
&& current.finished
|
||||
&& !importing(server)
|
||||
{
|
||||
if current.failed.iter().any(|dir| canonical(path).starts_with(canonical(dir))) {
|
||||
return Err(KotlinDebugUnavailable::ImportFailed);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
tokio::select! {
|
||||
_ = state.changed() => {}
|
||||
() = progress_ended => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` with its deepest existing ancestor resolved, so a symlinked root (or `/tmp` vs
|
||||
/// `/private/tmp` on macOS) and the folder URIs kotlin-lsp reports name one directory alike.
|
||||
fn canonical(path: &Path) -> PathBuf {
|
||||
for ancestor in path.ancestors() {
|
||||
if let Ok(real) = dunce::canonicalize(ancestor) {
|
||||
let rest = path.strip_prefix(ancestor).unwrap_or(Path::new(""));
|
||||
return if rest.as_os_str().is_empty() { real } else { real.join(rest) };
|
||||
}
|
||||
}
|
||||
path.to_path_buf()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::{IdeEvent, SharedSink};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn an_instance_that_was_replaced_does_not_record_its_import() {
|
||||
let cfg = toml::from_str("name = \"kotlin-lsp\"\ncommand = \"kotlin-lsp\"").unwrap();
|
||||
let server = Server::new(cfg, PathBuf::from("/p"), LanguageId::new("kotlin"));
|
||||
let finished = json!({ "phase": "FINISHED", "folders": [
|
||||
{ "folderUri": "file:///p/", "status": "SUCCESS" },
|
||||
{ "folderUri": "file:///p/lib/", "status": "FAILED" },
|
||||
]});
|
||||
let old = server.begin_start().unwrap();
|
||||
record_import_state(&server, old, &json!({ "phase": "IN_PROGRESS", "folders": [] }));
|
||||
assert_eq!(*server.import_state.borrow(), Some(ImportState { finished: false, failed: vec![] }));
|
||||
record_import_state(&server, old, &finished);
|
||||
let done = Some(ImportState { finished: true, failed: vec![PathBuf::from("/p/lib")] });
|
||||
assert_eq!(*server.import_state.borrow(), done);
|
||||
|
||||
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
server.begin_stop(&sink, false);
|
||||
let new = server.begin_start().unwrap();
|
||||
assert_eq!(*server.import_state.borrow(), None);
|
||||
// The dying instance's FINISHED arrives after the restart.
|
||||
record_import_state(&server, old, &finished);
|
||||
assert_eq!(*server.import_state.borrow(), None);
|
||||
record_import_state(&server, new, &finished);
|
||||
assert_eq!(*server.import_state.borrow(), done);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ mod handler;
|
||||
mod injections;
|
||||
mod inspect;
|
||||
pub mod jdtls;
|
||||
mod kotlin_lsp;
|
||||
mod launch;
|
||||
mod library_source;
|
||||
mod loading;
|
||||
@@ -38,6 +39,7 @@ mod vue_navigation;
|
||||
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
|
||||
pub use inspect::{FileInspection, InspectHost, InspectOptions};
|
||||
pub use jdtls::jvm_inspect::JdtJvmInspector;
|
||||
pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
|
||||
pub use launch::{LaunchError, Launcher, ProcessLauncher};
|
||||
pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES};
|
||||
pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path};
|
||||
@@ -317,6 +319,10 @@ impl<T> Outcome<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How often a rename the server withdrew (`ContentModified`) is asked, and the pause between.
|
||||
const RENAME_ATTEMPTS: u32 = 5;
|
||||
const RENAME_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(300);
|
||||
|
||||
/// Implementation requests of one line-marker pass in flight at once.
|
||||
const IMPLEMENTATION_CONCURRENCY: usize = 4;
|
||||
|
||||
@@ -2374,8 +2380,25 @@ impl LspManager {
|
||||
let query = || async {
|
||||
let mut params = snap.pos_params(t, pos);
|
||||
params["newName"] = json!(new_name);
|
||||
let r: Option<lsp::WorkspaceEdit> =
|
||||
self.call(t, "textDocument/rename", params, Some(cancel), LONG_TIMEOUT).await?;
|
||||
// A server still analysing an edit withdraws the rename (`ContentModified`, which
|
||||
// arrives as a cancellation). The user asked for it: ask again shortly instead of
|
||||
// dropping it without a word, and say why if it keeps being withdrawn.
|
||||
let mut attempt = 0;
|
||||
let r: Option<lsp::WorkspaceEdit> = loop {
|
||||
match self.call(t, "textDocument/rename", params.clone(), Some(cancel), LONG_TIMEOUT).await {
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled && !cancel.is_cancelled() => {
|
||||
attempt += 1;
|
||||
if attempt >= RENAME_ATTEMPTS {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} was still analysing a change and withdrew the rename; try again",
|
||||
t.server.name
|
||||
)));
|
||||
}
|
||||
tokio::time::sleep(RENAME_RETRY_DELAY).await;
|
||||
}
|
||||
r => break r?,
|
||||
}
|
||||
};
|
||||
Ok(r.map(|e| convert::workspace_edit(&self.conv(t.enc()), e)).unwrap_or_default())
|
||||
};
|
||||
// The Angular server renames in the file's own project only, whatever is open.
|
||||
|
||||
@@ -148,6 +148,8 @@ pub struct Server {
|
||||
/// The instance whose just registered external project is loading, until an open file's
|
||||
/// diagnostics arrive (0: none).
|
||||
pub external_loading: std::sync::atomic::AtomicU64,
|
||||
/// kotlin-lsp's project import as the current instance last reported it.
|
||||
pub(crate) import_state: watch::Sender<Option<crate::kotlin_lsp::ImportState>>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
@@ -193,6 +195,7 @@ impl Server {
|
||||
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
|
||||
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
|
||||
external_loading: std::sync::atomic::AtomicU64::new(0),
|
||||
import_state: watch::channel(None).0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,10 +252,20 @@ impl Server {
|
||||
*self.started.lock() = Some(Instant::now());
|
||||
// A crash report quotes only this instance's output, not its predecessors'.
|
||||
self.stderr.lock().clear();
|
||||
self.import_state.send_replace(None);
|
||||
self.phase.send_replace(Phase::Starting);
|
||||
Some(st.generation)
|
||||
}
|
||||
|
||||
/// Publishes instance `generation`'s import state unless a newer start replaced it; checked
|
||||
/// under the state lock so it cannot land after [`Self::begin_start`]'s reset.
|
||||
pub(crate) fn set_import_state(&self, generation: u64, state: crate::kotlin_lsp::ImportState) {
|
||||
let st = self.state.lock();
|
||||
if st.generation == generation {
|
||||
self.import_state.send_replace(Some(state));
|
||||
}
|
||||
}
|
||||
|
||||
/// Starting → Missing/Failed, publishing the status the failed start reports
|
||||
/// before waiters wake: a request that wakes on `Phase::Down` reads the status
|
||||
/// to say why it has no server, and must not still see `Starting`.
|
||||
|
||||
@@ -7,7 +7,7 @@ use ide_core::{
|
||||
use ide_jsonrpc::{Handler, NoopHandler, Peer};
|
||||
use ide_language::{LanguageConfig, LanguageRegistry, LanguageServerConfig};
|
||||
use ide_lsp::types::*;
|
||||
use ide_lsp::{LaunchError, Launcher, LspManager};
|
||||
use ide_lsp::{KotlinDebugServer, KotlinDebugUnavailable, LaunchError, Launcher, LspManager};
|
||||
use ide_text::AppliedChange;
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{Value, json};
|
||||
@@ -3211,6 +3211,33 @@ async fn rename_merges_the_servers_that_can_rename_there() {
|
||||
assert_eq!(edits_in(&edit, &html), [(rng(0, 3, 0, 8), "heading".into())]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_rename_the_server_withdraws_while_analysing_is_asked_again_then_explained() {
|
||||
let h = harness(&[("dart", incremental_caps())]);
|
||||
let (doc, path) = h.open("a.mk", "count = 1\n");
|
||||
h.wait_status("dart", ServerStatus::Ready).await;
|
||||
let dart = h.launcher.state("dart");
|
||||
let r = json!({"start":{"line":0,"character":0},"end":{"line":0,"character":5}});
|
||||
dart.respond("textDocument/rename", json!({ "changes": { uri_of(&path): [{ "range": r, "newText": "total" }] } }));
|
||||
// Withdrawn while the server analyses an edit, then answered.
|
||||
dart.errors.lock().insert("textDocument/rename".into(), (-32801, "Document was modified".into()));
|
||||
let renaming = {
|
||||
let mgr = h.mgr.clone();
|
||||
tokio::spawn(async move { mgr.rename(doc, Pos::new(0, 1), "total".into(), Default::default()).await })
|
||||
};
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
dart.errors.lock().clear();
|
||||
let edit = renaming.await.unwrap().unwrap().edit;
|
||||
assert_eq!(edits_in(&edit, &path), [(rng(0, 0, 0, 5), "total".into())]);
|
||||
assert!(dart.requested("textDocument/rename").len() >= 2);
|
||||
|
||||
// Withdrawn every time: an error that says so, not a silent cancellation.
|
||||
dart.errors.lock().insert("textDocument/rename".into(), (-32801, "Document was modified".into()));
|
||||
let err = h.mgr.rename(doc, Pos::new(0, 1), "total".into(), Default::default()).await.unwrap_err();
|
||||
assert_ne!(err.kind, ide_core::error::ErrorKind::Cancelled);
|
||||
assert!(err.message.contains("try again"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn angular_server_is_asked_about_typescript_only_in_component_templates() {
|
||||
let servers = [("vtsls", incremental_caps()), ("angular-language-server", incremental_caps())];
|
||||
@@ -5963,6 +5990,208 @@ async fn hierarchy_refactorings_name_the_servers_without_them() {
|
||||
assert_eq!(e.message, "plain doesn't support Pull Members Up");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ kotlin-lsp debug server
|
||||
|
||||
type KotlinDebug = Result<KotlinDebugServer, KotlinDebugUnavailable>;
|
||||
|
||||
fn kotlin_lsp_harness() -> (Harness, Arc<MockState>) {
|
||||
let h = harness_for("kotlin", "kt", &[("kotlin-lsp", incremental_caps())], &[]);
|
||||
let s = h.launcher.state("kotlin-lsp");
|
||||
s.respond("workspace/executeCommand", json!(4711));
|
||||
(h, s)
|
||||
}
|
||||
|
||||
fn spawn_kotlin_debug_server(
|
||||
h: &Harness,
|
||||
path: &Path,
|
||||
import_wait: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> tokio::task::JoinHandle<KotlinDebug> {
|
||||
let (mgr, path, cancel) = (h.mgr.clone(), path.to_owned(), cancel.clone());
|
||||
tokio::spawn(async move { mgr.kotlin_debug_server(&path, import_wait, &cancel).await })
|
||||
}
|
||||
|
||||
async fn joined(debug: tokio::task::JoinHandle<KotlinDebug>) -> KotlinDebug {
|
||||
tokio::time::timeout(Duration::from_secs(10), debug).await.expect("kotlin_debug_server returned").unwrap()
|
||||
}
|
||||
|
||||
fn import_progress(s: &MockState, value: Value) {
|
||||
s.peer().notify("$/progress", json!({ "token": "import", "value": value })).unwrap();
|
||||
}
|
||||
|
||||
fn import_finished(s: &MockState, folders: &[(&Path, &str)]) {
|
||||
let folders: Vec<Value> = folders
|
||||
.iter()
|
||||
.map(|(dir, status)| json!({ "folderUri": format!("{}/", uri_of(dir)), "tool": "gradle", "status": status }))
|
||||
.collect();
|
||||
s.peer().notify("intellij/workspaceImportState", json!({ "phase": "FINISHED", "folders": folders })).unwrap();
|
||||
}
|
||||
|
||||
fn debug_server_requests(s: &MockState) -> Vec<Value> {
|
||||
s.requested("workspace/executeCommand")
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn waits_for_the_import_before_starting_the_debug_server() {
|
||||
// kotlin-lsp sends the progress end and FINISHED within the same millisecond, in either order.
|
||||
for finished_first in [false, true] {
|
||||
let (h, s) = kotlin_lsp_harness();
|
||||
let main = h.root.join("Main.kt");
|
||||
std::fs::write(&main, "fun main() {}").unwrap();
|
||||
let debug = spawn_kotlin_debug_server(&h, &main, Duration::from_secs(10), &CancellationToken::new());
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
|
||||
// Repeats every few seconds while the project stays usable: never waited for.
|
||||
s.peer()
|
||||
.notify("$/progress", json!({ "token": "index", "value": { "kind": "begin", "title": "Indexing" } }))
|
||||
.unwrap();
|
||||
import_progress(&s, json!({ "kind": "begin", "title": "Importing" }));
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Busy).await;
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert!(debug_server_requests(&s).is_empty(), "started while importing");
|
||||
|
||||
if finished_first {
|
||||
import_finished(&s, &[(&h.root, "SUCCESS")]);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert!(debug_server_requests(&s).is_empty(), "started while the Importing task runs");
|
||||
import_progress(&s, json!({ "kind": "end" }));
|
||||
} else {
|
||||
import_progress(&s, json!({ "kind": "end" }));
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert!(debug_server_requests(&s).is_empty(), "started before FINISHED");
|
||||
import_finished(&s, &[(&h.root, "SUCCESS")]);
|
||||
}
|
||||
|
||||
assert_eq!(joined(debug).await, Ok(KotlinDebugServer { port: 4711, root: h.root.clone() }));
|
||||
assert_eq!(
|
||||
debug_server_requests(&s),
|
||||
vec![json!({ "command": "start_debug_server", "arguments": [uri_of(&h.root)] })],
|
||||
"finished_first: {finished_first}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn still_importing_after_the_wait_is_reported() {
|
||||
let (h, s) = kotlin_lsp_harness();
|
||||
let r = h.mgr.kotlin_debug_server(&h.root, Duration::from_millis(200), &CancellationToken::new()).await;
|
||||
assert_eq!(r, Err(KotlinDebugUnavailable::StillImporting));
|
||||
assert!(debug_server_requests(&s).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cancelling_the_import_wait_reports_still_importing() {
|
||||
let (h, s) = kotlin_lsp_harness();
|
||||
let cancel = CancellationToken::new();
|
||||
let debug = spawn_kotlin_debug_server(&h, &h.root, Duration::from_secs(60), &cancel);
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert!(!debug.is_finished());
|
||||
cancel.cancel();
|
||||
assert_eq!(joined(debug).await, Err(KotlinDebugUnavailable::StillImporting));
|
||||
assert!(debug_server_requests(&s).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_failed_import_of_the_module_is_reported() {
|
||||
// Another module that failed does not keep this one from debugging.
|
||||
for other_module_failed in [false, true] {
|
||||
let (h, s) = kotlin_lsp_harness();
|
||||
let main = h.root.join("app/Main.kt");
|
||||
let debug = spawn_kotlin_debug_server(&h, &main, Duration::from_secs(10), &CancellationToken::new());
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
|
||||
let lib = h.root.join("lib");
|
||||
let (folders, expected) = if other_module_failed {
|
||||
(
|
||||
vec![(h.root.as_path(), "SUCCESS"), (lib.as_path(), "FAILED")],
|
||||
Ok(KotlinDebugServer { port: 4711, root: h.root.clone() }),
|
||||
)
|
||||
} else {
|
||||
(vec![(h.root.as_path(), "FAILED")], Err(KotlinDebugUnavailable::ImportFailed))
|
||||
};
|
||||
import_finished(&s, &folders);
|
||||
assert_eq!(joined(debug).await, expected, "other_module_failed: {other_module_failed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_failed_import_is_reported_through_a_symlinked_root() {
|
||||
// The debugged path names the root through a symlink, the folder URI names its target,
|
||||
// and the file does not exist yet.
|
||||
let (h, s) = kotlin_lsp_harness();
|
||||
let links = tempfile::tempdir().unwrap();
|
||||
let link = links.path().join("project");
|
||||
std::os::unix::fs::symlink(&h.root, &link).unwrap();
|
||||
let debug =
|
||||
spawn_kotlin_debug_server(&h, &link.join("app/Main.kt"), Duration::from_secs(10), &CancellationToken::new());
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
|
||||
import_finished(&s, &[(&h.root, "FAILED")]);
|
||||
assert_eq!(joined(debug).await, Err(KotlinDebugUnavailable::ImportFailed));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_older_finished_import_does_not_count_after_a_restart() {
|
||||
let (h, s) = kotlin_lsp_harness();
|
||||
let debug = spawn_kotlin_debug_server(&h, &h.root, Duration::from_secs(10), &CancellationToken::new());
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
|
||||
import_finished(&s, &[(&h.root, "SUCCESS")]);
|
||||
assert!(joined(debug).await.is_ok());
|
||||
|
||||
let id = h.mgr.servers()[0].id;
|
||||
h.mgr.restart(id).await.unwrap();
|
||||
let r = h.mgr.kotlin_debug_server(&h.root, Duration::from_millis(300), &CancellationToken::new()).await;
|
||||
assert_eq!(r, Err(KotlinDebugUnavailable::StillImporting));
|
||||
assert_eq!(debug_server_requests(&s).len(), 1);
|
||||
|
||||
let debug = spawn_kotlin_debug_server(&h, &h.root, Duration::from_secs(10), &CancellationToken::new());
|
||||
import_finished(&s, &[(&h.root, "SUCCESS")]);
|
||||
assert_eq!(joined(debug).await, Ok(KotlinDebugServer { port: 4711, root: h.root.clone() }));
|
||||
assert_eq!(debug_server_requests(&s).len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn without_kotlin_lsp_the_reason_is_not_configured() {
|
||||
let wait = Duration::from_secs(1);
|
||||
let cancel = CancellationToken::new();
|
||||
let h = harness_for("kotlin", "kt", &[("kotlin-language-server", incremental_caps())], &[]);
|
||||
assert_eq!(h.mgr.kotlin_debug_server(&h.root, wait, &cancel).await, Err(KotlinDebugUnavailable::NotConfigured));
|
||||
|
||||
let (h, s) = kotlin_lsp_harness();
|
||||
s.missing.store(true, Ordering::SeqCst);
|
||||
assert_eq!(h.mgr.kotlin_debug_server(&h.root, wait, &cancel).await, Err(KotlinDebugUnavailable::NotInstalled));
|
||||
|
||||
let (h, _) = kotlin_lsp_harness();
|
||||
h.mgr.set_config_hook(Box::new(|cfg, _| cfg.launch_error = Some("needs JDK 21".into())));
|
||||
assert_eq!(
|
||||
h.mgr.kotlin_debug_server(&h.root, wait, &cancel).await,
|
||||
Err(KotlinDebugUnavailable::NotStarted("needs JDK 21".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_debug_server_error_is_reported() {
|
||||
let (h, s) = kotlin_lsp_harness();
|
||||
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "boom".into()));
|
||||
let debug = spawn_kotlin_debug_server(&h, &h.root, Duration::from_secs(10), &CancellationToken::new());
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
|
||||
import_finished(&s, &[(&h.root, "SUCCESS")]);
|
||||
match joined(debug).await {
|
||||
Err(KotlinDebugUnavailable::Failed(m)) => assert!(m.contains("boom"), "{m}"),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
|
||||
s.errors.lock().clear();
|
||||
for port in [json!(0), json!(65536), json!("4711"), Value::Null] {
|
||||
s.respond("workspace/executeCommand", port.clone());
|
||||
match h.mgr.kotlin_debug_server(&h.root, Duration::from_secs(1), &CancellationToken::new()).await {
|
||||
Err(KotlinDebugUnavailable::Failed(m)) => {
|
||||
assert_eq!(m, format!("unexpected debug server port: {port}"))
|
||||
}
|
||||
other => panic!("{port}: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn injected_documents_route_and_resolve_without_exposing_virtual_files() {
|
||||
for encoding in ["utf-16", "utf-8"] {
|
||||
@@ -6151,3 +6380,69 @@ async fn missing_injected_server_uses_the_normal_single_install_notification() {
|
||||
h.mgr.did_close(host);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
/// #2248: `go.mod` files are served by gopls with the language id it
|
||||
/// understands, and its pushed diagnostics reach the Problems view.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn gomod_files_are_served_by_gopls() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(dir.path()).unwrap();
|
||||
let registry = Arc::new(LanguageRegistry::load(None).unwrap());
|
||||
// The bundled configs route Go files of every kind to gopls.
|
||||
for lang in ["go", "go.mod", "go.work"] {
|
||||
let cfg = registry.get(&LanguageId::new(lang)).unwrap_or_else(|| panic!("{lang} has no language definition"));
|
||||
assert!(cfg.language_servers.iter().any(|s| s.name == "gopls"), "{lang} is not served by gopls");
|
||||
}
|
||||
let launcher = Arc::new(MockLauncher::default());
|
||||
*launcher.state("gopls").caps.lock() = incremental_caps();
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = {
|
||||
let events = events.clone();
|
||||
Arc::new(move |e: IdeEvent| events.lock().push(e))
|
||||
};
|
||||
let mgr = LspManager::with_launcher(root.clone(), registry, sink, launcher.clone());
|
||||
let text = "module example.com/app\n\ngo 1.22\n";
|
||||
let path = root.join("go.mod");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
assert!(mgr.serves_path(&path));
|
||||
mgr.did_open(DocId::next(), &path, &LanguageId::new("go.mod"), 1, text);
|
||||
let gopls = launcher.state("gopls");
|
||||
eventually(|| !gopls.notified("textDocument/didOpen").is_empty()).await;
|
||||
// gopls only treats the file as a module file for the `go.mod` id.
|
||||
let opened = &gopls.notified("textDocument/didOpen")[0]["textDocument"];
|
||||
assert_eq!(opened["languageId"], json!("go.mod"));
|
||||
// The module root (the go.mod itself is the marker) scopes the server.
|
||||
let init = &gopls.requested("initialize")[0];
|
||||
assert_eq!(init["rootUri"], json!(uri_of(&root)));
|
||||
// Same startup options as when a `.go` file launches the server first.
|
||||
assert_eq!(init["initializationOptions"]["semanticTokens"], json!(true));
|
||||
// Pushed go.mod diagnostics surface with gopls as their source.
|
||||
gopls
|
||||
.peer()
|
||||
.notify(
|
||||
"textDocument/publishDiagnostics",
|
||||
json!({ "uri": uri_of(&path), "diagnostics": [{
|
||||
"range": {"start": {"line": 2, "character": 3}, "end": {"line": 2, "character": 7}},
|
||||
"severity": 1, "message": "missing requirement", "source": "gopls" }] }),
|
||||
)
|
||||
.unwrap();
|
||||
eventually(|| {
|
||||
events
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|e| matches!(e, IdeEvent::Diagnostics { path: p, source, .. } if p.0 == path && source == "gopls"))
|
||||
})
|
||||
.await;
|
||||
let items = events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Diagnostics { path: p, items, .. } if p.0 == path => Some(items.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.next_back()
|
||||
.unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].message, "missing requirement");
|
||||
mgr.shutdown_all().await;
|
||||
}
|
||||
@@ -863,6 +863,48 @@ fn module_dir(root: &Path, module: Option<&Path>) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// The module directory a Tomcat config's WAR belongs to. Created configs carry it as `cwd`
|
||||
/// (set from the selected artifact); when it is missing, the selected artifact id still names
|
||||
/// the module — `maven:<pom>` its POM, `gradle:…` its build — instead of falling back to the
|
||||
/// project root, whose own `src` rarely exists and would make every launch look current.
|
||||
fn artifact_module_dir(root: &Path, config: &RunConfig) -> PathBuf {
|
||||
if config.cwd.is_some() {
|
||||
return module_dir(root, config.cwd.as_ref().map(|c| c.0.as_path()));
|
||||
}
|
||||
let selected = config.extras.params.get("artifact").filter(|s| !s.is_empty());
|
||||
if let Some(id) = selected {
|
||||
if let Some(pom) = id.strip_prefix("maven:") {
|
||||
// `maven:pom.xml` is the root module itself.
|
||||
return match Path::new(pom).parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
Some(dir) => root.join(dir),
|
||||
None => root.to_path_buf(),
|
||||
};
|
||||
}
|
||||
if let Some(rest) = id.strip_prefix("gradle:") {
|
||||
// `gradle:<project path>` for the root build, else `gradle:["<build dir>", "<path>"]`.
|
||||
if let Ok((dir, path)) = serde_json::from_str::<(String, String)>(rest) {
|
||||
return root.join(gradle_module_dir(&dir, &path));
|
||||
}
|
||||
if !rest.trim_matches(':').is_empty() {
|
||||
return root.join(gradle_module_dir("", rest));
|
||||
}
|
||||
}
|
||||
}
|
||||
root.to_path_buf()
|
||||
}
|
||||
|
||||
/// Maps a Gradle project path (`:` for the build itself, `:web` for a subproject) below its
|
||||
/// build directory. Custom `projectDir` mappings are invisible here, so this is the
|
||||
/// conventional layout; still closer than the project root.
|
||||
fn gradle_module_dir(dir: &str, path: &str) -> PathBuf {
|
||||
let mut module = PathBuf::from(dir);
|
||||
let rel = path.trim_start_matches(':').replace(':', "/");
|
||||
if !rel.is_empty() {
|
||||
module.push(rel);
|
||||
}
|
||||
module
|
||||
}
|
||||
|
||||
/// The newest modification time of `path` or, for a directory, anything below it.
|
||||
fn newest_mtime_under(path: &Path) -> Option<std::time::SystemTime> {
|
||||
let meta = fs::metadata(path).ok()?;
|
||||
@@ -902,14 +944,16 @@ fn find_built_war(root: &Path, module: Option<&Path>) -> Option<PathBuf> {
|
||||
|
||||
/// Whether the WAR a Tomcat config deploys is newer than the module's sources, so its
|
||||
/// "Before launch › Build" task can be skipped (IDEA's up-to-date check). Conservative:
|
||||
/// a missing WAR or a newer source/build file means it is not current. Only the module's
|
||||
/// own sources and build files are inspected, not dependency modules.
|
||||
pub fn artifact_is_current(root: &Path, base: &Path, config: &RunConfig) -> bool {
|
||||
/// a missing WAR or a newer source/build file means it is not current. Besides the WAR
|
||||
/// module's sources, web resources and build files, the sources and build files of every
|
||||
/// module in `modules` (project-relative build modules) are inspected: a mapper XML or
|
||||
/// class in a module the WAR depends on is packaged into it as a JAR.
|
||||
pub fn artifact_is_current(root: &Path, base: &Path, config: &RunConfig, modules: &[PathBuf]) -> bool {
|
||||
let p = &config.extras.params;
|
||||
if p.get("deployment").map(String::as_str).unwrap_or("artifact") != "artifact" {
|
||||
return true;
|
||||
}
|
||||
let module = module_dir(root, config.cwd.as_ref().map(|c| c.0.as_path()));
|
||||
let module = artifact_module_dir(root, config);
|
||||
let selected = p.get("artifact").filter(|s| !s.is_empty());
|
||||
let recorded_id = fs::read_to_string(base.join("artifact-id.txt")).ok();
|
||||
let id_matches = selected.is_none_or(|id| recorded_id.as_deref().map(str::trim) == Some(id.as_str()));
|
||||
@@ -923,7 +967,24 @@ pub fn artifact_is_current(root: &Path, base: &Path, config: &RunConfig) -> bool
|
||||
};
|
||||
let Ok(war_time) = fs::metadata(&war).and_then(|m| m.modified()) else { return false };
|
||||
let mut newest = newest_mtime_under(&module.join("src"));
|
||||
for dir in [module.as_path(), root] {
|
||||
// Web content packaged from outside `src` (a custom `warSourceDirectory`, `webResources`
|
||||
// directories, a custom Gradle web app dir) also lands in the WAR — the running-session
|
||||
// sync copies it from these same folders — so a save there must rebuild too (#2685).
|
||||
for (dir, _) in web_sources(&module) {
|
||||
if let Some(time) = newest_mtime_under(&dir)
|
||||
&& newest.is_none_or(|cur| time > cur)
|
||||
{
|
||||
newest = Some(time);
|
||||
}
|
||||
}
|
||||
let other_modules = modules.iter().map(|m| root.join(m));
|
||||
for dir in [module.clone(), root.to_path_buf()].into_iter().chain(other_modules) {
|
||||
if dir != module
|
||||
&& let Some(time) = newest_mtime_under(&dir.join("src"))
|
||||
&& newest.is_none_or(|cur| time > cur)
|
||||
{
|
||||
newest = Some(time);
|
||||
}
|
||||
for name in ["pom.xml", "build.gradle", "build.gradle.kts"] {
|
||||
if let Ok(time) = fs::metadata(dir.join(name)).and_then(|m| m.modified())
|
||||
&& newest.is_none_or(|cur| time > cur)
|
||||
@@ -946,6 +1007,7 @@ pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only:
|
||||
let recorded_id = fs::read_to_string(base.join("artifact-id.txt")).ok();
|
||||
let id_matches = selected.is_none_or(|id| recorded_id.as_deref().map(str::trim) == Some(id.as_str()));
|
||||
let recorded = fs::read_to_string(base.join("artifact-path.txt")).ok().map(|s| PathBuf::from(s.trim()));
|
||||
let module = artifact_module_dir(root, config);
|
||||
let war = match recorded.filter(|_| id_matches) {
|
||||
Some(war) => {
|
||||
if war.is_file() && is_war(&war) {
|
||||
@@ -954,7 +1016,7 @@ pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only:
|
||||
return Err(invalid(format!("The built WAR artifact does not exist: {}", war.display())));
|
||||
}
|
||||
}
|
||||
None => find_built_war(root, config.cwd.as_ref().map(|c| c.0.as_path())).ok_or_else(|| {
|
||||
None => find_built_war(root, Some(module.as_path())).ok_or_else(|| {
|
||||
invalid("No built WAR artifact found - build the project first, or add 'Build' to Before launch")
|
||||
})?,
|
||||
};
|
||||
@@ -1592,10 +1654,119 @@ mod tests {
|
||||
c.cwd = Some(ide_core::IpcPath(PathBuf::from("web")));
|
||||
c.extras.params.insert("artifact".into(), "maven:web/pom.xml".into());
|
||||
// The WAR was packed after the source was written.
|
||||
assert!(artifact_is_current(root, &base, &c));
|
||||
assert!(artifact_is_current(root, &base, &c, &[]));
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
fs::write(&source, "class App { int changed; }").unwrap();
|
||||
assert!(!artifact_is_current(root, &base, &c));
|
||||
assert!(!artifact_is_current(root, &base, &c, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_to_date_check_follows_the_selected_artifact_when_cwd_is_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let module = root.join("web");
|
||||
fs::create_dir_all(module.join("src/main/resources")).unwrap();
|
||||
fs::create_dir_all(module.join("target")).unwrap();
|
||||
fs::write(root.join("pom.xml"), "<project/>").unwrap();
|
||||
fs::write(module.join("pom.xml"), "<project/>").unwrap();
|
||||
let source = module.join("src/main/resources/applicationContext.xml");
|
||||
fs::write(&source, "<beans/>").unwrap();
|
||||
let war = module.join("target/app.war");
|
||||
archive(&war, &[("WEB-INF/web.xml", "<web-app/>")]);
|
||||
let base = root.join("cache");
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
// A previous IDE build recorded its archive, as `ide_build::web::build` leaves behind.
|
||||
fs::write(base.join("artifact-id.txt"), "maven:web/pom.xml").unwrap();
|
||||
fs::write(base.join("artifact-path.txt"), war.to_string_lossy().as_bytes()).unwrap();
|
||||
// Older configs may not carry the artifact's module as `cwd` (the UI sets it today).
|
||||
let mut c = config();
|
||||
c.extras.params.insert("artifact".into(), "maven:web/pom.xml".into());
|
||||
assert!(artifact_is_current(root, &base, &c, &[]));
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
fs::write(&source, "<beans><edited/></beans>").unwrap();
|
||||
assert!(!artifact_is_current(root, &base, &c, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_to_date_check_watches_web_resources_outside_src() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let module = root.join("web");
|
||||
fs::create_dir_all(module.join("src/main/java")).unwrap();
|
||||
fs::create_dir_all(module.join("target")).unwrap();
|
||||
fs::create_dir_all(module.join("WebContent/WEB-INF")).unwrap();
|
||||
fs::write(
|
||||
module.join("pom.xml"),
|
||||
"<project><plugin><artifactId>maven-war-plugin</artifactId><configuration><webResources>\
|
||||
<resource><directory>WebContent</directory></resource></webResources></configuration></plugin></project>",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(module.join("src/main/java/App.java"), "class App {}").unwrap();
|
||||
let web_xml = module.join("WebContent/WEB-INF/web.xml");
|
||||
fs::write(&web_xml, "<web-app/>").unwrap();
|
||||
let war = module.join("target/app.war");
|
||||
archive(&war, &[("WEB-INF/web.xml", "<web-app/>"), ("index.html", "built")]);
|
||||
let base = root.join("cache");
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
let mut c = config();
|
||||
c.cwd = Some(ide_core::IpcPath(PathBuf::from("web")));
|
||||
c.extras.params.insert("artifact".into(), "maven:web/pom.xml".into());
|
||||
assert!(artifact_is_current(root, &base, &c, &[]));
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
fs::write(&web_xml, "<web-app><edited/></web-app>").unwrap();
|
||||
assert!(!artifact_is_current(root, &base, &c, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_to_date_check_watches_the_modules_the_war_depends_on() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let web = root.join("web");
|
||||
let dao = root.join("dao");
|
||||
fs::create_dir_all(web.join("src/main/java")).unwrap();
|
||||
fs::create_dir_all(web.join("target")).unwrap();
|
||||
fs::create_dir_all(dao.join("src/main/resources/mapper")).unwrap();
|
||||
fs::write(web.join("pom.xml"), "<project/>").unwrap();
|
||||
fs::write(dao.join("pom.xml"), "<project/>").unwrap();
|
||||
fs::write(web.join("src/main/java/App.java"), "class App {}").unwrap();
|
||||
let mapper = dao.join("src/main/resources/mapper/UserMapper.xml");
|
||||
fs::write(&mapper, "<mapper/>").unwrap();
|
||||
archive(&web.join("target/app.war"), &[("WEB-INF/web.xml", "<web-app/>")]);
|
||||
let base = root.join("cache");
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
let mut c = config();
|
||||
c.cwd = Some(ide_core::IpcPath(PathBuf::from("web")));
|
||||
c.extras.params.insert("artifact".into(), "maven:web/pom.xml".into());
|
||||
let modules = [PathBuf::from(""), PathBuf::from("web"), PathBuf::from("dao")];
|
||||
assert!(artifact_is_current(root, &base, &c, &modules));
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
fs::write(&mapper, "<mapper><select/></mapper>").unwrap();
|
||||
assert!(!artifact_is_current(root, &base, &c, &modules), "a dependency module's XML changed");
|
||||
assert!(artifact_is_current(root, &base, &c, &[]), "without the build's modules only the WAR module counts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_module_prefers_cwd_then_selected_artifact() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let module_of = |cwd: Option<&str>, artifact: Option<&str>| {
|
||||
let mut c = config();
|
||||
c.cwd = cwd.map(|d| ide_core::IpcPath(PathBuf::from(d)));
|
||||
if let Some(id) = artifact {
|
||||
c.extras.params.insert("artifact".into(), id.into());
|
||||
}
|
||||
artifact_module_dir(root, &c)
|
||||
};
|
||||
// The UI keeps `cwd` on the artifact's module; it wins over a stale selection.
|
||||
assert_eq!(module_of(Some("web"), Some("maven:other/pom.xml")), root.join("web"));
|
||||
assert_eq!(module_of(None, Some("maven:web/pom.xml")), root.join("web"));
|
||||
assert_eq!(module_of(None, Some("maven:pom.xml")), root);
|
||||
assert_eq!(module_of(None, Some("gradle::web")), root.join("web"));
|
||||
assert_eq!(module_of(None, Some("gradle::")), root);
|
||||
assert_eq!(module_of(None, Some("gradle:[\"sub\",\":web\"]")), root.join("sub/web"));
|
||||
assert_eq!(module_of(None, Some("other:whatever")), root);
|
||||
assert_eq!(module_of(None, None), root);
|
||||
assert_eq!(module_of(Some("web"), None), root.join("web"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -441,7 +441,7 @@ fn jdk_groups(root: &Path, s: &Settings, cancel: &CancellationToken) -> Vec<Libr
|
||||
Some(files) => files,
|
||||
None => {
|
||||
let java = [".classpath", ".project"].iter().any(|f| root.join(f).is_file())
|
||||
|| root.join(".idea").is_dir()
|
||||
|| idea_java_project(root)
|
||||
|| !s.languages.java.jdk.trim().is_empty()
|
||||
|| !s.project_structure.source_roots.is_empty();
|
||||
if !java {
|
||||
@@ -561,6 +561,27 @@ pub(crate) fn classpath_entries(dir: &Path, text: &str) -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
/// The JDK home and its sources (`lib/src.zip`, `src.zip` in JDK 8) as library roots.
|
||||
/// An IntelliJ project (`.idea`) that is a Java one: a Java SDK or language level in
|
||||
/// `misc.xml`, or a Java module file. GoLand, PyCharm and WebStorm write `.idea` too, so the
|
||||
/// folder alone doesn't make a project need the JDK.
|
||||
fn idea_java_project(root: &Path) -> bool {
|
||||
let idea = root.join(".idea");
|
||||
if !idea.is_dir() {
|
||||
return false;
|
||||
}
|
||||
let misc = std::fs::read_to_string(idea.join("misc.xml")).unwrap_or_default();
|
||||
if misc.contains("JavaSDK") || misc.contains("languageLevel=\"JDK_") {
|
||||
return true;
|
||||
}
|
||||
let java_module = |dir: &Path| {
|
||||
std::fs::read_dir(dir).into_iter().flatten().flatten().any(|e| {
|
||||
e.path().extension().is_some_and(|x| x == "iml")
|
||||
&& std::fs::read_to_string(e.path()).is_ok_and(|t| t.contains("JAVA_MODULE"))
|
||||
})
|
||||
};
|
||||
java_module(root) || java_module(&idea)
|
||||
}
|
||||
|
||||
pub(crate) fn jdk_library(jdk: &crate::java::Jdk) -> Option<LibraryGroup> {
|
||||
let name = jdk_name(&jdk.home).unwrap_or_else(|| format!("JDK {}", jdk.major));
|
||||
let mut roots = named_roots(vec![(name.clone(), jdk.home.clone())]);
|
||||
@@ -1263,3 +1284,32 @@ mod go_cache_tests {
|
||||
assert_eq!(groups[1].roots.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod idea_project_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_idea_folder_marks_a_java_project_only_when_it_declares_java() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
assert!(!idea_java_project(root));
|
||||
// GoLand / PyCharm write `.idea` without any Java in it.
|
||||
std::fs::create_dir_all(root.join(".idea")).unwrap();
|
||||
std::fs::write(root.join(".idea/misc.xml"), r#"<project version="4"><component name="GOROOT" /></project>"#)
|
||||
.unwrap();
|
||||
std::fs::write(root.join(".idea/demo.iml"), r#"<module type="WEB_MODULE" version="4" />"#).unwrap();
|
||||
assert!(!idea_java_project(root));
|
||||
// A Java module file, at the root or in `.idea`.
|
||||
std::fs::write(root.join("app.iml"), r#"<module type="JAVA_MODULE" version="4" />"#).unwrap();
|
||||
assert!(idea_java_project(root));
|
||||
std::fs::remove_file(root.join("app.iml")).unwrap();
|
||||
// The project JDK in misc.xml.
|
||||
std::fs::write(
|
||||
root.join(".idea/misc.xml"),
|
||||
r#"<project version="4"><component name="ProjectRootManager" languageLevel="JDK_17" project-jdk-name="17" project-jdk-type="JavaSDK" /></project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(idea_java_project(root));
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,6 @@ ui-mode = "GoMod"
|
||||
[[language-servers]]
|
||||
name = "gopls"
|
||||
command = "gopls"
|
||||
initialization-options = { semanticTokens = true }
|
||||
root-markers = ["go.work", "go.mod"]
|
||||
install-hint = "go install golang.org/x/tools/gopls@latest"
|
||||
@@ -8,5 +8,6 @@ ui-mode = "GoMod"
|
||||
[[language-servers]]
|
||||
name = "gopls"
|
||||
command = "gopls"
|
||||
initialization-options = { semanticTokens = true }
|
||||
root-markers = ["go.work"]
|
||||
install-hint = "go install golang.org/x/tools/gopls@latest"
|
||||
@@ -29,6 +29,9 @@ interface document-indexer {
|
||||
|
||||
/// A range of the document tied to a key the plugin chooses
|
||||
/// (`com.example.UserMapper#findById`, say). Keys are compared as is.
|
||||
/// Ranges lie inside the document; an empty one marks a position (a
|
||||
/// definition that is the whole file, at its start): Go to Declaration
|
||||
/// lands there, Find Usages and Rename skip it.
|
||||
record anchor {
|
||||
key: string,
|
||||
range: range,
|
||||
|
||||
+143
-40
@@ -21,7 +21,7 @@ import { activate as activateBuildTools } from "@/features/build-tools";
|
||||
import { useBuild } from "@/features/build-tools/store";
|
||||
import { activate as activateDebugger } from "@/features/debugger";
|
||||
import { addBreakpoint } from "@/features/debugger/breakpoints/store";
|
||||
import { activeSession, evaluate, sessionSupports, useDebug } from "@/features/debugger/state";
|
||||
import { activeSession, evaluate, rootOf, sessionSupports, useDebug } from "@/features/debugger/state";
|
||||
import { activate as activateRun } from "@/features/run";
|
||||
import { resetRuns, useRun } from "@/features/run/store";
|
||||
import { activateWorkbench } from "@/workbench/activate";
|
||||
@@ -33,33 +33,56 @@ interface Case {
|
||||
fixture: string;
|
||||
/** Runs in the project copy first (installs its dependencies). */
|
||||
setup?: (root: string) => void;
|
||||
/** The IDE's tool that debugs it. */
|
||||
debugger: string;
|
||||
/** A system tool that ships with the toolchain (the IDE does not install it). */
|
||||
/** The IDE's tools that debug it, installed unless found. */
|
||||
tools: string[];
|
||||
/** The adapter of the session Debug starts. */
|
||||
adapter: string;
|
||||
/** A system tool that ships with the toolchain (the IDE does not install it): `adapter` is run to check it. */
|
||||
system?: boolean;
|
||||
/** A saved configuration to debug (the debugger is its explicit choice), instead of the file's context. */
|
||||
configId?: string;
|
||||
/** Global settings (key: value) written before the project opens. */
|
||||
settings?: Record<string, unknown>;
|
||||
/** The title of a notification Debug shows. */
|
||||
notice?: string;
|
||||
/** Instances, fields and referrers may answer `timedOut` while the adapter is still busy after a stop. */
|
||||
retryTimedOut?: boolean;
|
||||
/** The entry point, debugged from its context. */
|
||||
file: string;
|
||||
/** A variable of the paused frame. */
|
||||
variable: string;
|
||||
/** Its value once the adapter has filled in a placeholder (`invalidated`), as the Variables pane shows it. */
|
||||
filledValue?: RegExp;
|
||||
/** An expression evaluated there (Evaluate Expression), and its value. */
|
||||
evaluate: [string, RegExp];
|
||||
/** A server: the breakpoint is in this request's handler, and the session is stopped at the end. */
|
||||
request?: string;
|
||||
/** JVM languages: the live instances of a class the paused program holds (Memory tab). */
|
||||
memory?: { className: string; instances: number };
|
||||
/** JVM languages: a class of the heap snapshot captured there and its instance count. */
|
||||
heap?: { className: string; instances: number };
|
||||
/** JVM languages: that class's instances, a field of the first one and its referrers (Show Instances). */
|
||||
instances?: { className: string; count: number; field: string };
|
||||
/** A server: the breakpoint is in this request's handler, and the session is stopped at the end. */
|
||||
request?: string;
|
||||
}
|
||||
|
||||
/** Debugging the Kotlin fixture, with either debugger. */
|
||||
const kotlin = {
|
||||
fixture: "kotlin-gradle",
|
||||
file: "src/main/kotlin/com/example/library/App.kt",
|
||||
variable: "library",
|
||||
evaluate: ["library", /Library/] as [string, RegExp],
|
||||
// Gradle forks the JVM and the debugger attaches to it: the attach-session path.
|
||||
memory: { className: "com.example.library.Book", instances: 3 },
|
||||
heap: { className: "com.example.library.Book", instances: 3 },
|
||||
instances: { className: "com.example.library.Book", count: 3, field: "title" },
|
||||
};
|
||||
|
||||
const CASES: Record<string, Case> = {
|
||||
typescript: {
|
||||
fixture: "ts-app",
|
||||
setup: (root) => execFileSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: root, stdio: "ignore" }),
|
||||
debugger: "js-debug",
|
||||
tools: ["js-debug"],
|
||||
adapter: "js-debug",
|
||||
file: "src/index.ts",
|
||||
variable: "cart",
|
||||
evaluate: ["cart.items.length", /^2$/],
|
||||
@@ -67,21 +90,24 @@ const CASES: Record<string, Case> = {
|
||||
python: {
|
||||
fixture: "python-app",
|
||||
setup: (root) => execFileSync("python3", ["-m", "venv", ".venv"], { cwd: root, stdio: "ignore" }),
|
||||
debugger: "debugpy",
|
||||
tools: ["debugpy"],
|
||||
adapter: "debugpy",
|
||||
file: "src/tasks/cli.py",
|
||||
variable: "tasks",
|
||||
evaluate: ["len(tasks.pending())", /^3$/],
|
||||
},
|
||||
rust: {
|
||||
fixture: "rust-app",
|
||||
debugger: "codelldb",
|
||||
tools: ["codelldb"],
|
||||
adapter: "codelldb",
|
||||
file: "src/main.rs",
|
||||
variable: "inventory",
|
||||
evaluate: ["inventory", /\S/],
|
||||
},
|
||||
go: {
|
||||
fixture: "go-app",
|
||||
debugger: "delve",
|
||||
tools: ["delve"],
|
||||
adapter: "delve",
|
||||
file: "cmd/server/main.go",
|
||||
variable: "r",
|
||||
evaluate: ["r.Method", /^"GET"$/],
|
||||
@@ -89,7 +115,8 @@ const CASES: Record<string, Case> = {
|
||||
},
|
||||
java: {
|
||||
fixture: "java-app",
|
||||
debugger: "java-debug",
|
||||
tools: ["java-debug"],
|
||||
adapter: "java-debug",
|
||||
file: "src/main/java/com/example/orders/App.java",
|
||||
variable: "book",
|
||||
evaluate: ["book.size()", /^3$/],
|
||||
@@ -98,22 +125,28 @@ const CASES: Record<string, Case> = {
|
||||
instances: { className: "com.example.orders.OrderBook", count: 1, field: "orders" },
|
||||
},
|
||||
kotlin: {
|
||||
fixture: "kotlin-gradle",
|
||||
debugger: "kotlin-debug-adapter",
|
||||
file: "src/main/kotlin/com/example/library/App.kt",
|
||||
variable: "library",
|
||||
evaluate: ["library", /Library/],
|
||||
// Gradle forks the JVM and the debugger attaches to it: the attach-session path.
|
||||
memory: { className: "com.example.library.Book", instances: 3 },
|
||||
heap: { className: "com.example.library.Book", instances: 3 },
|
||||
instances: { className: "com.example.library.Book", count: 3, field: "title" },
|
||||
...kotlin,
|
||||
tools: ["kotlin-lsp"],
|
||||
adapter: "kotlin-lsp",
|
||||
// kotlin-lsp evaluates for itself after each stop, which the inspector's link waits out.
|
||||
retryTimedOut: true,
|
||||
// First "Collecting data…", then the `toString` kotlin-lsp invokes.
|
||||
filledValue: /Library@/,
|
||||
},
|
||||
"kotlin-fwcd": {
|
||||
...kotlin,
|
||||
tools: ["kotlin-debug-adapter"],
|
||||
adapter: "kotlin-debug-adapter",
|
||||
settings: { languageServers: { "kotlin-lsp": { enabled: false } } },
|
||||
notice: "Debugging Kotlin with kotlin-debug-adapter",
|
||||
},
|
||||
// GDB's own DAP interpreter (GDB 14+), the configuration's explicit debugger choice:
|
||||
// the C/C++ default stays lldb-dap. GDB 15.0.50.20240403-0ubuntu1 says `15`.
|
||||
c: {
|
||||
fixture: "c-app",
|
||||
setup: (root) => execFileSync("make", ["app-c"], { cwd: root, stdio: "ignore" }),
|
||||
debugger: "gdb",
|
||||
tools: [],
|
||||
adapter: "gdb",
|
||||
system: true,
|
||||
configId: "user:app-c",
|
||||
file: "src/main.c",
|
||||
@@ -123,7 +156,8 @@ const CASES: Record<string, Case> = {
|
||||
cpp: {
|
||||
fixture: "c-app",
|
||||
setup: (root) => execFileSync("make", ["app-cpp"], { cwd: root, stdio: "ignore" }),
|
||||
debugger: "gdb",
|
||||
tools: [],
|
||||
adapter: "gdb",
|
||||
system: true,
|
||||
configId: "user:app-cpp",
|
||||
file: "src/main.cpp",
|
||||
@@ -134,7 +168,8 @@ const CASES: Record<string, Case> = {
|
||||
fixture: "php-app",
|
||||
setup: (root) =>
|
||||
execFileSync("composer", ["install", "--no-interaction", "--no-progress"], { cwd: root, stdio: "ignore" }),
|
||||
debugger: "php-debug",
|
||||
tools: ["php-debug"],
|
||||
adapter: "php-debug",
|
||||
file: "bin/quote.php",
|
||||
variable: "$shipment",
|
||||
evaluate: ["count($shipment)", /^3$/],
|
||||
@@ -149,7 +184,15 @@ let base = "";
|
||||
/** Backend events received, for failure messages. */
|
||||
const events: string[] = [];
|
||||
beforeAll(() => {
|
||||
onIdeEvent("*", (e) => events.push(e.type === "debug" ? `debug ${e.session} ${e.event.kind}` : e.type));
|
||||
onIdeEvent("*", (e) =>
|
||||
events.push(
|
||||
e.type === "debug"
|
||||
? `debug ${e.session} ${e.event.kind}`
|
||||
: e.type === "task"
|
||||
? `task ${e.title}${e.done ? " (done)" : ""}`
|
||||
: e.type,
|
||||
),
|
||||
);
|
||||
const disposers = [activateWorkbench(), activateBuildTools(), activateRun(), activateDebugger()];
|
||||
deactivate = () => disposers.reverse().forEach((d) => d());
|
||||
});
|
||||
@@ -171,6 +214,8 @@ async function open(c: Case): Promise<string> {
|
||||
c.setup?.(root);
|
||||
server = await Server.start(join(base, "home"));
|
||||
disconnect = connectDesktop(server);
|
||||
for (const [key, value] of Object.entries(c.settings ?? {}))
|
||||
await call(commands.settingsSet("global", key, JSON.stringify(value)));
|
||||
await openProject(root);
|
||||
const trusted = await commands.projectTrustSet(root, true, false);
|
||||
if (trusted.status !== "ok") throw new Error(trusted.error.message);
|
||||
@@ -190,7 +235,9 @@ async function open(c: Case): Promise<string> {
|
||||
|
||||
async function install(tool: string) {
|
||||
const tools = await call(commands.toolsList());
|
||||
if (tools.find((t) => t.name === tool)?.status === "installed") return;
|
||||
// CI puts its pinned kotlin-lsp on `PATH`: that one is tested, not the latest release.
|
||||
const status = tools.find((t) => t.name === tool)?.status;
|
||||
if (status === "installed" || status === "onPath") return;
|
||||
const channel = new Channel<InstallEvent>();
|
||||
const info = await call(commands.toolsInstall(tool, nextRequestId(), channel));
|
||||
expect(info.status, `${tool} installs`).toBe("installed");
|
||||
@@ -255,12 +302,9 @@ const pausedOrEnded = () => {
|
||||
async function smoke(language: string) {
|
||||
const c = CASES[language]!;
|
||||
const root = await open(c);
|
||||
if (c.system) {
|
||||
// GDB ships with the toolchain (14+ for its DAP interpreter); the IDE does not install it.
|
||||
execFileSync(c.debugger, ["--version"], { stdio: "pipe" });
|
||||
} else {
|
||||
await install(c.debugger);
|
||||
}
|
||||
// GDB ships with the toolchain (14+ for its DAP interpreter); the IDE does not install it.
|
||||
if (c.system) execFileSync(c.adapter, ["--version"], { stdio: "pipe" });
|
||||
for (const tool of c.tools) await install(tool);
|
||||
|
||||
const path = join(root, c.file);
|
||||
const content = readFileSync(path, "utf8");
|
||||
@@ -268,6 +312,8 @@ async function smoke(language: string) {
|
||||
const line = marker + 1;
|
||||
addBreakpoint(path, line);
|
||||
|
||||
const started = Date.now();
|
||||
const begun = events.length;
|
||||
if (c.configId) {
|
||||
const config = (await call(commands.runConfigs())).find((cfg) => cfg.id === c.configId);
|
||||
expect(config, c.configId).toBeTruthy();
|
||||
@@ -277,24 +323,62 @@ async function smoke(language: string) {
|
||||
}
|
||||
if (c.request) void requestWhenListening(c.request);
|
||||
const s = await until("the breakpoint", pausedOrEnded, 5 * 60_000);
|
||||
const firstStop = Date.now() - started;
|
||||
expect(s.stopReason, `${s.stopDescription}\n${shown()}`).toBe("breakpoint");
|
||||
expect(rootOf(s).adapter, shown()).toBe(c.adapter);
|
||||
if (c.notice) {
|
||||
const titles = useNotifications.getState().items.map((n) => n.title);
|
||||
expect(titles, shown()).toContain(c.notice);
|
||||
}
|
||||
const top = s.frames[0]!;
|
||||
expect(top.path).toBe(path);
|
||||
expect(top.line).toBe(line);
|
||||
|
||||
const scopes = await call(commands.debugScopes(s.id, top.id));
|
||||
const locals = await call(commands.debugVariables(s.id, scopes[0]!.variablesReference));
|
||||
let locals = await call(commands.debugVariables(s.id, scopes[0]!.variablesReference));
|
||||
expect(locals.map((v) => v.name)).toContain(c.variable);
|
||||
let filled = "";
|
||||
if (c.filledValue) {
|
||||
const since = Date.now();
|
||||
let epoch: number | null = null;
|
||||
let last = locals.find((v) => v.name === c.variable)?.value;
|
||||
while (!c.filledValue.test(last ?? "")) {
|
||||
if (Date.now() - since > 10_000) throw new Error(`${c.variable} stayed ${JSON.stringify(last)}\n${shown()}`);
|
||||
const now = useDebug.getState().variablesEpoch;
|
||||
if (now === epoch) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
continue;
|
||||
}
|
||||
// What the Variables pane does on each `invalidated`: pull the frame's locals again.
|
||||
epoch = now;
|
||||
const again = await call(commands.debugScopes(s.id, top.id));
|
||||
locals = await call(commands.debugVariables(s.id, again.find((sc) => !sc.expensive)!.variablesReference));
|
||||
last = locals.find((v) => v.name === c.variable)?.value;
|
||||
}
|
||||
filled = ` (filled in after ${((Date.now() - since) / 1000).toFixed(1)} s)`;
|
||||
}
|
||||
|
||||
// The value, not kotlin-lsp's "Collecting data…" placeholder.
|
||||
const value = await evaluate(c.evaluate[0], "repl", s);
|
||||
expect(value.value, c.evaluate[0]).toMatch(c.evaluate[1]);
|
||||
if (c.debugger === "gdb") {
|
||||
if (c.adapter === "gdb") {
|
||||
const expression = await evaluate(c.variable, "watch", s);
|
||||
expect(expression.value).toBeTruthy();
|
||||
const environment = await evaluate("environment_ok", "watch", s);
|
||||
expect(environment.value).toBe("1");
|
||||
}
|
||||
|
||||
const filters = (await call(commands.debugExceptionFilters())).find((f) => f.session === rootOf(s).id);
|
||||
const filterIds = filters?.filters.map((f) => f.filter) ?? [];
|
||||
// The JVM debuggers offer caught / uncaught exceptions; Delve offers none.
|
||||
if (c.memory) expect(filterIds, `exception filters of ${c.adapter}`).not.toHaveLength(0);
|
||||
const waits = events.slice(begun).filter((e) => e.startsWith("task Waiting for")).length;
|
||||
console.info(
|
||||
`${language}: ${c.adapter} stopped after ${(firstStop / 1000).toFixed(1)} s (${waits} wait task events); ` +
|
||||
`${c.variable} = ${locals.find((v) => v.name === c.variable)?.value}${filled}; evaluated ${value.value}; ` +
|
||||
`exception filters [${filterIds.join(", ")}]`,
|
||||
);
|
||||
|
||||
if (c.memory) {
|
||||
const memory = await call(commands.debugJvmMemoryClasses(s.id));
|
||||
if (memory.status === "ready") {
|
||||
@@ -364,6 +448,7 @@ async function smoke(language: string) {
|
||||
}
|
||||
}
|
||||
|
||||
let timedOutRetries = 0;
|
||||
if (c.instances) {
|
||||
const { className, count, field } = c.instances;
|
||||
// The inspector comes up beside the session (jdtls bundle, Kotlin JDWP link).
|
||||
@@ -373,24 +458,35 @@ async function smoke(language: string) {
|
||||
if (backend && backend.kind !== "ready") throw new Error(`JVM inspector ${JSON.stringify(backend)}\n${shown()}`);
|
||||
return sessionSupports("jvmInstances", session);
|
||||
});
|
||||
const listed = await call(commands.debugJvmMemoryInstances(s.id, className, 1000));
|
||||
expect(listed.status, `instances: ${JSON.stringify(listed)}\n${shown()}`).toBe("ready");
|
||||
if (listed.status !== "ready") return;
|
||||
type Answer = { status: "ready" } | { status: "unavailable"; reason: { kind: string } };
|
||||
const ready = async <T extends Answer>(what: string, ask: () => Promise<T>) => {
|
||||
let r = await ask();
|
||||
for (let retry = 0; c.retryTimedOut && retry < 5; retry++) {
|
||||
if (r.status !== "unavailable" || r.reason.kind !== "timedOut") break;
|
||||
timedOutRetries++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
r = await ask();
|
||||
}
|
||||
expect(r.status, `${what}: ${JSON.stringify(r)}\n${shown()}`).toBe("ready");
|
||||
return r as Extract<T, { status: "ready" }>;
|
||||
};
|
||||
const listed = await ready("instances", () => call(commands.debugJvmMemoryInstances(s.id, className, 1000)));
|
||||
expect(listed.instances, `${className} instances`).toHaveLength(count);
|
||||
const first = listed.instances[0]!;
|
||||
if (!("id" in first)) throw new Error(`${className} instance without an id: ${JSON.stringify(first)}`);
|
||||
const object = { generation: listed.generation, id: first.id };
|
||||
const fields = await call(commands.debugJvmMemoryFields(s.id, object));
|
||||
if (fields.status !== "ready") throw new Error(`fields: ${JSON.stringify(fields)}\n${shown()}`);
|
||||
const fields = await ready("fields", () => call(commands.debugJvmMemoryFields(s.id, object)));
|
||||
const held = fields.fields.find((f) => f.name === field)?.value;
|
||||
if (!held || !("id" in held)) throw new Error(`${className}.${field}: ${JSON.stringify(fields.fields)}`);
|
||||
// Only objects count as referrers, not stack frames: Java's `OrderBook` is held by a local alone.
|
||||
await ready("referrers", () => call(commands.debugJvmMemoryReferrers(s.id, object, 50)));
|
||||
const heldRef = { generation: listed.generation, id: held.id };
|
||||
const referrers = await call(commands.debugJvmMemoryReferrers(s.id, heldRef, 50));
|
||||
if (referrers.status !== "ready") throw new Error(`referrers: ${JSON.stringify(referrers)}\n${shown()}`);
|
||||
const referrers = await ready("referrers", () => call(commands.debugJvmMemoryReferrers(s.id, heldRef, 50)));
|
||||
const back = referrers.referrers.find((r) => "id" in r.value && r.value.id === first.id);
|
||||
expect(back?.via, `referrers of ${className}.${field}: ${JSON.stringify(referrers)}`).toContain(field);
|
||||
}
|
||||
|
||||
const stepped = events.length;
|
||||
// Python 3.12 reports a conditional expression's line twice: Step Over may stop on it
|
||||
// (and its breakpoint) again.
|
||||
for (let step = 0; step < 3 && paused()!.frames[0]!.line === line; step++) {
|
||||
@@ -402,6 +498,13 @@ async function smoke(language: string) {
|
||||
expect(after.stopReason, shown()).toBe("step");
|
||||
expect(after.frames[0]!.line, shown()).toBeGreaterThan(line);
|
||||
|
||||
const invalidated = (from: number, to = events.length) =>
|
||||
events.slice(from, to).filter((e) => e.endsWith(" invalidated")).length;
|
||||
console.info(
|
||||
`${language}: invalidated ${invalidated(begun, stepped)} at the breakpoint, ${invalidated(stepped)} after Step Over; ` +
|
||||
`${timedOutRetries} timedOut retries`,
|
||||
);
|
||||
|
||||
await executeAction("debug.resume");
|
||||
if (c.request) {
|
||||
await until("the request to be served", () => useDebug.getState().sessions[s.id]?.status === "running");
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
use crate::state::AppState;
|
||||
use ide_agent::registry::AgentRegistry;
|
||||
use ide_agent::{
|
||||
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
|
||||
ExternalSession, McpServer, McpServerInput, NewAgentTask,
|
||||
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt,
|
||||
AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpServer, McpServerInput, NewAgentTask,
|
||||
};
|
||||
use ide_core::{IdeResult, IpcPath, RequestId};
|
||||
use ide_host::ops::agent;
|
||||
@@ -62,6 +62,32 @@ pub fn agent_set_env(
|
||||
agent::set_env(&state.host, window.label(), id, env)
|
||||
}
|
||||
|
||||
/// Saves a new custom ACP agent through its agent file; refuses built-in and
|
||||
/// ACP Registry ids and taken ids.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_custom_save(window: Window, state: State<'_, AppState>, input: CustomAgentInput) -> IdeResult<()> {
|
||||
agent::save_custom_agent(&state.host, window.label(), input)
|
||||
}
|
||||
|
||||
/// Removes a custom agent's file (registry installs go through `agent_registry_remove`).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_custom_remove(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<()> {
|
||||
agent::remove_custom_agent(&state.host, window.label(), id)
|
||||
}
|
||||
|
||||
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom agents.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_custom_import(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
text: String,
|
||||
) -> IdeResult<Vec<AcpImportOutcome>> {
|
||||
agent::import_acp_agents(&state.host, window.label(), text)
|
||||
}
|
||||
|
||||
/// Agents of the ACP Registry this machine can run, with what is installed; the list
|
||||
/// saved earlier when the registry is unreachable. Cancel with `cancel_request(request)`.
|
||||
#[tauri::command]
|
||||
@@ -356,6 +382,19 @@ pub async fn agent_rollback_changes(
|
||||
agent::rollback_changes(&state.host, window.label(), task, paths).await
|
||||
}
|
||||
|
||||
/// Accept: copies a worktree task's changed files among `paths` (all without) into the
|
||||
/// local checkout, uncommitted; files the checkout changed itself are left alone and reported.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn agent_accept_changes(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
task: String,
|
||||
paths: Option<Vec<String>>,
|
||||
) -> IdeResult<AgentAcceptOutcome> {
|
||||
agent::accept_changes(&state.host, window.label(), task, paths).await
|
||||
}
|
||||
|
||||
/// Content of `path` when the task started (left side of its diff).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
//! File › Invalidate Caches… and restarting the IDE.
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::windows::WindowsState;
|
||||
use ide_core::IdeResult;
|
||||
use ide_host::data_dirs;
|
||||
use ide_workspace::caches::{self, InvalidateCachesOptions};
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
/// Schedules the invalidation for the next start (`None` cancels a pending one).
|
||||
@@ -26,3 +30,52 @@ pub fn app_restart(app: AppHandle, windows: State<'_, WindowsState>) {
|
||||
windows.set_restart();
|
||||
super::window::app_quit(app, windows);
|
||||
}
|
||||
|
||||
/// Settings › System Settings › Caches: where the local data lives.
|
||||
#[derive(Debug, Clone, Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalDataInfo {
|
||||
/// The directory in use by this run.
|
||||
pub current: String,
|
||||
/// The OS default (what clearing the custom directory returns to).
|
||||
pub default_dir: String,
|
||||
/// The custom root stored by Settings, if any (`current` is `<root>/<identifier>`).
|
||||
pub custom_root: Option<String>,
|
||||
/// `OPENIDEA_LOCAL_DATA_DIR` is set and wins over everything Settings stores.
|
||||
pub env_override: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn caches_local_data_info(app: AppHandle, state: State<'_, AppState>) -> IdeResult<LocalDataInfo> {
|
||||
let identifier = app.config().identifier.clone();
|
||||
let stringify = |path: PathBuf| path.display().to_string();
|
||||
Ok(LocalDataInfo {
|
||||
current: stringify(state.host.local_data_dir.clone()),
|
||||
default_dir: stringify(data_dirs::default_local_data_dir(&identifier).map_err(ide_core::IdeError::internal)?),
|
||||
custom_root: data_dirs::stored_override(&identifier).map(stringify),
|
||||
env_override: std::env::var_os(data_dirs::LOCAL_DATA_ENV).is_some_and(|v| !v.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Stores (`Some`) or clears (`None`, an empty string counts) the custom
|
||||
/// local-data root; with `migrate`, the current tree moves at the next start.
|
||||
/// Returns the directory that takes effect after a restart. The caller
|
||||
/// restarts the IDE (see `app_restart`).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn caches_set_local_data_dir(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
path: Option<String>,
|
||||
migrate: bool,
|
||||
) -> IdeResult<String> {
|
||||
let identifier = app.config().identifier.clone();
|
||||
let current = state.host.local_data_dir.clone();
|
||||
let root = path.filter(|s| !s.trim().is_empty()).map(PathBuf::from);
|
||||
let next = data_dirs::set_override(&identifier, root.as_deref()).map_err(ide_core::IdeError::internal)?;
|
||||
if migrate {
|
||||
data_dirs::schedule_custom_migration(&identifier, ¤t, &next).map_err(ide_core::IdeError::internal)?;
|
||||
}
|
||||
Ok(next.display().to_string())
|
||||
}
|
||||
@@ -135,3 +135,19 @@ pub async fn http_environment_file(
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Where `{{name}}` is defined in the environment files that apply to request file `path`
|
||||
/// (Navigate › Declaration on a variable), private file first.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn http_variable_definitions(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
path: IpcPath,
|
||||
name: String,
|
||||
) -> IdeResult<Vec<ide_core::Location>> {
|
||||
let root = state.project(window.label()).ok().map(|p| p.root.clone());
|
||||
tauri::async_runtime::spawn_blocking(move || ide_http::env::variable_definitions(&path, root.as_deref(), &name))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
@@ -122,6 +122,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
agent::agent_providers,
|
||||
agent::agent_set_command,
|
||||
agent::agent_set_env,
|
||||
agent::agent_custom_save,
|
||||
agent::agent_custom_remove,
|
||||
agent::agent_custom_import,
|
||||
agent::agent_registry_list,
|
||||
agent::agent_registry_install,
|
||||
agent::agent_registry_remove,
|
||||
@@ -152,6 +155,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
agent::agent_changes,
|
||||
agent::agent_base_text,
|
||||
agent::agent_rollback_changes,
|
||||
agent::agent_accept_changes,
|
||||
notebook::notebook_open,
|
||||
notebook::notebook_get,
|
||||
notebook::notebook_edit,
|
||||
@@ -220,6 +224,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
refactor::refactor_undo_file_move,
|
||||
refactor::refactor_can_undo_file_move,
|
||||
caches::caches_schedule_invalidation,
|
||||
caches::caches_local_data_info,
|
||||
caches::caches_set_local_data_dir,
|
||||
caches::app_restart,
|
||||
nav::index_status,
|
||||
nav::index_file_extensions,
|
||||
@@ -643,6 +649,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
http::http_curl,
|
||||
http::http_from_curl,
|
||||
http::http_environment_file,
|
||||
http::http_variable_definitions,
|
||||
http::http_history,
|
||||
http::http_history_get,
|
||||
http::http_history_clear,
|
||||
|
||||
@@ -90,7 +90,9 @@ pub fn run() {
|
||||
linux_webkit::configure();
|
||||
|
||||
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier);
|
||||
let local_data_dir = ide_host::data_dirs::local_data_dir(&context.config().identifier);
|
||||
// A custom directory from Settings › System Settings › Caches applies here too
|
||||
// (`OPENIDEA_LOCAL_DATA_DIR` still wins), so tools resolve against the new root.
|
||||
let local_data_dir = ide_host::data_dirs::startup_local_data_dir(&context.config().identifier);
|
||||
// Managed LSP/DAP installs; must run before other threads start and before PATH is cached.
|
||||
match &local_data_dir {
|
||||
Ok(data) => {
|
||||
|
||||
@@ -75,6 +75,11 @@ impl AppState {
|
||||
tracing::info!(?data_dir, ?local_data_dir, "application data directories");
|
||||
// Saved git HTTPS credentials (askpass answers them silently).
|
||||
ide_ssh::git_creds::init(data_dir.join("git-credentials.json"));
|
||||
// A newly chosen local-data directory from Settings › System Settings ›
|
||||
// Caches: the previous tree moves first, so a pending invalidation still
|
||||
// wipes what was just moved.
|
||||
let identifier = app.config().identifier.clone();
|
||||
ide_host::data_dirs::apply_custom_migration(&identifier, &local_data_dir);
|
||||
// File › Invalidate Caches… requested at the last run.
|
||||
let removed = ide_workspace::caches::apply_pending(&data_dir, &local_data_dir);
|
||||
if !removed.is_empty() {
|
||||
|
||||
+104
-1
@@ -139,6 +139,15 @@ export const commands = {
|
||||
* an empty map clears them.
|
||||
*/
|
||||
agentSetEnv: (id: string, env: { [key in string]: string }) => typedError<null, IdeError>(__TAURI_INVOKE("agent_set_env", { id, env })),
|
||||
/**
|
||||
* Saves a new custom ACP agent through its agent file; refuses built-in and
|
||||
* ACP Registry ids and taken ids.
|
||||
*/
|
||||
agentCustomSave: (input: CustomAgentInput) => typedError<null, IdeError>(__TAURI_INVOKE("agent_custom_save", { input })),
|
||||
/** Removes a custom agent's file (registry installs go through `agent_registry_remove`). */
|
||||
agentCustomRemove: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_custom_remove", { id })),
|
||||
/** Imports a JetBrains `acp.json` file's `agent_servers` entries as custom agents. */
|
||||
agentCustomImport: (text: string) => typedError<AcpImportOutcome[], IdeError>(__TAURI_INVOKE("agent_custom_import", { text })),
|
||||
/**
|
||||
* Agents of the ACP Registry this machine can run, with what is installed; the list
|
||||
* saved earlier when the registry is unreachable. Cancel with `cancel_request(request)`.
|
||||
@@ -209,6 +218,11 @@ export const commands = {
|
||||
agentBaseText: (task: string, path: IpcPath) => typedError<string | null, IdeError>(__TAURI_INVOKE("agent_base_text", { task, path })),
|
||||
/** Rollback: the task's changed files among `paths` (all without) back as they were when it started. */
|
||||
agentRollbackChanges: (task: string, paths: string[] | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_rollback_changes", { task, paths })),
|
||||
/**
|
||||
* Accept: copies a worktree task's changed files among `paths` (all without) into the
|
||||
* local checkout, uncommitted; files the checkout changed itself are left alone and reported.
|
||||
*/
|
||||
agentAcceptChanges: (task: string, paths: string[] | null) => typedError<AgentAcceptOutcome, IdeError>(__TAURI_INVOKE("agent_accept_changes", { task, paths })),
|
||||
notebookOpen: (path: string) => typedError<NotebookSnapshot, IdeError>(__TAURI_INVOKE("notebook_open", { path })),
|
||||
notebookGet: (id: string) => typedError<NotebookSnapshot, IdeError>(__TAURI_INVOKE("notebook_get", { id })),
|
||||
notebookEdit: (id: string, version: number, edit: NotebookEdit) => typedError<NotebookSnapshot, IdeError>(__TAURI_INVOKE("notebook_edit", { id, version, edit })),
|
||||
@@ -392,6 +406,14 @@ export const commands = {
|
||||
/** Build tool sync results (Maven / Gradle) and other per-project caches. */
|
||||
buildCaches?: boolean,
|
||||
} | null) => typedError<null, IdeError>(__TAURI_INVOKE("caches_schedule_invalidation", { options })),
|
||||
cachesLocalDataInfo: () => typedError<LocalDataInfo, IdeError>(__TAURI_INVOKE("caches_local_data_info")),
|
||||
/**
|
||||
* Stores (`Some`) or clears (`None`, an empty string counts) the custom
|
||||
* local-data root; with `migrate`, the current tree moves at the next start.
|
||||
* Returns the directory that takes effect after a restart. The caller
|
||||
* restarts the IDE (see `app_restart`).
|
||||
*/
|
||||
cachesSetLocalDataDir: (path: string | null, migrate: boolean) => typedError<string, IdeError>(__TAURI_INVOKE("caches_set_local_data_dir", { path, migrate })),
|
||||
/** Quits through every window's unsaved-changes check, then starts again. */
|
||||
appRestart: () => __TAURI_INVOKE<void>("app_restart"),
|
||||
indexStatus: () => typedError<IndexStatus, IdeError>(__TAURI_INVOKE("index_status")),
|
||||
@@ -1478,6 +1500,11 @@ export const commands = {
|
||||
* existing one, else a new one next to it.
|
||||
*/
|
||||
httpEnvironmentFile: (path: IpcPath, isPrivate: boolean) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("http_environment_file", { path, isPrivate })),
|
||||
/**
|
||||
* Where `{{name}}` is defined in the environment files that apply to request file `path`
|
||||
* (Navigate › Declaration on a variable), private file first.
|
||||
*/
|
||||
httpVariableDefinitions: (path: IpcPath, name: string) => typedError<Location[], IdeError>(__TAURI_INVOKE("http_variable_definitions", { path, name })),
|
||||
httpHistory: () => typedError<HttpHistoryEntry[], IdeError>(__TAURI_INVOKE("http_history")),
|
||||
httpHistoryGet: (id: string) => typedError<HttpExchange, IdeError>(__TAURI_INVOKE("http_history_get", { id })),
|
||||
httpHistoryClear: () => typedError<null, IdeError>(__TAURI_INVOKE("http_history_clear")),
|
||||
@@ -1616,6 +1643,17 @@ export const commands = {
|
||||
};
|
||||
|
||||
/* Types */
|
||||
/** What importing one entry of a JetBrains `acp.json` file did. */
|
||||
export type AcpImportOutcome = {
|
||||
/** The `agent_servers` key the entry was imported from. */
|
||||
name: string,
|
||||
/** The id its agent file would get (also when skipped). */
|
||||
id: string,
|
||||
saved: boolean,
|
||||
/** Why the entry was skipped; empty when it was saved. */
|
||||
reason?: string,
|
||||
};
|
||||
|
||||
export type ActionPerformed = {
|
||||
action: string,
|
||||
title: string,
|
||||
@@ -1638,6 +1676,20 @@ export type ActiveTheme = {
|
||||
theme: ThemeDef,
|
||||
};
|
||||
|
||||
/** What accepting a worktree task's changes copied into the local checkout. */
|
||||
export type AgentAcceptOutcome = {
|
||||
/** Relative paths copied into the checkout (uncommitted), including ones already identical. */
|
||||
accepted: string[],
|
||||
/** Files left alone because the checkout has its own edits to them. */
|
||||
skipped: AgentAcceptSkipped[],
|
||||
};
|
||||
|
||||
/** A file accept left alone, with why. */
|
||||
export type AgentAcceptSkipped = {
|
||||
path: string,
|
||||
reason: string,
|
||||
};
|
||||
|
||||
/** A task the agent runs in the background while the conversation goes on. */
|
||||
export type AgentBackgroundTask = {
|
||||
id: string,
|
||||
@@ -1941,6 +1993,14 @@ export type AgentRetry = {
|
||||
error: string | null,
|
||||
};
|
||||
|
||||
/** Tools › Agents: Command Execution Policy (whitelist and blacklist patterns). */
|
||||
export type AgentSecuritySettings = {
|
||||
/** Commands or patterns (`git status*`, `pwd`) automatically approved without prompting. */
|
||||
allowedCommands?: string[],
|
||||
/** Commands or patterns (`rm -rf *`, `mkfs*`) automatically rejected. */
|
||||
blockedCommands?: string[],
|
||||
};
|
||||
|
||||
/** Settings › Agents: what the conversation shows and how messages are sent. */
|
||||
export type AgentSettings = AgentSettings_Serialize | AgentSettings_Deserialize;
|
||||
|
||||
@@ -3167,6 +3227,18 @@ export type CreateRequest = {
|
||||
values: { [key in string]: string },
|
||||
};
|
||||
|
||||
/**
|
||||
* Payload for creating a custom ACP agent (always the ACP driver); the id
|
||||
* becomes the `<config>/agents/<id>.toml` file name.
|
||||
*/
|
||||
export type CustomAgentInput = {
|
||||
id: string,
|
||||
name: string,
|
||||
command: string,
|
||||
args?: string[],
|
||||
env?: { [key in string]: string },
|
||||
};
|
||||
|
||||
/** Imported editor colour scheme on top of a built-in base. */
|
||||
export type CustomColorScheme = CustomColorScheme_Serialize | CustomColorScheme_Deserialize;
|
||||
|
||||
@@ -3625,7 +3697,12 @@ export type DebugEvent =
|
||||
*/
|
||||
{ kind: "hotCodeReplace"; changeType: string; message: string } |
|
||||
/** The session's capabilities changed after it started: fetch them again. */
|
||||
{ kind: "capabilities" } | { kind: "jvmBackend"; state: JvmBackendState };
|
||||
{ kind: "capabilities" } |
|
||||
/**
|
||||
* The adapter's `invalidated` event: what it shows for `areas` (`all`, `stacks`, `threads`,
|
||||
* `variables`; empty means all) is stale and has to be fetched again.
|
||||
*/
|
||||
{ kind: "invalidated"; areas: string[] } | { kind: "jvmBackend"; state: JvmBackendState };
|
||||
|
||||
/** A debug adapter session. */
|
||||
export type DebugSessionId = number;
|
||||
@@ -6409,6 +6486,18 @@ export type LineMatch = {
|
||||
textOffset: number,
|
||||
};
|
||||
|
||||
/** Settings › System Settings › Caches: where the local data lives. */
|
||||
export type LocalDataInfo = {
|
||||
/** The directory in use by this run. */
|
||||
current: string,
|
||||
/** The OS default (what clearing the custom directory returns to). */
|
||||
defaultDir: string,
|
||||
/** The custom root stored by Settings, if any (`current` is `<root>/<identifier>`). */
|
||||
customRoot: string | null,
|
||||
/** `OPENIDEA_LOCAL_DATA_DIR` is set and wins over everything Settings stores. */
|
||||
envOverride: boolean,
|
||||
};
|
||||
|
||||
/** What a local definition declares (nvim-treesitter `@local.definition.<kind>`). */
|
||||
export type LocalKind = "var" | "parameter" | "function" | "type" | "import" | "macro" | "label" |
|
||||
/** Plain `@local.definition` or a kind without a better mapping. */
|
||||
@@ -7337,6 +7426,11 @@ actions: NotificationAction[] }) & { arg?: never; args?: never; cancellable?: ne
|
||||
({ type: "promptRequested"; pluginId: string; promptId: string; prompt: Prompt }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } |
|
||||
/** The call that asked gave up (cancelled, or its plugin stopped): close the prompt. */
|
||||
({ type: "promptClosed"; promptId: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } |
|
||||
/**
|
||||
* A command of this window's prompting plugin was run from another window and refused
|
||||
* while the plugin waits for the answer here: bring this window to the front.
|
||||
*/
|
||||
({ type: "promptWaiting"; pluginId: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } |
|
||||
/** Bring the tool window of the plugin's view `view` to the front. */
|
||||
({ type: "revealView"; pluginId: string; view: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never } |
|
||||
/**
|
||||
@@ -7431,6 +7525,11 @@ actions: NotificationAction[] }) & { arg?: never; args?: never; cancellable?: ne
|
||||
({ type: "promptRequested"; pluginId: string; promptId: string; prompt: Prompt }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } |
|
||||
/** The call that asked gave up (cancelled, or its plugin stopped): close the prompt. */
|
||||
({ type: "promptClosed"; promptId: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; pluginId?: never; program?: never; prompt?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } |
|
||||
/**
|
||||
* A command of this window's prompting plugin was run from another window and refused
|
||||
* while the plugin waits for the answer here: bring this window to the front.
|
||||
*/
|
||||
({ type: "promptWaiting"; pluginId: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never; view?: never } |
|
||||
/** Bring the tool window of the plugin's view `view` to the front. */
|
||||
({ type: "revealView"; pluginId: string; view: string }) & { actions?: never; arg?: never; args?: never; body?: never; cancellable?: never; capabilities?: never; chain?: never; channel?: never; column?: never; cwd?: never; done?: never; id?: never; item?: never; key?: never; level?: never; line?: never; log?: never; message?: never; newWindow?: never; path?: never; program?: never; prompt?: never; promptId?: never; ranges?: never; reply?: never; request?: never; requestId?: never; reveal?: never; root?: never; selection?: never; sets?: never; taskId?: never; text?: never; theme?: never; title?: never; total?: never; updates?: never; url?: never; value?: never } |
|
||||
/**
|
||||
@@ -8869,6 +8968,8 @@ export type Settings_Deserialize = {
|
||||
agents?: AgentSettings_Deserialize,
|
||||
/** Agents › MCP Server (global only). */
|
||||
mcpServer?: McpServerSettings,
|
||||
/** Tools › Agents: Command Execution Policy. */
|
||||
agentSecurity?: AgentSecuritySettings,
|
||||
/** Plugin settings (`[contributes.settings]`): plugin id → its values by key. */
|
||||
plugins?: { [key in string]: { [key in string]: unknown } },
|
||||
};
|
||||
@@ -8931,6 +9032,8 @@ export type Settings_Serialize = {
|
||||
agents: AgentSettings_Serialize,
|
||||
/** Agents › MCP Server (global only). */
|
||||
mcpServer: McpServerSettings,
|
||||
/** Tools › Agents: Command Execution Policy. */
|
||||
agentSecurity: AgentSecuritySettings,
|
||||
/** Plugin settings (`[contributes.settings]`): plugin id → its values by key. */
|
||||
plugins: { [key in string]: { [key in string]: unknown } },
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
} from "./editing";
|
||||
import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./comments";
|
||||
import { enterInDocComment } from "./docComment";
|
||||
import { skipClosingQuote, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys";
|
||||
import { skipClosingQuote, smartAngleBrackets, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys";
|
||||
import { stringClipboard } from "./stringClipboard";
|
||||
import { filterCopiedText } from "./clipboard";
|
||||
import { LINE_INSET } from "./lineInset";
|
||||
@@ -596,6 +596,8 @@ export function prefsExtension(o: PrefsOptions): Extension {
|
||||
// Pairing: closeBrackets() reads the brackets list from language data; ours comes first.
|
||||
const brackets = [...(k.pairBrackets ? ["(", "[", "{"] : []), ...(k.pairQuotes ? ["'", '"'] : [])];
|
||||
out.push(Prec.highest(EditorState.languageData.of(() => [{ closeBrackets: { brackets } }])));
|
||||
// `<` pairs where the syntax says a generic type list starts, as in IDEA — no separate option.
|
||||
if (k.pairBrackets && !o.readOnly) out.push(smartAngleBrackets(o.language));
|
||||
if (!k.surroundSelection) out.push(Prec.highest(noSurround));
|
||||
if (k.pairQuotes) out.push(Prec.highest(skipClosingQuote));
|
||||
if (k.tabOutOfPair) out.push(tabOut);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { indentService, indentUnit } from "@codemirror/language";
|
||||
import { indentService, indentUnit, LanguageSupport, StreamLanguage } from "@codemirror/language";
|
||||
import { closeBrackets, insertBracket } from "@codemirror/autocomplete";
|
||||
import { java } from "@codemirror/lang-java";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { languages as cmLanguages } from "@codemirror/language-data";
|
||||
import { kotlin, csharp } from "@codemirror/legacy-modes/mode/clike";
|
||||
import { validateEditorExtras } from "@/platform/editorSettings";
|
||||
import {
|
||||
autoIndentBackspace,
|
||||
insertTab,
|
||||
@@ -11,6 +16,8 @@ import {
|
||||
tabOut,
|
||||
tabOutOfScope,
|
||||
} from "./smartKeys";
|
||||
import { prefsExtension } from "./prefs";
|
||||
import { interpolationParser } from "./interpolation";
|
||||
|
||||
/** One unit (2) deeper after a (non-blank) line ending in `{`, one less on a line starting with `}`. */
|
||||
const braces = indentService.of((cx, pos) => {
|
||||
@@ -171,3 +178,158 @@ describe("tab out of pairs", () => {
|
||||
expect(tabOutOfScope(v)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("smart angle brackets", () => {
|
||||
/** The real wiring: prefsExtension attaches the `<` handler for the document's language. */
|
||||
const prefs = (language: string | undefined, smartKeys: Record<string, unknown> = {}) =>
|
||||
[
|
||||
closeBrackets(),
|
||||
prefsExtension({
|
||||
prefs: validateEditorExtras({ smartKeys }),
|
||||
path: "",
|
||||
softWrap: false,
|
||||
showWhitespace: false,
|
||||
hardWrapAt: 120,
|
||||
readOnly: false,
|
||||
language,
|
||||
}),
|
||||
] satisfies Extension[];
|
||||
|
||||
/** Type through the real input handlers, falling back to a plain insert. */
|
||||
const type = (v: EditorView, ch: string) => {
|
||||
const r = v.state.selection.main;
|
||||
const insert = () => v.state.update(v.state.replaceSelection(ch), { userEvent: "input.type" });
|
||||
if (!v.state.facet(EditorView.inputHandler).some((h) => h(v, r.from, r.to, ch, insert)))
|
||||
v.dispatch(v.state.replaceSelection(ch), { userEvent: "input.type" });
|
||||
};
|
||||
|
||||
/** Types `<` at the caret (marked `|`) in `language` (whose parser is attached) and checks the doc. */
|
||||
const pair = (language: string | undefined, support: Extension, text: string, expected: string) => {
|
||||
const v = make(text, [support, prefs(language, {})]);
|
||||
type(v, "<");
|
||||
expect(show(v)).toBe(expected);
|
||||
};
|
||||
|
||||
const JAVA = java();
|
||||
const TS = javascript({ typescript: true });
|
||||
const TSX = javascript({ jsx: true, typescript: true });
|
||||
const JS = javascript();
|
||||
const KOTLIN = StreamLanguage.define(interpolationParser(kotlin, "Kotlin"));
|
||||
const CSHARP = StreamLanguage.define(interpolationParser(csharp, "C#"));
|
||||
let RUST: LanguageSupport;
|
||||
beforeAll(async () => {
|
||||
RUST = (await cmLanguages.find((l) => l.name === "Rust")!.load()) as LanguageSupport;
|
||||
});
|
||||
|
||||
it("pairs after a type name in Java: fields, type arguments and class declarations", () => {
|
||||
pair("java", JAVA, "class Box|", "class Box<|>");
|
||||
pair("java", JAVA, "class A {\n java.util.List|\n}", "class A {\n java.util.List<|>\n}");
|
||||
pair("java", JAVA, "class A {\n java.util.Map<String, |\n}", "class A {\n java.util.Map<String, <|>\n}");
|
||||
pair("java", JAVA, "class A extends Comparable|", "class A extends Comparable<|>");
|
||||
});
|
||||
|
||||
it("does not pair comparisons or shifts in Java", () => {
|
||||
pair("java", JAVA, "class A {\n void f() {\n a |\n }\n}", "class A {\n void f() {\n a <|\n }\n}");
|
||||
pair("java", JAVA, "class A {\n void f() {\n i |\n }\n}", "class A {\n void f() {\n i <|\n }\n}");
|
||||
pair("java", JAVA, "class A {\n void f() {\n x |\n }\n}", "class A {\n void f() {\n x <|\n }\n}");
|
||||
});
|
||||
|
||||
it("does not pair inside Java strings or comments", () => {
|
||||
pair("java", JAVA, 'class A {\n String s = "abc|";\n}', 'class A {\n String s = "abc<|";\n}');
|
||||
pair("java", JAVA, "class A {\n void f() {\n // abc|\n }\n}", "class A {\n void f() {\n // abc<|\n }\n}");
|
||||
});
|
||||
|
||||
it("pairs type positions in TypeScript but not expressions", () => {
|
||||
pair("typescript", TS, "const a: Array|", "const a: Array<|>");
|
||||
pair("typescript", TS, "const a = new Array|", "const a = new Array<|>");
|
||||
pair("typescript", TS, "const a: Map<string, |", "const a: Map<string, <|>");
|
||||
pair("typescript", TS, "function f|", "function f<|>");
|
||||
pair("typescript", TS, "if (a |", "if (a <|");
|
||||
pair("typescript", TS, "const y = x |", "const y = x <|");
|
||||
pair("typescript", TS, 'const s = "abc|"', 'const s = "abc<|"');
|
||||
});
|
||||
|
||||
it("never pairs JSX / HTML tags in TSX", () => {
|
||||
pair("typescriptreact", TSX, "const x = |", "const x = <|");
|
||||
pair("typescriptreact", TSX, "return |", "return <|");
|
||||
// Type syntax still pairs there.
|
||||
pair("typescriptreact", TSX, "const a: Array|", "const a: Array<|>");
|
||||
});
|
||||
|
||||
it("pairs Rust type identifiers, fn names and turbofish", () => {
|
||||
pair("rust", RUST, "let v: Vec|", "let v: Vec<|>");
|
||||
pair("rust", RUST, "let v: Vec<Vec|", "let v: Vec<Vec<|>");
|
||||
pair("rust", RUST, "fn f|", "fn f<|>");
|
||||
pair("rust", RUST, "let v: Vec<_> = x.iter().collect::|", "let v: Vec<_> = x.iter().collect::<|>");
|
||||
});
|
||||
|
||||
it("does not pair comparisons or shifts in Rust", () => {
|
||||
pair("rust", RUST, "fn f() {\n let c = a |\n}", "fn f() {\n let c = a <|\n}");
|
||||
pair("rust", RUST, "fn f() {\n while i |\n}", "fn f() {\n while i <|\n}");
|
||||
pair("rust", RUST, "fn f() {\n let c = x |\n}", "fn f() {\n let c = x <|\n}");
|
||||
});
|
||||
|
||||
it("pairs capitalized type positions in Kotlin (stream parser)", () => {
|
||||
pair("kotlin", KOTLIN, "class A {\n val x: List|\n}", "class A {\n val x: List<|>\n}");
|
||||
pair("kotlin", KOTLIN, "class Box|", "class Box<|>");
|
||||
// Kotlin puts a function's type parameters right after `fun`.
|
||||
pair("kotlin", KOTLIN, "fun |", "fun <|>");
|
||||
// Lowercase values are comparisons, not types.
|
||||
pair("kotlin", KOTLIN, "fun f() {\n val c = a |\n}", "fun f() {\n val c = a <|\n}");
|
||||
});
|
||||
|
||||
it("pairs capitalized type positions in C# (stream parser)", () => {
|
||||
pair("csharp", CSHARP, "class A {\n List|\n}", "class A {\n List<|>\n}");
|
||||
pair("csharp", CSHARP, "class Box|", "class Box<|>");
|
||||
pair(
|
||||
"csharp",
|
||||
CSHARP,
|
||||
"class A {\n void F() {\n var c = a |\n }\n}",
|
||||
"class A {\n void F() {\n var c = a <|\n }\n}",
|
||||
);
|
||||
});
|
||||
|
||||
it("languages without a rule never pair, even outside type syntax", () => {
|
||||
pair("javascript", JS, "let y = a|", "let y = a<|");
|
||||
pair(undefined, JS, "let y = List|", "let y = List<|");
|
||||
});
|
||||
|
||||
it("needs the Insert pair bracket option on", () => {
|
||||
const v = make("class A {\n java.util.List|\n}", [JAVA, prefs("java", { pairBrackets: false })]);
|
||||
type(v, "<");
|
||||
expect(show(v)).toBe("class A {\n java.util.List<|\n}");
|
||||
});
|
||||
|
||||
it("typing > steps over the inserted one, including out of nested pairs", () => {
|
||||
const v = make("class A {\n java.util.Map|\n}", [JAVA, prefs("java", {})]);
|
||||
type(v, "<");
|
||||
expect(show(v)).toBe("class A {\n java.util.Map<|>\n}");
|
||||
type(v, "String, java.util.List");
|
||||
expect(show(v)).toBe("class A {\n java.util.Map<String, java.util.List|>\n}");
|
||||
type(v, "<");
|
||||
expect(show(v)).toBe("class A {\n java.util.Map<String, java.util.List<|>>\n}");
|
||||
type(v, ">");
|
||||
expect(show(v)).toBe("class A {\n java.util.Map<String, java.util.List<>|>\n}");
|
||||
type(v, ">");
|
||||
expect(show(v)).toBe("class A {\n java.util.Map<String, java.util.List<>>|\n}");
|
||||
// A third > has no pair left to step over and inserts.
|
||||
type(v, ">");
|
||||
expect(show(v)).toBe("class A {\n java.util.Map<String, java.util.List<>>>|\n}");
|
||||
});
|
||||
|
||||
it("a > typed ahead of an existing, not-inserted > is kept as-is", () => {
|
||||
const v = make("class A {\n java.util.List<String|>\n}", [JAVA, prefs("java", {})]);
|
||||
type(v, ">");
|
||||
expect(show(v)).toBe("class A {\n java.util.List<String>|>\n}");
|
||||
});
|
||||
|
||||
it("pairs step out with Tab via the PAIRS table", () => {
|
||||
const v = make("class A {\n java.util.List|\n}", [JAVA, prefs("java", {})]);
|
||||
type(v, "<");
|
||||
expect(insertTab(v)).toBe(true);
|
||||
expect(show(v)).toBe("class A {\n java.util.List<>|\n}");
|
||||
// Once out, Tab inserts whitespace again.
|
||||
insertTab(v);
|
||||
expect(show(v)).toBe("class A {\n java.util.List<> |\n}");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* IDEA-style Tab, smart Backspace and Tab out of auto-inserted brackets and
|
||||
* quotes.
|
||||
* IDEA-style Tab, smart Backspace, smart angle brackets and Tab out of
|
||||
* auto-inserted brackets and quotes.
|
||||
*/
|
||||
import {
|
||||
EditorSelection,
|
||||
@@ -95,7 +95,8 @@ export const smartBackspace: Command = (view) => {
|
||||
|
||||
// --- Tab out of brackets / quotes ------------------------------------------------------------
|
||||
|
||||
const PAIRS: Record<string, string> = { "(": ")", "[": "]", "{": "}", '"': '"', "'": "'", "`": "`" };
|
||||
// `<>` pairs inserted by the smart angle brackets below land here too, so Tab steps over `>`.
|
||||
const PAIRS: Record<string, string> = { "(": ")", "[": "]", "{": "}", "<": ">", '"': '"', "'": "'", "`": "`" };
|
||||
|
||||
interface Scope {
|
||||
from: number;
|
||||
@@ -188,3 +189,191 @@ export const skipClosingQuote = EditorView.inputHandler.of((view, _from, _to, te
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
// --- smart angle brackets --------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `<` pairs into `<>` only where the syntax tree says a generic type argument
|
||||
* list starts, as in IDEA: after a type name in Java / Kotlin / C# /
|
||||
* TypeScript, or a Rust turbofish — under the existing Insert pair bracket
|
||||
* option, with no setting of its own. Languages without a rule never pair, so
|
||||
* a plain `<` stays literal wherever it could be a tag opener or a comparison:
|
||||
* `a < b`, `i <= n` and `x << 2` never pair (the caret sits in a value
|
||||
* position there), nor do HTML / JSX tags or literals.
|
||||
*/
|
||||
|
||||
/** The identifier immediately before `pos`, or "" when the caret is not right after one. */
|
||||
function wordBefore(state: EditorState, pos: number): string {
|
||||
const line = state.doc.lineAt(pos);
|
||||
return /[A-Za-z_$][\w$]*$/.exec(line.text.slice(0, pos - line.from))?.[0] ?? "";
|
||||
}
|
||||
|
||||
/** The caret is inside a literal: never pair there, whatever the language rule says. */
|
||||
function inLiteral(state: EditorState, pos: number): boolean {
|
||||
for (
|
||||
let n: ReturnType<ReturnType<typeof syntaxTree>["resolveInner"]> | null = syntaxTree(state).resolveInner(pos, -1);
|
||||
n;
|
||||
n = n.parent
|
||||
)
|
||||
if (/string|comment|template|rune/i.test(n.name)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Java: a type name (`List`, `new ArrayList`), inside type arguments, or a generic-able declaration name. */
|
||||
function javaTypePosition(state: EditorState, pos: number): boolean {
|
||||
const n = syntaxTree(state).resolveInner(pos, -1);
|
||||
const parent = n.parent;
|
||||
if (n.name === "TypeName" || n.name === "TypeArguments" || parent?.name === "TypeArguments") return true;
|
||||
if (
|
||||
n.name === "Definition" &&
|
||||
parent &&
|
||||
[
|
||||
"ClassDeclaration",
|
||||
"InterfaceDeclaration",
|
||||
"EnumDeclaration",
|
||||
"RecordDeclaration",
|
||||
"ConstructorDeclaration",
|
||||
].includes(parent.name)
|
||||
)
|
||||
return true;
|
||||
// A recovered expression identifier only starts a type argument list when it
|
||||
// is a type by convention (`List<String> x;` mid-typing): values are
|
||||
// lowercase, so `a < b` / `i <= n` / `x << 2` stay comparisons.
|
||||
if (n.name === "Identifier" && parent?.name !== "MarkerAnnotation" && parent?.name !== "Annotation")
|
||||
return /^[A-Z]/.test(wordBefore(state, pos));
|
||||
return false;
|
||||
}
|
||||
|
||||
/** TypeScript: type names and type argument lists, or a generic-able declaration name. */
|
||||
function tsTypePosition(state: EditorState, pos: number): boolean {
|
||||
const n = syntaxTree(state).resolveInner(pos, -1);
|
||||
const parent = n.parent;
|
||||
if (n.name === "TypeName" || n.name === "TypeArgList" || parent?.name === "TypeArgList") return true;
|
||||
if (n.name === "TypeDefinition" && parent && ["TypeParamList", "InterfaceDeclaration"].includes(parent.name))
|
||||
return true;
|
||||
// The name of a class / function declaration, `new Array<`, or a superclass.
|
||||
if (
|
||||
n.name === "VariableDefinition" &&
|
||||
parent &&
|
||||
["ClassDeclaration", "FunctionDeclaration", "InterfaceDeclaration"].includes(parent.name)
|
||||
)
|
||||
return true;
|
||||
if (n.name === "VariableName" && parent && ["NewExpression", "ClassDeclaration"].includes(parent.name)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Rust: type identifiers (`Vec`), a `fn` / `trait` / `struct` name, `impl<`, or a turbofish `::<`. */
|
||||
function rustTypePosition(state: EditorState, pos: number): boolean {
|
||||
const n = syntaxTree(state).resolveInner(pos, -1);
|
||||
if (n.name === "TypeIdentifier" || n.name === "TypeArgList" || n.name === "GenericFunction" || n.name === "impl")
|
||||
return true;
|
||||
if (n.name === "::" && n.parent?.name === "GenericFunction") return true;
|
||||
if (n.name === "BoundIdentifier") return n.parent?.name === "FunctionItem";
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kotlin and C# use stream parsers here, whose trees are flat token lists
|
||||
* without type contexts — so the rule is the token before the caret: a type /
|
||||
* declaration identifier, capitalized as the language's types are (`List`,
|
||||
* `Box`, `fun <T>`), never a lowercase value (`a < b`).
|
||||
*/
|
||||
function cLikeTypePosition(state: EditorState, pos: number): boolean {
|
||||
let token: ReturnType<ReturnType<typeof syntaxTree>["resolveInner"]> | null = null;
|
||||
for (let cur = syntaxTree(state).topNode.firstChild; cur; cur = cur.nextSibling)
|
||||
if (cur.to <= pos && /^[ \t]*$/.test(state.sliceDoc(cur.to, pos))) token = cur;
|
||||
if (!token) return false;
|
||||
// Kotlin puts a function's type parameters right after the `fun` keyword.
|
||||
if (token.name === "keyword") return state.sliceDoc(token.from, token.to) === "fun";
|
||||
return /^(?:typeName|variableName)/.test(token.name) && /^[A-Z]/.test(state.sliceDoc(token.from, token.to));
|
||||
}
|
||||
|
||||
const ANGLE_PAIR_RULES: Record<string, (state: EditorState, pos: number) => boolean> = {
|
||||
java: javaTypePosition,
|
||||
typescript: tsTypePosition,
|
||||
typescriptreact: tsTypePosition,
|
||||
kotlin: cLikeTypePosition,
|
||||
csharp: cLikeTypePosition,
|
||||
rust: rustTypePosition,
|
||||
};
|
||||
|
||||
/** The `<>` pairs the smart angle brackets inserted; `>` steps over their closing half. */
|
||||
const angleScopes = StateField.define<readonly Scope[]>({
|
||||
create: () => [],
|
||||
update(scopes, tr) {
|
||||
for (const e of tr.effects) if (e.is(leaveScopes)) scopes = scopes.filter((s) => !e.value.includes(s.to));
|
||||
if (!tr.docChanged) return scopes;
|
||||
const caret = tr.startState.selection.main.head;
|
||||
let next: Scope[] = scopes.filter((s) => {
|
||||
let keep = true;
|
||||
tr.changes.iterChangedRanges((fromA, toA) => {
|
||||
const outside = fromA < s.from || toA > s.to;
|
||||
if (outside && (caret < s.from || caret > s.to || (toA >= s.from && fromA <= s.to))) keep = false;
|
||||
});
|
||||
return keep;
|
||||
});
|
||||
next = next.map((s) => ({ from: tr.changes.mapPos(s.from, -1), to: tr.changes.mapPos(s.to, 1) }));
|
||||
return [...next, ...insertedAnglePairs(tr)];
|
||||
},
|
||||
});
|
||||
|
||||
/** Empty scopes between the `<>` pairs a typing transaction auto-inserted. */
|
||||
function insertedAnglePairs(tr: Transaction): Scope[] {
|
||||
if (!tr.isUserEvent("input.type")) return [];
|
||||
const out: Scope[] = [];
|
||||
tr.changes.iterChanges((_fa, _ta, fromB, toB, inserted) => {
|
||||
if (toB - fromB !== 2 || inserted.toString() !== "<>") return;
|
||||
const inside = fromB + 1;
|
||||
if (tr.newSelection.ranges.some((r) => r.empty && r.head === inside)) out.push({ from: inside, to: inside });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart Keys › Insert pair bracket, for `<`: pairs it where the language's
|
||||
* rule sees a type argument list start; typing `>` steps over the inserted
|
||||
* half (Tab-out goes through `PAIRS`). No rule, no pairing.
|
||||
*/
|
||||
export function smartAngleBrackets(language: string | undefined): Extension {
|
||||
const isTypePosition = ANGLE_PAIR_RULES[language ?? ""];
|
||||
if (!isTypePosition) return [];
|
||||
return [
|
||||
angleScopes,
|
||||
EditorView.inputHandler.of((view, _from, _to, text) => {
|
||||
const { state } = view;
|
||||
if (state.readOnly || state.selection.ranges.some((r) => !r.empty)) return false;
|
||||
if (text === "<") {
|
||||
if (!state.selection.ranges.every((r) => !inLiteral(state, r.head) && isTypePosition(state, r.head)))
|
||||
return false;
|
||||
view.dispatch(
|
||||
state.changeByRange((r) => ({
|
||||
changes: { from: r.from, to: r.to, insert: "<>" },
|
||||
range: EditorSelection.cursor(r.from + 1),
|
||||
})),
|
||||
{ scrollIntoView: true, userEvent: "input.type" },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (text === ">") {
|
||||
const scopes = state.field(angleScopes, false) ?? [];
|
||||
if (
|
||||
!state.selection.ranges.every(
|
||||
(r) => state.sliceDoc(r.head, r.head + 1) === ">" && scopes.some((s) => s.to === r.head),
|
||||
)
|
||||
)
|
||||
return false;
|
||||
view.dispatch({
|
||||
selection: EditorSelection.create(
|
||||
state.selection.ranges.map((r) => EditorSelection.cursor(r.head + 1)),
|
||||
state.selection.mainIndex,
|
||||
),
|
||||
effects: leaveScopes.of(state.selection.ranges.map((r) => r.head)),
|
||||
scrollIntoView: true,
|
||||
userEvent: "input.type",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -76,6 +76,20 @@
|
||||
font-size: var(--fs-small);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.customActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.customNotice {
|
||||
margin: 0;
|
||||
padding: 0 12px 6px;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
@@ -253,6 +267,10 @@
|
||||
font-size: var(--fs-small);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Inside a dialog the form needs the body inset the dialog doesn't give it. */
|
||||
.dialogForm {
|
||||
padding: 12px 16px 4px;
|
||||
}
|
||||
.form > .rowHint:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -5,13 +5,20 @@
|
||||
* start with, and how it is launched. Launch edits are staged until Apply; installs run in
|
||||
* the background and keep running after the page closes.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import type { AgentConfigOption, AgentProvider, AgentRegistry, RegistryAgent, RequestId } from "@/bindings";
|
||||
import { useCallback, useEffect, useId, useMemo, useState, type ReactNode } from "react";
|
||||
import type {
|
||||
AgentConfigOption,
|
||||
AgentProvider,
|
||||
AgentRegistry,
|
||||
CustomAgentInput,
|
||||
RegistryAgent,
|
||||
RequestId,
|
||||
} from "@/bindings";
|
||||
import { isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { tx, useTxTemplate } from "@/platform/i18n";
|
||||
import { openExternalUrl } from "@/platform/openUrl";
|
||||
import clsx from "clsx";
|
||||
import { Button, Icon, IconButton, Input, List, ProgressBar, SearchField, Select, Spinner, T } from "@/ui";
|
||||
import { Button, Dialog, Icon, IconButton, Input, List, ProgressBar, SearchField, Select, Spinner, T } from "@/ui";
|
||||
import { useSettingsForm } from "@/workbench/contributions";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { providerIcon } from "./model";
|
||||
@@ -135,6 +142,9 @@ export function AgentList({
|
||||
const [job, setJob] = useState<Job | null>(null);
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
@@ -247,6 +257,51 @@ export function AgentList({
|
||||
.finally(() => setJob(null));
|
||||
};
|
||||
|
||||
const removeCustom = async (agent: Agent) => {
|
||||
if (
|
||||
!(await confirm({
|
||||
title: tx`Remove ${agent.name}`,
|
||||
message: tx`Remove ${agent.name} and delete its agent file? Its tasks stay, but can't continue without it.`,
|
||||
ok: tx("Remove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
setAgentError(agent.id, null);
|
||||
setRemoving(agent.id);
|
||||
try {
|
||||
await backend.removeCustom(agent.id);
|
||||
changed();
|
||||
} catch (e) {
|
||||
setAgentError(agent.id, errorText(e));
|
||||
} finally {
|
||||
setRemoving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const importAcpJson = async () => {
|
||||
setImporting(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const text = await backend.pickAcpJson();
|
||||
if (text == null) return;
|
||||
const outcomes = await backend.importAcp(text);
|
||||
const saved = outcomes.filter((o) => o.saved);
|
||||
const skipped = outcomes.filter((o) => !o.saved);
|
||||
const names = saved.map((o) => o.name).join(", ");
|
||||
const parts = [saved.length ? tx`Imported ${names} from acp.json.` : tx("Imported nothing from acp.json.")];
|
||||
if (skipped.length) {
|
||||
const details = skipped.map((o) => `${o.name} (${o.reason})`).join(", ");
|
||||
parts.push(tx`Skipped ${details}.`);
|
||||
}
|
||||
setNotice(parts.join(" "));
|
||||
changed();
|
||||
} catch (e) {
|
||||
setNotice(errorText(e));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (agent: Agent) => {
|
||||
if (
|
||||
!(await confirm({
|
||||
@@ -319,6 +374,19 @@ export function AgentList({
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
<div className={styles.customActions}>
|
||||
<Button size="small" variant="secondary" disabled={busy} onClick={() => setAdding(true)}>
|
||||
<T>Add Custom Agent…</T>
|
||||
</Button>
|
||||
<Button size="small" variant="secondary" disabled={busy || importing} onClick={() => void importAcpJson()}>
|
||||
{importing ? <T>Importing…</T> : <T>Import acp.json</T>}
|
||||
</Button>
|
||||
</div>
|
||||
{notice && (
|
||||
<p className={styles.customNotice} role="status">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{(!registry || registry.warning) && (
|
||||
<div className={styles.registryState}>
|
||||
{registryError ? (
|
||||
@@ -351,9 +419,20 @@ export function AgentList({
|
||||
error={errors[agent.id] ?? null}
|
||||
onInstall={() => void install(agent)}
|
||||
onRemove={() => void remove(agent)}
|
||||
onRemoveCustom={() => void removeCustom(agent)}
|
||||
onCancel={(request) => registryBackend.cancel(request)}
|
||||
/>
|
||||
)}
|
||||
{adding && (
|
||||
<CustomAgentModal
|
||||
backend={backend}
|
||||
onClose={() => setAdding(false)}
|
||||
onSaved={() => {
|
||||
setAdding(false);
|
||||
changed();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -409,6 +488,7 @@ function Details({
|
||||
error,
|
||||
onInstall,
|
||||
onRemove,
|
||||
onRemoveCustom,
|
||||
onCancel,
|
||||
}: {
|
||||
agent: Agent;
|
||||
@@ -421,10 +501,12 @@ function Details({
|
||||
error: string | null;
|
||||
onInstall: () => void;
|
||||
onRemove: () => void;
|
||||
onRemoveCustom: () => void;
|
||||
onCancel: (request: RequestId) => void;
|
||||
}) {
|
||||
const { provider, registry } = agent;
|
||||
const installed = provider?.source.kind === "registry" || (!provider && registry?.installed != null);
|
||||
const isCustom = provider?.source.kind === "custom";
|
||||
const canInstall = registry?.distribution != null;
|
||||
const update =
|
||||
provider?.source.kind === "registry" ? provider.source.update : registry?.update ? registry.version : null;
|
||||
@@ -481,6 +563,11 @@ function Details({
|
||||
{removing ? "Removing…" : "Remove"}
|
||||
</Button>
|
||||
)}
|
||||
{isCustom && (
|
||||
<Button size="small" disabled={busy} onClick={onRemoveCustom}>
|
||||
{removing ? "Removing…" : "Remove"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -524,6 +611,185 @@ function Details({
|
||||
);
|
||||
}
|
||||
|
||||
/** Suggests an agent-file id for a display name (`"My Agent!"` → `"my-agent"`). */
|
||||
function slugifyId(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/** Splits arguments on whitespace, honoring single and double quotes. */
|
||||
function parseArgs(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
let current = "";
|
||||
let quote: '"' | "'" | null = null;
|
||||
for (const ch of text.trim()) {
|
||||
if (quote) {
|
||||
if (ch === quote) quote = null;
|
||||
else current += ch;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
} else if (/\s/.test(ch)) {
|
||||
if (current) {
|
||||
out.push(current);
|
||||
current = "";
|
||||
}
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
if (current) out.push(current);
|
||||
return out;
|
||||
}
|
||||
|
||||
function CustomAgentModal({
|
||||
backend,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
backend: CommandsBackend;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const uid = useId();
|
||||
const [name, setName] = useState("");
|
||||
const [id, setId] = useState("");
|
||||
const [idTouched, setIdTouched] = useState(false);
|
||||
const [command, setCommand] = useState("");
|
||||
const [argsText, setArgsText] = useState("");
|
||||
const [envText, setEnvText] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const changeName = (next: string) => {
|
||||
setName(next);
|
||||
if (!idTouched) setId(slugifyId(next));
|
||||
};
|
||||
const canSave = name.trim().length > 0 && id.trim().length > 0 && command.trim().length > 0 && !saving;
|
||||
|
||||
const browse = async () => {
|
||||
try {
|
||||
const trimmed = name.trim();
|
||||
const title = trimmed ? tx`Select ${trimmed}` : tx("Select executable");
|
||||
const path = await backend.chooseExecutable(title, command);
|
||||
if (path) setCommand(path);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const input: CustomAgentInput = {
|
||||
id: id.trim(),
|
||||
name: name.trim(),
|
||||
command: command.trim(),
|
||||
args: parseArgs(argsText),
|
||||
env: parseEnv(envText),
|
||||
};
|
||||
await backend.saveCustom(input);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={tx("Add Custom Agent")}
|
||||
width={560}
|
||||
buttons={[
|
||||
{ label: "Add", role: "default", disabled: !canSave, onClick: () => void save() },
|
||||
{ label: "Cancel", role: "cancel", onClick: onClose },
|
||||
]}
|
||||
>
|
||||
<div className={`${styles.form} ${styles.dialogForm}`}>
|
||||
{error && (
|
||||
<p className={styles.error} role="alert" style={{ gridColumn: "1 / -1" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Row label={<T>Name</T>} htmlFor={`${uid}-name`}>
|
||||
<div className={styles.field}>
|
||||
<Input
|
||||
id={`${uid}-name`}
|
||||
data-autofocus
|
||||
value={name}
|
||||
placeholder={tx("My agent")}
|
||||
onChange={(e) => changeName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
<Row
|
||||
label={<T>ID</T>}
|
||||
htmlFor={`${uid}-id`}
|
||||
hint={<T>Letters, digits, dashes and underscores only; names the agent file.</T>}
|
||||
>
|
||||
<div className={styles.field}>
|
||||
<Input
|
||||
id={`${uid}-id`}
|
||||
mono
|
||||
value={id}
|
||||
placeholder="my-agent"
|
||||
onChange={(e) => {
|
||||
setId(e.target.value);
|
||||
setIdTouched(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
<Row label={<T>Command</T>} htmlFor={`${uid}-command`}>
|
||||
<div className={styles.field}>
|
||||
<Input
|
||||
id={`${uid}-command`}
|
||||
mono
|
||||
value={command}
|
||||
placeholder={tx("/path/to/agent-acp")}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
/>
|
||||
<IconButton icon="folder-open" title="Browse…" disabled={saving} onClick={() => void browse()} />
|
||||
</div>
|
||||
</Row>
|
||||
<Row
|
||||
label={<T>Arguments</T>}
|
||||
htmlFor={`${uid}-args`}
|
||||
hint={<T>Separate arguments with spaces; quote ones containing spaces.</T>}
|
||||
>
|
||||
<div className={styles.field}>
|
||||
<Input
|
||||
id={`${uid}-args`}
|
||||
mono
|
||||
value={argsText}
|
||||
placeholder="--acp"
|
||||
onChange={(e) => setArgsText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
<Row label={<T>Environment variables</T>} htmlFor={`${uid}-env`}>
|
||||
<div className={styles.field}>
|
||||
<Input
|
||||
id={`${uid}-env`}
|
||||
mono
|
||||
value={envText}
|
||||
placeholder="NAME=value; OTHER=value"
|
||||
onChange={(e) => setEnvText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function providerStatus(agent: AgentProvider): string {
|
||||
if (agent.source.kind === "registry") return tx`ACP Registry · ${agent.source.version}`;
|
||||
if (agent.source.kind === "custom") return tx("Agent file");
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.column {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.columnTitle {
|
||||
color: var(--fg-strong);
|
||||
font-size: var(--fs-small);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.items {
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-subtle);
|
||||
border-radius: var(--radius-control);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
|
||||
.pattern {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Settings › Tools › Agents › Command Execution Policy: whitelist (auto-approve)
|
||||
* and blacklist (auto-reject) command patterns evaluated on agent permission requests.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { validateAgentSecurity } from "@/platform/workflowSettings";
|
||||
import { Button, IconButton, T } from "@/ui";
|
||||
import { useSettingsForm } from "@/workbench/contributions";
|
||||
import styles from "./AgentPolicy.module.css";
|
||||
|
||||
export function AgentPolicyPage() {
|
||||
const form = useSettingsForm();
|
||||
const raw = form.useValue("agentSecurity");
|
||||
const policy = useMemo(() => validateAgentSecurity(raw), [raw]);
|
||||
const [allowDraft, setAllowDraft] = useState("");
|
||||
const [blockDraft, setBlockDraft] = useState("");
|
||||
|
||||
const addAllowed = () => {
|
||||
const pattern = allowDraft.trim();
|
||||
if (!pattern) return;
|
||||
setAllowDraft("");
|
||||
if (policy.allowedCommands.includes(pattern)) return;
|
||||
form.set("agentSecurity", { ...policy, allowedCommands: [...policy.allowedCommands, pattern] });
|
||||
};
|
||||
|
||||
const removeAllowed = (pattern: string) => {
|
||||
form.set("agentSecurity", {
|
||||
...policy,
|
||||
allowedCommands: policy.allowedCommands.filter((p) => p !== pattern),
|
||||
});
|
||||
};
|
||||
|
||||
const addBlocked = () => {
|
||||
const pattern = blockDraft.trim();
|
||||
if (!pattern) return;
|
||||
setBlockDraft("");
|
||||
if (policy.blockedCommands.includes(pattern)) return;
|
||||
form.set("agentSecurity", { ...policy, blockedCommands: [...policy.blockedCommands, pattern] });
|
||||
};
|
||||
|
||||
const removeBlocked = (pattern: string) => {
|
||||
form.set("agentSecurity", {
|
||||
...policy,
|
||||
blockedCommands: policy.blockedCommands.filter((p) => p !== pattern),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.body} aria-label="Command Execution Policy">
|
||||
<p className={styles.notice}>
|
||||
<T>
|
||||
Allowed commands run without asking: an entry matches that command, alone or with more arguments. Commands
|
||||
with substitutions, redirections or variable assignments always ask. Blocked commands are rejected and win
|
||||
over allowed ones; they support the * and ? wildcards.
|
||||
</T>
|
||||
</p>
|
||||
<div className={styles.grid}>
|
||||
<div className={styles.column}>
|
||||
<div className={styles.columnTitle}>
|
||||
<T>Allowed Commands (Whitelist)</T>
|
||||
</div>
|
||||
<div className={styles.items}>
|
||||
{policy.allowedCommands.map((pattern) => (
|
||||
<div key={pattern} className={styles.item}>
|
||||
<code className={styles.pattern}>{pattern}</code>
|
||||
<IconButton
|
||||
icon="close"
|
||||
size="small"
|
||||
title={tx`Remove ${pattern}`}
|
||||
onClick={() => removeAllowed(pattern)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.add}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={allowDraft}
|
||||
onChange={(e) => setAllowDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addAllowed()}
|
||||
placeholder={tx("e.g. cargo test*")}
|
||||
aria-label="Allowed command pattern"
|
||||
/>
|
||||
<Button size="small" aria-label="Add allowed command" onClick={addAllowed}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.column}>
|
||||
<div className={styles.columnTitle}>
|
||||
<T>Blocked Commands (Blacklist)</T>
|
||||
</div>
|
||||
<div className={styles.items}>
|
||||
{policy.blockedCommands.map((pattern) => (
|
||||
<div key={pattern} className={styles.item}>
|
||||
<code className={styles.pattern}>{pattern}</code>
|
||||
<IconButton
|
||||
icon="close"
|
||||
size="small"
|
||||
title={tx`Remove ${pattern}`}
|
||||
onClick={() => removeBlocked(pattern)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.add}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={blockDraft}
|
||||
onChange={(e) => setBlockDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addBlocked()}
|
||||
placeholder={tx("e.g. rm -rf *")}
|
||||
aria-label="Blocked command pattern"
|
||||
/>
|
||||
<Button size="small" aria-label="Add blocked command" onClick={addBlocked}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -256,6 +256,26 @@ describe("model and effort button", () => {
|
||||
expect(screen.queryByRole("button", { name: "Reasoning Effort" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tucks Fast into the model popup with effort", () => {
|
||||
const config = [
|
||||
...copilotConfig().filter((o) => o.category !== "thought_level"),
|
||||
{
|
||||
id: "fast",
|
||||
name: "Fast",
|
||||
category: "model_config",
|
||||
description: null,
|
||||
current: "false",
|
||||
via: "option" as const,
|
||||
values: [value("false", "Off"), value("true", "On")],
|
||||
},
|
||||
];
|
||||
render(<ConfigPickers config={config} onChange={vi.fn()} />);
|
||||
expect(screen.queryByRole("switch", { name: "Fast" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "Model & effort settings" });
|
||||
expect(within(dialog).getByRole("switch", { name: "Fast" })).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
it("steps through the advertised effort levels and picks a model from the popup", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ConfigPickers config={copilotConfig()} values={{ reasoning_effort: "high" }} onChange={onChange} />);
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { AgentConfigOption } from "@/bindings";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { Icon, IconButton, Popup, Select, T, Toggle } from "@/ui";
|
||||
import { SelectListbox, type SelectOption } from "@/ui/Select";
|
||||
import { mainOptions, modeIcon } from "./composerModel";
|
||||
import { mainOptions, modelConfigOptions, modeIcon } from "./composerModel";
|
||||
import styles from "./Composer.module.css";
|
||||
|
||||
export function configPickerOptions(option: AgentConfigOption): SelectOption[] {
|
||||
@@ -38,7 +38,8 @@ export function configPickerOptions(option: AgentConfigOption): SelectOption[] {
|
||||
|
||||
/** The on and off values of a two-state permissions option, or null when it has other values. */
|
||||
function switchValues(option: AgentConfigOption): { on: string; off: string } | null {
|
||||
if (option.category !== "permissions") return null;
|
||||
const isFast = option.id === "fast" || option.category === "model_config";
|
||||
if (option.category !== "permissions" && !isFast) return null;
|
||||
const on = option.values.find((v) => /^(on|true)$/i.test(v.value))?.value;
|
||||
const off = option.values.find((v) => /^(off|false)$/i.test(v.value))?.value;
|
||||
return on && off ? { on, off } : null;
|
||||
@@ -63,24 +64,33 @@ export function ConfigPickers({
|
||||
const options = mainOptions(config);
|
||||
const valueOf = (o: AgentConfigOption) => values?.[o.id] ?? o.current ?? null;
|
||||
const model = options.find((o) => o.category === "model");
|
||||
const effort = model && options.find((o) => o.category === "thought_level" && o.values.length > 0);
|
||||
const effort = config.find((o) => o.category === "thought_level" && o.values.length > 0);
|
||||
const modelConfigs = modelConfigOptions(config);
|
||||
const combinedModel = model && (effort || modelConfigs.length > 0);
|
||||
const tuckedModelConfig = new Set(modelConfigs.map((o) => o.id));
|
||||
const mode = options.find((o) => o.category === "mode");
|
||||
const allowAll = mode && options.find((o) => switchValues(o));
|
||||
const inline = options.filter((o) => o !== effort && o !== allowAll);
|
||||
// The compact menu holds everything but the model button (which carries the effort).
|
||||
const secondary = options.filter((o) => o.category !== "model" && o !== effort);
|
||||
const inline = options.filter(
|
||||
(o) => o !== effort && o !== allowAll && !(combinedModel && tuckedModelConfig.has(o.id)),
|
||||
);
|
||||
// The compact menu holds everything but the model button (effort + model parameters).
|
||||
const secondary = options.filter(
|
||||
(o) => o.category !== "model" && o !== effort && !(combinedModel && tuckedModelConfig.has(o.id)),
|
||||
);
|
||||
const approval = secondary.find((o) => o.category === "approval" || o.category === "mode");
|
||||
const summary = secondary.map((o) => `${tx(o.name)}: ${selected(o, valueOf(o))?.name ?? tx("Default")}`).join(" · ");
|
||||
return (
|
||||
<>
|
||||
{inline.map((o) => (
|
||||
<span key={o.id} className={clsx(styles.configPicker, o.category !== "model" && styles.inlineSetting)}>
|
||||
{o === model && effort ? (
|
||||
{o === model && combinedModel ? (
|
||||
<ModelEffortPicker
|
||||
model={o}
|
||||
modelValue={valueOf(o)}
|
||||
effort={effort}
|
||||
effortValue={valueOf(effort)}
|
||||
effortValue={effort ? valueOf(effort) : null}
|
||||
modelConfigs={modelConfigs}
|
||||
valueOf={valueOf}
|
||||
onChange={onChange}
|
||||
/>
|
||||
) : o === mode && allowAll ? (
|
||||
@@ -193,20 +203,33 @@ function ModelEffortPicker({
|
||||
modelValue,
|
||||
effort,
|
||||
effortValue,
|
||||
modelConfigs,
|
||||
valueOf,
|
||||
onChange,
|
||||
}: {
|
||||
model: AgentConfigOption;
|
||||
modelValue: string | null;
|
||||
effort: AgentConfigOption;
|
||||
effort?: AgentConfigOption;
|
||||
effortValue: string | null;
|
||||
modelConfigs: AgentConfigOption[];
|
||||
valueOf: (o: AgentConfigOption) => string | null;
|
||||
onChange: (option: string, value: string) => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchor = useRef<HTMLButtonElement>(null);
|
||||
const modelName = selected(model, modelValue)?.name ?? tx("Default");
|
||||
const effortName = selected(effort, effortValue)?.name ?? tx("Default");
|
||||
const summary = `${tx(model.name)}: ${modelName} · ${tx(effort.name)}: ${effortName}`;
|
||||
const effortName = effort ? (selected(effort, effortValue)?.name ?? tx("Default")) : null;
|
||||
const modelConfigSummary = modelConfigs
|
||||
.map((o) => {
|
||||
const current = selected(o, valueOf(o));
|
||||
return current ? `${tx(o.name)}: ${current.name}` : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const summary = [effortName ? `${tx(effort!.name)}: ${effortName}` : null, modelConfigSummary]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -216,22 +239,24 @@ function ModelEffortPicker({
|
||||
aria-label={tx(model.name)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={summary}
|
||||
title={`${tx(model.name)}: ${modelName}${summary ? ` · ${summary}` : ""}`}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className={styles.chipValue}>{modelName}</span>
|
||||
{/* Every level's name in one cell, so the button keeps the widest one's width. */}
|
||||
<span className={styles.chipDetail}>
|
||||
{[...new Set([effortName, ...effort.values.map((v) => v.name)])].map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className={name === effortName ? undefined : styles.chipSizer}
|
||||
aria-hidden={name !== effortName}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{effort && (
|
||||
<span className={styles.chipDetail}>
|
||||
{[...new Set([effortName, ...effort.values.map((v) => v.name)])].map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className={name === effortName ? undefined : styles.chipSizer}
|
||||
aria-hidden={name !== effortName}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<Icon name="chevron-down" size={14} className={styles.chipChevron} />
|
||||
</button>
|
||||
<Popup
|
||||
@@ -241,13 +266,21 @@ function ModelEffortPicker({
|
||||
aria-label={tx("Model & effort settings")}
|
||||
className={styles.modelEffortPopup}
|
||||
>
|
||||
<div className={styles.effortBlock}>
|
||||
<div className={styles.effortHeader}>
|
||||
<span className={styles.settingsLabel}>{tx(effort.name)}</span>
|
||||
<span className={styles.effortValue}>{effortName}</span>
|
||||
{effort && (
|
||||
<div className={styles.effortBlock}>
|
||||
<div className={styles.effortHeader}>
|
||||
<span className={styles.settingsLabel}>{tx(effort.name)}</span>
|
||||
<span className={styles.effortValue}>{effortName}</span>
|
||||
</div>
|
||||
<EffortSlider option={effort} value={effortValue} onChange={(v) => onChange(effort.id, v)} />
|
||||
</div>
|
||||
<EffortSlider option={effort} value={effortValue} onChange={(v) => onChange(effort.id, v)} />
|
||||
</div>
|
||||
)}
|
||||
{modelConfigs.map((o) => (
|
||||
<div key={o.id} className={styles.modeFooter}>
|
||||
<span title={o.description ?? undefined}>{tx(o.name)}</span>
|
||||
<ConfigPicker option={o} value={valueOf(o)} onChange={onChange} framed />
|
||||
</div>
|
||||
))}
|
||||
<SelectListbox
|
||||
aria-label={tx(model.name)}
|
||||
value={modelValue}
|
||||
|
||||
@@ -105,6 +105,7 @@ import {
|
||||
type SubagentEntry,
|
||||
} from "./model";
|
||||
import {
|
||||
acceptChanges,
|
||||
cancelTask,
|
||||
stopBackgroundTask,
|
||||
loadEntries,
|
||||
@@ -2848,6 +2849,48 @@ function ChangesPane({
|
||||
});
|
||||
}
|
||||
};
|
||||
const accept = async () => {
|
||||
const chosen = (changes ?? []).filter((c) => selected.has(c.path));
|
||||
const count = chosen.length || changes?.length || 0;
|
||||
if (!count) return;
|
||||
const name = basename((chosen[0] ?? changes![0]!).path);
|
||||
const ok = await confirm({
|
||||
title: "Accept Changes",
|
||||
message: (
|
||||
<>
|
||||
<p>
|
||||
{count === 1
|
||||
? tx`Copy the changes to ${name} into the local checkout?`
|
||||
: tx`Copy the changes to ${count} files into the local checkout?`}
|
||||
</p>
|
||||
<p>{tx("They land uncommitted. Files you changed yourself since the task started are left alone.")}</p>
|
||||
</>
|
||||
),
|
||||
ok: "Accept",
|
||||
icon: "warning",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const outcome = await acceptChanges(task.id, chosen.length ? chosen.map((c) => c.relPath) : null);
|
||||
setSelected(new Set());
|
||||
if (outcome.skipped.length) {
|
||||
const skippedNames = outcome.skipped.map((s) => s.path).join(", ");
|
||||
notifications().notify({
|
||||
level: "warning",
|
||||
title: tx("Changes accepted, some files kept"),
|
||||
body: tx`You changed them yourself, accept them by hand: ${skippedNames}.`,
|
||||
});
|
||||
} else {
|
||||
notifications().notify({ level: "info", title: tx("Changes accepted") });
|
||||
}
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: tx("Cannot accept the changes"),
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<aside className={styles.changes} aria-label={tx("Changes")}>
|
||||
<div className={styles.changesHeader}>
|
||||
@@ -2861,6 +2904,14 @@ function ChangesPane({
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.bannerSpacer} />
|
||||
{task.isolation === "worktree" && (
|
||||
<IconButton
|
||||
icon="check"
|
||||
title={selected.size ? "Accept Selected Changes…" : "Accept All Changes…"}
|
||||
disabled={!changes?.length || task.status === "starting" || task.status === "running"}
|
||||
onClick={() => void accept()}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon="revert"
|
||||
title={selected.size ? "Rollback Selected Changes…" : "Rollback All Changes…"}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { create } from "zustand";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { buildPageTree } from "@/features/settings/model/pages";
|
||||
import { SettingsFormContext, useSettingsPages, type SettingsForm } from "@/workbench/contributions";
|
||||
import { AgentPolicyPage } from "./AgentPolicyPage";
|
||||
import { activateSettingsPages } from "./index";
|
||||
import { AGENTS_GROUP, POLICY_PAGE } from "./model";
|
||||
|
||||
const useFormStore = create<{ agentSecurity?: unknown; staged: unknown[] }>(() => ({
|
||||
agentSecurity: undefined,
|
||||
staged: [],
|
||||
}));
|
||||
|
||||
function FormWrapper({ children }: { children: ReactNode }) {
|
||||
const form = useMemo<SettingsForm>(
|
||||
() => ({
|
||||
scope: "global",
|
||||
apply: async () => true,
|
||||
useValue: (key) => useFormStore((s) => (key === "agentSecurity" ? s.agentSecurity : undefined)),
|
||||
set: (key, value) => {
|
||||
if (key === "agentSecurity") {
|
||||
useFormStore.setState((s) => ({ agentSecurity: value, staged: [...s.staged, value] }));
|
||||
}
|
||||
},
|
||||
setMapEntry: () => undefined,
|
||||
useExternal: (_key, base) => ({ value: base, current: base, set: () => undefined, saving: false }),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
return <SettingsFormContext.Provider value={form}>{children}</SettingsFormContext.Provider>;
|
||||
}
|
||||
|
||||
describe("Command Execution Policy sub-page", () => {
|
||||
it("registers under Agents group in settings", () => {
|
||||
const dispose = activateSettingsPages();
|
||||
try {
|
||||
const tree = buildPageTree(useSettingsPages.getState().pages.values());
|
||||
const agentsGroup = tree.find((n) => n.title === AGENTS_GROUP);
|
||||
expect(agentsGroup).toBeDefined();
|
||||
const policyNode = agentsGroup?.children.find((n) => n.key === POLICY_PAGE);
|
||||
expect(policyNode?.title).toBe("Command Execution Policy");
|
||||
expect(policyNode?.page?.projectScope).toBe(true);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("stages whitelist and blacklist pattern edits via SettingsForm", () => {
|
||||
useFormStore.setState({ agentSecurity: undefined, staged: [] });
|
||||
render(
|
||||
<FormWrapper>
|
||||
<AgentPolicyPage />
|
||||
</FormWrapper>,
|
||||
);
|
||||
|
||||
const policy = screen.getByRole("region", { name: "Command Execution Policy" });
|
||||
expect(within(policy).getByText("rm -rf *")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(within(policy).getByRole("textbox", { name: "Allowed command pattern" }), {
|
||||
target: { value: "cargo test*" },
|
||||
});
|
||||
fireEvent.click(within(policy).getByRole("button", { name: "Add allowed command" }));
|
||||
expect(within(policy).getByText("cargo test*")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(policy).getByRole("button", { name: "Remove cargo test*" }));
|
||||
expect(within(policy).queryByText("cargo test*")).toBeNull();
|
||||
|
||||
fireEvent.change(within(policy).getByRole("textbox", { name: "Blocked command pattern" }), {
|
||||
target: { value: "git push --force*" },
|
||||
});
|
||||
fireEvent.click(within(policy).getByRole("button", { name: "Add blocked command" }));
|
||||
expect(within(policy).getByText("git push --force*")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(policy).getByRole("button", { name: "Remove git push --force*" }));
|
||||
expect(within(policy).queryByText("git push --force*")).toBeNull();
|
||||
expect(useFormStore.getState().staged).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { AgentProvider, AgentRegistry, InstallEvent, RegistryAgent, RequestId } from "@/bindings";
|
||||
import type {
|
||||
AcpImportOutcome,
|
||||
AgentProvider,
|
||||
AgentRegistry,
|
||||
InstallEvent,
|
||||
RegistryAgent,
|
||||
RequestId,
|
||||
} from "@/bindings";
|
||||
import { IpcError } from "@/platform/ipc";
|
||||
import { setLocale, tx } from "@/platform/i18n";
|
||||
import { iconMarkup } from "@/ui/icons";
|
||||
@@ -366,6 +373,10 @@ class FakeCommands implements CommandsBackend {
|
||||
saved: [string, string | null][] = [];
|
||||
providers = vi.fn(async () => this.list);
|
||||
chooseExecutable = vi.fn(async () => "/chosen/agent");
|
||||
saveCustom = vi.fn(async () => {});
|
||||
removeCustom = vi.fn(async () => {});
|
||||
importAcp = vi.fn(async (): Promise<AcpImportOutcome[]> => []);
|
||||
pickAcpJson = vi.fn(async (): Promise<string | null> => null);
|
||||
savedEnv: [string, Record<string, string>][] = [];
|
||||
async setEnv(id: string, env: Record<string, string>) {
|
||||
this.savedEnv.push([id, env]);
|
||||
@@ -783,3 +794,95 @@ describe("the agent list", () => {
|
||||
expect(screen.getByText(/after its first session/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom agents", () => {
|
||||
let dispose = () => {};
|
||||
beforeEach(() => openSettings(REGISTRY_PAGE));
|
||||
afterEach(() => {
|
||||
closeSettings();
|
||||
dispose();
|
||||
});
|
||||
|
||||
const customProvider = (): AgentProvider => ({
|
||||
...claudeProvider("/opt/mine"),
|
||||
id: "mine",
|
||||
name: "Mine",
|
||||
args: ["acp"],
|
||||
defaultProgram: null,
|
||||
defaultArgs: [],
|
||||
loginHint: null,
|
||||
commandEditable: false,
|
||||
commandReadOnlyReason: "customAgent",
|
||||
source: { kind: "custom" },
|
||||
});
|
||||
|
||||
const openList = (commands: FakeCommands) => {
|
||||
const backend = new FakeBackend();
|
||||
dispose = registerSettingsPage({
|
||||
id: REGISTRY_PAGE,
|
||||
title: "Agents",
|
||||
group: "Tools",
|
||||
component: () => <RegistryPage backend={backend} commandBackend={commands} />,
|
||||
});
|
||||
render(
|
||||
<>
|
||||
<SettingsDialog />
|
||||
<MessagesHost />
|
||||
</>,
|
||||
);
|
||||
};
|
||||
|
||||
it("adds a custom agent through the modal, suggesting the id", async () => {
|
||||
const commands = new FakeCommands();
|
||||
openList(commands);
|
||||
await waitFor(() => expect(commands.providers).toHaveBeenCalled());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Custom Agent…" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Add Custom Agent" });
|
||||
fireEvent.change(within(dialog).getByRole("textbox", { name: "Name" }), { target: { value: "My Agent" } });
|
||||
expect(within(dialog).getByRole("textbox", { name: "ID" })).toHaveValue("my-agent");
|
||||
fireEvent.change(within(dialog).getByRole("textbox", { name: "Command" }), {
|
||||
target: { value: "/opt/mine" },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Add" }));
|
||||
await waitFor(() =>
|
||||
expect(commands.saveCustom).toHaveBeenCalledWith({
|
||||
id: "my-agent",
|
||||
name: "My Agent",
|
||||
command: "/opt/mine",
|
||||
args: [],
|
||||
env: {},
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Add Custom Agent" })).toBeNull());
|
||||
expect(commands.providers.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("removes a custom agent after asking", async () => {
|
||||
const commands = new FakeCommands();
|
||||
commands.list = [customProvider()];
|
||||
openList(commands);
|
||||
await waitFor(() => expect(row("Mine")).toBeInTheDocument());
|
||||
fireEvent.click(within(row("Mine")).getByRole("button", { name: "Remove" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Remove Mine" });
|
||||
expect(dialog).toHaveTextContent("Remove Mine and delete its agent file?");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Remove" }));
|
||||
await waitFor(() => expect(commands.removeCustom).toHaveBeenCalledWith("mine"));
|
||||
expect(commands.providers.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("imports acp.json and reports saved and skipped entries", async () => {
|
||||
const commands = new FakeCommands();
|
||||
commands.pickAcpJson.mockResolvedValueOnce('{"agent_servers": {}}');
|
||||
commands.importAcp.mockResolvedValueOnce([
|
||||
{ name: "My Agent", id: "my-agent", saved: true, reason: "" },
|
||||
{ name: "Taken", id: "taken", saved: false, reason: "there is already a custom agent 'taken'" },
|
||||
]);
|
||||
openList(commands);
|
||||
await waitFor(() => expect(commands.providers).toHaveBeenCalled());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Import acp.json" }));
|
||||
await screen.findByText(
|
||||
"Imported My Agent from acp.json. Skipped Taken (there is already a custom agent 'taken').",
|
||||
);
|
||||
expect(commands.importAcp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
applyCompletion,
|
||||
atEdgeLine,
|
||||
bypassDefaults,
|
||||
cursorModelDefaults,
|
||||
modelConfigOptions,
|
||||
canSend,
|
||||
completionAt,
|
||||
isPathQuery,
|
||||
@@ -198,15 +200,29 @@ describe("agent composer", () => {
|
||||
expect(atEdgeLine("a\nb", 1, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults Cursor fast mode off when available", () => {
|
||||
const fast = option("model_config", ["false", "true"], "true");
|
||||
fast.id = "fast";
|
||||
fast.name = "Fast";
|
||||
expect(cursorModelDefaults("cursor", [fast])).toEqual({ fast: "false" });
|
||||
expect(cursorModelDefaults("claude", [fast])).toEqual({});
|
||||
expect(modelConfigOptions([fast]).map((o) => o.id)).toEqual(["fast"]);
|
||||
});
|
||||
|
||||
it("picks the main settings and cycles the mode", () => {
|
||||
const config = [option("fast", ["on"], null), option("mode", ["ask", "code"], "code"), option("model", ["m"], "m")];
|
||||
expect(mainOptions(config).map((o) => o.id)).toEqual(["model", "mode"]);
|
||||
expect(mainOptions(config).map((o) => o.id)).toEqual(["model", "mode", "fast"]);
|
||||
expect(nextValue(config[1]!)).toBe("ask");
|
||||
const permissions = option("allow_all", ["off", "on"], "off");
|
||||
permissions.category = "permissions";
|
||||
expect(mainOptions([...config, permissions]).map((o) => o.id)).toEqual(["model", "mode", permissions.id]);
|
||||
expect(mainOptions([...config, permissions]).map((o) => o.id)).toEqual(["model", "mode", permissions.id, "fast"]);
|
||||
const collaboration = option("collaboration_mode", ["default", "plan"], "default");
|
||||
expect(mainOptions([...config, collaboration]).map((o) => o.id)).toEqual(["model", "mode", collaboration.id]);
|
||||
expect(mainOptions([...config, collaboration]).map((o) => o.id)).toEqual([
|
||||
"model",
|
||||
"mode",
|
||||
collaboration.id,
|
||||
"fast",
|
||||
]);
|
||||
const approvals = {
|
||||
...option("openidea.cursor.approval", ["ask", "bypass"], "ask"),
|
||||
category: "approval",
|
||||
@@ -216,6 +232,7 @@ describe("agent composer", () => {
|
||||
"mode",
|
||||
permissions.id,
|
||||
approvals.id,
|
||||
"fast",
|
||||
]);
|
||||
expect(
|
||||
[
|
||||
|
||||
@@ -171,10 +171,32 @@ export function atEdgeLine(text: string, caret: number, dir: -1 | 1): boolean {
|
||||
return dir < 0 ? !text.slice(0, caret).includes("\n") : !text.slice(caret).includes("\n");
|
||||
}
|
||||
|
||||
/** The settings shown in the composer: model, mode, collaboration mode, permissions, approvals, effort. */
|
||||
const matchesCategory = (o: AgentConfigOption, category: string) =>
|
||||
o.category === category || (category === "model_config" && (o.category === "model_config" || o.id === "fast"));
|
||||
|
||||
/** Cursor / ACP model parameters (`fast`, `context`…), separate from the model id itself. */
|
||||
export function modelConfigOptions(config: readonly AgentConfigOption[]): AgentConfigOption[] {
|
||||
return config.filter((o) => o.category === "model_config" || o.id === "fast");
|
||||
}
|
||||
|
||||
/** The settings shown in the composer: model, mode, collaboration mode, permissions, approvals, effort, fast. */
|
||||
export function mainOptions(config: readonly AgentConfigOption[]): AgentConfigOption[] {
|
||||
const order = ["model", "mode", "collaboration_mode", "permissions", "approval", "thought_level"];
|
||||
return order.flatMap((c) => config.filter((o) => o.category === c).slice(0, 1));
|
||||
const order = ["model", "mode", "collaboration_mode", "permissions", "approval", "thought_level", "model_config"];
|
||||
return order.flatMap((c) => {
|
||||
const matched = config.filter((o) => matchesCategory(o, c));
|
||||
return c === "model_config" ? matched : matched.slice(0, 1);
|
||||
});
|
||||
}
|
||||
|
||||
/** Cursor defaults to Fast; prefer standard Composer unless the user chose otherwise. */
|
||||
export function cursorModelDefaults(
|
||||
providerId: string | undefined,
|
||||
config: readonly AgentConfigOption[],
|
||||
): Record<string, string> {
|
||||
if (providerId !== "cursor") return {};
|
||||
const fast = config.find((o) => o.id === "fast" || o.category === "model_config");
|
||||
if (fast?.values.some((v) => v.value === "false")) return { [fast.id]: "false" };
|
||||
return {};
|
||||
}
|
||||
|
||||
export const modeOption = (config: readonly AgentConfigOption[]) => config.find((o) => o.category === "mode");
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
AGENTS_GROUP,
|
||||
HOME_KIND,
|
||||
MCP_PAGE,
|
||||
POLICY_PAGE,
|
||||
REGISTRY_PAGE,
|
||||
SESSION_KIND,
|
||||
TASK_DRAG,
|
||||
@@ -56,8 +57,17 @@ export function activateSettingsPages(): () => void {
|
||||
keywords: ["mcp", "model context protocol", "servers", "tools", "agent"],
|
||||
component: lazyComponent(() => import("./McpServersPage").then((m) => m.McpServersPage)),
|
||||
}),
|
||||
registerSettingsPage({
|
||||
id: POLICY_PAGE,
|
||||
title: "Command Execution Policy",
|
||||
group: AGENTS_GROUP,
|
||||
order: 490,
|
||||
keywords: ["agent", "policy", "security", "whitelist", "blacklist", "permission", "command", "exec"],
|
||||
projectScope: true,
|
||||
component: lazyComponent(() => import("./AgentPolicyPage").then((m) => m.AgentPolicyPage)),
|
||||
}),
|
||||
];
|
||||
return () => disposers.forEach((d) => d());
|
||||
return () => disposers.reverse().forEach((d) => d());
|
||||
}
|
||||
|
||||
export function activate(): () => void {
|
||||
|
||||
@@ -13,6 +13,8 @@ export const REGISTRY_PAGE = "agents.list";
|
||||
export const AGENTS_GROUP = "Agents";
|
||||
/** Settings › Agents › External MCP Servers: User and Workspace MCP configurations. */
|
||||
export const MCP_PAGE = "agents.mcpServers";
|
||||
/** Settings › Agents › Command Execution Policy. */
|
||||
export const POLICY_PAGE = "agents.policy";
|
||||
|
||||
export const sessionKey = (task: string) => `agent:${task}`;
|
||||
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
import { open } from "@/platform/dialog";
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
AcpImportOutcome,
|
||||
AgentProvider,
|
||||
AgentRegistry,
|
||||
CustomAgentInput,
|
||||
InstallEvent,
|
||||
RegistryAgent,
|
||||
RegistryDistribution,
|
||||
RequestId,
|
||||
} from "@/bindings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
|
||||
export interface RegistryBackend {
|
||||
@@ -24,6 +27,10 @@ export interface CommandsBackend {
|
||||
set(id: string, command: string | null): Promise<void>;
|
||||
setEnv(id: string, env: Record<string, string>): Promise<void>;
|
||||
chooseExecutable(title: string, current?: string): Promise<string | null>;
|
||||
saveCustom(input: CustomAgentInput): Promise<void>;
|
||||
removeCustom(id: string): Promise<void>;
|
||||
importAcp(text: string): Promise<AcpImportOutcome[]>;
|
||||
pickAcpJson(): Promise<string | null>;
|
||||
}
|
||||
|
||||
export const tauriRegistry: RegistryBackend = {
|
||||
@@ -51,6 +58,23 @@ export const tauriCommands: CommandsBackend = {
|
||||
setEnv: async (id, env) => {
|
||||
await call(commands.agentSetEnv(id, env));
|
||||
},
|
||||
saveCustom: async (input) => {
|
||||
await call(commands.agentCustomSave(input));
|
||||
},
|
||||
removeCustom: async (id) => {
|
||||
await call(commands.agentCustomRemove(id));
|
||||
},
|
||||
importAcp: (text) => call(commands.agentCustomImport(text)),
|
||||
pickAcpJson: async () => {
|
||||
const picked = await open({
|
||||
title: tx("Select acp.json"),
|
||||
directory: false,
|
||||
multiple: false,
|
||||
filters: [{ name: tx("ACP config"), extensions: ["json"] }],
|
||||
});
|
||||
if (typeof picked !== "string") return null;
|
||||
return call(commands.fsReadText(picked));
|
||||
},
|
||||
};
|
||||
|
||||
export const DISTRIBUTION_LABEL: Record<RegistryDistribution, string> = {
|
||||
|
||||
@@ -31,6 +31,13 @@ const backend = vi.hoisted(() => ({
|
||||
}>,
|
||||
imported: [] as Array<[string, string]>,
|
||||
externalError: null as string | null,
|
||||
accepted: [] as Array<[string, string[] | null]>,
|
||||
acceptedOutcome: { accepted: ["very-long-file-name.ts"], skipped: [] } as {
|
||||
accepted: string[];
|
||||
skipped: Array<{ path: string; reason: string }>;
|
||||
},
|
||||
confirmed: [] as string[],
|
||||
confirmAnswer: true,
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (original) => ({
|
||||
...(await original<typeof import("@/platform/ipc")>()),
|
||||
@@ -44,6 +51,11 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
return { status: "ok", data: backend.entries.get(key) ?? [] };
|
||||
},
|
||||
agentChanges: async () => ({ status: "ok", data: backend.changes }),
|
||||
agentRollbackChanges: async () => ({ status: "ok", data: null }),
|
||||
agentAcceptChanges: async (task: string, paths: string[] | null) => {
|
||||
backend.accepted.push([task, paths]);
|
||||
return { status: "ok", data: backend.acceptedOutcome };
|
||||
},
|
||||
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
|
||||
agentRespond: async (task: string, permission: number, option: string | null) => {
|
||||
backend.answers.push([task, permission, option]);
|
||||
@@ -64,6 +76,13 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
vi.mock("@tauri-apps/api/webview", () => ({
|
||||
getCurrentWebview: () => ({ onDragDropEvent: () => Promise.resolve(() => {}) }),
|
||||
}));
|
||||
vi.mock("@/workbench/messages", async (original) => ({
|
||||
...(await original<typeof import("@/workbench/messages")>()),
|
||||
confirm: async (options: { title: string }) => {
|
||||
backend.confirmed.push(options.title);
|
||||
return backend.confirmAnswer;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/workbench/contributions", async (original) => ({
|
||||
...(await original<typeof import("@/workbench/contributions")>()),
|
||||
editorService: () => ({ closeInput: vi.fn(), openInput: vi.fn() }),
|
||||
@@ -140,6 +159,10 @@ beforeEach(() => {
|
||||
backend.external = [];
|
||||
backend.imported = [];
|
||||
backend.externalError = null;
|
||||
backend.accepted = [];
|
||||
backend.acceptedOutcome = { accepted: ["very-long-file-name.ts"], skipped: [] };
|
||||
backend.confirmed = [];
|
||||
backend.confirmAnswer = true;
|
||||
backend.entries = new Map([
|
||||
["A", [{ type: "agent", text: "Answer A" }, subagent]],
|
||||
[entriesKey("A", "child"), [{ type: "agent", text: "Child answer" }]],
|
||||
@@ -1348,3 +1371,57 @@ describe("sessions from other apps", () => {
|
||||
expect(screen.queryByRole("button", { name: "Import" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("accept changes", () => {
|
||||
const worktreeChanges = () => {
|
||||
backend.changes = [
|
||||
{
|
||||
path: "/project/very-long-file-name.ts",
|
||||
relPath: "very-long-file-name.ts",
|
||||
kind: "modified",
|
||||
added: 8,
|
||||
removed: 2,
|
||||
},
|
||||
];
|
||||
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
|
||||
useAgents.setState({
|
||||
tasks: new Map([[task("A").id, { ...task("A"), isolation: "worktree", status: "ready" }]]),
|
||||
});
|
||||
};
|
||||
|
||||
it("copies a worktree task's changes into the checkout after confirming", async () => {
|
||||
worktreeChanges();
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByRole("complementary", { name: "Changes" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Accept All Changes…" }));
|
||||
expect(backend.confirmed).toEqual(["Accept Changes"]);
|
||||
await waitFor(() => expect(backend.accepted).toEqual([["A", null]]));
|
||||
});
|
||||
|
||||
it("does nothing when the confirm dialog is dismissed", async () => {
|
||||
worktreeChanges();
|
||||
backend.confirmAnswer = false;
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByRole("complementary", { name: "Changes" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Accept All Changes…" }));
|
||||
await waitFor(() => expect(backend.confirmed).toEqual(["Accept Changes"]));
|
||||
expect(backend.accepted).toEqual([]);
|
||||
});
|
||||
|
||||
it("offers no accept action for tasks that run in the checkout", async () => {
|
||||
backend.changes = [
|
||||
{
|
||||
path: "/project/very-long-file-name.ts",
|
||||
relPath: "very-long-file-name.ts",
|
||||
kind: "modified",
|
||||
added: 8,
|
||||
removed: 2,
|
||||
},
|
||||
];
|
||||
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByRole("complementary", { name: "Changes" });
|
||||
expect(screen.queryByRole("button", { name: /Accept .* Changes…/ })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Rollback All Changes…" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { Channel } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import type {
|
||||
AgentAcceptOutcome,
|
||||
AgentChange,
|
||||
AgentCommand,
|
||||
AgentConfigOption,
|
||||
@@ -21,7 +22,15 @@ import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { editorService, notifications, notifyTabTitlesChanged, toolWindowService } from "@/workbench/contributions";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { bypassDefaults, canSend, emptyDraft, toPrompt, type ComposerDraft, type FileRef } from "./composerModel";
|
||||
import {
|
||||
bypassDefaults,
|
||||
canSend,
|
||||
cursorModelDefaults,
|
||||
emptyDraft,
|
||||
toPrompt,
|
||||
type ComposerDraft,
|
||||
type FileRef,
|
||||
} from "./composerModel";
|
||||
import { withQuote } from "./conversation";
|
||||
import {
|
||||
HOME_KEY,
|
||||
@@ -532,6 +541,15 @@ export async function rollbackChanges(task: string, relPaths: string[] | null):
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies a worktree task's changed files into the local checkout, uncommitted. */
|
||||
export async function acceptChanges(task: string, relPaths: string[] | null): Promise<AgentAcceptOutcome> {
|
||||
try {
|
||||
return await call(commands.agentAcceptChanges(task, relPaths));
|
||||
} finally {
|
||||
await refreshChanges(task);
|
||||
}
|
||||
}
|
||||
|
||||
const pendingChanges = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
function scheduleChanges(task: string) {
|
||||
if (pendingChanges.has(task)) return;
|
||||
@@ -693,7 +711,11 @@ export async function startTask(
|
||||
providers.find((p) => p.id === draft.provider),
|
||||
draft,
|
||||
);
|
||||
const defaults = { ...bypassDefaults(options), ...providerDefaults(draft.provider) };
|
||||
const defaults = {
|
||||
...cursorModelDefaults(draft.provider, options),
|
||||
...bypassDefaults(options),
|
||||
...providerDefaults(draft.provider),
|
||||
};
|
||||
const config = options.flatMap((o) => {
|
||||
const value =
|
||||
o.category === "model" || o.category === "thought_level"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { useProject } from "@/platform/project";
|
||||
import type { LaunchConfig } from "@/platform/ipc";
|
||||
import {
|
||||
provideNotificationService,
|
||||
provideToolWindowService,
|
||||
type ToolWindowService,
|
||||
} from "@/workbench/contributions";
|
||||
import { handleDebugEvent, startSession, useDebug } from "../state";
|
||||
|
||||
const ipc = vi.hoisted(() => ({ debugStart: vi.fn() }));
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const real = await original<typeof import("@/platform/ipc")>();
|
||||
return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } };
|
||||
});
|
||||
|
||||
const kotlin: LaunchConfig = {
|
||||
name: "AppKt",
|
||||
adapter: "kotlin-debug-adapter",
|
||||
request: "launch",
|
||||
program: null,
|
||||
args: [],
|
||||
cwd: "/p",
|
||||
env: {},
|
||||
extraJson: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useDebug.setState({ sessions: {}, active: null, console: {} });
|
||||
useProject.setState({ project: { root: "/p" } as never });
|
||||
provideNotificationService({ notify: vi.fn() });
|
||||
provideToolWindowService({ show: vi.fn() } as unknown as ToolWindowService);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps the adapter the backend chose, and the one asked for to start again", async () => {
|
||||
ipc.debugStart.mockImplementation(async () => {
|
||||
handleDebugEvent(7, { kind: "started", name: "AppKt", adapter: "kotlin-lsp", parent: null });
|
||||
return { status: "ok", data: 7 };
|
||||
});
|
||||
expect(await startSession(kotlin)).toBe(7);
|
||||
const session = useDebug.getState().sessions[7]!;
|
||||
expect(session.adapter).toBe("kotlin-lsp");
|
||||
expect(session.launch?.adapter).toBe("kotlin-debug-adapter");
|
||||
});
|
||||
|
||||
it("takes the requested adapter when the backend reported none", async () => {
|
||||
ipc.debugStart.mockResolvedValue({ status: "ok", data: 8 });
|
||||
expect(await startSession(kotlin)).toBe(8);
|
||||
expect(useDebug.getState().sessions[8]!.adapter).toBe("kotlin-debug-adapter");
|
||||
});
|
||||
@@ -50,7 +50,7 @@ function deferred<T>() {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useDebug.setState({ sessions: {}, active: null, stopSeq: 0 });
|
||||
useDebug.setState({ sessions: {}, active: null, stopSeq: 0, variablesEpoch: 0 });
|
||||
useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} });
|
||||
adoptSession(1, { adapter: "java-debug" });
|
||||
ipc.debugStackTrace.mockResolvedValue(ipc.ok([frame(6840)]));
|
||||
@@ -74,6 +74,17 @@ describe("execution point state", () => {
|
||||
expect(useExecutionPoint.getState()).toMatchObject({ path, line: 9 });
|
||||
});
|
||||
|
||||
it("counts stops per session", async () => {
|
||||
adoptSession(2, { adapter: "java-debug" });
|
||||
expect(useDebug.getState().sessions[1]!.stops).toBe(0);
|
||||
stopped();
|
||||
stopped();
|
||||
handleDebugEvent(2, { kind: "stopped", threadId: 1, allThreadsStopped: false, reason: "step", description: null });
|
||||
expect(useDebug.getState().sessions[1]!.stops).toBe(2);
|
||||
expect(useDebug.getState().sessions[2]!.stops).toBe(1);
|
||||
await settle();
|
||||
});
|
||||
|
||||
it("clears the old point while a new stack or selected thread is loading", async () => {
|
||||
stopped();
|
||||
await settle();
|
||||
@@ -200,3 +211,54 @@ describe("execution point state", () => {
|
||||
expect(useExecutionPoint.getState().values.count).toBe("42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidated variables", () => {
|
||||
const library = (value: string): Variable => ({
|
||||
name: "library",
|
||||
value,
|
||||
typeName: null,
|
||||
variablesReference: 0,
|
||||
evaluateName: "library",
|
||||
});
|
||||
|
||||
it("reloads the selected frame's variables without counting a stop", async () => {
|
||||
ipc.debugVariables.mockResolvedValue(ipc.ok([library("Collecting data…")]));
|
||||
stopped();
|
||||
await settle();
|
||||
expect(useExecutionPoint.getState().values.library).toBe("Collecting data…");
|
||||
const before = useDebug.getState();
|
||||
ipc.debugVariables.mockResolvedValue(ipc.ok([library("Library@1")]));
|
||||
handleDebugEvent(1, { kind: "invalidated", areas: ["variables"] });
|
||||
await settle();
|
||||
expect(useDebug.getState().variablesEpoch).toBe(1);
|
||||
expect(useExecutionPoint.getState().values.library).toBe("Library@1");
|
||||
// A stop would mark every value changed and make the Memory view ask again.
|
||||
expect(useDebug.getState().stopSeq).toBe(before.stopSeq);
|
||||
expect(useDebug.getState().sessions[1]!.stops).toBe(before.sessions[1]!.stops);
|
||||
});
|
||||
|
||||
it.each([[[]], [["all"]], [["variables", "threads"]]])("reloads for areas %j", async (areas) => {
|
||||
stopped();
|
||||
await settle();
|
||||
ipc.debugScopes.mockClear();
|
||||
handleDebugEvent(1, { kind: "invalidated", areas });
|
||||
await settle();
|
||||
expect(useDebug.getState().variablesEpoch).toBe(1);
|
||||
expect(ipc.debugScopes).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("changes nothing while the program runs or for other areas", async () => {
|
||||
handleDebugEvent(1, { kind: "invalidated", areas: ["variables"] });
|
||||
stopped();
|
||||
await settle();
|
||||
ipc.debugScopes.mockClear();
|
||||
handleDebugEvent(1, { kind: "invalidated", areas: ["threads", "stacks"] });
|
||||
await settle();
|
||||
expect(ipc.debugScopes).not.toHaveBeenCalled();
|
||||
await resumeExecution(1, async () => {});
|
||||
handleDebugEvent(1, { kind: "invalidated", areas: ["variables"] });
|
||||
await settle();
|
||||
expect(ipc.debugScopes).not.toHaveBeenCalled();
|
||||
expect(useDebug.getState().variablesEpoch).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,19 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const adapterData = vi.hoisted(() => ({
|
||||
/** Value of a `library` variable the adapter computes asynchronously; null leaves it out. */
|
||||
library: null as string | null,
|
||||
scopes: vi.fn(),
|
||||
memoryClasses: vi.fn(),
|
||||
/** Value the adapter answers a watch with. */
|
||||
watch: "Collecting data…",
|
||||
}));
|
||||
|
||||
const debugEvaluateIn = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
vi.fn(async (..._args: unknown[]) => ({
|
||||
status: "ok" as const,
|
||||
data: { name: "result", value: "4", typeName: "int", variablesReference: 0, evaluateName: null },
|
||||
data: { name: "result", value: "4", typeName: "int" as string | null, variablesReference: 0, evaluateName: null },
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -15,11 +24,30 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
debugJvmMemoryClasses: () => ok({ status: "unavailable", reason: { kind: "programExited" } }),
|
||||
debugJvmMemoryClasses: () => {
|
||||
adapterData.memoryClasses();
|
||||
return ok({ status: "unavailable", reason: { kind: "programExited" } });
|
||||
},
|
||||
debugEvaluateIn,
|
||||
debugScopes: () => ok([{ name: "Locals", variablesReference: 1, expensive: false }]),
|
||||
debugSetVariable: () =>
|
||||
ok({ name: "count", value: "4", typeName: "int", variablesReference: 0, evaluateName: "count" }),
|
||||
debugScopes: () => {
|
||||
adapterData.scopes();
|
||||
return ok([{ name: "Locals", variablesReference: 1, expensive: false }]);
|
||||
},
|
||||
debugVariables: () =>
|
||||
ok([
|
||||
...(adapterData.library == null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: "library",
|
||||
value: adapterData.library,
|
||||
typeName: "Library",
|
||||
variablesReference: 0,
|
||||
evaluateName: "library",
|
||||
},
|
||||
]),
|
||||
{ name: "count", value: "3", typeName: "int", variablesReference: 0, evaluateName: "count" },
|
||||
{ name: "label", value: "'hi'", typeName: "str", variablesReference: 0, evaluateName: "label" },
|
||||
{
|
||||
@@ -39,7 +67,7 @@ vi.mock("@/workbench/confirmTerminate", () => ({ confirmTerminate }));
|
||||
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { setLocale } from "@/platform/i18n";
|
||||
import { useDebug, type DebugSession } from "../state";
|
||||
import { handleDebugEvent, setVariableValue, useDebug, type DebugSession } from "../state";
|
||||
import { DebugTabs, DebugToolWindow } from "../ui/DebugToolWindow";
|
||||
import { DebuggerOverlays } from "../ui/Overlays";
|
||||
import { useOverlays } from "../ui/popoverStore";
|
||||
@@ -264,3 +292,87 @@ describe("debug tool window", () => {
|
||||
expect(useDebug.getState().sessions).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("variables the adapter computes after the stop", () => {
|
||||
const tick = () => act(() => new Promise((resolve) => setTimeout(resolve, 0)));
|
||||
const setup = () => {
|
||||
adapterData.library = "Collecting data…";
|
||||
adapterData.scopes.mockClear();
|
||||
useDebug.setState({ sessions: { 1: session }, active: 1, stopSeq: 1, variablesEpoch: 0 });
|
||||
return render(<DebugToolWindow />);
|
||||
};
|
||||
|
||||
it("shows the value sent after an invalidation, unmarked as changed", async () => {
|
||||
setup();
|
||||
expect(await screen.findByText("Collecting data…")).toBeTruthy();
|
||||
adapterData.library = "com.example.library.Library@1";
|
||||
act(() => handleDebugEvent(1, { kind: "invalidated", areas: ["variables"] }));
|
||||
const value = await screen.findByText("com.example.library.Library@1");
|
||||
expect(screen.queryByText("Collecting data…")).toBeNull();
|
||||
expect(value.closest("[role=treeitem]")?.querySelector("[class*=changed]")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the watch value the adapter computes after an invalidation", async () => {
|
||||
adapterData.watch = "Collecting data…";
|
||||
const answer = async (...args: unknown[]) => ({
|
||||
status: "ok" as const,
|
||||
data: {
|
||||
name: String(args[2]),
|
||||
value: adapterData.watch,
|
||||
typeName: null as string | null,
|
||||
variablesReference: 0,
|
||||
evaluateName: null,
|
||||
},
|
||||
});
|
||||
debugEvaluateIn.mockImplementation(answer);
|
||||
useDebug.setState({ watches: ['library.search("java").size'] });
|
||||
try {
|
||||
setup();
|
||||
const watch = () => screen.getByText('library.search("java").size').closest("[role=treeitem]")!;
|
||||
await waitFor(() => expect(watch().textContent).toContain("Collecting data…"));
|
||||
adapterData.watch = "1";
|
||||
act(() => handleDebugEvent(1, { kind: "invalidated", areas: ["variables"] }));
|
||||
await waitFor(() => expect(watch().textContent).not.toContain("Collecting data…"));
|
||||
expect(watch().textContent).toContain("1");
|
||||
} finally {
|
||||
debugEvaluateIn.mockImplementation(async () => ({
|
||||
status: "ok" as const,
|
||||
data: { name: "result", value: "4", typeName: "int", variablesReference: 0, evaluateName: null },
|
||||
}));
|
||||
useDebug.setState({ watches: [] });
|
||||
}
|
||||
});
|
||||
|
||||
it("reloads after Set Value", async () => {
|
||||
setup();
|
||||
await screen.findByText("Collecting data…");
|
||||
adapterData.library = "Library@2";
|
||||
await act(() => setVariableValue({ container: 1, name: "count", evaluateName: "count" }, "4", session));
|
||||
expect(await screen.findByText("Library@2")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does nothing for an invalidation while the program runs", async () => {
|
||||
setup();
|
||||
await screen.findByText("Collecting data…");
|
||||
act(() => useDebug.setState({ sessions: { 1: { ...session, status: "running" } } }));
|
||||
adapterData.scopes.mockClear();
|
||||
adapterData.library = "Library@3";
|
||||
act(() => handleDebugEvent(1, { kind: "invalidated", areas: ["variables"] }));
|
||||
await tick();
|
||||
expect(adapterData.scopes).not.toHaveBeenCalled();
|
||||
expect(useDebug.getState().variablesEpoch).toBe(0);
|
||||
});
|
||||
|
||||
it("does not ask the Memory view again", async () => {
|
||||
const caps = { jvmMemory: true } as DebugSession["caps"];
|
||||
useDebug.setState({ sessions: { 1: { ...session, caps } }, active: 1, variablesEpoch: 0 });
|
||||
render(<DebugToolWindow />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Memory" }));
|
||||
expect(await screen.findByText("The program has exited.")).toBeTruthy();
|
||||
adapterData.memoryClasses.mockClear();
|
||||
act(() => handleDebugEvent(1, { kind: "invalidated", areas: ["variables"] }));
|
||||
await tick();
|
||||
expect(useDebug.getState().variablesEpoch).toBe(1);
|
||||
expect(adapterData.memoryClasses).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,11 @@ interface DebugState {
|
||||
watches: string[];
|
||||
/** Incremented on every stop; views re-pull data when it changes. */
|
||||
stopSeq: number;
|
||||
/**
|
||||
* Incremented when the adapter says it has new values for the suspended frame (`invalidated`).
|
||||
* Separate from `stopSeq`: no stop happened, so nothing may count as changed and the Memory view stays put.
|
||||
*/
|
||||
variablesEpoch: number;
|
||||
}
|
||||
|
||||
export const useDebug = create<DebugState>(() => ({
|
||||
@@ -105,6 +110,7 @@ export const useDebug = create<DebugState>(() => ({
|
||||
console: {},
|
||||
watches: [],
|
||||
stopSeq: 0,
|
||||
variablesEpoch: 0,
|
||||
}));
|
||||
|
||||
/** Execution point of the selected frame, plus the frame's locals for inline values. */
|
||||
@@ -1164,6 +1170,12 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) {
|
||||
case "jvmBackend":
|
||||
patch(id, { jvmBackend: event.state });
|
||||
break;
|
||||
case "invalidated":
|
||||
if (s.status !== "paused") break;
|
||||
if (event.areas.length && !event.areas.some((a) => a === "variables" || a === "all")) break;
|
||||
useDebug.setState((st) => ({ variablesEpoch: st.variablesEpoch + 1 }));
|
||||
if (s.selectedFrame != null) void loadLocals(id, s.selectedFrame);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1176,7 +1188,7 @@ export function resetSessions() {
|
||||
cancelOutputFlush();
|
||||
ansiStreams.clear();
|
||||
announcedPorts.clear();
|
||||
useDebug.setState({ sessions: {}, active: null, console: {}, stopSeq: 0 });
|
||||
useDebug.setState({ sessions: {}, active: null, console: {}, stopSeq: 0, variablesEpoch: 0 });
|
||||
useExecutionPoint.setState({ path: null, line: 0, top: true, values: {}, variables: {} });
|
||||
publishContext();
|
||||
}
|
||||
|
||||
@@ -403,6 +403,7 @@ interface EvalRow {
|
||||
function VariablesPane({ session }: { session: DebugSession }) {
|
||||
const watches = useDebug((s) => s.watches);
|
||||
const stopSeq = useDebug((s) => s.stopSeq);
|
||||
const variablesEpoch = useDebug((s) => s.variablesEpoch);
|
||||
const [expression, setExpression] = useState("");
|
||||
const evalField = useRef<HTMLInputElement>(null);
|
||||
const completion = useExpressionCompletion(evalField, expression, setExpression, session);
|
||||
@@ -443,9 +444,10 @@ function VariablesPane({ session }: { session: DebugSession }) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session.id, frame, stopSeq]);
|
||||
}, [session.id, frame, stopSeq, variablesEpoch]);
|
||||
|
||||
// Watches are re-evaluated on every stop / frame change.
|
||||
// Watches are re-evaluated on every stop / frame change, and when the adapter has new values (kotlin-lsp first
|
||||
// answers "Collecting data…").
|
||||
useEffect(() => {
|
||||
if (frame == null || !watches.length) return;
|
||||
let cancelled = false;
|
||||
@@ -466,7 +468,7 @@ function VariablesPane({ session }: { session: DebugSession }) {
|
||||
};
|
||||
// `session` identity changes on every patch; frame/stopSeq capture what matters.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.id, frame, stopSeq, watches]);
|
||||
}, [session.id, frame, stopSeq, variablesEpoch, watches]);
|
||||
|
||||
const roots = useMemo<VarRoot[]>(() => {
|
||||
const out: VarRoot[] = [];
|
||||
@@ -583,7 +585,7 @@ function VariablesPane({ session }: { session: DebugSession }) {
|
||||
<VariablesView
|
||||
roots={roots}
|
||||
session={session.id}
|
||||
generation={stopSeq * 100000 + (frame ?? 0)}
|
||||
generation={(stopSeq * 1000 + (variablesEpoch % 1000)) * 100000 + (frame ?? 0)}
|
||||
changes={{ epoch: stopSeq, scope: changeScope }}
|
||||
aria-label="Variables"
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
registerServiceView,
|
||||
showServiceView,
|
||||
} from "@/workbench/contributions";
|
||||
import { registerVariableNavigation } from "./variableNavigation";
|
||||
import { HTTP_LANGUAGE, httpEditorExtension, refreshRequests, requestAtCaret } from "./editor";
|
||||
import { HttpClientHeaderActions, HttpClientView } from "./HttpClientView";
|
||||
import { httpLanguageDescription } from "./language";
|
||||
@@ -134,6 +135,7 @@ async function newRequest(directory?: string) {
|
||||
export function activate(): () => void {
|
||||
const disposers = [
|
||||
registerSpringEndpointProvider(),
|
||||
registerVariableNavigation(),
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root) resetHttpClient();
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Text } from "@codemirror/state";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fileVariableDefinitions, variableAt } from "./variableNavigation";
|
||||
|
||||
const doc = Text.of([
|
||||
"@host = localhost:8080",
|
||||
" @token=abc",
|
||||
"@hostname = other",
|
||||
"GET http://{{host}}/a?t={{ token }}&id={{$uuid}}",
|
||||
"Authorization: Bearer {{token}}",
|
||||
]);
|
||||
const offsetOf = (line: number, needle: string, delta = 0) =>
|
||||
doc.line(line).from + doc.line(line).text.indexOf(needle) + delta;
|
||||
|
||||
describe("http variable navigation", () => {
|
||||
it("finds the name of the {{variable}} under the caret", () => {
|
||||
expect(variableAt(doc, offsetOf(4, "host", 1))).toEqual({
|
||||
name: "host",
|
||||
from: offsetOf(4, "host"),
|
||||
to: offsetOf(4, "host") + 4,
|
||||
});
|
||||
// Spaces inside the braces are not part of the name.
|
||||
expect(variableAt(doc, offsetOf(4, "token", 2))?.name).toBe("token");
|
||||
expect(variableAt(doc, offsetOf(4, "{{ token", 2))).toBeNull();
|
||||
expect(variableAt(doc, offsetOf(4, "GET"))).toBeNull();
|
||||
// Dynamic variables have no definition.
|
||||
expect(variableAt(doc, offsetOf(4, "$uuid", 1))).toBeNull();
|
||||
});
|
||||
|
||||
it("lists the file's @name definitions", () => {
|
||||
expect(fileVariableDefinitions(doc, "/p/a.http", "host")).toEqual([
|
||||
{ path: "/p/a.http", range: { start: { line: 0, col: 1 }, end: { line: 0, col: 5 } } },
|
||||
]);
|
||||
expect(fileVariableDefinitions(doc, "/p/a.http", "token")).toEqual([
|
||||
{ path: "/p/a.http", range: { start: { line: 1, col: 3 }, end: { line: 1, col: 8 } } },
|
||||
]);
|
||||
expect(fileVariableDefinitions(doc, "/p/a.http", "missing")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Navigate › Declaration (⌘-click, ⌘B) on `{{name}}` in a `.http` file: the `@name = …` line of
|
||||
* the same file, then the key `name` in each environment of the nearest
|
||||
* `http-client.private.env.json` and `http-client.env.json` — the variable lookup order.
|
||||
*/
|
||||
import type { Text } from "@codemirror/state";
|
||||
import { docOf } from "@/editor/lsp/lsp";
|
||||
import { registerNavigationProvider } from "@/editor/navigationProviders";
|
||||
import { call, commands, type Location } from "@/platform/ipc";
|
||||
import { HTTP_LANGUAGE } from "./editor";
|
||||
|
||||
/** The `{{name}}` reference whose name covers document `offset`, as document offsets. */
|
||||
export function variableAt(doc: Text, offset: number): { name: string; from: number; to: number } | null {
|
||||
const line = doc.lineAt(offset);
|
||||
for (const m of line.text.matchAll(/\{\{\s*([^{}\s]+)\s*\}\}/g)) {
|
||||
const name = m[1]!;
|
||||
const from = line.from + m.index + m[0].indexOf(name, 2);
|
||||
const to = from + name.length;
|
||||
// Dynamic variables (`$uuid`, `$random.integer`…) have no definition.
|
||||
if (offset >= from && offset < to) return name.startsWith("$") ? null : { name, from, to };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `@name = value` lines of the request file defining `name`. */
|
||||
export function fileVariableDefinitions(doc: Text, path: string, name: string): Location[] {
|
||||
const out: Location[] = [];
|
||||
for (let n = 1; n <= doc.lines; n++) {
|
||||
const text = doc.line(n).text;
|
||||
const m = /^(\s*@)([^\s=]+)\s*=/.exec(text);
|
||||
if (m?.[2] !== name) continue;
|
||||
const col = m[1]!.length;
|
||||
out.push({ path, range: { start: { line: n - 1, col }, end: { line: n - 1, col: col + name.length } } });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function registerVariableNavigation(): () => void {
|
||||
return registerNavigationProvider(async (view, offset) => {
|
||||
const doc = docOf(view);
|
||||
if (!doc || doc.virtual || doc.language !== HTTP_LANGUAGE) return null;
|
||||
const text = view.state.doc;
|
||||
const at = variableAt(text, offset);
|
||||
if (!at) return null;
|
||||
const inFile = fileVariableDefinitions(text, doc.path, at.name);
|
||||
let inEnv: Location[] = [];
|
||||
try {
|
||||
inEnv = await call(commands.httpVariableDefinitions(doc.path, at.name));
|
||||
} catch {
|
||||
// No project or unreadable environment files: the file's own definitions still count.
|
||||
}
|
||||
return { from: at.from, to: at.to, targets: [...inFile, ...inEnv] };
|
||||
});
|
||||
}
|
||||
@@ -108,6 +108,7 @@ export const ICLS_COLORS: Record<string, string[]> = {
|
||||
MODIFIED_LINES_COLOR: ["--gutter-modified"],
|
||||
ADDED_LINES_COLOR: ["--gutter-added"],
|
||||
DELETED_LINES_COLOR: ["--gutter-deleted"],
|
||||
CONSOLE_BACKGROUND_KEY: ["--bg-console"],
|
||||
};
|
||||
|
||||
type Part = "FOREGROUND" | "BACKGROUND" | "EFFECT_COLOR" | "ERROR_STRIPE_COLOR";
|
||||
@@ -194,22 +195,70 @@ export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
|
||||
MARKDOWN_HEADER_LEVEL_1: [["FOREGROUND", "--syn-markup-heading"]],
|
||||
MARKDOWN_CODE_SPAN: [["FOREGROUND", "--syn-markup-code"]],
|
||||
MARKDOWN_LINK_TEXT: [["FOREGROUND", "--syn-link"]],
|
||||
CONSOLE_BLACK_OUTPUT: [["FOREGROUND", "--ansi-black"]],
|
||||
CONSOLE_RED_OUTPUT: [["FOREGROUND", "--ansi-red"]],
|
||||
CONSOLE_GREEN_OUTPUT: [["FOREGROUND", "--ansi-green"]],
|
||||
CONSOLE_YELLOW_OUTPUT: [["FOREGROUND", "--ansi-yellow"]],
|
||||
CONSOLE_BLUE_OUTPUT: [["FOREGROUND", "--ansi-blue"]],
|
||||
CONSOLE_MAGENTA_OUTPUT: [["FOREGROUND", "--ansi-magenta"]],
|
||||
CONSOLE_CYAN_OUTPUT: [["FOREGROUND", "--ansi-cyan"]],
|
||||
CONSOLE_GRAY_OUTPUT: [["FOREGROUND", "--ansi-white"]],
|
||||
CONSOLE_DARKGRAY_OUTPUT: [["FOREGROUND", "--ansi-bright-black"]],
|
||||
CONSOLE_RED_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-red"]],
|
||||
CONSOLE_GREEN_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-green"]],
|
||||
CONSOLE_YELLOW_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-yellow"]],
|
||||
CONSOLE_BLUE_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-blue"]],
|
||||
CONSOLE_MAGENTA_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-magenta"]],
|
||||
CONSOLE_CYAN_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-cyan"]],
|
||||
CONSOLE_WHITE_OUTPUT: [["FOREGROUND", "--ansi-bright-white"]],
|
||||
CONSOLE_BLACK_OUTPUT: [
|
||||
["FOREGROUND", "--ansi-black"],
|
||||
["FOREGROUND", "--terminal-ansi-black"],
|
||||
],
|
||||
CONSOLE_RED_OUTPUT: [
|
||||
["FOREGROUND", "--ansi-red"],
|
||||
["FOREGROUND", "--terminal-ansi-red"],
|
||||
],
|
||||
CONSOLE_GREEN_OUTPUT: [
|
||||
["FOREGROUND", "--ansi-green"],
|
||||
["FOREGROUND", "--terminal-ansi-green"],
|
||||
],
|
||||
CONSOLE_YELLOW_OUTPUT: [
|
||||
["FOREGROUND", "--ansi-yellow"],
|
||||
["FOREGROUND", "--terminal-ansi-yellow"],
|
||||
],
|
||||
CONSOLE_BLUE_OUTPUT: [
|
||||
["FOREGROUND", "--ansi-blue"],
|
||||
["FOREGROUND", "--terminal-ansi-blue"],
|
||||
],
|
||||
CONSOLE_MAGENTA_OUTPUT: [
|
||||
["FOREGROUND", "--ansi-magenta"],
|
||||
["FOREGROUND", "--terminal-ansi-magenta"],
|
||||
],
|
||||
CONSOLE_CYAN_OUTPUT: [
|
||||
["FOREGROUND", "--ansi-cyan"],
|
||||
["FOREGROUND", "--terminal-ansi-cyan"],
|
||||
],
|
||||
CONSOLE_GRAY_OUTPUT: [
|
||||
["FOREGROUND", "--ansi-white"],
|
||||
["FOREGROUND", "--terminal-ansi-white"],
|
||||
],
|
||||
CONSOLE_DARKGRAY_OUTPUT: [
|
||||
["FOREGROUND", "--ansi-bright-black"],
|
||||
["FOREGROUND", "--terminal-ansi-bright-black"],
|
||||
],
|
||||
CONSOLE_RED_BRIGHT_OUTPUT: [
|
||||
["FOREGROUND", "--ansi-bright-red"],
|
||||
["FOREGROUND", "--terminal-ansi-bright-red"],
|
||||
],
|
||||
CONSOLE_GREEN_BRIGHT_OUTPUT: [
|
||||
["FOREGROUND", "--ansi-bright-green"],
|
||||
["FOREGROUND", "--terminal-ansi-bright-green"],
|
||||
],
|
||||
CONSOLE_YELLOW_BRIGHT_OUTPUT: [
|
||||
["FOREGROUND", "--ansi-bright-yellow"],
|
||||
["FOREGROUND", "--terminal-ansi-bright-yellow"],
|
||||
],
|
||||
CONSOLE_BLUE_BRIGHT_OUTPUT: [
|
||||
["FOREGROUND", "--ansi-bright-blue"],
|
||||
["FOREGROUND", "--terminal-ansi-bright-blue"],
|
||||
],
|
||||
CONSOLE_MAGENTA_BRIGHT_OUTPUT: [
|
||||
["FOREGROUND", "--ansi-bright-magenta"],
|
||||
["FOREGROUND", "--terminal-ansi-bright-magenta"],
|
||||
],
|
||||
CONSOLE_CYAN_BRIGHT_OUTPUT: [
|
||||
["FOREGROUND", "--ansi-bright-cyan"],
|
||||
["FOREGROUND", "--terminal-ansi-bright-cyan"],
|
||||
],
|
||||
CONSOLE_WHITE_OUTPUT: [
|
||||
["FOREGROUND", "--ansi-bright-white"],
|
||||
["FOREGROUND", "--terminal-ansi-bright-white"],
|
||||
],
|
||||
};
|
||||
|
||||
export interface ConvertedTheme {
|
||||
|
||||
@@ -32,7 +32,7 @@ import { setPluginDecorations } from "./decorations";
|
||||
import { editorsFor, onEditorEvent } from "@/editor/api";
|
||||
import { executeAction, executeActionOrThrow, getAction, onActionExecuted } from "@/platform/actions";
|
||||
import { clearContext, setContext, type ContextValue } from "@/platform/context";
|
||||
import { onIdeEvent } from "@/platform/ipc";
|
||||
import { isTauri, onIdeEvent } from "@/platform/ipc";
|
||||
import { openExternalUrl } from "@/platform/openUrl";
|
||||
import { tx, VerbatimTextContext } from "@/platform/i18n";
|
||||
import { useProject } from "@/platform/project";
|
||||
@@ -612,6 +612,9 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
|
||||
openPrompts.get(e.promptId)?.abort();
|
||||
openPrompts.delete(e.promptId);
|
||||
return true;
|
||||
case "promptWaiting":
|
||||
bringWindowToFront();
|
||||
return true;
|
||||
case "commitRequested":
|
||||
void answerCommit(e.requestId, e.request);
|
||||
return true;
|
||||
@@ -623,6 +626,18 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** Another window ran a command of a plugin whose prompt is open here: show this window. */
|
||||
function bringWindowToFront() {
|
||||
if (!isTauri()) return;
|
||||
void import("@tauri-apps/api/window")
|
||||
.then(async ({ getCurrentWindow }) => {
|
||||
const w = getCurrentWindow();
|
||||
if (await w.isMinimized()) await w.unminimize();
|
||||
await w.setFocus();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend refuses the event kinds and status items until this window's
|
||||
* project is open and trusted (`authorize_command`), but startup asks before
|
||||
|
||||
@@ -60,7 +60,7 @@ import styles from "./Run.module.css";
|
||||
const ADAPTERS: ReadonlyArray<{ id: string; label: string }> = [
|
||||
{ id: "netcoredbg", label: "netcoredbg (.NET)" },
|
||||
{ id: "java-debug", label: "java-debug (Java)" },
|
||||
{ id: "kotlin-debug-adapter", label: "kotlin-debug-adapter (Kotlin)" },
|
||||
{ id: "kotlin-debug-adapter", label: "kotlin-lsp or kotlin-debug-adapter (Kotlin)" },
|
||||
{ id: "debugpy", label: "debugpy (Python)" },
|
||||
{ id: "js-debug", label: "js-debug (Node.js / TypeScript)" },
|
||||
{ id: "delve", label: "Delve (Go)" },
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useProject } from "@/platform/project";
|
||||
import { dotnetBuildConfig, dotnetProjectType, dotnetTestType } from "./dotnetTypes";
|
||||
import { ConfigForm } from "./ConfigForm";
|
||||
import { validateConfig } from "./configs";
|
||||
import { newConfigOfType, registerBuiltinTypes, withParams } from "./types";
|
||||
import { formParams, newConfigOfType, registerBuiltinTypes, withParams } from "./types";
|
||||
|
||||
const resolveDotnet = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
@@ -69,6 +69,36 @@ describe(".NET configurations", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens a new configuration, and a typed dotnet run command, as the form with its project field", () => {
|
||||
const fresh = newConfigOfType(dotnetProjectType, []);
|
||||
expect(formParams(fresh, dotnetProjectType)).toEqual({ project: "" });
|
||||
render(
|
||||
<ConfigForm
|
||||
config={fresh}
|
||||
all={[fresh]}
|
||||
type={dotnetProjectType}
|
||||
errors={{}}
|
||||
modifyRequest={0}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText(".NET project")).toBeTruthy();
|
||||
// #2733: typed into the command fields of a configuration without params.
|
||||
const typed: RunConfig = {
|
||||
...fresh,
|
||||
params: {},
|
||||
command: "dotnet",
|
||||
args: ["run", "--project", "Worker.Service", "--no-launch-profile", "--", "--verbose"],
|
||||
};
|
||||
expect(formParams(typed, dotnetProjectType)).toEqual({
|
||||
project: "Worker.Service",
|
||||
programArgs: "--verbose",
|
||||
});
|
||||
// Options the form has no field for keep the command editor.
|
||||
expect(formParams({ ...typed, args: ["run", "--project", "A", "--no-build"] }, dotnetProjectType)).toBeNull();
|
||||
expect(formParams({ ...typed, args: ["build", "A"] }, dotnetProjectType)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds edited project/framework from the open root instead of a profile cwd", () => {
|
||||
useProject.setState({ project: { root: "/repo" } as never });
|
||||
const c = withParams(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { call, commands, type RunConfig } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import type { RunConfigTypeContribution } from "@/workbench/contributions";
|
||||
import { parseArgs } from "./configs";
|
||||
import { formatArgs, parseArgs } from "./configs";
|
||||
import { commandName } from "./commandName";
|
||||
import { prepareDotnetTest } from "@/platform/dotnet";
|
||||
|
||||
function profile(p: Readonly<Record<string, string>>): {
|
||||
@@ -28,6 +29,38 @@ export function dotnetCommand(p: Readonly<Record<string, string>>, test = false)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The form fields of a `dotnet run` command line ([`dotnetCommand`] reversed), or `null` when it
|
||||
* uses anything the form can't show. Lets a new configuration, and one typed as a command, open
|
||||
* as the form with its project field.
|
||||
*/
|
||||
function dotnetRunParams(c: RunConfig): Record<string, string> | null {
|
||||
if (!/^dotnet(\.exe)?$/i.test(commandName(c.command))) return null;
|
||||
const [verb, ...rest] = c.args;
|
||||
if (verb !== "run") return null;
|
||||
const p: Record<string, string> = c.command === "dotnet" ? {} : { executable: c.command };
|
||||
const flags: Record<string, string> = {
|
||||
"--project": "project",
|
||||
"--framework": "framework",
|
||||
"--configuration": "configuration",
|
||||
"--launch-profile": "launchProfile",
|
||||
};
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
const arg = rest[i]!;
|
||||
if (arg === "--") {
|
||||
p.programArgs = formatArgs(rest.slice(i + 1));
|
||||
break;
|
||||
}
|
||||
if (arg === "--no-launch-profile") continue;
|
||||
const key = flags[arg];
|
||||
const value = rest[i + 1];
|
||||
if (!key || value === undefined) return null;
|
||||
p[key] = value;
|
||||
i++;
|
||||
}
|
||||
return { project: "", ...p };
|
||||
}
|
||||
|
||||
/** Build the current project/framework, independently of the run's profile working directory. */
|
||||
export function dotnetBuildConfig(config: RunConfig): RunConfig {
|
||||
const p = config.params ?? {};
|
||||
@@ -104,6 +137,7 @@ export const dotnetProjectType: RunConfigTypeContribution = {
|
||||
},
|
||||
],
|
||||
commandLine: (p) => dotnetCommand(p),
|
||||
paramsFrom: dotnetRunParams,
|
||||
async prepareLaunch(config) {
|
||||
const resolved = await call(commands.runDotnetResolve(config, false));
|
||||
const p = resolved.params ?? {};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/** Imperative prompt dialogs of the Services view, rendered by one overlay. */
|
||||
import { Fragment, useState, type ReactNode } from "react";
|
||||
import { create } from "zustand";
|
||||
import { SshConfigSelect } from "@/features/settings/components/SshConfigSelect";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import type { DockerConnection, DockerEndpoint, DockerRegistry } from "@/platform/ipc";
|
||||
import type { SshConfigValid } from "@/platform/remoteSettings";
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { Button, Dialog, Input, Select, T } from "@/ui";
|
||||
import { errorMessage, newId, saveConnection, saveRegistry, testRegistryLogin } from "./store";
|
||||
import styles from "./Services.module.css";
|
||||
@@ -128,14 +132,53 @@ export function openConnectionDialog(existing?: DockerConnection) {
|
||||
open((close) => <ConnectionDialog existing={existing} onDone={close} />);
|
||||
}
|
||||
|
||||
/** `ssh://[user@]host[:port]` of an SSH configuration whose authentication is the OpenSSH
|
||||
* config: the alias stays in the URL so the system `ssh` that docker runs applies the
|
||||
* entry's user, key and jump hosts. */
|
||||
export function sshConfigUrl(c: Pick<SshConfigValid, "user" | "host" | "port">): string {
|
||||
return `ssh://${c.user ? `${c.user}@` : ""}${c.host}${c.port ? `:${c.port}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of the SSH configuration an Engine API URL was filled from, to show the selection:
|
||||
* an exact `ssh://[user@]host[:port]` URL matching an OpenSSH-config configuration's user,
|
||||
* host and port. Anything else (a `tcp://` URL, a different user or port) is manual.
|
||||
*/
|
||||
export function sshConfigForUrl(url: string, configs: readonly SshConfigValid[]): string {
|
||||
const m = /^ssh:\/\/(?:([^@/\s]+)@)?([A-Za-z0-9._-]+)(?::(\d+))?$/i.exec(url.trim());
|
||||
if (!m) return "";
|
||||
const config = configs.find(
|
||||
(c) =>
|
||||
c.auth === "openSsh" &&
|
||||
c.host.trim().toLowerCase() === (m[2] ?? "").toLowerCase() &&
|
||||
(!m[1] || c.user === m[1]) &&
|
||||
(!m[3] || c.port === Number(m[3])),
|
||||
);
|
||||
return config?.id ?? "";
|
||||
}
|
||||
|
||||
/** Settings › Tools › SSH Configurations, where the TCP/SSH kind's picker is edited. */
|
||||
const SSH_SETTINGS_PAGE = "tools.sshConfigurations";
|
||||
|
||||
function ConnectionDialog({ existing, onDone }: { existing?: DockerConnection; onDone: () => void }) {
|
||||
const ep = existing?.endpoint;
|
||||
const sshConfigs = useWorkflowSettings((w) => w.ssh.configs);
|
||||
const [name, setName] = useState(existing?.name ?? "Docker");
|
||||
const [kind, setKind] = useState<EndpointKind>(ep?.kind ?? "socket");
|
||||
const [path, setPath] = useState(ep?.kind === "socket" ? ep.path : "/var/run/docker.sock");
|
||||
const [host, setHost] = useState(ep?.kind === "tcp" ? ep.host : "");
|
||||
const [tls, setTls] = useState(ep?.kind === "tcp" ? (ep.tlsCertDir ?? "") : "");
|
||||
const [context, setContext] = useState(ep?.kind === "context" ? ep.name : "");
|
||||
// The configuration picked in the dropdown; shown even when docker cannot use it yet.
|
||||
const [picked, setPicked] = useState(() => (ep?.kind === "tcp" ? sshConfigForUrl(ep.host, sshConfigs) : ""));
|
||||
const fromUrl = sshConfigForUrl(host, sshConfigs);
|
||||
const shown = fromUrl || picked;
|
||||
const unusable = !fromUrl && sshConfigs.find((c) => c.id === picked && c.auth !== "openSsh");
|
||||
const pickSsh = (id: string) => {
|
||||
setPicked(id);
|
||||
const config = sshConfigs.find((c) => c.id === id);
|
||||
if (config?.auth === "openSsh" && config.host.trim()) setHost(sshConfigUrl(config));
|
||||
};
|
||||
const endpoint = endpointFrom(kind, { path, host, tls, context });
|
||||
const ok = !!endpoint && name.trim().length > 0;
|
||||
const submit = async () => {
|
||||
@@ -184,6 +227,29 @@ function ConnectionDialog({ existing, onDone }: { existing?: DockerConnection; o
|
||||
)}
|
||||
{kind === "tcp" && (
|
||||
<>
|
||||
<span className={styles.fieldLabel}>
|
||||
<T>SSH configuration:</T>
|
||||
</span>
|
||||
<div className={styles.fieldRow}>
|
||||
<SshConfigSelect
|
||||
value={shown}
|
||||
onChange={pickSsh}
|
||||
none="None — type the Engine API URL below"
|
||||
aria-label="SSH configuration"
|
||||
/>
|
||||
<Button
|
||||
title="Settings › Tools › SSH Configurations"
|
||||
aria-label="Edit SSH Configurations"
|
||||
onClick={() => void executeAction("file.settings", SSH_SETTINGS_PAGE)}
|
||||
>
|
||||
…
|
||||
</Button>
|
||||
</div>
|
||||
{unusable && (
|
||||
<span className={styles.hint}>
|
||||
<T>Docker over SSH uses the system ssh: choose a configuration with OpenSSH config authentication.</T>
|
||||
</span>
|
||||
)}
|
||||
<label className={styles.fieldLabel} htmlFor="docker-conn-host">
|
||||
<T>Engine API URL:</T>
|
||||
</label>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import type { DockerContainer, DockerSnapshot } from "@/platform/ipc";
|
||||
import type { SshConfigValid } from "@/platform/remoteSettings";
|
||||
import { fileIconName } from "@/ui/icons";
|
||||
import {
|
||||
composeFileIcon,
|
||||
@@ -54,7 +55,7 @@ import {
|
||||
splitLayerInstruction,
|
||||
unitsToBytes,
|
||||
} from "./dashboard";
|
||||
import { endpointFrom } from "./dialogs";
|
||||
import { endpointFrom, sshConfigForUrl, sshConfigUrl } from "./dialogs";
|
||||
import { dockerItems } from "./docker";
|
||||
import { setDockerFilters, useServices } from "./store";
|
||||
import { addServiceMenu, expandableKeys, indexTree, setProviderRoots, useServicesTree } from "./tree";
|
||||
@@ -209,6 +210,36 @@ describe("services tree host", () => {
|
||||
expect(endpointFrom("context", { ...f, context: "orbstack" })).toEqual({ kind: "context", name: "orbstack" });
|
||||
expect(endpointFrom("default", f)).toEqual({ kind: "default" });
|
||||
});
|
||||
|
||||
it("fills the Engine API URL from an OpenSSH-config SSH configuration and shows the selection", () => {
|
||||
const box: SshConfigValid = {
|
||||
id: "ssh1",
|
||||
name: "Build server",
|
||||
host: "build-server",
|
||||
port: 2222,
|
||||
user: "ops",
|
||||
auth: "openSsh",
|
||||
keyFile: "",
|
||||
saveSecret: false,
|
||||
};
|
||||
const bare: SshConfigValid = { ...box, id: "ssh2", name: "", host: "bare", port: null, user: "", auth: "openSsh" };
|
||||
const password: SshConfigValid = { ...box, id: "ssh3", host: "vault", user: "root", port: null, auth: "password" };
|
||||
const configs = [box, bare, password];
|
||||
expect(sshConfigUrl(box)).toBe("ssh://ops@build-server:2222");
|
||||
expect(sshConfigUrl(bare)).toBe("ssh://bare");
|
||||
// The selection follows the URL the field holds; only OpenSSH-config configurations match.
|
||||
expect(sshConfigForUrl("ssh://ops@build-server:2222", configs)).toBe("ssh1");
|
||||
expect(sshConfigForUrl(" ssh://build-server:2222 ", configs)).toBe("ssh1");
|
||||
expect(sshConfigForUrl("ssh://build-server", configs)).toBe("ssh1");
|
||||
expect(sshConfigForUrl("ssh://bare", configs)).toBe("ssh2");
|
||||
// Another user or port than the configuration's, a tcp URL or a password configuration is not a selection.
|
||||
expect(sshConfigForUrl("ssh://root@build-server", configs)).toBe("");
|
||||
expect(sshConfigForUrl("ssh://build-server:22", configs)).toBe("");
|
||||
expect(sshConfigForUrl("ssh://root@vault", configs)).toBe("");
|
||||
expect(sshConfigForUrl("tcp://build-host:2376", configs)).toBe("");
|
||||
expect(sshConfigForUrl("ssh://", configs)).toBe("");
|
||||
expect(sshConfigForUrl("", configs)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("docker run targets", () => {
|
||||
|
||||
@@ -134,7 +134,7 @@ describe("scheme actions", () => {
|
||||
filters: [{ name: "IntelliJ Color Scheme", extensions: ["icls", "xml"] }],
|
||||
});
|
||||
expect(value("colorScheme")).toMatch(/^custom-/);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Applied 12 values; skipped 3");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Applied 13 values; skipped 2");
|
||||
const schemes = value("customSchemes") as Record<string, { name: string }>;
|
||||
expect(Object.values(schemes).some((s) => s.name === "Material Darker")).toBe(true);
|
||||
});
|
||||
@@ -369,3 +369,16 @@ describe("scheme attribute state", () => {
|
||||
expect(useSettingsDialog.getState().staged["global|editor.colorOverrides"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Console Colors (#2698)", () => {
|
||||
it("lists ANSI colours and selects Red from the preview", () => {
|
||||
act(() => stage("editor.colorScheme", "global", "editor.colorScheme", "dark"));
|
||||
schemePage("editor.colorScheme.console-colors");
|
||||
const tree = screen.getByRole("tree", { name: "Color scheme attributes" });
|
||||
expect(within(tree).getByRole("treeitem", { name: "Console background" })).toBeInTheDocument();
|
||||
expect(within(tree).getByRole("treeitem", { name: "Red" })).toBeInTheDocument();
|
||||
const preview = screen.getByLabelText("Color scheme preview");
|
||||
act(() => fireEvent.click(within(preview).getByText(/^red\s*$/)));
|
||||
expect(screen.getByRole("treeitem", { name: "Red", selected: true })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -14,15 +14,20 @@ export function SshConfigSelect({
|
||||
value,
|
||||
onChange,
|
||||
none,
|
||||
configs: given,
|
||||
"aria-label": ariaLabel = "SSH configuration",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
/** Label of an empty choice (e.g. "Local machine"); omitted: no empty choice. */
|
||||
none?: string;
|
||||
/** The configurations to pick from; omitted: the global `ssh.configs` setting. Forms
|
||||
* outside the settings window pass the live list (the staged base is not loaded there). */
|
||||
configs?: SshConfigValid[];
|
||||
"aria-label"?: string;
|
||||
}) {
|
||||
const configs = useSshConfigs();
|
||||
const staged = useSshConfigs();
|
||||
const configs = given ?? staged;
|
||||
const options: SelectOption[] = [
|
||||
...(none !== undefined ? [{ value: "", label: none }] : []),
|
||||
...configs.map((c) => ({ value: c.id, label: c.name.trim() || sshLabel(c), detail: sshLabel(c) })),
|
||||
|
||||
@@ -10,11 +10,13 @@ import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "rea
|
||||
import clsx from "clsx";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { isTauri } from "@/platform/ipc";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import type { LocalDataInfo } from "@/bindings";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { ZOOM_LIMITS } from "@/platform/zoom";
|
||||
import { SCHEMES as EDITOR_SCHEMES, listSchemes, customSchemes } from "@/platform/colorScheme";
|
||||
import { formatTimestamp } from "@/platform/dateFormat";
|
||||
import { Checkbox, IconButton, Input, Popup, type SelectOption, T } from "@/ui";
|
||||
import { Button, Checkbox, IconButton, Input, Popup, type SelectOption, T } from "@/ui";
|
||||
import { NOTIFICATION_GROUPS } from "@/workbench/notificationStore";
|
||||
import { ColorSwatches, FormGroup, NumberInput, Row, SearchLabel, Select, Toggle } from "../components/Form";
|
||||
import { useLabelMatch, useSetting } from "../components/context";
|
||||
@@ -467,6 +469,124 @@ function DefaultDirectoryRow() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings › System Settings › Caches: where tools, language servers, indexes
|
||||
* and caches live (the C: problem of #1722). A custom directory takes effect
|
||||
* after a restart; the previous tree moves along when asked.
|
||||
*/
|
||||
function LocalDataDirRow() {
|
||||
const [info, setInfo] = useState<LocalDataInfo | null>(null);
|
||||
const [root, setRoot] = useState("");
|
||||
const [move, setMove] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const id = useId();
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
let live = true;
|
||||
void call(commands.cachesLocalDataInfo()).then(
|
||||
(v) => {
|
||||
if (!live) return;
|
||||
setInfo(v);
|
||||
setRoot(v.customRoot ?? "");
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
const browse = async () => {
|
||||
const { open } = await import("@/platform/dialog");
|
||||
const picked = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: "Local data directory",
|
||||
defaultPath: root || undefined,
|
||||
});
|
||||
const path = Array.isArray(picked) ? picked[0] : picked;
|
||||
if (path) setRoot(path);
|
||||
};
|
||||
const apply = async (next: string | null) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await call(commands.cachesSetLocalDataDir(next, move));
|
||||
await commands.appRestart();
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Could not change the local data directory",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
key: "app.localDataDir",
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
if (!isTauri()) {
|
||||
return (
|
||||
<p className={styles.note}>
|
||||
<T>Restart is only available in the desktop app</T>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const locked = busy || info?.envOverride === true;
|
||||
return (
|
||||
<>
|
||||
<Row label="Current location">
|
||||
<Input className={styles.pathInput} value={info?.current ?? ""} readOnly />
|
||||
</Row>
|
||||
<Row
|
||||
label="Custom directory"
|
||||
htmlFor={id}
|
||||
hint="Tools, language servers, indexes and caches live here. Empty means the OS default. Changing it restarts the IDE."
|
||||
>
|
||||
<Input
|
||||
id={id}
|
||||
className={styles.pathInput}
|
||||
value={root}
|
||||
placeholder="System default"
|
||||
disabled={locked}
|
||||
onChange={(e) => setRoot(e.target.value)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="folder-open"
|
||||
size="small"
|
||||
title="Browse…"
|
||||
aria-label="Browse"
|
||||
disabled={locked}
|
||||
onClick={() => void browse()}
|
||||
/>
|
||||
</Row>
|
||||
{info?.envOverride === true && (
|
||||
<p role="note" className={styles.note}>
|
||||
<T>The OPENIDEA_LOCAL_DATA_DIR environment variable is set and takes precedence.</T>
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.fontRow}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
disabled={locked}
|
||||
onClick={() => void apply(root.trim() ? root.trim() : null)}
|
||||
>
|
||||
Apply and Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={locked || (!root && !info?.customRoot)}
|
||||
onClick={() => {
|
||||
setRoot("");
|
||||
void apply(null);
|
||||
}}
|
||||
>
|
||||
Reset to default
|
||||
</Button>
|
||||
<Checkbox label="Move existing data to the new location" checked={move} onChange={setMove} disabled={locked} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function IdleSaveRow() {
|
||||
const [on, setOn] = useSetting<boolean>("general.saveOnIdle");
|
||||
const [seconds, setSeconds] = useSetting<number>("general.idleSeconds");
|
||||
@@ -715,6 +835,17 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
|
||||
{ kind: "custom", label: "Default directory", component: DefaultDirectoryRow },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Caches",
|
||||
options: [
|
||||
{
|
||||
kind: "custom",
|
||||
label: "Local data directory",
|
||||
keywords: ["cache", "local data", "data directory", "C盘", "缓存", "目录"],
|
||||
component: LocalDataDirRow,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Autosave",
|
||||
options: [
|
||||
|
||||
@@ -97,9 +97,7 @@ function treeIds(nodes: readonly Node[], out = new Set<string>()): Set<string> {
|
||||
|
||||
/** Settings sub-page that owns an attribute, for preview clicks that cross pages. */
|
||||
function attrPageId(attr: AttributeDef): string {
|
||||
return `editor.colorScheme.${
|
||||
attr.path[0] === "Language Defaults" ? "language-defaults" : attr.path[0]!.toLowerCase()
|
||||
}`;
|
||||
return languagePageId(attr.path[0]!);
|
||||
}
|
||||
|
||||
/** Preview click on another sub-page: the target page selects and reveals the attribute. */
|
||||
@@ -123,6 +121,7 @@ function previewVars(
|
||||
k.startsWith("--fg-") ||
|
||||
k.startsWith("--bg-") ||
|
||||
k.startsWith("--border-") ||
|
||||
k.startsWith("--ansi-") ||
|
||||
k === "--caret"
|
||||
)
|
||||
vars[k] = v;
|
||||
@@ -236,6 +235,30 @@ const SAMPLES: Record<string, CodeSample> = {
|
||||
],
|
||||
caret: 2,
|
||||
},
|
||||
"Console Colors": {
|
||||
code: [
|
||||
[
|
||||
["black ", "ansiBlack"],
|
||||
["red ", "ansiRed"],
|
||||
["green ", "ansiGreen"],
|
||||
["yellow ", "ansiYellow"],
|
||||
["blue ", "ansiBlue"],
|
||||
["magenta ", "ansiMagenta"],
|
||||
["cyan ", "ansiCyan"],
|
||||
["white", "ansiWhite"],
|
||||
],
|
||||
[
|
||||
["dark ", "ansiBrightBlack"],
|
||||
["bright-red ", "ansiBrightRed"],
|
||||
["bright-green ", "ansiBrightGreen"],
|
||||
["bright-yellow ", "ansiBrightYellow"],
|
||||
["bright-blue ", "ansiBrightBlue"],
|
||||
["bright-magenta ", "ansiBrightMagenta"],
|
||||
["bright-cyan ", "ansiBrightCyan"],
|
||||
["bright-white", "ansiBrightWhite"],
|
||||
],
|
||||
],
|
||||
},
|
||||
"Language Defaults": {
|
||||
code: [
|
||||
[["# Language Defaults", "lineComment"]],
|
||||
@@ -334,30 +357,30 @@ const SAMPLES: Record<string, CodeSample> = {
|
||||
caret: 4,
|
||||
code: [
|
||||
[
|
||||
["import ", "keyword"],
|
||||
["import ", "pyKeyword"],
|
||||
["os", "pyStaticSymbol"],
|
||||
],
|
||||
[
|
||||
["MAX_RETRIES", "pyConstant"],
|
||||
[" = ", "operator"],
|
||||
["3", "number"],
|
||||
[" = ", "pyOperationSign"],
|
||||
["3", "pyNumber"],
|
||||
],
|
||||
[],
|
||||
[
|
||||
["@decorator", "pyDecorator"],
|
||||
["(", "pyParentheses"],
|
||||
["param", "pyKeywordArg"],
|
||||
["=", "operator"],
|
||||
["1", "number"],
|
||||
["=", "pyOperationSign"],
|
||||
["1", "pyNumber"],
|
||||
[")", "pyParentheses"],
|
||||
],
|
||||
[
|
||||
["def ", "keyword"],
|
||||
["f", "functionDeclaration"],
|
||||
["def ", "pyKeyword"],
|
||||
["f", "pyFunctionDeclaration"],
|
||||
["(", "pyParentheses"],
|
||||
["x", "parameter"],
|
||||
["x", "pyParameter"],
|
||||
[")", "pyParentheses"],
|
||||
[":", "operator"],
|
||||
[":", "pyOperationSign"],
|
||||
],
|
||||
[[' """', "pyDocstring"]],
|
||||
[[" Syntax Highlighting Demo", "pyDocstring"]],
|
||||
@@ -390,199 +413,199 @@ const SAMPLES: Record<string, CodeSample> = {
|
||||
[[' """', "pyDocstring"]],
|
||||
[],
|
||||
[
|
||||
[" def ", "keyword"],
|
||||
[" def ", "pyKeyword"],
|
||||
["nested_func", "pyNestedFunction"],
|
||||
["(", "pyParentheses"],
|
||||
["y", "parameter"],
|
||||
["y", "pyParameter"],
|
||||
[")", "pyParentheses"],
|
||||
[":", "operator"],
|
||||
[":", "pyOperationSign"],
|
||||
],
|
||||
[
|
||||
[" ", "text"],
|
||||
["print", "pyPredefinedUsage"],
|
||||
["print", "pyBuiltin"],
|
||||
["(", "pyParentheses"],
|
||||
["y", "identifier"],
|
||||
[" + ", "operator"],
|
||||
["1", "number"],
|
||||
["y", "pyLocalVariable"],
|
||||
[" + ", "pyOperationSign"],
|
||||
["1", "pyNumber"],
|
||||
[")", "pyParentheses"],
|
||||
],
|
||||
[],
|
||||
[
|
||||
[" s", "pyLocalVariable"],
|
||||
[" = ", "operator"],
|
||||
[" = ", "pyOperationSign"],
|
||||
["(", "pyParentheses"],
|
||||
['"Test"', "string"],
|
||||
[", ", "operator"],
|
||||
["2", "number"],
|
||||
["+", "operator"],
|
||||
["3", "number"],
|
||||
[", ", "operator"],
|
||||
['"Test"', "pyString"],
|
||||
[", ", "pyOperationSign"],
|
||||
["2", "pyNumber"],
|
||||
["+", "pyOperationSign"],
|
||||
["3", "pyNumber"],
|
||||
[", ", "pyOperationSign"],
|
||||
["{", "pyBraces"],
|
||||
["'a'", "string"],
|
||||
[": ", "operator"],
|
||||
["'b'", "string"],
|
||||
["'a'", "pyString"],
|
||||
[": ", "pyOperationSign"],
|
||||
["'b'", "pyString"],
|
||||
["}", "pyBraces"],
|
||||
[",", "operator"],
|
||||
[",", "pyOperationSign"],
|
||||
],
|
||||
[
|
||||
[" ", "text"],
|
||||
["f", "pyFString"],
|
||||
["'{", "pyFString"],
|
||||
["x", "parameter"],
|
||||
["x", "pyParameter"],
|
||||
["!s:", "pyFormat"],
|
||||
["{", "pyFormat"],
|
||||
['"^10"', "string"],
|
||||
['"^10"', "pyString"],
|
||||
["}", "pyFormat"],
|
||||
["}'", "pyFString"],
|
||||
[", ", "operator"],
|
||||
[", ", "pyOperationSign"],
|
||||
["f", "pyFString"],
|
||||
['"{', "pyFString"],
|
||||
["1", "number"],
|
||||
["1", "pyNumber"],
|
||||
[":", "pyFString"],
|
||||
["01d", "pyFormat"],
|
||||
['}"', "pyFString"],
|
||||
[")", "pyParentheses"],
|
||||
[" ", "text"],
|
||||
["# Comment", "lineComment"],
|
||||
["# Comment", "pyLineComment"],
|
||||
],
|
||||
[
|
||||
[" ", "text"],
|
||||
["f", "functionCall"],
|
||||
["f", "pyFunctionCall"],
|
||||
["(", "pyParentheses"],
|
||||
["s", "pyLocalVariable"],
|
||||
["[", "pyBrackets"],
|
||||
["0", "number"],
|
||||
["0", "pyNumber"],
|
||||
["]", "pyBrackets"],
|
||||
[".", "operator"],
|
||||
["lower", "pyPredefinedUsage"],
|
||||
[".", "pyOperationSign"],
|
||||
["lower", "pyMethodCall"],
|
||||
["(", "pyParentheses"],
|
||||
[")", "pyParentheses"],
|
||||
[")", "pyParentheses"],
|
||||
],
|
||||
[
|
||||
[" ", "text"],
|
||||
["nested_func", "functionCall"],
|
||||
["nested_func", "pyFunctionCall"],
|
||||
["(", "pyParentheses"],
|
||||
["42", "number"],
|
||||
["42", "pyNumber"],
|
||||
[")", "pyParentheses"],
|
||||
],
|
||||
[],
|
||||
[
|
||||
["class ", "keyword"],
|
||||
["Foo", "className"],
|
||||
[":", "operator"],
|
||||
["class ", "pyKeyword"],
|
||||
["Foo", "pyClassDefinition"],
|
||||
[":", "pyOperationSign"],
|
||||
],
|
||||
[
|
||||
[" tags", "pyClassAttribute"],
|
||||
[": ", "text"],
|
||||
["List", "pyAnnotation"],
|
||||
["[", "pyBrackets"],
|
||||
["str", "pyAnnotation"],
|
||||
["str", "pyBuiltin"],
|
||||
["]", "pyBrackets"],
|
||||
],
|
||||
[],
|
||||
[
|
||||
[" def ", "keyword"],
|
||||
[" def ", "pyKeyword"],
|
||||
["__init__", "pyPredefinedDefinition"],
|
||||
["(", "pyParentheses"],
|
||||
["self", "pySelf"],
|
||||
[": ", "text"],
|
||||
["Foo", "pyAnnotation"],
|
||||
[")", "pyParentheses"],
|
||||
[":", "operator"],
|
||||
[":", "pyOperationSign"],
|
||||
],
|
||||
[
|
||||
[" byte_string", "pyLocalVariable"],
|
||||
[": ", "text"],
|
||||
["bytes", "pyAnnotation"],
|
||||
[" = ", "operator"],
|
||||
["bytes", "pyBuiltin"],
|
||||
[" = ", "pyOperationSign"],
|
||||
["b'newline:", "pyBytes"],
|
||||
["\\n", "escape"],
|
||||
["\\n", "pyValidEscape"],
|
||||
[" also newline:", "pyBytes"],
|
||||
["\\x0a", "escape"],
|
||||
["\\x0a", "pyValidEscape"],
|
||||
["'", "pyBytes"],
|
||||
],
|
||||
[
|
||||
[" text_string", "pyLocalVariable"],
|
||||
[" = ", "operator"],
|
||||
['u"Cyrillic Я is ', "string"],
|
||||
["\\u042f", "escape"],
|
||||
[". Oops: ", "string"],
|
||||
["\\u042g", "weakWarning"],
|
||||
['"', "string"],
|
||||
[" = ", "pyOperationSign"],
|
||||
['u"Cyrillic Я is ', "pyString"],
|
||||
["\\u042f", "pyValidEscape"],
|
||||
[". Oops: ", "pyString"],
|
||||
["\\u042g", "pyInvalidEscape"],
|
||||
['"', "pyString"],
|
||||
],
|
||||
[
|
||||
[" ", "text"],
|
||||
["self", "pySelf"],
|
||||
[".", "operator"],
|
||||
[".", "pyOperationSign"],
|
||||
["make_sense", "pyMethodCall"],
|
||||
["(", "pyParentheses"],
|
||||
["whatever", "pyKeywordArg"],
|
||||
["=", "operator"],
|
||||
["1", "number"],
|
||||
["=", "pyOperationSign"],
|
||||
["1", "pyNumber"],
|
||||
[")", "pyParentheses"],
|
||||
],
|
||||
[],
|
||||
[["@property", "pyDecorator"]],
|
||||
[
|
||||
[" def ", "keyword"],
|
||||
[" def ", "pyKeyword"],
|
||||
["alias", "pyProperty"],
|
||||
["(", "pyParentheses"],
|
||||
["self", "pySelf"],
|
||||
[")", "pyParentheses"],
|
||||
[":", "operator"],
|
||||
[":", "pyOperationSign"],
|
||||
],
|
||||
[
|
||||
[" return ", "keyword"],
|
||||
[" return ", "pyKeyword"],
|
||||
["self", "pySelf"],
|
||||
[".", "operator"],
|
||||
[".", "pyOperationSign"],
|
||||
["sense", "pyInstanceAttribute"],
|
||||
],
|
||||
[],
|
||||
[
|
||||
[" def ", "keyword"],
|
||||
["make_sense", "functionDeclaration"],
|
||||
[" def ", "pyKeyword"],
|
||||
["make_sense", "pyFunctionDeclaration"],
|
||||
["[", "pyBrackets"],
|
||||
["T", "pyTypeParameter"],
|
||||
["]", "pyBrackets"],
|
||||
["(", "pyParentheses"],
|
||||
["self", "pySelf"],
|
||||
[", ", "operator"],
|
||||
["whatever", "parameter"],
|
||||
[", ", "pyOperationSign"],
|
||||
["whatever", "pyParameter"],
|
||||
[": ", "text"],
|
||||
["T", "pyAnnotation"],
|
||||
[")", "pyParentheses"],
|
||||
[":", "operator"],
|
||||
[":", "pyOperationSign"],
|
||||
],
|
||||
[
|
||||
[" ", "text"],
|
||||
["self", "pySelf"],
|
||||
[".", "operator"],
|
||||
[".", "pyOperationSign"],
|
||||
["sense", "pyInstanceAttribute"],
|
||||
[" = ", "operator"],
|
||||
["whatever", "parameter"],
|
||||
[" = ", "pyOperationSign"],
|
||||
["whatever", "pyParameter"],
|
||||
],
|
||||
[],
|
||||
[
|
||||
["x", "pyGlobalVariable"],
|
||||
[" = ", "operator"],
|
||||
["len", "pyPredefinedUsage"],
|
||||
[" = ", "pyOperationSign"],
|
||||
["len", "pyBuiltin"],
|
||||
["(", "pyParentheses"],
|
||||
['"abc"', "string"],
|
||||
['"abc"', "pyString"],
|
||||
[")", "pyParentheses"],
|
||||
],
|
||||
[
|
||||
["type ", "keyword"],
|
||||
["type ", "pyKeyword"],
|
||||
["my_int", "pyGlobalVariable"],
|
||||
[" = ", "operator"],
|
||||
[" = ", "pyOperationSign"],
|
||||
["int", "pyBuiltin"],
|
||||
],
|
||||
[
|
||||
["print", "pyPredefinedUsage"],
|
||||
["print", "pyBuiltin"],
|
||||
["(", "pyParentheses"],
|
||||
["f", "functionCall"],
|
||||
[".", "operator"],
|
||||
["__doc__", "pyInstanceAttribute"],
|
||||
["f", "pyFunctionCall"],
|
||||
[".", "pyOperationSign"],
|
||||
["__doc__", "pyPredefinedUsage"],
|
||||
[")", "pyParentheses"],
|
||||
],
|
||||
],
|
||||
@@ -816,6 +839,72 @@ const SAMPLES: Record<string, CodeSample> = {
|
||||
[["}", "kotlin.operator"]],
|
||||
],
|
||||
},
|
||||
"C#": {
|
||||
code: [
|
||||
[["// Greets the user", "csharp.lineComment"]],
|
||||
[
|
||||
["using ", "csharp.keyword"],
|
||||
["System", "csharp.packageReference"],
|
||||
[";", "csharp.operator"],
|
||||
],
|
||||
[["/// <summary>Says hello.</summary>", "csharp.docComment"]],
|
||||
[
|
||||
["[", "csharp.operator"],
|
||||
["Serializable", "csharp.metadata"],
|
||||
["]", "csharp.operator"],
|
||||
],
|
||||
[
|
||||
["public class ", "csharp.keyword"],
|
||||
["Greeter", "csharp.className"],
|
||||
],
|
||||
[["{", "csharp.operator"]],
|
||||
[
|
||||
[" private const int ", "csharp.keyword"],
|
||||
["MaxRetries", "csharp.constant"],
|
||||
[" = ", "csharp.operator"],
|
||||
["3", "csharp.number"],
|
||||
[";", "csharp.operator"],
|
||||
],
|
||||
[
|
||||
[" public string ", "csharp.keyword"],
|
||||
["Name", "csharp.field"],
|
||||
[" { ", "csharp.operator"],
|
||||
["get", "csharp.keyword"],
|
||||
["; }", "csharp.operator"],
|
||||
],
|
||||
[
|
||||
[" public string ", "csharp.keyword"],
|
||||
["Greet", "csharp.functionDeclaration"],
|
||||
["(", "csharp.operator"],
|
||||
["int ", "csharp.keyword"],
|
||||
["count", "csharp.parameter"],
|
||||
[")", "csharp.operator"],
|
||||
],
|
||||
[[" {", "csharp.operator"]],
|
||||
[
|
||||
[" var ", "csharp.keyword"],
|
||||
["message", "csharp.identifier"],
|
||||
[" = ", "csharp.operator"],
|
||||
['$"Hello, {Name}', "csharp.string"],
|
||||
["\\n", "csharp.escape"],
|
||||
['"', "csharp.string"],
|
||||
[";", "csharp.operator"],
|
||||
],
|
||||
[
|
||||
[" return ", "csharp.keyword"],
|
||||
["string", "csharp.keyword"],
|
||||
[".", "csharp.operator"],
|
||||
["Format", "csharp.functionCall"],
|
||||
["(", "csharp.operator"],
|
||||
["message", "csharp.identifier"],
|
||||
[", ", "csharp.operator"],
|
||||
["count", "csharp.parameter"],
|
||||
[");", "csharp.operator"],
|
||||
],
|
||||
[[" }", "csharp.operator"]],
|
||||
[["}", "csharp.operator"]],
|
||||
],
|
||||
},
|
||||
PHP: {
|
||||
code: [
|
||||
[["<?php", "php.scriptTag"]],
|
||||
@@ -1125,10 +1214,15 @@ function ColorSchemePage({ scopes, landing }: { scopes: readonly string[]; landi
|
||||
const attr = ATTRIBUTES.find((a) => a.id === selected);
|
||||
const language = attr?.language;
|
||||
const inherited = useMemo(() => (attr ? resolvedAttribute(id, attr.id, customs) : {}), [id, attr, customs]);
|
||||
const vars = useMemo(
|
||||
() => ({ ...previewVars(id, mine, customs, language), ...fontWeightVars(weight, boldFontWeight) }) as CSSProperties,
|
||||
[id, mine, customs, language, weight, boldFontWeight],
|
||||
);
|
||||
const vars = useMemo(() => {
|
||||
const next = {
|
||||
...previewVars(id, mine, customs, language),
|
||||
...fontWeightVars(weight, boldFontWeight),
|
||||
} as CSSProperties & Record<string, string>;
|
||||
// Console preview sits on the console background, not the editor surface.
|
||||
if (scopes[0] === "Console Colors" && next["--bg-console"]) next["--bg-editor"] = next["--bg-console"];
|
||||
return next;
|
||||
}, [id, mine, customs, language, weight, boldFontWeight, scopes]);
|
||||
const baseVars = useMemo(
|
||||
() => ({ ...previewVars(id, mine, customs), ...fontWeightVars(weight, boldFontWeight) }) as CSSProperties,
|
||||
[id, mine, customs, weight, boldFontWeight],
|
||||
@@ -1377,15 +1471,19 @@ function ColorSchemePage({ scopes, landing }: { scopes: readonly string[]; landi
|
||||
}
|
||||
|
||||
const LANGUAGES = [...new Set(ATTRIBUTES.map((a) => a.path[0]!))].filter(
|
||||
(s) => !["General", "Debugger", "Language Defaults"].includes(s),
|
||||
(s) => !["General", "Debugger", "Language Defaults", "Console Colors"].includes(s),
|
||||
);
|
||||
/** A sub-page id: `C#` → `csharp`, `Console Colors` → `console-colors` (`#` would end a settings link). */
|
||||
const languagePageId = (language: string) =>
|
||||
`editor.colorScheme.${language.toLowerCase().replace(/#/g, "sharp").replace(/\s+/g, "-")}`;
|
||||
|
||||
/** Sub-pages linked from the Color Scheme landing page, in sidebar order. */
|
||||
const SCHEME_SUBPAGES = [
|
||||
{ id: "editor.colorScheme.general", title: "General" },
|
||||
{ id: "editor.colorScheme.language-defaults", title: "Language Defaults" },
|
||||
{ id: "editor.colorScheme.debugger", title: "Debugger" },
|
||||
...LANGUAGES.map((l) => ({ id: `editor.colorScheme.${l.toLowerCase()}`, title: l })),
|
||||
{ id: "editor.colorScheme.console-colors", title: "Console Colors" },
|
||||
...LANGUAGES.map((l) => ({ id: languagePageId(l), title: l })),
|
||||
];
|
||||
|
||||
/** The scope chooser, import/export buttons and preview are identical on every sub-page. */
|
||||
@@ -1477,11 +1575,19 @@ export const COLOR_SCHEME_PAGES: PageSpec[] = [
|
||||
keywords: ["breakpoint", "inline values"],
|
||||
sections: [colorSchemeSection(["Debugger"])],
|
||||
}),
|
||||
editorPage({
|
||||
id: "editor.colorScheme.console-colors",
|
||||
title: "Console Colors",
|
||||
order: 4,
|
||||
projectScope: false,
|
||||
keywords: ["terminal", "ansi", "console", "background"],
|
||||
sections: [colorSchemeSection(["Console Colors"])],
|
||||
}),
|
||||
...LANGUAGES.map((language, i) =>
|
||||
editorPage({
|
||||
id: `editor.colorScheme.${language.toLowerCase()}`,
|
||||
id: languagePageId(language),
|
||||
title: language,
|
||||
order: 4 + i,
|
||||
order: 5 + i,
|
||||
projectScope: false,
|
||||
keywords: ATTRIBUTES.filter((a) => a.path[0] === language).map((a) => a.name),
|
||||
sections: [colorSchemeSection([language])],
|
||||
|
||||
@@ -674,7 +674,12 @@ const SMART_KEYS = editorPage({
|
||||
label: 'Use "CamelHumps" words',
|
||||
hint: "⌥← / ⌥→ and ⌥⌫ stop at humps of camelCase names.",
|
||||
},
|
||||
{ t: "check", key: `${P}.smartKeys.pairBrackets`, label: "Insert paired brackets (), [], {}" },
|
||||
{
|
||||
t: "check",
|
||||
key: `${P}.smartKeys.pairBrackets`,
|
||||
label: "Insert paired brackets (), [], {}",
|
||||
hint: "Also pairs <> for generics where the syntax says a type starts.",
|
||||
},
|
||||
{ t: "check", key: `${P}.smartKeys.pairQuotes`, label: "Insert pair quote" },
|
||||
{ t: "check", key: `${P}.smartKeys.surroundSelection`, label: "Surround selection on typing quote or brace" },
|
||||
{ t: "check", key: `${P}.smartKeys.tabOutOfPair`, label: "Jump outside closing bracket/quote with Tab" },
|
||||
|
||||
@@ -43,12 +43,14 @@ export function buildTheme(read: TokenReader): ITheme {
|
||||
const color = (name: string, fallback = FALLBACK[name] ?? "#808080"): Rgba =>
|
||||
parseColor(read(name)) ?? parseColor(fallback)!;
|
||||
|
||||
const bg = color("--bg-island", read("--bg-app") || FALLBACK["--bg-app"]);
|
||||
// Console Colors edits `--bg-console`; fall back to the island / app surface.
|
||||
const bg = color("--bg-console", read("--bg-island") || read("--bg-app") || FALLBACK["--bg-app"]);
|
||||
const fg = color("--fg-editor", read("--fg") || FALLBACK["--fg"]);
|
||||
const strong = color("--fg-strong");
|
||||
const accent = color("--accent");
|
||||
const caret = color("--caret", toHex(accent));
|
||||
const selection = parseColor(read("--bg-editor-selection"));
|
||||
// Theme `--terminal-ansi-*` first; Settings/import mirror `--ansi-*` into those names.
|
||||
const ansi = (name: string, derived: () => Rgba): Rgba =>
|
||||
parseColor(read(`--terminal-ansi-${name}`)) ?? parseColor(read(`--ansi-${name}`)) ?? derived();
|
||||
|
||||
|
||||
@@ -58,19 +58,56 @@ describe("color schemes", () => {
|
||||
expect(css).toContain("--syn-constant: #224466 !important");
|
||||
});
|
||||
|
||||
it("offers a C# scheme page and scopes its overrides to C# editors", () => {
|
||||
expect(ATTRIBUTES.find((a) => a.id === "csharp.className")?.path).toEqual(["C#"]);
|
||||
const css = schemeCss("dark", { dark: { "csharp.keyword": { foreground: "#224466" } } });
|
||||
expect(css).toContain('.cm-editor[data-language="C#"]');
|
||||
expect(css).toContain("--syn-keyword: #224466 !important");
|
||||
});
|
||||
|
||||
it("groups Python attributes as PyCharm's color settings page does", () => {
|
||||
const python = (name: string) => ATTRIBUTES.find((a) => a.id === `py${name}`)!;
|
||||
expect(python("Number").name).toBe("Number");
|
||||
expect(python("Keyword").name).toBe("Keyword");
|
||||
expect(python("LineComment").name).toBe("Line comment");
|
||||
expect(python("String").name).toBe("String › Text (unicode)");
|
||||
expect(python("ValidEscape").name).toBe("String › Escape sequence › Valid");
|
||||
expect(python("InvalidEscape").name).toBe("String › Escape sequence › Invalid");
|
||||
expect(python("FString").name).toBe("String › f-string › Expression braces");
|
||||
expect(python("Bytes").name).toBe("String › Binary (bytes)");
|
||||
expect(python("Docstring").name).toBe("Docstring › Text");
|
||||
expect(python("OperationSign").name).toBe("Braces and Operators › Operation sign");
|
||||
expect(python("Parentheses").name).toBe("Braces and Operators › Parentheses");
|
||||
expect(python("FunctionDeclaration").name).toBe("Functions › Function definition");
|
||||
expect(python("FunctionCall").name).toBe("Functions › Function call");
|
||||
expect(python("MethodCall").name).toBe("Functions › Method call");
|
||||
expect(python("NestedFunction").name).toBe("Functions › Nested function definition");
|
||||
expect(python("Parameter").name).toBe("Parameters › Parameter");
|
||||
expect(python("Self").name).toBe("Parameters › 'self' parameter");
|
||||
expect(python("KeywordArg").name).toBe("Keyword argument");
|
||||
expect(python("PredefinedDefinition").name).toBe("Special names › Definition");
|
||||
expect(python("PredefinedUsage").name).toBe("Special names › Usage");
|
||||
expect(python("Parentheses").name).toBe("Braces and Operators › Parentheses");
|
||||
expect(python("Builtin").name).toBe("Special names › Built-in name");
|
||||
expect(python("Decorator").name).toBe("Special names › Decorator");
|
||||
expect(python("ClassDefinition").name).toBe("Classes › Class definition");
|
||||
expect(python("LocalVariable").name).toBe("Identifiers › Local variables");
|
||||
expect(python("GlobalVariable").name).toBe("Identifiers › Global variable");
|
||||
// Attribute ids (the override and .icls keys) keep their old, stable names.
|
||||
for (const a of ATTRIBUTES.filter((a) => a.path[0] === "Python")) expect(a.id).toMatch(/^py[A-Z]/);
|
||||
for (const a of ATTRIBUTES.filter((a) => a.path[0] === "Python")) {
|
||||
expect(a.id).toMatch(/^py[A-Z]/);
|
||||
expect(a.language).toBe("Python");
|
||||
}
|
||||
});
|
||||
|
||||
it("scopes Python overrides to Python editors and falls back to the shared token like JetBrains", () => {
|
||||
// Shared token: a Python override lands in the language block, not on :root.
|
||||
expect(ATTRIBUTES.find((a) => a.id === "pyKeyword")?.token).toBe("keyword");
|
||||
const css = schemeCss("dark", { dark: { pyKeyword: { foreground: "#224466" } } });
|
||||
expect(css).toContain('.cm-editor[data-language="Python"]');
|
||||
expect(css).toContain("--syn-keyword: #224466 !important");
|
||||
expect(css.indexOf('.cm-editor[data-language="Python"]')).toBeGreaterThan(css.indexOf(":root"));
|
||||
// Unset values resolve through the shared scheme token, as JetBrains' fallback keys do.
|
||||
expect(resolvedAttribute("dark", "pyKeyword")).toMatchObject({ foreground: "#cf8e6d" });
|
||||
});
|
||||
|
||||
it("resolves scheme attribute state and editor defaults", () => {
|
||||
@@ -131,6 +168,25 @@ describe("color schemes", () => {
|
||||
expect(pythonItalic).toContain("--syn-python-static-italic: italic !important");
|
||||
});
|
||||
|
||||
it("mirrors Console Colors ANSI overrides to the terminal palette (#2698)", () => {
|
||||
expect(ATTRIBUTES.filter((a) => a.path[0] === "Console Colors")).toHaveLength(17);
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = "light";
|
||||
const css = schemeCss(
|
||||
"theme-default",
|
||||
{
|
||||
light: {
|
||||
ansiRed: { foreground: "#aa0000" },
|
||||
consoleBackground: { background: "#101010" },
|
||||
},
|
||||
},
|
||||
root,
|
||||
);
|
||||
expect(css).toContain("--ansi-red: #aa0000 !important");
|
||||
expect(css).toContain("--terminal-ansi-red: #aa0000 !important");
|
||||
expect(css).toContain("--bg-console: #101010 !important");
|
||||
});
|
||||
|
||||
it("leaves selected text in syntax colours until a selection foreground is set (#2446)", () => {
|
||||
expect(ATTRIBUTES.find((a) => a.id === "selectionForeground")).toMatchObject({
|
||||
name: "Selection foreground",
|
||||
|
||||
+85
-31
@@ -55,6 +55,14 @@ const syn = (
|
||||
|
||||
const LD = "Language Defaults";
|
||||
|
||||
/** A Python page attribute: language-scoped, so its overrides apply to Python editors only. */
|
||||
const py = (
|
||||
id: string,
|
||||
name: string,
|
||||
token: string,
|
||||
defaults: Pick<AttributeDef, "defaultBold" | "defaultItalic" | "defaultEffect"> = {},
|
||||
): AttributeDef => ({ ...syn(id, name, ["Python"], token, defaults), language: "Python" });
|
||||
|
||||
/** IDEA-style attribute tree: General, Language Defaults, then languages. */
|
||||
const BASE_ATTRIBUTES: readonly AttributeDef[] = [
|
||||
{ id: "text", name: "Default text", path: ["General", "Text"], fg: "--fg-editor", bg: "--bg-editor" },
|
||||
@@ -127,6 +135,26 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [
|
||||
{ id: "inlineValue", name: "Inline debugger values", path: ["Debugger"], fg: "--syn-inline-value" },
|
||||
{ id: "breakpointLine", name: "Breakpoint line", path: ["Debugger"], bg: "--bg-breakpoint-line" },
|
||||
|
||||
// Console / terminal: IDEA's Console Colors (background + 16 ANSI). `--ansi-*` is mirrored
|
||||
// to `--terminal-ansi-*` in overrideDeclarations so the terminal picks up edits.
|
||||
{ id: "consoleBackground", name: "Console background", path: ["Console Colors"], bg: "--bg-console" },
|
||||
{ id: "ansiBlack", name: "Black", path: ["Console Colors"], fg: "--ansi-black" },
|
||||
{ id: "ansiRed", name: "Red", path: ["Console Colors"], fg: "--ansi-red" },
|
||||
{ id: "ansiGreen", name: "Green", path: ["Console Colors"], fg: "--ansi-green" },
|
||||
{ id: "ansiYellow", name: "Yellow", path: ["Console Colors"], fg: "--ansi-yellow" },
|
||||
{ id: "ansiBlue", name: "Blue", path: ["Console Colors"], fg: "--ansi-blue" },
|
||||
{ id: "ansiMagenta", name: "Magenta", path: ["Console Colors"], fg: "--ansi-magenta" },
|
||||
{ id: "ansiCyan", name: "Cyan", path: ["Console Colors"], fg: "--ansi-cyan" },
|
||||
{ id: "ansiWhite", name: "White", path: ["Console Colors"], fg: "--ansi-white" },
|
||||
{ id: "ansiBrightBlack", name: "Dark gray", path: ["Console Colors"], fg: "--ansi-bright-black" },
|
||||
{ id: "ansiBrightRed", name: "Bright red", path: ["Console Colors"], fg: "--ansi-bright-red" },
|
||||
{ id: "ansiBrightGreen", name: "Bright green", path: ["Console Colors"], fg: "--ansi-bright-green" },
|
||||
{ id: "ansiBrightYellow", name: "Bright yellow", path: ["Console Colors"], fg: "--ansi-bright-yellow" },
|
||||
{ id: "ansiBrightBlue", name: "Bright blue", path: ["Console Colors"], fg: "--ansi-bright-blue" },
|
||||
{ id: "ansiBrightMagenta", name: "Bright magenta", path: ["Console Colors"], fg: "--ansi-bright-magenta" },
|
||||
{ id: "ansiBrightCyan", name: "Bright cyan", path: ["Console Colors"], fg: "--ansi-bright-cyan" },
|
||||
{ id: "ansiBrightWhite", name: "Bright white", path: ["Console Colors"], fg: "--ansi-bright-white" },
|
||||
|
||||
syn("keyword", "Keyword", [LD], "keyword"),
|
||||
syn("identifier", "Identifiers › Local variable", [LD], "variable"),
|
||||
syn("parameter", "Identifiers › Parameter", [LD], "param"),
|
||||
@@ -154,36 +182,51 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [
|
||||
...[1, 2, 3, 4, 5].map((n) => syn(`semantic${n}`, `Semantic highlighting › Color ${n}`, [LD], `rainbow-${n}`)),
|
||||
...[1, 2, 3].map((n) => syn(`bracket${n}`, `Rainbow brackets › Level ${n}`, [LD], `bracket-${n}`)),
|
||||
|
||||
// Python's tree mirrors PyCharm's groups and their order (PySyntaxBundle.properties in python-syntax).
|
||||
syn("pyBytes", "String › Binary (bytes)", ["Python"], "python-bytes"),
|
||||
syn("pyFString", "String › f-string › Expression braces", ["Python"], "python-fstring"),
|
||||
syn("pyFormat", "String › f-string › Format spec number", ["Python"], "python-format"),
|
||||
syn("pyDocstring", "Docstring › Text", ["Python"], "docstring", { defaultItalic: true }),
|
||||
syn("pyDocstringTag", "Docstring › Tag", ["Python"], "python-docstring-tag"),
|
||||
syn("pyDocstringKeyword", "Docstring › Keyword", ["Python"], "python-docstring-keyword"),
|
||||
syn("pyParentheses", "Braces and Operators › Parentheses", ["Python"], "python-parentheses"),
|
||||
syn("pyBrackets", "Braces and Operators › Brackets", ["Python"], "python-brackets"),
|
||||
syn("pyBraces", "Braces and Operators › Braces", ["Python"], "python-braces"),
|
||||
syn("pyNestedFunction", "Functions › Nested function definition", ["Python"], "python-nested-function"),
|
||||
syn("pyMethodCall", "Functions › Method call", ["Python"], "python-method-call"),
|
||||
syn("pySelf", "Parameters › 'self' parameter", ["Python"], "self"),
|
||||
syn("pyKeywordArg", "Keyword argument", ["Python"], "keyword-arg"),
|
||||
syn("pyPredefinedDefinition", "Special names › Definition", ["Python"], "python-predefined-definition"),
|
||||
syn("pyPredefinedUsage", "Special names › Usage", ["Python"], "python-predefined-usage"),
|
||||
syn("pyBuiltin", "Built-in name", ["Python"], "python-builtin"),
|
||||
syn("pyDecorator", "Decorator", ["Python"], "annotation"),
|
||||
syn("pyAnnotation", "Type annotation", ["Python"], "python-annotation"),
|
||||
syn("pyLocalVariable", "Local variables", ["Python"], "python-local-variable"),
|
||||
syn("pyTypeParameter", "Type parameters", ["Python"], "python-type-parameter"),
|
||||
syn("pyGlobalVariable", "Global variable", ["Python"], "python-global-variable"),
|
||||
syn("pyInstanceAttribute", "Instance attribute", ["Python"], "python-instance-attribute"),
|
||||
syn("pyClassAttribute", "Class attribute", ["Python"], "python-class-attribute"),
|
||||
syn("pyConstant", "Constant", ["Python"], "python-constant"),
|
||||
syn("pyProperty", "Property", ["Python"], "python-property"),
|
||||
// Python's tree mirrors PyCharm's colour page (PythonColorsPage.java): every key the editor can
|
||||
// restyle, in the page's order. Language-scoped like java.*: shared-token attributes reuse the
|
||||
// Language Defaults token, so an override shadows it inside Python editors only and unset values
|
||||
// keep falling back to the scheme default, as JetBrains' fallback keys do.
|
||||
py("pyNumber", "Number", "number"),
|
||||
py("pyKeyword", "Keyword", "keyword"),
|
||||
py("pyLineComment", "Line comment", "comment"),
|
||||
py("pyBytes", "String › Binary (bytes)", "python-bytes"),
|
||||
py("pyString", "String › Text (unicode)", "string"),
|
||||
py("pyValidEscape", "String › Escape sequence › Valid", "escape"),
|
||||
py("pyInvalidEscape", "String › Escape sequence › Invalid", "invalid"),
|
||||
py("pyFString", "String › f-string › Expression braces", "python-fstring"),
|
||||
py("pyFormat", "String › f-string › Format spec number", "python-format"),
|
||||
py("pyDocstring", "Docstring › Text", "docstring", { defaultItalic: true }),
|
||||
py("pyDocstringTag", "Docstring › Tag", "python-docstring-tag"),
|
||||
py("pyDocstringKeyword", "Docstring › Keyword", "python-docstring-keyword"),
|
||||
py("pyOperationSign", "Braces and Operators › Operation sign", "punct"),
|
||||
py("pyParentheses", "Braces and Operators › Parentheses", "python-parentheses"),
|
||||
py("pyBrackets", "Braces and Operators › Brackets", "python-brackets"),
|
||||
py("pyBraces", "Braces and Operators › Braces", "python-braces"),
|
||||
py("pyFunctionDeclaration", "Functions › Function definition", "function"),
|
||||
py("pyNestedFunction", "Functions › Nested function definition", "python-nested-function"),
|
||||
py("pyFunctionCall", "Functions › Function call", "function-call"),
|
||||
py("pyMethodCall", "Functions › Method call", "python-method-call"),
|
||||
py("pyParameter", "Parameters › Parameter", "param"),
|
||||
py("pySelf", "Parameters › 'self' parameter", "self"),
|
||||
py("pyKeywordArg", "Keyword argument", "keyword-arg"),
|
||||
py("pyPredefinedDefinition", "Special names › Definition", "python-predefined-definition"),
|
||||
py("pyPredefinedUsage", "Special names › Usage", "python-predefined-usage"),
|
||||
py("pyBuiltin", "Special names › Built-in name", "python-builtin"),
|
||||
py("pyDecorator", "Special names › Decorator", "annotation"),
|
||||
py("pyClassDefinition", "Classes › Class definition", "type"),
|
||||
py("pyAnnotation", "Type annotation", "python-annotation"),
|
||||
py("pyLocalVariable", "Identifiers › Local variables", "python-local-variable"),
|
||||
py("pyTypeParameter", "Identifiers › Type parameters", "python-type-parameter"),
|
||||
py("pyGlobalVariable", "Identifiers › Global variable", "python-global-variable"),
|
||||
py("pyInstanceAttribute", "Identifiers › Instance attribute", "python-instance-attribute"),
|
||||
py("pyClassAttribute", "Identifiers › Class attribute", "python-class-attribute"),
|
||||
py("pyConstant", "Identifiers › Constant", "python-constant"),
|
||||
py("pyProperty", "Identifiers › Property", "python-property"),
|
||||
{
|
||||
id: "pyStaticSymbol",
|
||||
name: "Static symbol",
|
||||
name: "Identifiers › Static symbol",
|
||||
path: ["Python"],
|
||||
language: "Python",
|
||||
token: "python-static",
|
||||
italicOnly: true,
|
||||
defaultItalic: false,
|
||||
@@ -194,7 +237,9 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [
|
||||
syn("regexp", "Regular expression", ["RegExp"], "regexp"),
|
||||
];
|
||||
|
||||
const SCHEME_LANGUAGES = { go: "Go", java: "Java", kotlin: "Kotlin", php: "PHP" } as const;
|
||||
const SCHEME_LANGUAGES = { csharp: "C#", go: "Go", java: "Java", kotlin: "Kotlin", php: "PHP" } as const;
|
||||
/** Languages that scope their overrides into a per-language editor block; Python ships its own attributes. */
|
||||
const SCHEME_LANGUAGE_SCOPES = { ...SCHEME_LANGUAGES, python: "Python" } as const;
|
||||
|
||||
export const ATTRIBUTES: readonly AttributeDef[] = [
|
||||
...BASE_ATTRIBUTES,
|
||||
@@ -380,7 +425,11 @@ export function overrideDeclarations(
|
||||
if (attr.language !== language) continue;
|
||||
const o = overrides[attr.id];
|
||||
if (!o) continue;
|
||||
if (setOrCleared(o.foreground) && attr.fg) out.push([attr.fg, o.foreground]);
|
||||
if (setOrCleared(o.foreground) && attr.fg) {
|
||||
out.push([attr.fg, o.foreground]);
|
||||
// Terminal prefers `--terminal-ansi-*`; keep it in sync with console `--ansi-*`.
|
||||
if (attr.fg.startsWith("--ansi-")) out.push([`--terminal-ansi-${attr.fg.slice("--ansi-".length)}`, o.foreground]);
|
||||
}
|
||||
if (setOrCleared(o.background) && attr.bg) out.push([attr.bg, o.background]);
|
||||
if (setOrCleared(o.effectColor) && attr.effect) out.push([attr.effect, o.effectColor]);
|
||||
if (attr.effect && setOrCleared(o.effectColor)) {
|
||||
@@ -531,7 +580,12 @@ export function schemeCss(
|
||||
const pluginTheme = !!el?.dataset.pluginTheme && id === setting;
|
||||
if (id !== themeScheme(root) || pluginTheme) {
|
||||
const tokens = resolvedSchemeTokens(id, valid);
|
||||
for (const [k, v] of tokens) if (SCHEME_VARS.has(k) || k.startsWith("--syn-")) decls.push([k, v]);
|
||||
for (const [k, v] of tokens)
|
||||
if (SCHEME_VARS.has(k) || k.startsWith("--syn-")) {
|
||||
decls.push([k, v]);
|
||||
// Terminal reads `--terminal-ansi-*`; keep it aligned with console `--ansi-*`.
|
||||
if (k.startsWith("--ansi-")) decls.push([`--terminal-ansi-${k.slice("--ansi-".length)}`, v]);
|
||||
}
|
||||
// The editor surface follows the scheme, not the island colour of the UI theme.
|
||||
const bg = tokens.get("--bg-editor") ?? tokens.get("--bg-island");
|
||||
if (bg) decls.push(["--bg-editor", bg], ["--bg-editor-gutter", tokens.get("--bg-editor-gutter") ?? bg]);
|
||||
@@ -541,7 +595,7 @@ export function schemeCss(
|
||||
.filter(([k, v]) => /^--[\w-]+$/.test(k) && SAFE.test(v))
|
||||
.map(([k, v]) => ` ${k}: ${v} !important;`);
|
||||
let css = body.length ? `:root {\n${body.join("\n")}\n}\n` : "";
|
||||
for (const language of Object.values(SCHEME_LANGUAGES)) {
|
||||
for (const language of Object.values(SCHEME_LANGUAGE_SCOPES)) {
|
||||
const languageBody = [
|
||||
...overrideDeclarations(valid[id]?.attributes, language),
|
||||
...overrideDeclarations(overrides?.[id], language),
|
||||
|
||||
@@ -260,6 +260,8 @@ describe("catalogs", () => {
|
||||
"⌘Enter",
|
||||
"Fork",
|
||||
"Shell",
|
||||
"ID",
|
||||
"my-agent",
|
||||
"Java",
|
||||
"Python",
|
||||
"C/C++",
|
||||
@@ -294,6 +296,20 @@ describe("catalogs", () => {
|
||||
expect(Object.keys(texts).sort()).toEqual([...SUPPORTED_LOCALES].sort());
|
||||
});
|
||||
|
||||
it("list every key once", () => {
|
||||
// JSON.parse keeps the last of two equal keys, so a merge that adds a key twice goes unnoticed.
|
||||
for (const dir of [LOCALES_DIR, join(LOCALES_DIR, "text")]) {
|
||||
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json"))) {
|
||||
const keys = [...readFileSync(join(dir, file), "utf8").matchAll(/^ {2}("(?:[^"\\]|\\.)*"):/gm)].map(
|
||||
(m) => JSON.parse(m[1]!) as string,
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const repeated = keys.filter((key) => seen.has(key) || !seen.add(key));
|
||||
expect(repeated, `${file} in ${dir}`).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("translate source text into non-empty text", () => {
|
||||
for (const [locale, catalog] of Object.entries(texts))
|
||||
for (const [source, text] of Object.entries(catalog)) expect(text.trim(), `${locale}:${source}`).not.toBe("");
|
||||
|
||||
@@ -211,7 +211,8 @@ describe(".icls schemes", () => {
|
||||
expect(scheme.attributes.gutter?.background).toBe("#212121");
|
||||
expect(scheme.attributes.lineNumber?.foreground).toBe("#424242");
|
||||
expect(scheme.attributes.selection?.background).toBe("#353535");
|
||||
expect([applied, skipped]).toEqual([12, 3]);
|
||||
expect(scheme.attributes.consoleBackground?.background).toBe("#212121");
|
||||
expect([applied, skipped]).toEqual([13, 2]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -333,7 +334,9 @@ describe(".icls schemes", () => {
|
||||
<option name="PY.BUILTIN_NAME"><value><option name="FOREGROUND" value="abcd"/></value></option>
|
||||
</attributes></scheme>`;
|
||||
const attrs = parseIcls(xml).scheme.attributes;
|
||||
expect(attrs.keyword).toEqual({ foreground: "#112233", bold: false, italic: false, effect: "none" });
|
||||
// PY.KEYWORD styles Python only now; the shared keyword keeps DEFAULT_KEYWORD's value.
|
||||
expect(attrs.pyKeyword).toEqual({ foreground: "#112233", bold: false, italic: false, effect: "none" });
|
||||
expect(attrs.keyword).toEqual({ foreground: "#ff0000", bold: true, italic: false });
|
||||
expect(attrs.pyNestedFunction?.foreground).toBe("#223344");
|
||||
expect(attrs.pyDocstring?.foreground).toBe("#334455");
|
||||
expect(attrs.pyDocstring?.effect).toBe("none");
|
||||
|
||||
+30
-4
@@ -155,8 +155,13 @@ Object.assign(ATTRIBUTE_NAMES, {
|
||||
"PY.PARENTHS": "pyParentheses",
|
||||
"PY.BRACKETS": "pyBrackets",
|
||||
"PY.BRACES": "pyBraces",
|
||||
"PY.KEYWORD": "keyword",
|
||||
"PY.FUNC_DEFINITION": "functionDeclaration",
|
||||
// PyCharm keys restyle Python only; DEFAULT_* keys carry the shared colour.
|
||||
"PY.KEYWORD": "pyKeyword",
|
||||
"PY.NUMBER": "pyNumber",
|
||||
"PY.LINE_COMMENT": "pyLineComment",
|
||||
"PY.STRING": "pyString",
|
||||
"PY.STRING.U": "pyString",
|
||||
"PY.FUNC_DEFINITION": "pyFunctionDeclaration",
|
||||
"PY.NESTED_FUNC_DEFINITION": "pyNestedFunction",
|
||||
"PY.METHOD_CALL": "pyMethodCall",
|
||||
"PY.DOC_COMMENT": "pyDocstring",
|
||||
@@ -168,8 +173,8 @@ Object.assign(ATTRIBUTE_NAMES, {
|
||||
"PY.PREDEFINED_USAGE": "pyPredefinedUsage",
|
||||
"PY.STRING.B": "pyBytes",
|
||||
"PY.STRING.F": "pyFString",
|
||||
"PY.VALID_STRING_ESCAPE": "escape",
|
||||
"PY.OPERATION_SIGN": "operator",
|
||||
"PY.VALID_STRING_ESCAPE": "pyValidEscape",
|
||||
"PY.OPERATION_SIGN": "pyOperationSign",
|
||||
});
|
||||
|
||||
// WebStorm's JavaScript keys; TypeScript repeats them with a TS. prefix. Language Defaults serve both.
|
||||
@@ -228,7 +233,28 @@ const COLOR_NAMES: Record<string, [string, "foreground" | "background"]> = {
|
||||
INDENT_GUIDE: ["indentGuide", "foreground"],
|
||||
RIGHT_MARGIN_COLOR: ["rightMargin", "foreground"],
|
||||
METHOD_SEPARATORS_COLOR: ["methodSeparator", "foreground"],
|
||||
CONSOLE_BACKGROUND_KEY: ["consoleBackground", "background"],
|
||||
};
|
||||
|
||||
Object.assign(ATTRIBUTE_NAMES, {
|
||||
CONSOLE_BLACK_OUTPUT: "ansiBlack",
|
||||
CONSOLE_RED_OUTPUT: "ansiRed",
|
||||
CONSOLE_GREEN_OUTPUT: "ansiGreen",
|
||||
CONSOLE_YELLOW_OUTPUT: "ansiYellow",
|
||||
CONSOLE_BLUE_OUTPUT: "ansiBlue",
|
||||
CONSOLE_MAGENTA_OUTPUT: "ansiMagenta",
|
||||
CONSOLE_CYAN_OUTPUT: "ansiCyan",
|
||||
CONSOLE_GRAY_OUTPUT: "ansiWhite",
|
||||
CONSOLE_DARKGRAY_OUTPUT: "ansiBrightBlack",
|
||||
CONSOLE_RED_BRIGHT_OUTPUT: "ansiBrightRed",
|
||||
CONSOLE_GREEN_BRIGHT_OUTPUT: "ansiBrightGreen",
|
||||
CONSOLE_YELLOW_BRIGHT_OUTPUT: "ansiBrightYellow",
|
||||
CONSOLE_BLUE_BRIGHT_OUTPUT: "ansiBrightBlue",
|
||||
CONSOLE_MAGENTA_BRIGHT_OUTPUT: "ansiBrightMagenta",
|
||||
CONSOLE_CYAN_BRIGHT_OUTPUT: "ansiBrightCyan",
|
||||
CONSOLE_WHITE_OUTPUT: "ansiBrightWhite",
|
||||
});
|
||||
|
||||
// Preserve editor attributes without a standard exchange name on our own round trip.
|
||||
for (const attr of ATTRIBUTES)
|
||||
if (!Object.values(ATTRIBUTE_NAMES).includes(attr.id)) ATTRIBUTE_NAMES[`OPENIDEA_${attr.id}`] = attr.id;
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
{
|
||||
"Debugging Kotlin with kotlin-debug-adapter": "Debugging Kotlin with kotlin-debug-adapter",
|
||||
"Waiting for kotlin-lsp to import the project": "Waiting for kotlin-lsp to import the project",
|
||||
"kotlin-lsp is not the Kotlin language server of this project.": "kotlin-lsp is not the Kotlin language server of this project.",
|
||||
"kotlin-lsp is not installed.": "kotlin-lsp is not installed.",
|
||||
"kotlin-lsp did not start: {detail}": "kotlin-lsp did not start: {detail}",
|
||||
"kotlin-lsp is still importing the project. Debug again once the import has finished to use its debugger.": "kotlin-lsp is still importing the project. Debug again once the import has finished to use its debugger.",
|
||||
"kotlin-lsp could not import the project.": "kotlin-lsp could not import the project.",
|
||||
"kotlin-lsp could not start its debugger: {detail}": "kotlin-lsp could not start its debugger: {detail}",
|
||||
"kotlin-lsp or kotlin-debug-adapter (Kotlin)": "kotlin-lsp or kotlin-debug-adapter (Kotlin)",
|
||||
"Black": "Black",
|
||||
"Bright blue": "Bright blue",
|
||||
"Bright cyan": "Bright cyan",
|
||||
"Bright green": "Bright green",
|
||||
"Bright magenta": "Bright magenta",
|
||||
"Bright red": "Bright red",
|
||||
"Bright white": "Bright white",
|
||||
"Bright yellow": "Bright yellow",
|
||||
"Console Colors": "Console Colors",
|
||||
"Console background": "Console background",
|
||||
"Cyan": "Cyan",
|
||||
"Dark gray": "Dark gray",
|
||||
"Magenta": "Magenta",
|
||||
"The program ended by itself, so no garbage collection ran before the profile was written: objects that were already unreachable may show as live.": "The program ended by itself, so no garbage collection ran before the profile was written: objects that were already unreachable may show as live.",
|
||||
"No garbage collection finished before the program stopped (the JVM's attach listener did not answer in time or was not reachable): objects that were already unreachable may show as live.": "No garbage collection finished before the program stopped (the JVM's attach listener did not answer in time or was not reachable): objects that were already unreachable may show as live.",
|
||||
"Profile Allocations": "Profile Allocations",
|
||||
"Profile Live Objects": "Profile Live Objects",
|
||||
"Allocations": "Allocations",
|
||||
"Live Objects": "Live Objects",
|
||||
"White": "White",
|
||||
"allocated": "allocated",
|
||||
"{size} allocated": "{size} allocated",
|
||||
"1 live sample (≈ 512 KB)": "1 live sample (≈ 512 KB)",
|
||||
|
||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user