Compare commits

..
Author SHA1 Message Date
l0ng-ai eff19e2ffa test(plugins): wait for a cold example component's editor results
The first editor-path call into a freshly installed component can miss its
budget on a slow machine and drop its answer; ask again until it arrives.
2026-11-09 08:18:54 +08:00
l0ng-ai dbbc70d71b fix(plugins): keep resolved paths consistent with host documents 2026-11-09 07:47:24 +08:00
l0ng-ai 03073d83e5 fix(plugins): normalize Windows paths across API 1.4 2026-11-09 07:47:24 +08:00
l0ng-ai 2dda0be909 test(locales): read backend strings continued over a line
A Rust string continued with a trailing backslash ended the literal scan
there, so catalog entries for messages later in the file looked unused.
2026-11-09 07:47:24 +08:00
l0ng-ai 46da4e8f5f chore(deny): allow the MIT-0 license
jsonschema, which validates plugin MCP tool schemas, depends on
borrow-or-share under MIT No Attribution.
2026-11-09 07:47:24 +08:00
l0ng-ai 39e884a07e feat(plugins): add database sources, MCP tools and API examples 2026-11-09 07:47:24 +08:00
l0ng-ai 129f0cc0d0 feat(plugins): add language features, injections, snippets and menus 2026-11-09 07:47:24 +08:00
l0ng-ai adc252ccd4 feat(plugins): add plugin run targets and interpreter routing 2026-11-09 07:47:24 +08:00
l0ng-ai 7a9baac475 feat(plugins): add run, terminal, test and content tab APIs 2026-11-09 07:47:24 +08:00
l0ng-ai 20fe72de09 feat(plugins): add editor and VCS APIs and command permissions 2026-11-09 07:47:24 +08:00
l0ng-ai d7edf8bded feat(plugins): add API 1.4 host services and window events 2026-11-09 07:47:23 +08:00
127 changed files with 583 additions and 2472 deletions

No files matched your search

