mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-09 17:20:38 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
440f5384ad | ||
|
|
13b590f2c7 | ||
|
|
8d5504b376 | ||
|
|
60b19b2ffb | ||
|
|
73b3a99699 | ||
|
|
95843ea12d | ||
|
|
3dac5b8bdc | ||
|
|
fd8b4ff14c | ||
|
|
f70eb13f75 | ||
|
|
337bf4b1ca | ||
|
|
047cac209b | ||
|
|
c5647a5934 | ||
|
|
1f2d86f793 | ||
|
|
998b478de3 |
No files matched your search
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()])
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
acb5f0a97061616d5bdcf7193d8fbe45c27818663dc0a9eab2e8203589492919
|
||||
c4900df449a6a323bab1f4fe5fe76ee42e2839c9197636b7c1cef733dffc683d
|
||||
@@ -1 +1 @@
|
||||
aaa3cdd1595ba8aeabae6472f7f1ef635bd3115662d64d9284152747edfb6b16
|
||||
0ea139d4a0d47072971c0b37b93085159e412554ffb45d19590bae4dc10f48b5
|
||||
+1
-1
@@ -1 +1 @@
|
||||
7d09b4e0e3edfbed92be8efaba47d468b7707d6164abf45b0bec495ac454f4dd
|
||||
4e4bc361395f6364e17211a9d3c32232a370ea1ee72e5197eab5f706ea465dc6
|
||||
@@ -1 +1 @@
|
||||
929b309e182c078790940244429b9872d8c7723c0bc25a07b152c83300905c47
|
||||
c39211d416f45dff0265216509070ffdecc4e0f4e738c88176cad5faaca01b65
|
||||
+1
-1
@@ -1 +1 @@
|
||||
5099f6ceba7936a9b54340a4ce0c9b50a49962dd96053c850c010ea5c803c6a2
|
||||
2bbb840e0bead664ccb467dbb299b269fc9a03ed4995c8d890ae195efe6e47a6
|
||||
+1
-1
@@ -1 +1 @@
|
||||
6deb9876934b6e21bd99c791f2a86167f1d67e808fbb1cc514c7e621514b654c
|
||||
b1fdf25b8307803b0e5e189bbe041cdb23b1e7b73abb4b76fd7a1b9ee48d87b0
|
||||
+1
-1
@@ -1 +1 @@
|
||||
5c2f5a254b25ba018369366df87707b423e77a573fcb73cc3c74b1f321f5aa61
|
||||
849d83a77d80e8d8a4f2c2801f4728300dd4504da411689834217b9973e582f4
|
||||
+1
-1
@@ -1 +1 @@
|
||||
cb4ed7e9d793df4219e92edd5d64616db65411cb77e111df862ca8237f647efb
|
||||
ff42121519f6796db56a71a504484b149592e9e28ed33c783d2e6141cfe84d6c
|
||||
@@ -1 +1 @@
|
||||
f43f1e52813a5f992c3d06cbe03aa26092dd56db45322b090906dfbb8d08b1ab
|
||||
0d5ed3d81ab5496b92e7b012ae6e1e765fb45f4f6b0613a03f2bc79029ed9397
|
||||
+1
-1
@@ -1 +1 @@
|
||||
656b51f57467a75b5416575871a5722e4f44be98407f6bfd0e6fa1e44f0bfa80
|
||||
dd34327c65747b1b57eef0854161837bd4e2843a59efec7730b81d8a2866a411
|
||||
+1
-1
@@ -1 +1 @@
|
||||
bafe2cfc88d8c1272eea289d3d804287d56159b1c5893147b73a6be88e00370f
|
||||
2141b215b141d82cc9a5cffa63c11e2b53e7bebcd31b93fb8d2db0ff5c2acd5b
|
||||
+1
-1
@@ -1 +1 @@
|
||||
ee5d474028c7068b06771efa5e8e2fa710cd0108d53737a36d8bf9f0f3490c08
|
||||
604a289139233c13108ff8aab448e4278604e5260d7b2485e3ace3bad9e855db
|
||||
+1
-1
@@ -1 +1 @@
|
||||
867fc7bba0e795155e96c39f5a05cc3a08a3b6809c4e3922437e393d65f6d829
|
||||
9efa3a6a41c05e81a50893a2cff79da6e8e5f97219ddfd54f7ed201270934bb5
|
||||
@@ -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.
|
||||
BIN
Binary file not shown.
+1
-1
@@ -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
|
||||
@@ -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
@@ -1 +1 @@
|
||||
66e92c09c0050dbf4c302793eea5048e21d1c91b9b44811b5f7edbae6eaa9075
|
||||
677ec8005d29d4727afbc4b8fba6afcbc26996f0db461d1e98a6e9e52b5f7fac
|
||||
Generated
+6
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 > * + * {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => [],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in new issue
Block a user