mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:20:15 -10:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eff19e2ffa | ||
|
|
dbbc70d71b | ||
|
|
03073d83e5 | ||
|
|
2dda0be909 | ||
|
|
46da4e8f5f | ||
|
|
39e884a07e | ||
|
|
129f0cc0d0 | ||
|
|
adc252ccd4 | ||
|
|
7a9baac475 | ||
|
|
20fe72de09 | ||
|
|
d7edf8bded |
No files matched your search
@@ -22,8 +22,6 @@ 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
|
||||
|
||||
+35
-54
@@ -100,7 +100,7 @@ struct Shared {
|
||||
providers: RwLock<Vec<provider::ProviderSpec>>,
|
||||
/// Last probe of each provider.
|
||||
probes: Mutex<HashMap<String, AgentProbe>>,
|
||||
/// One pre-session lookup of commands and settings per provider, including failed attempts.
|
||||
/// One pre-session command lookup 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_settings(self: &Arc<Self>, spec: provider::ProviderSpec) {
|
||||
async fn fetch_commands(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, options)) =
|
||||
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
|
||||
if let Ok(commands) =
|
||||
native::claude::commands(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 || {
|
||||
shared.remember_discovered(&spec.id, &options, &commands);
|
||||
shared.emit(AgentEvent::Providers);
|
||||
if shared.known_commands(&spec.id).is_empty() {
|
||||
shared.remember(&spec.id, None, Some(&commands), None);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -187,10 +187,6 @@ 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()
|
||||
}
|
||||
@@ -203,52 +199,37 @@ impl Shared {
|
||||
commands: Option<&[AgentCommand]>,
|
||||
model: Option<&str>,
|
||||
) {
|
||||
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);
|
||||
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();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !c.is_empty() && known.config != c {
|
||||
known.config = c.to_vec();
|
||||
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());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
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)
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
serde_json::to_vec_pretty(&*cache)
|
||||
};
|
||||
if let Ok(bytes) = snapshot
|
||||
&& let Err(e) = write_file(&self.paths.models_file, &bytes)
|
||||
{
|
||||
tracing::warn!("saving agent settings: {e}");
|
||||
@@ -477,10 +458,10 @@ impl AgentManager {
|
||||
// merged without duplicating what `availableCommands` or the disk offer.
|
||||
let mut offered = offered;
|
||||
if task.is_none()
|
||||
&& (offered.is_empty() || !self.shared.has_config(&provider))
|
||||
&& offered.is_empty()
|
||||
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
|
||||
{
|
||||
self.shared.fetch_settings(spec).await;
|
||||
self.shared.fetch_commands(spec).await;
|
||||
offered = self.shared.known_commands(&provider);
|
||||
}
|
||||
for &(name, description) in builtin_commands(&provider) {
|
||||
|
||||
@@ -69,9 +69,8 @@ pub(crate) fn connect(
|
||||
Ok(super::serve(shim, client))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// Discover commands without a prompt, ACP session, or pending session file.
|
||||
pub(crate) async fn commands(spec: ProviderSpec, cwd: PathBuf) -> Result<Vec<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())?;
|
||||
@@ -80,52 +79,28 @@ pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<(Vec<Va
|
||||
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();
|
||||
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()))
|
||||
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())
|
||||
})
|
||||
.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.
|
||||
|
||||
+10
-112
@@ -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", "session_report", "empty", "settings_timeout", "error", "exit", "timeout", "missing"] {
|
||||
for outcome in ["success", "error", "exit", "timeout", "missing"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let script = root.join("fake-claude");
|
||||
@@ -380,7 +380,6 @@ 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())
|
||||
@@ -392,34 +391,16 @@ if outcome == 'exit':
|
||||
if outcome == 'timeout':
|
||||
time.sleep(60)
|
||||
response = {'subtype': outcome, 'request_id': request['request_id']}
|
||||
if outcome in ('success', 'session_report', 'empty', 'settings_timeout'):
|
||||
response['subtype'] = 'success'
|
||||
if outcome == 'success':
|
||||
response['response'] = {'commands': [
|
||||
{'name': 'compact', 'description': 'Free up context', 'argumentHint': '[instructions]'},
|
||||
{'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'] = {}
|
||||
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}]}
|
||||
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();
|
||||
@@ -436,59 +417,19 @@ for line in sys.stdin:
|
||||
}
|
||||
spec.env = vec![("OUTCOME".into(), outcome.into())];
|
||||
}
|
||||
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, concurrent) = tokio::join!(mgr.commands("claude", None), mgr.commands("claude", None));
|
||||
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 matches!(outcome, "success" | "session_report" | "settings_timeout") {
|
||||
if outcome == "success" {
|
||||
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_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!(!root.join("models.json").exists());
|
||||
}
|
||||
assert!(mgr.tasks.read().is_empty());
|
||||
assert!(!root.join("store").exists());
|
||||
@@ -498,25 +439,10 @@ 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();
|
||||
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");
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2432,31 +2358,3 @@ 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 = ["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"] }
|
||||
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"] }
|
||||
+10
-128
@@ -4,12 +4,10 @@ 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)]
|
||||
@@ -30,11 +28,10 @@ pub struct InputWriter {
|
||||
}
|
||||
|
||||
impl InputWriter {
|
||||
/// On Unix, pass the readiness hook returned by `nonblocking` for this writer.
|
||||
/// Unix writers must first be made nonblocking with `nonblocking`.
|
||||
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()));
|
||||
@@ -77,19 +74,11 @@ impl InputWriter {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
|
||||
#[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());
|
||||
let state = lock.lock().unwrap();
|
||||
if state.aborted {
|
||||
return Ok(());
|
||||
}
|
||||
drop(wake.wait_timeout(state, RETRY).unwrap());
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
@@ -179,46 +168,14 @@ impl Drop for InputWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a writer nonblocking and return its readiness hook. PTY clones share file
|
||||
/// status flags, so readers must also handle WouldBlock.
|
||||
/// PTY clones share file status flags, so readers must also handle WouldBlock.
|
||||
#[cfg(unix)]
|
||||
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
let wait = readiness(fd, libc::POLLOUT)?;
|
||||
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<()> {
|
||||
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(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(())
|
||||
})
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -264,11 +221,6 @@ 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();
|
||||
@@ -291,11 +243,6 @@ 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();
|
||||
@@ -309,26 +256,6 @@ 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()));
|
||||
@@ -339,59 +266,14 @@ 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();
|
||||
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();
|
||||
nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let mut writer = InputWriter::new("test-pipe-input".into(), Box::new(pipe), |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,7 +16,6 @@ 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;
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/// 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,12 +110,6 @@ 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,15 +630,9 @@ 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)]
|
||||
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}"))?;
|
||||
if let Some(fd) = pair.master.as_raw_fd() {
|
||||
ide_core::input::nonblocking(fd).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"));
|
||||
@@ -668,27 +662,16 @@ 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,
|
||||
#[cfg(unix)]
|
||||
wait_writable,
|
||||
|e| {
|
||||
tracing::warn!("debug input: {e}");
|
||||
},
|
||||
)
|
||||
let writer = ide_core::input::InputWriter::new(format!("openidea-debug-input-{}", s.id.0), writer, |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,
|
||||
#[cfg(unix)]
|
||||
wait_readable,
|
||||
);
|
||||
forward_terminal_output(s, reader);
|
||||
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
|
||||
let _ = previous.child.kill();
|
||||
}
|
||||
@@ -697,11 +680,7 @@ 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>,
|
||||
#[cfg(unix)] mut wait_readable: impl FnMut() -> std::io::Result<()> + Send + 'static,
|
||||
) {
|
||||
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
|
||||
let session = s.id;
|
||||
let sink = s.owner.sink.clone();
|
||||
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
|
||||
@@ -713,14 +692,6 @@ fn forward_terminal_output(
|
||||
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,12 +276,16 @@ impl PluginHost {
|
||||
self.shared.run_configurations.write().insert((plugin, project), items);
|
||||
}
|
||||
}
|
||||
/// Resolves only an owned plugin test configuration in the client's trusted project.
|
||||
fn test_configuration(
|
||||
/// Parses a bounded batch under the editor-provider soft/hard budgets.
|
||||
pub fn parse_test_results(
|
||||
&self,
|
||||
client: &str,
|
||||
config: &str,
|
||||
) -> IdeResult<Option<(std::path::PathBuf, String, String)>> {
|
||||
chunk: String,
|
||||
) -> IdeResult<Option<Vec<ide_plugin::PluginTestEvent>>> {
|
||||
if chunk.len() > 64 << 10 {
|
||||
return Err(IdeError::invalid("test output batch too large"));
|
||||
}
|
||||
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"));
|
||||
@@ -298,44 +302,7 @@ impl PluginHost {
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
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 plugin = self.get(owner)?;
|
||||
let (actor, activated) = self.ensure_active(&plugin, None)?;
|
||||
if activated {
|
||||
self.changed();
|
||||
|
||||
@@ -468,16 +468,6 @@ 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 {
|
||||
@@ -627,11 +617,6 @@ 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,10 +286,6 @@ 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") || plugin == "api14" && id == "tests") {
|
||||
if plugin != "execution" || !matches!(id, "detected" | "dynamic") {
|
||||
return Err(HostError::NotFound(id.into()));
|
||||
}
|
||||
Ok(ide_run::RunConfig {
|
||||
id: format!("plugin:{plugin}:{id}"),
|
||||
id: format!("plugin:execution:{id}"),
|
||||
name: "Tests".into(),
|
||||
kind: "plugin".into(),
|
||||
command: "execution-tool".into(),
|
||||
@@ -6018,50 +6018,6 @@ 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);
|
||||
let server = tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
|
||||
tokio::spawn(rpc::serve(host, 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,53 +416,6 @@ 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();
|
||||
@@ -513,33 +466,9 @@ 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,22 +1165,6 @@ 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,8 +34,6 @@ 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 @@
|
||||
f8c5b18f9734fd19f65a32e5087655d75ad944dddda31780f61672b94da3da6e
|
||||
b9b06665132bd6db753aa098703f2d83c9185d756114cdd1abb986f9d6d56c06
|
||||
+1
-9
@@ -155,12 +155,4 @@ impl Plugin for Api14 {
|
||||
host::storage::set("events", &serde_json::to_string(&received).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
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);
|
||||
register_plugin!(Api14);
|
||||
@@ -1 +1 @@
|
||||
f4ae5c053230e2a805b097d79392501ad4654f715d6d194937299273b67fda7c
|
||||
e986322ee0a7e8b09096deee3335e542823ad836cecdf14a4f44e2066fadf7b5
|
||||
@@ -1 +1 @@
|
||||
7631939096b867c4707f7e0f929d2b75f91ef2b76da764d6e21233a5c06e8af9
|
||||
cb74e35e0b0fa2bf7d46b143190d4e72d90abfa7a95962c037495b722d8c73d3
|
||||
@@ -1 +1 @@
|
||||
f5a2d43df242aa0ba9f358d9abf7296fd6a56b163bb526e62ce9c8b4d17e4b9a
|
||||
7ef1de0b1eb9d70ed3f5ba1dfc8cec6ccf3445ce76ad128f10f3d2456a990c2c
|
||||
@@ -1 +1 @@
|
||||
f19481ef89309e6fc65736cb18018a4bf9b679e5a1c6c8e9f92adf39578ca4cb
|
||||
dcca07b99ce7d6691bd501d0e7c80d6f4ce0350b32a3bd7726b13f5b315c3982
|
||||
@@ -1 +1 @@
|
||||
a6ba20c5bd531d965e64041b05f1dabfb1634c3192b06cdb61578bd2bf6f1af2
|
||||
6ab6593d3d7226a1a52bcdbe827697be39e9ed5117d238ba2366ad1a6911a623
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
4c20506bb51bb9c08ebedb3ffb0c89ef153ee9565a466ac99d941a2c83ac2c9f
|
||||
a410bcd828c7b43d550c67c433343d1c8d4a5310b62578fc1d7fec542f2761eb
|
||||
@@ -89,17 +89,6 @@ 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 @@
|
||||
110bbe7e4c0bd02d93c0c7ccae8fcb1eccbcf355587ca271d8046b588f3db898
|
||||
86238b45b09b0274d59282b35295aaee650e999aa309640d9347787e7d461ec4
|
||||
@@ -1 +1 @@
|
||||
2c02b56def836148a1be5ac0429394d9430d468c3d3ee3682a3c742bd8c90436
|
||||
d433104828c588baf00dece077a087dc7f4cd78097952adca5b6049e366ca944
|
||||
@@ -1 +1 @@
|
||||
6681327fc078ecad751627998c4b6299b6176484a61760d6f265b41bd3f42c97
|
||||
4093e1d4791c3786f4b67fd4ef76928fc17048470f858824a29aafd71855c77d
|
||||
@@ -1 +1 @@
|
||||
a6a21264dffff392ea5e998c4545cdbe21a08bae429d2765e6bfeac9f2c9f863
|
||||
aeb6d2f0a589731a1b7f71c9c71232b72d2535f0f555c4fbdb7101861b906d77
|
||||
@@ -1 +1 @@
|
||||
f456deae0bbdee79c22d844ac8a9db5e4b867eaed245dc9332c1708e32e01ae0
|
||||
b723a8826d256fb57f04bbe8aa4b5300c9777f79d889bed2770f6f2bd7a65aa3
|
||||
@@ -1 +1 @@
|
||||
50e77e306f196dd43f7d95789cc1da94085a2863f1ccbec83701c34916ffff41
|
||||
31f6c0067f7668235ce7f28cb14f75950549c690a8f191aa63a71fe25f8397c6
|
||||
@@ -1 +1 @@
|
||||
b9342ba2bf0107bb65074aecf726991d326a45e50ac12100a97c7a6051b01161
|
||||
f03ff9487293925027c5681800633d2e0a71ef908db9ea037dff5e4e0a48458e
|
||||
@@ -1 +1 @@
|
||||
b4300035602303343f8b1c15326f86a696bfe814a3f2c7e1e186f5eed5a92321
|
||||
98a083b972c0a887de07b720c606ffcf13f99d835cfa64d553a1243ffc19e4b1
|
||||
+1
-1
@@ -1 +1 @@
|
||||
91d6f1f11a26effda7a21fd423f8ea6f42e707168ac5080033e9116c89d11d10
|
||||
639a25cbc3aaab3080bf2894bc4710f45a1043a8a0f0f7f8c41ac20253f8bc98
|
||||
@@ -1 +1 @@
|
||||
3edc6122e6cbba033d37f44d7855deff8473f097205846e06768888ac416742c
|
||||
741d2909f021ec4c2f572edba0d83bcee4843a1aae4d8a79ea0367419a667640
|
||||
@@ -1 +1 @@
|
||||
83ce23d58d809e9a724dfcb8bec2cccc1f73c77324d39d1c6ee2a7c855d763fd
|
||||
1da271855c8e63d9b6873521392bb2e14cb85f9de4fddcc7bce0c80916298caf
|
||||
@@ -1 +1 @@
|
||||
557e263c737bc5cdf15f5bc4ed4fa5d7a4f2e252b06c2eb873e936a968519c08
|
||||
e14bfd9a7f61a7ff44f656a148ed133e973c66edfb0859bbff4d4cda9663eb3a
|
||||
@@ -1 +1 @@
|
||||
884e60babc875a5971a9ee42d0949c22712b9af749a6995d6273efa191aa65b5
|
||||
1c2f975248675b935adac4ac096d23d3f45ca44a46e9e4a2ee578bb69bb35356
|
||||
@@ -1 +1 @@
|
||||
5f2f03bbc397dde8ad7c9b1cf2a61e9a4ae5881b34d71ec2e5748fc474754d59
|
||||
2d566981f7f3aa78fbd6188e2bbdb17e33c01f6863e9b9a0b656f541371374bc
|
||||
@@ -1 +1 @@
|
||||
253a7ab7bb1827dee5434b47c732e484976237ca5a5d637536ccc36905d84be0
|
||||
69b349e65d32c5b22f7b6d312944e0e7387d1f7b8e46a1e88fcfcd87262ea407
|
||||
@@ -1 +1 @@
|
||||
93467ab4d2a02ec72820f17df873510707dfdabd02d70c19164b8192d967bf7d
|
||||
e62e75f6e21f4d3c3797554581b6a78fc75a5362ce32f627c9858dd5f1d4edc5
|
||||
@@ -1 +1 @@
|
||||
62372338f5c0129177936ad34c7f0fc5d8cd2f14c3f895c026ca7f79f1e31391
|
||||
c4740acdb3c805e13bf9a0c2e2c2a830336a1085285c39de3dce73a301f70150
|
||||
@@ -1 +1 @@
|
||||
4d1952be87c87f44e23b2420f966e23912405ba842d9928f1f9dec409dfa670e
|
||||
315d592adb776916170a6c2293379e7a7a086b9ae513dcac4aefad925d91df5d
|
||||
@@ -1 +1 @@
|
||||
3992885be293901280938f5dc6498a39a310e1a3c7e34473394116a2327bb13b
|
||||
7a88345173c4a77f6f94a047af53a43852adcf7e0377199f670374f296b98b19
|
||||
@@ -1 +1 @@
|
||||
70926629540ab1277641aef53419ce3014f291e424502619378511735bdbb8d5
|
||||
c98b94057e62a575d6c4b6c4c92e1e88e101bcef9138683e47b0bebf262e3f63
|
||||
+23
-101
@@ -426,16 +426,11 @@ 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),
|
||||
#[cfg(unix)]
|
||||
wait_writable,
|
||||
)?)
|
||||
ide_core::input::nonblocking(pipe.as_raw_fd())?;
|
||||
}
|
||||
Some(input_writer(id, Box::new(pipe))?)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
@@ -467,24 +462,10 @@ 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(),
|
||||
#[cfg(unix)]
|
||||
None,
|
||||
)?;
|
||||
spawn_reader(id, Stream::Stdout, out, tx.clone())?;
|
||||
}
|
||||
if let Some(err) = stderr {
|
||||
spawn_reader(
|
||||
id,
|
||||
Stream::Stderr,
|
||||
err,
|
||||
tx.clone(),
|
||||
#[cfg(unix)]
|
||||
None,
|
||||
)?;
|
||||
spawn_reader(id, Stream::Stderr, err, tx.clone())?;
|
||||
}
|
||||
spawn_pump(rx, on_output.clone(), entry.clone(), sink, options.encoding.clone(), notice)?;
|
||||
Ok(())
|
||||
@@ -557,13 +538,9 @@ impl ProcessManager {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
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"))?,
|
||||
)?;
|
||||
if let Some(fd) = pair.master.as_raw_fd() {
|
||||
ide_core::input::nonblocking(fd)?;
|
||||
}
|
||||
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") {
|
||||
@@ -578,13 +555,7 @@ 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,
|
||||
#[cfg(unix)]
|
||||
wait_writable,
|
||||
)
|
||||
.inspect_err(|_| {
|
||||
let writer = input_writer(id, writer).inspect_err(|_| {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
})?;
|
||||
@@ -606,15 +577,8 @@ 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(),
|
||||
#[cfg(unix)]
|
||||
Some(Box::new(wait_readable)),
|
||||
)
|
||||
.and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), None));
|
||||
let result = spawn_reader(id, Stream::Stdout, reader, tx.clone())
|
||||
.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();
|
||||
@@ -825,16 +789,11 @@ 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),
|
||||
#[cfg(unix)]
|
||||
wait_writable,
|
||||
)?)
|
||||
ide_core::input::nonblocking(pipe.as_raw_fd())?;
|
||||
}
|
||||
Some(input_writer(entry.id, Box::new(pipe))?)
|
||||
}
|
||||
None => None,
|
||||
})
|
||||
@@ -846,24 +805,10 @@ fn run_followup(
|
||||
drop(pid);
|
||||
let readers = (|| {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
spawn_reader(
|
||||
entry.id,
|
||||
Stream::Stdout,
|
||||
out,
|
||||
tx.clone(),
|
||||
#[cfg(unix)]
|
||||
None,
|
||||
)?;
|
||||
spawn_reader(entry.id, Stream::Stdout, out, tx.clone())?;
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
spawn_reader(
|
||||
entry.id,
|
||||
Stream::Stderr,
|
||||
err,
|
||||
tx.clone(),
|
||||
#[cfg(unix)]
|
||||
None,
|
||||
)?;
|
||||
spawn_reader(entry.id, Stream::Stderr, err, tx.clone())?;
|
||||
}
|
||||
Ok::<_, std::io::Error>(())
|
||||
})();
|
||||
@@ -1037,7 +982,6 @@ 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 || {
|
||||
@@ -1051,19 +995,7 @@ fn spawn_reader(
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
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);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => std::thread::sleep(BATCH_INTERVAL),
|
||||
#[cfg(unix)]
|
||||
Err(e) if e.raw_os_error() == Some(libc::EIO) => break,
|
||||
Err(e) => {
|
||||
@@ -1286,20 +1218,10 @@ fn signal_number(_description: &str) -> Option<i32> {
|
||||
None
|
||||
}
|
||||
|
||||
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}");
|
||||
},
|
||||
)
|
||||
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}");
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+35
-102
@@ -279,8 +279,11 @@ pub fn jdtls_runtime(jdks: &[Jdk], min_java: u32) -> Option<&Jdk> {
|
||||
/// for 21, but the Eclipse platform it ships moves on (1.63 needs 25), and on
|
||||
/// an older JDK the server dies resolving its bundles.
|
||||
pub fn jdtls_min_java(cfg: &LanguageServerConfig) -> u32 {
|
||||
let required = crate::jdtls::script_for(cfg)
|
||||
.and_then(|script| Some(script.parent()?.parent()?.to_path_buf()))
|
||||
let search = cfg.env.get("PATH").map(std::ffi::OsString::from).or_else(|| std::env::var_os("PATH"));
|
||||
let required = search
|
||||
.and_then(|s| ide_run::which::find_executable(&cfg.command, &s))
|
||||
.and_then(|exe| dunce::canonicalize(exe).ok())
|
||||
.and_then(|exe| Some(exe.parent()?.parent()?.to_path_buf()))
|
||||
.and_then(|home| bundles_min_java(&home));
|
||||
required.map_or(JDTLS_MIN_JAVA, |v| v.max(JDTLS_MIN_JAVA))
|
||||
}
|
||||
@@ -503,31 +506,21 @@ pub fn configure_jdtls(
|
||||
}
|
||||
}
|
||||
let jdks = all.as_slice();
|
||||
if cfg.launch_error.is_none() {
|
||||
// A `JAVA_HOME` the config inherits (e.g. the project's JDK 8 for
|
||||
// compilation) must not run jdtls: a chosen `server_jdk`, else any
|
||||
// usable JDK, replaces it when it probes as too old or incomplete for
|
||||
// the server (issue #2227). A `JAVA_HOME` that cannot be probed is the
|
||||
// user's call and is left untouched.
|
||||
let env_jdk = cfg.env.get("JAVA_HOME").and_then(|home| jdk_at(Path::new(home)));
|
||||
if cfg.env.contains_key("JAVA_HOME") && env_jdk.is_none() {
|
||||
// Unresolvable explicit value: keep it, jdtls reports what it finds.
|
||||
} else {
|
||||
let existing = env_jdk.filter(|jdk| server_jdk_error(jdk, min_java).is_none());
|
||||
let runtime = if let Some(jdk) = server_jdk {
|
||||
server_jdk_error(jdk, min_java).map_or(Ok(jdk), Err)
|
||||
} else if let Some(jdk) = existing.as_ref() {
|
||||
Ok(jdk)
|
||||
} else {
|
||||
jdtls_runtime(jdks, min_java).ok_or_else(|| missing_runtime_message(jdks, min_java))
|
||||
};
|
||||
match runtime {
|
||||
Ok(jdk) => {
|
||||
cfg.env.insert("JAVA_HOME".to_owned(), jdk.home.to_string_lossy().into_owned());
|
||||
}
|
||||
Err(e) => cfg.launch_error = Some(e),
|
||||
if cfg.launch_error.is_none() && !cfg.env.contains_key("JAVA_HOME") {
|
||||
let runtime = match server_jdk {
|
||||
Some(jdk) => server_jdk_error(jdk, min_java).map_or(Ok(jdk), Err),
|
||||
None => jdtls_runtime(jdks, min_java).ok_or_else(|| missing_runtime_message(jdks, min_java)),
|
||||
};
|
||||
match runtime {
|
||||
Ok(jdk) => {
|
||||
cfg.env.insert("JAVA_HOME".to_owned(), jdk.home.to_string_lossy().into_owned());
|
||||
}
|
||||
Err(e) => cfg.launch_error = Some(e),
|
||||
}
|
||||
} else if cfg.launch_error.is_none()
|
||||
&& let Some(jdk) = cfg.env.get("JAVA_HOME").and_then(|home| jdk_at(Path::new(home)))
|
||||
{
|
||||
cfg.launch_error = server_jdk_error(&jdk, min_java);
|
||||
}
|
||||
let mut runtimes = runtimes(&project_jdks);
|
||||
if let Some(project) = project_jdk.or_else(|| pick_jdk(&project_jdks, project_java_version(server_root))) {
|
||||
@@ -722,10 +715,6 @@ mod tests {
|
||||
.unwrap();
|
||||
write_test_java(&home);
|
||||
std::fs::write(home.join("bin").join(crate::exe("javac")), "").unwrap();
|
||||
// `jdk_at` reports the canonical home; `configure_jdtls` writes it to
|
||||
// JAVA_HOME, so tests compare against the same real path (macOS
|
||||
// /var→/private/var, Windows 8.3 short names).
|
||||
let home = dunce::canonicalize(home).unwrap();
|
||||
Jdk { major, home }
|
||||
}
|
||||
|
||||
@@ -844,7 +833,7 @@ mod tests {
|
||||
"name": "JavaSE-21", "path": pick_jdk(&installed, None).unwrap().home, "default": true
|
||||
}])
|
||||
);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], server.home.to_string_lossy());
|
||||
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("custom-server-runtime").to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1018,11 +1007,15 @@ mod tests {
|
||||
);
|
||||
let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap();
|
||||
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
|
||||
assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": jdks[3].home }));
|
||||
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": jdks[1].home }));
|
||||
assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": jdks[0].home, "default": true }));
|
||||
assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": fixtures.path().join("jdk8") }));
|
||||
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": fixtures.path().join("jdk21a") }));
|
||||
assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": fixtures.path().join("jdk25"), "default": true }));
|
||||
assert_eq!(opts["bundles"], json!(["/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar"]));
|
||||
assert_eq!(cfg.env["JAVA_HOME"], jdks[0].home.to_string_lossy(), "first JDK new enough to run jdtls");
|
||||
assert_eq!(
|
||||
cfg.env["JAVA_HOME"],
|
||||
fixtures.path().join("jdk25").to_string_lossy(),
|
||||
"first JDK new enough to run jdtls"
|
||||
);
|
||||
assert_eq!(cfg.launch_error, None);
|
||||
|
||||
// Deterministic (reconfigure compares configs) and idempotent.
|
||||
@@ -1056,8 +1049,8 @@ mod tests {
|
||||
);
|
||||
let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap();
|
||||
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
|
||||
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": project.home, "default": true }));
|
||||
assert_eq!(cfg.env["JAVA_HOME"], project.home.to_string_lossy());
|
||||
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": fixtures.path().join("jdk21b"), "default": true }));
|
||||
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("jdk21b").to_string_lossy());
|
||||
|
||||
let mut other: LanguageServerConfig = toml::from_str("name = \"pyright\"\ncommand = \"x\"").unwrap();
|
||||
configure_jdtls(
|
||||
@@ -1229,71 +1222,11 @@ mod tests {
|
||||
assert!(err.contains("Download JDK 25"), "{err}");
|
||||
|
||||
// Once a JDK 25 has been downloaded, Auto runs jdtls on it.
|
||||
let temurin = test_jdk(fixtures.path(), 25, "/tools/jdks/temurin-25");
|
||||
let downloaded = [installed.as_slice(), std::slice::from_ref(&temurin)].concat();
|
||||
let downloaded = [installed.as_slice(), &[test_jdk(fixtures.path(), 25, "/tools/jdks/temurin-25")]].concat();
|
||||
let mut cfg = jdtls();
|
||||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &downloaded, None, None, 25, &[], None);
|
||||
assert_eq!(cfg.launch_error, None);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], temurin.home.to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_2227_a_project_java_home_too_old_for_the_server_is_replaced() {
|
||||
// A project's JAVA_HOME (JDK 8 for compilation) must not run jdtls when
|
||||
// the chosen server_jdk — or any usable JDK — is new enough.
|
||||
let fixtures = tempfile::tempdir().unwrap();
|
||||
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
|
||||
let project = test_jdk(fixtures.path(), 8, "/jdk8");
|
||||
let server = test_jdk(fixtures.path(), 25, "/jdk25");
|
||||
|
||||
// The chosen server JDK overrides the project's JAVA_HOME.
|
||||
let mut cfg = jdtls();
|
||||
cfg.env.insert("JAVA_HOME".into(), project.home.to_string_lossy().into_owned());
|
||||
configure_jdtls(
|
||||
&mut cfg,
|
||||
Path::new("/p"),
|
||||
Path::new("/data"),
|
||||
std::slice::from_ref(&project),
|
||||
None,
|
||||
Some(&server),
|
||||
25,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert_eq!(cfg.launch_error, None);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], server.home.to_string_lossy());
|
||||
|
||||
// Auto picks any usable installed JDK over the stale JAVA_HOME.
|
||||
let mut cfg = jdtls();
|
||||
cfg.env.insert("JAVA_HOME".into(), project.home.to_string_lossy().into_owned());
|
||||
let installed = [project.clone(), server.clone()];
|
||||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, None, 25, &[], None);
|
||||
assert_eq!(cfg.launch_error, None);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], server.home.to_string_lossy());
|
||||
|
||||
// A JAVA_HOME new enough for the server stays.
|
||||
let mut cfg = jdtls();
|
||||
cfg.env.insert("JAVA_HOME".into(), server.home.to_string_lossy().into_owned());
|
||||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, None, 25, &[], None);
|
||||
assert_eq!(cfg.launch_error, None);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], server.home.to_string_lossy());
|
||||
|
||||
// Nothing usable to replace it with: the too-old JAVA_HOME loses to the
|
||||
// same missing-runtime error a missing JAVA_HOME gives.
|
||||
let mut cfg = jdtls();
|
||||
cfg.env.insert("JAVA_HOME".into(), project.home.to_string_lossy().into_owned());
|
||||
configure_jdtls(
|
||||
&mut cfg,
|
||||
Path::new("/p"),
|
||||
Path::new("/data"),
|
||||
std::slice::from_ref(&project),
|
||||
None,
|
||||
None,
|
||||
25,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert!(cfg.launch_error.unwrap().contains("JDK 25 or newer"), "auto must say what to install");
|
||||
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("tools/jdks/temurin-25").to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1355,23 +1288,23 @@ mod tests {
|
||||
let cfg = run(Some(&jdk(21, "/jdk21")), JDTLS_MIN_JAVA);
|
||||
assert_eq!(
|
||||
(cfg.env["JAVA_HOME"].clone(), cfg.launch_error),
|
||||
(jdk(21, "/jdk21").home.to_string_lossy().into_owned(), None)
|
||||
(fixtures.path().join("jdk21").to_string_lossy().into_owned(), None)
|
||||
);
|
||||
let opts = serde_json::to_value(cfg.initialization_options.unwrap()).unwrap();
|
||||
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
|
||||
assert_eq!(
|
||||
rt[0],
|
||||
json!({ "name": "JavaSE-17", "path": project.home, "default": true }),
|
||||
json!({ "name": "JavaSE-17", "path": fixtures.path().join("jdk17"), "default": true }),
|
||||
"still compiles on 17"
|
||||
);
|
||||
|
||||
// Not set: the first installed JDK new enough, as before.
|
||||
assert_eq!(run(None, JDTLS_MIN_JAVA).env["JAVA_HOME"], jdk(25, "/jdk25").home.to_string_lossy());
|
||||
assert_eq!(run(None, JDTLS_MIN_JAVA).env["JAVA_HOME"], fixtures.path().join("jdk25").to_string_lossy());
|
||||
|
||||
// A chosen JDK outside the scanned locations.
|
||||
let own = jdk(25, "/opt/own-jdk");
|
||||
let cfg = run(Some(&own), 25);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], own.home.to_string_lossy());
|
||||
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("opt/own-jdk").to_string_lossy());
|
||||
|
||||
// Too old for this jdtls: the user's choice is not overridden, it is explained.
|
||||
let old = jdk(21, "/jdk21");
|
||||
|
||||
+15
-24
@@ -228,29 +228,6 @@ pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String]
|
||||
args
|
||||
}
|
||||
|
||||
/// The release script a `jdtls` command on `PATH` resolves to: itself when it
|
||||
/// already is one, or the script its Scoop shim launches. `None` for a command
|
||||
/// that is neither a release script nor a jdtls Scoop shim.
|
||||
///
|
||||
/// Shared with [`crate::java::jdtls_min_java`], which needs the same install
|
||||
/// root to read the bundles' required Java version.
|
||||
pub(crate) fn script_for(cfg: &LanguageServerConfig) -> Option<PathBuf> {
|
||||
if cfg.name != "jdtls" {
|
||||
return None;
|
||||
}
|
||||
let search = cfg.env.get("PATH").map(std::ffi::OsString::from).or_else(|| std::env::var_os("PATH"));
|
||||
let script = search.and_then(|s| ide_run::which::find_executable(&cfg.command, &s))?;
|
||||
let resolved = dunce::canonicalize(script).ok()?;
|
||||
let name = resolved.file_name().map(|n| n.to_string_lossy().to_ascii_lowercase());
|
||||
match name.as_deref() {
|
||||
Some(n) if SCRIPTS.contains(&n) => Some(resolved),
|
||||
// A scoop shim (`shims\jdtls.exe` from PATHEXT) hides the release
|
||||
// layout behind itself (issue #794); follow its `.shim` to the script.
|
||||
Some("jdtls.exe") if cfg!(windows) => shim_target(&resolved),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scoop shims either point at the release script or run Python with the
|
||||
/// script in `args`. Paths may be quoted and contain spaces and backslashes.
|
||||
fn shim_target(shim_exe: &Path) -> Option<PathBuf> {
|
||||
@@ -286,7 +263,21 @@ pub fn launch_directly(cfg: &mut LanguageServerConfig) {
|
||||
if cfg.name != "jdtls" || cfg.launch_error.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(script) = script_for(cfg) else { return };
|
||||
let search = cfg.env.get("PATH").map(std::ffi::OsString::from).or_else(|| std::env::var_os("PATH"));
|
||||
let Some(script) = search.and_then(|s| ide_run::which::find_executable(&cfg.command, &s)) else { return };
|
||||
let Ok(resolved) = dunce::canonicalize(&script) else { return };
|
||||
let name = resolved.file_name().map(|n| n.to_string_lossy().to_ascii_lowercase());
|
||||
let script = match name.as_deref() {
|
||||
Some(n) if SCRIPTS.contains(&n) => resolved,
|
||||
// A scoop shim (`shims\jdtls.exe` from PATHEXT) hides the release layout
|
||||
// behind itself, so the launch ran the shim's own command line and the
|
||||
// configured JDK never applied (issue #794).
|
||||
Some("jdtls.exe") if cfg!(windows) => match shim_target(&resolved) {
|
||||
Some(target) => target,
|
||||
None => return,
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
let Some(install) =
|
||||
jdtls_home(&script).and_then(|h| find_install(&h, std::env::consts::OS, std::env::consts::ARCH))
|
||||
else {
|
||||
|
||||
@@ -163,13 +163,9 @@ impl TerminalManager {
|
||||
apply_powershell_env(&shell, &mut cmd);
|
||||
|
||||
#[cfg(unix)]
|
||||
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"))?,
|
||||
)?;
|
||||
if let Some(fd) = pair.master.as_raw_fd() {
|
||||
ide_core::input::nonblocking(fd)?;
|
||||
}
|
||||
|
||||
let mut child = pair
|
||||
.slave
|
||||
@@ -184,15 +180,9 @@ 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,
|
||||
#[cfg(unix)]
|
||||
wait_writable,
|
||||
move |e| {
|
||||
tracing::warn!(%id, "terminal input: {e}");
|
||||
},
|
||||
)
|
||||
let writer = ide_core::input::InputWriter::new(format!("openidea-term-write-{}", id.0), writer, move |e| {
|
||||
tracing::warn!(%id, "terminal input: {e}");
|
||||
})
|
||||
.inspect_err(|_| {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
@@ -210,15 +200,9 @@ impl TerminalManager {
|
||||
self.sessions.lock().insert(id, session);
|
||||
|
||||
let (tx, rx) = mpsc::channel::<Msg>();
|
||||
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()));
|
||||
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()));
|
||||
if let Err(e) = spawned {
|
||||
if let Some(s) = self.sessions.lock().remove(&id) {
|
||||
let _ = s.killer.lock().kill();
|
||||
@@ -423,12 +407,7 @@ fn terminate(session: &Session) {
|
||||
}
|
||||
}
|
||||
|
||||
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<()> {
|
||||
fn spawn_reader(id: TerminalId, mut reader: Box<dyn Read + Send>, tx: mpsc::Sender<Msg>) -> std::io::Result<()> {
|
||||
std::thread::Builder::new().name(format!("openidea-term-read-{}", id.0)).spawn(move || {
|
||||
let mut buf = vec![0u8; 16 * 1024];
|
||||
loop {
|
||||
@@ -440,17 +419,7 @@ fn spawn_reader(
|
||||
}
|
||||
}
|
||||
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!(%id, "terminal read: {e}");
|
||||
break;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::thread::sleep(BATCH_INTERVAL);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => 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,8 +46,6 @@
|
||||
"@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",
|
||||
|
||||
+3
-23
@@ -553,7 +553,6 @@ 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. |
|
||||
|
||||
@@ -1629,10 +1628,9 @@ 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. 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.
|
||||
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.
|
||||
|
||||
`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
|
||||
@@ -2359,24 +2357,6 @@ 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 @@
|
||||
c4900df449a6a323bab1f4fe5fe76ee42e2839c9197636b7c1cef733dffc683d
|
||||
acb5f0a97061616d5bdcf7193d8fbe45c27818663dc0a9eab2e8203589492919
|
||||
@@ -1 +1 @@
|
||||
0ea139d4a0d47072971c0b37b93085159e412554ffb45d19590bae4dc10f48b5
|
||||
aaa3cdd1595ba8aeabae6472f7f1ef635bd3115662d64d9284152747edfb6b16
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4e4bc361395f6364e17211a9d3c32232a370ea1ee72e5197eab5f706ea465dc6
|
||||
7d09b4e0e3edfbed92be8efaba47d468b7707d6164abf45b0bec495ac454f4dd
|
||||
@@ -1 +1 @@
|
||||
c39211d416f45dff0265216509070ffdecc4e0f4e738c88176cad5faaca01b65
|
||||
929b309e182c078790940244429b9872d8c7723c0bc25a07b152c83300905c47
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2bbb840e0bead664ccb467dbb299b269fc9a03ed4995c8d890ae195efe6e47a6
|
||||
5099f6ceba7936a9b54340a4ce0c9b50a49962dd96053c850c010ea5c803c6a2
|
||||
+1
-1
@@ -1 +1 @@
|
||||
b1fdf25b8307803b0e5e189bbe041cdb23b1e7b73abb4b76fd7a1b9ee48d87b0
|
||||
6deb9876934b6e21bd99c791f2a86167f1d67e808fbb1cc514c7e621514b654c
|
||||
+1
-1
@@ -1 +1 @@
|
||||
849d83a77d80e8d8a4f2c2801f4728300dd4504da411689834217b9973e582f4
|
||||
5c2f5a254b25ba018369366df87707b423e77a573fcb73cc3c74b1f321f5aa61
|
||||
+1
-1
@@ -1 +1 @@
|
||||
ff42121519f6796db56a71a504484b149592e9e28ed33c783d2e6141cfe84d6c
|
||||
cb4ed7e9d793df4219e92edd5d64616db65411cb77e111df862ca8237f647efb
|
||||
@@ -1 +1 @@
|
||||
0d5ed3d81ab5496b92e7b012ae6e1e765fb45f4f6b0613a03f2bc79029ed9397
|
||||
f43f1e52813a5f992c3d06cbe03aa26092dd56db45322b090906dfbb8d08b1ab
|
||||
+1
-1
@@ -1 +1 @@
|
||||
dd34327c65747b1b57eef0854161837bd4e2843a59efec7730b81d8a2866a411
|
||||
656b51f57467a75b5416575871a5722e4f44be98407f6bfd0e6fa1e44f0bfa80
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2141b215b141d82cc9a5cffa63c11e2b53e7bebcd31b93fb8d2db0ff5c2acd5b
|
||||
bafe2cfc88d8c1272eea289d3d804287d56159b1c5893147b73a6be88e00370f
|
||||
+1
-1
@@ -1 +1 @@
|
||||
604a289139233c13108ff8aab448e4278604e5260d7b2485e3ace3bad9e855db
|
||||
ee5d474028c7068b06771efa5e8e2fa710cd0108d53737a36d8bf9f0f3490c08
|
||||
+1
-1
@@ -1 +1 @@
|
||||
9efa3a6a41c05e81a50893a2cff79da6e8e5f97219ddfd54f7ed201270934bb5
|
||||
867fc7bba0e795155e96c39f5a05cc3a08a3b6809c4e3922437e393d65f6d829
|
||||
@@ -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 for integrating a test framework.
|
||||
API example, not a general test framework or a custom rerun-failed contract.
|
||||
|
||||
Build from the repository root:
|
||||
|
||||
@@ -23,7 +23,3 @@ 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 @@
|
||||
5f05e3696671258af5474b10abafd3cab48385493d5336061a9a8679307b56b4
|
||||
b7aaac21c6c34d9d722aa23c13c66e61ef11028f038fa877fe67d03b03c18483
|
||||
@@ -5,12 +5,6 @@ while IFS= read -r line; do
|
||||
'test '*) name=${line#test } ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
if [ "$#" -gt 0 ]; then
|
||||
selected=0
|
||||
for test in "$@"; do
|
||||
[ "$test" != "$name" ] || selected=1
|
||||
done
|
||||
[ "$selected" -eq 1 ] || continue
|
||||
fi
|
||||
[ -z "$1" ] || [ "$1" = "$name" ] || continue
|
||||
printf 'start %s\npass %s\n' "$name" "$name"
|
||||
done < example.tests
|
||||
@@ -48,12 +48,6 @@ 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![];
|
||||
@@ -98,12 +92,6 @@ 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 @@
|
||||
677ec8005d29d4727afbc4b8fba6afcbc26996f0db461d1e98a6e9e52b5f7fac
|
||||
66e92c09c0050dbf4c302793eea5048e21d1c91b9b44811b5f7edbae6eaa9075
|
||||
Generated
-6
@@ -23,12 +23,6 @@ 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,10 +158,6 @@ 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,12 +1101,6 @@ 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,
|
||||
@@ -1327,9 +1321,6 @@ 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,14 +53,3 @@ 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,7 +47,4 @@ 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(await rerunFailed(id), "Rerun Failed Tests is offered").toBe(true);
|
||||
expect(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,7 +14,6 @@ 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,
|
||||
@@ -29,7 +28,6 @@ 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,7 +514,6 @@ 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,22 +963,6 @@ 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,8 +1145,6 @@ 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. */
|
||||
@@ -1863,7 +1861,6 @@ 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,12 +131,6 @@ 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"],
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* 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,21 +180,15 @@
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.modelEffortPopup {
|
||||
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;
|
||||
width: min(300px, calc(100vw - 24px));
|
||||
padding: 4px 12px 12px;
|
||||
}
|
||||
.effortHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.effortValue {
|
||||
color: var(--fg-strong);
|
||||
|
||||
@@ -234,24 +234,8 @@ describe("model and effort button", () => {
|
||||
expect(slider).toHaveAttribute("aria-valuetext", "high");
|
||||
fireEvent.change(slider, { target: { value: "3" } });
|
||||
expect(onChange).toHaveBeenLastCalledWith("reasoning_effort", "xhigh");
|
||||
// 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" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Model" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: /GPT-6 Sol/ }));
|
||||
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 { SelectListbox, type SelectOption } from "@/ui/Select";
|
||||
import type { SelectOption } from "@/ui/Select";
|
||||
import { mainOptions, modeIcon } from "./composerModel";
|
||||
import styles from "./Composer.module.css";
|
||||
|
||||
@@ -241,21 +241,15 @@ function ModelEffortPicker({
|
||||
aria-label={tx("Model & effort settings")}
|
||||
className={styles.modelEffortPopup}
|
||||
>
|
||||
<div className={styles.effortBlock}>
|
||||
<div className={styles.effortHeader}>
|
||||
<span className={styles.settingsLabel}>{tx(effort.name)}</span>
|
||||
<span className={styles.effortValue}>{effortName}</span>
|
||||
</div>
|
||||
<EffortSlider option={effort} value={effortValue} onChange={(v) => onChange(effort.id, v)} />
|
||||
<div className={styles.settingsRow}>
|
||||
<span className={styles.settingsLabel}>{tx(model.name)}</span>
|
||||
<ConfigPicker option={model} value={modelValue} onChange={onChange} framed />
|
||||
</div>
|
||||
<SelectListbox
|
||||
aria-label={tx(model.name)}
|
||||
value={modelValue}
|
||||
options={configPickerOptions(model)}
|
||||
searchable
|
||||
listClassName={styles.modelList}
|
||||
onChoose={(v) => onChange(model.id, v)}
|
||||
/>
|
||||
<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)} />
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { AgentConfigOption, AgentProvider, NewAgentTask } from "@/bindings";
|
||||
import { HomeEditor } from "./HomeEditor";
|
||||
import { HOME, savedDraft, updateComposer, useAgents } from "./store";
|
||||
@@ -64,7 +64,11 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
async function chooseModel(model: string) {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
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("option", { name: model }));
|
||||
fireEvent.pointerDown(document.body);
|
||||
}
|
||||
|
||||
@@ -118,11 +118,6 @@
|
||||
}
|
||||
.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,18 +1,15 @@
|
||||
/** 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";
|
||||
@@ -192,18 +189,16 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
docked={docked}
|
||||
/>
|
||||
{view && <SubagentBar task={task} path={path} cards={cards} onNavigate={setPath} />}
|
||||
<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>
|
||||
<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)}
|
||||
/>
|
||||
{view ? (
|
||||
<div className={styles.readOnly}>
|
||||
<T>Subagents take their instructions from the agent that started them.</T>
|
||||
@@ -462,65 +457,6 @@ 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;
|
||||
@@ -604,7 +540,6 @@ 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;
|
||||
@@ -1080,23 +1015,19 @@ const Entry = memo(function Entry({
|
||||
case "thought":
|
||||
return (
|
||||
<div className={styles.process}>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<details className={styles.thought}>
|
||||
<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>
|
||||
</Fold>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
case "tool":
|
||||
@@ -1452,18 +1383,14 @@ const ProcessRun = memo(function ProcessRun({
|
||||
tools.length > 0 ? `${tools.length} ${tx(tools.length === 1 ? "tool call" : "tool calls")}` : null,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<div className={styles.processBody}>
|
||||
{row.entries.map(({ index, entry }) =>
|
||||
entry.type === "tool" ? (
|
||||
@@ -1473,37 +1400,9 @@ const ProcessRun = memo(function ProcessRun({
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</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,
|
||||
@@ -1544,18 +1443,13 @@ const ToolEntry = memo(function ToolEntry({
|
||||
);
|
||||
return (
|
||||
<div className={styles.process}>
|
||||
<Fold
|
||||
className={styles.toolDetails}
|
||||
open={liveTool(entry)}
|
||||
summary={
|
||||
<summary className={styles.tool}>
|
||||
<Icon name="chevron-right" size={12} className={styles.toolChevron} />
|
||||
{head}
|
||||
</summary>
|
||||
}
|
||||
>
|
||||
<details className={styles.toolDetails} open={liveTool(entry) || undefined}>
|
||||
<summary className={styles.tool}>
|
||||
<Icon name="chevron-right" size={12} className={styles.toolChevron} />
|
||||
{head}
|
||||
</summary>
|
||||
<pre className={styles.output}>{entry.output}</pre>
|
||||
</Fold>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1638,30 +1532,22 @@ function SubagentCard({
|
||||
</div>
|
||||
)}
|
||||
{entry.prompt && (
|
||||
<Fold
|
||||
className={styles.subagentSection}
|
||||
summary={
|
||||
<summary>
|
||||
<T>Task</T>
|
||||
</summary>
|
||||
}
|
||||
>
|
||||
<details className={styles.subagentSection}>
|
||||
<summary>
|
||||
<T>Task</T>
|
||||
</summary>
|
||||
<div className={styles.thoughtText}>{entry.prompt}</div>
|
||||
</Fold>
|
||||
</details>
|
||||
)}
|
||||
{entry.reply && (
|
||||
<Fold
|
||||
className={styles.subagentSection}
|
||||
summary={
|
||||
<summary>
|
||||
<T>Result</T>
|
||||
</summary>
|
||||
}
|
||||
>
|
||||
<details className={styles.subagentSection}>
|
||||
<summary>
|
||||
<T>Result</T>
|
||||
</summary>
|
||||
<div className={styles.subagentReply}>
|
||||
<AgentReply text={entry.reply} cwd={task.cwd} task={task} onDiagramDrawn={onDiagramDrawn} />
|
||||
</div>
|
||||
</Fold>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,6 @@ 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";
|
||||
|
||||
@@ -244,37 +243,8 @@ 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,8 +65,7 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.body > [role="toolbar"],
|
||||
.body > .summary {
|
||||
.body > [role="toolbar"] {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.row {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
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")>()),
|
||||
@@ -13,29 +8,12 @@ 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: (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 () => {};
|
||||
},
|
||||
},
|
||||
useProject: { getState: () => ({ project: { root: "/p" } }), subscribe: () => () => {} },
|
||||
}));
|
||||
|
||||
const { applyDiagnosticsEvent, clearProblems } = await import("./problemsStore");
|
||||
@@ -57,7 +35,6 @@ describe("reportProblems", () => {
|
||||
vi.useFakeTimers();
|
||||
clearProblems();
|
||||
reports.length = 0;
|
||||
state.report.mockReset();
|
||||
applyDiagnosticsEvent("/p/a.rs", "ra", [d(1), d(2)]);
|
||||
stop = reportProblems();
|
||||
});
|
||||
@@ -66,35 +43,6 @@ 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 { useRemote } from "@/platform/remote";
|
||||
import { isRemoteWindow } 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 (!useProject.getState().project) return;
|
||||
if (isRemoteWindow() || !useProject.getState().project) return;
|
||||
const map = useProblems.getState().map;
|
||||
const files: FileProblems[] = [];
|
||||
for (const [path, sources] of map)
|
||||
@@ -42,16 +42,8 @@ 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);
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { adoptSession, appendConsole, useDebug } from "../state";
|
||||
import { DebugConsole } from "../ui/DebugConsole";
|
||||
|
||||
const nav = vi.hoisted(() => ({
|
||||
openTarget: vi.fn(async () => {}),
|
||||
openUrl: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/run/navigate", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/features/run/navigate")>("@/features/run/navigate");
|
||||
return {
|
||||
...actual,
|
||||
openTarget: nav.openTarget,
|
||||
openUrl: nav.openUrl,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-virtual", () => ({
|
||||
useVirtualizer: ({ count }: { count: number }) => ({
|
||||
getTotalSize: () => count * 18,
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: count }, (_, index) => ({ index, start: index * 18, size: 18, key: index })),
|
||||
scrollToIndex: () => {},
|
||||
measureElement: () => {},
|
||||
measure: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
usePlatform.setState({ os: "macos" });
|
||||
useDebug.setState({ sessions: {}, console: {}, active: null });
|
||||
nav.openTarget.mockClear();
|
||||
nav.openUrl.mockClear();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
});
|
||||
|
||||
describe("Debug console links", () => {
|
||||
it("renders clickable file and URL links in output", () => {
|
||||
const session = adoptSession(1);
|
||||
session.launch = {
|
||||
name: "test",
|
||||
adapter: "delve",
|
||||
request: "launch",
|
||||
program: "main.go",
|
||||
args: [],
|
||||
cwd: "/repo/demo-app",
|
||||
env: {},
|
||||
extraJson: null,
|
||||
};
|
||||
|
||||
appendConsole(1, "stdout", " /Users/dev/demo-app/pkg/api/server.go:42:\n");
|
||||
appendConsole(1, "stdout", "2026-01-01T12:00:00.000+0000 DEBUG pkg/store/db.go:15 failed\n");
|
||||
appendConsole(1, "stdout", "Listening on http://localhost:8080/api\n");
|
||||
appendConsole(1, "system", "Process finished with exit code 0\n");
|
||||
|
||||
render(<DebugConsole session={session} />);
|
||||
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links).toHaveLength(3);
|
||||
|
||||
expect(links[0]?.textContent).toBe("/Users/dev/demo-app/pkg/api/server.go:42");
|
||||
expect(links[1]?.textContent).toBe("pkg/store/db.go:15");
|
||||
expect(links[2]?.textContent).toBe("http://localhost:8080/api");
|
||||
});
|
||||
|
||||
it("navigates to target file on click with resolved cwd", () => {
|
||||
const session = adoptSession(1);
|
||||
session.launch = {
|
||||
name: "test",
|
||||
adapter: "delve",
|
||||
request: "launch",
|
||||
program: "main.go",
|
||||
args: [],
|
||||
cwd: "/repo/demo-app",
|
||||
env: {},
|
||||
extraJson: null,
|
||||
};
|
||||
|
||||
appendConsole(1, "stdout", "2026-01-01T12:00:00.000+0000 DEBUG pkg/store/db.go:15 failed\n");
|
||||
|
||||
render(<DebugConsole session={session} />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "pkg/store/db.go:15" });
|
||||
fireEvent.click(link);
|
||||
|
||||
expect(nav.openTarget).toHaveBeenCalledTimes(1);
|
||||
expect(nav.openTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: "pkg/store/db.go",
|
||||
line: 15,
|
||||
}),
|
||||
"/repo/demo-app",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens URL on click", () => {
|
||||
const session = adoptSession(1);
|
||||
appendConsole(1, "stdout", "See docs at https://example.com/guide\n");
|
||||
|
||||
render(<DebugConsole session={session} />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "https://example.com/guide" });
|
||||
fireEvent.click(link);
|
||||
|
||||
expect(nav.openUrl).toHaveBeenCalledTimes(1);
|
||||
expect(nav.openUrl).toHaveBeenCalledWith("https://example.com/guide");
|
||||
});
|
||||
|
||||
it("does not trigger navigation when text is selected", () => {
|
||||
const session = adoptSession(1);
|
||||
appendConsole(1, "stdout", " /Users/dev/demo-app/pkg/api/server.go:42:\n");
|
||||
|
||||
render(<DebugConsole session={session} />);
|
||||
|
||||
// Mock active selection
|
||||
vi.spyOn(window, "getSelection").mockReturnValue({
|
||||
toString: () => "server.go",
|
||||
} as unknown as Selection);
|
||||
|
||||
const link = screen.getByRole("link", { name: "/Users/dev/demo-app/pkg/api/server.go:42" });
|
||||
fireEvent.click(link);
|
||||
|
||||
expect(nav.openTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { copySelectionOnKeyDown, copyText } from "@/ui/selectableText";
|
||||
@@ -15,10 +6,16 @@ import { EmptyState, IconButton, showContextMenu, T, type MenuItem } from "@/ui"
|
||||
import { formatBinding, hasPrimaryModifier, hostBinding, registerKeyClaim } from "@/platform/keymap";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { spanStyle } from "@/ui/ansi";
|
||||
import { spanStyle, type Span, type SpanStyle } from "@/ui/ansi";
|
||||
import ansi from "@/ui/ansi.module.css";
|
||||
import { ConsoleFindBar } from "@/ui/ConsoleFindBar";
|
||||
import { DEFAULT_FIND_OPTIONS, findInLines, type FindOptions, type FindResult } from "@/ui/consoleFind";
|
||||
import {
|
||||
DEFAULT_FIND_OPTIONS,
|
||||
findInLines,
|
||||
type ConsoleMatch,
|
||||
type FindOptions,
|
||||
type FindResult,
|
||||
} from "@/ui/consoleFind";
|
||||
import {
|
||||
appendConsole,
|
||||
clearConsole,
|
||||
@@ -27,14 +24,10 @@ import {
|
||||
readsProgramInput,
|
||||
sendProgramInput,
|
||||
useDebug,
|
||||
type ConsoleLine,
|
||||
type DebugSession,
|
||||
} from "../state";
|
||||
import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory";
|
||||
import { useExpressionCompletion } from "./ExpressionCompletion";
|
||||
import { findOutputLinks, type OutputLink } from "@/features/run/links";
|
||||
import { openTarget, openUrl, runDirectory } from "@/features/run/navigate";
|
||||
import { segmentLine, type SegmentMark } from "@/features/run/segments";
|
||||
import styles from "./DebugToolWindow.module.css";
|
||||
|
||||
const EMPTY_FIND: FindResult = { matches: [], overflow: false, invalid: false };
|
||||
@@ -42,64 +35,69 @@ const EMPTY_FIND: FindResult = { matches: [], overflow: false, invalid: false };
|
||||
const MARK_COLOR = "var(--bg-search-match)";
|
||||
const CURRENT_MARK_COLOR = "var(--bg-search-match-current)";
|
||||
|
||||
const DebugLine = memo(
|
||||
function DebugLine({
|
||||
line,
|
||||
marks,
|
||||
cwd,
|
||||
}: {
|
||||
line: ConsoleLine;
|
||||
marks?: readonly SegmentMark[];
|
||||
length: number;
|
||||
cwd: string;
|
||||
}) {
|
||||
const segments = segmentLine(line.spans, line.category === "system" ? [] : findOutputLinks(line.text), marks);
|
||||
const activate = (e: MouseEvent, link: OutputLink) => {
|
||||
e.preventDefault();
|
||||
if (window.getSelection()?.toString()) return;
|
||||
if (link.kind === "url") void openUrl(link.url);
|
||||
else void openTarget(link.target, cwd);
|
||||
};
|
||||
if (segments.length === 0) return <>{"\u200B"}</>;
|
||||
interface Segment {
|
||||
text: string;
|
||||
style: SpanStyle;
|
||||
/** The piece is inside a find match. */
|
||||
mark: boolean;
|
||||
/** …and inside the current one. */
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
/** Splits a line's styled spans at find-match boundaries (the Run console's `segmentLine`, without links). */
|
||||
function findSegments(spans: readonly Span[], marks: readonly ConsoleMatch[], active?: ConsoleMatch): Segment[] {
|
||||
const out: Segment[] = [];
|
||||
let pos = 0;
|
||||
let mi = 0;
|
||||
for (const span of spans) {
|
||||
const { text, ...style } = span;
|
||||
let offset = 0;
|
||||
while (offset < text.length) {
|
||||
const abs = pos + offset;
|
||||
while (mi < marks.length && marks[mi]!.end <= abs) mi++;
|
||||
const mark = marks[mi];
|
||||
if (mark && mark.start <= abs) {
|
||||
const take = Math.min(mark.end - abs, text.length - offset);
|
||||
out.push({ text: text.slice(offset, offset + take), style, mark: true, current: mark === active });
|
||||
offset += take;
|
||||
} else if (mark && mark.start < pos + text.length) {
|
||||
const take = Math.min(mark.start - abs, text.length - offset);
|
||||
out.push({ text: text.slice(offset, offset + take), style, mark: false, current: false });
|
||||
offset += take;
|
||||
} else {
|
||||
out.push({ text: text.slice(offset), style, mark: false, current: false });
|
||||
offset = text.length;
|
||||
}
|
||||
}
|
||||
pos += text.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A match keeps the span's colours but takes the search background (as IDEA does). */
|
||||
function segmentNode(seg: Segment, key: number) {
|
||||
if (!seg.mark)
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg, i) => {
|
||||
const style = seg.mark
|
||||
? {
|
||||
...spanStyle(seg.style),
|
||||
backgroundColor: seg.current ? CURRENT_MARK_COLOR : MARK_COLOR,
|
||||
color: "var(--fg-search-match)",
|
||||
}
|
||||
: spanStyle(seg.style);
|
||||
const marked = seg.mark ? { "data-find-match": "", "data-find-current": seg.current ? "" : undefined } : {};
|
||||
return seg.link ? (
|
||||
<a
|
||||
key={i}
|
||||
href={seg.link.kind === "url" ? seg.link.url : undefined}
|
||||
className={styles.link}
|
||||
style={style}
|
||||
onClick={(e) => activate(e, seg.link!)}
|
||||
role="link"
|
||||
tabIndex={-1}
|
||||
{...marked}
|
||||
>
|
||||
{seg.text}
|
||||
</a>
|
||||
) : seg.mark ? (
|
||||
<mark key={i} className={styles.findMatch} style={style} {...marked}>
|
||||
{seg.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i} style={style}>
|
||||
{seg.text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
<span key={key} style={spanStyle(seg.style)}>
|
||||
{seg.text}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
(a, b) => a.line === b.line && a.length === b.length && a.cwd === b.cwd && a.marks === b.marks,
|
||||
);
|
||||
return (
|
||||
<mark
|
||||
key={key}
|
||||
className={styles.findMatch}
|
||||
style={{
|
||||
...spanStyle(seg.style),
|
||||
backgroundColor: seg.current ? CURRENT_MARK_COLOR : MARK_COLOR,
|
||||
color: "var(--fg-search-match)",
|
||||
}}
|
||||
data-find-match=""
|
||||
data-find-current={seg.current ? "" : undefined}
|
||||
>
|
||||
{seg.text}
|
||||
</mark>
|
||||
);
|
||||
}
|
||||
|
||||
export function DebugServiceConsole({ sessionId }: { sessionId: number }) {
|
||||
const session = useDebug((s) => s.sessions[sessionId]);
|
||||
@@ -162,11 +160,6 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
|
||||
});
|
||||
}, [lines, virtualizer, follow, softWrap]);
|
||||
|
||||
const cwd = useMemo(
|
||||
() => runDirectory(session.launch?.cwd ?? session.runConfig?.cwd),
|
||||
[session.launch?.cwd, session.runConfig?.cwd],
|
||||
);
|
||||
|
||||
// Find (⌘F): search the whole buffer, not just the rendered rows.
|
||||
const found = useMemo(
|
||||
() => (findOpen ? findInLines(lines ?? [], query, findOptions) : EMPTY_FIND),
|
||||
@@ -175,15 +168,14 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
|
||||
const activeIndex = found.matches.length ? Math.min(Math.max(activeMatchIndex, 0), found.matches.length - 1) : -1;
|
||||
const current = activeIndex >= 0 ? found.matches[activeIndex] : undefined;
|
||||
const marksByLine = useMemo(() => {
|
||||
const byLine = new Map<number, SegmentMark[]>();
|
||||
found.matches.forEach((match, i) => {
|
||||
const mark: SegmentMark = { start: match.start, end: match.end, current: i === activeIndex };
|
||||
const byLine = new Map<number, ConsoleMatch[]>();
|
||||
found.matches.forEach((match) => {
|
||||
const marks = byLine.get(match.line);
|
||||
if (marks) marks.push(mark);
|
||||
else byLine.set(match.line, [mark]);
|
||||
if (marks) marks.push(match);
|
||||
else byLine.set(match.line, [match]);
|
||||
});
|
||||
return byLine;
|
||||
}, [found, activeIndex]);
|
||||
}, [found]);
|
||||
|
||||
// The line at the top of the viewport: typing searches from there, as IDEA does.
|
||||
const topLine = () => {
|
||||
@@ -470,7 +462,7 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
|
||||
className={clsx(styles.out, styles[`cat-${l.category}`])}
|
||||
style={{ transform: `translateY(${item.start}px)` }}
|
||||
>
|
||||
<DebugLine line={l} marks={marksByLine.get(item.index)} length={l.text.length} cwd={cwd} />
|
||||
{findSegments(l.spans, marksByLine.get(item.index) ?? [], current).map(segmentNode)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -273,18 +273,6 @@
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--link);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, var(--link) 45%, transparent);
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover {
|
||||
color: var(--link-hover);
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
/* Soft-Wrap off: long lines scroll horizontally. */
|
||||
.noWrap {
|
||||
white-space: pre;
|
||||
|
||||
@@ -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, useRemote } from "@/platform/remote";
|
||||
import { isRemoteWindow } from "@/platform/remote";
|
||||
|
||||
import { registerOverlay } from "@/workbench/contributions";
|
||||
import { McpConfirmHost } from "./ConfirmDialog";
|
||||
@@ -85,13 +85,6 @@ 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 () => {
|
||||
@@ -103,7 +96,6 @@ export function activate(): () => void {
|
||||
window.removeEventListener("beforeunload", unload);
|
||||
offRegistry();
|
||||
offEvents();
|
||||
offRemote();
|
||||
offProject();
|
||||
window.removeEventListener("focus", schedule);
|
||||
window.removeEventListener("blur", schedule);
|
||||
|
||||
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user