Compare commits

..
Author SHA1 Message Date
l0ng-ai 440f5384ad perf(agents): make switching to a long conversation tab cheap
A kept-alive agent tab is hidden with `content-visibility: hidden`, and WebKit
recomputes style for the whole subtree when it is shown again. With a long
conversation that is tens of thousands of nodes, so Ctrl+Tab (or any switch)
to it blocked the main thread for 200–500 ms.

- Folded sections (tool call runs, tool output, reasoning, subagent task and
  result) render their content only once opened, or while Find in
  Conversation is open so their text is still found.
- Conversation rows skip rendering while off screen (`content-visibility:
  auto`) once they have been laid out, keeping their remembered height so
  scrolling up doesn't jump; a width change lays them out again.
2026-11-09 11:00:54 +08:00
l0ng-ai 13b590f2c7 feat(plugins): let test frameworks select tests to rerun (#2228)
* feat(plugins): let test frameworks select tests to rerun

* test(smoke): await Rerun Failed Tests
2026-11-09 10:08:51 +08:00
l0ng-ai 8d5504b376 fix(usages): keep the scope summary row from taking half of the Find Usages panel 2026-11-09 10:07:02 +08:00
l0ng-ai 60b19b2ffb Revert "fix(agents): stop defaulting new tasks to bypass" (#2234)
Reverts #2232: new agent tasks default to Bypass again.
2026-11-09 09:55:17 +08:00
l0ng-ai 73b3a99699 fix(agents): stop defaulting new tasks to bypass (#2232)
New agent tasks defaulted to Bypass whenever the provider offered it, whatever the user had chosen. A task now starts with the settings shown in the composer: the provider's reported mode, or Ask for Cursor. An explicit choice is kept, including Bypass, and running tasks keep their own mode. Cached provider settings could hold a Bypass written automatically with no record of its source, so a cached bypass is dropped on load, read and save while the option stays offered.

Fixes #740
2026-11-09 09:46:57 +08:00
arookieofc 95843ea12d fix(vue): highlight SCSS in <style lang="scss"> blocks (#2219) (#2221)
The Vue editor mode (`@codemirror/lang-vue` over the stock HTML base)
parses every `<style>` block as plain CSS, so SCSS — nested rules,
$-variables, and so on — rendered uncoloured or mis-highlighted.

Register a Vue mode of our own (overriding the language-data entry) that
gives the HTML base a nested Sass grammar for `<style>` whose `lang`
is `scss` or `sass`; blocks without a dialect keep CSS.
2026-11-09 09:23:17 +08:00
l0ng-ai 3dac5b8bdc fix(plugins): report Problems to plugins in remote projects (#2223) 2026-11-09 09:18:11 +08:00
l0ng-ai fd8b4ff14c fix(terminal): resume pasted input as soon as the PTY accepts more (#2220) 2026-11-09 09:17:30 +08:00
f70eb13f75 fix(process): don't spawn taskkill when the child started nothing (#2056)
`kill_tree` and `kill_tree_of` run `taskkill /T /F /PID` on Windows even when the child has no descendants, which is the common case for a program a plugin started, read from and closed. `taskkill` is a process spawn — ~0.7 s here, measured on this machine — and it runs inside host calls that are inside a plugin call's budget: dropping eight process handles at once overran a 5 s budget, so the plugin was trapped for something the host did.

When `descendants(pid)` is empty the child itself is all there is to kill, and both callers already do that (`kill_tree` follows with `child.kill()` + `child.wait()`, `kill_tree_of` leaves the child to its owner), so the spawn buys nothing. Skip it.

Verified:

- `cargo test -p ide-core` → 77 passed.
- `cargo test -p ide-host --test plugins` → 95 passed, including `a_plugin_keeps_a_process_running_behind_a_handle`, which fails on unmodified `main` on Windows (`plugin "misbehave" timed out after 5s` at its `childdrop` step, which drops eight handles) and now passes in 9.7 s instead of 20 s.
- clippy `--all-targets -D warnings` and `cargo fmt --check` clean; no component or WIT change, so nothing to rebuild.

Co-authored-by: chao <[email protected]>
Co-authored-by: l0ng-ai <[email protected]>
2026-11-09 09:14:41 +08:00
l0ng-ai 337bf4b1ca fix(terminal): configure ConPTY compatibility on Windows (#2180) 2026-11-09 09:12:20 +08:00
l0ng-ai 047cac209b feat(plugins): plugin API 1.4 (#2207)
Plugin API 1.4: grants for unencrypted HTTP, local sockets, IDE commands and owned database sources; window status items, output channels, content tabs, editor selections, decorations, search, Problems and VCS reads; runs, keyed terminals with exit events, plugin run targets, dynamic run configurations and test parsers; language features, injections, snippets and menu locations; plugin MCP tools on the built-in server; four official API examples. Released 1.3 plugins keep working.
2026-11-09 08:40:40 +08:00
SunZhiQiang c5647a5934 feat(agents): show the model list and effort in one popover (#2213)
Choosing an agent model took two popovers, and the list cut a name on one row while showing it in full on another. The chip's popover now shows the effort slider above a searchable model list. Names with a description keep their full width, and the description is cut instead. The list is extracted from `Select` as `SelectListbox`; `Select` keeps its previews across popup open and close.

Co-authored-by: panda-student <[email protected]>
2026-11-09 08:39:44 +08:00
SunZhiQiang 1f2d86f793 feat(agents): load Claude's models and effort before the first task (#2212)
New Agent Task showed no model, effort or mode for Claude until an agent had run a task. The `initialize` request that already loads commands also returns those options, so they are now remembered and shown, with one extra `get_settings` request to read the configured effort. No prompt or session starts, a failed lookup is not repeated, and a session's own report always wins, in memory and on disk.

Co-authored-by: panda-student <[email protected]>
2026-11-09 08:34:46 +08:00
Lee Dong Geon 998b478de3 ci(plugins): skip the scheduled plugin index refresh on forks (#2211) 2026-11-09 08:16:56 +08:00
120 changed files with 2101 additions and 455 deletions

No files matched your search

+2
View File
@@ -22,6 +22,8 @@ env:
jobs:
merge:
# The schedule is for this repository's release; forks can still run it by hand.
if: github.event_name != 'schedule' || github.repository == 'l0ng-ai/openidea'
runs-on: ubuntu-latest
steps:
- name: Merge the community index
+54 -35
View File
@@ -100,7 +100,7 @@ struct Shared {
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
probes: Mutex<HashMap<String, AgentProbe>>,
/// One pre-session command lookup per provider, including failed attempts.
/// One pre-session lookup of commands and settings per provider, including failed attempts.
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
idle_release_ms: AtomicU64,
@@ -153,7 +153,7 @@ impl Shared {
cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default()
}
async fn fetch_commands(self: &Arc<Self>, spec: provider::ProviderSpec) {
async fn fetch_settings(self: &Arc<Self>, spec: provider::ProviderSpec) {
let mut ready = {
let mut fetches = self.command_fetches.lock();
fetches
@@ -163,14 +163,14 @@ 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(commands) =
native::claude::commands(spec.clone(), shared.paths.project_root.clone()).await
if let Ok((commands, options)) =
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
{
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
let options = config::options_of(&json!({ "configOptions": options }));
let _ = tokio::task::spawn_blocking(move || {
if shared.known_commands(&spec.id).is_empty() {
shared.remember(&spec.id, None, Some(&commands), None);
}
shared.remember_discovered(&spec.id, &options, &commands);
shared.emit(AgentEvent::Providers);
})
.await;
}
@@ -187,6 +187,10 @@ impl Shared {
}
}
fn has_config(&self, provider: &str) -> bool {
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
}
fn root_key(&self) -> String {
self.paths.project_root.to_string_lossy().into_owned()
}
@@ -199,37 +203,52 @@ impl Shared {
commands: Option<&[AgentCommand]>,
model: Option<&str>,
) {
let snapshot = {
let mut cache = self.known.lock();
let known = cache.entry(provider.to_owned()).or_default();
let mut changed = false;
if let Some(c) = config {
if let Some(model) = model {
let levels: Vec<_> =
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
if known.thought_levels.get(model) != Some(&levels) {
known.thought_levels.insert(model.to_owned(), levels);
changed = true;
}
}
if !c.is_empty() && known.config != c {
known.config = c.to_vec();
self.remember_inner(provider, config, commands, model, false);
}
fn remember_discovered(&self, provider: &str, config: &[AgentConfigOption], commands: &[AgentCommand]) {
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), true);
}
fn remember_inner(
&self,
provider: &str,
config: Option<&[AgentConfigOption]>,
commands: Option<&[AgentCommand]>,
model: Option<&str>,
discovered: bool,
) {
// Keep the update and its disk write ordered; a session report wins over discovery.
let mut cache = self.known.lock();
let known = cache.entry(provider.to_owned()).or_default();
let mut changed = false;
if let Some(c) = config.filter(|_| !discovered || known.config.is_empty()) {
if let Some(model) = model {
let levels: Vec<_> =
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
if known.thought_levels.get(model) != Some(&levels) {
known.thought_levels.insert(model.to_owned(), levels);
changed = true;
}
}
let root = self.root_key();
if let Some(c) =
commands.filter(|c| !c.is_empty() && known.commands.get(&root).map(Vec::as_slice) != Some(c))
{
known.commands.insert(root, c.to_vec());
if !c.is_empty() && known.config != c {
known.config = c.to_vec();
changed = true;
}
if !changed {
return;
}
serde_json::to_vec_pretty(&*cache)
};
if let Ok(bytes) = snapshot
}
let root = self.root_key();
if let Some(c) = commands.filter(|c| {
!c.is_empty()
&& (!discovered || known.commands.get(&root).is_none_or(Vec::is_empty))
&& known.commands.get(&root).map(Vec::as_slice) != Some(c)
}) {
known.commands.insert(root, c.to_vec());
changed = true;
}
if !changed {
return;
}
if let Ok(bytes) = serde_json::to_vec_pretty(&*cache)
&& let Err(e) = write_file(&self.paths.models_file, &bytes)
{
tracing::warn!("saving agent settings: {e}");
@@ -458,10 +477,10 @@ impl AgentManager {
// merged without duplicating what `availableCommands` or the disk offer.
let mut offered = offered;
if task.is_none()
&& offered.is_empty()
&& (offered.is_empty() || !self.shared.has_config(&provider))
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
{
self.shared.fetch_commands(spec).await;
self.shared.fetch_settings(spec).await;
offered = self.shared.known_commands(&provider);
}
for &(name, description) in builtin_commands(&provider) {
+43 -18
View File
@@ -69,8 +69,9 @@ pub(crate) fn connect(
Ok(super::serve(shim, client))
}
/// Discover commands without a prompt, ACP session, or pending session file.
pub(crate) async fn commands(spec: ProviderSpec, cwd: PathBuf) -> Result<Vec<Value>, String> {
/// Discover the slash commands and the model, mode and effort options without a prompt,
/// ACP session, or pending session file.
pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<(Vec<Value>, Value), String> {
tokio::time::timeout(Duration::from_secs(5), async move {
let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd));
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
@@ -79,28 +80,52 @@ pub(crate) async fn commands(spec: ProviderSpec, cwd: PathBuf) -> Result<Vec<Val
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
let _stop = io.kill.clone().drop_guard();
let request = json!({ "type": "control_request", "request_id": "commands",
"request": { "subtype": "initialize", "perTaskStopAffordance": true } });
io.stdin.write_all(format!("{request}\n").as_bytes()).await.map_err(|e| e.to_string())?;
io.stdin.flush().await.map_err(|e| e.to_string())?;
let mut lines = BufReader::new(io.stdout).lines();
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
if msg["type"] == "control_response" && msg["response"]["request_id"] == "commands" {
let response = &msg["response"];
return if response["subtype"] == "success" {
Ok(Settings::from_cli(&response["response"], &Value::Null).commands)
} else {
Err("Claude Code did not initialize".into())
};
}
}
Err("Claude Code exited".into())
let init = control(
&mut io.stdin,
&mut lines,
"commands",
json!({ "subtype": "initialize", "perTaskStopAffordance": true }),
)
.await?
.ok_or("Claude Code did not initialize")?;
// The effort and model the user configured; without them the options show the defaults.
let applied = tokio::time::timeout(
Duration::from_secs(2),
control(&mut io.stdin, &mut lines, "settings", json!({ "subtype": "get_settings" })),
)
.await
.ok()
.and_then(Result::ok)
.flatten()
.unwrap_or_default();
let settings = Settings::from_cli(&init, &applied);
Ok((settings.commands.clone(), settings.options()))
})
.await
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
}
/// Sends one control request and waits for its response; `None` when the CLI answered with an error.
async fn control(
stdin: &mut (impl AsyncWriteExt + Unpin),
lines: &mut tokio::io::Lines<impl AsyncBufReadExt + Unpin>,
id: &str,
request: Value,
) -> Result<Option<Value>, String> {
let line = json!({ "type": "control_request", "request_id": id, "request": request });
stdin.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())?;
stdin.flush().await.map_err(|e| e.to_string())?;
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
if msg["type"] == "control_response" && msg["response"]["request_id"] == id {
let response = &msg["response"];
return Ok((response["subtype"] == "success").then(|| response["response"].clone()));
}
}
Err("Claude Code exited".into())
}
struct Config {
pub program: String,
/// Extra arguments (a custom agent's), before the session's own.
+112 -10
View File
@@ -371,7 +371,7 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
async fn claude_commands_before_session_are_fetched_once_and_saved() {
use std::os::unix::fs::PermissionsExt;
for outcome in ["success", "error", "exit", "timeout", "missing"] {
for outcome in ["success", "session_report", "empty", "settings_timeout", "error", "exit", "timeout", "missing"] {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let script = root.join("fake-claude");
@@ -380,6 +380,7 @@ async fn claude_commands_before_session_are_fetched_once_and_saved() {
r#"#!/usr/bin/env python3
import json, os, pathlib, sys, time
root = pathlib.Path.cwd()
(root / 'pid').write_text(str(os.getpid()))
with (root / 'launches').open('a') as f:
f.write(json.dumps(sys.argv[1:]) + '\n')
request = json.loads(sys.stdin.readline())
@@ -391,16 +392,34 @@ if outcome == 'exit':
if outcome == 'timeout':
time.sleep(60)
response = {'subtype': outcome, 'request_id': request['request_id']}
if outcome == 'success':
if outcome in ('success', 'session_report', 'empty', 'settings_timeout'):
response['subtype'] = 'success'
response['response'] = {'commands': [
{'name': 'compact', 'description': 'Free up context', 'argumentHint': '[instructions]'},
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}]}
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}],
'models': [
{'value': 'default', 'displayName': 'Default (recommended)', 'description': 'Use the default model',
'supportsEffort': True, 'supportedEffortLevels': ['low', 'high']},
{'value': 'custom-opus', 'displayName': 'custom-opus', 'description': 'Custom Opus model'}]}
if outcome == 'empty':
response['response'] = {}
else:
response['error'] = 'Authentication required'
if outcome == 'session_report':
(root / 'initialized').touch()
while not (root / 'continue').exists():
time.sleep(0.01)
print(json.dumps({'type': 'control_response', 'response': response}), flush=True)
for line in sys.stdin:
with (root / 'requests').open('a') as f:
f.write(line)
request = json.loads(line)
if request['request']['subtype'] == 'get_settings':
if outcome == 'settings_timeout':
time.sleep(60)
reply = {'subtype': 'success', 'request_id': request['request_id'],
'response': {'applied': {'effort': 'high'}, 'effective': {}}}
print(json.dumps({'type': 'control_response', 'response': reply}), flush=True)
"#,
)
.unwrap();
@@ -417,19 +436,59 @@ for line in sys.stdin:
}
spec.env = vec![("OUTCOME".into(), outcome.into())];
}
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), mgr.commands("claude", None));
let concurrent = async {
if outcome == "session_report" {
tokio::time::timeout(Duration::from_secs(2), async {
while !root.join("initialized").exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
let reported = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": "low",
"options": [{ "value": "default" }, { "value": "low" }, { "value": "high" }] }
] }));
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
std::fs::write(root.join("continue"), "").unwrap();
}
mgr.commands("claude", None).await
};
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), concurrent);
let first = first.unwrap();
assert_eq!(first, concurrent.unwrap());
assert_eq!(first, mgr.commands("claude", None).await.unwrap());
let names: Vec<_> = first.iter().map(|c| c.name.as_str()).collect();
if outcome == "success" {
if matches!(outcome, "success" | "session_report" | "settings_timeout") {
assert_eq!(names, ["compact", "clear", "model", "context", "triage"]);
assert_eq!(first[0].hint.as_deref(), Some("[instructions]"));
let restored = manager(root, Arc::new(FakeAgent::default()));
assert_eq!(first, restored.commands("claude", None).await.unwrap());
// The same handshake lists the models, so the composer has them before any task.
for mgr in [&mgr, &restored] {
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
let model = claude.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
assert_eq!(model.current.as_deref(), Some("default"));
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
assert_eq!(values, ["default", "custom-opus"]);
let effort =
claude.thought_levels["default"].iter().find(|o| o.category.as_deref() == Some("thought_level"));
assert_eq!(
effort.unwrap().current.as_deref(),
Some(match outcome {
"settings_timeout" => "default",
"session_report" => "low",
_ => "high",
})
);
}
} else {
assert_eq!(names, ["triage"], "{outcome}");
assert!(!root.join("models.json").exists());
assert_eq!(root.join("models.json").exists(), outcome == "empty");
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
assert!(!claude.config.iter().any(|o| o.category.as_deref() == Some("model")), "{outcome}");
}
assert!(mgr.tasks.read().is_empty());
assert!(!root.join("store").exists());
@@ -439,10 +498,25 @@ for line in sys.stdin:
let args: Vec<String> = serde_json::from_str(launches.trim()).unwrap();
assert!(args.windows(2).any(|a| a == ["--input-format", "stream-json"]));
let requests = std::fs::read_to_string(root.join("requests")).unwrap();
assert_eq!(requests.lines().count(), 1, "only initialize, no prompt or other control request");
let request: Value = serde_json::from_str(requests.trim()).unwrap();
assert_eq!(request["type"], "control_request");
assert_eq!(request["request"]["subtype"], "initialize");
let subtypes: Vec<String> = requests
.lines()
.map(|l| serde_json::from_str::<Value>(l).unwrap()["request"]["subtype"].as_str().unwrap().to_owned())
.collect();
// The handshake and the settings read: no prompt or other control request.
let expected: &[&str] = if matches!(outcome, "success" | "session_report" | "empty" | "settings_timeout") {
&["initialize", "get_settings"]
} else {
&["initialize"]
};
assert_eq!(subtypes, expected, "{outcome}");
let pid = std::fs::read_to_string(root.join("pid")).unwrap();
tokio::time::timeout(Duration::from_secs(2), async {
while std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("discovery process must exit");
}
}
}
@@ -2358,3 +2432,31 @@ fn mcp_servers_follow_server_state_and_agent_capabilities() {
"args":["--mcp-stdio","--project","/project"],"env":[]})]
);
}
#[tokio::test(flavor = "multi_thread")]
async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
for discovery_first in [false, true] {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let options = |model: &str, effort: &str| {
config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": model,
"options": [{ "value": "saved" }, { "value": "discovered" }] },
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": effort,
"options": [{ "value": "low" }, { "value": "high" }] }
] }))
};
let saved = options("saved", "low");
let discovered = options("discovered", "high");
if discovery_first {
mgr.shared.remember_discovered("claude", &discovered, &[]);
}
mgr.shared.remember("claude", Some(&saved), None, Some("saved"));
mgr.shared.remember_discovered("claude", &discovered, &[]);
let restored = manager(tmp.path(), Arc::new(FakeAgent::default()));
for mgr in [&mgr, &restored] {
assert_eq!(model_of(&mgr.shared.known_config("claude")).as_deref(), Some("saved"));
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["saved"], saved[1..]);
}
}
}
+1 -1
View File
@@ -22,4 +22,4 @@ tempfile.workspace = true
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading", "Win32_System_IO"] }
windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
+128 -10
View File
@@ -4,10 +4,12 @@ use std::collections::VecDeque;
use std::io::{self, Write};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::JoinHandle;
#[cfg(any(not(unix), test))]
use std::time::Duration;
const MAX_BYTES: usize = 4 * 1024 * 1024;
const MAX_WRITES: usize = 1024;
#[cfg(any(not(unix), test))]
const RETRY: Duration = Duration::from_millis(4);
#[derive(Default)]
@@ -28,10 +30,11 @@ pub struct InputWriter {
}
impl InputWriter {
/// Unix writers must first be made nonblocking with `nonblocking`.
/// On Unix, pass the readiness hook returned by `nonblocking` for this writer.
pub fn new(
name: String,
mut writer: Box<dyn Write + Send>,
#[cfg(unix)] mut wait_writable: impl FnMut() -> io::Result<()> + Send + 'static,
on_error: impl FnOnce(io::Error) + Send + 'static,
) -> io::Result<Self> {
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
@@ -74,11 +77,19 @@ impl InputWriter {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
let state = lock.lock().unwrap();
if state.aborted {
return Ok(());
#[cfg(unix)]
match wait_writable() {
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
result => result?,
}
#[cfg(not(unix))]
{
let state = lock.lock().unwrap();
if state.aborted {
return Ok(());
}
drop(wake.wait_timeout(state, RETRY).unwrap());
}
drop(wake.wait_timeout(state, RETRY).unwrap());
}
Err(e) => return Err(e),
}
@@ -168,14 +179,46 @@ impl Drop for InputWriter {
}
}
/// PTY clones share file status flags, so readers must also handle WouldBlock.
/// Make a writer nonblocking and return its readiness hook. PTY clones share file
/// status flags, so readers must also handle WouldBlock.
#[cfg(unix)]
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<()> {
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
let wait = readiness(fd, libc::POLLOUT)?;
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
Ok(wait)
}
/// Wait for output from a nonblocking PTY without delaying the child's next read.
#[cfg(unix)]
pub fn readable(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
readiness(fd, libc::POLLIN)
}
#[cfg(unix)]
fn readiness(fd: std::os::fd::RawFd, events: libc::c_short) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
// Own a duplicate so the hook remains valid if the caller closes its fd.
let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
if duplicate == -1 {
return Err(io::Error::last_os_error());
}
// SAFETY: fcntl returned a new, owned descriptor.
let fd = unsafe { OwnedFd::from_raw_fd(duplicate) };
Ok(move || {
let mut pollfd = libc::pollfd { fd: fd.as_raw_fd(), events, revents: 0 };
// Readiness wakes immediately; the timeout only bounds abort latency.
if unsafe { libc::poll(&mut pollfd, 1, 50) } == -1 {
return Err(io::Error::last_os_error());
}
if pollfd.revents & libc::POLLNVAL != 0 {
return Err(io::Error::from_raw_os_error(libc::EBADF));
}
Ok(())
})
}
#[cfg(test)]
@@ -221,6 +264,11 @@ mod tests {
let writer = InputWriter::new(
"test-input".into(),
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
#[cfg(unix)]
|| {
std::thread::sleep(RETRY);
Ok(())
},
|e| panic!("{e}"),
)
.unwrap();
@@ -243,6 +291,11 @@ mod tests {
let writer = InputWriter::new(
"test-input".into(),
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
#[cfg(unix)]
|| {
std::thread::sleep(RETRY);
Ok(())
},
|e| panic!("{e}"),
)
.unwrap();
@@ -256,6 +309,26 @@ mod tests {
assert_eq!(*output.lock().unwrap(), format!("{paste}tail").as_bytes());
}
#[cfg(unix)]
#[test]
fn readiness_errors_are_reported_and_reject_following_writes() {
let (dropped, _rx) = mpsc::channel();
let (errors, error) = mpsc::channel();
let writer = InputWriter::new(
"test-error-input".into(),
Box::new(SlowWriter { ready: Arc::new(AtomicBool::new(false)), output: Arc::default(), dropped }),
|| Err(io::Error::from_raw_os_error(libc::EBADF)),
move |e| errors.send(e.raw_os_error()).unwrap(),
)
.unwrap();
writer.write(b"input").unwrap();
assert_eq!(error.recv_timeout(Duration::from_secs(1)).unwrap(), Some(libc::EBADF));
assert_eq!(writer.write(b"later").unwrap_err().kind(), io::ErrorKind::BrokenPipe);
let state = writer.shared.0.lock().unwrap();
assert!(state.queue.is_empty());
assert_eq!(state.bytes, 0);
}
#[test]
fn tiny_writes_are_also_bounded() {
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
@@ -266,14 +339,59 @@ mod tests {
assert_eq!(writer.write(b"x").unwrap_err().kind(), io::ErrorKind::WouldBlock);
}
#[cfg(unix)]
#[test]
fn writable_input_resumes_without_timer_delays() {
use std::io::Read;
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
let (pipe, mut peer) = UnixStream::pair().unwrap();
let capacity: libc::c_int = 1024;
// Keep the OS buffer small enough that a timer per buffer takes seconds.
assert_eq!(
unsafe {
libc::setsockopt(
pipe.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_SNDBUF,
(&capacity as *const libc::c_int).cast(),
std::mem::size_of_val(&capacity) as libc::socklen_t,
)
},
0
);
peer.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
let writer =
InputWriter::new("test-ready-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
let bytes = vec![b'x'; MAX_BYTES];
writer.write(&bytes).unwrap();
writer.finish();
// Start with a full buffer, then make the fd writable from another thread.
std::thread::sleep(Duration::from_millis(50));
let (tx, rx) = mpsc::channel();
let started = Instant::now();
let reader = std::thread::spawn(move || {
let mut output = Vec::new();
peer.read_to_end(&mut output).unwrap();
tx.send(output).unwrap();
});
let output = rx.recv_timeout(Duration::from_secs(1)).expect("input resumes as soon as the fd is writable");
assert_eq!(output, bytes);
reader.join().unwrap();
eprintln!("4 MiB input drained in {:?}", started.elapsed());
}
#[cfg(unix)]
#[test]
fn close_interrupts_a_full_os_pipe_while_the_peer_stays_open() {
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
let (pipe, _peer) = UnixStream::pair().unwrap();
nonblocking(pipe.as_raw_fd()).unwrap();
let mut writer = InputWriter::new("test-pipe-input".into(), Box::new(pipe), |e| panic!("{e}")).unwrap();
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
let mut writer =
InputWriter::new("test-pipe-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
writer.write(&vec![b'x'; MAX_BYTES]).unwrap();
std::thread::sleep(Duration::from_millis(50));
assert!(!writer.worker.as_ref().unwrap().is_finished());
+1
View File
@@ -16,6 +16,7 @@ pub mod ipc_path;
pub mod java_runtime;
pub mod jsonc;
pub mod output_encoding;
pub mod platform;
pub mod process;
pub mod project_dir;
pub mod project_roots;
+29
View File
@@ -0,0 +1,29 @@
/// The Windows OS build used by ConPTY; unavailable on other platforms or on query failure.
#[cfg(windows)]
pub fn windows_build_number() -> Option<u32> {
use windows_sys::Wdk::System::SystemServices::RtlGetVersion;
use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
let mut info =
OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32, ..Default::default() };
// SAFETY: `info` is initialized and declares the size of the writable structure.
let status = unsafe { RtlGetVersion(&mut info) };
(status >= 0 && info.dwBuildNumber > 0).then_some(info.dwBuildNumber)
}
#[cfg(not(windows))]
pub fn windows_build_number() -> Option<u32> {
None
}
#[cfg(test)]
mod tests {
#[test]
fn windows_build_is_platform_specific() {
let build = super::windows_build_number();
#[cfg(windows)]
assert!(build.is_some_and(|build| build > 0));
#[cfg(not(windows))]
assert_eq!(build, None);
}
}
+6
View File
@@ -110,6 +110,12 @@ const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
#[cfg(windows)]
fn taskkill_tree_of(pid: u32) {
let started = windows_tree::descendants(pid);
// When the child started nothing, killing it alone is the whole job:
// `taskkill` would be a process spawn (measured at ~0.7 s here) for the
// same outcome, and the callers kill the child themselves.
if started.is_empty() {
return;
}
let mut taskkill = Command::new("taskkill");
prepare(&mut taskkill)
.args(["/T", "/F", "/PID", &pid.to_string()])
+37 -8
View File
@@ -630,9 +630,15 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
#[cfg(unix)]
if let Some(fd) = pair.master.as_raw_fd() {
ide_core::input::nonblocking(fd).map_err(|e| format!("terminal of {program}: {e}"))?;
}
let wait_writable = ide_core::input::nonblocking(
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
)
.map_err(|e| format!("terminal of {program}: {e}"))?;
#[cfg(unix)]
let wait_readable = ide_core::input::readable(
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
)
.map_err(|e| format!("terminal of {program}: {e}"))?;
let mut cmd = CommandBuilder::new(program);
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
@@ -662,16 +668,27 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
drop(pair.slave);
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = ide_core::input::InputWriter::new(format!("openidea-debug-input-{}", s.id.0), writer, |e| {
tracing::warn!("debug input: {e}");
})
let writer = ide_core::input::InputWriter::new(
format!("openidea-debug-input-{}", s.id.0),
writer,
#[cfg(unix)]
wait_writable,
|e| {
tracing::warn!("debug input: {e}");
},
)
.inspect_err(|_| {
let _ = child.kill();
let _ = child.wait();
})
.map_err(|e| format!("terminal input worker: {e}"))?;
let pid = child.process_id();
forward_terminal_output(s, reader);
forward_terminal_output(
s,
reader,
#[cfg(unix)]
wait_readable,
);
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
let _ = previous.child.kill();
}
@@ -680,7 +697,11 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
}
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
fn forward_terminal_output(
s: &Session,
mut reader: Box<dyn std::io::Read + Send>,
#[cfg(unix)] mut wait_readable: impl FnMut() -> std::io::Result<()> + Send + 'static,
) {
let session = s.id;
let sink = s.owner.sink.clone();
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
@@ -692,6 +713,14 @@ fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send
Ok(n) => pending.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
#[cfg(unix)]
if let Err(e) = wait_readable()
&& e.kind() != std::io::ErrorKind::Interrupted
{
tracing::warn!(%session, "debug terminal read: {e}");
break;
}
#[cfg(not(unix))]
std::thread::sleep(std::time::Duration::from_millis(4));
continue;
}
+41 -8
View File
@@ -276,16 +276,12 @@ impl PluginHost {
self.shared.run_configurations.write().insert((plugin, project), items);
}
}
/// Parses a bounded batch under the editor-provider soft/hard budgets.
pub fn parse_test_results(
/// Resolves only an owned plugin test configuration in the client's trusted project.
fn test_configuration(
&self,
client: &str,
config: &str,
chunk: String,
) -> IdeResult<Option<Vec<ide_plugin::PluginTestEvent>>> {
if chunk.len() > 64 << 10 {
return Err(IdeError::invalid("test output batch too large"));
}
) -> IdeResult<Option<(std::path::PathBuf, String, String)>> {
let root = self.shared.project_of(client).ok_or_else(|| IdeError::not_found("no project"))?;
if !self.shared.workspace().is_some_and(|w| w.trusted(&root)) {
return Err(IdeError::invalid("project is not trusted"));
@@ -302,7 +298,44 @@ impl PluginHost {
{
return Ok(None);
}
let plugin = self.get(owner)?;
Ok(Some((root, owner.to_owned(), id.to_owned())))
}
/// Derives replacement argv under the parser's soft/hard call budgets.
pub fn select_tests(&self, client: &str, config: &str, tests: Vec<Vec<String>>) -> IdeResult<Option<Vec<String>>> {
if tests.is_empty()
|| tests.len() > 10000
|| tests.iter().any(|p| p.is_empty() || p.len() > 128)
|| serde_json::to_vec(&tests).is_ok_and(|b| b.len() > 4 << 20)
{
return Ok(None);
}
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
let plugin = self.get(&owner)?;
let (actor, activated) = self.ensure_active(&plugin, None)?;
if activated {
self.changed();
}
let invocation = ide_plugin::Invocation { workspace_root: Some(root), ..Default::default() };
let hard = self.limits.edit_hard;
let started = Instant::now();
let result = actor.call(Some(self.limits.edit_soft), move |rt| rt.select_tests(id, tests, invocation, hard));
let args = self.finish_provider_call(&plugin, &actor, started, result).flatten();
Ok(args.filter(|args| args.len() <= 10000 && args.iter().map(String::len).sum::<usize>() <= 4 << 20))
}
/// Parses a bounded batch under the editor-provider soft/hard budgets.
pub fn parse_test_results(
&self,
client: &str,
config: &str,
chunk: String,
) -> IdeResult<Option<Vec<ide_plugin::PluginTestEvent>>> {
if chunk.len() > 64 << 10 {
return Err(IdeError::invalid("test output batch too large"));
}
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
let plugin = self.get(&owner)?;
let (actor, activated) = self.ensure_active(&plugin, None)?;
if activated {
self.changed();
@@ -468,6 +468,16 @@ impl RemotePlugins {
}
});
let h = host.clone();
d.add("plugin_select_tests", move |c| {
let h = h.clone();
async move {
let host = alive(&h)?;
let config = c.arg::<String>("config")?;
let tests = c.arg("tests")?;
blocking(move || host.select_tests(&c.window, &config, tests)).await
}
});
let h = host.clone();
d.add("plugin_parse_test_results", move |c| {
let h = h.clone();
async move {
@@ -617,6 +627,11 @@ impl RemotePlugins {
c.host.mcp_reports.set_focused_editor(&c.window, c.arg("editor")?, c.arg("focused")?);
Ok(())
});
d.add("mcp_report_problems", |c| async move {
c.host.project(&c.window)?;
c.host.mcp_reports.report_problems(&c.window, c.arg("reset")?, c.arg("files")?);
Ok(())
});
let h = host.clone();
d.add("plugin_decoration_sets", move |_c| {
let h = h.clone();
+4
View File
@@ -286,6 +286,10 @@ where
let p = Peer::connect(reader, writer, handler);
let _ = peer.set(p.clone());
p.closed().await;
// Reports belong to the disconnected desktop, even while the host shuts down.
for window in host.projects.read().keys() {
host.mcp_reports.forget_window(window);
}
}
/// Sends a window's events to the client as `event` notifications.
+46 -2
View File
@@ -76,11 +76,11 @@ impl TestWorkspace {
impl PluginWorkspace for TestWorkspace {
fn run_configuration(&self, _client: &str, plugin: &str, id: &str) -> Result<ide_run::RunConfig, HostError> {
if plugin != "execution" || !matches!(id, "detected" | "dynamic") {
if !(plugin == "execution" && matches!(id, "detected" | "dynamic") || plugin == "api14" && id == "tests") {
return Err(HostError::NotFound(id.into()));
}
Ok(ide_run::RunConfig {
id: format!("plugin:execution:{id}"),
id: format!("plugin:{plugin}:{id}"),
name: "Tests".into(),
kind: "plugin".into(),
command: "execution-tool".into(),
@@ -6018,6 +6018,50 @@ fn api14_stage3_configurations_test_results_and_content_actions_use_real_exports
assert!(f.host.parse_test_results("main", "plugin:execution:dynamic", "trap".into()).unwrap().is_none());
}
#[test]
fn api14_fixture_exports_test_selection() {
let f = fixture(Limits::default());
f.install(&sample("api14"));
assert_eq!(
f.host.select_tests("main", "plugin:api14:tests", vec![vec!["Suite".into(), "Test".into()]]).unwrap(),
Some(vec!["Suite::Test".into()])
);
}
#[cfg(unix)]
#[test]
fn api14_test_selection_uses_owned_config_ids_and_name_paths() {
let f = execution_fixture();
f.host.refresh_run_configurations(&f.project);
for id in ["dynamic", "detected"] {
assert_eq!(
f.host
.select_tests(
"main",
&format!("plugin:execution:{id}"),
vec![vec!["Suite".into(), "Test".into()], vec!["Other".into()]]
)
.unwrap(),
Some(vec![id.into(), "Suite::Test".into(), "Other".into()])
);
}
assert_eq!(f.host.select_tests("main", "plugin:execution:dynamic", vec![vec!["none".into()]]).unwrap(), None);
assert_eq!(f.host.select_tests("main", "cargo:test", vec![vec!["Test".into()]]).unwrap(), None);
assert_eq!(f.host.select_tests("main", "plugin:execution:dynamic", vec![]).unwrap(), None);
}
#[cfg(unix)]
#[test]
fn api14_test_selection_traps_and_timeouts_are_unsupported() {
for failure in ["trap", "timeout"] {
let f = execution_fixture();
f.host.refresh_run_configurations(&f.project);
let started = Instant::now();
assert_eq!(f.host.select_tests("main", "plugin:execution:dynamic", vec![vec![failure.into()]]).unwrap(), None);
assert!(started.elapsed() < Duration::from_secs(5));
}
}
#[cfg(unix)]
#[test]
fn api14_stage3_terminal_calls_are_denied_without_process_grants() {
+73 -2
View File
@@ -396,7 +396,7 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
plugins.register(&mut dispatcher);
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
tokio::spawn(rpc::serve(host, dispatcher, peer_slot, sr, sw));
let server = tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let client = Arc::new(Client { desktop: Some(desktop.clone()), ..Client::default() });
let peer = Peer::connect(cr, cw, client.clone());
@@ -416,6 +416,53 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
assert!(editors.as_str().unwrap().contains("active: true"));
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert_eq!(problems.as_str(), Some("[]"));
let other_file = project.join("other.txt");
std::fs::write(&other_file, "other text").unwrap();
let outside_file = tmp.path().join("outside.txt");
std::fs::write(&outside_file, "outside").unwrap();
let problem = |path: &Path, message: &str| {
json!({"path": path, "items": [{
"range": {"start": {"line": 0, "col": 1}, "end": {"line": 0, "col": 3}},
"severity": "warning", "message": message, "source": "remote-check", "code": null
}]})
};
let files = json!([
problem(&remote_file, "remote warning"),
problem(&other_file, "other warning"),
problem(&outside_file, "outside warning")
]);
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
let problems = problems.as_str().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(problems.contains("other warning"), "{problems}");
assert!(!problems.contains("outside warning"), "{problems}");
for path in ["remote.txt", remote_file.to_str().unwrap()] {
let problems = invoke(&peer, "plugin_run_command", run("problems", Some(path))).await.unwrap();
let problems = problems.as_str().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(!problems.contains("other warning"), "{problems}");
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
}
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", Some("missing.txt"))).await.unwrap(), json!("[]"));
invoke(&peer, "mcp_report_problems", json!({"reset": false, "files": [{"path": remote_file, "items": []}]}))
.await
.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(!problems.as_str().unwrap().contains("remote warning"));
assert!(problems.as_str().unwrap().contains("other warning"));
invoke(
&peer,
"mcp_report_problems",
json!({"reset": true, "files": [problem(&remote_file, "replacement warning")]}),
)
.await
.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(problems.as_str().unwrap().contains("replacement warning"));
assert!(!problems.as_str().unwrap().contains("other warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
invoke(&peer, "plugin_run_command", run("open-editor", Some("remote.txt"))).await.unwrap();
invoke(&peer, "plugin_run_command", run("decorations", remote_file.to_str())).await.unwrap();
let sets = invoke(&peer, "plugin_decoration_sets", json!({})).await.unwrap();
@@ -466,9 +513,33 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
stream.read_to_string(&mut response).await.unwrap();
assert!(response.contains("You can close this tab"));
assert_eq!(oauth.await.unwrap().unwrap(), json!("code=desktop%2Bcode"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
invoke(&peer, "close_project", json!({})).await.unwrap();
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
peer.kill();
tokio::time::timeout(Duration::from_secs(5), server).await.unwrap().unwrap();
let plugin_host = plugins.host().clone();
let result = tokio::task::spawn_blocking(move || {
plugin_host
.run_command(
"api14",
"problems",
None,
ide_plugin::Invocation {
client: Some("main".into()),
workspace_root: Some(project),
..Default::default()
},
)
.unwrap()
})
.await
.unwrap();
assert_eq!(result.as_deref(), Some("[]"));
plugins.host().shutdown();
desktop.shutdown();
peer.kill();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+16
View File
@@ -1165,6 +1165,22 @@ impl Runtime {
})
.collect())
}
/// Selects tests under the same budget as the test parser.
pub fn select_tests(
&mut self,
config: String,
tests: Vec<Vec<String>>,
invocation: Invocation,
timeout: Duration,
) -> Result<Option<Vec<String>>, CallError> {
if self.exports.test_results.is_none() {
return Err(CallError::missing_export("test-results"));
}
self.call(invocation, timeout, |e, store| {
e.test_results.as_ref().unwrap().call_select_tests(store, &config, &tests)
})
}
/// One editor-budget batch of a plugin test parser.
pub fn parse_test_results(
&mut self,
+2
View File
@@ -34,6 +34,8 @@ fn components() -> Vec<PathBuf> {
repo().join("crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm"),
];
let official = repo().join("plugins/official");
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
Binary file not shown.
@@ -1 +1 @@
b9b06665132bd6db753aa098703f2d83c9185d756114cdd1abb986f9d6d56c06
f8c5b18f9734fd19f65a32e5087655d75ad944dddda31780f61672b94da3da6e
+9 -1
View File
@@ -155,4 +155,12 @@ impl Plugin for Api14 {
host::storage::set("events", &serde_json::to_string(&received).unwrap()).unwrap();
}
}
register_plugin!(Api14);
impl openidea_plugin::TestResults for Api14 {
fn parse(&mut self, _config: &str, _chunk: &str) -> Vec<openidea_plugin::PluginTestEvent> {
vec![]
}
fn select_tests(&mut self, config: &str, tests: &[Vec<String>]) -> Option<Vec<String>> {
(config == "tests").then(|| tests.iter().map(|p| p.join("::")).collect())
}
}
register_plugin!(Api14, test_results);
@@ -1 +1 @@
e986322ee0a7e8b09096deee3335e542823ad836cecdf14a4f44e2066fadf7b5
f4ae5c053230e2a805b097d79392501ad4654f715d6d194937299273b67fda7c
@@ -1 +1 @@
cb74e35e0b0fa2bf7d46b143190d4e72d90abfa7a95962c037495b722d8c73d3
7631939096b867c4707f7e0f929d2b75f91ef2b76da764d6e21233a5c06e8af9
@@ -1 +1 @@
7ef1de0b1eb9d70ed3f5ba1dfc8cec6ccf3445ce76ad128f10f3d2456a990c2c
f5a2d43df242aa0ba9f358d9abf7296fd6a56b163bb526e62ce9c8b4d17e4b9a
@@ -1 +1 @@
dcca07b99ce7d6691bd501d0e7c80d6f4ce0350b32a3bd7726b13f5b315c3982
f19481ef89309e6fc65736cb18018a4bf9b679e5a1c6c8e9f92adf39578ca4cb
@@ -1 +1 @@
6ab6593d3d7226a1a52bcdbe827697be39e9ed5117d238ba2366ad1a6911a623
a6ba20c5bd531d965e64041b05f1dabfb1634c3192b06cdb61578bd2bf6f1af2
Binary file not shown.
@@ -1 +1 @@
a410bcd828c7b43d550c67c433343d1c8d4a5310b62578fc1d7fec542f2761eb
4c20506bb51bb9c08ebedb3ffb0c89ef153ee9565a466ac99d941a2c83ac2c9f
+11
View File
@@ -89,6 +89,17 @@ impl RunConfigurations for Execution {
}
}
impl TestResults for Execution {
fn select_tests(&mut self, config: &str, tests: &[Vec<String>]) -> Option<Vec<String>> {
match tests.first()?.first()?.as_str() {
"none" => return None,
"trap" => panic!("selection failed"),
"timeout" => loop {
std::hint::spin_loop();
},
_ => {}
}
Some(std::iter::once(config.to_owned()).chain(tests.iter().map(|p| p.join("::"))).collect())
}
fn parse(&mut self, _: &str, chunk: &str) -> Vec<PluginTestEvent> {
if chunk == "trap" {
panic!("parser failed");
@@ -1 +1 @@
86238b45b09b0274d59282b35295aaee650e999aa309640d9347787e7d461ec4
110bbe7e4c0bd02d93c0c7ccae8fcb1eccbcf355587ca271d8046b588f3db898
@@ -1 +1 @@
d433104828c588baf00dece077a087dc7f4cd78097952adca5b6049e366ca944
2c02b56def836148a1be5ac0429394d9430d468c3d3ee3682a3c742bd8c90436
@@ -1 +1 @@
4093e1d4791c3786f4b67fd4ef76928fc17048470f858824a29aafd71855c77d
6681327fc078ecad751627998c4b6299b6176484a61760d6f265b41bd3f42c97
@@ -1 +1 @@
aeb6d2f0a589731a1b7f71c9c71232b72d2535f0f555c4fbdb7101861b906d77
a6a21264dffff392ea5e998c4545cdbe21a08bae429d2765e6bfeac9f2c9f863
@@ -1 +1 @@
b723a8826d256fb57f04bbe8aa4b5300c9777f79d889bed2770f6f2bd7a65aa3
f456deae0bbdee79c22d844ac8a9db5e4b867eaed245dc9332c1708e32e01ae0
@@ -1 +1 @@
31f6c0067f7668235ce7f28cb14f75950549c690a8f191aa63a71fe25f8397c6
50e77e306f196dd43f7d95789cc1da94085a2863f1ccbec83701c34916ffff41
@@ -1 +1 @@
f03ff9487293925027c5681800633d2e0a71ef908db9ea037dff5e4e0a48458e
b9342ba2bf0107bb65074aecf726991d326a45e50ac12100a97c7a6051b01161
@@ -1 +1 @@
98a083b972c0a887de07b720c606ffcf13f99d835cfa64d553a1243ffc19e4b1
b4300035602303343f8b1c15326f86a696bfe814a3f2c7e1e186f5eed5a92321
@@ -1 +1 @@
639a25cbc3aaab3080bf2894bc4710f45a1043a8a0f0f7f8c41ac20253f8bc98
91d6f1f11a26effda7a21fd423f8ea6f42e707168ac5080033e9116c89d11d10
@@ -1 +1 @@
741d2909f021ec4c2f572edba0d83bcee4843a1aae4d8a79ea0367419a667640
3edc6122e6cbba033d37f44d7855deff8473f097205846e06768888ac416742c
@@ -1 +1 @@
1da271855c8e63d9b6873521392bb2e14cb85f9de4fddcc7bce0c80916298caf
83ce23d58d809e9a724dfcb8bec2cccc1f73c77324d39d1c6ee2a7c855d763fd
@@ -1 +1 @@
e14bfd9a7f61a7ff44f656a148ed133e973c66edfb0859bbff4d4cda9663eb3a
557e263c737bc5cdf15f5bc4ed4fa5d7a4f2e252b06c2eb873e936a968519c08
@@ -1 +1 @@
1c2f975248675b935adac4ac096d23d3f45ca44a46e9e4a2ee578bb69bb35356
884e60babc875a5971a9ee42d0949c22712b9af749a6995d6273efa191aa65b5
@@ -1 +1 @@
2d566981f7f3aa78fbd6188e2bbdb17e33c01f6863e9b9a0b656f541371374bc
5f2f03bbc397dde8ad7c9b1cf2a61e9a4ae5881b34d71ec2e5748fc474754d59
@@ -1 +1 @@
69b349e65d32c5b22f7b6d312944e0e7387d1f7b8e46a1e88fcfcd87262ea407
253a7ab7bb1827dee5434b47c732e484976237ca5a5d637536ccc36905d84be0
@@ -1 +1 @@
e62e75f6e21f4d3c3797554581b6a78fc75a5362ce32f627c9858dd5f1d4edc5
93467ab4d2a02ec72820f17df873510707dfdabd02d70c19164b8192d967bf7d
@@ -1 +1 @@
c4740acdb3c805e13bf9a0c2e2c2a830336a1085285c39de3dce73a301f70150
62372338f5c0129177936ad34c7f0fc5d8cd2f14c3f895c026ca7f79f1e31391
@@ -1 +1 @@
315d592adb776916170a6c2293379e7a7a086b9ae513dcac4aefad925d91df5d
4d1952be87c87f44e23b2420f966e23912405ba842d9928f1f9dec409dfa670e
@@ -1 +1 @@
7a88345173c4a77f6f94a047af53a43852adcf7e0377199f670374f296b98b19
3992885be293901280938f5dc6498a39a310e1a3c7e34473394116a2327bb13b
@@ -1 +1 @@
c98b94057e62a575d6c4b6c4c92e1e88e101bcef9138683e47b0bebf262e3f63
70926629540ab1277641aef53419ce3014f291e424502619378511735bdbb8d5
+101 -23
View File
@@ -426,11 +426,16 @@ impl ProcessManager {
Ok(match child.stdin.take() {
Some(pipe) if !redirected => {
#[cfg(unix)]
{
let wait_writable = {
use std::os::fd::AsRawFd;
ide_core::input::nonblocking(pipe.as_raw_fd())?;
}
Some(input_writer(id, Box::new(pipe))?)
ide_core::input::nonblocking(pipe.as_raw_fd())?
};
Some(input_writer(
id,
Box::new(pipe),
#[cfg(unix)]
wait_writable,
)?)
}
_ => None,
})
@@ -462,10 +467,24 @@ impl ProcessManager {
let stderr = child.stderr.take();
let result = (|| -> std::io::Result<()> {
if let Some(out) = stdout {
spawn_reader(id, Stream::Stdout, out, tx.clone())?;
spawn_reader(
id,
Stream::Stdout,
out,
tx.clone(),
#[cfg(unix)]
None,
)?;
}
if let Some(err) = stderr {
spawn_reader(id, Stream::Stderr, err, tx.clone())?;
spawn_reader(
id,
Stream::Stderr,
err,
tx.clone(),
#[cfg(unix)]
None,
)?;
}
spawn_pump(rx, on_output.clone(), entry.clone(), sink, options.encoding.clone(), notice)?;
Ok(())
@@ -538,9 +557,13 @@ impl ProcessManager {
cmd.env(k, v);
}
#[cfg(unix)]
if let Some(fd) = pair.master.as_raw_fd() {
ide_core::input::nonblocking(fd)?;
}
let wait_writable = ide_core::input::nonblocking(
pair.master.as_raw_fd().ok_or_else(|| std::io::Error::other("PTY has no file descriptor"))?,
)?;
#[cfg(unix)]
let wait_readable = ide_core::input::readable(
pair.master.as_raw_fd().ok_or_else(|| std::io::Error::other("PTY has no file descriptor"))?,
)?;
let mut child = pair.slave.spawn_command(cmd).map_err(|e| {
let text = e.to_string();
if text.contains("No such file") || text.contains("not found") {
@@ -555,7 +578,13 @@ impl ProcessManager {
let writer = pair.master.take_writer().map_err(|e| IdeError::internal(format!("pty writer: {e}")))?;
let id = RunId::next();
let writer = input_writer(id, writer).inspect_err(|_| {
let writer = input_writer(
id,
writer,
#[cfg(unix)]
wait_writable,
)
.inspect_err(|_| {
let _ = child.kill();
let _ = child.wait();
})?;
@@ -577,8 +606,15 @@ impl ProcessManager {
on_output(OutputChunk::System { text: format!("{}\n", inv.display) });
let (tx, rx) = mpsc::channel();
let result = spawn_reader(id, Stream::Stdout, reader, tx.clone())
.and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), None));
let result = spawn_reader(
id,
Stream::Stdout,
reader,
tx.clone(),
#[cfg(unix)]
Some(Box::new(wait_readable)),
)
.and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), None));
if let Err(e) = result {
let _ = child.kill();
let _ = child.wait();
@@ -789,11 +825,16 @@ fn run_followup(
Ok(match child.stdin.take() {
Some(pipe) => {
#[cfg(unix)]
{
let wait_writable = {
use std::os::fd::AsRawFd;
ide_core::input::nonblocking(pipe.as_raw_fd())?;
}
Some(input_writer(entry.id, Box::new(pipe))?)
ide_core::input::nonblocking(pipe.as_raw_fd())?
};
Some(input_writer(
entry.id,
Box::new(pipe),
#[cfg(unix)]
wait_writable,
)?)
}
None => None,
})
@@ -805,10 +846,24 @@ fn run_followup(
drop(pid);
let readers = (|| {
if let Some(out) = child.stdout.take() {
spawn_reader(entry.id, Stream::Stdout, out, tx.clone())?;
spawn_reader(
entry.id,
Stream::Stdout,
out,
tx.clone(),
#[cfg(unix)]
None,
)?;
}
if let Some(err) = child.stderr.take() {
spawn_reader(entry.id, Stream::Stderr, err, tx.clone())?;
spawn_reader(
entry.id,
Stream::Stderr,
err,
tx.clone(),
#[cfg(unix)]
None,
)?;
}
Ok::<_, std::io::Error>(())
})();
@@ -982,6 +1037,7 @@ fn spawn_reader(
stream: Stream,
mut pipe: impl Read + Send + 'static,
tx: Sender<Msg>,
#[cfg(unix)] mut wait_readable: Option<Box<dyn FnMut() -> std::io::Result<()> + Send>>,
) -> std::io::Result<()> {
let label = if stream == Stream::Stdout { "out" } else { "err" };
std::thread::Builder::new().name(format!("openidea-run-{label}-{}", id.0)).spawn(move || {
@@ -995,7 +1051,19 @@ fn spawn_reader(
}
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => std::thread::sleep(BATCH_INTERVAL),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
#[cfg(unix)]
if let Some(wait) = &mut wait_readable {
if let Err(e) = wait()
&& e.kind() != std::io::ErrorKind::Interrupted
{
tracing::warn!(%id, "run read: {e}");
break;
}
continue;
}
std::thread::sleep(BATCH_INTERVAL);
}
#[cfg(unix)]
Err(e) if e.raw_os_error() == Some(libc::EIO) => break,
Err(e) => {
@@ -1218,10 +1286,20 @@ fn signal_number(_description: &str) -> Option<i32> {
None
}
fn input_writer(id: RunId, writer: Box<dyn std::io::Write + Send>) -> std::io::Result<ide_core::input::InputWriter> {
ide_core::input::InputWriter::new(format!("openidea-run-input-{}", id.0), writer, move |e| {
tracing::warn!(%id, "run input: {e}");
})
fn input_writer(
id: RunId,
writer: Box<dyn std::io::Write + Send>,
#[cfg(unix)] wait_writable: impl FnMut() -> std::io::Result<()> + Send + 'static,
) -> std::io::Result<ide_core::input::InputWriter> {
ide_core::input::InputWriter::new(
format!("openidea-run-input-{}", id.0),
writer,
#[cfg(unix)]
wait_writable,
move |e| {
tracing::warn!(%id, "run input: {e}");
},
)
}
#[cfg(test)]
+42 -11
View File
@@ -163,9 +163,13 @@ impl TerminalManager {
apply_powershell_env(&shell, &mut cmd);
#[cfg(unix)]
if let Some(fd) = pair.master.as_raw_fd() {
ide_core::input::nonblocking(fd)?;
}
let wait_writable = ide_core::input::nonblocking(
pair.master.as_raw_fd().ok_or_else(|| std::io::Error::other("PTY has no file descriptor"))?,
)?;
#[cfg(unix)]
let wait_readable = ide_core::input::readable(
pair.master.as_raw_fd().ok_or_else(|| std::io::Error::other("PTY has no file descriptor"))?,
)?;
let mut child = pair
.slave
@@ -180,9 +184,15 @@ impl TerminalManager {
let id = TerminalId::next();
let info = TerminalInfo { id, title: shell_title(&shell), pid: child.process_id() };
let exited = Arc::new(AtomicBool::new(false));
let writer = ide_core::input::InputWriter::new(format!("openidea-term-write-{}", id.0), writer, move |e| {
tracing::warn!(%id, "terminal input: {e}");
})
let writer = ide_core::input::InputWriter::new(
format!("openidea-term-write-{}", id.0),
writer,
#[cfg(unix)]
wait_writable,
move |e| {
tracing::warn!(%id, "terminal input: {e}");
},
)
.inspect_err(|_| {
let _ = child.kill();
let _ = child.wait();
@@ -200,9 +210,15 @@ impl TerminalManager {
self.sessions.lock().insert(id, session);
let (tx, rx) = mpsc::channel::<Msg>();
let spawned = spawn_reader(id, reader, tx.clone())
.and_then(|_| spawn_waiter(id, child, tx))
.and_then(|_| spawn_pump(id, rx, on_output, exited, self.sessions.clone(), sink, Instant::now()));
let spawned = spawn_reader(
id,
reader,
tx.clone(),
#[cfg(unix)]
wait_readable,
)
.and_then(|_| spawn_waiter(id, child, tx))
.and_then(|_| spawn_pump(id, rx, on_output, exited, self.sessions.clone(), sink, Instant::now()));
if let Err(e) = spawned {
if let Some(s) = self.sessions.lock().remove(&id) {
let _ = s.killer.lock().kill();
@@ -407,7 +423,12 @@ fn terminate(session: &Session) {
}
}
fn spawn_reader(id: TerminalId, mut reader: Box<dyn Read + Send>, tx: mpsc::Sender<Msg>) -> std::io::Result<()> {
fn spawn_reader(
id: TerminalId,
mut reader: Box<dyn Read + Send>,
tx: mpsc::Sender<Msg>,
#[cfg(unix)] mut wait_readable: impl FnMut() -> std::io::Result<()> + Send + 'static,
) -> std::io::Result<()> {
std::thread::Builder::new().name(format!("openidea-term-read-{}", id.0)).spawn(move || {
let mut buf = vec![0u8; 16 * 1024];
loop {
@@ -419,7 +440,17 @@ fn spawn_reader(id: TerminalId, mut reader: Box<dyn Read + Send>, tx: mpsc::Send
}
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => std::thread::sleep(BATCH_INTERVAL),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
#[cfg(unix)]
if let Err(e) = wait_readable()
&& e.kind() != std::io::ErrorKind::Interrupted
{
tracing::warn!(%id, "terminal read: {e}");
break;
}
#[cfg(not(unix))]
std::thread::sleep(BATCH_INTERVAL);
}
// EIO is how Linux/macOS report "slave side closed".
#[cfg(unix)]
Err(e) if e.raw_os_error() == Some(libc::EIO) => break,
+2
View File
@@ -46,6 +46,8 @@
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-sass": "^6.0.2",
"@codemirror/lang-vue": "^0.1.3",
"@codemirror/language": "^6.13.1",
"@codemirror/language-data": "^6.5.2",
"@codemirror/legacy-modes": "^6.5.5",
+23 -3
View File
@@ -553,6 +553,7 @@ then run `openidea-plugin check`.
| `host::reply_content_tab(request_id, reply)` | `views.reply-content-tab` | API 1.4: reply to an owned action in the originating window. No capability; commands/events only. |
| `RunConfigurations::configurations(root)` | export `run-configurations.configurations` | API 1.4: optional `plugin-run-configurations` world; SDK registration `run_configurations`. Full project list, refreshed on configuration listing. |
| `TestResults::parse(config, chunk)` | export `test-results.parse` | API 1.4: optional `plugin-test-results` world; SDK registration `test_results`. Bounded output batches for `framework = "plugin"`; failures use built-in parsers. |
| `TestResults::select_tests(config, tests)` | export `test-results.select-tests` | API 1.4: full replacement argv for test-tree name paths, or `None` when unsupported (the SDK default). Enables rerunning failed tests and running tree selections. |
| `host::open_location(path, line, column)` | `views.open-location` | Opens a file inside the project in an editor of the invoking window, the caret at `line`/`column` (0-based, UTF-16): a view's links to code. Needs `project.read`. |
| `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. |
@@ -1628,9 +1629,10 @@ test's parent must already exist. Locations use absolute project-contained paths
zero-based lines and UTF-16 columns. Passed/failed durations are milliseconds.
The host uses the editor-provider soft/hard budget per batch and bounds returned
events to 10,000 / 4 MiB. A missing, failed or invalid parser switches that run to
the built-in parsers; common framework commands retain their existing rerun-failed
filters. Arbitrary frameworks have no failed-test selection protocol in this
export and therefore cannot supply a custom rerun-failed filter.
the built-in parsers. Implement `select_tests(config, tests)` to derive the full
replacement argv for failed tests or test-tree selections; its default returns
`None`. See [Selecting plugin tests](#selecting-plugin-tests-api-14-unreleased)
for the name-path contract and unsupported cases.
`host::open_terminal_session(key, title, &Command::new("tool"))` opens a normal
Terminal tab in this window's project root. Reopening the same key focuses its
@@ -2357,6 +2359,24 @@ its normal editor and project context. No capability is needed. Invalid or
undeclared actions reject the whole notification. Labels are limited to 256
bytes and arguments to 64 KiB. Plain `host::notify` behaves as before.
### Selecting plugin tests (API 1.4, unreleased)
A plugin using `framework = "plugin"` can implement `TestResults::select_tests`
(`test-results.select-tests`) alongside `parse`. It receives the same plugin-local
configuration id as `parse` and a list of test-tree paths: the names from the
plugin's `test-start` events, from parent suite to test, exactly as shown in the
tree. Return the **full replacement argument list**, including any required base
arguments, to run only those tests. `None` (the Rust SDK default), errors and
call timeouts leave test selection unsupported. An empty argument list is a
supported selection. The IDE preserves the configuration's command, environment,
working directory and run target.
This enables **Rerun Failed Tests** and the test tree's **Run** / **Debug** entries.
Calls use the test parser's bounded background budget and run on the plugin's
host, including remote projects. See `official/test-runner` for an example.
This export is added to the existing interface while 1.4 is unreleased; released
export interfaces remain frozen under the rules above.
### Plugin-provided run targets (API 1.4)
`host::register_run_target(project, target)` registers or replaces a read-only,
+1 -1
View File
@@ -1 +1 @@
acb5f0a97061616d5bdcf7193d8fbe45c27818663dc0a9eab2e8203589492919
c4900df449a6a323bab1f4fe5fe76ee42e2839c9197636b7c1cef733dffc683d
@@ -1 +1 @@
aaa3cdd1595ba8aeabae6472f7f1ef635bd3115662d64d9284152747edfb6b16
0ea139d4a0d47072971c0b37b93085159e412554ffb45d19590bae4dc10f48b5
+1 -1
View File
@@ -1 +1 @@
7d09b4e0e3edfbed92be8efaba47d468b7707d6164abf45b0bec495ac454f4dd
4e4bc361395f6364e17211a9d3c32232a370ea1ee72e5197eab5f706ea465dc6
+1 -1
View File
@@ -1 +1 @@
929b309e182c078790940244429b9872d8c7723c0bc25a07b152c83300905c47
c39211d416f45dff0265216509070ffdecc4e0f4e738c88176cad5faaca01b65
+1 -1
View File
@@ -1 +1 @@
5099f6ceba7936a9b54340a4ce0c9b50a49962dd96053c850c010ea5c803c6a2
2bbb840e0bead664ccb467dbb299b269fc9a03ed4995c8d890ae195efe6e47a6
+1 -1
View File
@@ -1 +1 @@
6deb9876934b6e21bd99c791f2a86167f1d67e808fbb1cc514c7e621514b654c
b1fdf25b8307803b0e5e189bbe041cdb23b1e7b73abb4b76fd7a1b9ee48d87b0
+1 -1
View File
@@ -1 +1 @@
5c2f5a254b25ba018369366df87707b423e77a573fcb73cc3c74b1f321f5aa61
849d83a77d80e8d8a4f2c2801f4728300dd4504da411689834217b9973e582f4
+1 -1
View File
@@ -1 +1 @@
cb4ed7e9d793df4219e92edd5d64616db65411cb77e111df862ca8237f647efb
ff42121519f6796db56a71a504484b149592e9e28ed33c783d2e6141cfe84d6c
+1 -1
View File
@@ -1 +1 @@
f43f1e52813a5f992c3d06cbe03aa26092dd56db45322b090906dfbb8d08b1ab
0d5ed3d81ab5496b92e7b012ae6e1e765fb45f4f6b0613a03f2bc79029ed9397
+1 -1
View File
@@ -1 +1 @@
656b51f57467a75b5416575871a5722e4f44be98407f6bfd0e6fa1e44f0bfa80
dd34327c65747b1b57eef0854161837bd4e2843a59efec7730b81d8a2866a411
+1 -1
View File
@@ -1 +1 @@
bafe2cfc88d8c1272eea289d3d804287d56159b1c5893147b73a6be88e00370f
2141b215b141d82cc9a5cffa63c11e2b53e7bebcd31b93fb8d2db0ff5c2acd5b
+1 -1
View File
@@ -1 +1 @@
ee5d474028c7068b06771efa5e8e2fa710cd0108d53737a36d8bf9f0f3490c08
604a289139233c13108ff8aab448e4278604e5260d7b2485e3ace3bad9e855db
+1 -1
View File
@@ -1 +1 @@
867fc7bba0e795155e96c39f5a05cc3a08a3b6809c4e3922437e393d65f6d829
9efa3a6a41c05e81a50893a2cff79da6e8e5f97219ddfd54f7ed201270934bb5
+5 -1
View File
@@ -10,7 +10,7 @@ The gutter command appends the test name as a filter to `run.start`. The fake
executable prints a deliberately tiny `start NAME` / `pass NAME` protocol;
`test-results.parse` retains split lines and emits stable test ids. Replace
that protocol/parser and executable to integrate a real framework. This is an
API example, not a general test framework or a custom rerun-failed contract.
API example for integrating a test framework.
Build from the repository root:
@@ -23,3 +23,7 @@ cargo run -p ide-host --bin openidea-plugin -- check plugins/official/test-runne
Install the built package folder through Settings › Plugins › Install from
Disk. See the [plugin guide](../../README.md#api-14) and
[official/community policy](../../README.md#81-official-and-community-plugins).
`test-results.select-tests` returns the selected names as the complete argv for
`openidea-example-tests`, enabling Rerun Failed Tests and Run from the test tree.
It returns `None` for unknown configurations or paths this example cannot filter.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
b7aaac21c6c34d9d722aa23c13c66e61ef11028f038fa877fe67d03b03c18483
5f05e3696671258af5474b10abafd3cab48385493d5336061a9a8679307b56b4
@@ -5,6 +5,12 @@ while IFS= read -r line; do
'test '*) name=${line#test } ;;
*) continue ;;
esac
[ -z "$1" ] || [ "$1" = "$name" ] || continue
if [ "$#" -gt 0 ]; then
selected=0
for test in "$@"; do
[ "$test" != "$name" ] || selected=1
done
[ "$selected" -eq 1 ] || continue
fi
printf 'start %s\npass %s\n' "$name" "$name"
done < example.tests
+12
View File
@@ -48,6 +48,12 @@ impl LanguageFeatures for TestRunner {
}
}
impl TestResults for TestRunner {
fn select_tests(&mut self, config: &str, tests: &[Vec<String>]) -> Option<Vec<String>> {
if config != "tests" || tests.is_empty() || tests.iter().any(|p| p.len() != 1) {
return None;
}
Some(tests.iter().map(|p| p[0].clone()).collect())
}
fn parse(&mut self, _config: &str, chunk: &str) -> Vec<PluginTestEvent> {
self.pending.push_str(chunk);
let mut events = vec![];
@@ -92,6 +98,12 @@ mod tests {
mock.set_run_configurations(plugin.configurations("/project"));
plugin.run_command("test", &mock.invocation(), Some("adds")).unwrap();
assert_eq!(mock.configuration_runs()[0].2, vec!["adds"]);
assert_eq!(
plugin.select_tests("tests", &[vec!["adds".into()], vec!["subs".into()]]),
Some(vec!["adds".into(), "subs".into()])
);
assert_eq!(plugin.select_tests("unknown", &[vec!["adds".into()]]), None);
assert_eq!(plugin.select_tests("tests", &[vec!["suite".into(), "adds".into()]]), None);
assert!(plugin.parse("tests", "sta").is_empty());
let events = plugin.parse("tests", "rt adds\npass adds\n");
assert!(matches!(&events[..], [PluginTestEvent::TestStarted(_), PluginTestEvent::Passed(_)]));
+1 -1
View File
@@ -1 +1 @@
66e92c09c0050dbf4c302793eea5048e21d1c91b9b44811b5f7edbae6eaa9075
677ec8005d29d4727afbc4b8fba6afcbc26996f0db461d1e98a6e9e52b5f7fac
+6
View File
@@ -23,6 +23,12 @@ importers:
'@codemirror/lang-javascript':
specifier: ^6.2.5
version: 6.2.5
'@codemirror/lang-sass':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-vue':
specifier: ^0.1.3
version: 0.1.3
'@codemirror/language':
specifier: ^6.13.1
version: 6.13.1
@@ -158,6 +158,10 @@ pub trait RunConfigurations {
/// Parses bounded test output batches; register with test_results.
pub trait TestResults {
fn parse(&mut self, config: &str, chunk: &str) -> Vec<PluginTestEvent>;
/// Full replacement argv for test-tree name paths, or None when filtering is unsupported.
fn select_tests(&mut self, _config: &str, _tests: &[Vec<String>]) -> Option<Vec<String>> {
None
}
}
impl crate::Event {
+9
View File
@@ -1101,6 +1101,12 @@ pub mod __rt {
})
.collect()
}
pub fn select_tests<T: Plugin + crate::TestResults + Default>(
config: &str,
tests: &[Vec<String>],
) -> Option<Vec<String>> {
with::<T, _>(|p| p.select_tests(config, tests))
}
pub fn test_results<T: Plugin + crate::TestResults + Default>(
config: &str,
chunk: &str,
@@ -1321,6 +1327,9 @@ macro_rules! __test_results_exports {
fn parse(config: String, chunk: String) -> Vec<$crate::test_results_bindings::exports::openidea::plugin::test_results::TestEvent> {
$crate::__rt::test_results::<$plugin>(&config, &chunk)
}
fn select_tests(config: String, tests: Vec<Vec<String>>) -> Option<Vec<String>> {
$crate::__rt::select_tests::<$plugin>(&config, &tests)
}
}
$crate::test_results_bindings::__export_tests_world!($component with_types_in $crate::test_results_bindings);
};
@@ -53,3 +53,14 @@ fn run_targets_require_a_program_grant_and_are_project_scoped() {
host::unregister_run_target(&project, "ddev");
assert!(mock.run_targets().is_empty());
}
#[test]
fn parsers_can_keep_the_default_unsupported_test_selection() {
struct Parser;
impl TestResults for Parser {
fn parse(&mut self, _: &str, _: &str) -> Vec<PluginTestEvent> {
vec![]
}
}
assert_eq!(Parser.select_tests("tests", &[vec!["Suite".into(), "Test".into()]]), None);
}
+3
View File
@@ -47,4 +47,7 @@ interface test-results {
}
/// Parses one bounded output batch; failures fall back to built-in parsers.
parse: func(config: string, chunk: string) -> list<test-event>;
/// Selects test-tree name paths (parents then test), using the same config id as parse.
/// Returns the full replacement argument list, or none when filtering is unsupported.
select-tests: func(config: string, tests: list<list<string>>) -> option<list<string>>;
}
+1 -1
View File
@@ -220,7 +220,7 @@ async function smoke(language: string) {
// Rerun Failed Tests runs that one test only.
const rerunOutput: string[] = [];
const before = useRun.getState().sessions.find((s) => s.id === id)!.startedAt;
expect(rerunFailed(id), "Rerun Failed Tests is offered").toBe(true);
expect(await rerunFailed(id), "Rerun Failed Tests is offered").toBe(true);
const ended = () => {
const s = useRun.getState().sessions.find((s) => s.id === id)!;
return s.startedAt > before && s.endedAt !== null && s.endedAt >= s.startedAt;
+2
View File
@@ -14,6 +14,7 @@ use tauri::{Manager, State, Window};
pub struct AppInfo {
pub version: String,
pub platform: String,
pub windows_build_number: Option<u32>,
pub config_dir: ide_core::IpcPath,
/// Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges.
pub wayland: bool,
@@ -28,6 +29,7 @@ pub fn app_info(state: State<'_, AppState>) -> AppInfo {
AppInfo {
version: env!("CARGO_PKG_VERSION").into(),
platform: std::env::consts::OS.into(),
windows_build_number: ide_core::platform::windows_build_number(),
config_dir: state.config_dir.clone().into(),
#[cfg(target_os = "linux")]
wayland: crate::linux_window_state::is_wayland(),
+1
View File
@@ -514,6 +514,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
plugin::plugin_terminal_session_spawn,
plugin::plugin_terminal_session_close,
plugin::plugin_parse_test_results,
plugin::plugin_select_tests,
plugin::plugin_content_tab_action,
plugin::plugin_choose_program,
plugin::plugin_program,
+16
View File
@@ -963,6 +963,22 @@ pub fn plugin_content_tab_action(
state.plugins.content_tab_action(window.label(), &plugin_id, action)
}
/// Selects test-tree paths in an owned plugin test configuration.
#[tauri::command]
#[specta::specta]
pub async fn plugin_select_tests(
window: Window,
state: State<'_, AppState>,
config: String,
tests: Vec<Vec<String>>,
) -> IdeResult<Option<Vec<String>>> {
let plugins = state.plugins.clone();
let client = window.label().to_owned();
tokio::task::spawn_blocking(move || plugins.select_tests(&client, &config, tests))
.await
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?
}
/// Parses one batch of an owned test configuration's output.
#[tauri::command]
#[specta::specta]
+3
View File
@@ -1145,6 +1145,8 @@ export const commands = {
pluginTerminalSessionClose: (operation: string) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_terminal_session_close", { operation })),
/** Parses one batch of an owned test configuration's output. */
pluginParseTestResults: (config: string, chunk: string) => typedError<PluginTestEvent[] | null, IdeError>(__TAURI_INVOKE("plugin_parse_test_results", { config, chunk })),
/** Selects test-tree paths in an owned plugin test configuration. */
pluginSelectTests: (config: string, tests: string[][]) => typedError<string[] | null, IdeError>(__TAURI_INVOKE("plugin_select_tests", { config, tests })),
/** Dispatches a wasm content editor action through the owning plugin's event handler. */
pluginContentTabAction: (pluginId: string, action: ContentTabAction) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_content_tab_action", { pluginId, action })),
/** Re-approves a plugin's executable, globally or for this window's project. */
@@ -1861,6 +1863,7 @@ export type AndroidDevice = {
export type AppInfo = {
version: string,
platform: string,
windowsBuildNumber: number | null,
configDir: IpcPath,
/** Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges. */
wayland: boolean,
+6
View File
@@ -131,6 +131,12 @@ const OWN_MODES = [
extensions: ["astro"],
load: () => import("./astro").then((m) => m.astro()),
}),
// Overrides the language-data Vue mode so `<style lang="scss">` highlights.
LanguageDescription.of({
name: "Vue",
extensions: ["vue"],
load: () => import("./vue").then((m) => m.vueLanguage()),
}),
LanguageDescription.of({
name: "YAML",
alias: ["yml"],
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { findLanguageDescription, loadSupport } from "./languages";
describe("vue language mode", () => {
it('highlights SCSS inside <style lang="scss">', async () => {
const desc = findLanguageDescription("Vue", "/p/a.vue")!;
expect(desc?.name).toBe("Vue");
const support = await loadSupport(desc);
const src = `<template><div/></template>\n` + `<style lang="scss">\n.a { .b { color: red; } $c: 1; }\n</style>`;
const tree = support!.language.parser.parse(src).toString();
// SCSS-only syntax that plain CSS can't parse: a nested rule and a $-variable.
expect(tree).toContain("SassVariableName");
// The nested .b ruleset only parses under the Sass grammar.
expect(tree).toMatch(/RuleSet\(ClassSelector.*RuleSet\(ClassSelector/s);
});
it("still parses a plain <style> block as CSS", async () => {
const support = await loadSupport(findLanguageDescription("Vue", "/p/a.vue")!);
const tree = support!.language.parser
.parse(`<template><div/></template><style>.a { color: red; }</style>`)
.toString();
expect(tree).toContain("StyleSheet");
expect(tree).toContain("Declaration");
expect(tree).not.toContain("SassVariableName");
});
});
+27
View File
@@ -0,0 +1,27 @@
/**
* Vue single-file components: `@codemirror/lang-vue`'s parser over an HTML
* base that also highlights `<style lang="scss|sass">` with the Sass grammar —
* stock `vue()` parses every `<style>` as plain CSS, which loses the SCSS
* colours.
*/
import { LanguageSupport } from "@codemirror/language";
import { html } from "@codemirror/lang-html";
import { sassLanguage } from "@codemirror/lang-sass";
import { vue } from "@codemirror/lang-vue";
export function vueLanguage(): LanguageSupport {
return vue({
base: html({
selfClosingTags: true,
// `<style>` without `lang` (or with `lang="css"`) keeps HTML's own CSS
// nesting; only an explicit SCSS/Sass dialect re-parses it.
nestedLanguages: [
{
tag: "style",
attrs: (a) => a.lang === "scss" || a.lang === "sass",
parser: sassLanguage.parser,
},
],
}),
});
}
+9 -3
View File
@@ -180,15 +180,21 @@
color: var(--fg-muted);
}
.modelEffortPopup {
width: min(300px, calc(100vw - 24px));
padding: 4px 12px 12px;
width: min(340px, calc(100vw - 24px));
padding: 12px 0 6px;
}
.effortBlock {
padding: 0 12px 8px;
}
/* The list takes the height of its models; with many it scrolls inside the popup. */
.modelEffortPopup .modelList {
max-height: 216px;
}
.effortHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
}
.effortValue {
color: var(--fg-strong);
+18 -2
View File
@@ -234,8 +234,24 @@ describe("model and effort button", () => {
expect(slider).toHaveAttribute("aria-valuetext", "high");
fireEvent.change(slider, { target: { value: "3" } });
expect(onChange).toHaveBeenLastCalledWith("reasoning_effort", "xhigh");
fireEvent.click(within(dialog).getByRole("button", { name: "Model" }));
fireEvent.click(screen.getByRole("option", { name: /GPT-6 Sol/ }));
// The model list is in the popup itself, not behind a second dropdown.
expect(within(dialog).queryByRole("button", { name: "Model" })).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("option", { name: /GPT-6 Sol/ }));
expect(onChange).toHaveBeenLastCalledWith("model", "gpt-6-sol");
expect(screen.getByRole("dialog", { name: "Model & effort settings" })).toBeInTheDocument();
});
it("filters the model list and picks the highlighted model with the keyboard", () => {
const onChange = vi.fn();
render(<ConfigPickers config={copilotConfig()} onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: "Model" }));
const dialog = screen.getByRole("dialog", { name: "Model & effort settings" });
const search = within(dialog).getByPlaceholderText("Search…");
expect(search).toHaveFocus();
fireEvent.change(search, { target: { value: "sol" } });
expect(within(dialog).getAllByRole("option")).toHaveLength(1);
fireEvent.keyDown(search, { key: "ArrowDown" });
fireEvent.keyDown(search, { key: "Enter" });
expect(onChange).toHaveBeenLastCalledWith("model", "gpt-6-sol");
});
+15 -9
View File
@@ -9,7 +9,7 @@ import clsx from "clsx";
import type { AgentConfigOption } from "@/bindings";
import { useTx } from "@/platform/i18n";
import { Icon, IconButton, Popup, Select, T, Toggle } from "@/ui";
import type { SelectOption } from "@/ui/Select";
import { SelectListbox, type SelectOption } from "@/ui/Select";
import { mainOptions, modeIcon } from "./composerModel";
import styles from "./Composer.module.css";
@@ -241,15 +241,21 @@ function ModelEffortPicker({
aria-label={tx("Model & effort settings")}
className={styles.modelEffortPopup}
>
<div className={styles.settingsRow}>
<span className={styles.settingsLabel}>{tx(model.name)}</span>
<ConfigPicker option={model} value={modelValue} onChange={onChange} framed />
<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>
<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)} />
<SelectListbox
aria-label={tx(model.name)}
value={modelValue}
options={configPickerOptions(model)}
searchable
listClassName={styles.modelList}
onChoose={(v) => onChange(model.id, v)}
/>
</Popup>
</>
);
+2 -6
View File
@@ -1,5 +1,5 @@
import { beforeEach, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { AgentConfigOption, AgentProvider, NewAgentTask } from "@/bindings";
import { HomeEditor } from "./HomeEditor";
import { HOME, savedDraft, updateComposer, useAgents } from "./store";
@@ -64,11 +64,7 @@ beforeEach(() => {
});
async function chooseModel(model: string) {
const button = screen.getByRole("button", { name: "Model" });
if (button.getAttribute("aria-haspopup") === "dialog") {
fireEvent.click(button);
fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Model" }));
} else fireEvent.click(button);
fireEvent.click(screen.getByRole("button", { name: "Model" }));
fireEvent.click(screen.getByRole("option", { name: model }));
fireEvent.pointerDown(document.body);
}
+5
View File
@@ -118,6 +118,11 @@
}
.conversationRow {
min-width: 0;
contain-intrinsic-size: auto 120px;
}
/* Laid out once, an off-screen row skips rendering at its remembered size (`useOffscreenRows`). */
.conversationRow[data-measured] {
content-visibility: auto;
}
/* Keep turns distinct and the work within a turn close to its author. */
.stream > * + * {
+159 -45
View File
@@ -1,15 +1,18 @@
/** An agent task as an editor tab: the conversation, a composer and the task's changed files. */
import {
createContext,
Fragment,
memo,
startTransition,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
type RefObject,
} from "react";
import clsx from "clsx";
@@ -189,16 +192,18 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
docked={docked}
/>
{view && <SubagentBar task={task} path={path} cards={cards} onNavigate={setPath} />}
<Stream
key={view ?? ""}
task={task}
entries={viewEntries ?? null}
subagent={view ? (cards.at(-1) ?? null) : null}
onOpenSubagent={openSubagent}
finding={finding && shown}
findFocus={findFocus}
onCloseFind={() => setFinding(false)}
/>
<FoldedContent.Provider value={finding && shown}>
<Stream
key={view ?? ""}
task={task}
entries={viewEntries ?? null}
subagent={view ? (cards.at(-1) ?? null) : null}
onOpenSubagent={openSubagent}
finding={finding && shown}
findFocus={findFocus}
onCloseFind={() => setFinding(false)}
/>
</FoldedContent.Provider>
{view ? (
<div className={styles.readOnly}>
<T>Subagents take their instructions from the agent that started them.</T>
@@ -457,6 +462,65 @@ function SubagentBar({
);
}
/**
* Lets the rows of a long conversation skip rendering while off screen (`data-measured`, see
* the CSS), so showing the tab again or restyling it costs what is on screen, not the whole
* history. A row skips only once it was laid out: it keeps its real height, and scrolling up
* doesn't jump (WebKit has no scroll anchoring). A new width lays them out again once it
* settles; Find in Conversation (`enabled` false) needs every row laid out.
*/
function useOffscreenRows(ref: RefObject<HTMLElement | null>, visible: boolean, enabled: boolean) {
// The width the marked rows were laid out at.
const width = useRef(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
const unmark = () => {
for (const row of el.querySelectorAll(`:scope > .${styles.conversationRow}[data-measured]`)) {
row.removeAttribute("data-measured");
}
};
if (!enabled) return unmark();
if (!visible) return;
let frame = 0;
let timer: number | undefined;
const mark = () => {
cancelAnimationFrame(frame);
// The next frame lays the new rows out (and remembers their size); mark them after it.
frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
for (const row of el.querySelectorAll(`:scope > .${styles.conversationRow}:not([data-measured])`)) {
row.setAttribute("data-measured", "");
}
});
});
};
const remeasure = () => {
if (el.clientWidth === width.current) return;
width.current = el.clientWidth;
unmark();
};
remeasure();
mark();
const rows = new MutationObserver(mark);
rows.observe(el, { childList: true });
const resize = new ResizeObserver(() => {
window.clearTimeout(timer);
timer = window.setTimeout(() => {
remeasure();
mark();
}, 200);
});
resize.observe(el);
return () => {
cancelAnimationFrame(frame);
window.clearTimeout(timer);
rows.disconnect();
resize.disconnect();
};
}, [ref, visible, enabled]);
}
interface StreamProps {
task: AgentTask;
entries: AgentEntry[] | null;
@@ -540,6 +604,7 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
}, [start]);
// Hidden behind another tab, it has no layout to scroll; shown again, it follows the end again.
const visible = useEditorShown();
useOffscreenRows(ref, visible, !finding);
/** A diagram that drew after the last streamed chunk grows the conversation: keep following. */
const onDiagramDrawn = useCallback(() => {
const el = ref.current;
@@ -1015,19 +1080,23 @@ const Entry = memo(function Entry({
case "thought":
return (
<div className={styles.process}>
<details className={styles.thought}>
<summary>
{working ? (
<Spinner size={12} />
) : (
<Icon name="chevron-right" size={12} className={styles.thoughtChevron} />
)}
<T>{working ? "Thinking" : "Reasoning"}</T>
</summary>
<Fold
className={styles.thought}
summary={
<summary>
{working ? (
<Spinner size={12} />
) : (
<Icon name="chevron-right" size={12} className={styles.thoughtChevron} />
)}
<T>{working ? "Thinking" : "Reasoning"}</T>
</summary>
}
>
<div className={styles.thoughtBody}>
<Markdown text={entry.text} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />
</div>
</details>
</Fold>
</div>
);
case "tool":
@@ -1383,14 +1452,18 @@ const ProcessRun = memo(function ProcessRun({
tools.length > 0 ? `${tools.length} ${tx(tools.length === 1 ? "tool call" : "tool calls")}` : null,
].filter(Boolean);
return (
<details className={clsx(styles.processRun, styles.process)}>
<summary className={styles.processSummary}>
<Icon name="chevron-right" size={12} className={styles.processChevron} />
{working && <Spinner size={12} />}
<span>{parts.join(" · ")}</span>
{added > 0 && <span className={styles.added}>+{added}</span>}
{removed > 0 && <span className={styles.removed}>−{removed}</span>}
</summary>
<Fold
className={clsx(styles.processRun, styles.process)}
summary={
<summary className={styles.processSummary}>
<Icon name="chevron-right" size={12} className={styles.processChevron} />
{working && <Spinner size={12} />}
<span>{parts.join(" · ")}</span>
{added > 0 && <span className={styles.added}>+{added}</span>}
{removed > 0 && <span className={styles.removed}>−{removed}</span>}
</summary>
}
>
<div className={styles.processBody}>
{row.entries.map(({ index, entry }) =>
entry.type === "tool" ? (
@@ -1400,10 +1473,38 @@ const ProcessRun = memo(function ProcessRun({
),
)}
</div>
</details>
</Fold>
);
});
/** Set while Find in Conversation is open: folded sections render their content, so it is found. */
const FoldedContent = createContext(false);
/**
* A collapsed section that renders its content only once opened: most of a long conversation
* stays folded, and keeping it out of the DOM keeps showing and restyling the tab cheap.
*/
function Fold({
className,
open,
summary,
children,
}: {
className?: string;
open?: boolean;
summary: ReactElement;
children: ReactNode;
}) {
const always = useContext(FoldedContent);
const [expanded, setExpanded] = useState(!!open);
return (
<details className={className} open={open || undefined} onToggle={(e) => setExpanded(e.currentTarget.open)}>
{summary}
{(expanded || open || always) && children}
</details>
);
}
const ToolEntry = memo(function ToolEntry({
task,
entry,
@@ -1443,13 +1544,18 @@ const ToolEntry = memo(function ToolEntry({
);
return (
<div className={styles.process}>
<details className={styles.toolDetails} open={liveTool(entry) || undefined}>
<summary className={styles.tool}>
<Icon name="chevron-right" size={12} className={styles.toolChevron} />
{head}
</summary>
<Fold
className={styles.toolDetails}
open={liveTool(entry)}
summary={
<summary className={styles.tool}>
<Icon name="chevron-right" size={12} className={styles.toolChevron} />
{head}
</summary>
}
>
<pre className={styles.output}>{entry.output}</pre>
</details>
</Fold>
</div>
);
});
@@ -1532,22 +1638,30 @@ function SubagentCard({
</div>
)}
{entry.prompt && (
<details className={styles.subagentSection}>
<summary>
<T>Task</T>
</summary>
<Fold
className={styles.subagentSection}
summary={
<summary>
<T>Task</T>
</summary>
}
>
<div className={styles.thoughtText}>{entry.prompt}</div>
</details>
</Fold>
)}
{entry.reply && (
<details className={styles.subagentSection}>
<summary>
<T>Result</T>
</summary>
<Fold
className={styles.subagentSection}
summary={
<summary>
<T>Result</T>
</summary>
}
>
<div className={styles.subagentReply}>
<AgentReply text={entry.reply} cwd={task.cwd} task={task} onDiagramDrawn={onDiagramDrawn} />
</div>
</details>
</Fold>
)}
</div>
);
+31 -1
View File
@@ -10,6 +10,7 @@ import { entriesKey, SESSION_KIND, sessionKey, type SubagentEntry } from "./mode
import { disconnect, useAgents } from "./store";
import { taskActions } from "./taskActions";
import { replyActions } from "./replyActions";
import { focusedSession } from "./sessionFocus";
import styles from "./Session.module.css";
import tagStyles from "@/ui/InlineTag.module.css";
@@ -243,8 +244,37 @@ describe("process folding", () => {
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
const summary = await screen.findByText("Reasoning · 2 tool calls");
const fold = summary.closest("details")!;
expect(fold.open).toBe(false);
// Folded content is rendered once opened, a level at a time.
const open = (details: HTMLDetailsElement) => {
details.open = true;
fireEvent(details, new Event("toggle"));
};
expect(screen.queryByText("Reasoning")).toBeNull();
open(fold);
const thought = (await screen.findAllByText("Reasoning"))[0]!.closest("details")!;
expect(screen.queryByText("think one")).toBeNull();
open(thought);
expect(await screen.findByText("think one")).toBeInTheDocument();
});
});
describe("folded content", () => {
it("is rendered for Find in Conversation, so its text is found", async () => {
backend.entries.set("A", [
tool("one", { output: "needle in a tool output" }),
tool("two"),
{ type: "agent", text: "Answer A" },
]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
const summary = await screen.findByText("2 tool calls");
expect(screen.queryByText("needle in a tool output")).toBeNull();
fireEvent.focus(summary);
act(() => focusedSession()!.openFind());
expect(await screen.findByText("needle in a tool output")).toBeInTheDocument();
expect(summary.closest("details")!.open).toBe(false);
expect(screen.getByText("think one")).toBeInTheDocument();
});
});
@@ -65,7 +65,8 @@
flex: 1;
min-height: 0;
}
.body > [role="toolbar"] {
.body > [role="toolbar"],
.body > .summary {
flex: 0 0 auto;
}
.row {
@@ -1,6 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Diagnostic, FileProblems } from "@/bindings";
const state = vi.hoisted(() => ({
projectChanged: () => {},
remoteChanged: () => {},
report: vi.fn(),
}));
const reports = vi.hoisted(() => [] as [boolean, FileProblems[]][]);
vi.mock("@/platform/ipc", async (importOriginal) => ({
...(await importOriginal<typeof import("@/platform/ipc")>()),
@@ -8,12 +13,29 @@ vi.mock("@/platform/ipc", async (importOriginal) => ({
commands: {
mcpReportProblems: async (reset: boolean, files: FileProblems[]) => {
reports.push([reset, files]);
await state.report();
},
},
}));
vi.mock("@/platform/project", () => ({
useProject: { getState: () => ({ project: { root: "/p" } }), subscribe: () => () => {} },
useProject: {
getState: () => ({ project: { root: "/p" } }),
subscribe: (fn: (s: unknown, prev: unknown) => void) => {
state.projectChanged = () => fn({ project: { root: "/p" } }, { project: null });
return () => {};
},
},
}));
vi.mock("@/platform/remote", () => ({
isRemoteWindow: () => true,
useRemote: {
subscribe: (fn: (s: unknown, prev: unknown) => void) => {
state.remoteChanged = () => fn({ session: {} }, { session: {} });
return () => {};
},
},
}));
const { applyDiagnosticsEvent, clearProblems } = await import("./problemsStore");
@@ -35,6 +57,7 @@ describe("reportProblems", () => {
vi.useFakeTimers();
clearProblems();
reports.length = 0;
state.report.mockReset();
applyDiagnosticsEvent("/p/a.rs", "ra", [d(1), d(2)]);
stop = reportProblems();
});
@@ -43,6 +66,35 @@ describe("reportProblems", () => {
vi.useRealTimers();
});
it.each(["projectChanged", "remoteChanged"] as const)("resends the full snapshot after %s", async (change) => {
await vi.advanceTimersByTimeAsync(1000);
state[change]();
await vi.advanceTimersByTimeAsync(1000);
expect(summary()).toEqual([
[true, [["/p/a.rs", 2]]],
[true, [["/p/a.rs", 2]]],
]);
});
it("retries a failed delta with the full snapshot", async () => {
await vi.advanceTimersByTimeAsync(1000);
state.report.mockRejectedValueOnce(new Error("disconnected"));
applyDiagnosticsEvent("/p/b.rs", "ra", [d(0)]);
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(1000);
expect(summary()).toEqual([
[true, [["/p/a.rs", 2]]],
[false, [["/p/b.rs", 1]]],
[
true,
[
["/p/a.rs", 2],
["/p/b.rs", 1],
],
],
]);
});
it("sends everything first, then only the files that changed once diagnostics settle", async () => {
await vi.advanceTimersByTimeAsync(1000);
applyDiagnosticsEvent("/p/b.rs", "ra", [d(0)]);
+10 -2
View File
@@ -1,7 +1,7 @@
/** Reports changed files from the Problems view to the built-in MCP host. */
import { commands, call, type FileProblems } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { isRemoteWindow } from "@/platform/remote";
import { useRemote } from "@/platform/remote";
import { useProblems, type ProblemsMap } from "./problemsStore";
const REPORT_MS = 1000;
@@ -13,7 +13,7 @@ export function reportProblems(): () => void {
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = () => {
timer = null;
if (isRemoteWindow() || !useProject.getState().project) return;
if (!useProject.getState().project) return;
const map = useProblems.getState().map;
const files: FileProblems[] = [];
for (const [path, sources] of map)
@@ -42,8 +42,16 @@ export function reportProblems(): () => void {
schedule();
}
});
const offRemote = useRemote.subscribe((s, prev) => {
if (s.session !== prev.session) {
reset = true;
reported = new Map();
schedule();
}
});
return () => {
disposed = true;
offRemote();
offProject();
off();
if (timer) clearTimeout(timer);
+9 -1
View File
@@ -2,7 +2,7 @@
import { activeEditor, onEditorEvent, openEditorPaths, useEditorRegistry } from "@/editor/api";
import { commands, call, onIdeEvent, ideEventsReady } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { isRemoteWindow } from "@/platform/remote";
import { isRemoteWindow, useRemote } from "@/platform/remote";
import { registerOverlay } from "@/workbench/contributions";
import { McpConfirmHost } from "./ConfirmDialog";
@@ -85,6 +85,13 @@ export function activate(): () => void {
schedule();
}
});
const offRemote = useRemote.subscribe((s, prev) => {
if (s.session !== prev.session) {
lastPaths = undefined;
lastEditor = undefined;
schedule();
}
});
window.addEventListener("focus", schedule);
window.addEventListener("blur", schedule);
return () => {
@@ -96,6 +103,7 @@ export function activate(): () => void {
window.removeEventListener("beforeunload", unload);
offRegistry();
offEvents();
offRemote();
offProject();
window.removeEventListener("focus", schedule);
window.removeEventListener("blur", schedule);
+18 -1
View File
@@ -7,6 +7,7 @@ const state = vi.hoisted(() => ({
registryChanged: () => {},
editorChanged: () => {},
projectChanged: () => {},
remoteChanged: () => {},
reportOpen: vi.fn(async () => undefined),
reportFocused: vi.fn(async () => undefined),
}));
@@ -44,7 +45,15 @@ vi.mock("@/platform/project", () => ({
},
},
}));
vi.mock("@/platform/remote", () => ({ isRemoteWindow: () => state.remote }));
vi.mock("@/platform/remote", () => ({
isRemoteWindow: () => state.remote,
useRemote: {
subscribe: (fn: (s: unknown, prev: unknown) => void) => {
state.remoteChanged = () => fn({ session: {} }, { session: {} });
return () => {};
},
},
}));
vi.mock("@/platform/ipc", () => ({
call: (promise: Promise<unknown>) => promise,
onIdeEvent: () => () => {},
@@ -106,6 +115,14 @@ describe("MCP editor reports", () => {
expect(state.reportOpen).toHaveBeenCalledTimes(2);
expect(state.reportFocused).toHaveBeenCalledTimes(2);
});
it("resends unchanged editors after reconnecting at the same root", async () => {
state.remote = true;
await vi.advanceTimersByTimeAsync(300);
state.remoteChanged();
await vi.advanceTimersByTimeAsync(300);
expect(state.reportOpen).toHaveBeenCalledTimes(2);
expect(state.reportFocused).toHaveBeenCalledTimes(2);
});
it("forwards remote editor reports and stops after disposal", async () => {
state.remote = true;
await vi.advanceTimersByTimeAsync(300);
+3
View File
@@ -88,6 +88,7 @@ export interface PluginsBackend {
answerPrompt(promptId: string, answer: string[] | null): Promise<void>;
/** This window's answer to a plugin's `commitRequested`. */
answerCommand(requestId: string, answer: CommandAnswer): Promise<void>;
selectTests(config: string, tests: string[][]): Promise<string[] | null>;
parseTestResults(config: string, chunk: string): Promise<import("@/bindings").PluginTestEvent[] | null>;
closeTerminalSession(operation: string): Promise<void>;
terminalSessionSpawn(
@@ -240,6 +241,7 @@ export const tauriPluginsBackend: PluginsBackend = {
answerPrompt: async (promptId, answer) => {
await commands.pluginAnswerPrompt(promptId, answer);
},
selectTests: (config, tests) => call(commands.pluginSelectTests(config, tests)),
parseTestResults: (config, chunk) => call(commands.pluginParseTestResults(config, chunk)),
closeTerminalSession: async (operation) => {
await call(commands.pluginTerminalSessionClose(operation));
@@ -666,6 +668,7 @@ export function createMemoryPluginsBackend(
throw new Error("Terminal unavailable");
},
parseTestResults: async () => null,
selectTests: async () => null,
contentTabAction: async () => {},
answerCommit: async () => {},
commitChecks: async () => [],
+2 -1
View File
@@ -4,7 +4,7 @@
* workbench events it subscribed to (`events.subscribe`), reported in
* batches.
*/
import { providePluginTestParser } from "@/workbench/contributions";
import { providePluginTestParser, providePluginTestSelector } from "@/workbench/contributions";
import {
contentTabData,
contentTabKey,
@@ -639,6 +639,7 @@ export function startRuntimeUi(): () => void {
const disposers = [
startRunTargets(),
stopParser,
providePluginTestSelector((config, tests) => pluginsBackend().selectTests(config, tests)),
projectStatusItems(),
trackWorkbenchEvents(),
refreshOnProjectChange(),
+33 -3
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { RunConfig } from "@/platform/ipc";
import type { RunTargetValid } from "@/platform/remoteSettings";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { EmptyState, IconButton, SplitGroup, SplitHandle, SplitPanel, Tabs, type TabItem, T } from "@/ui";
@@ -11,6 +12,8 @@ import { runDirectory } from "./navigate";
import { remoteLabel } from "./remote";
import {
acceptsInput,
baseConfigOf,
canRunSelectedTests,
clearConsole,
closeSessionTab,
closeStdin,
@@ -29,6 +32,7 @@ import {
type RunSession,
isLive,
} from "./store";
import { rerunFailedConfigs } from "./testing/rerun";
import { TestTreeView, formatDuration } from "./TestTreeView";
import { StackTracePanel, closeStackTrace, useStackTraces, type StackTraceTab } from "./stackTrace";
import { showApplicationUpdate, useApplicationUpdates } from "./applicationUpdate";
@@ -77,6 +81,31 @@ export function SessionPanel({
const [findOpen, setFindOpen] = useState(false);
const running = isLive(session);
const cwd = runDirectory(session.config.cwd);
const baseConfig = useRun(() => baseConfigOf(session));
// Test results mutate in place; their version refreshes the selection.
const failedSelection = useMemo(
() => (running ? [] : rerunFailedConfigs(baseConfig, failedTests(session.id))),
[running, baseConfig, session.id, version], // eslint-disable-line react-hooks/exhaustive-deps
);
const [resolvedSelection, setResolvedSelection] = useState<{
request: Promise<RunConfig[]>;
supported: boolean;
} | null>(null);
useEffect(() => {
let current = true;
if (failedSelection instanceof Promise) {
void failedSelection.then((configs) => {
if (current) setResolvedSelection({ request: failedSelection, supported: configs.length > 0 });
});
}
return () => {
current = false;
};
}, [failedSelection]);
const canRerunFailed =
failedSelection instanceof Promise
? resolvedSelection?.request === failedSelection && resolvedSelection.supported
: failedSelection.length > 0;
// Selecting a test with captured output shows just that output (like IDEA).
const testModel = useMemo(() => {
@@ -164,8 +193,8 @@ export function SessionPanel({
tone="danger"
title="Rerun Failed Tests"
shortcut={shortcutLabel("run.rerunFailed")}
disabled={running || failedTests(session.id).length === 0}
onClick={() => rerunFailed(session.id)}
disabled={!canRerunFailed}
onClick={() => void rerunFailed(session.id)}
/>
)}
<IconButton
@@ -226,6 +255,7 @@ export function SessionPanel({
running={running}
selected={selectedTest}
onSelect={setSelectedTest}
canRunTests={(ids) => canRunSelectedTests(session.id, ids)}
onRunTests={(ids, debug) => void runSelectedTests(session.id, ids, debug)}
/>
</SplitPanel>
+30 -2
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { ContextMenuHost } from "@/ui";
import { TestResults } from "./testing";
import { TestTreeView } from "./TestTreeView";
@@ -25,7 +25,10 @@ function results() {
return r;
}
function Host(props: { onRunTests?: (ids: string[], debug: boolean) => void }) {
function Host(props: {
onRunTests?: (ids: string[], debug: boolean) => void;
canRunTests?: (ids: string[]) => boolean | Promise<boolean>;
}) {
return (
<>
<TestTreeView
@@ -83,4 +86,29 @@ describe("TestTreeView", () => {
expect.objectContaining({ left: { text: "1", label: "Expected" }, right: { text: "2", label: "Actual" } }),
);
});
it.each([true, false])(
"enables Run and Debug only when the async selector returns support (%s)",
async (supported) => {
let resolve!: (supported: boolean) => void;
const selection = new Promise<boolean>((done) => {
resolve = done;
});
const canRunTests = vi.fn(() => selection);
const onRunTests = vi.fn();
render(<Host onRunTests={onRunTests} canRunTests={canRunTests} />);
fireEvent.contextMenu(screen.getByText("subs()"));
expect(screen.getByRole("menuitem", { name: "Run 'subs()'" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("menuitem", { name: "Debug 'subs()'" })).toHaveAttribute("aria-disabled", "true");
expect(canRunTests).toHaveBeenCalledWith(["MathTest\u0000subs()"]);
await act(async () => resolve(supported));
for (const label of ["Run 'subs()'", "Debug 'subs()'"]) {
expect(screen.getByRole("menuitem", { name: label }).hasAttribute("aria-disabled")).toBe(!supported);
}
if (supported) {
fireEvent.click(screen.getByText("Run 'subs()'"));
expect(onRunTests).toHaveBeenCalledWith(["MathTest\u0000subs()"], false);
}
},
);
});
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more