Compare commits

...
Author SHA1 Message Date
l0ng-ai cbcd1818a7 fix(plugins): accept empty navigation anchor ranges as positions
A document indexer that anchors a whole-file definition at the file's start
(an empty range at 0:0) had the whole document rejected with "invalid
navigation anchors". Empty ranges inside the document are now accepted:
Go to Declaration lands on them, while Find Usages and Rename skip them so
a rename never inserts the new name at that position. The error for a
document that is still rejected names the plugin and the file.
2026-11-12 00:11:01 +08:00
ntbang0901 c73d858895 feat(debugger): debug Kotlin with kotlin-lsp's debugger (#2651)
kotlin-debug-adapter (fwcd) is unmaintained: its evaluate only resolves plain local names and it reports breakpoints verified without checking. Kotlin debugging now uses the debugger inside JetBrains kotlin-lsp once its project import has finished, attached to the JVM the IDE starts through the existing JDWP link. kotlin-debug-adapter stays the fallback, with a notice saying why (not installed, still importing, import failed, cannot start).

kotlin-lsp's DAP quirks (step stop reasons, thread ids from earlier stops, frame names, file: URIs) are handled in one place, and its invalidated events reload the Variables pane and watches.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-12 00:05:41 +08:00
l0ng-ai 6e5486586c fix(i18n): restore the comma missing after the local data directory strings in zh.json (#2771) 2026-11-12 00:03:13 +08:00
arookieofc 93642b3741 feat(settings): custom local data directory with one-click migration (#1722) (#2677) 2026-11-11 23:53:24 +08:00
Gabriel Gondim b06f2e27ea feat(settings): Color Scheme › Console Colors for the terminal's ANSI colors (#2751)
Settings › Editor › Color Scheme gets a Console Colors page, as in IntelliJ IDEA: the console background and the 16 ANSI colors, used by the terminal and consoles. A light IDE theme no longer forces a light terminal. IDEA's CONSOLE_* keys from .icls schemes and IntelliJ theme imports set the same colors.

Closes #2698

Co-authored-by: GGVidal <[email protected]>
2026-11-11 23:40:04 +08:00
Muuandmuutot 86f69b7fca feat(settings): complete the Python color page as PyCharm's full model (#2761)
The Python page of Settings › Editor › Color Scheme now has PyCharm's 38 attributes in its order and groups, all scoped to Python: an override lands in the editor block for Python files instead of recolouring every language, and unset values fall back to the shared scheme value. The seven attributes that had no default value (parentheses, brackets, braces, nested function, method call, type annotation, f-string format spec) now resolve, and .icls PY.* keys map to their Python attributes. Attribute ids and saved overrides are unchanged.

Co-authored-by: muutot <[email protected]>
2026-11-11 23:38:25 +08:00
Vadim LazukandInf1mum fefa135f5a feat(docker): pick an SSH configuration for TCP/SSH Docker connections (#2737)
The TCP / SSH host kind of a Docker connection now offers the IDE's SSH Configurations (with … to open Settings › Tools › SSH Configurations), as IntelliJ IDEA does. Picking a configuration with OpenSSH config authentication fills the Engine API URL with ssh://[user@]host[:port], keeping the alias so the system ssh docker runs applies the entry's key, user and jump hosts; an existing ssh:// URL shows its configuration again. Password and key-pair configurations are listed but explained as unusable for now, since docker over SSH goes through the system ssh.

Refs #939

Co-authored-by: Inf1mum <[email protected]>
2026-11-11 23:36:44 +08:00
Vadim LazukandInf1mum 8fe2d35d62 feat(editor): pair <> where a generic type list starts (#2738)
Under Smart Keys › Insert paired brackets, < now pairs into <> where the syntax tree says a type argument / parameter list starts, as in IntelliJ IDEA: after a type name or generic declaration name in Java, TypeScript, Kotlin and C#, and Rust types, fn names and turbofish ::<. Comparisons (a < b, i <= n, x << 2), tags and literals never pair; > steps over the inserted half and Tab leaves it. Languages without a rule don't pair.

Closes #2700

Co-authored-by: Inf1mum <[email protected]>
2026-11-11 23:34:24 +08:00
rohitkumardandaandrohitdanda 21046c3047 fix(platform): open only http(s) and mailto URLs externally (#2755)
openExternalUrl handed any string to the system opener, and plugins and rendered content (Markdown, notebook output, editor links) reach it with arbitrary input, so file: or app-launching schemes such as ms-msdt: and search-ms: could be passed to the OS. It now opens only http:, https: and mailto: URLs and ignores anything else, including malformed or whitespace-padded strings.

Co-authored-by: rohitdanda <[email protected]>
2026-11-11 23:32:18 +08:00
l0ng-ai 017561a20b fix(libraries): list the JDK for an .idea project only when it is a Java one (#2765)
A project without a build manifest got a JDK under External Libraries as soon
as it had an .idea folder, which GoLand, PyCharm and WebStorm write too: a Go
project showed < jdk-25 >. The folder now counts only when misc.xml names a
Java SDK or language level, or a module file is a JAVA_MODULE.
2026-11-11 23:31:36 +08:00
l0ng-ai d3a68b2754 fix(lsp): ask again when a server withdraws a rename while analysing (#2762)
A server still analysing an edit may answer textDocument/rename with
ContentModified (the Dart analysis server does when the document changed
under it). That maps to a cancellation, so the rename the user confirmed was
dropped without a word. The rename is now asked again a few times, 300 ms
apart, and if it keeps being withdrawn the error says so.
2026-11-11 23:28:22 +08:00
l0ng-ai e98a560523 fix(run): open .NET Project configurations as the form with a project field (#2757)
The .NET Project type had no way back from a command line to its fields, and a
new configuration starts without params, so the editor fell back to the plain
Command/Arguments form: there was no .NET project field, and Debug failed with
"Select a .NET project". dotnet run command lines the form can express now map
back to its fields, for new configurations and ones typed as a command.

Fixes #2733
2026-11-11 23:21:53 +08:00
l0ng-ai 6983e8ec2f feat(color-scheme): add a C# page to Settings › Editor › Color Scheme (#2756)
C# gets its own page like Go, Java, Kotlin and PHP: every Language Defaults
attribute can be overridden for C# editors only, with a C# preview. Language
sub-page ids are now slugged (C# → csharp): the '#' in editor.colorScheme.c#
made the page unreachable from the settings tree.
2026-11-11 23:17:46 +08:00
l0ng-ai dc355602a9 feat(http-client): go to a {{variable}}'s definition (#2754)
Navigate › Declaration (⌘-click, ⌘B) on {{name}} in a .http file now lists
its definitions in lookup order: the file's @name = … line, then the key in
each environment (and $shared) of the nearest http-client.private.env.json
and http-client.env.json. Dynamic variables ($uuid…) have none.
2026-11-11 23:13:42 +08:00
l0ng-ai 924fa9259d fix(plugins): refuse a command while its plugin waits for an answer in another window (#2753)
A plugin runs one call at a time, and a prompt waits for its answer without a
limit. A command run from another window while the plugin's prompt was open
queued silently behind it and ran only after the prompt was answered. It is
now refused with a message naming the reason, and the window that holds the
prompt is brought to the front.

Fixes #2673
2026-11-11 23:04:11 +08:00
l0ng-ai 24deeb0dbb fix(i18n): drop repeated zh catalog keys and test that every key appears once (#2752)
Merges had added eleven zh.json keys a second time (Docker, Shell, the language
names, Add Service ({kind}), Others, Servers). JSON.parse silently keeps the last
copy, so nothing failed. The catalogs test now reads each catalog file and fails
on a key listed twice.
2026-11-11 22:58:21 +08:00
renyun2andrenyun2 0ce7f6e430 feat(agents): expose Cursor effort and Fast as separate options (#2705)
Cursor's ACP agent only offers thinking effort and a separate Fast switch when the client advertises parameterizedModelPicker. OpenIDEA now advertises it, and when an ACP agent is picked in the Home composer before any session has reported its options, a short discovery session (session/new without MCP servers, killed afterwards or on timeout, once per agent per run) records its config options and effort levels. Effort and Fast show inside the model control, a new Cursor task defaults Fast to off, and config changes show immediately and roll back if the agent rejects them.

Co-authored-by: renyun2 <[email protected]>
2026-11-11 22:54:44 +08:00
misswellandmisswell 84fd0d0a60 fix(build): skip JaCoCo instrumentation in IDE compile builds (#2686)
Projects that bind jacoco-maven-plugin's offline instrument goal to process-classes had target/classes rewritten by the IDE's own Build/Rebuild and launch builds, so a later Debug/Run without the JaCoCo agent failed with NoClassDefFoundError. The Maven steps of Build and Rebuild now pass -Djacoco.skip=true; artifact (package) builds and coverage runs are unchanged, and Automatic launches stay incremental.

Fixes #2676

Co-authored-by: misswell <[email protected]>
2026-11-11 22:50:57 +08:00
Kamal ElFadlandelfadl 96c99e0396 feat(agents): add a command execution policy for ACP agents (#1165)
Settings › Agents › Command Execution Policy (global or per project) lists commands an agent may run without asking and commands it is never allowed to run. A permission request for a shell command is answered from the policy: a blocked pattern (glob, checked against the whole command and every part of a chain) rejects it; it is approved only when every part of a &&/||/;/|/& chain matches an allowed entry exactly or up to a word boundary and none uses command substitution, redirection, subshells, here-docs or leading variable assignments. Everything else prompts as before. The allowed list starts empty; the blocked list ships common destructive commands.

Closes #1152

Co-authored-by: elfadl <[email protected]>
2026-11-11 22:50:17 +08:00
arookieofcanda-rookie-of-C-language fd4838fc36 feat(agents): create, remove and import custom ACP agents (#2690)
Settings › Agents › Coding Agents can add a custom ACP agent (name, id, command, arguments, environment) without writing its agent file by hand, remove one, and import the agent_servers entries of a JetBrains acp.json. Built-in and ACP Registry ids are refused, ids are validated as file stems on save and on remove, and colliding or incomplete acp.json entries are skipped with a reason.

Closes #228

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-11 22:45:36 +08:00
arookieofcanda-rookie-of-C-language 4873905884 feat(agents): accept worktree task changes into the local checkout (#2704)
The Changes pane of a worktree task gets an Accept action next to Rollback: after confirmation it copies the task's changed files (all, or the selected ones) from the task worktree into the local checkout, uncommitted. A file the checkout has changed itself is left alone and listed in the notification instead of being overwritten; running and local tasks are refused.

Closes #1777

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-11 22:40:49 +08:00
arookieofcanda-rookie-of-C-language 6b37788971 test(lsp): go.mod/go.work reach gopls with the language id it understands (#2719)
Pins the go.mod serving contract: opening go.mod through LspManager with the bundled registry sends didOpen to gopls with languageId go.mod, scoped to the module root, and gopls's pushed diagnostics reach Problems. The gomod/gowork gopls entries now use the same initialization options as the go entry, so startup options no longer depend on which file launches the server first.

Refs #2248

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-11 22:33:33 +08:00
arookieofcanda-rookie-of-C-language 44bf648bee fix(tomcat): rebuild the WAR when web resources or dependency modules change (#2722)
The pre-launch up-to-date check only compared the WAR with the WAR module's own src and build files, so a save in a dependency module (mapper XML in a DAO module), in web content outside src (custom warSourceDirectory / webResources), or in any module when the config had no cwd (the check fell back to the project root) left the old WAR deployed. The check now resolves the WAR module from the selected artifact when cwd is missing, includes its web resource directories, and includes the sources and build files of every module in the build model.

Fixes #2685

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-11 22:32:36 +08:00
113 changed files with 6950 additions and 375 deletions

No files matched your search

+102
View File
@@ -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)
}
+4
View File
@@ -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 },
})
}
+74 -2
View File
@@ -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]
+24 -1
View File
@@ -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
View File
@@ -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())
}
+46
View File
@@ -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,
}
+198 -1
View File
@@ -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());
}
}
+487
View File
@@ -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);
}
}
+166
View File
@@ -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:?}"
);
}
+101
View File
@@ -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");
}
}
+31 -1
View File
@@ -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",
+1
View File
@@ -536,6 +536,7 @@ mod tests {
crate::maven::LOCAL_REPO_TRACKING_ARG,
"-o",
"-Peditor",
"-Djacoco.skip=true",
"test-compile"
]
);
+13
View File
@@ -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"]})
);
}
}
+3
View File
@@ -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(),
}
}
+27
View File
@@ -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(),
],
}
}
}
+7
View File
@@ -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" },
+1 -1
View File
@@ -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 -5
View File
@@ -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 {
+147
View File
@@ -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());
}
}
+48 -8
View File
@@ -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(())
+34 -1
View File
@@ -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
View File
@@ -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() {
+320 -26
View File
@@ -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:?}");
}
}
}
+36 -2
View File
@@ -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> {
+4
View File
@@ -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();
+415
View File
@@ -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(&notices, &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());
}
+10 -2
View File
@@ -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)?;
+7
View File
@@ -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();
+11
View File
@@ -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)
+36 -5
View File
@@ -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 { .. }
+4
View File
@@ -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 },
+11 -1
View File
@@ -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,
+29
View File
@@ -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 [
+121
View File
@@ -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(
+3
View File
@@ -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, &params)
}
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
mgr.java_import_ready(self.server.clone(), self.generation);
}
+195
View File
@@ -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);
}
}
+25 -2
View File
@@ -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.
+13
View File
@@ -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`.
+296 -1
View File
@@ -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;
}
+179 -8
View File
@@ -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]
+51 -1
View File
@@ -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));
}
}
+1
View File
@@ -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"
+1
View File
@@ -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
View File
@@ -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");
+41 -2
View File
@@ -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]
+53
View File
@@ -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, &current, &next).map_err(ide_core::IdeError::internal)?;
}
Ok(next.display().to_string())
}
+16
View File
@@ -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()))
}
+7
View File
@@ -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,
+3 -1
View File
@@ -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) => {
+5
View File
@@ -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
View File
@@ -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 } },
};
+3 -1
View File
@@ -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);
+164 -2
View File
@@ -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}");
});
});
+192 -3
View File
@@ -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;
}),
];
}
+18
View File
@@ -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;
}
+269 -3
View File
@@ -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;
}
+126
View File
@@ -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} />);
+62 -29
View File
@@ -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}
+51
View File
@@ -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…"}
+80
View File
@@ -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);
});
});
+104 -1
View File
@@ -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);
});
});
+20 -3
View File
@@ -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(
[
+25 -3
View File
@@ -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");
+11 -1
View File
@@ -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 {
+2
View File
@@ -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}`;
+24
View File
@@ -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> = {
+77
View File
@@ -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();
});
});
+24 -2
View File
@@ -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();
});
});
+13 -1
View File
@@ -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();
}
+6 -4
View File
@@ -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"
/>
+2
View File
@@ -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] };
});
}
+65 -16
View File
@@ -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 {
+16 -1
View File
@@ -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
+1 -1
View File
@@ -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)" },
+31 -1
View File
@@ -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(
+35 -1
View File
@@ -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 ?? {};
+66
View File
@@ -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>
+32 -1
View File
@@ -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) })),
+133 -2
View File
@@ -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" },
+3 -1
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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),
+16
View File
@@ -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("");
+5 -2
View File
@@ -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
View File
@@ -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;
+23
View File
@@ -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