-2
View File
@@ -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
View File
@@ -100,7 +100,7 @@ struct Shared {
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
probes: Mutex<HashMap<String, AgentProbe>>,
/// One pre-session 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) {
+18 -43
View File
@@ -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
View File
@@ -371,7 +371,7 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
async fn claude_commands_before_session_are_fetched_once_and_saved() {
use std::os::unix::fs::PermissionsExt;
for outcome in ["success", "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..]);
}
}
}
+1 -1
View File
@@ -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
View File
@@ -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());
-1
View File
@@ -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;
-29
View File
@@ -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);
}
}
-6
View File
@@ -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()])
+8 -37
View File
@@ -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;
}
+8 -41
View File
@@ -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();
-4
View File
@@ -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.
+2 -46
View File
@@ -76,11 +76,11 @@ impl TestWorkspace {
impl PluginWorkspace for TestWorkspace {
fn run_configuration(&self, _client: &str, plugin: &str, id: &str) -> Result<ide_run::RunConfig, HostError> {
if !(plugin == "execution" && matches!(id, "detected" | "dynamic") || 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() {
+2 -73
View File
@@ -396,7 +396,7 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
plugins.register(&mut dispatcher);
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
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)]
-16
View File
@@ -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,
-2
View File
@@ -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
View File
@@ -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
-11
View File
@@ -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 @@
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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+11 -42
View File
@@ -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,
-2
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
c4900df449a6a323bab1f4fe5fe76ee42e2839c9197636b7c1cef733dffc683d
acb5f0a97061616d5bdcf7193d8fbe45c27818663dc0a9eab2e8203589492919
@@ -1 +1 @@
0ea139d4a0d47072971c0b37b93085159e412554ffb45d19590bae4dc10f48b5
aaa3cdd1595ba8aeabae6472f7f1ef635bd3115662d64d9284152747edfb6b16
+1 -1
View File
@@ -1 +1 @@
4e4bc361395f6364e17211a9d3c32232a370ea1ee72e5197eab5f706ea465dc6
7d09b4e0e3edfbed92be8efaba47d468b7707d6164abf45b0bec495ac454f4dd
+1 -1
View File
@@ -1 +1 @@
c39211d416f45dff0265216509070ffdecc4e0f4e738c88176cad5faaca01b65
929b309e182c078790940244429b9872d8c7723c0bc25a07b152c83300905c47
+1 -1
View File
@@ -1 +1 @@
2bbb840e0bead664ccb467dbb299b269fc9a03ed4995c8d890ae195efe6e47a6
5099f6ceba7936a9b54340a4ce0c9b50a49962dd96053c850c010ea5c803c6a2
+1 -1
View File
@@ -1 +1 @@
b1fdf25b8307803b0e5e189bbe041cdb23b1e7b73abb4b76fd7a1b9ee48d87b0
6deb9876934b6e21bd99c791f2a86167f1d67e808fbb1cc514c7e621514b654c
+1 -1
View File
@@ -1 +1 @@
849d83a77d80e8d8a4f2c2801f4728300dd4504da411689834217b9973e582f4
5c2f5a254b25ba018369366df87707b423e77a573fcb73cc3c74b1f321f5aa61
+1 -1
View File
@@ -1 +1 @@
ff42121519f6796db56a71a504484b149592e9e28ed33c783d2e6141cfe84d6c
cb4ed7e9d793df4219e92edd5d64616db65411cb77e111df862ca8237f647efb
+1 -1
View File
@@ -1 +1 @@
0d5ed3d81ab5496b92e7b012ae6e1e765fb45f4f6b0613a03f2bc79029ed9397
f43f1e52813a5f992c3d06cbe03aa26092dd56db45322b090906dfbb8d08b1ab
+1 -1
View File
@@ -1 +1 @@
dd34327c65747b1b57eef0854161837bd4e2843a59efec7730b81d8a2866a411
656b51f57467a75b5416575871a5722e4f44be98407f6bfd0e6fa1e44f0bfa80
+1 -1
View File
@@ -1 +1 @@
2141b215b141d82cc9a5cffa63c11e2b53e7bebcd31b93fb8d2db0ff5c2acd5b
bafe2cfc88d8c1272eea289d3d804287d56159b1c5893147b73a6be88e00370f
+1 -1
View File
@@ -1 +1 @@
604a289139233c13108ff8aab448e4278604e5260d7b2485e3ace3bad9e855db
ee5d474028c7068b06771efa5e8e2fa710cd0108d53737a36d8bf9f0f3490c08
+1 -1
View File
@@ -1 +1 @@
9efa3a6a41c05e81a50893a2cff79da6e8e5f97219ddfd54f7ed201270934bb5
867fc7bba0e795155e96c39f5a05cc3a08a3b6809c4e3922437e393d65f6d829
+1 -5
View File
@@ -10,7 +10,7 @@ The gutter command appends the test name as a filter to `run.start`. The fake
executable prints a deliberately tiny `start NAME` / `pass NAME` protocol;
`test-results.parse` retains split lines and emits stable test ids. Replace
that protocol/parser and executable to integrate a real framework. This is an
API example 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.
Binary file not shown.
+1 -1
View File
@@ -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
-12
View File
@@ -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
View File
@@ -1 +1 @@
677ec8005d29d4727afbc4b8fba6afcbc26996f0db461d1e98a6e9e52b5f7fac
66e92c09c0050dbf4c302793eea5048e21d1c91b9b44811b5f7edbae6eaa9075
-6
View File
@@ -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 {
-9
View File
@@ -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);
}
-3
View File
@@ -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
View File
@@ -220,7 +220,7 @@ async function smoke(language: string) {
// Rerun Failed Tests runs that one test only.
const rerunOutput: string[] = [];
const before = useRun.getState().sessions.find((s) => s.id === id)!.startedAt;
expect(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;
-2
View File
@@ -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(),
-1
View File
@@ -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,
-16
View File
@@ -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]
-3
View File
@@ -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,
-6
View File
@@ -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"],
-26
View File
@@ -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");
});
});
-27
View File
@@ -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,
},
],
}),
});
}
+3 -9
View File
@@ -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);
+2 -18
View File
@@ -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 -15
View File
@@ -9,7 +9,7 @@ import clsx from "clsx";
import type { AgentConfigOption } from "@/bindings";
import { useTx } from "@/platform/i18n";
import { Icon, IconButton, Popup, Select, T, Toggle } from "@/ui";
import { 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>
</>
);
+6 -2
View File
@@ -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);
}
-5
View File
@@ -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 > * + * {
+45 -159
View File
@@ -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>
);
+1 -31
View File
@@ -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)]);
+2 -10
View File
@@ -1,7 +1,7 @@
/** Reports changed files from the Problems view to the built-in MCP host. */
import { commands, call, type FileProblems } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { 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();
});
});
+76 -84
View File
@@ -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;
+1 -9
View File
@@ -2,7 +2,7 @@
import { activeEditor, onEditorEvent, openEditorPaths, useEditorRegistry } from "@/editor/api";
import { commands, call, onIdeEvent, ideEventsReady } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { isRemoteWindow, 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