Compare commits

..
Author SHA1 Message Date
l0ng-ai 764cc9c519 feat(debugger): console actions in a toolbar on the left of the debug Console
Scroll to End, Soft-Wrap and Clear sat at the far right of the evaluate row,
easy to miss or cut off in a narrow window. The Console tab now has the same
vertical toolbar as the Run window: Find, Scroll to End, Soft-Wrap and Clear.
2026-11-06 01:03:42 +08:00
146 changed files with 237 additions and 4938 deletions

No files matched your search

Generated
+5 -7
View File
@@ -1496,7 +1496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 3.0.9",
"syn 2.0.119",
]
[[package]]
@@ -3001,7 +3001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238"
dependencies = [
"bstr",
"hashbrown 0.17.1",
"hashbrown 0.16.1",
]
[[package]]
@@ -4182,7 +4182,6 @@ dependencies = [
"ide-language",
"memchr",
"parking_lot",
"portable-pty",
"serde",
"serde_json",
"specta",
@@ -4544,7 +4543,6 @@ dependencies = [
"ignore",
"parking_lot",
"serde",
"serde_json",
"specta",
"tempfile",
"tokio",
@@ -8380,7 +8378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
dependencies = [
"foldhash",
"hashbrown 0.17.1",
"hashbrown 0.16.1",
]
[[package]]
@@ -9982,9 +9980,9 @@ dependencies = [
[[package]]
name = "tree-sitter-haskell"
version = "0.24.1"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "781de30e0d1ec8e6db4ed799d36ce1cedaf4684374dada45ebe2a8ea44c3f814"
checksum = "977c51e504548cba13fc27cb5a2edab2124cf6716a1934915d07ab99523b05a4"
dependencies = [
"cc",
"tree-sitter-language",
-3
View File
@@ -252,8 +252,6 @@ pub struct SmartKeysSettings {
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
pub reformat_on_paste: String,
pub smart_indent: bool,
/// Enter › Insert documentation comment stub: Enter after `/**` closes the comment.
pub insert_doc_stub: bool,
/// Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`.
pub backspace_unindent: String,
}
@@ -269,7 +267,6 @@ impl Default for SmartKeysSettings {
tab_out_of_pair: true,
reformat_on_paste: "indentEachLine".into(),
smart_indent: true,
insert_doc_stub: true,
backspace_unindent: "autoindent".into(),
}
}
-3
View File
@@ -229,9 +229,6 @@ pub enum DebugEvent {
thread_id: i64,
started: bool,
},
/// The program runs in a terminal the IDE gave it (`runInTerminal`): what is typed for it
/// in the debug console reaches its input.
ProgramInput,
BreakpointsChanged {
path: crate::IpcPath,
},
+6 -59
View File
@@ -1,5 +1,5 @@
//! Settings of remote work: SSH configurations, SFTP deployment and run
//! targets (SSH, Docker, Compose). The fields hang off `Settings` in `settings.rs`.
//! Settings of remote work: SSH configurations, SFTP deployment and SSH run
//! targets. The fields hang off `Settings` in `settings.rs`.
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -140,55 +140,22 @@ pub struct DeploymentMapping {
pub web: String,
}
/// Where a run target runs configurations.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum RunTargetKind {
/// Another machine over SSH, the project synchronised to its folder.
#[default]
Ssh,
/// A new container of an image (`docker run --rm`), the project bind-mounted.
Docker,
/// A new container of a Compose service (`docker compose run --rm`).
Compose,
}
/// Build, Execution, Deployment › Run Targets: run configurations on another machine
/// or in a container.
/// Build, Execution, Deployment › Run Targets: run configurations on another machine.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct RunTarget {
pub kind: RunTargetKind,
pub id: String,
pub name: String,
/// [`SshConfiguration::id`] (SSH).
/// [`SshConfiguration::id`].
pub ssh_config: String,
/// Remote folder the project is synchronised to and run from (SSH).
/// Remote folder the project is synchronised to and run from.
pub project_root: String,
/// Upload changed project files before each run (SSH).
/// Upload changed project files before each run.
pub sync_before_run: bool,
/// Interpreters on the target; empty = the one on the remote `PATH`.
pub python: String,
pub node: String,
pub java: String,
/// Id of a Docker connection of the Services tool window (Docker, Compose);
/// empty = the CLI's own daemon.
pub docker_connection: String,
/// Image to run (Docker).
pub image: String,
/// Compose files, relative to the project; empty = the project's default one (Compose).
pub compose_files: Vec<String>,
/// Service to run (Compose).
pub service: String,
/// Where the project is in the container; empty = `/workspaces/<folder>` (Docker),
/// the service's bind mount of the project (Compose).
pub container_root: String,
/// Published ports, as `docker run --publish` takes them (`8000:8000`).
pub ports: Vec<String>,
/// More options for `docker run` / `docker compose run`, shell-like quoted.
pub run_options: String,
/// Run as the image's user instead of the project folder's owner (Linux).
pub image_user: bool,
}
#[cfg(test)]
@@ -209,24 +176,4 @@ mod deployment_tests {
);
}
}
#[test]
fn run_target_kind_defaults_to_ssh_and_round_trips() {
let legacy: RunTarget =
serde_json::from_str(r#"{"id":"t","name":"Build box","sshConfig":"ssh","projectRoot":"/srv"}"#).unwrap();
assert_eq!((legacy.kind, legacy.ssh_config.as_str()), (RunTargetKind::Ssh, "ssh"));
let docker = RunTarget {
kind: RunTargetKind::Compose,
docker_connection: "local".into(),
compose_files: vec!["compose.dev.yaml".into()],
service: "web".into(),
ports: vec!["8000:8000".into()],
image_user: true,
..legacy
};
let json = serde_json::to_value(&docker).unwrap();
assert_eq!(json["kind"], "compose");
assert_eq!(json["composeFiles"], serde_json::json!(["compose.dev.yaml"]));
assert_eq!(serde_json::from_value::<RunTarget>(json).unwrap(), docker);
}
}
-1
View File
@@ -16,7 +16,6 @@ specta.workspace = true
tokio = { workspace = true, features = ["net"] }
parking_lot.workspace = true
memchr = "2"
portable-pty.workspace = true
tracing.workspace = true
[dev-dependencies]
+5 -5
View File
@@ -64,13 +64,13 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"codelldb",
&["--port", PORT_PLACEHOLDER],
"tcp",
"Install CodeLLDB in Settings › Languages & Frameworks › Language Servers",
"Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH",
),
"kotlin-debug-adapter" => cfg(
"kotlin-debug-adapter",
&[],
"stdio",
"Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers",
"Install the Kotlin Debug Adapter from Settings > Language Servers",
),
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
"debugpy" => cfg(
@@ -84,7 +84,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
// Without a host it listens on `localhost`, which may resolve to ::1 only.
&[PORT_PLACEHOLDER, "127.0.0.1"],
"tcp",
"Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers",
"Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`",
),
"delve" => cfg(
"dlv",
@@ -96,7 +96,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"netcoredbg",
&["--interpreter=vscode"],
"stdio",
"Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers",
"Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)",
),
"lldb-dap" => {
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
@@ -668,7 +668,7 @@ mod tests {
assert!(find_binary("definitely-not-a-real-binary-openidea", &spec.cwd).is_none());
let err = missing(&spec);
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
assert!(err.message.contains("Settings › Languages & Frameworks › Language Servers"));
assert!(err.message.contains("netcoredbg/releases"));
}
#[tokio::test]
-10
View File
@@ -1266,16 +1266,6 @@ impl DebugManager {
Ok(())
}
/// Types `text` into the terminal the session's program runs in (see [`ide_core::event::DebugEvent::ProgramInput`]).
pub fn send_input(&self, s: DebugSessionId, text: &str) -> IdeResult<()> {
let session = self.session(s)?;
let mut program = session.program.lock();
let program = program
.as_mut()
.ok_or_else(|| IdeError::invalid("The program doesn't read input from the debug console"))?;
program.write(text).map_err(|e| IdeError::internal(format!("cannot write to the program: {e}")))
}
/// Flutter's DAP adapter: injects the changed sources into the running app.
/// Not part of the DAP standard; adapters that do not know the request reject it.
pub async fn hot_reload(&self, s: DebugSessionId) -> IdeResult<()> {
+19 -86
View File
@@ -43,8 +43,6 @@ pub(crate) struct Session {
pub adapter_process: Mutex<Option<Child>>,
/// Processes started for `runInTerminal`.
pub debuggees: Mutex<Vec<Child>>,
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
pub program: Mutex<Option<ProgramTerminal>>,
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
pub configured: AtomicBool,
pub initialized: watch::Sender<bool>,
@@ -92,7 +90,6 @@ impl Session {
caps: Mutex::new(Value::Null),
adapter_process: Mutex::new(child),
debuggees: Mutex::new(Vec::new()),
program: Mutex::new(None),
configured: AtomicBool::new(false),
initialized: watch::channel(false).0,
terminated: AtomicBool::new(false),
@@ -269,9 +266,6 @@ impl Session {
for mut c in std::mem::take(&mut *self.debuggees.lock()) {
let _ = c.start_kill();
}
if let Some(mut program) = self.program.lock().take() {
let _ = program.child.kill();
}
}
}
@@ -457,25 +451,8 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
}
}
/// A `runInTerminal` program and the pseudo-terminal it runs in.
pub(crate) struct ProgramTerminal {
writer: Box<dyn std::io::Write + Send>,
child: Box<dyn portable_pty::Child + Send + Sync>,
_master: Box<dyn portable_pty::MasterPty + Send>,
}
impl ProgramTerminal {
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
self.writer.write_all(text.as_bytes())?;
self.writer.flush()
}
}
/// `runInTerminal`: starts the debuggee on a pseudo-terminal, as an IDE terminal would (CodeLLDB
/// starts a launcher that gives the program its terminal, and the program reads input only from
/// a terminal), forwards its output to the console, and keeps the terminal for typed input.
/// `runInTerminal`: spawns the debuggee directly and forwards its output to the console.
pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, String> {
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
let argv: Vec<String> = args
.get("args")
.and_then(Value::as_array)
@@ -484,25 +461,16 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
let Some((program, rest)) = argv.split_first() else {
return Err("runInTerminal without args".into());
};
let pair = native_pty_system()
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
.map_err(|e| format!("cannot open a terminal for {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"));
for (k, v) in ide_core::process::host_env_fixes() {
match v {
Some(v) => cmd.env(k, v),
None => cmd.env_remove(k),
}
}
for (k, v) in ide_core::process::shell_env() {
cmd.env(k, v);
}
cmd.env("TERM", "xterm-256color");
match args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
Some(cwd) => cmd.cwd(cwd),
None => cmd.cwd(&s.spec.cwd),
let mut cmd = tokio::process::Command::new(program);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(rest)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(cwd) = args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
cmd.current_dir(cwd);
}
if let Some(env) = args.get("env").and_then(Value::as_object) {
for (k, v) in env {
@@ -512,53 +480,18 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
};
}
}
let child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
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 pid = child.process_id();
forward_terminal_output(s, reader);
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
let _ = previous.child.kill();
let mut child = cmd.spawn().map_err(|e| format!("failed to start {program}: {e}"))?;
let pid = child.id();
if let Some(out) = child.stdout.take() {
forward_output(s, out, "stdout");
}
s.owner.sink.emit(IdeEvent::Debug { session: s.id, event: DebugEvent::ProgramInput });
if let Some(err) = child.stderr.take() {
forward_output(s, err, "stderr");
}
s.debuggees.lock().push(child);
Ok(json!({ "processId": pid }))
}
/// 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>) {
let session = s.id;
let sink = s.owner.sink.clone();
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
let mut buf = vec![0u8; 8192];
let mut pending: Vec<u8> = Vec::new();
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => pending.extend_from_slice(&buf[..n]),
}
// Keep incomplete UTF-8 sequences, and a `\r` that may start a `\r\n`, for the next chunk.
let mut valid = match std::str::from_utf8(&pending) {
Ok(_) => pending.len(),
Err(e) if e.error_len().is_none() => e.valid_up_to(),
Err(_) => pending.len(),
};
if valid > 0 && pending[valid - 1] == b'\r' {
valid -= 1;
}
let output = String::from_utf8_lossy(&pending[..valid]).replace("\r\n", "\n");
pending.drain(..valid);
if !output.is_empty() {
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } });
}
}
if !pending.is_empty() {
let output = String::from_utf8_lossy(&pending).replace("\r\n", "\n");
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } });
}
});
}
fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream: R, category: &'static str) {
let session = s.id;
let sink = s.owner.sink.clone();
-34
View File
@@ -468,40 +468,6 @@ async fn run_in_terminal_spawns_debuggee() {
assert!(response["body"]["processId"].as_u64().unwrap() > 0);
}
#[cfg(unix)]
#[tokio::test]
async fn run_in_terminal_program_reads_typed_input() {
let h = harness(Script {
// CodeLLDB's launcher asks to clear the terminal; the console keeps its output.
run_in_terminal: Some(vec![
"sh".into(),
"-c".into(),
"[ -t 0 ] && echo 'name?' && read name && echo \"hello $name\"".into(),
"--clear-screen".into(),
]),
..Script::default()
});
let session = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::ProgramInput)).await;
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("name?"))).await;
h.manager.send_input(session, "Bob\n").unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("hello Bob"))).await;
let output: String = h
.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. } if category == "stdout" => {
Some(output.clone())
}
_ => None,
})
.collect();
assert!(!output.contains('\r'), "{output:?}");
h.manager.stop(session).await.unwrap();
assert!(h.manager.send_input(session, "x\n").is_err());
}
#[test]
fn launch_arguments_merge_and_defaults() {
let mut c = config();
-140
View File
@@ -1,140 +0,0 @@
//! Drives php-debug (vscode-php-debug) the way a PHP Remote Debug (Xdebug)
//! configuration does: a launch without a program makes it listen, and every PHP
//! run that Xdebug connects stops at the breakpoint set in the local copy of the
//! file, through `pathMappings`, until the session is stopped. A second listener on
//! the same port fails to start and names the port.
//!
//! Ignored by default (needs PHP with Xdebug 3, Node.js and php-debug). Run with
//! `PHP_DEBUG_ADAPTER=<php-debug-adapter> cargo test -p ide-dap --test php_debug -- --ignored --nocapture`
//! (`PHP_BIN` picks the PHP executable, default `php`).
use ide_core::event::DebugEvent;
use ide_core::ipc_path::to_ipc;
use ide_core::{IdeEvent, SharedSink};
use ide_dap::*;
use ide_language::LanguageRegistry;
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
const SCRIPT: &str = "<?php\n$greeting = \"hello\";\n$n = 40 + 2;\necho \"$greeting $n\\n\";\n";
/// 0-based line of `$n = 40 + 2;`.
const BREAK_LINE: u32 = 2;
async fn wait_for(events: &Mutex<Vec<IdeEvent>>, timeout: Duration, pred: impl Fn(&DebugEvent) -> bool) -> DebugEvent {
let start = Instant::now();
loop {
if let Some(e) = events.lock().iter().find_map(|e| match e {
IdeEvent::Debug { event, .. } if pred(event) => Some(event.clone()),
_ => None,
}) {
return e;
}
assert!(start.elapsed() < timeout, "timed out; events: {:#?}", events.lock());
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
fn listen_config(port: u16, served: &Path, local: &Path) -> LaunchConfig {
// As remote.ts builds it for Remote root = `served`, Local root = `local`.
let mapping = serde_json::Map::from_iter([(to_ipc(served), to_ipc(local).into())]);
LaunchConfig {
name: "PHP Remote".into(),
adapter: "php-debug".into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some(local.to_path_buf().into()),
env: BTreeMap::new(),
extra_json: Some(serde_json::json!({ "port": port, "pathMappings": mapping }).to_string()),
}
}
#[tokio::test(flavor = "multi_thread")]
#[ignore]
async fn listens_for_xdebug_until_stopped_with_path_mappings() {
let Ok(adapter) = std::env::var("PHP_DEBUG_ADAPTER") else {
eprintln!("PHP_DEBUG_ADAPTER not set; skipping");
return;
};
let php = std::env::var("PHP_BIN").unwrap_or_else(|_| "php".into());
let dir = tempfile::tempdir().unwrap();
// The project here, and the copy PHP runs from (a container's /var/www/html).
let (local, served) = (dir.path().join("local"), dir.path().join("served"));
for d in [&local, &served] {
std::fs::create_dir_all(d).unwrap();
std::fs::write(d.join("index.php"), SCRIPT).unwrap();
}
// Resolved like the project root the UI sends (`/private/var` on macOS, no `\\?\` on Windows).
let resolved = |p: &Path| PathBuf::from(to_ipc(&std::fs::canonicalize(p).unwrap()));
let (local, served) = (resolved(&local), resolved(&served));
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
let sink: SharedSink = {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().push(e))
};
let local_script = local.join("index.php");
manager
.set_breakpoints(
local_script.clone(),
vec![Breakpoint {
line: BREAK_LINE,
condition: None,
hit_condition: None,
log_message: None,
enabled: true,
}],
)
.await
.unwrap();
let id = manager
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
Some(adapter.clone())
})
.await
.unwrap_or_else(|e| panic!("start: {e:?}"));
// Requests in a row each stop; the session keeps listening between them.
for request in 1..=2 {
events.lock().clear();
let run = std::process::Command::new(&php)
.args([
"-dxdebug.mode=debug",
"-dxdebug.start_with_request=yes",
"-dxdebug.client_host=127.0.0.1",
&format!("-dxdebug.client_port={port}"),
"index.php",
])
.current_dir(&served)
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
let stopped = wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Stopped { .. })).await;
let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() };
let frames = manager.stack_trace(id, thread_id).await.unwrap();
assert_eq!(frames[0].line, BREAK_LINE, "request {request}");
let top = frames[0].path.as_deref().and_then(|p| p.canonicalize().ok());
assert_eq!(top, local_script.canonicalize().ok(), "request {request}");
manager.resume(id, thread_id).await.unwrap();
wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Thread { started: false, .. })).await;
let out = run.wait_with_output().unwrap();
assert_eq!(String::from_utf8_lossy(&out.stdout), "hello 42\n", "request {request}");
assert!(manager.sessions(None).iter().any(|s| s.id == id), "still listening after request {request}");
}
// Another listener on the port (another IDE): the start fails and says why.
let err = manager
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink), |_| Some(adapter.clone()))
.await
.expect_err("a second listener on the same port");
assert!(err.message.contains("EADDRINUSE") && err.message.ends_with(&format!(":{port}")), "{err:?}");
assert_eq!(manager.sessions(None).len(), 1);
manager.stop(id).await.unwrap();
assert!(manager.sessions(None).is_empty());
}
-86
View File
@@ -440,89 +440,3 @@ pub fn compose_op(
});
run_streaming(ep, None, &args, cancel, on_output)
}
/// Whether `ep` is a daemon on this machine, where a bind mount of the project
/// is this machine's folder. The CLI's default daemon and a context are looked
/// up (`DOCKER_HOST`, else the context's endpoint).
pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
let url = match ep {
DockerEndpoint::Socket { path } => path.trim().to_owned(),
DockerEndpoint::Tcp { host, .. } => {
let host = host.trim();
if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") }
}
DockerEndpoint::Default => {
match ide_core::process::host_var_os("DOCKER_HOST").map(|v| v.to_string_lossy().trim().to_owned()) {
Some(url) if !url.is_empty() => url,
_ => context_host(ep, None)?,
}
}
DockerEndpoint::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?,
};
Ok(daemon_url_is_local(&url))
}
/// The daemon URL of context `name` (the current one with `None`).
fn context_host(ep: &DockerEndpoint, name: Option<&str>) -> IdeResult<String> {
let mut args = vec!["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"];
args.extend(name);
Ok(run(ep, &args, QUICK)?.trim().to_owned())
}
/// `unix://`, `npipe://`, a bare socket path and TCP on the loopback address are
/// this machine; `ssh://` and other hosts are not.
pub fn daemon_url_is_local(url: &str) -> bool {
let url = url.trim();
let Some((scheme, rest)) = url.split_once("://") else { return true };
match scheme {
"unix" | "npipe" => true,
"tcp" | "http" | "https" => {
let host = rest.split('/').next().unwrap_or_default();
let host = match host.strip_prefix('[') {
Some(v6) => v6.split(']').next().unwrap_or_default(),
None => host.rsplit_once(':').map_or(host, |(h, _)| h),
};
matches!(host, "localhost" | "127.0.0.1" | "::1")
}
_ => false,
}
}
/// `docker compose config --format json` of the project at `dir` (`files`
/// relative to it; none = the project's default file).
pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeResult<String> {
let mut args: Vec<&str> = vec!["compose"];
for f in files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) {
args.extend(["--file", f]);
}
args.extend(["config", "--format", "json"]);
let mut cmd = command(ep, &args)?;
cmd.current_dir(dir);
let out = process::run(cmd, QUICK, None)?;
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
}
#[cfg(test)]
mod local_tests {
use super::*;
#[test]
fn local_daemon_urls() {
for url in [
"unix:///var/run/docker.sock",
"/var/run/docker.sock",
"npipe:////./pipe/docker_engine",
"tcp://localhost:2375",
"tcp://127.0.0.1:2376",
"tcp://[::1]:2375",
"",
] {
assert!(daemon_url_is_local(url), "{url}");
}
for url in ["ssh://me@build", "tcp://build:2376", "tcp://10.0.0.5:2375", "https://docker.example.com"] {
assert!(!daemon_url_is_local(url), "{url}");
}
assert!(endpoint_is_local(&DockerEndpoint::Socket { path: "/run/user/1000/docker.sock".into() }).unwrap());
assert!(!endpoint_is_local(&DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: None }).unwrap());
}
}
+1 -28
View File
@@ -98,15 +98,7 @@ fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
*p += 1;
Ok(())
}
Some(Tok::Id(id)) => {
if let Some(path) = id.strip_prefix("workspaceContains:")
&& (path.is_empty()
|| path.starts_with('/')
|| path.contains('\\')
|| path.split('/').any(|s| s == ".."))
{
return Err(format!("workspaceContains: needs a relative path inside the project: {src}"));
}
Some(Tok::Id(_)) => {
*p += 1;
if matches!(t.get(*p), Some(Tok::Op("==" | "!="))) {
*p += 1;
@@ -141,23 +133,4 @@ mod tests {
assert!(validate_when(bad).is_err(), "{bad}");
}
}
#[test]
fn workspace_contains_paths_stay_in_the_project() {
for ok in [
"workspaceContains:pom.xml",
"workspaceContains:.devcontainer/devcontainer.json",
"!workspaceContains:a/b && editorFocus",
] {
assert!(validate_when(ok).is_ok(), "{ok}");
}
for bad in [
"workspaceContains:",
"workspaceContains:/etc/passwd",
"workspaceContains:../x",
"workspaceContains:a/../../x",
] {
assert!(validate_when(bad).unwrap_err().contains("workspaceContains"), "{bad}");
}
}
}
-108
View File
@@ -92,33 +92,6 @@ fn format_range(text: &ide_text::Document, range: Range) -> formatter::FormatRan
}
}
/// The edits PHP-CS-Fixer makes to the unsaved text: it fixes a temporary copy with the file's
/// name (from stdin it doesn't fix anything), so the file on disk stays as it is.
fn php_cs_fixer_edits(
tools: &Tools,
php: Option<&Path>,
fixer: &formatter::PhpCsFixer,
path: &Path,
unsaved: &str,
) -> IdeResult<Vec<TextEdit>> {
let name = path.file_name().ok_or_else(|| IdeError::invalid("PHP-CS-Fixer: file has no name"))?;
let dir = tempfile::tempdir()?;
let copy = dir.path().join(name);
std::fs::write(&copy, unsaved)?;
formatter::run_php_cs_fixer(tools.php_cs_fixer_command(php, fixer, &copy)?, &Default::default())?;
let formatted = std::fs::read_to_string(&copy)?;
let text = ide_text::Document::new(unsaved);
Ok(text
.diff(&formatted)
.changes
.into_iter()
.map(|c| TextEdit {
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
new_text: c.insert,
})
.collect())
}
pub async fn format(
host: &Host,
window: &str,
@@ -130,34 +103,6 @@ pub async fn format(
let snap = project.documents.snapshot(doc)?;
let settings = project.settings.effective();
let language = project.languages.get(&snap.language);
// A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files.
if options.tool.is_none()
&& snap.path.extension().is_some_and(|e| e == "php")
&& let Some(fixer) = formatter::php_cs_fixer(&snap.path)
{
if options.on_save && !settings.editor.format_on_save {
return Ok(Vec::new());
}
host.require_trust(window)?;
if snap.read_only {
return Err(IdeError::invalid("The file is read-only"));
}
let php = ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter);
let tools = Tools::new(&host.local_data_dir);
let edits = tokio::task::spawn_blocking(move || {
php_cs_fixer_edits(&tools, php.as_deref(), &fixer, &snap.path, &snap.text)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
if range.is_some() {
project.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Info,
title: "PHP-CS-Fixer".into(),
body: Some("PHP-CS-Fixer does not support selection formatting; formatted the whole file".into()),
});
}
return Ok(edits);
}
let mut selected = external_tool(options.tool, language.as_ref(), &snap.path, &settings.languages)?;
if let Some(tool) = selected
&& options.tool.is_none()
@@ -328,59 +273,6 @@ mod tests {
assert_eq!(rust.message, "oxfmt cannot format Rust files");
}
#[test]
fn php_cs_fixer_needs_a_config_and_the_project_binary() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("src/Http")).unwrap();
let file = root.join("src/Http/Kernel.php");
assert_eq!(formatter::php_cs_fixer(&file), None);
std::fs::write(root.join(".php-cs-fixer.dist.php"), "<?php").unwrap();
assert_eq!(formatter::php_cs_fixer(&file), None, "no vendor/bin/php-cs-fixer");
std::fs::create_dir_all(root.join("vendor/bin")).unwrap();
std::fs::write(root.join("vendor/bin/php-cs-fixer"), "").unwrap();
let found = formatter::php_cs_fixer(&file).unwrap();
assert_eq!(found.config, root.join(".php-cs-fixer.dist.php"));
assert_eq!(found.bin, root.join("vendor/bin/php-cs-fixer"));
std::fs::write(root.join(".php-cs-fixer.php"), "<?php").unwrap();
assert_eq!(formatter::php_cs_fixer(&file).unwrap().config, root.join(".php-cs-fixer.php"));
std::fs::write(root.join("src/.php-cs-fixer.dist.php"), "<?php").unwrap();
assert_eq!(formatter::php_cs_fixer(&file).unwrap().config, root.join("src/.php-cs-fixer.dist.php"));
}
#[cfg(unix)]
#[test]
fn php_cs_fixer_fixes_a_copy_of_the_unsaved_text() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let fixer = formatter::PhpCsFixer { config: root.join(".php-cs-fixer.dist.php"), bin: root.join("fixer") };
std::fs::write(&fixer.config, "<?php").unwrap();
let php = root.join("php");
// Stands in for `php vendor/bin/php-cs-fixer fix … <file>`: rewrites the last argument.
std::fs::write(
&php,
"#!/bin/sh\nfor last; do :; done\ncase \"$last\" in */Kernel.php) ;; *) exit 3 ;; esac\n\
[ \"$2\" = fix ] || exit 4\nsed 's/ echo/ echo/' \"$last\" > \"$last.new\" && mv \"$last.new\" \"$last\"\n",
)
.unwrap();
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
let file = root.join("src/Kernel.php");
let tools = Tools::new(&root.join("tools"));
let edits = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "<?php\n echo 1;\n").unwrap();
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, " ");
assert!(!file.exists(), "the file on disk is left alone");
std::fs::write(&php, "#!/bin/sh\necho 'Configuration file is invalid' >&2\nexit 16\n").unwrap();
let err = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "<?php\n").unwrap_err();
assert!(
err.message.starts_with("PHP-CS-Fixer: ") && err.message.contains("Configuration file is invalid"),
"{}",
err.message
);
}
#[test]
fn ruff_ranges_are_one_based_unicode_columns() {
let text = ide_text::Document::new("😀x = 1\ny = 2\n");
-44
View File
@@ -245,7 +245,6 @@ impl Host {
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
Some(project),
))
}
@@ -258,7 +257,6 @@ impl Host {
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
lsp: Option<Arc<ide_lsp::LspManager>>,
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
retired: Option<Arc<Project>>,
) -> std::thread::JoinHandle<()> {
for bridge in &bridges {
bridge.stop();
@@ -268,10 +266,6 @@ impl Host {
}
let runtime = self.runtime.clone();
std::thread::spawn(move || {
// Dropping the project stops its file watcher and index worker, which first finish the
// batch they are applying (many file events while a build writes its output), so it
// isn't done on the closing caller's thread (the UI thread for a window close).
let retired = retired.map(|project| std::thread::spawn(move || drop(project)));
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
@@ -286,9 +280,6 @@ impl Host {
debug.stop_key(&key).await;
}
});
if let Some(retired) = retired {
let _ = retired.join();
}
})
}
@@ -328,7 +319,6 @@ impl Host {
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
None,
None,
None,
));
let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT;
while pending.iter().any(|handle| !handle.is_finished()) {
@@ -432,37 +422,3 @@ impl Host {
self.projects.write().insert(window.to_owned(), project);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn detached_close_releases_the_project_before_a_reopen() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = Arc::downgrade(&host.open_project("main", &root).unwrap());
host.close_project_detached("main");
assert!(host.project("main").is_err());
// The project (its file watcher and index database) is dropped off the closing thread, but
// before opening a project waits out the close.
host.wait_detached_closes();
assert!(project.upgrade().is_none(), "the closed project is dropped");
host.open_project("main", &root).unwrap();
host.close_project("main");
}
}
-8
View File
@@ -162,11 +162,6 @@ pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, th
host.debugger.pause(session, thread_id).await
}
/// Types `text` into the session's program (a `runInTerminal` program).
pub async fn debug_send_input(host: &Host, _window: &str, session: DebugSessionId, text: String) -> IdeResult<()> {
host.debugger.send_input(session, &text)
}
pub async fn debug_hot_reload(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
host.debugger.hot_reload(session).await
}
@@ -386,9 +381,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
"debug_pause",
|c| async move { debug_pause(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await },
);
d.add("debug_send_input", |c| async move {
debug_send_input(&c.host, &c.window, c.arg("session")?, c.arg("text")?).await
});
d.add("debug_step", |c| async move {
debug_step(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("kind")?).await
});
-26
View File
@@ -257,28 +257,6 @@ pub async fn lsp_implementations(
result
}
/// Go to Implementation of the symbol at `pos`. Implementations found before a slower
/// search for the rest (Java: the project's, before the dependencies') arrive on
/// `on_partial`; the result has them all. Cancel with `cancel_request(request)`.
pub async fn lsp_goto_implementation(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
on_partial: super::Channel<Vec<Location>>,
) -> IdeResult<Vec<Location>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp
.goto_implementation(doc, pos, cancel, &|locs| {
let _ = on_partial.send(locs);
})
.await;
host.end_request(request);
result
}
/// Contents of a read-only language-server document, including JDT library
/// classes and disk class files. Cached per project.
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
@@ -768,10 +746,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_implementations", |c| async move {
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
});
d.add("lsp_goto_implementation", |c| async move {
let on_partial = c.sender("onPartial")?;
lsp_goto_implementation(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, on_partial).await
});
d.add(
"lsp_resolve_class_file",
|c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await },
+2 -25
View File
@@ -1,6 +1,5 @@
use super::Emit;
use crate::Host;
use ide_core::remote_settings::RunTargetKind;
use ide_core::{IdeError, IdeResult, RunId};
use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions};
use std::time::Duration;
@@ -141,33 +140,12 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
if config.command.is_empty() {
return Err(IdeError::invalid("run configuration has no command"));
}
// Settings › Run Targets: over SSH, or in a container.
// Settings › Run Targets: over SSH.
if config.kind == "tomcat" && config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
return Err(IdeError::invalid("Tomcat Local requires the local machine"));
}
if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
let settings = project.settings.effective();
let target = ide_remote::service::run_target(&settings, id)?;
if target.kind != RunTargetKind::Ssh {
// A local run of the docker CLI: the command is resolved in the container, and
// this machine's toolchains and `PYTHONPATH` don't apply.
let config = container::prepare(host, &project.root, target, config).await?;
let encoding = settings.console.encoding;
let options = StartOptions {
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
python_paths: Vec::new(),
// A folder on this machine: not in the container.
ruby_reporter: None,
};
project.require_trust()?;
return host.processes.start(
&project.root,
&config,
&options,
project.sink.clone(),
Box::new(move |c| on_output(c)),
);
}
let out = on_output.clone();
return host
.remote
@@ -293,5 +271,4 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
}
mod commands;
mod container;
pub use commands::*;
-108
View File
@@ -1,108 +0,0 @@
//! Docker and Compose run targets: the configuration becomes a local run of the
//! docker CLI (see `ide_remote::docker`).
use crate::Host;
use ide_core::remote_settings::{RunTarget, RunTargetKind};
use ide_core::{IdeError, IdeResult};
use ide_docker::DockerEndpoint;
use ide_remote::docker::{
ContainerRun, compose_container_root, container_config, container_name, default_container_root,
};
use ide_run::RunConfig;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// `config` rewritten to run in a new container of `target`'s image or service.
pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config: RunConfig) -> IdeResult<RunConfig> {
if config.kind == "dotnet" || config.kind == "dotnet-test" {
return Err(IdeError::invalid(".NET Project requires the local machine"));
}
let endpoint = endpoint(&host.data_dir, &target)?;
let root = root.to_path_buf();
host.blocking(move || {
if !ide_docker::endpoint_is_local(&endpoint)? {
return Err(IdeError::invalid(format!(
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
target.name
)));
}
let docker = ide_docker::docker_executable().ok_or_else(|| {
IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH")
})?;
let container_root = match target.container_root.trim() {
"" if target.kind == RunTargetKind::Compose => {
let json = ide_docker::compose_config(&endpoint, &root, &target.compose_files)?;
compose_container_root(&json, target.service.trim(), &root)?
}
"" => default_container_root(&root),
folder => folder.to_owned(),
};
let file_env = env_files(&root, &config)?;
let name = container_name(&config.id, &unique());
container_config(
&config,
&ContainerRun {
target: &target,
root: &root,
container_root: &container_root,
docker: &docker.to_string_lossy(),
endpoint_env: &ide_docker::run_env(&endpoint),
file_env: &file_env,
name: &name,
user: user(&root, &target),
},
)
})
.await
}
/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon.
fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
let id = target.docker_connection.trim();
if id.is_empty() {
return Ok(DockerEndpoint::Default);
}
ide_docker::load_settings(data_dir).connections.into_iter().find(|c| c.id == id).map(|c| c.endpoint).ok_or_else(
|| {
IdeError::not_found(format!(
"Run target '{}' uses a Docker connection that no longer exists; choose one in Settings › Run Targets",
target.name
))
},
)
}
/// The configuration's `.env` files, read here: the container gets their variables.
fn env_files(root: &Path, config: &RunConfig) -> IdeResult<BTreeMap<String, String>> {
let extras = &config.extras;
if extras.env_files.is_empty() {
return Ok(BTreeMap::new());
}
let paths: Vec<PathBuf> = extras
.env_files
.iter()
.map(|f| if Path::new(f).is_absolute() { PathBuf::from(f) } else { root.join(f) })
.collect();
let base = if extras.include_system_env { ide_run::system_env() } else { BTreeMap::new() };
ide_run::envfile::load(&paths, &base).map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}")))
}
/// On Linux the container writes files as the project folder's owner, unless the
/// target keeps the image's user. Docker Desktop and OrbStack map ownership themselves.
fn user(root: &Path, target: &RunTarget) -> Option<(u32, u32)> {
#[cfg(target_os = "linux")]
if !target.image_user {
use std::os::unix::fs::MetadataExt;
return std::fs::metadata(root).ok().map(|m| (m.uid(), m.gid()));
}
let _ = (root, target);
None
}
/// Distinguishes runs of the same configuration.
fn unique() -> String {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNT: AtomicU32 = AtomicU32::new(0);
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
format!("{:x}-{}", secs & 0xff_ffff, COUNT.fetch_add(1, Ordering::Relaxed))
}
-31
View File
@@ -96,33 +96,6 @@ pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) ->
.map_err(join_err)
}
/// The Composer project `path` belongs to, when its dependencies need `composer install`.
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ComposerInstallState {
pub root: ide_core::IpcPath,
pub reason: ide_sdk::php::ComposerInstall,
}
/// `None` in an untrusted project too: `composer install` runs the project's scripts.
pub async fn sdk_php_composer_install(
host: &Host,
window: &str,
path: PathBuf,
) -> IdeResult<Option<ComposerInstallState>> {
let project = host.project(window)?;
if !project.is_trusted() {
return Ok(None);
}
let root = project.root.clone();
tokio::task::spawn_blocking(move || {
ide_sdk::php::composer_install(&path, &root)
.map(|(root, reason)| ComposerInstallState { root: ide_core::IpcPath::from(root), reason })
})
.await
.map_err(join_err)
}
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
@@ -238,10 +211,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
"sdk_php_language_level",
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
);
d.add(
"sdk_php_composer_install",
|c| async move { sdk_php_composer_install(&c.host, &c.window, c.arg("path")?).await },
);
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
d.add("sdk_locate_tools", |c| async move {
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
+3 -3
View File
@@ -611,7 +611,7 @@ mod tests {
}
/// A project with PHPStan whose PHP logs each run, waits `pause` seconds, prints `stderr` and
/// reports `report`. `pause` is read before the run is logged, so a test that sees the run can change it.
/// reports `report`.
#[cfg(unix)]
struct Fixture {
_dir: tempfile::TempDir,
@@ -634,8 +634,8 @@ mod tests {
// Named php8.5: the selected PHP runs vendor/bin/phpstan, whatever its name.
let php = base.join("php/php8.5");
std::fs::create_dir_all(php.parent().unwrap()).unwrap();
let script = "#!/bin/sh\nshift\npause=\"$(cat pause 2>/dev/null || echo 0)\"\n\
echo \"$XDEBUG_MODE $*\" >> runs.log\nsleep \"$pause\"\ncat stderr >&2 2>/dev/null\ncat report\n";
let script = "#!/bin/sh\nshift\necho \"$XDEBUG_MODE $*\" >> runs.log\n\
sleep \"$(cat pause 2>/dev/null || echo 0)\"\ncat stderr >&2 2>/dev/null\ncat report\n";
std::fs::write(&php, script).unwrap();
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::create_dir_all(root.join(".openidea")).unwrap();
-78
View File
@@ -30,11 +30,6 @@ pub trait PluginWorkspace: Send + Sync {
fn projects(&self) -> Vec<PathBuf>;
/// Unsaved text of the open document at `path`.
fn buffer_text(&self, path: &Path) -> Option<String>;
/// Up to `limit` files of the project index below `root` whose root-relative path satisfies
/// `matches`; `None` when no index covers `root` yet (the caller walks the disk instead).
fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option<Vec<PathBuf>> {
None
}
/// Version and text of the open document at `path`.
fn document(&self, path: &Path) -> Option<(u64, String)>;
/// Applies `edits` to the open document at `path` if it is still at
@@ -464,19 +459,6 @@ impl HostApi for PluginApi {
.build()
.map_err(|e| HostError::Invalid(format!("{glob:?}: {e}")))?
.compile_matcher();
// Hidden files and folders are left out, as in the walk below.
let visible = |rel: &Path| !rel.iter().any(|c| c.to_str().is_some_and(|c| c.starts_with('.')));
// The project index already lists the files: walking a large tree on every call can take
// longer than an editor provider's deadline (the plugin is then stopped as crashed).
if let Some(files) = self
.shared
.workspace()
.and_then(|w| w.indexed_files(root, &|rel| visible(rel) && matcher.is_match(rel), limit))
{
let mut out: Vec<String> = files.iter().map(|p| to_ipc(p)).collect();
out.sort();
return Ok(out);
}
let mut out = Vec::new();
for (n, entry) in ignore::WalkBuilder::new(root).hidden(true).build().flatten().enumerate() {
if n >= MAX_WALK || out.len() >= limit {
@@ -756,12 +738,6 @@ impl PluginWorkspace for HostWorkspace {
HostWorkspace::projects(self).iter().find_map(|p| p.documents.text_of(path))
}
fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
// The index of the project rooted at `root` (a workspace member's files are in its own).
let project = HostWorkspace::projects(self).into_iter().find(|p| p.root == root)?;
project.index.files_matching(root, matches, limit)
}
fn document(&self, path: &Path) -> Option<(u64, String)> {
let (project, doc) = self.open_document(path)?;
let snap = project.documents.snapshot(doc).ok()?;
@@ -845,60 +821,6 @@ mod tests {
assert_eq!(api.vcs_status(&root).unwrap().unwrap().root, to_ipc(&root));
}
/// A project index that lists `files` (root-relative), whatever is on disk.
struct Indexed(PathBuf, Vec<&'static str>);
impl PluginWorkspace for Indexed {
fn projects(&self) -> Vec<PathBuf> {
vec![self.0.clone()]
}
fn buffer_text(&self, _: &Path) -> Option<String> {
None
}
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit]) -> Result<u64, HostError> {
Err(HostError::Stale)
}
fn trusted(&self, _: &Path) -> bool {
true
}
fn project_of(&self, _: &str) -> Option<PathBuf> {
None
}
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
Ok(())
}
fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
(root == self.0).then(|| {
self.1.iter().map(Path::new).filter(|rel| matches(rel)).take(limit).map(|rel| root.join(rel)).collect()
})
}
}
#[test]
fn find_files_uses_the_project_index_when_there_is_one() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let api = api(&root, Arc::default());
*api.shared.workspace.write() = Some(Arc::new(Indexed(
root.clone(),
vec!["src/b/UserMapper.xml", "src/a/OrderMapper.xml", ".idea/workspace.xml", "pom.xml"],
)));
// Listed by the index, not walked: none of these exist on disk.
assert_eq!(
api.find_files(&root, "**/*Mapper.xml", 10).unwrap(),
[to_ipc(&root.join("src/a/OrderMapper.xml")), to_ipc(&root.join("src/b/UserMapper.xml"))]
);
assert_eq!(api.find_files(&root, "**/*.xml", 10).unwrap().len(), 3, "hidden folders are left out");
assert_eq!(api.find_files(&root, "**/*.xml", 1).unwrap().len(), 1);
// Another root: no index covers it, so the disk is walked.
let other = root.join("other");
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("a.xml"), "").unwrap();
assert_eq!(api.find_files(&other, "*.xml", 10).unwrap(), [to_ipc(&other.join("a.xml"))]);
}
#[test]
fn programs_run_only_as_approved() {
let tmp = tempfile::tempdir().unwrap();
-122
View File
@@ -1,122 +0,0 @@
//! Docker run targets through `ops::run::start`, with a stand-in `docker`
//! (`OPENIDEA_DOCKER`) that prints what it was given.
#![cfg(unix)]
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use ide_run::{OutputChunk, RunConfig};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
fn host(base: &Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
fn config(target: &str, root: &Path) -> RunConfig {
RunConfig {
id: "app".into(),
name: "app".into(),
kind: "python".into(),
command: root.join(".venv/bin/python").to_string_lossy().into_owned(),
args: vec![root.join("main.py").to_string_lossy().into_owned()],
cwd: None,
env: BTreeMap::from([("DATA".to_owned(), root.join("data").to_string_lossy().into_owned())]),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: ide_run::RunExtras { target: Some(target.into()), ..Default::default() },
}
}
async fn output_of(host: &Host, config: RunConfig) -> String {
let out = Arc::new(Mutex::new(String::new()));
let sink = out.clone();
ops::run::start(
host,
"main",
config,
Arc::new(move |chunk| match chunk {
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
sink.lock().push_str(&text)
}
}),
)
.await
.unwrap();
for _ in 0..200 {
if out.lock().contains("Process finished") {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
out.lock().clone()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn docker_targets_run_through_the_docker_cli() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let docker = base.join("docker");
std::fs::write(
&docker,
"#!/bin/sh\nif [ \"$1\" = context ]; then echo unix:///var/run/docker.sock; exit 0; fi\n\
for a in \"$@\"; do echo \"arg:$a\"; done\necho \"DATA=$DATA\"\n",
)
.unwrap();
std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap();
// SAFETY: the only test of this binary, before any thread looks docker up.
unsafe {
std::env::set_var("OPENIDEA_DOCKER", &docker);
std::env::remove_var("DOCKER_HOST");
}
let root = base.join("proj");
std::fs::create_dir_all(&root).unwrap();
let host = host(&base.join("app"));
std::fs::create_dir_all(&host.data_dir).unwrap();
std::fs::write(
host.data_dir.join("docker.json"),
r#"{"connections": [{"id": "far", "name": "Build box", "endpoint": {"kind": "tcp", "host": "build:2376"}}]}"#,
)
.unwrap();
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let targets = serde_json::json!([
{ "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12" },
{ "kind": "docker", "id": "far", "name": "Far", "image": "python:3.12", "dockerConnection": "far" },
]);
ops::project::set_settings(
&host,
"main",
ide_workspace::settings::SettingsScope::Project,
"runTargets",
&targets.to_string(),
)
.unwrap();
let out = output_of(&host, config("py", &root)).await;
let args: Vec<&str> = out.lines().filter_map(|l| l.strip_prefix("arg:")).collect();
assert_eq!(&args[..4], ["run", "--rm", "--interactive", "--init"], "{out}");
assert!(args.contains(&format!("{}:/workspaces/proj", root.display()).as_str()), "{out}");
assert!(args.ends_with(&["python:3.12", "python3", "/workspaces/proj/main.py"]), "{out}");
assert!(out.contains("DATA=/workspaces/proj/data"), "{out}");
assert!(!args.iter().any(|a| a.contains("/workspaces/proj/data")), "values stay out of the command line");
let err = ops::run::start(&host, "main", config("far", &root), Arc::new(|_| {})).await.unwrap_err();
assert!(err.message.contains("another machine"), "{}", err.message);
}
@@ -1,159 +0,0 @@
//! Docker and Compose run targets against a real daemon. Ignored by default:
//! `OPENIDEA_DOCKER_TEST_DIR` names an empty folder the daemon can bind-mount
//! (on this machine, or at the same path in a VM such as OrbStack's), and
//! `docker` with the Compose plugin must be on `PATH`.
#![cfg(unix)]
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use ide_run::{OutputChunk, RunConfig};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
fn host(base: &Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
fn config(id: &str, target: &str, root: &Path, args: &[&str]) -> RunConfig {
RunConfig {
id: id.into(),
name: id.into(),
kind: "python".into(),
command: root.join(".venv/bin/python").to_string_lossy().into_owned(),
args: args.iter().map(|a| a.to_string()).collect(),
cwd: None,
env: BTreeMap::from([("GREETING".to_owned(), "hello".to_owned())]),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: ide_run::RunExtras { target: Some(target.into()), ..Default::default() },
}
}
type Output = Arc<Mutex<String>>;
async fn start(host: &Host, config: RunConfig) -> (ide_core::RunId, Output) {
let out: Output = Arc::default();
let sink = out.clone();
let info = ops::run::start(
host,
"main",
config,
Arc::new(move |chunk| match chunk {
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
sink.lock().push_str(&text)
}
}),
)
.await
.unwrap();
(info.id, out)
}
async fn finished(out: &Output, secs: u64) -> String {
for _ in 0..secs * 10 {
if out.lock().contains("Process finished") {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
out.lock().clone()
}
fn containers(label: &str) -> String {
let out = Command::new("docker").args(["ps", "--all", "--quiet", "--filter", label]).output().unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_owned()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires a Docker daemon and OPENIDEA_DOCKER_TEST_DIR"]
async fn runs_in_docker_and_compose_containers() {
let root = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR"));
let root = dunce::canonicalize(root).unwrap();
let name = root.file_name().unwrap().to_string_lossy().into_owned();
std::fs::write(
root.join("main.py"),
"import os\nprint('cwd=' + os.getcwd())\nprint('GREETING=' + os.environ['GREETING'])\n\
open('out.txt', 'w').write('ok')\n",
)
.unwrap();
std::fs::write(
root.join("compose.yaml"),
"services:\n app:\n image: python:3.12-alpine\n volumes:\n - .:/app\n",
)
.unwrap();
let state = tempfile::tempdir().unwrap();
let host = host(state.path());
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let targets = serde_json::json!([
{ "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12-alpine" },
{ "kind": "compose", "id": "app", "name": "Compose", "service": "app" },
]);
ops::project::set_settings(
&host,
"main",
ide_workspace::settings::SettingsScope::Project,
"runTargets",
&targets.to_string(),
)
.unwrap();
let (_, out) = start(&host, config("docker-main", "py", &root, &[&root.join("main.py").to_string_lossy()])).await;
let out = finished(&out, 120).await;
assert!(out.contains(&format!("cwd=/workspaces/{name}")), "{out}");
assert!(out.contains("GREETING=hello") && out.contains("exit code 0"), "{out}");
let written = root.join("out.txt");
assert_eq!(std::fs::read_to_string(&written).unwrap(), "ok");
#[cfg(target_os = "linux")]
{
use std::os::unix::fs::MetadataExt;
let (file, folder) = (std::fs::metadata(&written).unwrap(), std::fs::metadata(&root).unwrap());
assert_eq!((file.uid(), file.gid()), (folder.uid(), folder.gid()), "written as the folder's owner");
}
std::fs::remove_file(&written).unwrap();
assert_eq!(containers("label=openidea.run=docker-main"), "", "removed when the run ends");
let (_, out) = start(&host, config("compose-main", "app", &root, &["main.py"])).await;
let out = finished(&out, 120).await;
assert!(out.contains("cwd=/app") && out.contains("GREETING=hello") && out.contains("exit code 0"), "{out}");
std::fs::remove_file(&written).unwrap();
// Stop: `docker stop` ends the container; `--rm` removes it.
let (id, out) =
start(&host, config("sleep", "py", &root, &["-c", "print('up', flush=True); import time; time.sleep(600)"]))
.await;
for _ in 0..600 {
if out.lock().contains("up\n") {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert_ne!(containers("label=openidea.run=sleep"), "", "{}", out.lock());
ops::run::stop(&host, "main", id).unwrap();
let out = finished(&out, 30).await;
assert!(out.contains(" stop openidea-sleep-"), "{out}");
for _ in 0..100 {
if containers("label=openidea.run=sleep").is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert_eq!(containers("label=openidea.run=sleep"), "", "{out}");
Command::new("docker").args(["compose", "down"]).current_dir(&root).status().unwrap();
}
-2
View File
@@ -35,8 +35,6 @@ async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_in
assert!(unity.projects.is_empty() && unity.solutions.is_empty());
assert!(project.index.wait_idle(Duration::from_secs(20)));
assert_eq!(project.index.find_files("Player.cs", 100).len(), 1);
let listed = project.index.files_matching(&project.root, |rel| rel.ends_with("Player.cs"), 10).unwrap();
assert_eq!(listed.len(), 1);
for name in ["Cached.cs", "Temporary.cs", "Editor.log"] {
assert!(project.index.find_files(name, 100).is_empty(), "indexed generated file {name}");
}
-22
View File
@@ -197,28 +197,6 @@ impl ProjectIndex {
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
}
/// Up to `limit` indexed files below `dir` (VCS-ignored ones left out) whose path relative to
/// `dir` satisfies `matches`: a file list without walking the disk. `None` while nothing is
/// indexed yet (first scan without a cache).
pub fn files_matching(&self, dir: &Path, matches: impl Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
let d = self.shared.data.read();
if d.file_count == 0 {
return None;
}
let mut out = Vec::new();
for file in d.files.iter().flatten().filter(|f| !f.ignored) {
if out.len() >= limit {
break;
}
if let Ok(rel) = file.path.strip_prefix(dir)
&& matches(rel)
{
out.push(file.path.clone());
}
}
Some(out)
}
/// Fuzzy search for folders that hold indexed files; `rel_path` ends in `/`.
pub fn find_dirs(&self, query: &str, limit: usize) -> Vec<FileMatch> {
matching::find_dirs(&self.shared.data.read(), query, limit)
+1 -1
View File
@@ -52,7 +52,7 @@ tree-sitter-proto = "0.6.0"
tree-sitter-make = "1.1.1"
tree-sitter-cmake = "0.7.4"
tree-sitter-xml = "0.7.0"
tree-sitter-haskell = "0.24.1"
tree-sitter-haskell = "0.23.1"
tree-sitter-dart = "0.2.0"
tree-sitter-graphql = "0.3.0"
tree-sitter-gdscript = "6.1.0"
@@ -1,7 +1,7 @@
(header module: (module) @name) @outline.module
(data_type name: (name) @name) @def.enum
(newtype name: (name) @name) @def.struct
(type_synonym name: (name) @name) @def.type
(type_synomym name: (name) @name) @def.type
(type_family name: (name) @name) @def.type
(class name: (name) @name) @def.interface
(instance name: (name) @name patterns: (_) @detail) @outline.namespace
-6
View File
@@ -136,12 +136,6 @@ impl ClientHandler {
fn show_message(&self, mgr: &LspManager, params: &Value) {
let text = params.get("message").and_then(Value::as_str).unwrap_or_default().to_owned();
// rust-analyzer built from source reports its own slow main-loop turns as errors, meant for
// its developers (`poke_rust_analyzer_developer`); nothing for the user to act on.
if text.starts_with("overly long loop turn") {
tracing::warn!("[{}] {text}", self.server.name);
return;
}
let level = match params.get("type").and_then(Value::as_u64) {
Some(1) => NotifyLevel::Error,
Some(2) => NotifyLevel::Warning,
+3 -103
View File
@@ -315,27 +315,6 @@ async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<L
.unwrap_or_default()
}
const JAVA_IMPLEMENTATIONS: &str = "openidea.java.implementations";
/// The answer of [`JAVA_IMPLEMENTATIONS`]: `complete` when the type is declared in the
/// sources, so no dependency can implement it.
#[derive(serde::Deserialize)]
struct SourceImplementations {
locations: Vec<lsp::Location>,
complete: bool,
}
/// Receives the implementations found so far, before a slower search for the rest.
type Partial<'a> = Option<&'a (dyn Fn(Vec<Location>) + Send + Sync)>;
/// `first`, then those of `more` not in it (same file and start).
fn merge_locations(first: Vec<Location>, more: Vec<Location>) -> Vec<Location> {
let mut seen: HashSet<(PathBuf, Pos)> = first.iter().map(|l| (l.path.to_path_buf(), l.range.start)).collect();
let mut out = first;
out.extend(more.into_iter().filter(|l| seen.insert((l.path.to_path_buf(), l.range.start))));
out
}
fn goto_method(kind: GotoKind) -> (&'static str, &'static str, &'static str) {
match kind {
GotoKind::Definition => ("textDocument/definition", "definitionProvider", "go to definition"),
@@ -1250,22 +1229,7 @@ impl LspManager {
}
let (method, cap, feature) = goto_method(kind);
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
self.goto_in(&snap, &targets, method, pos, None, None).await
}
/// Go to Implementation, with the implementations found first passed to `partial` while
/// the search goes on: jdtls answers from the project's sources quickly, then searches every
/// dependency jar for a type declared in one. Returns them all, the first ones first.
pub async fn goto_implementation(
&self,
doc: DocId,
pos: Pos,
cancel: CancellationToken,
partial: &(dyn Fn(Vec<Location>) + Send + Sync),
) -> IdeResult<Vec<Location>> {
let (method, cap, feature) = goto_method(GotoKind::Implementation);
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
self.goto_in(&snap, &targets, method, pos, Some(&cancel), Some(partial)).await
self.goto_in(&snap, &targets, method, pos, None).await
}
/// Implementations of the symbols at each of `positions` (gutter line
@@ -1282,7 +1246,7 @@ impl LspManager {
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
let requests = positions.iter().copied().map(|pos| {
let (snap, targets, cancel) = (&snap, &targets, &cancel);
async move { self.goto_in(snap, targets, method, pos, Some(cancel), None).await }
async move { self.goto_in(snap, targets, method, pos, Some(cancel)).await }
});
let results: Vec<IdeResult<Vec<Location>>> =
futures::stream::iter(requests).buffered(IMPLEMENTATION_CONCURRENCY).collect().await;
@@ -1295,46 +1259,6 @@ impl LspManager {
Ok(results.into_iter().map(Result::unwrap_or_default).collect())
}
/// jdtls: the implementations in the project's sources (`openidea.java.implementations`),
/// and whether they are all there are. Its standard request builds the type hierarchy over
/// every dependency jar too, which takes long in a project with many dependencies.
async fn java_source_implementations(
&self,
snap: &DocSnap,
t: &Target,
pos: Pos,
cancel: Option<&CancellationToken>,
) -> (Vec<Location>, bool) {
if t.server.name != "jdtls" || !t.caps().commands().iter().any(|c| c == JAVA_IMPLEMENTATIONS) {
return (Vec::new(), false);
}
let params = snap.pos_params(t, pos);
let arguments =
json!([params["textDocument"]["uri"], params["position"]["line"], params["position"]["character"]]);
let result: IdeResult<SourceImplementations> = self
.call(
t,
"workspace/executeCommand",
json!({ "command": JAVA_IMPLEMENTATIONS, "arguments": arguments }),
cancel,
DEFAULT_TIMEOUT,
)
.await;
match result {
Ok(found) => {
let locs =
convert::goto_response(&self.conv(t.enc()), lsp::GotoDefinitionResponse::Array(found.locations));
let locs = library_sources(snap, pos, locs).await;
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
(locs, found.complete)
}
Err(e) => {
tracing::debug!("{JAVA_IMPLEMENTATIONS}: {}", e.message);
(Vec::new(), false)
}
}
}
async fn goto_in(
&self,
snap: &DocSnap,
@@ -1342,24 +1266,9 @@ impl LspManager {
method: &str,
pos: Pos,
cancel: Option<&CancellationToken>,
partial: Partial<'_>,
) -> IdeResult<Vec<Location>> {
let mut out = Outcome::new();
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
// Found in the sources before the standard request searches the dependencies too.
let mut first = Vec::new();
if method == "textDocument/implementation" {
let (locs, complete) = self.java_source_implementations(snap, t, pos, cancel).await;
if complete && !locs.is_empty() {
return Ok(locs);
}
if let Some(partial) = partial
&& !locs.is_empty()
{
partial(locs.clone());
first = locs;
}
}
let r = loading::while_loading(&t.server, cancel, loading::LOADING_WAIT, goto_is_empty, || {
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
t,
@@ -1375,19 +1284,12 @@ impl LspManager {
let locs = convert::goto_response(&self.conv(t.enc()), r);
let locs = library_sources(snap, pos, locs).await;
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
let locs = merge_locations(first, locs);
if !locs.is_empty() {
return Ok(locs);
}
out.value = Some(Vec::new());
}
Ok(None) if !first.is_empty() => return Ok(first),
Ok(None) => out.value = Some(Vec::new()),
// Those shown stay valid when the rest can't be found.
Err(e) if !first.is_empty() && e.kind != ErrorKind::Cancelled => {
tracing::debug!("go to implementation in the dependencies: {}", e.message);
return Ok(first);
}
Err(e) => out.fail(e),
}
}
@@ -1571,9 +1473,7 @@ impl LspManager {
return Vec::new();
}
let defining = self.ready_targets(&snap.servers, |c| c.has("definitionProvider")).await;
self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel), None)
.await
.unwrap_or_default()
self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel)).await.unwrap_or_default()
};
let (results, declarations) = futures::join!(join_all(requests), declarations);
let mut out = Outcome::new();
+1 -74
View File
@@ -954,11 +954,6 @@ async fn apply_edit_hook_and_server_requests() {
assert_eq!(v, Value::Null);
assert!(peer.request::<_, Value>("unknown/method", Value::Null, None).await.is_err());
peer.notify(
"window/showMessage",
json!({ "type": 1, "message": "overly long loop turn took 113ms (event handling took 95ms): PrimeCaches" }),
)
.unwrap();
peer.notify("window/showMessage", json!({ "type": 2, "message": "careful" })).unwrap();
peer.notify("window/showMessage", json!({ "type": 3, "message": "info only" })).unwrap();
peer.notify("window/logMessage", json!({ "type": 1, "message": "log" })).unwrap();
@@ -967,10 +962,7 @@ async fn apply_edit_hook_and_server_requests() {
})
.await;
let notifies = h.events.lock().iter().filter(|e| matches!(e, IdeEvent::Notify { .. })).count();
assert_eq!(
notifies, 2,
"showMessageRequest error + showMessage warning; info and rust-analyzer's developer notices are not surfaced"
);
assert_eq!(notifies, 2, "showMessageRequest error + showMessage warning; info is not surfaced");
// Dynamically registered capability is now usable.
s.respond(
@@ -1804,71 +1796,6 @@ async fn implementations_per_position() {
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
}
#[tokio::test(flavor = "multi_thread")]
async fn java_implementations_come_from_sources_first() {
let caps = json!({
"textDocumentSync": 1,
"implementationProvider": true,
"executeCommandProvider": { "commands": ["openidea.java.implementations"] },
});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let (doc, path) = h.open("Repo.java", "interface Repo {}\nclass A implements Repo {}\nclass B {}\nclass C {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let uri = uri_of(&path);
let at = move |lines: &[u32]| {
lines
.iter()
.map(|&l| json!({ "uri": uri, "range": {"start":{"line":l,"character":6},"end":{"line":l,"character":7}} }))
.collect::<Value>()
};
*s.responder.lock() = Some(Arc::new(move |_, method, params| match method {
// Line 0 is declared in the sources; line 2 in a dependency, which implements it on line 3.
"workspace/executeCommand" => Some(match params["arguments"][1].as_u64() {
Some(0) => json!({ "locations": at(&[1]), "complete": true }),
Some(2) => json!({ "locations": at(&[1]), "complete": false }),
_ => json!({ "locations": [], "complete": true }),
}),
"textDocument/implementation" => Some(at(&[3, 1])),
_ => None,
}));
let lines = |locs: &[ide_core::Location]| locs.iter().map(|l| l.range.start.line).collect::<Vec<_>>();
let found = h.mgr.goto(doc, Pos::new(0, 10), GotoKind::Implementation).await.unwrap();
assert_eq!(lines(&found), vec![1]);
let markers = h.mgr.implementations(doc, &[Pos::new(0, 10)], Default::default()).await.unwrap();
assert_eq!(lines(&markers[0]), vec![1]);
assert!(s.requested("textDocument/implementation").is_empty(), "no search through the dependencies");
let asked = s.requested("workspace/executeCommand");
assert_eq!(asked[0]["command"], "openidea.java.implementations");
assert_eq!((asked[0]["arguments"][1].clone(), asked[0]["arguments"][2].clone()), (json!(0), json!(10)));
// A dependency's type: those in the sources first, then the dependencies' too.
let partial = Mutex::new(Vec::new());
let found = h
.mgr
.goto_implementation(doc, Pos::new(2, 6), Default::default(), &|locs| partial.lock().push(lines(&locs)))
.await
.unwrap();
assert_eq!(*partial.lock(), vec![vec![1]]);
assert_eq!(lines(&found), vec![1, 3]);
assert_eq!(s.requested("textDocument/implementation").len(), 1);
// Without a receiver for the first ones (gutter markers), just all of them.
let found = h.mgr.goto(doc, Pos::new(2, 6), GotoKind::Implementation).await.unwrap();
assert_eq!(lines(&found), vec![3, 1]);
assert_eq!(s.requested("textDocument/implementation").len(), 2);
// None in the sources: the standard request, with the dependencies.
let partial = Mutex::new(Vec::new());
let found = h
.mgr
.goto_implementation(doc, Pos::new(3, 6), Default::default(), &|locs| partial.lock().push(lines(&locs)))
.await
.unwrap();
assert!(partial.lock().is_empty());
assert_eq!(lines(&found), vec![3, 1]);
assert_eq!(s.requested("textDocument/implementation").len(), 3);
}
#[tokio::test(flavor = "multi_thread")]
async fn navigation_rename_format_and_decorations() {
let h = harness(&[("mock", incremental_caps())]);
+1 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "ide-remote"
description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets (SSH, Docker, Compose)"
description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -10,7 +10,6 @@ ide-core.workspace = true
ide-ssh.workspace = true
ide-run.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
tokio.workspace = true
tracing.workspace = true
-347
View File
@@ -1,347 +0,0 @@
//! Run configurations on Docker and Compose run targets: rewritten into a local
//! `docker run --rm` / `docker compose run --rm` of the configuration's program,
//! with the project bind-mounted and its paths and interpreters swapped for the
//! container's. The console, stdin, Stop and test parsers then work as for any
//! local run.
use crate::target::remote_command;
use ide_core::remote_settings::{RunTarget, RunTargetKind};
use ide_core::{IdeError, IdeResult};
use ide_run::RunConfig;
use std::collections::BTreeMap;
use std::path::Path;
/// What the rewrite needs besides the configuration and its target.
pub struct ContainerRun<'a> {
pub target: &'a RunTarget,
/// The project on this machine.
pub root: &'a Path,
/// Where the project is in the container.
pub container_root: &'a str,
/// The docker executable.
pub docker: &'a str,
/// The connection's variables for the docker CLI (`ide_docker::run_env`).
pub endpoint_env: &'a BTreeMap<String, String>,
/// Variables of the configuration's `.env` files, already read.
pub file_env: &'a BTreeMap<String, String>,
/// Container name, unique per run.
pub name: &'a str,
/// `--user uid:gid`; `None` keeps the image's user.
pub user: Option<(u32, u32)>,
}
/// The container folder of the project when a Docker target sets none.
pub fn default_container_root(root: &Path) -> String {
let name = root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let name: String =
name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') { c } else { '-' }).collect();
format!("/workspaces/{}", if name.is_empty() { "project" } else { &name })
}
/// A container name for a run of `config_id`: Docker allows `[a-zA-Z0-9][a-zA-Z0-9_.-]*`.
pub fn container_name(config_id: &str, unique: &str) -> String {
let id: String = config_id
.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') { c } else { '-' })
.take(40)
.collect();
format!("openidea-{}-{unique}", id.trim_matches('-'))
}
/// `config` as a local run of the docker CLI that runs it in a new container.
pub fn container_config(config: &RunConfig, run: &ContainerRun) -> IdeResult<RunConfig> {
let target = run.target;
let command = remote_command(config, target, run.root, run.container_root);
// Variables go by name (`-e NAME`) so their values stay out of the command line
// echoed in the console; the docker CLI reads them from its own environment.
let mut env: BTreeMap<String, String> = run.file_env.clone();
if matches!(config.kind.as_str(), "python" | "pytest" | "django-test")
&& !command.env.contains_key("PYTHONUNBUFFERED")
{
env.insert("PYTHONUNBUFFERED".into(), "1".into());
}
env.extend(command.env.clone());
let pty = config.extras.emulate_terminal;
let mut args: Vec<String> = Vec::new();
match target.kind {
RunTargetKind::Docker => {
if target.image.trim().is_empty() {
return Err(IdeError::invalid(format!("Run target '{}' has no image", target.name)));
}
args.extend(["run", "--rm", "--interactive", "--init"].map(String::from));
if pty {
args.push("--tty".into());
}
let root = run.root.to_string_lossy();
args.extend(["--volume".into(), format!("{root}:{}", run.container_root)]);
}
RunTargetKind::Compose => {
if target.service.trim().is_empty() {
return Err(IdeError::invalid(format!("Run target '{}' has no service", target.name)));
}
args.push("compose".into());
for file in target.compose_files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) {
args.extend(["--file".into(), run.root.join(file).to_string_lossy().into_owned()]);
}
args.extend(["run", "--rm"].map(String::from));
if !pty {
args.push("--no-TTY".into());
}
}
RunTargetKind::Ssh => return Err(IdeError::internal("an SSH target does not run in a container")),
}
args.extend(["--name".into(), run.name.to_owned(), "--label".into(), format!("openidea.run={}", config.id)]);
args.extend(["--workdir".into(), command.cwd.clone()]);
let mut docker_env = run.endpoint_env.clone();
for (key, value) in &env {
if run.endpoint_env.contains_key(key) {
// `PATH`, `DOCKER_HOST`…: the CLI's own value differs from the container's.
args.extend(["--env".into(), format!("{key}={value}")]);
} else {
args.extend(["--env".into(), key.clone()]);
docker_env.insert(key.clone(), value.clone());
}
}
for port in target.ports.iter().map(|p| p.trim()).filter(|p| !p.is_empty()) {
args.extend(["--publish".into(), port.to_owned()]);
}
if let Some((uid, gid)) = run.user {
args.extend(["--user".into(), format!("{uid}:{gid}")]);
}
let options = target.run_options.trim();
if !options.is_empty() {
args.extend(ide_run::import::split_args(options));
}
args.push(match target.kind {
RunTargetKind::Compose => target.service.trim().to_owned(),
_ => target.image.trim().to_owned(),
});
match command.shell_line {
Some(line) => args.extend(["sh".into(), "-c".into(), line]),
None => {
args.push(command.program);
args.extend(command.args);
}
}
let mut out = config.clone();
out.command = run.docker.to_owned();
out.args = args;
out.cwd = Some(run.root.to_path_buf().into());
out.env = docker_env;
let extras = &mut out.extras;
extras.target = None;
extras.env_files.clear();
// The docker CLI needs `HOME` (contexts, credentials); the container gets only `env`.
extras.include_system_env = true;
// `docker stop` signals the container's process (the CLI may not forward a signal).
extras.stop_command = Some(format!("{} stop {}", shell_word(run.docker), run.name));
Ok(out)
}
/// `word` for this machine's shell (the stop command runs there): the docker CLI found
/// outside `PATH` (Docker.app's bundle) may contain spaces.
fn shell_word(word: &str) -> String {
if !word.contains(|c: char| c.is_whitespace() || "'\"$`\\&;|<>()*?#~".contains(c)) || word.is_empty() {
word.to_owned()
} else if cfg!(windows) {
format!("\"{word}\"")
} else {
format!("'{}'", word.replace('\'', "'\\''"))
}
}
/// The project's folder in the container of a Compose `service`, from the JSON of
/// `docker compose config --format json`: the target of a bind mount of the project
/// or a folder above it.
pub fn compose_container_root(config_json: &str, service: &str, root: &Path) -> IdeResult<String> {
let model: serde_json::Value = serde_json::from_str(config_json)
.map_err(|e| IdeError::internal(format!("cannot read `docker compose config`: {e}")))?;
let Some(svc) = model.pointer(&format!("/services/{}", service.replace('~', "~0").replace('/', "~1"))) else {
return Err(IdeError::not_found(format!("the Compose files have no service '{service}'")));
};
let mounts = svc.get("volumes").and_then(|v| v.as_array()).into_iter().flatten();
let mut best: Option<(usize, String)> = None;
for m in mounts {
if m.get("type").and_then(|t| t.as_str()) != Some("bind") {
continue;
}
let (Some(source), Some(target)) =
(m.get("source").and_then(|s| s.as_str()), m.get("target").and_then(|t| t.as_str()))
else {
continue;
};
let Ok(rel) = root.strip_prefix(source) else { continue };
let depth = Path::new(source).components().count();
if best.as_ref().is_none_or(|(d, _)| depth > *d) {
let rel = crate::mapping::posix(rel);
let target = target.trim_end_matches('/');
let folder = match (target.is_empty(), rel.is_empty()) {
(true, true) => "/".to_owned(),
(_, true) => target.to_owned(),
_ => format!("{target}/{rel}"),
};
best = Some((depth, folder));
}
}
best.map(|(_, folder)| folder).ok_or_else(|| {
IdeError::invalid(format!(
"service '{service}' does not mount the project folder: add a bind mount of it, or set the container folder of the run target"
))
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn config(command: &str, args: &[&str]) -> RunConfig {
RunConfig {
id: "app".into(),
name: "app".into(),
kind: "python".into(),
command: command.into(),
args: args.iter().map(|s| s.to_string()).collect(),
cwd: None,
env: BTreeMap::from([("DATA".to_owned(), "/work/proj/data".to_owned())]),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
}
}
fn run<'a>(
target: &'a RunTarget,
endpoint: &'a BTreeMap<String, String>,
files: &'a BTreeMap<String, String>,
) -> ContainerRun<'a> {
ContainerRun {
target,
root: Path::new("/work/proj"),
container_root: "/workspaces/proj",
docker: "/usr/local/bin/docker",
endpoint_env: endpoint,
file_env: files,
name: "openidea-app-1",
user: Some((501, 20)),
}
}
// Unix paths are not absolute on Windows.
#[cfg(unix)]
#[test]
fn docker_target_runs_the_program_in_a_new_container() {
let target = RunTarget {
kind: RunTargetKind::Docker,
name: "py".into(),
image: "python:3.12".into(),
ports: vec!["8000:8000".into(), " ".into()],
run_options: r#"--network host --label "a b""#.into(),
..Default::default()
};
let endpoint = BTreeMap::from([
("DOCKER_CONTEXT".to_owned(), "orbstack".to_owned()),
("PATH".to_owned(), "/usr/local/bin".to_owned()),
]);
let files = BTreeMap::from([("SECRET".to_owned(), "s3".to_owned())]);
let mut c = config("/work/proj/.venv/bin/python", &["-u", "/work/proj/main.py"]);
c.env.insert("PATH".into(), "/opt/bin".into());
c.extras.target = Some("t".into());
c.extras.env_files = vec![".env".into()];
let out = container_config(&c, &run(&target, &endpoint, &files)).unwrap();
assert_eq!(out.command, "/usr/local/bin/docker");
assert_eq!(
out.args.join(" "),
"run --rm --interactive --init --volume /work/proj:/workspaces/proj \
--name openidea-app-1 --label openidea.run=app --workdir /workspaces/proj \
--env DATA --env PATH=/opt/bin --env PYTHONUNBUFFERED --env SECRET \
--publish 8000:8000 --user 501:20 --network host --label a b \
python:3.12 python3 -u /workspaces/proj/main.py"
);
assert_eq!(out.env["DATA"], "/workspaces/proj/data", "values reach the CLI's environment, mapped");
assert_eq!(out.env["SECRET"], "s3");
assert_eq!(out.env["PATH"], "/usr/local/bin", "the CLI keeps its own PATH");
assert_eq!(out.env["DOCKER_CONTEXT"], "orbstack");
assert_eq!(out.cwd.as_deref(), Some(Path::new("/work/proj")));
assert_eq!(out.extras.target, None);
assert!(out.extras.env_files.is_empty() && out.extras.include_system_env);
assert_eq!(out.extras.stop_command.as_deref(), Some("/usr/local/bin/docker stop openidea-app-1"));
assert_eq!(out.kind, "python", "test and output parsers still apply");
let mut c = config("npm run dev", &[]);
c.kind = "npm".into();
c.extras.emulate_terminal = true;
let target = RunTarget { kind: RunTargetKind::Docker, image: "node:22".into(), ..Default::default() };
let mut r = run(&target, &endpoint, &files);
r.user = None;
let out = container_config(&c, &r).unwrap();
assert!(out.args.starts_with(&["run", "--rm", "--interactive", "--init", "--tty"].map(String::from)));
assert!(!out.args.contains(&"--user".to_string()) && !out.args.contains(&"PYTHONUNBUFFERED".to_string()));
assert!(out.args.ends_with(&["node:22", "sh", "-c", "npm run dev"].map(String::from)));
let target = RunTarget { kind: RunTargetKind::Docker, name: "none".into(), ..Default::default() };
assert!(container_config(&c, &run(&target, &endpoint, &files)).unwrap_err().message.contains("no image"));
}
#[cfg(unix)]
#[test]
fn compose_target_runs_in_a_new_container_of_the_service() {
let target = RunTarget {
kind: RunTargetKind::Compose,
compose_files: vec!["compose.yaml".into(), "compose.dev.yaml".into()],
service: "web".into(),
..Default::default()
};
let none = BTreeMap::new();
let mut c = config("/work/proj/.venv/bin/python", &["manage.py", "test"]);
c.kind = "django-test".into();
c.cwd = Some(PathBuf::from("/work/proj/backend").into());
let mut r = run(&target, &none, &none);
r.container_root = "/app";
let out = container_config(&c, &r).unwrap();
assert_eq!(
out.args.join(" "),
"compose --file /work/proj/compose.yaml --file /work/proj/compose.dev.yaml run --rm --no-TTY \
--name openidea-app-1 --label openidea.run=app --workdir /app/backend \
--env DATA --env PYTHONUNBUFFERED --user 501:20 web python3 manage.py test"
);
assert_eq!(out.env["DATA"], "/app/data");
let target = RunTarget { kind: RunTargetKind::Compose, name: "c".into(), ..Default::default() };
assert!(container_config(&c, &run(&target, &none, &none)).unwrap_err().message.contains("no service"));
}
#[cfg(unix)]
#[test]
fn compose_container_root_follows_the_project_bind_mount() {
let json = r#"{"services": {
"web": {"volumes": [
{"type": "volume", "source": "data", "target": "/data"},
{"type": "bind", "source": "/work", "target": "/src"},
{"type": "bind", "source": "/work/proj", "target": "/app/"}
]},
"worker": {"volumes": [{"type": "bind", "source": "/work", "target": "/src"}]},
"db": {}
}}"#;
let root = Path::new("/work/proj");
assert_eq!(compose_container_root(json, "web", root).unwrap(), "/app", "the closest mount wins");
assert_eq!(compose_container_root(json, "worker", root).unwrap(), "/src/proj");
assert!(compose_container_root(json, "db", root).unwrap_err().message.contains("does not mount"));
assert_eq!(compose_container_root(json, "api", root).unwrap_err().kind, ide_core::error::ErrorKind::NotFound);
}
#[test]
fn names_and_default_folder() {
assert_eq!(container_name("workspace:a/b:Run app", "k3x"), "openidea-workspace-a-b-Run-app-k3x");
assert_eq!(default_container_root(Path::new("/work/my proj")), "/workspaces/my-proj");
}
#[cfg(unix)]
#[test]
fn stop_quotes_the_docker_cli_for_the_shell() {
assert_eq!(shell_word("/usr/local/bin/docker"), "/usr/local/bin/docker");
assert_eq!(shell_word("/Volumes/My Apps/docker"), "'/Volumes/My Apps/docker'");
}
}
+1 -2
View File
@@ -1,8 +1,7 @@
//! Remote work over SSH: SFTP deployment ([`deploy`], [`mapping`]) and run
//! configurations on run targets ([`target`]; Docker and Compose ones in [`docker`]).
//! configurations on run targets ([`target`]).
pub mod deploy;
pub mod docker;
pub mod import;
pub mod mapping;
pub mod service;
-3
View File
@@ -338,9 +338,6 @@ impl RemoteService {
) -> IdeResult<RunInfo> {
let target_id = config.extras.target.as_deref().unwrap_or_default();
let target = run_target(settings, target_id)?;
if target.kind != ide_core::remote_settings::RunTargetKind::Ssh {
return Err(IdeError::internal("container run targets start as local runs of the docker CLI"));
}
let session = self.ssh.session(&target.ssh_config).await?;
let fs = RemoteFs::new(session.sftp().await?);
let folder = if target.project_root.trim().is_empty() {
+1 -1
View File
@@ -241,7 +241,7 @@ pub(crate) fn config(name: &str, kind: &str, command: String, args: Vec<String>,
/// Splits a command line into words: whitespace, `'…'` and `"…"` quoting, backslash escapes
/// (like `parseArgs` in `src/platform/commandLine.ts`).
pub fn split_args(line: &str) -> Vec<String> {
pub(crate) fn split_args(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut has = false;
+1 -1
View File
@@ -1169,7 +1169,7 @@ mod kotlin_bridge_bundle_tests {
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
let extension = data_dir.join("java-usages-1.5.0.jar");
let extension = data_dir.join("java-usages-1.3.0.jar");
let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar");
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
clean_usages_bundles(data_dir, &extension);
-70
View File
@@ -52,43 +52,6 @@ pub fn composer_level(root: &Path) -> Option<PhpLanguageLevel> {
.map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Require))
}
/// Why `composer install` is due in a Composer project.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum ComposerInstall {
/// No `vendor/autoload.php`.
Missing,
/// A package of `composer.lock` is not installed, or at another version.
Outdated,
}
/// The Composer project `file` belongs to (the nearest `composer.json` at or below `root`), and
/// whether its dependencies need `composer install`.
pub fn composer_install(file: &Path, root: &Path) -> Option<(PathBuf, ComposerInstall)> {
let dir = file.ancestors().filter(|d| d.starts_with(root)).find(|d| d.join("composer.json").is_file())?;
let composer: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("composer.json")).ok()?).ok()?;
let vendor = dir.join(composer.pointer("/config/vendor-dir").and_then(Value::as_str).unwrap_or("vendor"));
if !vendor.join("autoload.php").is_file() {
return Some((dir.to_path_buf(), ComposerInstall::Missing));
}
let read = |path: PathBuf| std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());
let lock = read(dir.join("composer.lock"))?;
let installed = read(vendor.join("composer/installed.json")).unwrap_or(Value::Null);
// Composer 2 writes `{"packages": [...], "dev": bool}`, Composer 1 the list itself.
let packages = installed.get("packages").unwrap_or(&installed).as_array().cloned().unwrap_or_default();
let versions: std::collections::HashMap<&str, &str> =
packages.iter().filter_map(|p| Some((p.get("name")?.as_str()?, p.get("version")?.as_str()?))).collect();
let dev = installed.get("dev").and_then(Value::as_bool).unwrap_or(true);
let sections: &[&str] = if dev { &["packages", "packages-dev"] } else { &["packages"] };
let outdated = sections
.iter()
.filter_map(|section| lock.get(*section).and_then(Value::as_array))
.flatten()
.filter_map(|p| Some((p.get("name")?.as_str()?, p.get("version")?.as_str()?)))
.any(|(name, version)| versions.get(name) != Some(&version));
outdated.then(|| (dir.to_path_buf(), ComposerInstall::Outdated))
}
/// Gives each PHP version its own Intelephense workspace storage (below `data_dir`).
/// Intelephense keeps the built-in functions of the version a workspace was first indexed
/// with in that storage, and reuses them after the version changes. Without a version, or
@@ -263,39 +226,6 @@ fn lower_bound(alternative: &str) -> Option<Version> {
mod tests {
use super::*;
#[test]
fn composer_install_is_due_without_vendor_or_when_the_lock_differs() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let app = root.join("app");
std::fs::create_dir_all(app.join("src")).unwrap();
let file = app.join("src/Kernel.php");
assert_eq!(composer_install(&file, root), None, "not a Composer project");
std::fs::write(app.join("composer.json"), r#"{"config": {"vendor-dir": "lib"}}"#).unwrap();
assert_eq!(composer_install(&file, root), Some((app.clone(), ComposerInstall::Missing)));
std::fs::create_dir_all(app.join("lib/composer")).unwrap();
std::fs::write(app.join("lib/autoload.php"), "<?php").unwrap();
assert_eq!(composer_install(&file, root), None, "installed, no lock to compare with");
let lock = r#"{"packages": [{"name": "a/a", "version": "v1.2.0"}], "packages-dev": [{"name": "t/t", "version": "10.0.0"}]}"#;
std::fs::write(app.join("composer.lock"), lock).unwrap();
let installed = |packages: &str, dev: bool| {
let json = format!(r#"{{"packages": [{packages}], "dev": {dev}}}"#);
std::fs::write(app.join("lib/composer/installed.json"), json).unwrap();
composer_install(&file, root)
};
let a = r#"{"name": "a/a", "version": "v1.2.0"}"#;
let t = r#"{"name": "t/t", "version": "10.0.0"}"#;
assert_eq!(installed(&format!("{a},{t}"), true), None);
assert_eq!(installed(a, false), None, "`--no-dev` leaves out the dev packages");
assert_eq!(installed(a, true), Some((app.clone(), ComposerInstall::Outdated)), "a dev package is missing");
let old = r#"{"name": "a/a", "version": "v1.1.0"}"#;
assert_eq!(installed(&format!("{old},{t}"), true), Some((app.clone(), ComposerInstall::Outdated)));
// Composer 1 lists the packages at the top level.
std::fs::write(app.join("lib/composer/installed.json"), format!("[{a},{t}]")).unwrap();
assert_eq!(composer_install(&file, root), None);
assert_eq!(composer_install(&root.join("other.php"), root), None, "outside the Composer project");
}
fn level(composer: Option<&str>, explicit: &str) -> Option<String> {
levels(composer, explicit).0.map(|l| l.version)
}
-45
View File
@@ -29,24 +29,6 @@ pub fn display_name(tool: &str) -> &str {
crate::registry::find(tool).map_or(tool, |t| t.display)
}
/// A project's PHP-CS-Fixer: its committed configuration and `vendor/bin/php-cs-fixer`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhpCsFixer {
pub config: PathBuf,
pub bin: PathBuf,
}
/// PHP-CS-Fixer for `file`: the nearest folder above it with `.php-cs-fixer.php` (which wins, as
/// PHP-CS-Fixer documents) or `.php-cs-fixer.dist.php`, and the nearest `vendor/bin/php-cs-fixer`.
/// `None` without both: Reformat Code keeps the language server.
pub fn php_cs_fixer(file: &Path) -> Option<PhpCsFixer> {
let config = file.parent()?.ancestors().find_map(|dir| {
[".php-cs-fixer.php", ".php-cs-fixer.dist.php"].iter().map(|name| dir.join(name)).find(|p| p.is_file())
})?;
let bin = find_up(file, &Path::new("vendor").join("bin").join("php-cs-fixer"))?;
Some(PhpCsFixer { config, bin })
}
fn package_bin(package: &Path, tool: &str) -> IdeResult<PathBuf> {
let name = display_name(tool);
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?)
@@ -177,33 +159,6 @@ pub fn arguments(
args
}
impl Tools {
/// `php vendor/bin/php-cs-fixer fix` of `target` with the project's configuration, from the
/// configuration's folder; `php` is the interpreter chosen in the settings, else the one on `PATH`.
pub fn php_cs_fixer_command(&self, php: Option<&Path>, fixer: &PhpCsFixer, target: &Path) -> IdeResult<Command> {
let php = match php {
Some(php) => php.to_path_buf(),
None => self.which("php").ok_or_else(|| IdeError::unavailable("PHP-CS-Fixer: PHP is not installed"))?,
};
let mut cmd = self.command(php);
cmd.arg(&fixer.bin)
.args(["fix", "--config"])
.arg(&fixer.config)
.args(["--using-cache=no", "--no-interaction", "--show-progress=none"])
.arg(target);
if let Some(dir) = fixer.config.parent() {
cmd.current_dir(dir);
}
Ok(cmd)
}
}
/// Runs a [`Tools::php_cs_fixer_command`], which rewrites its target file in place.
pub fn run_php_cs_fixer(mut command: Command, cancel: &CancellationToken) -> IdeResult<String> {
crate::process::run_input(&mut command, "", Duration::from_secs(30), cancel)
.map_err(|e| IdeError::invalid(format!("PHP-CS-Fixer: {}", e.message.trim())))
}
pub fn run(tool: &str, mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult<String> {
crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool(tool))
}
-82
View File
@@ -32,7 +32,6 @@ const BUILD_FILES: &[&str] = &[
"Pipfile",
"requirements.txt",
"package.json",
"composer.json",
];
/// Folder names that are (or contain) conventional roots.
@@ -215,15 +214,6 @@ fn module_roots(root: &Path, rel: &str, files: &[String], out: &mut Vec<SourceFo
}
}
// Before `package.json`: a Laravel app's `tests/` is the PHP test root.
if has("composer.json")
&& let Ok(text) = std::fs::read_to_string(dir.join("composer.json"))
{
for (sub, kind) in composer_roots(&text) {
add(&sub, kind, "composer.json");
}
}
if has("package.json") {
// A JVM build's `src` holds source sets, not JavaScript.
if !has("pom.xml") && !GRADLE_FILES.iter().any(|f| has(f)) {
@@ -240,37 +230,6 @@ fn module_roots(root: &Path, rel: &str, files: &[String], out: &mut Vec<SourceFo
}
}
/// `autoload.psr-4` folders as Source roots and `autoload-dev.psr-4` ones as Test roots, relative to
/// the `composer.json` folder; paths that leave it or point into `vendor/` are left out.
fn composer_roots(text: &str) -> Vec<(String, RootKind)> {
let Ok(json) = serde_json::from_str::<serde_json::Value>(text) else { return Vec::new() };
let mut out = Vec::new();
for (section, kind) in [("autoload", RootKind::Source), ("autoload-dev", RootKind::Test)] {
let Some(map) = json.get(section).and_then(|a| a.get("psr-4")).and_then(|m| m.as_object()) else { continue };
for value in map.values() {
let paths: Vec<&str> = match value {
serde_json::Value::String(p) => vec![p.as_str()],
serde_json::Value::Array(a) => a.iter().filter_map(|p| p.as_str()).collect(),
_ => Vec::new(),
};
for path in paths {
let parts: Vec<&str> = path.split(['/', '\\']).filter(|c| !c.is_empty() && *c != ".").collect();
if path.starts_with(['/', '\\'])
|| parts.iter().any(|c| *c == ".." || c.contains(':'))
|| parts.first() == Some(&"vendor")
{
continue;
}
let sub = parts.join("/");
if !out.iter().any(|(p, _)| *p == sub) {
out.push((sub, kind));
}
}
}
}
out
}
/// Gradle's conventional `src/<set>/<lang>` folders that exist: `(set, lang)`.
fn source_sets(dir: &Path) -> Vec<(String, String)> {
let mut out = Vec::new();
@@ -348,47 +307,6 @@ mod tests {
assert!(roots.roots.iter().any(|r| r.path == "server/src" && r.kind == RootKind::Source));
}
#[test]
fn composer_psr4_folders_are_source_and_test_roots() {
let dir = tree(&[
"app/",
"database/factories/",
"database/seeders/",
"lib/",
"tests/",
"vendor/acme/src/",
"package.json",
]);
fs::write(
dir.path().join("composer.json"),
r#"{
"autoload": { "psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": ["database/seeders/", "lib", "missing/"],
"Vendored\\": "vendor/acme/src/",
"Outside\\": "../elsewhere/"
} },
"autoload-dev": { "psr-4": { "Tests\\": "tests/" } }
}"#,
)
.unwrap();
let mut roots = summary(&detect(dir.path()));
roots.sort();
assert_eq!(
roots,
[
"[email protected]",
"database/[email protected]",
"database/[email protected]",
"[email protected]",
"[email protected]",
]
);
assert!(affects_roots(Path::new("composer.json")));
assert!(composer_roots("not json").is_empty());
}
#[test]
fn maven_conventional_roots_and_target() {
let dir = tree(&[
+1 -1
View File
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: OpenIDEA Java usages
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
Bundle-Version: 1.5.0
Bundle-Version: 1.3.0
Bundle-RequiredExecutionEnvironment: JavaSE-21
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
+1 -10
View File
@@ -57,7 +57,7 @@ is not a claim of full IntelliJ feature parity.
## Rebuild and delivery
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
`java-usages-1.5.0.jar`, and added alongside existing initialization bundles.
`java-usages-1.3.0.jar`, and added alongside existing initialization bundles.
Users need no compiler, Maven or Python. Manifest/install version must change
when replacing the bundle because OSGi caches bundles.
@@ -75,14 +75,6 @@ unrelated content and non-Java resource changes; its reproducible SHA-256 is
`cbae3e3a0bcf2818b4cc9d98998bd9b81f92e1ae7803dc757134485f9776a4e1`.
Version 1.3.0 adds the class-hierarchy refactoring commands; its reproducible SHA-256 is
`cca4cc385ace286111730d2a6164333b8958d74d69631548eadca8863255c24a`.
Version 1.4.0 adds `openidea.java.implementations` (Go to Implementation in the workspace's
source folders, before the standard request that also searches every dependency jar); its
reproducible SHA-256 is `d7601493eda846811b4bb8bfe7f3d56e3234a45e663648210db3937cebc770a5`.
Version 1.5.0 answers `{locations, complete}`: `complete` when the type is declared in the sources
(no dependency implements it), otherwise the app also runs the standard request and adds the
dependencies' implementations after the first ones. The search also covers the project's Kotlin
classes attached by the Kotlin bridge; its reproducible SHA-256 is
`7588748c2797f9da2beb209714a75c29b4e31b155f095329bfb7e399d807c20a`.
```sh
export PATH=/path/to/jdk25/bin:$PATH
@@ -112,7 +104,6 @@ python3 java/java-usages/tests/verify.py /path/to/jdtls /path/to/lombok.jar
python3 java/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write.json
OPENIDEA_USAGES_INDEX_STRESS=1 python3 java/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write-indexed.json
python3 java/java-usages/tests/hierarchy.py /path/to/jdtls
python3 java/java-usages/tests/implementations.py /path/to/jdtls
```
The harnesses select the JDT LS configuration for the current platform. The first
Binary file not shown.
-3
View File
@@ -7,9 +7,6 @@
<delegateCommandHandler class="ai.openidea.configuration.ConfigurationCommand">
<command id="openidea.java.configuration"/>
</delegateCommandHandler>
<delegateCommandHandler class="ai.openidea.implementations.ImplementationsCommand">
<command id="openidea.java.implementations"/>
</delegateCommandHandler>
<delegateCommandHandler class="ai.openidea.hierarchy.HierarchyCommand">
<command id="openidea.java.hierarchy.members"/>
<command id="openidea.java.hierarchy.refactor"/>
@@ -1,92 +0,0 @@
package ai.openidea.implementations;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.jdt.core.*;
import org.eclipse.jdt.internal.corext.util.MethodOverrideTester;
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
import org.eclipse.jdt.ls.core.internal.JDTUtils;
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
import org.eclipse.lsp4j.Location;
/**
* Implementations of the type or method at a position, looked up in the workspace's own code
* only (source folders and bridged Kotlin classes): {@code {locations, complete}}. The standard
* request builds the type hierarchy over every dependency jar too, which takes long in a project
* with many dependencies. {@code complete} when the type is declared in the sources, which no
* dependency extends; otherwise the client also asks the standard request for the dependencies'
* implementations.
*/
public final class ImplementationsCommand implements IDelegateCommandHandler {
/** Classpath attribute of the Kotlin bridge's library entries (`java/kotlin-bridge`). */
private static final String KOTLIN_OUTPUT = "ai.openidea.kotlin";
@Override
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
List<Location> locations = new ArrayList<>();
boolean complete = find(arguments, locations, monitor);
return Map.of("locations", locations, "complete", complete);
}
/** Adds the implementations to {@code result}; whether they are all there are. */
private static boolean find(List<Object> arguments, List<Location> result, IProgressMonitor monitor) throws Exception {
String uri = (String) arguments.get(0);
int line = ((Number) arguments.get(1)).intValue();
int column = ((Number) arguments.get(2)).intValue();
ITypeRoot root = JDTUtils.resolveTypeRoot(uri);
if (root == null) return false;
var preferences = JavaLanguageServerPlugin.getPreferencesManager();
IJavaElement element = JDTUtils.findElementAtSelection(root, line, column, preferences, monitor);
IMethod method = null;
IType type;
if (element instanceof IMethod m) {
int flags = m.getFlags();
if (m.isConstructor() || Flags.isStatic(flags) || Flags.isPrivate(flags) || Flags.isFinal(flags)) return false;
method = m;
type = m.getDeclaringType();
} else if (element instanceof IType t) {
type = t;
} else {
return false;
}
if (type == null || type.getJavaProject() == null) return false;
IRegion sources = JavaCore.newRegion();
for (IJavaProject project : JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects()) {
for (IPackageFragmentRoot fragmentRoot : project.getPackageFragmentRoots()) {
if (fragmentRoot.getKind() == IPackageFragmentRoot.K_SOURCE || isKotlinOutput(fragmentRoot)) {
sources.add(fragmentRoot);
}
}
}
ITypeHierarchy hierarchy = type.getJavaProject().newTypeHierarchy(type, sources, monitor);
for (IType subtype : hierarchy.getAllSubtypes(type)) {
if (monitor.isCanceled()) throw new OperationCanceledException();
IMember found;
if (method == null) {
found = subtype.isInterface() ? null : subtype;
} else {
// Seen from the subtype, so its type arguments substitute the method's type variables.
IMethod overriding = new MethodOverrideTester(subtype, hierarchy).findOverridingMethodInType(subtype, method);
found = overriding == null || Flags.isAbstract(overriding.getFlags()) ? null : overriding;
}
Location location = found == null ? null : JDTUtils.toLocation(found);
if (location != null) result.add(location);
}
return !type.isBinary();
}
/** The project's compiled Kotlin classes, attached as a library by the Kotlin bridge. */
private static boolean isKotlinOutput(IPackageFragmentRoot root) throws JavaModelException {
if (root.getKind() != IPackageFragmentRoot.K_BINARY) return false;
IClasspathEntry entry = root.getRawClasspathEntry();
if (entry == null) return false;
for (IClasspathAttribute attribute : entry.getExtraAttributes()) {
if (KOTLIN_OUTPUT.equals(attribute.getName()) && "true".equals(attribute.getValue())) return true;
}
return false;
}
}
-198
View File
@@ -1,198 +0,0 @@
"""Go to Implementation in project sources against a real JDT LS. Argument: jdtls home."""
import json
import pathlib
import platform
import queue
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import urllib.parse
home = pathlib.Path(sys.argv[1]).resolve()
bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-java-usages.jar'
work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-implementations-'))
config = work / 'config'
config.mkdir()
system, machine = platform.system(), platform.machine().lower()
config_name = {'Windows': 'config_win', 'Darwin': 'config_mac'}.get(system, 'config_linux')
config_names = ([config_name + '_arm'] if machine in ('arm64', 'aarch64') else []) + [config_name]
config_source = next(home / name / 'config.ini' for name in config_names if (home / name / 'config.ini').is_file())
(config / 'config.ini').write_bytes(config_source.read_bytes())
project = work / 'project'
(project / 'src/zoo').mkdir(parents=True)
(project / '.project').write_text('<projectDescription><name>zoo</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>')
# `kotlin`: the project's compiled Kotlin classes, as the Kotlin bridge attaches them.
(project / '.classpath').write_text('<classpath><classpathentry kind="src" path="src"/><classpathentry kind="lib" path="kotlin"><attributes><attribute name="ai.openidea.kotlin" value="true"/></attributes></classpathentry><classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/><classpathentry kind="output" path="bin"/></classpath>')
sources = {
'Repo.java': '''package zoo;
public interface Repo<T> {
void save(T item);
default String name() {
return "repo";
}
}
''',
'SubRepo.java': '''package zoo;
public interface SubRepo extends Repo<String> {
}
''',
'Base.java': '''package zoo;
public abstract class Base implements Repo<Integer> {
public abstract void save(Integer item);
}
''',
'Numbers.java': '''package zoo;
public class Numbers extends Base {
public void save(Integer item) {
}
}
''',
'Words.java': '''package zoo;
public class Words implements SubRepo {
public void save(String item) {
}
public String name() {
return "words";
}
}
''',
'Task.java': '''package zoo;
public class Task implements Runnable {
public void run() {
}
}
''',
'Use.java': '''package zoo;
public class Use {
void use(Repo<String> repo, Runnable task) {
repo.save("x");
task.run();
}
void alone() {
}
}
''',
}
for name, text in sources.items():
(project / 'src/zoo' / name).write_text(text)
uri = lambda name: (project / 'src/zoo' / name).as_uri()
# Stands in for a Kotlin class of the project implementing the Java interface.
(work / 'kotlin-src/zoo').mkdir(parents=True)
(work / 'kotlin-src/zoo/Longs.java').write_text('package zoo;\n\npublic class Longs implements Repo<Long> {\n public void save(Long item) {\n }\n}\n')
subprocess.run(['javac', '-implicit:none', '-sourcepath', str(project / 'src'), '-d', str(project / 'kotlin'), str(work / 'kotlin-src/zoo/Longs.java')], check=True)
log = (work / 'stderr.log').open('w')
proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home / 'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work / 'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log)
messages = queue.Queue()
lock = threading.Lock()
def send(message):
data = json.dumps(message).encode()
with lock:
proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data)
proc.stdin.flush()
def read():
while True:
headers = {}
while True:
line = proc.stdout.readline()
if not line:
return
if line == b'\r\n':
break
k, v = line.decode().split(':', 1)
headers[k.lower()] = v.strip()
message = json.loads(proc.stdout.read(int(headers['content-length'])))
if 'method' in message and 'id' in message:
send({'jsonrpc': '2.0', 'id': message['id'], 'result': [] if message['method'] == 'workspace/configuration' else None})
else:
messages.put(message)
threading.Thread(target=read, daemon=True).start()
seq = 0
def request(method, params):
global seq
seq += 1
send({'jsonrpc': '2.0', 'id': seq, 'method': method, 'params': params})
deadline = time.monotonic() + 180
while time.monotonic() < deadline:
msg = messages.get(timeout=max(1, deadline - time.monotonic()))
if msg.get('id') == seq:
if 'error' in msg:
raise RuntimeError(msg['error'])
return msg.get('result')
raise TimeoutError(method)
def position(name, needle):
lines = sources[name].splitlines()
line = next(i for i, t in enumerate(lines) if needle in t)
return line, lines[line].index(needle)
def command(name, *args):
return request('workspace/executeCommand', {'command': name, 'arguments': list(args)})
def implementations(file, needle):
line, col = position(file, needle)
found = command('openidea.java.implementations', uri(file), line, col)
names = []
for loc in found['locations']:
name = pathlib.Path(urllib.parse.unquote(urllib.parse.urlparse(loc['uri']).path)).name
names.append(f"{name}:{loc['range']['start']['line']}")
return sorted(names), found['complete']
try:
init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'zoo'}], 'capabilities': {}, 'initializationOptions': {'bundles': [str(bundle)], 'extendedClientCapabilities': {'classFileContentsSupport': True}, 'settings': {'java': {'import': {'gradle': {'enabled': False}, 'maven': {'enabled': False}}}}}})
assert 'openidea.java.implementations' in init['capabilities']['executeCommandProvider']['commands']
send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}})
for name, text in sources.items():
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': uri(name), 'languageId': 'java', 'version': 1, 'text': text}}})
request('java/buildWorkspace', True)
# (implementations in the sources, whether they are all there are)
cases = {
# Classes, abstract ones too, and those of the bridged Kotlin output; not the sub-interface.
('Repo.java', 'Repo<T>'): (['Base.java:2', 'Longs.java:0', 'Numbers.java:2', 'Words.java:2'], True),
# Through the type arguments; not the abstract re-declaration.
('Repo.java', 'save(T'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
('Use.java', 'save("x")'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
('Repo.java', 'name()'): (['Words.java:6'], True),
# A library type's implementations in the sources; the library has more.
('Use.java', 'Runnable task'): (['Task.java:2'], False),
('Use.java', 'run();'): (['Task.java:3'], False),
('Use.java', 'alone()'): ([], True),
}
failures = []
for (file, needle), expected in cases.items():
actual = implementations(file, needle)
print(f'{file} {needle!r}: {actual}')
if actual != expected:
failures.append((file, needle, expected, actual))
assert not failures, failures
print('ok')
finally:
proc.kill()
shutil.rmtree(work, ignore_errors=True)
+1 -1
View File
@@ -17,4 +17,4 @@ install-hint = "dotnet tool install --global csharp-ls"
adapter = "netcoredbg"
command = "netcoredbg"
args = ["--interpreter=vscode"]
install-hint = "Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers"
install-hint = "Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)"
+1 -1
View File
@@ -18,4 +18,4 @@ initialization-options = { AutomaticWorkspaceInit = true }
adapter = "netcoredbg"
command = "netcoredbg"
args = ["--interpreter=vscode"]
install-hint = "Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers"
install-hint = "Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)"
+1 -1
View File
@@ -59,4 +59,4 @@ adapter = "js-debug"
command = "js-debug-adapter"
args = ["${port}", "127.0.0.1"]
transport = "tcp"
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`"
+1 -1
View File
@@ -58,4 +58,4 @@ adapter = "js-debug"
command = "js-debug-adapter"
args = ["${port}", "127.0.0.1"]
transport = "tcp"
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`"
+1 -1
View File
@@ -32,4 +32,4 @@ install-hint = "brew install kotlin-language-server (https://github.com/fwcd/kot
[debugger]
adapter = "kotlin-debug-adapter"
command = "kotlin-debug-adapter"
install-hint = "Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers"
install-hint = "Install https://github.com/fwcd/kotlin-debug-adapter and put `kotlin-debug-adapter` on PATH"
+1 -1
View File
@@ -29,4 +29,4 @@ install-hint = "Install phpactor (https://phpactor.readthedocs.io/en/master/usag
[debugger]
adapter = "php-debug"
command = "php-debug-adapter"
install-hint = "Install PHP Debug (Xdebug) in Settings › Languages & Frameworks › Language Servers, and enable Xdebug"
install-hint = "Install vscode-php-debug (Xdebug) as `php-debug-adapter` (e.g. via mason.nvim) and enable Xdebug"
+1 -1
View File
@@ -23,4 +23,4 @@ adapter = "codelldb"
command = "codelldb"
args = ["--port", "${port}"]
transport = "tcp"
install-hint = "Install CodeLLDB in Settings › Languages & Frameworks › Language Servers"
install-hint = "Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH"
+1 -1
View File
@@ -17,4 +17,4 @@ install-hint = "npm i -g bash-language-server (optionally install shellcheck and
[debugger]
adapter = "bash-debug"
command = "bash-debug-adapter"
install-hint = "Install Bash Debug in Settings › Languages & Frameworks › Language Servers"
install-hint = "Install https://github.com/rogalmic/vscode-bash-debug as `bash-debug-adapter` (e.g. via mason.nvim)"
+1 -1
View File
@@ -69,4 +69,4 @@ adapter = "js-debug"
command = "js-debug-adapter"
args = ["${port}", "127.0.0.1"]
transport = "tcp"
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`"
+1 -1
View File
@@ -58,4 +58,4 @@ adapter = "js-debug"
command = "js-debug-adapter"
args = ["${port}", "127.0.0.1"]
transport = "tcp"
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`"
+1 -1
View File
@@ -16,4 +16,4 @@ adapter = "codelldb"
command = "codelldb"
args = ["--port", "${port}"]
transport = "tcp"
install-hint = "Install CodeLLDB in Settings › Languages & Frameworks › Language Servers"
install-hint = "Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases)"
-7
View File
@@ -152,13 +152,6 @@ is loaded. Menus, shortcuts and status bar items refer to the plugin's
declared commands. A plugin shortcut never takes a keystroke the IDE (or the
user) already uses; the user can rebind it in the keymap editor.
A `when` clause (on commands and menus) can test whether the open project
contains a file or folder: `when = "workspaceContains:.devcontainer/devcontainer.json"`.
The path is exact (no globs) and relative to the project root; in a workspace the
key is true when any of its projects contains the path. It updates when
files change. An IDE that doesn't know the key treats it as
false: the plugin still installs and the key reads as false there.
### Activation
Code is instantiated lazily: when one of the plugin's commands runs, or when
+1 -4
View File
@@ -200,10 +200,7 @@ async function smoke(language: string) {
}
// Tests, with the intentionally failing one switched on: one failure, located, the rest passed.
// Switched on in the saved configuration, as reruns launch the configuration as it is saved now.
const failing = c.tests.failing(config(c.tests.config));
useRun.setState((s) => ({ configs: s.configs.map((x) => (x.id === failing.id ? failing : x)) }));
const { id, output } = await runToEnd(failing);
const { id, output } = await runToEnd(c.tests.failing(config(c.tests.config)));
const tests = runtime(id)!.tests!;
const log = `\n--- output ---\n${output.slice(-80).join("\n")}`;
expect(tests.counts(), log).toMatchObject({ passed: c.tests.passed, failed: 1 });
+1 -2
View File
@@ -129,8 +129,7 @@ pub fn project_rename(window: Window, state: State<'_, AppState>, name: String)
Ok(project_info(&project))
}
// Not on the main thread: closing stops the project's language servers, runs and file watcher.
#[tauri::command(async)]
#[tauri::command]
#[specta::specta]
pub fn close_project(window: Window, state: State<'_, AppState>) {
crate::windows::close_auxiliary_windows(window.app_handle(), window.label());
-7
View File
@@ -92,13 +92,6 @@ pub async fn debug_resume(state: State<'_, AppState>, session: DebugSessionId, t
ide_host::ops::debug::debug_resume(&state.host, "", session, thread_id).await
}
/// Types `text` into the session's program (a `runInTerminal` program).
#[tauri::command]
#[specta::specta]
pub async fn debug_send_input(state: State<'_, AppState>, session: DebugSessionId, text: String) -> IdeResult<()> {
ide_host::ops::debug::debug_send_input(&state.host, "", session, text).await
}
#[tauri::command]
#[specta::specta]
pub async fn debug_pause(state: State<'_, AppState>, session: DebugSessionId, thread_id: i64) -> IdeResult<()> {
-17
View File
@@ -80,23 +80,6 @@ pub async fn lsp_goto(
ide_host::ops::lsp::lsp_goto(&state.host, window.label(), doc, pos, kind).await
}
/// Go to Implementation of the symbol at `pos`. Implementations found before a
/// slower search for the rest arrive on `on_partial`; the result has them all.
/// Cancel with `cancel_request(request)`.
#[tauri::command]
#[specta::specta]
pub async fn lsp_goto_implementation(
window: Window,
state: State<'_, AppState>,
request: RequestId,
doc: DocId,
pos: Pos,
on_partial: tauri::ipc::Channel<Vec<Location>>,
) -> IdeResult<Vec<Location>> {
let on_partial = super::sender(on_partial);
ide_host::ops::lsp::lsp_goto_implementation(&state.host, window.label(), request, doc, pos, on_partial).await
}
/// Implementations of the symbols at `positions`, one list per position (the
/// editor's "is implemented / overridden" gutter markers). Cancel with
/// `cancel_request(request)`.
-3
View File
@@ -219,7 +219,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
lsp::lsp_signature_help,
lsp::lsp_goto,
lsp::lsp_implementations,
lsp::lsp_goto_implementation,
lsp::lsp_virtual_document,
lsp::lsp_resolve_class_file,
lsp::lsp_references,
@@ -382,7 +381,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
debug::debug_completions,
debug::debug_resume,
debug::debug_pause,
debug::debug_send_input,
debug::debug_hot_reload,
debug::debug_hot_restart,
debug::debug_step,
@@ -402,7 +400,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
sdk::sdk_python_effective,
sdk::sdk_php_effective,
sdk::sdk_php_language_level,
sdk::sdk_php_composer_install,
sdk::sdk_project_libraries,
sdk::sdk_locate_tools,
sdk::sdk_python_create_venv,
-11
View File
@@ -42,17 +42,6 @@ pub async fn sdk_php_language_level(
ide_host::ops::sdk::sdk_php_language_level(&state.host, window.label(), path).await
}
/// The Composer project `path` belongs to, when its dependencies need `composer install`.
#[tauri::command]
#[specta::specta]
pub async fn sdk_php_composer_install(
window: Window,
state: State<'_, AppState>,
path: PathBuf,
) -> IdeResult<Option<ide_host::ops::sdk::ComposerInstallState>> {
ide_host::ops::sdk::sdk_php_composer_install(&state.host, window.label(), path).await
}
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
#[tauri::command]
-4
View File
@@ -10,8 +10,6 @@ mod dev;
mod events;
mod jump_list;
#[cfg(any(target_os = "linux", test))]
mod linux_webkit;
#[cfg(any(target_os = "linux", test))]
mod linux_window_state;
mod logging;
#[cfg(target_os = "macos")]
@@ -70,8 +68,6 @@ pub fn run() {
// Before the first window: one taskbar identity for every launch path.
jump_list::set_app_id(&context.config().identifier);
logging::init(&context.config().identifier);
#[cfg(target_os = "linux")]
linux_webkit::configure();
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier);
// Managed LSP/DAP installs; must run before other threads start and before PATH is cached.
-51
View File
@@ -1,51 +0,0 @@
//! WebKitGTK's DMA-BUF renderer fails on Wayland with the NVIDIA driver: the window
//! never shows and GDK logs "Error 71 (Protocol error) dispatching to Wayland display".
//! On that combination the renderer is turned off before the first web view is created.
const DISABLE_DMABUF: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
/// Run before other threads start (`set_var`).
#[cfg(target_os = "linux")]
pub fn configure() {
let nvidia = ["/proc/driver/nvidia/version", "/sys/module/nvidia"].iter().any(|p| std::path::Path::new(p).exists());
if disable_dmabuf(|k| std::env::var(k).ok(), nvidia) {
tracing::info!("Wayland with the NVIDIA driver: {DISABLE_DMABUF}=1");
unsafe { std::env::set_var(DISABLE_DMABUF, "1") };
}
}
/// A Wayland session on the NVIDIA driver, unless the user already set the variable.
fn disable_dmabuf(var: impl Fn(&str) -> Option<String>, nvidia: bool) -> bool {
if !nvidia || var(DISABLE_DMABUF).is_some() {
return false;
}
let wayland_display = var("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty());
// GDK tries the backends of `GDK_BACKEND` in order.
let gdk_wayland =
var("GDK_BACKEND").is_none_or(|b| matches!(b.split(',').next().map(str::trim), Some("wayland" | "*" | "")));
wayland_display && gdk_wayland
}
#[cfg(test)]
mod tests {
use super::*;
fn decide(vars: &[(&str, &str)], nvidia: bool) -> bool {
disable_dmabuf(|k| vars.iter().find(|(name, _)| *name == k).map(|(_, v)| v.to_string()), nvidia)
}
#[test]
fn turns_the_dmabuf_renderer_off_on_wayland_with_nvidia_only() {
assert!(decide(&[("WAYLAND_DISPLAY", "wayland-0")], true));
assert!(decide(&[("WAYLAND_DISPLAY", "wayland-0"), ("GDK_BACKEND", "wayland,x11")], true));
assert!(!decide(&[("WAYLAND_DISPLAY", "wayland-0")], false));
assert!(!decide(&[("DISPLAY", ":0")], true));
assert!(!decide(&[("WAYLAND_DISPLAY", "")], true));
assert!(!decide(&[("WAYLAND_DISPLAY", "wayland-0"), ("GDK_BACKEND", "x11")], true));
}
#[test]
fn keeps_the_users_own_setting() {
assert!(!decide(&[("WAYLAND_DISPLAY", "wayland-0"), (DISABLE_DMABUF, "0")], true));
}
}
+9 -44
View File
@@ -8,7 +8,7 @@ use objc2::{
runtime::{AnyClass, AnyObject, Imp, ProtocolObject, Sel},
};
use objc2_app_kit::{NSWindow, NSWindowUserTabbingPreference};
use objc2_foundation::{NSNotificationCenter, NSObjectProtocol, NSRect};
use objc2_foundation::{NSNotificationCenter, NSObjectProtocol};
use tauri::{AppHandle, Manager, WebviewWindow};
fn tabbing_preference(app: &AppHandle) -> IdeResult<TabbingPreference> {
@@ -136,35 +136,30 @@ pub fn set_simple_fullscreen(window: &WebviewWindow, on: bool) -> tauri::Result<
})
}
/// A window in simple fullscreen: its `NSWindow` and the minimum size restored when it leaves.
struct Covering {
native: usize,
min_size: objc2_foundation::NSSize,
}
thread_local! {
static COVERING: std::cell::RefCell<std::collections::HashMap<String, Covering>> = std::cell::RefCell::default();
/// Minimum sizes of windows in simple fullscreen, restored when they leave it.
static MIN_SIZES: std::cell::RefCell<std::collections::HashMap<String, objc2_foundation::NSSize>> =
std::cell::RefCell::default();
}
/// Simple fullscreen drops the title bar and resizing; ours is transparent over the web toolbar,
/// so keep it and its buttons, the green one (enabled only for a resizable window) leading back
/// out of fullscreen. A minimum size of the whole screen keeps the edges from being dragged.
/// A titled window is kept below the menu bar or camera housing; see `constrain_frame_rect`.
fn keep_window_buttons(window: &WebviewWindow, on: bool) {
use objc2_app_kit::NSWindowStyleMask;
let Ok(pointer) = window.ns_window() else { return };
let native = unsafe { &*pointer.cast::<NSWindow>() };
let label = window.label().to_owned();
if !on {
if let Some(covering) = COVERING.with_borrow_mut(|windows| windows.remove(&label)) {
native.setMinSize(covering.min_size);
if let Some(size) = MIN_SIZES.with_borrow_mut(|sizes| sizes.remove(&label)) {
native.setMinSize(size);
}
return;
}
let Some(screen) = native.screen() else { return };
let frame = screen.frame();
COVERING.with_borrow_mut(|windows| {
windows.entry(label).or_insert_with(|| Covering { native: pointer as usize, min_size: native.minSize() });
MIN_SIZES.with_borrow_mut(|sizes| {
sizes.entry(label).or_insert_with(|| native.minSize());
});
native.setMinSize(frame.size);
native.setStyleMask(native.styleMask() | NSWindowStyleMask::Titled | NSWindowStyleMask::Resizable);
@@ -182,7 +177,7 @@ pub fn forget_simple_fullscreen(app: &AppHandle, label: &str) -> bool {
if was {
let label = label.to_owned();
let _ = app.run_on_main_thread(move || {
COVERING.with_borrow_mut(|windows| windows.remove(&label));
MIN_SIZES.with_borrow_mut(|sizes| sizes.remove(&label));
present(any);
});
}
@@ -223,39 +218,9 @@ pub fn intercept_toggle_fullscreen(window: &WebviewWindow) {
let _ = NATIVE_TOGGLE_FULLSCREEN.set(native);
let replacement: Imp = std::mem::transmute(toggle_fullscreen as ToggleFullScreen);
objc2::ffi::class_replaceMethod(class, selector, replacement, types);
let selector = objc2::sel!(constrainFrameRect:toScreen:);
let Some(method) = (*class).instance_method(selector) else { return };
let native: ConstrainFrameRect = std::mem::transmute(method.implementation());
let types = objc2::ffi::method_getTypeEncoding(method);
let _ = NATIVE_CONSTRAIN_FRAME_RECT.set(native);
let replacement: Imp = std::mem::transmute(constrain_frame_rect as ConstrainFrameRect);
objc2::ffi::class_replaceMethod(class, selector, replacement, types);
});
}
type ConstrainFrameRect = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, NSRect, *mut AnyObject) -> NSRect;
static NATIVE_CONSTRAIN_FRAME_RECT: std::sync::OnceLock<ConstrainFrameRect> = std::sync::OnceLock::new();
/// AppKit keeps a titled window's top edge below the menu bar, and on a notched display below the
/// camera housing, whenever the window is placed, shown or resized. A window in simple fullscreen
/// is titled for its buttons but must still cover the whole screen.
unsafe extern "C-unwind" fn constrain_frame_rect(
this: *mut AnyObject,
selector: Sel,
frame: NSRect,
screen: *mut AnyObject,
) -> NSRect {
if COVERING.with_borrow(|windows| windows.values().any(|covering| covering.native == this as usize)) {
return frame;
}
match NATIVE_CONSTRAIN_FRAME_RECT.get() {
Some(native) => unsafe { native(this, selector, frame, screen) },
None => frame,
}
}
unsafe extern "C-unwind" fn toggle_fullscreen(this: *mut AnyObject, selector: Sel, sender: *mut AnyObject) {
if let Some(window) = APP.get().and_then(|app| simple_fullscreen_window(app, this)) {
if let Err(error) = crate::window_groups::toggle_fullscreen(&window) {
+5 -73
View File
@@ -409,12 +409,6 @@ export const commands = {
* `cancel_request(request)`.
*/
lspImplementations: (request: RequestId, doc: DocId, positions: Pos[]) => typedError<Location[][], IdeError>(__TAURI_INVOKE("lsp_implementations", { request, doc, positions })),
/**
* Go to Implementation of the symbol at `pos`. Implementations found before a
* slower search for the rest arrive on `on_partial`; the result has them all.
* Cancel with `cancel_request(request)`.
*/
lspGotoImplementation: (request: RequestId, doc: DocId, pos: Pos, onPartial: Channel<Location[]>) => typedError<Location[], IdeError>(__TAURI_INVOKE("lsp_goto_implementation", { request, doc, pos, onPartial })),
/**
* Contents of a read-only language-server document, including JDT library
* classes and disk class files. Cached per project.
@@ -741,8 +735,6 @@ export const commands = {
debugCompletions: (session: DebugSessionId, frameId: number | null, text: string, line: number, column: number) => typedError<DebugCompletion[], IdeError>(__TAURI_INVOKE("debug_completions", { session, frameId, text, line, column })),
debugResume: (session: DebugSessionId, threadId: number) => typedError<null, IdeError>(__TAURI_INVOKE("debug_resume", { session, threadId })),
debugPause: (session: DebugSessionId, threadId: number) => typedError<null, IdeError>(__TAURI_INVOKE("debug_pause", { session, threadId })),
/** Types `text` into the session's program (a `runInTerminal` program). */
debugSendInput: (session: DebugSessionId, text: string) => typedError<null, IdeError>(__TAURI_INVOKE("debug_send_input", { session, text })),
debugHotReload: (session: DebugSessionId) => typedError<null, IdeError>(__TAURI_INVOKE("debug_hot_reload", { session })),
debugHotRestart: (session: DebugSessionId) => typedError<null, IdeError>(__TAURI_INVOKE("debug_hot_restart", { session })),
debugStep: (session: DebugSessionId, threadId: number, kind: StepKind) => typedError<null, IdeError>(__TAURI_INVOKE("debug_step", { session, threadId, kind })),
@@ -784,11 +776,6 @@ export const commands = {
sdkPhpEffective: () => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_php_effective")),
/** The PHP language level of the project `path` belongs to: the effective one and Auto's. */
sdkPhpLanguageLevel: (path: string) => typedError<PhpLanguageLevels, IdeError>(__TAURI_INVOKE("sdk_php_language_level", { path })),
/** The Composer project `path` belongs to, when its dependencies need `composer install`. */
sdkPhpComposerInstall: (path: string) => typedError<{
root: IpcPath,
reason: ComposerInstall,
} | null, IdeError>(__TAURI_INVOKE("sdk_php_composer_install", { path })),
/**
* SDK / library source folders shown under "External Libraries" in the Project view,
* with the Maven / Gradle dependency jars of the build model.
@@ -2391,19 +2378,6 @@ export type ComposeOp = "start" | "stop" | "restart" |
/** Stops and removes the project's containers and networks. */
"down";
/** Why `composer install` is due in a Composer project. */
export type ComposerInstall =
/** No `vendor/autoload.php`. */
"missing" |
/** A package of `composer.lock` is not installed, or at another version. */
"outdated";
/** The Composer project `path` belongs to, when its dependencies need `composer install`. */
export type ComposerInstallState = {
root: IpcPath,
reason: ComposerInstall,
};
/** The user's answer on an install or consent page. */
export type Consent = {
/**
@@ -2863,12 +2837,7 @@ export type DebugEvent =
* First event of every session (only build output of a root session's
* `cargo` step can precede it). `parent` is set for adapter-started child sessions.
*/
{ kind: "started"; name: string; adapter: string; parent: DebugSessionId | null } | { kind: "initialized" } | { kind: "stopped"; threadId: number; reason: string; description: string | null; allThreadsStopped: boolean } | { kind: "continued"; threadId: number; allThreadsContinued: boolean } | { kind: "output"; category: string; output: string } | { kind: "thread"; threadId: number; started: boolean } |
/**
* The program runs in a terminal the IDE gave it (`runInTerminal`): what is typed for it
* in the debug console reaches its input.
*/
{ kind: "programInput" } | { kind: "breakpointsChanged"; path: IpcPath } | { kind: "terminated" } | { kind: "exited"; code: number } |
{ kind: "started"; name: string; adapter: string; parent: DebugSessionId | null } | { kind: "initialized" } | { kind: "stopped"; threadId: number; reason: string; description: string | null; allThreadsStopped: boolean } | { kind: "continued"; threadId: number; allThreadsContinued: boolean } | { kind: "output"; category: string; output: string } | { kind: "thread"; threadId: number; started: boolean } | { kind: "breakpointsChanged"; path: IpcPath } | { kind: "terminated" } | { kind: "exited"; code: number } |
/**
* java-debug's `hotcodereplace` event: `change_type` is `BUILD_COMPLETE` (classes
* changed and can be reloaded), `STARTING`, `END`, `WARNING` or `ERROR`.
@@ -6518,57 +6487,22 @@ export type RunSettings = {
terminationTimeoutMs?: number,
};
/**
* Build, Execution, Deployment › Run Targets: run configurations on another machine
* or in a container.
*/
/** Build, Execution, Deployment › Run Targets: run configurations on another machine. */
export type RunTarget = {
kind?: RunTargetKind,
id?: string,
name?: string,
/** [`SshConfiguration::id`] (SSH). */
/** [`SshConfiguration::id`]. */
sshConfig?: string,
/** Remote folder the project is synchronised to and run from (SSH). */
/** Remote folder the project is synchronised to and run from. */
projectRoot?: string,
/** Upload changed project files before each run (SSH). */
/** Upload changed project files before each run. */
syncBeforeRun?: boolean,
/** Interpreters on the target; empty = the one on the remote `PATH`. */
python?: string,
node?: string,
java?: string,
/**
* Id of a Docker connection of the Services tool window (Docker, Compose);
* empty = the CLI's own daemon.
*/
dockerConnection?: string,
/** Image to run (Docker). */
image?: string,
/** Compose files, relative to the project; empty = the project's default one (Compose). */
composeFiles?: string[],
/** Service to run (Compose). */
service?: string,
/**
* Where the project is in the container; empty = `/workspaces/<folder>` (Docker),
* the service's bind mount of the project (Compose).
*/
containerRoot?: string,
/** Published ports, as `docker run --publish` takes them (`8000:8000`). */
ports?: string[],
/** More options for `docker run` / `docker compose run`, shell-like quoted. */
runOptions?: string,
/** Run as the image's user instead of the project folder's owner (Linux). */
imageUser?: boolean,
};
/** Where a run target runs configurations. */
export type RunTargetKind =
/** Another machine over SSH, the project synchronised to its folder. */
"ssh" |
/** A new container of an image (`docker run --rm`), the project bind-mounted. */
"docker" |
/** A new container of a Compose service (`docker compose run --rm`). */
"compose";
export type RustSettings = {
/** Directory holding `cargo` / `rustc`; empty = `PATH` / rustup default. */
toolchain?: string,
@@ -6936,8 +6870,6 @@ export type SmartKeysSettings = {
/** Reformat on paste: `none` | `indentBlock` | `indentEachLine`. */
reformatOnPaste?: string,
smartIndent?: boolean,
/** Enter › Insert documentation comment stub: Enter after `/**` closes the comment. */
insertDocStub?: boolean,
/** Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`. */
backspaceUnindent?: string,
};
-11
View File
@@ -21,7 +21,6 @@ import {
type SyntaxHighlights,
type TextChange,
} from "@/platform/ipc";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { EditorState } from "@codemirror/state";
import { customRegions } from "./cm/regions";
@@ -61,11 +60,6 @@ export interface DocBackend {
onDirty(handler: (e: { doc: DocId; dirty: boolean }) => void): () => void;
/** Files changed on disk (used to refresh nothing but closed-file state). */
onFsChanged?(handler: (e: { removed: string[] }) => void): () => void;
/**
* Every OpenIDEA window lost focus to another application. Also seen while focus is in a frame
* (a plugin's web view), which hides the window's own `blur`.
*/
onApplicationDeactivated?(handler: () => void): () => void;
/**
* An open document's language changed (Settings › File Types, Override File
* Type), or its own mode (`DocumentInfo.uiMode`).
@@ -92,11 +86,6 @@ export const ipcBackend: DocBackend = {
onPatched: (h) => onIdeEvent("docPatched", (e) => h(e)),
onDirty: (h) => onIdeEvent("docDirty", (e) => h(e)),
onFsChanged: (h) => onIdeEvent("fsChanged", (e) => h(e)),
onApplicationDeactivated: (h) => {
if (!isTauri()) return () => {};
const listener = getCurrentWebviewWindow().listen("ide://application-deactivated", () => h());
return () => void listener.then((unlisten) => unlisten());
},
onLanguage: (h) => onIdeEvent("docLanguage", (e) => h(e)),
};
-77
View File
@@ -1,77 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { undo, history } from "@codemirror/commands";
import { enterInDocComment, phpDocTags } from "./docComment";
const views: EditorView[] = [];
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
const slashStar = EditorState.languageData.of(() => [
{ commentTokens: { line: "//", block: { open: "/*", close: "*/" } } },
]);
const hash = EditorState.languageData.of(() => [{ commentTokens: { line: "#" } }]);
/** `|` marks the caret. */
function enter(text: string, language = "php", tokens = slashStar) {
const at = text.indexOf("|");
const view = new EditorView({
state: EditorState.create({
doc: text.replace("|", ""),
selection: EditorSelection.cursor(at),
extensions: [tokens, history()],
}),
});
views.push(view);
const handled = enterInDocComment(language)(view);
const s = view.state;
const caret = s.selection.main.head;
return { handled, view, text: s.doc.sliceString(0, caret) + "|" + s.doc.sliceString(caret) };
}
describe("Enter after /**", () => {
it("lists a PHP function's parameters and return type, caret on the summary line", () => {
const r = enter("/**|\nfunction f(int $a, $b): string {}\n/**\n * old\n */\nfunction g() {}");
expect(r.handled).toBe(true);
expect(r.text).toBe(
"/**\n * |\n * @param int $a\n * @param $b\n * @return string\n */\nfunction f(int $a, $b): string {}\n/**\n * old\n */\nfunction g() {}",
);
undo(r.view);
expect(r.view.state.doc.toString()).toBe(
"/**\nfunction f(int $a, $b): string {}\n/**\n * old\n */\nfunction g() {}",
);
});
it("uses the indentation of the /** line inside a class", () => {
expect(
enter("class A {\n /**|\n public static function make(?array $o = [], string ...$rest): ?static {}\n}")
.text,
).toBe(
"class A {\n /**\n * |\n * @param ?array $o\n * @param string ...$rest\n * @return ?static\n */\n public static function make(?array $o = [], string ...$rest): ?static {}\n}",
);
});
it("gives constructors and void methods no @return; promoted parameters keep their type", () => {
expect(phpDocTags("public function __construct(private int $a, #[Attr] readonly Foo&Bar $b) {}")).toEqual([
"@param int $a",
"@param Foo&Bar $b",
]);
expect(phpDocTags("function run(array &$x = array(1, 2), &$y): void {}")).toEqual(["@param array $x", "@param $y"]);
expect(phpDocTags("abstract protected function find(int|string $id): ?Model;")).toEqual([
"@param int|string $id",
"@return ?Model",
]);
});
it("closes the comment with an empty summary above anything else, and in other /* */ languages", () => {
expect(enter("/**|\nclass A {}").text).toBe("/**\n * |\n */\nclass A {}");
expect(enter(" /**|\n\nconst x = 1;", "javascript").text).toBe(" /**\n * |\n */\n\nconst x = 1;");
});
it("only breaks the line in an existing doc block, after other text, or without /* */ comments", () => {
expect(enter("/**|\n * old\n */\nfunction f() {}").handled).toBe(false);
expect(enter("$a = 1; /**|").handled).toBe(false);
expect(enter("/** x|").handled).toBe(false);
expect(enter("/**|", "python", hash).handled).toBe(false);
});
});
-88
View File
@@ -1,88 +0,0 @@
/**
* Smart Keys › Enter › Insert documentation comment stub: Enter right after a `/**` that opens a
* new comment closes it, the caret on the summary line. In PHP the stub of a function lists its
* parameters (`@param`) and return type (`@return`), as PhpStorm does.
*/
import { EditorSelection } from "@codemirror/state";
import type { Command } from "@codemirror/view";
/** `@param` / `@return` lines for the PHP function declared in `text` (the code below the `/**`). */
export function phpDocTags(text: string): string[] {
const m =
/^\s*(?:#\[[^\]]*\]\s*)*(?:(?:abstract|final|public|protected|private|static)\s+)*function\s+&?\s*(\w+)\s*\(/.exec(
text,
);
if (!m) return [];
let depth = 1;
let i = m[0].length;
for (; i < text.length && depth > 0; i++) {
const c = text[i];
if (c === "(" || c === "[") depth++;
else if (c === ")" || c === "]") depth--;
}
if (depth > 0) return [];
const tags = splitTopLevel(text.slice(m[0].length, i - 1)).flatMap((param) => {
const p = /^(?:#\[[^\]]*\]\s*)*(?:(?:public|protected|private|readonly)\s+)*([^$=.]*?)\s*(\.\.\.)?\s*(\$\w+)/.exec(
param.trim(),
);
if (!p) return [];
// `&$x` passes by reference: the `&` belongs to no type (`Foo&Bar` is an intersection).
const type = p[1]!.replace(/&\s*$/, "").trim().replace(/\s+/g, " ");
const name = (p[2] ?? "") + p[3];
return [type ? `@param ${type} ${name}` : `@param ${name}`];
});
const ret = /^\s*:\s*([?\w\\|&() ]+?)\s*(?:\{|;|$)/.exec(text.slice(i));
const type = ret?.[1]?.trim();
if (type && type !== "void" && m[1] !== "__construct") tags.push(`@return ${type}`);
return tags;
}
function splitTopLevel(params: string): string[] {
const out: string[] = [];
let depth = 0;
let start = 0;
for (let i = 0; i < params.length; i++) {
const c = params[i];
if (c === "(" || c === "[") depth++;
else if (c === ")" || c === "]") depth--;
else if (c === "," && depth === 0) {
out.push(params.slice(start, i));
start = i + 1;
}
}
out.push(params.slice(start));
return out.filter((p) => p.trim());
}
/** The Enter command for documents of `language` (stub tags in PHP). */
export function enterInDocComment(language: string | undefined): Command {
return (view) => {
const { state } = view;
if (state.readOnly || state.selection.ranges.length !== 1) return false;
const r = state.selection.main;
if (!r.empty) return false;
const block = state.languageDataAt<{ block?: { open: string; close: string } }>("commentTokens", r.head)[0]?.block;
if (block?.open !== "/*" || block.close !== "*/") return false;
const line = state.doc.lineAt(r.head);
const before = line.text.slice(0, r.head - line.from);
if (before.trim() !== "/**" || line.text.slice(r.head - line.from).trim()) return false;
// A `/**` that already starts a doc block (the next line continues it) only breaks the line.
const next = line.number < state.doc.lines ? state.doc.line(line.number + 1).text.trimStart() : "";
if (next.startsWith("*")) return false;
const indent = /^[ \t]*/.exec(before)![0];
const br = state.lineBreak;
const below = line.number < state.doc.lines ? state.sliceDoc(state.doc.line(line.number + 1).from) : "";
const tags = language === "php" ? phpDocTags(below.slice(0, 4000)) : [];
const summary = `${br}${indent} * `;
const insert = summary + tags.map((t) => `${br}${indent} * ${t}`).join("") + `${br}${indent} */`;
view.dispatch(
state.update({
changes: { from: r.head, insert },
selection: EditorSelection.cursor(r.head + summary.length),
scrollIntoView: true,
userEvent: "input",
}),
);
return true;
};
}
-2
View File
@@ -48,7 +48,6 @@ import {
selectHumpRight,
} from "./editing";
import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./comments";
import { enterInDocComment } from "./docComment";
import { skipClosingQuote, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys";
// --- soft wraps ---------------------------------------------------------------
@@ -572,7 +571,6 @@ export function prefsExtension(o: PrefsOptions): Extension {
{ key: "Home", run: cursorLineStart, shift: selectLineStart, preventDefault: true },
{ mac: "Mod-ArrowLeft", run: cursorLineStart, shift: selectLineStart, preventDefault: true },
);
if (!o.readOnly && k.insertDocStub) keys.push({ key: "Enter", run: enterInDocComment(o.language) });
if (!o.readOnly) keys.push({ key: "Enter", run: enterInLineComment });
if (!k.smartIndent) keys.push({ key: "Enter", run: insertNewlineKeepIndent });
if (k.backspaceUnindent === "none") keys.push({ key: "Backspace", run: deleteCharBackwardStrict });
-19
View File
@@ -4,7 +4,6 @@ import { createMemoryBackend } from "./backend";
import { initialTabsState } from "./model/tabs";
import { useDocMeta } from "./model/document";
import { resetDistractionFree, setDistractionFree } from "@/workbench/distractionFree";
import { registerTextEditorWrapper } from "@/workbench/contributions";
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { resolveMenuItems } from "@/ui";
@@ -320,22 +319,4 @@ describe("EditorController", () => {
dispose();
useProject.setState({ project: null, settings: null });
});
it("switches an open image tab to a text tab when a matching text editor wrapper registers", async () => {
ctrl = new EditorController(createMemoryBackend({ "/p/arch.diagram.svg": "<svg/>" }));
ctrl.start();
await ctrl.openInput({ kind: "image", key: "image:/p/arch.diagram.svg", path: "/p/arch.diagram.svg" });
expect(openFilePaths(useTabs.getState())).toEqual([]);
const offWrapper = registerTextEditorWrapper({
id: "plugin.acme.diagram.canvas",
matches: (p) => p.endsWith(".diagram.svg"),
component: () => null,
});
try {
expect(openFilePaths(useTabs.getState())).toEqual(["/p/arch.diagram.svg"]);
await vi.waitFor(() => expect(ctrl?.activeSlot?.model.path).toBe("/p/arch.diagram.svg"));
} finally {
offWrapper();
}
});
});
-39
View File
@@ -23,16 +23,13 @@ import {
menuGroupItems,
notifications,
resolveFileEditor,
textEditorWrapperFor,
useContributions,
useStandaloneEditor,
useTextEditorWrappers,
type EditorCloseSession,
type EditorInput,
type OpenOptions,
type FileEditorResolution,
type OpenEditorTab,
type TextEditorWrapperContribution,
} from "@/workbench/contributions";
import { useDistractionFree } from "@/workbench/distractionFree";
import { ask } from "@/workbench/messages";
@@ -347,11 +344,6 @@ export class EditorController implements SlotHost {
);
// Gutter, centred column and sticky lines follow Distraction Free Mode.
this.disposers.push(useDistractionFree.subscribe(() => this.manager.applySettings()));
this.disposers.push(
useTextEditorWrappers.subscribe(({ wrappers }) => {
this.upgradeWrappedImageTabs(wrappers);
}),
);
if (prevRoot) this.restoreSession(prevRoot);
if (this.manager.backend.onFsChanged)
this.disposers.push(this.manager.backend.onFsChanged((e) => this.onFilesRemoved(e.removed)));
@@ -361,8 +353,6 @@ export class EditorController implements SlotHost {
void this.saveCustomDocuments(true).catch((e) => this.documentError(e));
}
};
if (this.manager.backend.onApplicationDeactivated)
this.disposers.push(this.manager.backend.onApplicationDeactivated(onWindowBlur));
const onPageHide = () => this.saveSession();
if (typeof window !== "undefined") {
if (this.manager.backend.syncFromDisk)
@@ -462,35 +452,6 @@ export class EditorController implements SlotHost {
// --- session (open tabs, splits, carets per project) ---------------------------
private upgradeWrappedImageTabs(wrappers: ReadonlyMap<string, TextEditorWrapperContribution>) {
const s = useTabs.getState();
let changed = false;
const panes: TabsState["panes"] = {};
const toEnsure: string[] = [];
for (const [paneId, pane] of Object.entries(s.panes)) {
let paneChanged = false;
const tabs = pane.tabs.map((tab) => {
const path = tab.input.path;
if (
tab.input.kind === "image" &&
path &&
textEditorWrapperFor(wrappers, path) &&
!pane.tabs.some((other) => other.id !== tab.id && other.input.key === fileKey(path))
) {
paneChanged = true;
changed = true;
if (pane.active === tab.id) toEnsure.push(tab.id);
return { ...tab, input: fileInput(path) };
}
return tab;
});
panes[paneId] = paneChanged ? { ...pane, tabs } : pane;
}
if (!changed) return;
useTabs.setState({ ...s, panes }, true);
for (const tabId of toEnsure) void this.ensureSlot(tabId);
}
private scheduleSessionSave() {
if (!this.sessionRoot) return;
clearTimeout(this.sessionTimer);
-18
View File
@@ -481,24 +481,6 @@ describe("custom editor document lifetime", () => {
expect(findTab(useTabs.getState(), input().key)).toBeNull();
});
it("autosaves when the application is deactivated while focus is in a frame", async () => {
ctrl.stop();
let deactivated = () => {};
ctrl = new EditorController({
...createMemoryBackend(),
onApplicationDeactivated: (handler) => {
deactivated = handler;
return () => {};
},
});
ctrl.start();
const doc = await open();
useProject.setState({ settings: { files: { autoSave: true } } as never });
// Focus inside a plugin's frame: the window's own blur has already fired, before the edit.
deactivated();
await vi.waitFor(() => expect(doc.saved).toBe("edited"));
});
it("does not prompt or blur-autosave a document until execution has drained", async () => {
const doc = await open();
useProject.setState({ settings: { files: { autoSave: true } } as never });
+3 -14
View File
@@ -1,14 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { cleanup, render, screen, within } from "@testing-library/react";
import { useProject } from "@/platform/project";
import { useTabs } from "../controller";
import type { Tab } from "../model/tabs";
import { EditorAreaView } from "./EditorArea";
const { closeTab } = vi.hoisted(() => ({ closeTab: vi.fn() }));
vi.mock("../controller", async (original) => ({
...(await original<object>()),
editorController: () => ({ publishContext: vi.fn(), tabMenu: () => [], closeTab }),
editorController: () => ({ publishContext: vi.fn(), tabMenu: () => [] }),
}));
vi.mock("./TextEditorHost", () => ({ TextEditorHost: () => <input aria-label="Source" /> }));
vi.mock("./FindBar", () => ({ FindBar: () => null }));
@@ -35,10 +34,7 @@ function seed(pinned: boolean[], tabs: Record<string, unknown>) {
useProject.setState({ settings: { editor: { tabs } } as never });
}
beforeEach(() => {
cleanup();
closeTab.mockClear();
});
beforeEach(() => cleanup());
afterEach(() => {
cleanup();
useTabs.setState(originalTabs, true);
@@ -73,11 +69,4 @@ describe("pinned tabs in a separate row", () => {
render(<EditorAreaView />);
expect(screen.getAllByRole("tablist", { name: "Editor tabs" })).toHaveLength(1);
});
it("middle click closes an editor tab", () => {
seed([false, false, false], { placement: "top" });
render(<EditorAreaView />);
fireEvent.mouseDown(screen.getByRole("tab", { name: /f2/ }), { button: 1 });
expect(closeTab).toHaveBeenCalledWith("p1", "t2");
});
});
+1 -8
View File
@@ -400,15 +400,8 @@
text-overflow: ellipsis;
}
/* Dialog bodies get no padding of their own; wrap the groups like Analysis dialogs' .form. */
.reformatBody {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
}
.reformatGroup {
margin: 0;
margin: 0 0 10px;
padding: 0;
border: 0;
display: flex;
-21
View File
@@ -17,7 +17,6 @@ vi.mock("@/workbench/contributions", async (orig) => {
const { LspPopups, DocumentationWindow } = await import("./Popups");
const { closePopup, openPopup, useLspPopup } = await import("./popupStore");
const { useDocWindow, pushHistory, stepHistory, hoverCandidates } = await import("./docs");
import popupStyles from "./Popups.module.css";
const loc = (path: string, line: number): Location => ({
path,
@@ -224,23 +223,3 @@ describe("quick definition popup", () => {
expect(openFile).toHaveBeenCalledWith("/p/b.ts", { selection: { line: 5, col: 4 }, focus: true });
});
});
describe("reformat dialog", () => {
it("wraps its content in the padded body", () => {
const onRun = vi.fn();
openPopup({
kind: "reformat",
title: "Reformat File",
hasSelection: false,
canOptimizeImports: true,
initial: { selectionOnly: false, optimizeImports: false },
onRun,
});
render(<LspPopups />);
const body = screen.getByRole("dialog", { name: "Reformat File" }).querySelector("[data-dialog-body]")!;
expect(body.firstElementChild).toHaveClass(popupStyles.reformatBody!);
expect(body.firstElementChild!.querySelectorAll("fieldset")).toHaveLength(2);
fireEvent.click(screen.getByRole("button", { name: "Run" }));
expect(onRun).toHaveBeenCalledWith({ selectionOnly: false, optimizeImports: false });
});
});
+29 -36
View File
@@ -621,43 +621,36 @@ function ReformatDialog({ popup }: { popup: Extract<LspPopup, { kind: "reformat"
{ label: "Cancel", role: "cancel" },
]}
>
<div className={styles.reformatBody}>
<fieldset className={styles.reformatGroup}>
<legend className={styles.reformatLegend}>
<T>Scope</T>
</legend>
<label className={styles.reformatOption}>
<input
type="radio"
name="reformat-scope"
checked={!selectionOnly}
onChange={() => setSelectionOnly(false)}
/>
<T>Whole file</T>
</label>
<label className={clsx(styles.reformatOption, !popup.hasSelection && styles.disabled)}>
<input
type="radio"
name="reformat-scope"
checked={selectionOnly}
disabled={!popup.hasSelection}
onChange={() => setSelectionOnly(true)}
/>
<T>Selected text</T>
</label>
</fieldset>
<fieldset className={styles.reformatGroup}>
<legend className={styles.reformatLegend}>
<T>Options</T>
</legend>
<Checkbox
checked={optimizeImports}
disabled={!popup.canOptimizeImports}
onChange={setOptimizeImports}
label="Optimize imports"
<fieldset className={styles.reformatGroup}>
<legend className={styles.reformatLegend}>
<T>Scope</T>
</legend>
<label className={styles.reformatOption}>
<input type="radio" name="reformat-scope" checked={!selectionOnly} onChange={() => setSelectionOnly(false)} />
<T>Whole file</T>
</label>
<label className={clsx(styles.reformatOption, !popup.hasSelection && styles.disabled)}>
<input
type="radio"
name="reformat-scope"
checked={selectionOnly}
disabled={!popup.hasSelection}
onChange={() => setSelectionOnly(true)}
/>
</fieldset>
</div>
<T>Selected text</T>
</label>
</fieldset>
<fieldset className={styles.reformatGroup}>
<legend className={styles.reformatLegend}>
<T>Options</T>
</legend>
<Checkbox
checked={optimizeImports}
disabled={!popup.canOptimizeImports}
onChange={setOptimizeImports}
label="Optimize imports"
/>
</fieldset>
</Dialog>
);
}
+4 -102
View File
@@ -15,17 +15,13 @@ import {
type Tooltip,
type ViewUpdate,
} from "@codemirror/view";
import { Channel } from "@tauri-apps/api/core";
import {
commands,
isCancelled,
nextRequestId,
type DocId,
type GotoKind,
type Highlight,
type Location,
type Pos,
type RequestId,
type WebReferenceKind,
} from "@/platform/ipc";
import {
@@ -52,7 +48,7 @@ import {
} from "./syntaxFallback";
import { showHint, showPendingHint } from "./hint";
import { outsideProjectReason } from "./outsideProject";
import { caretAnchor, openPopup, updatePopup, type ListPopupItem } from "./popupStore";
import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore";
import { readManyFileLines, targetLabel } from "./files";
import {
documentLinks,
@@ -138,89 +134,9 @@ export async function locationItems(locs: readonly DeclarationTarget[]): Promise
/** "Choose Implementation of foo (3 found)". */
export function chooserTitle(kind: GotoKind, name: string | null, count: number, fromSyntax = false): string {
if (kind === "implementation" && !fromSyntax)
return name ? tx`Choose Implementation of ${name} (${count} found)` : tx`Choose Implementation (${count} found)`;
return `${TITLES[kind]}${name ? ` of ${name}` : ""} (${count} found${fromSyntax ? ", based on syntax" : ""})`;
}
/** The chooser's title while the server is still searching for more. */
function searchingTitle(name: string | null, count: number): string {
return name
? tx`Choose Implementation of ${name} (${count} found, searching…)`
: tx`Choose Implementation (${count} found, searching…)`;
}
function nameAt(view: EditorView, offset: number): string | null {
const word = view.state.wordAt(offset);
return word ? view.state.sliceDoc(word.from, word.to) : null;
}
/**
* Go to Implementation whose chooser opens with the implementations the server finds first
* (Java: the project's) while it searches on (the dependencies'), and fills in when it ends.
* Closing the chooser cancels the search.
*/
function streamedImplementations(view: EditorView, offset: number, onOpen: () => void) {
const name = nameAt(view, offset);
let current: RequestId | null = null;
let finished = false;
let opened = false;
let closed = false;
let first: DeclarationTarget[] = [];
let showing: Promise<void> = Promise.resolve();
return {
request(doc: DocId, pos: Pos): Promise<DeclarationTarget[]> {
const request = (current = nextRequestId());
finished = false;
const channel = new Channel<Location[]>();
channel.onmessage = (locs) => {
if (finished || opened || current !== request || !locs.length) return;
opened = true;
onOpen();
first = dedupeLocations(locs);
showing = locationItems(first).then((items) => {
// All of them arrived meanwhile: shown as usual.
if (finished) {
opened = false;
return;
}
openPopup({
kind: "list",
anchor: caretAnchor(view, offset),
title: searchingTitle(name, first.length),
items,
pin: pinToFind(name ?? "", first),
onClose: () => {
closed = true;
if (!finished) void commands.cancelRequest(request).catch(() => {});
},
});
});
};
return lspRequest(() => commands.lspGotoImplementation(request, doc, pos, channel)).finally(() => {
finished = true;
});
},
/** Whether the chooser opened with the first ones (and so the search's result goes there). */
async opened(): Promise<boolean> {
await showing;
return opened;
},
/** Fills the opened chooser with all of them, after those it shows. */
async complete(locs: readonly DeclarationTarget[]) {
if (closed) return;
const all = dedupeLocations([...first, ...locs]);
const items = await locationItems(all);
if (closed) return;
updatePopup("list", {
title: chooserTitle("implementation", name, all.length),
items,
pin: pinToFind(name ?? "", all),
});
},
};
}
/** Pin button of the targets chooser: the targets as a Find tool window tab (code-insight). */
export function pinToFind(symbol: string, locations: readonly Location[]) {
return {
@@ -259,25 +175,14 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number)
let fromSyntax = false;
const declaration = kind === "definition" || kind === "declaration";
let serverDown = false;
let streamed: ReturnType<typeof streamedImplementations> | null = null;
try {
const done = showPendingHint(view, tx`Finding navigation target…`);
const stream = kind === "implementation" ? streamedImplementations(view, offset, done) : null;
streamed = stream;
const request = stream
? () => stream.request(doc.doc, pos)
: () => lspRequest(() => commands.lspGoto(doc.doc, pos, kind));
try {
locs = dedupeLocations(await gotoAfterEdits(view, request));
locs = dedupeLocations(await gotoAfterEdits(view, () => lspRequest(() => commands.lspGoto(doc.doc, pos, kind))));
} finally {
done();
}
} catch (e) {
// The chooser shows the first ones: closing it cancelled the search, or the rest failed.
if (streamed && (await streamed.opened())) {
await streamed.complete([]);
return;
}
if (isCancelled(e) || !declaration) {
reportLspError(view, e, true);
return;
@@ -294,10 +199,6 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number)
locs = fb;
fromSyntax = true;
}
if (streamed && (await streamed.opened())) {
await streamed.complete(locs);
return;
}
if (!locs.length) {
// The server has no definition here: a link (`href`, `#include`, Markdown) or a file path.
if (
@@ -333,7 +234,8 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number)
}, 150);
return;
}
const name = nameAt(view, offset);
const word = view.state.wordAt(offset);
const name = word ? view.state.sliceDoc(word.from, word.to) : null;
openPopup({
kind: "list",
anchor: caretAnchor(view, offset),
+1 -10
View File
@@ -10,17 +10,8 @@ import { registerAction } from "@/platform/actions";
const lspGoto = vi.hoisted(() => vi.fn<typeof commands.lspGoto>());
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
// Go to Implementation streams (`lspGotoImplementation`); here it answers all at once.
const lspGotoImplementation: typeof commands.lspGotoImplementation = (_request, doc, pos) =>
lspGoto(doc, pos, "implementation");
return { ...real, isTauri: () => true, commands: { ...real.commands, lspGoto, lspGotoImplementation } };
return { ...real, isTauri: () => true, commands: { ...real.commands, lspGoto } };
});
vi.mock("@tauri-apps/api/core", async (orig) => ({
...(await orig<object>()),
Channel: class {
onmessage = () => {};
},
}));
const { gotoTargets, navigationExtension } = await import("./navigation");
const { lspDoc } = await import("./lsp");
+6 -99
View File
@@ -1,35 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { commands, Location } from "@/platform/ipc";
import type { commands } from "@/platform/ipc";
const { lspGoto, lspGotoImplementation, cancelRequest } = vi.hoisted(() => ({
lspGoto: vi.fn<typeof commands.lspGoto>(),
lspGotoImplementation: vi.fn<typeof commands.lspGotoImplementation>(),
cancelRequest: vi.fn<typeof commands.cancelRequest>(async () => {}),
}));
const lspGoto = vi.hoisted(() => vi.fn<typeof commands.lspGoto>());
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
const fsReadText: typeof commands.fsReadText = async () => ({
status: "error",
error: { kind: "notFound", message: "not found" },
});
return {
...real,
isTauri: () => true,
commands: { ...real.commands, lspGoto, lspGotoImplementation, cancelRequest, fsReadText },
};
return { ...real, isTauri: () => true, commands: { ...real.commands, lspGoto } };
});
vi.mock("@tauri-apps/api/core", async (orig) => ({
...(await orig<object>()),
Channel: class {
onmessage = () => {};
},
}));
const { gotoTargets } = await import("./navigation");
const { lspDoc } = await import("./lsp");
const { useLspPopup, closePopup } = await import("./popupStore");
const { provideEditorService } = await import("@/workbench/contributions");
const openFile = vi.fn(async () => {});
provideEditorService({ openFile } as never);
@@ -69,87 +50,13 @@ describe("slow navigation feedback", () => {
});
it("replaces progress with the server error", async () => {
const search = slowImplementations();
let resolve!: (result: Awaited<ReturnType<typeof commands.lspGoto>>) => void;
lspGoto.mockImplementationOnce(() => new Promise((r) => (resolve = r)));
const pending = gotoTargets(view, "implementation");
await vi.advanceTimersByTimeAsync(250);
search.resolve({ status: "error", error: { kind: "protocol", message: "Server timed out" } });
resolve({ status: "error", error: { kind: "protocol", message: "Server timed out" } });
await pending;
expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe("Server timed out");
expect(openFile).not.toHaveBeenCalled();
});
});
const at = (path: string, line: number): Location => ({
path,
range: { start: { line, col: 0 }, end: { line, col: 1 } },
uri: null,
});
/** A Go to Implementation request answered when the test says: the first ones, then all. */
function slowImplementations() {
let channel!: { onmessage: (locs: Location[]) => void };
let resolve!: (result: Awaited<ReturnType<typeof commands.lspGotoImplementation>>) => void;
lspGotoImplementation.mockImplementationOnce((_request, _doc, _pos, onPartial) => {
channel = onPartial as unknown as typeof channel;
return new Promise((r) => (resolve = r));
});
return {
first: (locs: Location[]) => channel.onmessage(locs),
resolve: (r: Parameters<typeof resolve>[0]) => resolve(r),
};
}
const listPopup = () => {
const popup = useLspPopup.getState().popup;
return popup?.kind === "list" ? popup : null;
};
describe("Go to Implementation while the search goes on", () => {
afterEach(() => {
closePopup();
cancelRequest.mockClear();
lspGotoImplementation.mockReset();
});
it("opens the chooser with the implementations found first, then adds the rest", async () => {
const search = slowImplementations();
const pending = gotoTargets(view, "implementation");
await vi.advanceTimersByTimeAsync(250);
search.first([at("/p/Own.java", 3)]);
await vi.waitFor(() => expect(listPopup()?.title).toBe("Choose Implementation of run (1 found, searching…)"));
expect(listPopup()!.items.map((i) => i.label)).toEqual(["Own.java"]);
expect(document.querySelector(".cm-lsp-hint")).toBeNull();
search.resolve({ status: "ok", data: [at("/p/Library.java", 8), at("/p/Own.java", 3)] });
await pending;
expect(listPopup()?.title).toBe("Choose Implementation of run (2 found)");
expect(listPopup()!.items.map((i) => i.label)).toEqual(["Own.java", "Library.java"]);
expect(openFile).not.toHaveBeenCalled();
expect(cancelRequest).not.toHaveBeenCalled();
});
it("cancels the search when the chooser closes", async () => {
const search = slowImplementations();
const pending = gotoTargets(view, "implementation");
await vi.waitFor(() => expect(lspGotoImplementation).toHaveBeenCalled());
search.first([at("/p/Own.java", 3)]);
await vi.waitFor(() => expect(listPopup()).not.toBeNull());
closePopup();
expect(cancelRequest).toHaveBeenCalledExactlyOnceWith(lspGotoImplementation.mock.lastCall![0]);
search.resolve({ status: "error", error: { kind: "cancelled", message: "cancelled" } });
await pending;
expect(listPopup()).toBeNull();
expect(document.querySelector(".cm-lsp-hint")).toBeNull();
});
it("goes to the only implementation when none came first", async () => {
const search = slowImplementations();
const pending = gotoTargets(view, "implementation");
await vi.waitFor(() => expect(lspGotoImplementation).toHaveBeenCalled());
search.resolve({ status: "ok", data: [at("/p/Library.java", 8)] });
await pending;
expect(listPopup()).toBeNull();
expect(openFile).toHaveBeenCalledWith("/p/Library.java", { selection: { line: 8, col: 0 }, focus: true });
});
});
+1 -12
View File
@@ -471,20 +471,9 @@
min-width: 0;
color: var(--fg-secondary);
}
/* Title, state and answers on one line when they fit; a long title takes its own line and
the state and answers go below it, in line with it. */
.permissionBody {
flex: 1;
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 2px 6px;
}
/* Up to three lines; the full text is its tooltip. */
.permissionTitle {
display: -webkit-box;
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
-webkit-box-orient: vertical;
@@ -493,7 +482,7 @@
overflow-wrap: anywhere;
}
.permissionLine > svg,
.permissionBody > .muted {
.permissionLine > .muted {
flex: none;
}
.permissionAnswers {
+5 -7
View File
@@ -1213,13 +1213,11 @@ function PermissionLine({ task, entry }: { task: AgentTask; entry: PermissionEnt
return (
<div className={styles.permissionLine}>
<Icon name={!entry.chosen ? "warning" : entry.chosen === "Cancelled" ? "minus" : "check"} size={13} />
<div className={styles.permissionBody}>
<span className={styles.permissionTitle} title={shortTitle(task, entry.title)}>
{shortTitle(task, entry.title)}
</span>
<span className={styles.muted}>{entry.chosen ?? <T>Waiting for your answer</T>}</span>
{!entry.chosen && <PermissionAnswers task={task} entry={entry} className={styles.permissionAnswers} />}
</div>
<span className={styles.permissionTitle} title={shortTitle(task, entry.title)}>
{shortTitle(task, entry.title)}
</span>
<span className={styles.muted}>{entry.chosen ?? <T>Waiting for your answer</T>}</span>
{!entry.chosen && <PermissionAnswers task={task} entry={entry} className={styles.permissionAnswers} />}
</div>
);
}
@@ -30,7 +30,7 @@ vi.mock("@/workbench/contributions", async (importOriginal) => {
import type { DebugCapabilities, DebugSessionId } from "@/platform/ipc";
import { getContext } from "@/platform/context";
import { _resetI18nForTests, setLocale } from "@/platform/i18n";
import { hotSwapMenuItems, hotSwapMessage, onHotCodeReplace, reloadChangedClasses } from "../hotswap";
import { hotSwapMessage, onHotCodeReplace, reloadChangedClasses } from "../hotswap";
import { publishContext, useDebug, type DebugSession } from "../state";
function session(id: number, hotCodeReplace: boolean): DebugSession {
@@ -80,13 +80,6 @@ describe("HotSwap", () => {
expect(getContext().debugCanHotSwap).toBe(false);
});
it("offers the reload in the editor menu of a Java file while a session can reload classes", () => {
expect(hotSwapMenuItems(["/p/app/src/A.java"])).toEqual([{ kind: "action", action: "debug.reloadChangedClasses" }]);
expect(hotSwapMenuItems(["/p/app/pom.xml"])).toEqual([]);
useDebug.setState({ sessions: { 2: session(2, false) }, active: 2 });
expect(hotSwapMenuItems(["/p/app/src/A.java"])).toEqual([]);
});
it("builds, reloads in Java sessions and reports the count", async () => {
await reloadChangedClasses();
expect(ipc.javaBuildWorkspace).toHaveBeenCalledWith("/p/app");
@@ -23,42 +23,6 @@ describe("run config → launch config", () => {
expect(launchConfigFor(rc({}), "/p")).toBeNull();
});
it("php-debug: the script runs on the PHP from Settings with Xdebug's debug mode, on a free port", () => {
const xdebug = ["-dxdebug.mode=debug", "-dxdebug.start_with_request=yes", "-dxdebug.client_port=${port}"];
// `artisan serve` with PHP at Default: the script is a file of the project.
const serve = launchConfigFor(
rc({ kind: "php", command: "php", args: ["artisan", "serve"], debugAdapter: "php-debug" }),
"/app",
"/opt/[email protected]/bin/php",
)!;
expect(serve).toMatchObject({ adapter: "php-debug", program: "/app/artisan", args: ["serve"], cwd: "/app" });
expect(extra(serve)).toEqual({ port: 0, runtimeArgs: xdebug, runtimeExecutable: "/opt/[email protected]/bin/php" });
// The PHP the command names wins, and its options stay with it.
const file = launchConfigFor(
rc({
kind: "php",
command: "/usr/bin/php8.4",
args: ["-dmemory_limit=1G", "src/a.php", "x"],
debugAdapter: "php-debug",
}),
"/app",
"/opt/[email protected]/bin/php",
)!;
expect(file).toMatchObject({ program: "/app/src/a.php", args: ["x"] });
expect(extra(file)).toEqual({
port: 0,
runtimeArgs: [...xdebug, "-dmemory_limit=1G"],
runtimeExecutable: "/usr/bin/php8.4",
});
// PHPUnit runs its script directly; without a PHP in Settings, php-debug uses the `php` on PATH.
const unit = launchConfigFor(
rc({ kind: "phpunit", command: "vendor/bin/phpunit", args: ["--filter", "testA"], debugAdapter: "php-debug" }),
"/app",
)!;
expect(unit).toMatchObject({ program: "/app/vendor/bin/phpunit", args: ["--filter", "testA"] });
expect(extra(unit)).toEqual({ port: 0, runtimeArgs: xdebug });
});
it("cargo run builds the binary through codelldb's cargo integration", () => {
const l = launchConfigFor(
rc({
@@ -25,20 +25,6 @@ describe("missing debug adapters", () => {
"netcoredbg",
);
expect(missingAdapter("delve", unavailable("debug adapter `delve` not found (x)"))?.tool).toBe("delve");
expect(missingAdapter("bash-debug", unavailable("debug adapter `bash-debug` not found (x)"))?.title).toBe(
"Bash Debug is not installed",
);
});
it("offers the managed install of the missing php-debug adapter", () => {
const e = unavailable(
"debug adapter `php-debug` not found (`php-debug-adapter` is neither installed by OpenIDEA nor on PATH). Install PHP Debug (Xdebug) in Settings › Languages & Frameworks › Language Servers, and enable Xdebug",
);
expect(missingAdapter("php-debug", e)).toMatchObject({
tool: "php-debug",
title: "PHP Debug (Xdebug) is not installed",
body: e.message,
});
});
it("leaves other failures and adapters without an installer alone", () => {
+1 -44
View File
@@ -30,12 +30,9 @@ const {
goRemoteType,
pythonRemoteType,
nodeRemoteType,
phpRemoteType,
jdwpAgentArgs,
xdebugCommand,
remoteAttachConfig,
remoteJvmTarget,
remoteWaitingNote,
} = remote;
function configOf(type: (typeof REMOTE_TYPES)[number], params: Record<string, string> = {}): RunConfig {
@@ -70,9 +67,7 @@ describe("remote debug configuration types", () => {
port: "Port must be a number from 1 to 65535",
});
expect(missingParams(configOf(type, { port: "abc" }), type).port).toBeDefined();
// PHP Remote Debug has no host: php-debug listens itself, Xdebug connects to it.
if (type.fields.some((f) => f.key === "host"))
expect(missingParams(configOf(type, { host: "" }), type)).toEqual({ host: "Host is required" });
expect(missingParams(configOf(type, { host: "" }), type)).toEqual({ host: "Host is required" });
});
it("Remote JVM Debug defaults to attaching to localhost:5005", () => {
@@ -162,44 +157,6 @@ describe("remote attach requests", () => {
});
});
it("PHP Remote Debug defaults to port 9003 and shows the xdebug command line", () => {
expect(configOf(phpRemoteType).params).toEqual({ port: "9003" });
const field = phpRemoteType.fields.find((f) => f.key === "hint")!;
expect(field.control).toBe("output");
expect(field.value!({ port: "9004" })).toContain("-dxdebug.client_port=9004");
expect(xdebugCommand({})).toBe(
"php -dxdebug.mode=debug -dxdebug.start_with_request=yes -dxdebug.client_port=9003 script.php",
);
});
it("PHP Remote Debug launches php-debug without a program and maps the roots", () => {
const l = remoteAttachConfig(configOf(phpRemoteType, { localRoot: ".", remoteRoot: "/var/www/html" }), "/p")!;
expect(l).toMatchObject({ adapter: "php-debug", request: "launch", cwd: "/p" });
expect(l.program).toBeNull();
expect(JSON.parse(l.extraJson!)).toEqual({ port: 9003, pathMappings: { "/var/www/html": "/p" } });
const plain = JSON.parse(remoteAttachConfig(configOf(phpRemoteType), "/p")!.extraJson!);
expect(plain).toEqual({ port: 9003 });
});
it.each([
[{ remoteRoot: "/app", localRoot: "web" }, { "/app": "/p/web" }],
[{ remoteRoot: "/app", localRoot: "/srv/site" }, { "/app": "/srv/site" }],
])("PHP Remote Debug maps the server path %j to the local root", (params, pathMappings) => {
const l = remoteAttachConfig(configOf(phpRemoteType, params), "/p")!;
expect(JSON.parse(l.extraJson!)).toEqual({ port: 9003, pathMappings });
expect(l).toMatchObject({ args: [], env: {} });
});
it("PHP Remote Debug keeps its form when Port, its only value, is cleared", () => {
const cleared = withParams(configOf(phpRemoteType), {}, phpRemoteType);
expect(missingParams(cleared, phpRemoteType)).toEqual({ port: "Port is required" });
});
it("PHP Remote Debug says which port it waits on; the attaching types don't", () => {
expect(remoteWaitingNote(configOf(phpRemoteType, { port: "9010" }))).toBe("Listening for Xdebug on port 9010");
for (const t of REMOTE_TYPES.filter((x) => x !== phpRemoteType)) expect(remoteWaitingNote(configOf(t))).toBeNull();
});
it("start-the-remote-side hints follow the port", () => {
expect(remote.delveServerCommand({ port: "4000" })).toBe(
"dlv debug --headless --listen=:4000 --api-version=2 --accept-multiclient",
@@ -1,52 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { IpcError, type LaunchConfig } from "@/platform/ipc";
import { provideNotificationService } from "@/workbench/contributions";
import { startFailureMessage } from "../startFailure";
import { startSession, useDebug } from "../state";
const ipc = vi.hoisted(() => ({ debugStart: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } };
});
const PORT_IN_USE =
"Port 9003 is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.";
describe("debug start failures", () => {
it.each([
"launch failed: listen EADDRINUSE: address already in use :::9003",
"launch failed: listen EADDRINUSE: address already in use 127.0.0.1:9003",
])("names the port another listener holds: %s", (message) => {
expect(startFailureMessage(new IpcError({ kind: "invalidInput", message }))).toBe(PORT_IN_USE);
});
it("keeps other failures as they are", () => {
expect(startFailureMessage(new Error("launch failed: Attach requests are not supported"))).toBe(
"launch failed: Attach requests are not supported",
);
expect(startFailureMessage("debug adapter exited")).toBe("debug adapter exited");
});
it("are what the notification of a failed start says", async () => {
const notify = vi.fn();
provideNotificationService({ notify });
useDebug.setState({ sessions: {}, active: null, console: {} });
const message = "launch failed: listen EADDRINUSE: address already in use :::9003";
ipc.debugStart.mockResolvedValueOnce({ status: "error", error: { kind: "invalidInput", message, data: null } });
const listen: LaunchConfig = {
name: "Listen for Xdebug",
adapter: "php-debug",
request: "launch",
program: null,
args: [],
cwd: "/p",
env: {},
extraJson: JSON.stringify({ port: 9003 }),
};
expect(await startSession(listen)).toBeNull();
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "Cannot start debugger for Listen for Xdebug", body: PORT_IN_USE }),
);
});
});
@@ -28,7 +28,6 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
const confirmTerminate = vi.hoisted(() => vi.fn(async () => false));
vi.mock("@/workbench/confirmTerminate", () => ({ confirmTerminate }));
import type { RunConfig } from "@/platform/ipc";
import { useDebug, type DebugSession } from "../state";
import { DebugTabs, DebugToolWindow } from "../ui/DebugToolWindow";
@@ -64,33 +63,6 @@ describe("debug tool window", () => {
expect(hot()).toEqual([true, true]);
});
it("says a PHP Remote Debug session listens for Xdebug while nothing is suspended", () => {
const runConfig = {
id: "php-remote:web",
name: "web",
kind: "php-remote",
command: "",
args: [],
cwd: null,
env: {},
detected: false,
isTest: false,
debugAdapter: "php-debug",
params: { port: "9010" },
} as RunConfig;
const waiting = { ...session, adapter: "php-debug", status: "running" as const, threads: [], frames: [] };
useDebug.setState({ sessions: { 1: { ...waiting, runConfig } }, active: 1 });
const { unmount } = render(<DebugToolWindow />);
fireEvent.click(screen.getByRole("tab", { name: "Threads & Variables" }));
expect(screen.getByText("Listening for Xdebug on port 9010")).toBeTruthy();
expect(screen.queryByText("Running…")).toBeNull();
unmount();
useDebug.setState({ sessions: { 1: waiting }, active: 1 });
render(<DebugToolWindow />);
fireEvent.click(screen.getByRole("tab", { name: "Threads & Variables" }));
expect(screen.getByText("Running…")).toBeTruthy();
});
it("shows preparation output, preserves a chosen tab and opens variables on pause", () => {
useDebug.setState({ sessions: { 1: { ...session, status: "starting" } }, active: 1 });
render(<DebugToolWindow />);
+1 -4
View File
@@ -299,10 +299,7 @@ async function startConfig(arg: unknown) {
}
const dotnet = config.kind === "dotnet" && config.debugAdapter === "netcoredbg";
const prepared = dotnet ? await call(commands.runDotnetResolve(config, true)) : config;
// php-debug starts the PHP chosen in Settings unless the command names one.
const php =
config.debugAdapter === "php-debug" && isTauri() ? await call(commands.sdkPhpEffective()).catch(() => null) : null;
const launch = launchConfigFor(prepared, root, php);
const launch = launchConfigFor(prepared, root);
if (!launch) {
notifications().notify({
group: "run",
-7
View File
@@ -9,7 +9,6 @@ import { call, commands, isTauri, type DebugEvent, type DebugSessionId } from "@
import { maybeEditorService, notifications } from "@/workbench/contributions";
import { isLive, useDebug, type DebugSession } from "./state";
import { tx } from "@/platform/i18n";
import type { MenuItem } from "@/ui/Menu";
const KEY = "debug.hotswap";
/** `BUILD_COMPLETE` events of our own build are not worth a prompt. */
@@ -19,12 +18,6 @@ export function hotSwapSessions(): DebugSession[] {
return Object.values(useDebug.getState().sessions).filter((s) => isLive(s) && !!s.caps?.hotCodeReplace);
}
/** Editor menu: Reload Changed Classes on a Java file while a debug session can reload classes. */
export function hotSwapMenuItems(paths: readonly string[]): MenuItem[] {
const java = paths.length === 1 && /\.java$/i.test(paths[0]!);
return java && hotSwapSessions().length ? [{ kind: "action", action: "debug.reloadChangedClasses" }] : [];
}
/** "3 classes", translated per count (the sentence around it is translated on its own). */
const classesText = (n: number) => (n === 1 ? tx`${n} class` : tx`${n} classes`);
+1 -6
View File
@@ -24,7 +24,7 @@ import { breakpointGutter } from "./editor/breakpointGutter";
import { executionPoint } from "./editor/execution";
import { runToCursorInlay } from "./editor/runToCursor";
import { installSmartStepPicking, smartStepTargets } from "./editor/smartStep";
import { hotSwapMenuItems, onHotCodeReplace } from "./hotswap";
import { onHotCodeReplace } from "./hotswap";
import {
activeSession,
handleDebugEvent,
@@ -68,11 +68,6 @@ export function activate(): () => void {
menus: ["editor"],
items: () => (activeSession()?.status === "paused" ? [{ kind: "action", action: "debug.evaluate" }] : []),
}),
registerMenuGroup({
id: "debugger.hotswap",
menus: ["editor"],
items: (_menu, { paths }) => hotSwapMenuItems(paths),
}),
// Stop (⌘F2) offers the running debug sessions beside run processes.
registerMenuGroup({
id: "debugger.stop",
+1 -35
View File
@@ -356,44 +356,11 @@ function flutterAttach(rc: RunConfig, cwd: string, adapter: string): LaunchConfi
};
}
const PHP_NAME = /^php(\d+(\.\d+)*)?(\.exe)?$/i;
/**
* php-debug runs `runtimeExecutable runtimeArgs program args` and doesn't enable Xdebug: debug mode and
* the connection back come from `-d` options, to the free port php-debug picks for `port: 0` (another
* IDE may listen on 9003). `php` is the PHP chosen in Settings, used when the command doesn't name one.
*/
function phpDebugLaunch(rc: RunConfig, cwd: string, php: string | null): LaunchConfig {
const name = rc.command.split(/[\\/]/).pop() ?? rc.command;
const runtimeArgs = ["-dxdebug.mode=debug", "-dxdebug.start_with_request=yes", "-dxdebug.client_port=${port}"];
let runtime = php;
let program: string;
let args: string[];
if (PHP_NAME.test(name)) {
// `php [options] script args`: the options stay with PHP; the script is a file (`artisan`).
if (name !== rc.command) runtime = resolveCommand(rc.command, cwd);
const at = rc.args.findIndex((a) => !a.startsWith("-"));
runtimeArgs.push(...(at < 0 ? rc.args : rc.args.slice(0, at)));
program = at < 0 ? "" : join(cwd, rc.args[at]!);
args = at < 0 ? [] : rc.args.slice(at + 1);
} else {
// A PHP script run directly (`vendor/bin/phpunit`; on Windows its `.bat` shim sits beside it).
program = resolveCommand(rc.command.replace(/\.bat$/i, ""), cwd);
args = [...rc.args];
}
return {
...base(rc, "php-debug", cwd),
program,
args,
extraJson: JSON.stringify({ port: 0, runtimeArgs, ...(runtime ? { runtimeExecutable: runtime } : {}) }),
};
}
/**
* The launch configuration that debugs `rc`, or `null` when it has no adapter.
* `root` is the project root (default working directory).
*/
export function launchConfigFor(rc: RunConfig, root: string, php: string | null = null): LaunchConfig | null {
export function launchConfigFor(rc: RunConfig, root: string): LaunchConfig | null {
const adapter = rc.debugAdapter;
if (!adapter || debugUnsupportedReason(rc)) return null;
const cwd = rc.cwd ? join(root, rc.cwd) : root;
@@ -407,7 +374,6 @@ export function launchConfigFor(rc: RunConfig, root: string, php: string | null
if (adapter === "js-debug") return jsDebugLaunch(rc, cwd);
if (adapter === "delve") return delveLaunch(rc, cwd, root);
if (adapter === "godot") return godotLaunch(rc, cwd);
if (adapter === "php-debug") return phpDebugLaunch(rc, cwd, php);
if (adapter === "netcoredbg" && rc.kind === "dotnet") {
if (!rc.params?.targetPath) return null;
return {
-2
View File
@@ -12,8 +12,6 @@ const INSTALLABLE: Record<string, { tool: string; title: string }> = {
"js-debug": { tool: "js-debug", title: "The JavaScript debugger (vscode-js-debug) is not installed" },
delve: { tool: "delve", title: "Delve is not installed" },
codelldb: { tool: "codelldb", title: "CodeLLDB is not installed" },
"php-debug": { tool: "php-debug", title: "PHP Debug (Xdebug) is not installed" },
"bash-debug": { tool: "bash-debug", title: "Bash Debug is not installed" },
};
export interface MissingAdapter {
+9 -67
View File
@@ -2,10 +2,8 @@
* Remote debug run configurations: the debugger attaches to a process started
* elsewhere (another machine, a container) over host:port, like IDEA's Remote JVM
* Debug, GoLand's Go Remote, PyCharm's remote debugpy and IDEA's Attach to Node.js.
* PHP Remote Debug is the exception: its php-debug listens and Xdebug connects to it.
* Only Debug applies to them (`debugOnly`); they launch no process.
*/
import { tx } from "@/platform/i18n";
import type { LaunchConfig, RunConfig } from "@/platform/ipc";
import { workflowSettings } from "@/platform/workflowSettings";
import type { RunConfigTypeContribution, RunConfigTypeField } from "@/workbench/contributions";
@@ -15,7 +13,6 @@ export const GO_REMOTE = "go-remote";
export const PYTHON_REMOTE = "python-remote";
export const NODE_REMOTE = "node-remote";
export const GODOT_ATTACH = "godot-attach";
export const PHP_REMOTE = "php-remote";
type Params = Readonly<Record<string, string>>;
@@ -224,38 +221,6 @@ export const nodeRemoteType: RunConfigTypeContribution = {
commandLine: () => null,
};
// ---------------------------------------------------------------------------
// PHP Remote Debug (Xdebug): php-debug listens, the remote PHP connects to it
/** How to start the remote side, so Xdebug connects back to the listening php-debug. */
export function xdebugCommand(p: Params): string {
return `php -dxdebug.mode=debug -dxdebug.start_with_request=yes -dxdebug.client_port=${portOf(p, 9003)} script.php`;
}
export const phpRemoteType: RunConfigTypeContribution = {
id: PHP_REMOTE,
label: "PHP Remote Debug (Xdebug)",
icon: "file-php",
description: "Listen for Xdebug connections: debug PHP requests served elsewhere (php-fpm, a container)",
defaults: { port: "9003" },
debugOnly: true,
debugAdapter: "php-debug",
fields: [
portField,
{
key: "hint",
label: "Start the remote side with",
control: "output",
mono: true,
value: xdebugCommand,
hint: "For web requests: the XDEBUG_SESSION cookie or query parameter; point xdebug.client_host at this machine when PHP runs in a container",
},
localRootField,
remoteRootField("e.g. /var/www/html"),
],
commandLine: () => null,
};
// ---------------------------------------------------------------------------
// Attach to Godot (the editor's debug adapter, Editor Settings › Network › Debug Adapter)
@@ -285,14 +250,13 @@ export const REMOTE_TYPES: readonly RunConfigTypeContribution[] = [
goRemoteType,
pythonRemoteType,
nodeRemoteType,
phpRemoteType,
godotAttachType,
];
export const isRemoteConfig = (c: Pick<RunConfig, "kind">) => REMOTE_TYPES.some((t) => t.id === c.kind);
// ---------------------------------------------------------------------------
// Attach / listen requests
// Attach requests
function isAbsolute(p: string) {
return p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
@@ -317,17 +281,11 @@ export function serverAddress(host: string, port: number): string {
return `${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`;
}
function requestBase(
rc: RunConfig,
adapter: string,
root: string,
request: "attach" | "launch",
extra: Record<string, unknown>,
): LaunchConfig {
function attachBase(rc: RunConfig, adapter: string, root: string, extra: Record<string, unknown>): LaunchConfig {
return {
name: rc.name,
adapter,
request,
request: "attach",
program: null,
args: [],
cwd: root,
@@ -337,11 +295,9 @@ function requestBase(
}
/**
* The request of a Go / Python / Node.js / PHP remote configuration. Delve's headless
* The attach request of a Go / Python / Node.js remote configuration. Delve's headless
* server and `debugpy --listen` speak DAP themselves (`debugServer` connects there);
* js-debug runs here and connects to the inspector. PHP Remote Debug is the one
* `launch`: php-debug is the side that listens, and each Xdebug connection becomes
* a thread, so nothing attaches and no program is named.
* js-debug runs here and connects to the inspector.
*/
export function remoteAttachConfig(rc: RunConfig, root: string): LaunchConfig | null {
const p = rc.params ?? {};
@@ -350,46 +306,32 @@ export function remoteAttachConfig(rc: RunConfig, root: string): LaunchConfig |
const skip = workflowSettings().debugger.skipLibraryCode;
switch (rc.kind) {
case GO_REMOTE:
return requestBase(rc, "delve", root, "attach", {
return attachBase(rc, "delve", root, {
debugServer: serverAddress(host, portOf(p, 2345)),
mode: "remote",
...(map ? { substitutePath: [{ from: map.local, to: map.remote }] } : {}),
});
case PYTHON_REMOTE:
return requestBase(rc, "debugpy", root, "attach", {
return attachBase(rc, "debugpy", root, {
debugServer: serverAddress(host, portOf(p, 5678)),
justMyCode: skip,
...(map ? { pathMappings: [{ localRoot: map.local, remoteRoot: map.remote }] } : {}),
});
case GODOT_ATTACH:
return requestBase(rc, "godot", root, "attach", {
debugServer: serverAddress(host, portOf(p, 6006)),
project: root,
});
return attachBase(rc, "godot", root, { debugServer: serverAddress(host, portOf(p, 6006)), project: root });
case NODE_REMOTE:
return requestBase(rc, "js-debug", root, "attach", {
return attachBase(rc, "js-debug", root, {
type: "pwa-node",
address: host,
port: portOf(p, 9229),
...(map ? { localRoot: map.local, remoteRoot: map.remote } : {}),
skipFiles: skip ? ["<node_internals>/**", "**/node_modules/**"] : [],
});
case PHP_REMOTE:
return requestBase(rc, "php-debug", root, "launch", {
port: portOf(p, 9003),
...(map ? { pathMappings: { [map.remote]: map.local } } : {}),
});
default:
return null;
}
}
/** What a session of `rc` shows while nothing is suspended, when it waits for the debuggee to connect. */
export function remoteWaitingNote(rc: Pick<RunConfig, "kind" | "params"> | undefined): string | null {
if (rc?.kind !== PHP_REMOTE) return null;
return tx`Listening for Xdebug on port ${portOf(rc.params ?? {}, 9003)}`;
}
/** What a Remote JVM Debug configuration attaches to (before the listen mode relay). */
export function remoteJvmTarget(rc: RunConfig): { mode: JdwpMode; host: string; port: number; module: string } {
const p = rc.params ?? {};
-16
View File
@@ -1,16 +0,0 @@
/**
* Why a debug session didn't start, as the user reads it. An adapter that listens for
* the debuggee (php-debug waiting for Xdebug) fails with Node's
* `listen EADDRINUSE: address already in use :::9003` when another IDE holds the port.
*/
import { tx } from "@/platform/i18n";
const PORT_IN_USE = /\bEADDRINUSE\b.*:(\d+)\s*$/;
export function startFailureMessage(e: unknown): string {
const message = e instanceof Error ? e.message : String(e);
const port = PORT_IN_USE.exec(message)?.[1];
return port
? tx`Port ${port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.`
: message;
}
+1 -17
View File
@@ -30,7 +30,6 @@ import { interpolateLog, logParts, suspends } from "./breakpoints/model";
import { syncSpecial, useSpecialBreakpoints } from "./breakpoints/special";
import { refreshExceptionFilters } from "./breakpoints/exceptions";
import { missingAdapter } from "./missingAdapter";
import { startFailureMessage } from "./startFailure";
export type SessionStatus = "starting" | "running" | "paused" | "terminated";
@@ -46,8 +45,6 @@ export interface DebugSession {
parent: DebugSessionId | null;
/** Launch configuration (root sessions), used by Rerun. */
launch: LaunchConfig | null;
/** The program runs in a terminal the IDE gave it: the console types into it while it runs. */
programInput?: boolean;
/** Process the session is attached to (a Run tab's build tool); stopped with the session. */
process: RunId | null;
status: SessionStatus;
@@ -608,7 +605,7 @@ export async function startSession(
group: "run",
level: "error",
title: `Cannot start debugger for ${launch.name}`,
body: startFailureMessage(e),
body: e instanceof Error ? e.message : String(e),
key: "debug.start",
});
return null;
@@ -947,16 +944,6 @@ export async function setVariableValue(
return result;
}
/** Whether the console types into the session's program (it runs, in a terminal the IDE gave it). */
export function readsProgramInput(s: DebugSession): boolean {
return !!s.programInput && s.status === "running";
}
/** Types `text` into the session's program; its terminal echoes it to the console. */
export async function sendProgramInput(session: DebugSession, text: string): Promise<void> {
await call(commands.debugSendInput(session.id, text));
}
export async function evaluate(
expression: string,
context: "repl" | "watch" | "hover" | "clipboard",
@@ -1035,9 +1022,6 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) {
case "thread":
if (s.status === "paused") void loadThreads(id);
break;
case "programInput":
patch(id, { programInput: true });
break;
case "breakpointsChanged":
void refreshStates(event.path);
break;
+3 -28
View File
@@ -16,16 +16,7 @@ import {
type FindOptions,
type FindResult,
} from "@/ui/consoleFind";
import {
appendConsole,
clearConsole,
evaluate,
isLive,
readsProgramInput,
sendProgramInput,
useDebug,
type DebugSession,
} from "../state";
import { appendConsole, clearConsole, evaluate, isLive, useDebug, type DebugSession } from "../state";
import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory";
import { useExpressionCompletion } from "./ExpressionCompletion";
import styles from "./DebugToolWindow.module.css";
@@ -254,16 +245,6 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
}, [findOpen, current, virtualizer]);
const submit = async () => {
if (readsProgramInput(session)) {
// Like the Run console: the line as typed, empty or not, goes to the program.
setInput("");
try {
await sendProgramInput(session, `${input}\n`);
} catch (err) {
appendConsole(session.id, "stderr", `${err instanceof Error ? err.message : String(err)}\n`);
}
return;
}
const e = input.trim();
if (!e) return;
pushHistory("console", e);
@@ -485,16 +466,10 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
value={input}
onChange={(e) => {
setInput(e.target.value);
if (!readsProgramInput(session)) completion.onInput();
completion.onInput();
}}
onKeyDown={onKeyDown}
placeholder={
!isLive(session)
? "Session finished"
: readsProgramInput(session)
? "Input for the program (pause to evaluate)"
: "Evaluate in the debug console"
}
placeholder={isLive(session) ? "Evaluate in the debug console" : "Session finished"}
disabled={!isLive(session)}
aria-label={tx("Debug console input")}
spellCheck={false}
+1 -6
View File
@@ -37,7 +37,6 @@ import {
useDebug,
type DebugSession,
} from "../state";
import { remoteWaitingNote } from "../remote";
import { DebugConsole } from "./DebugConsole";
import { VariablesView, type VarRoot } from "./VariablesView";
import { useExpressionCompletion } from "./ExpressionCompletion";
@@ -305,11 +304,7 @@ function FramesPane({ session }: { session: DebugSession }) {
onBlur={() => blurDebugView("frames")}
>
{!paused && (
<div className={styles.note}>
{session.status === "terminated"
? tx("Finished")
: (remoteWaitingNote(rootOf(session).runConfig) ?? tx("Running…"))}
</div>
<div className={styles.note}>{session.status === "terminated" ? tx("Finished") : tx("Running…")}</div>
)}
{items.map((item) =>
item.kind === "hidden" ? (
@@ -1,206 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getContext, setContext } from "@/platform/context";
import { useProject } from "@/platform/project";
import { usePlugins } from "../store";
import {
affectsWorkspaceContains,
refreshWorkspaceContains,
startWorkspaceContains,
workspaceContainsKeys,
} from "../workspaceContains";
const ipc = vi.hoisted(() => ({
handlers: {} as Record<string, (e: unknown) => void>,
files: {} as Record<string, string[]>,
readDir: vi.fn(),
}));
vi.mock("@/platform/ipc", () => ({
call: (p: unknown) => p,
isTauri: () => true,
commands: { fsReadDir: (dir: string) => ipc.readDir(dir) },
onIdeEvent: (name: string, handler: (e: unknown) => void) => {
ipc.handlers[name] = handler;
return () => {
delete ipc.handlers[name];
};
},
}));
describe("workspaceContains", () => {
it("collects keys from when clauses", () => {
expect(
workspaceContainsKeys([
"workspaceContains:a/b.txt && editorFocus",
null,
"!workspaceContains:pom.xml",
"workspaceContains:a/b.txt",
]),
).toEqual(["workspaceContains:a/b.txt", "workspaceContains:pom.xml"]);
});
it("sets each key from its parent folder's listing", async () => {
const list = vi.fn(async (dir: string) => (dir === "/p/.devcontainer" ? ["devcontainer.json"] : ["src"]));
await refreshWorkspaceContains(
["/p"],
["workspaceContains:.devcontainer/devcontainer.json", "workspaceContains:pom.xml"],
list,
);
expect(getContext()["workspaceContains:.devcontainer/devcontainer.json"]).toBe(true);
expect(getContext()["workspaceContains:pom.xml"]).toBe(false);
expect(list).toHaveBeenCalledWith("/p");
});
it("is false without a project or when the folder can't be read", async () => {
const list = vi.fn(async (): Promise<string[]> => {
throw new Error("missing");
});
await refreshWorkspaceContains(["/p"], ["workspaceContains:x/y"], list);
expect(getContext()["workspaceContains:x/y"]).toBe(false);
setContext("workspaceContains:x/y", true);
list.mockClear();
await refreshWorkspaceContains([], ["workspaceContains:x/y"], list);
expect(list).not.toHaveBeenCalled();
expect(getContext()["workspaceContains:x/y"]).toBe(false);
});
it("is true when any root contains the path", async () => {
const list = vi.fn(async (dir: string) => (dir === "/b" ? ["pom.xml"] : []));
await refreshWorkspaceContains(["/a", "/b"], ["workspaceContains:pom.xml"], list);
expect(getContext()["workspaceContains:pom.xml"]).toBe(true);
});
it("ignores keys that are absolute or climb out of the project, and a trailing slash", async () => {
const list = vi.fn(async (dir: string) => (dir === "/p" ? [".devcontainer"] : []));
await refreshWorkspaceContains(
["/p"],
[
"workspaceContains:/etc/passwd",
"workspaceContains:../a",
"workspaceContains:a/../../b",
"workspaceContains:.devcontainer/",
],
list,
);
expect(list).toHaveBeenCalledTimes(1);
expect(list).toHaveBeenCalledWith("/p");
expect(getContext()["workspaceContains:/etc/passwd"]).toBe(false);
expect(getContext()["workspaceContains:../a"]).toBe(false);
expect(getContext()["workspaceContains:a/../../b"]).toBe(false);
expect(getContext()["workspaceContains:.devcontainer/"]).toBe(true);
});
it("only picks keys at a word boundary", () => {
expect(workspaceContainsKeys(["xworkspaceContains:../a", "a && workspaceContains:b"])).toEqual([
"workspaceContains:b",
]);
});
it("refreshes only when a watched path or one of its parents changes", () => {
const keys = ["workspaceContains:.devcontainer/devcontainer.json"];
const affects = (paths: string[], roots = ["/p"]) => affectsWorkspaceContains(paths, roots, keys);
expect(affects(["/p2/x"])).toBe(false);
expect(affects(["/p/src/a.ts"])).toBe(false);
expect(affects(["/p/.devcontainer"])).toBe(true);
expect(affects(["/p/.devcontainer/devcontainer.json"])).toBe(true);
expect(affects(["/q/.devcontainer"], ["/p", "/q"])).toBe(true);
expect(affects(["/p/.devcontainer"], [])).toBe(false);
});
it("ignores a stale refresh that resolves after a newer one", async () => {
const key = "workspaceContains:pom.xml";
let releaseOld: (names: string[]) => void = () => {};
let releaseNew: (names: string[]) => void = () => {};
const old = new Promise<string[]>((resolve) => (releaseOld = resolve));
const fresh = new Promise<string[]>((resolve) => (releaseNew = resolve));
let latest = 0;
const first = refreshWorkspaceContains(
["/a"],
[key],
() => old,
(
(t) => () =>
t === latest
)(++latest),
);
const second = refreshWorkspaceContains(
["/b"],
[key],
() => fresh,
(
(t) => () =>
t === latest
)(++latest),
);
releaseNew(["pom.xml"]);
await second;
releaseOld([]);
await first;
expect(getContext()[key]).toBe(true);
});
});
describe("startWorkspaceContains", () => {
const key = "workspaceContains:.devcontainer/devcontainer.json";
const setProject = (root: string) => useProject.setState({ project: { root, workspace: null } as never });
const keyIs = (value: boolean) => vi.waitFor(() => expect(getContext()[key]).toBe(value));
const emit = (name: string, event: unknown) => {
const handler = ipc.handlers[name];
if (!handler) {
throw new Error(`no handler for ${name}`);
}
handler(event);
};
let stop: () => void;
beforeEach(() => {
ipc.files = { "/p": ["src"], "/q": [".devcontainer"], "/q/.devcontainer": ["devcontainer.json"] };
ipc.readDir.mockReset();
ipc.readDir.mockImplementation(async (dir: string) => (ipc.files[dir] ?? []).map((name) => ({ name })));
usePlugins.setState({ ui: [{ pluginId: "x", point: "menus", key: "k", decl: { when: key } }] as never });
setProject("/p");
setContext(key, undefined);
stop = startWorkspaceContains();
});
afterEach(() => stop());
it("re-evaluates when the file is created and deleted", async () => {
await keyIs(false);
ipc.files["/p"] = [".devcontainer"];
ipc.files["/p/.devcontainer"] = ["devcontainer.json"];
emit("fsChanged", { created: ["/p/.devcontainer"], modified: [], removed: [] });
await keyIs(true);
ipc.files["/p/.devcontainer"] = [];
emit("fsChanged", { created: [], modified: [], removed: ["/p/.devcontainer/devcontainer.json"] });
await keyIs(false);
});
it("re-evaluates when the project changes", async () => {
await keyIs(false);
setProject("/q");
await keyIs(true);
});
it.each([["/p/.devcontainer"], ["/p"]])("re-evaluates when ignored folder %s changes", async (dir) => {
await keyIs(false);
ipc.files["/p/.devcontainer"] = ["devcontainer.json"];
emit("excludedDirsChanged", { dirs: [dir] });
await keyIs(true);
});
it("ignores unrelated changes", async () => {
await keyIs(false);
ipc.readDir.mockClear();
emit("fsChanged", { created: ["/p/src/a.ts"], modified: [], removed: [] });
emit("excludedDirsChanged", { dirs: ["/p/node_modules"] });
expect(ipc.readDir).not.toHaveBeenCalled();
});
it("stops listening when disposed", async () => {
await keyIs(false);
stop();
expect(ipc.handlers.fsChanged).toBeUndefined();
expect(ipc.handlers.excludedDirsChanged).toBeUndefined();
});
});
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more