feat(debugger): Remote debug run configurations (Remote JVM, Go, Python, Node.js) (#50)

Root cause: run configurations could only launch a process; there was no "attach to a debugger that is already listening" type, and debugServer only connected to 127.0.0.1.
Fix (IDEA / GoLand / PyCharm): debug-only Remote JVM Debug (attach / listen, live JDWP agent args per JDK version), Go Remote (dlv --headless, substitutePath), Python Remote Debug (debugpy --listen, pathMappings) and Attach to Node.js (inspector, localRoot/remoteRoot); debugServer accepts host:port and [v6]:port; JVM listen mode via a cancellable relay so java-debug can attach.
Verified: ide-dap tests (host:port, relay handshake, cancel), run/debugger vitest, in-app on macOS: Python Remote Debug attached to a debugpy server, paused at app.py:3 with variables. Remote JVM/Go/Node not exercised in-app.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 10:38:38 +08:00
1 джерело 642b80cef0
коміт e150101244
20 files changed
+944 -36

No files matched your search

+15 -4
Переглянути файл
@@ -36,6 +36,8 @@ pub struct AdapterSpec {
pub cwd: PathBuf,
/// Extra environment of the adapter process (debugpy's private copy on `PYTHONPATH`).
pub env: Vec<(String, String)>,
/// Host of an adapter that is already running (`debugServer`); `None` is the loopback interface.
pub server_host: Option<String>,
}
/// Built-in adapters used when no language definition names `adapter`.
@@ -116,6 +118,7 @@ pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
install_hint: config.install_hint.clone(),
cwd,
env: Vec::new(),
server_host: None,
}
}
@@ -309,7 +312,12 @@ impl AdapterLauncher for ProcessLauncher {
Box::pin(async move {
let stderr = Arc::new(parking_lot::Mutex::new(Vec::new()));
if let Some(port) = reuse_port {
let stream = connect_with_retries(port, None, Duration::from_secs(5)).await?;
// A remote adapter (`dlv --headless` on another machine) may answer slowly.
let (host, timeout) = match spec.server_host.as_deref() {
Some(host) => (host, CONNECT_TIMEOUT),
None => (LOOPBACK, Duration::from_secs(5)),
};
let stream = connect_with_retries(host, port, None, timeout).await?;
let (r, w) = stream.into_split();
return Ok(AdapterIo {
reader: Box::new(r),
@@ -366,7 +374,7 @@ impl AdapterLauncher for ProcessLauncher {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone());
}
let stream = match connect_with_retries(port, Some(&mut child), CONNECT_TIMEOUT).await {
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), CONNECT_TIMEOUT).await {
Ok(s) => s,
Err(e) => {
let _ = child.start_kill();
@@ -410,14 +418,17 @@ fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
});
}
const LOOPBACK: &str = "127.0.0.1";
async fn connect_with_retries(
host: &str,
port: u16,
mut child: Option<&mut Child>,
timeout: Duration,
) -> IdeResult<tokio::net::TcpStream> {
let deadline = Instant::now() + timeout;
loop {
match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
match tokio::net::TcpStream::connect((host, port)).await {
Ok(s) => {
let _ = s.set_nodelay(true);
return Ok(s);
@@ -432,7 +443,7 @@ async fn connect_with_retries(
}
if Instant::now() >= deadline {
return Err(IdeError::unavailable(format!(
"could not connect to debug adapter on port {port}: {e}"
"could not connect to debug adapter at {host}:{port}: {e}"
)));
}
tokio::time::sleep(Duration::from_millis(100)).await;
+97
Переглянути файл
@@ -0,0 +1,97 @@
//! "Listen to remote JVM" (IDEA's Remote JVM Debug in listen mode): the IDE listens,
//! the remote JVM (`-agentlib:jdwp=…,server=n,address=<ide-host>:<port>`) connects to
//! it. java-debug can only attach, so the connection is relayed: once the JVM has
//! connected, java-debug attaches to a one-shot loopback port that forwards the JDWP
//! stream (handshake included) both ways.
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::io::ErrorKind;
use std::time::Duration;
use tokio::net::{TcpListener, TcpStream};
/// How long the relay waits for java-debug to attach once the JVM is connected.
const ATTACH_TIMEOUT: Duration = Duration::from_secs(60);
/// The address to listen on for `host`: the interface it names, or every interface when
/// it is not one of this machine's (a DNS name or a NAT address the JVM connects to).
async fn bind(host: &str, port: u16) -> IdeResult<TcpListener> {
let host = host.trim();
let host = if host.is_empty() { "0.0.0.0" } else { host.trim_start_matches('[').trim_end_matches(']') };
match TcpListener::bind((host, port)).await {
Ok(l) => Ok(l),
Err(e) if e.kind() == ErrorKind::AddrNotAvailable || e.kind() == ErrorKind::Other => {
TcpListener::bind(("0.0.0.0", port))
.await
.map_err(|e| IdeError::unavailable(format!("cannot listen on port {port}: {e}")))
}
Err(e) => Err(IdeError::unavailable(format!("cannot listen on {host}:{port}: {e}"))),
}
}
/// Waits for a JVM to connect to `host:port`, then returns a loopback port that relays
/// to it (accepts one connection). Cancelled through `cancel` while waiting.
pub async fn listen_for_jvm(host: &str, port: u16, cancel: CancellationToken) -> IdeResult<u16> {
let listener = bind(host, port).await?;
let jvm = tokio::select! {
_ = cancel.cancelled() => return Err(IdeError::cancelled()),
accepted = listener.accept() => accepted.map_err(IdeError::from)?.0,
};
drop(listener);
let _ = jvm.set_nodelay(true);
let relay = TcpListener::bind(("127.0.0.1", 0)).await?;
let local = relay.local_addr()?.port();
tokio::spawn(relay_one(relay, jvm));
Ok(local)
}
async fn relay_one(relay: TcpListener, mut jvm: TcpStream) {
let debugger = match tokio::time::timeout(ATTACH_TIMEOUT, relay.accept()).await {
Ok(Ok((s, _))) => s,
_ => return,
};
drop(relay);
let _ = debugger.set_nodelay(true);
let mut debugger = debugger;
if let Err(e) = tokio::io::copy_bidirectional(&mut debugger, &mut jvm).await {
tracing::debug!(target: "ide_dap::jdwp", "JDWP relay closed: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::test]
async fn relays_the_jdwp_handshake_both_ways() {
let port = TcpListener::bind(("127.0.0.1", 0)).await.unwrap().local_addr().unwrap().port();
let waiting = tokio::spawn(listen_for_jvm("127.0.0.1", port, CancellationToken::new()));
// The remote JVM (`server=n`) connects to the IDE.
let mut jvm = loop {
if let Ok(s) = TcpStream::connect(("127.0.0.1", port)).await {
break s;
}
tokio::time::sleep(Duration::from_millis(20)).await;
};
let local = waiting.await.unwrap().unwrap();
// java-debug attaches to the relay and starts the handshake.
let mut debugger = TcpStream::connect(("127.0.0.1", local)).await.unwrap();
debugger.write_all(b"JDWP-Handshake").await.unwrap();
let mut buf = [0u8; 14];
jvm.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"JDWP-Handshake");
jvm.write_all(b"JDWP-Handshake").await.unwrap();
debugger.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"JDWP-Handshake");
}
#[tokio::test]
async fn waiting_is_cancellable() {
let cancel = CancellationToken::new();
let waiting = tokio::spawn(listen_for_jvm("127.0.0.1", 0, cancel.clone()));
tokio::time::sleep(Duration::from_millis(50)).await;
cancel.cancel();
let err = waiting.await.unwrap().unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
}
}
+33 -9
Переглянути файл
@@ -12,6 +12,7 @@ pub mod adapter;
pub mod attach;
mod cargo;
pub mod connection;
pub mod jdwp;
mod session;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
@@ -45,7 +46,8 @@ pub struct LaunchConfig {
/// A `"cargo": {"args": [...], "filter": {"name", "kind"}}` entry builds with cargo
/// and launches the produced executable; `"debugServer": <port>` connects to an
/// adapter already listening on that local port (e.g. java-debug inside jdtls)
/// instead of starting one.
/// instead of starting one, `"debugServer": "host:port"` to one on another machine
/// (`dlv --headless`, `debugpy --listen`).
pub extra_json: Option<String>,
}
@@ -357,15 +359,34 @@ pub(crate) fn substitute_free_port(value: &mut Value, port: &mut impl FnMut() ->
})
}
/// Where a running adapter listens: `debugServer` of the launch arguments.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DebugServer {
/// `None` for the loopback interface (an adapter inside a local process such as jdtls).
pub host: Option<String>,
pub port: u16,
}
/// Removes `debugServer` (VS Code's "connect to a running adapter" attribute) from the
/// launch arguments and returns its port.
pub(crate) fn take_debug_server(arguments: &mut Value) -> IdeResult<Option<u16>> {
/// launch arguments: a port (`4711`, `"4711"`) on this machine, or `"host:port"` /
/// `"[v6]:port"` of a remote one (`dlv --headless`, `debugpy --listen`).
pub(crate) fn take_debug_server(arguments: &mut Value) -> IdeResult<Option<DebugServer>> {
let Some(v) = arguments.as_object_mut().and_then(|o| o.remove("debugServer")) else { return Ok(None) };
let port = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()));
match port.and_then(|p| u16::try_from(p).ok()).filter(|p| *p != 0) {
Some(p) => Ok(Some(p)),
None => Err(IdeError::invalid(format!("invalid debugServer port: {v}"))),
let invalid = || IdeError::invalid(format!("invalid debugServer address: {v}"));
let port_of = |p: u64| u16::try_from(p).ok().filter(|p| *p != 0);
if let Some(p) = v.as_u64() {
return port_of(p).map(|port| Some(DebugServer { host: None, port })).ok_or_else(invalid);
}
let s = v.as_str().map(str::trim).ok_or_else(invalid)?;
let (host, port) = match s.rsplit_once(':') {
Some((host, port)) => {
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
(Some(host).filter(|h| !h.is_empty()).map(str::to_owned), port)
}
None => (None, s),
};
let port = port.trim().parse::<u64>().ok().and_then(port_of).ok_or_else(invalid)?;
Ok(Some(DebugServer { host, port }))
}
/// DAP stack frame → [`StackFrame`] (1-based → 0-based).
@@ -558,6 +579,9 @@ impl DebugManager {
}
let id = DebugSessionId::next();
let debug_server = take_debug_server(&mut arguments)?;
if let Some(server) = &debug_server {
spec.server_host = server.host.clone();
}
if let Some(cargo) = arguments.as_object_mut().and_then(|o| o.remove("cargo")) {
let cargo_args: Vec<String> = cargo
.get("args")
@@ -599,8 +623,8 @@ impl DebugManager {
if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() {
arguments["sourceLanguages"] = json!(["rust"]);
}
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, debug_server)
.await
let port = debug_server.map(|s| s.port);
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await
}
/// `startDebugging` reverse request: a child session on the same adapter.
+46 -2
Переглянути файл
@@ -597,14 +597,23 @@ async fn sessions_deliver_events_to_their_owner_only() {
#[test]
fn debug_server_is_taken_from_launch_arguments() {
let local = |port| Some(DebugServer { host: None, port });
let remote = |host: &str, port| Some(DebugServer { host: Some(host.into()), port });
let mut args = json!({"name": "x", "debugServer": 4711, "mainClass": "a.B"});
assert_eq!(take_debug_server(&mut args).unwrap(), Some(4711));
assert_eq!(take_debug_server(&mut args).unwrap(), local(4711));
assert!(args.get("debugServer").is_none());
assert_eq!(args["mainClass"], "a.B");
assert_eq!(take_debug_server(&mut json!({"debugServer": "5005"})).unwrap(), Some(5005));
assert_eq!(take_debug_server(&mut json!({"debugServer": "5005"})).unwrap(), local(5005));
assert_eq!(take_debug_server(&mut json!({"name": "x"})).unwrap(), None);
// A remote adapter: `dlv --headless --listen=:2345`, `debugpy --listen 0.0.0.0:5678`.
assert_eq!(take_debug_server(&mut json!({"debugServer": "build-box:2345"})).unwrap(), remote("build-box", 2345));
assert_eq!(take_debug_server(&mut json!({"debugServer": "10.0.0.7:5678"})).unwrap(), remote("10.0.0.7", 5678));
assert_eq!(take_debug_server(&mut json!({"debugServer": "[::1]:2345"})).unwrap(), remote("::1", 2345));
assert_eq!(take_debug_server(&mut json!({"debugServer": ":2345"})).unwrap(), local(2345));
assert!(take_debug_server(&mut json!({"debugServer": 0})).is_err());
assert!(take_debug_server(&mut json!({"debugServer": 70000})).is_err());
assert!(take_debug_server(&mut json!({"debugServer": "host:"})).is_err());
assert!(take_debug_server(&mut json!({"debugServer": "host:abc"})).is_err());
}
#[test]
@@ -696,6 +705,41 @@ async fn connects_to_running_debug_server_over_tcp() {
assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. })));
}
/// Go Remote / Python remote: the adapter runs on another machine (`dlv --headless`,
/// `debugpy --listen`); `debugServer: "host:port"` connects there instead of spawning one.
#[tokio::test]
async fn connects_to_remote_debug_server_by_host() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let port = listener.local_addr().unwrap().port();
let log: Log = Arc::default();
let server_log = log.clone();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
fake_adapter(stream, server_log, Script::default()).await;
});
let (owner, _events) = recording_owner("main");
let manager = DebugManager::new(Arc::new(LanguageRegistry::from_configs(Vec::new())));
let config = LaunchConfig {
name: "Remote".into(),
adapter: "delve".into(),
request: "attach".into(),
program: None,
args: vec![],
cwd: Some(std::env::temp_dir().into()),
env: BTreeMap::new(),
extra_json: Some(
json!({"debugServer": format!("localhost:{port}"), "mode": "remote", "substitutePath": [{"from": "/src", "to": "/app"}]})
.to_string(),
),
};
let id = manager.start(config, owner).await.unwrap();
let attach = log.lock().iter().find(|m| m["command"] == "attach").cloned().unwrap();
assert_eq!(attach["arguments"]["mode"], "remote");
assert_eq!(attach["arguments"]["substitutePath"][0]["to"], "/app");
assert!(attach["arguments"].get("debugServer").is_none(), "consumed by the client");
manager.stop(id).await.unwrap();
}
fn requests(h: &Harness, command: &str) -> Vec<Value> {
h.log
.lock()
+16
Переглянути файл
@@ -264,6 +264,22 @@ pub async fn debug_attachable_processes() -> IdeResult<Vec<attach::AttachablePro
.map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))?
}
/// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
#[tauri::command]
#[specta::specta]
pub async fn debug_listen_for_jvm(
state: State<'_, AppState>,
request: ide_core::RequestId,
host: String,
port: u16,
) -> IdeResult<u16> {
let token = state.begin_request(request);
let result = jdwp::listen_for_jvm(&host, port, token).await;
state.end_request(request);
result
}
/// HotSwap (Reload Changed Classes) in a java-debug session.
#[tauri::command]
#[specta::specta]
+1
Переглянути файл
@@ -286,6 +286,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
debug::debug_set_data_breakpoints,
debug::debug_data_breakpoint_info,
debug::debug_attachable_processes,
debug::debug_listen_for_jvm,
debug::debug_redefine_classes,
coverage::coverage_prepare,
coverage::coverage_collect,
+7 -1
Переглянути файл
@@ -582,6 +582,11 @@ export const commands = {
debugDataBreakpointInfo: (session: DebugSessionId, container: number, name: string, frameId: number | null) => typedError<DataBreakpointInfo, IdeError>(__TAURI_INVOKE("debug_data_breakpoint_info", { session, container, name, frameId })),
/** Attach to Process: the user's processes a debugger can attach to. */
debugAttachableProcesses: () => typedError<AttachableProcess[], IdeError>(__TAURI_INVOKE("debug_attachable_processes")),
/**
* Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
* JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
*/
debugListenForJvm: (request: RequestId, host: string, port: number) => typedError<number, IdeError>(__TAURI_INVOKE("debug_listen_for_jvm", { request, host, port })),
/** HotSwap (Reload Changed Classes) in a java-debug session. */
debugRedefineClasses: (session: DebugSessionId) => typedError<HotSwapResult, IdeError>(__TAURI_INVOKE("debug_redefine_classes", { session })),
/**
@@ -2949,7 +2954,8 @@ export type LaunchConfig = {
* A `"cargo": {"args": [...], "filter": {"name", "kind"}}` entry builds with cargo
* and launches the produced executable; `"debugServer": <port>` connects to an
* adapter already listening on that local port (e.g. java-debug inside jdtls)
* instead of starting one.
* instead of starting one, `"debugServer": "host:port"` to one on another machine
* (`dlv --headless`, `debugpy --listen`).
*/
extraJson: string | null,
};
+159
Переглянути файл
@@ -0,0 +1,159 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RunConfig } from "@/platform/ipc";
import { registerRunConfigType } from "@/workbench/contributions";
const invoked: Array<[string, unknown[]]> = [];
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
const ok = (data: unknown) => Promise.resolve({ status: "ok", data });
return {
...real,
commands: {
...real.commands,
javaDebugServer: (...a: unknown[]) => {
invoked.push(["javaDebugServer", a]);
return ok(41234);
},
},
};
});
const { javaAttachConfig } = await import("../java");
const remote = await import("../remote");
const { missingParams, newConfigOfType, isDebugOnly, terminalApplies, withParams } =
await import("@/features/run/types");
const { validateConfig } = await import("@/features/run/configs");
const {
REMOTE_TYPES,
remoteJvmType,
goRemoteType,
pythonRemoteType,
nodeRemoteType,
jdwpAgentArgs,
remoteAttachConfig,
remoteJvmTarget,
} = remote;
function configOf(type: (typeof REMOTE_TYPES)[number], params: Record<string, string> = {}): RunConfig {
const c = newConfigOfType(type, []);
return withParams({ ...c, name: "Remote" }, { ...c.params, ...params }, type);
}
let disposers: Array<() => void> = [];
beforeEach(() => {
invoked.length = 0;
disposers = REMOTE_TYPES.map(registerRunConfigType);
});
afterEach(() => disposers.forEach((d) => d()));
describe("remote debug configuration types", () => {
it.each(REMOTE_TYPES.map((t) => [t.id, t] as const))(
"%s is debug-only, launches nothing and is valid by default",
(_, type) => {
const c = configOf(type);
expect(type.debugOnly).toBe(true);
expect(isDebugOnly(c)).toBe(true);
expect(c.debugAdapter).toBe(type.debugAdapter);
expect(c.command).toBe("");
expect(terminalApplies(c, type)).toBe(false);
expect(validateConfig(c, [c])).toEqual({});
expect(missingParams(c, type)).toEqual({});
},
);
it.each(REMOTE_TYPES.map((t) => [t.id, t] as const))("%s rejects a bad port and an empty host", (_, type) => {
expect(missingParams(configOf(type, { port: "70000" }), type)).toEqual({
port: "Port must be a number from 1 to 65535",
});
expect(missingParams(configOf(type, { port: "abc" }), type).port).toBeDefined();
expect(missingParams(configOf(type, { host: "" }), type)).toEqual({ host: "Host is required" });
});
it("Remote JVM Debug defaults to attaching to localhost:5005", () => {
expect(configOf(remoteJvmType).params).toEqual({ mode: "attach", host: "localhost", port: "5005", jdk: "9" });
});
});
describe("Remote JVM Debug: command line arguments for remote JVM", () => {
it.each([
[{ jdk: "9" }, "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"],
[{ jdk: "5" }, "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005"],
[{ jdk: "1.4" }, "-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005"],
[{ jdk: "9", port: "8000" }, "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000"],
[
{ mode: "listen", host: "dev-box", port: "5005" },
"-agentlib:jdwp=transport=dt_socket,server=n,address=dev-box:5005,suspend=y",
],
])("%j → %s", (params, expected) => {
expect(jdwpAgentArgs({ ...remoteJvmType.defaults, ...params })).toBe(expected);
});
it("is shown as a read-only field that follows the form", () => {
const field = remoteJvmType.fields.find((f) => f.key === "args")!;
expect(field.control).toBe("output");
expect(field.value!({ port: "6006", jdk: "5" })).toContain("address=6006");
});
});
describe("remote attach requests", () => {
it("Remote JVM Debug attaches java-debug to host:port with the module's sources", async () => {
const c = configOf(remoteJvmType, { host: "10.0.0.5", port: "5006", module: "api" });
const t = remoteJvmTarget(c);
expect(t).toEqual({ mode: "attach", host: "10.0.0.5", port: 5006, module: "api" });
const l = await javaAttachConfig({ name: c.name, module: t.module, hostName: t.host, port: t.port }, "/p");
expect(invoked).toEqual([["javaDebugServer", ["api"]]]);
expect(l.adapter).toBe("java-debug");
expect(l.request).toBe("attach");
expect(l.cwd).toBe("/p/api");
expect(JSON.parse(l.extraJson!)).toMatchObject({ debugServer: 41234, hostName: "10.0.0.5", port: 5006 });
});
it("Remote JVM Debug in listen mode", () => {
expect(remoteJvmTarget(configOf(remoteJvmType, { mode: "listen" })).mode).toBe("listen");
});
it("Go Remote connects to dlv --headless and maps the source root", () => {
const l = remoteAttachConfig(configOf(goRemoteType, { host: "build", remoteRoot: "/go/src/app" }), "/p")!;
expect(l).toMatchObject({ adapter: "delve", request: "attach", cwd: "/p" });
expect(JSON.parse(l.extraJson!)).toEqual({
debugServer: "build:2345",
mode: "remote",
substitutePath: [{ from: "/p", to: "/go/src/app" }],
});
const plain = JSON.parse(remoteAttachConfig(configOf(goRemoteType), "/p")!.extraJson!);
expect(plain).toEqual({ debugServer: "localhost:2345", mode: "remote" });
});
it("Python remote connects to debugpy --listen with pathMappings", () => {
const l = remoteAttachConfig(
configOf(pythonRemoteType, { host: "::1", localRoot: "svc", remoteRoot: "/app" }),
"/p",
)!;
expect(l.adapter).toBe("debugpy");
expect(JSON.parse(l.extraJson!)).toMatchObject({
debugServer: "[::1]:5678",
pathMappings: [{ localRoot: "/p/svc", remoteRoot: "/app" }],
});
});
it("Attach to Node.js attaches js-debug to the inspector with local/remote roots", () => {
const l = remoteAttachConfig(configOf(nodeRemoteType, { host: "web", remoteRoot: "/usr/src/app" }), "/p")!;
expect(l.adapter).toBe("js-debug");
expect(JSON.parse(l.extraJson!)).toMatchObject({
type: "pwa-node",
address: "web",
port: 9229,
localRoot: "/p",
remoteRoot: "/usr/src/app",
});
});
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",
);
expect(remote.debugpyServerCommand({ port: "5679" })).toContain("--listen 0.0.0.0:5679");
expect(remote.nodeInspectCommand({})).toBe("node --inspect=0.0.0.0:9229 app.js");
});
});
+52 -1
Переглянути файл
@@ -1,6 +1,6 @@
/** Debugger actions (ids match `keymaps/intellij-macos.ts`). */
import { registerActions, type Action } from "@/platform/actions";
import { call, commands, isTauri, type RunConfig, type RunId } from "@/platform/ipc";
import { call, commands, isCancelled, isTauri, nextRequestId, type RunConfig, type RunId } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { activeEditor } from "@/editor/api";
import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions";
@@ -18,6 +18,7 @@ import { toggleBreakpointSmart } from "./breakpoints/toggle";
import { isJavaAttachTarget, isJavaDebuggable, javaAttachConfig, javaLaunchConfig } from "./java";
import { reloadChangedClasses } from "./hotswap";
import { debugUnsupportedReason, launchConfigFor } from "./launch";
import { isRemoteConfig, REMOTE_JVM, remoteAttachConfig, remoteJvmTarget } from "./remote";
import {
activeSession,
isLive,
@@ -142,6 +143,52 @@ function evaluateSeed(): string {
return expressionAt(ed.view, sel.head)?.text ?? "";
}
/**
* Remote JVM Debug in listen mode: the IDE listens and the remote JVM connects; java-debug
* then attaches through a loopback relay. Null when cancelled.
*/
async function listenForJvm(name: string, host: string, port: number): Promise<number | null> {
const request = nextRequestId();
const key = `debug.listen.${request}`;
notifications().notify({
group: "run",
level: "info",
key,
sticky: true,
title: `'${name}': listening for the remote JVM on port ${port}`,
body: `Start the remote JVM with -agentlib:jdwp=transport=dt_socket,server=n,address=${host}:${port},suspend=y`,
actions: [{ label: "Cancel", run: () => void commands.cancelRequest(request) }],
});
try {
return await call(commands.debugListenForJvm(request, host, port));
} catch (e) {
if (isCancelled(e)) return null;
throw e;
} finally {
notifications().expire?.(key);
}
}
/** Remote JVM Debug, Go Remote, Python / Node.js remote: attach over host:port. */
async function startRemote(config: RunConfig, root: string) {
if (config.kind === REMOTE_JVM) {
const t = remoteJvmTarget(config);
let hostName = t.host;
let port = t.port;
if (t.mode === "listen") {
const relay = await listenForJvm(config.name, t.host, t.port);
if (relay == null) return;
[hostName, port] = ["127.0.0.1", relay];
}
const launch = await javaAttachConfig({ name: config.name, module: t.module, hostName, port }, root);
// A listen relay accepts one debugger: rerunning listens again through the configuration.
await startSession(launch, { rerunnable: t.mode === "attach" });
return;
}
const launch = remoteAttachConfig(config, root);
if (launch) await startSession(launch);
}
async function startConfig(arg: unknown) {
const root = useProject.getState().project?.root;
if (!root) return;
@@ -167,6 +214,10 @@ async function startConfig(arg: unknown) {
}
config = configs[0]!;
}
if (isRemoteConfig(config)) {
await startRemote(config, root);
return;
}
if (isJavaDebuggable(config)) {
await maybeEditorService()
?.saveAll()
+9 -1
Переглянути файл
@@ -8,7 +8,12 @@ import { onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { valueTooltip } from "./editor/valueTooltip";
import { registerEditorContribution } from "@/editor/api";
import { registerMenuGroup, registerOverlay, registerToolWindow } from "@/workbench/contributions";
import {
registerMenuGroup,
registerOverlay,
registerRunConfigType,
registerToolWindow,
} from "@/workbench/contributions";
import { registerDebugActions } from "./actions";
import { loadBreakpoints } from "./breakpoints/store";
import { loadExceptionBreakpoints } from "./breakpoints/exceptions";
@@ -19,6 +24,7 @@ import { installSmartStepPicking, smartStepTargets } from "./editor/smartStep";
import { onHotCodeReplace } from "./hotswap";
import { handleDebugEvent, isLive, loadWatches, publishContext, resetSessions, stopSession, useDebug } from "./state";
import { DebuggerOverlays } from "./ui/Overlays";
import { REMOTE_TYPES } from "./remote";
export function activate(): () => void {
loadWatches();
@@ -28,6 +34,8 @@ export function activate(): () => void {
loadSpecialBreakpoints(useProject.getState().project?.root ?? null);
const disposers = [
registerDebugActions(),
// Remote JVM Debug, Go Remote, …: Run/Debug Configurations › Add New Configuration.
...REMOTE_TYPES.map(registerRunConfigType),
registerToolWindow({
id: "debug",
title: "Debug",
+316
Переглянути файл
@@ -0,0 +1,316 @@
/**
* 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.
* Only Debug applies to them (`debugOnly`); they launch no process.
*/
import type { LaunchConfig, RunConfig } from "@/platform/ipc";
import { workflowSettings } from "@/platform/workflowSettings";
import type { RunConfigTypeContribution, RunConfigTypeField } from "@/workbench/contributions";
export const REMOTE_JVM = "remote-jvm";
export const GO_REMOTE = "go-remote";
export const PYTHON_REMOTE = "python-remote";
export const NODE_REMOTE = "node-remote";
type Params = Readonly<Record<string, string>>;
/** A TCP port, or null. */
export function parsePort(value: string | undefined): number | null {
const v = (value ?? "").trim();
if (!/^\d+$/.test(v)) return null;
const n = Number(v);
return n >= 1 && n <= 65535 ? n : null;
}
const hostField: RunConfigTypeField = { key: "host", label: "Host", mono: true, required: true };
const portField: RunConfigTypeField = {
key: "port",
label: "Port",
mono: true,
required: true,
validate: (v) => (parsePort(v) == null ? "Port must be a number from 1 to 65535" : null),
};
const hostOf = (p: Params) => p.host?.trim() || "localhost";
const portOf = (p: Params, fallback: number) => parsePort(p.port) ?? fallback;
// ---------------------------------------------------------------------------
// Remote JVM Debug
export type JdwpMode = "attach" | "listen";
export type JdkVersion = "9" | "5" | "1.4";
/**
* The agent the remote JVM is started with, as IDEA shows it ("Command line arguments
* for remote JVM"). Attach: the JVM listens (`server=y`); JDK 9+ binds only to localhost
* unless the address names a host, hence `*:`. Listen: the JVM connects to the IDE.
*/
export function jdwpAgentArgs(p: Params): string {
const mode: JdwpMode = p.mode === "listen" ? "listen" : "attach";
const jdk = (p.jdk || "9") as JdkVersion;
const port = portOf(p, 5005);
const opts =
mode === "listen"
? `transport=dt_socket,server=n,address=${hostOf(p)}:${port},suspend=y`
: `transport=dt_socket,server=y,suspend=n,address=${jdk === "9" ? "*:" : ""}${port}`;
return jdk === "1.4" ? `-Xdebug -Xrunjdwp:${opts}` : `-agentlib:jdwp=${opts}`;
}
const JVM_KINDS = new Set(["application", "spring-boot", "maven", "gradle", "java-file"]);
export const remoteJvmType: RunConfigTypeContribution = {
id: REMOTE_JVM,
label: "Remote JVM Debug",
icon: "attach-to-process",
description: "Attach the debugger to a JVM started with a JDWP agent, here or on another machine",
defaults: { mode: "attach", host: "localhost", port: "5005", jdk: "9" },
debugOnly: true,
debugAdapter: "java-debug",
fields: [
{
key: "mode",
label: "Debugger mode",
control: "select",
options: [
{ value: "attach", label: "Attach to remote JVM" },
{ value: "listen", label: "Listen to remote JVM" },
],
},
hostField,
portField,
{
key: "jdk",
label: "JDK version of the remote JVM",
control: "select",
options: [
{ value: "9", label: "JDK 9 or later" },
{ value: "5", label: "JDK 5 - 8" },
{ value: "1.4", label: "JDK 1.4.x" },
],
},
{
key: "args",
label: "Command line arguments for remote JVM",
control: "output",
mono: true,
value: jdwpAgentArgs,
hint: "Add to the remote JVM's command line (or JAVA_TOOL_OPTIONS)",
},
{
key: "module",
label: "Use module classpath",
mono: true,
placeholder: "Project root",
hint: "Module directory whose sources the debugger shows",
suggestions: (configs) => [...new Set(configs.filter((c) => JVM_KINDS.has(c.kind) && c.cwd).map((c) => c.cwd!))],
},
],
commandLine: () => null,
};
// ---------------------------------------------------------------------------
// Go Remote (Delve headless server)
/** How to start the remote side, as GoLand shows it. */
export function delveServerCommand(p: Params): string {
return `dlv debug --headless --listen=:${portOf(p, 2345)} --api-version=2 --accept-multiclient`;
}
const localRootField: RunConfigTypeField = {
key: "localRoot",
label: "Local root",
mono: true,
placeholder: "Project root",
};
const remoteRootField = (placeholder: string): RunConfigTypeField => ({
key: "remoteRoot",
label: "Remote root",
mono: true,
placeholder,
hint: "Where the project's sources are on the remote machine, when the paths differ",
});
export const goRemoteType: RunConfigTypeContribution = {
id: GO_REMOTE,
label: "Go Remote",
icon: "file-go",
description: "Attach to a Delve server (dlv --headless) here or on another machine",
defaults: { host: "localhost", port: "2345" },
debugOnly: true,
debugAdapter: "delve",
fields: [
hostField,
portField,
{
key: "hint",
label: "Start the remote side with",
control: "output",
mono: true,
value: delveServerCommand,
hint: "Delve 1.7.3 or later; dlv exec ./app or dlv attach <pid> take the same flags",
},
localRootField,
remoteRootField("e.g. /go/src/app"),
],
commandLine: () => null,
};
// ---------------------------------------------------------------------------
// Python remote (debugpy --listen)
export function debugpyServerCommand(p: Params): string {
return `python -m debugpy --listen 0.0.0.0:${portOf(p, 5678)} --wait-for-client app.py`;
}
export const pythonRemoteType: RunConfigTypeContribution = {
id: PYTHON_REMOTE,
label: "Python Remote Debug (debugpy)",
icon: "file-python",
description: "Attach to a Python process listening with debugpy here or on another machine",
defaults: { host: "localhost", port: "5678" },
debugOnly: true,
debugAdapter: "debugpy",
fields: [
hostField,
portField,
{
key: "hint",
label: "Start the remote side with",
control: "output",
mono: true,
value: debugpyServerCommand,
hint: "pip install debugpy on the remote machine",
},
localRootField,
remoteRootField("e.g. /app"),
],
commandLine: () => null,
};
// ---------------------------------------------------------------------------
// Attach to Node.js (inspector)
export function nodeInspectCommand(p: Params): string {
return `node --inspect=0.0.0.0:${portOf(p, 9229)} app.js`;
}
export const nodeRemoteType: RunConfigTypeContribution = {
id: NODE_REMOTE,
label: "Attach to Node.js",
icon: "file-js",
description: "Attach to a Node.js process started with --inspect here or on another machine",
defaults: { host: "localhost", port: "9229" },
debugOnly: true,
debugAdapter: "js-debug",
fields: [
hostField,
portField,
{
key: "hint",
label: "Start the remote side with",
control: "output",
mono: true,
value: nodeInspectCommand,
},
localRootField,
remoteRootField("e.g. /usr/src/app"),
],
commandLine: () => null,
};
export const REMOTE_TYPES: readonly RunConfigTypeContribution[] = [
remoteJvmType,
goRemoteType,
pythonRemoteType,
nodeRemoteType,
];
export const isRemoteConfig = (c: Pick<RunConfig, "kind">) => REMOTE_TYPES.some((t) => t.id === c.kind);
// ---------------------------------------------------------------------------
// Attach requests
function isAbsolute(p: string) {
return p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
}
function join(base: string, p: string) {
if (isAbsolute(p)) return p;
const rel = p.replace(/^\.\//, "");
return rel === "." || rel === "" ? base : `${base.replace(/[\\/]$/, "")}/${rel}`;
}
/** Local root (absolute) ↔ remote root, or null when the paths are the same on both sides. */
function rootMapping(p: Params, root: string): { local: string; remote: string } | null {
const remote = p.remoteRoot?.trim();
if (!remote) return null;
const local = p.localRoot?.trim() ? join(root, p.localRoot.trim()) : root;
return { local, remote };
}
/** `host:port` for `debugServer` (IPv6 literals in brackets). */
export function serverAddress(host: string, port: number): string {
return `${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`;
}
function attachBase(rc: RunConfig, adapter: string, root: string, extra: Record<string, unknown>): LaunchConfig {
return {
name: rc.name,
adapter,
request: "attach",
program: null,
args: [],
cwd: root,
env: {},
extraJson: JSON.stringify(extra),
};
}
/**
* 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.
*/
export function remoteAttachConfig(rc: RunConfig, root: string): LaunchConfig | null {
const p = rc.params ?? {};
const host = hostOf(p);
const map = rootMapping(p, root);
const skip = workflowSettings().debugger.skipLibraryCode;
switch (rc.kind) {
case GO_REMOTE:
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 attachBase(rc, "debugpy", root, {
debugServer: serverAddress(host, portOf(p, 5678)),
justMyCode: skip,
...(map ? { pathMappings: [{ localRoot: map.local, remoteRoot: map.remote }] } : {}),
});
case NODE_REMOTE:
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/**"] : [],
});
default:
return null;
}
}
/** 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 ?? {};
return {
mode: p.mode === "listen" ? "listen" : "attach",
host: hostOf(p),
port: portOf(p, 5005),
module: p.module?.trim() ?? "",
};
}
+47 -5
Переглянути файл
@@ -170,6 +170,8 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
}
const compound = config.kind === COMPOUND;
// Compound and debug-only types (Remote JVM Debug) start no process of their own.
const noProcess = compound || !!type?.debugOnly;
const params = compound ? {} : formParams(config, type);
const update = (patch: Partial<RunConfig>) => onChange({ ...config, ...patch });
const setParam = (key: string, value: string) => {
@@ -199,7 +201,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
// A terminal has no separate input file: the two options exclude each other.
const blocked = (id: OptionId) =>
(id === "emulateTerminal" && isShown("stdinFile")) || (id === "stdinFile" && !!config.emulateTerminal);
const menuItems: MenuItem[] = OPTION_GROUPS.filter((g) => !compound || !g.process).flatMap((g): MenuItem[] => [
const menuItems: MenuItem[] = OPTION_GROUPS.filter((g) => !noProcess || !g.process).flatMap((g): MenuItem[] => [
{ kind: "header", label: g.label },
...g.options
.filter(
@@ -269,7 +271,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
"isTest",
] as const
).filter(
(id) => (!compound || id === "openToolWindow" || id === "focusToolWindow") && optionSet(config, id, openByDefault),
(id) => (!noProcess || id === "openToolWindow" || id === "focusToolWindow") && optionSet(config, id, openByDefault),
);
return (
@@ -332,7 +334,13 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
type.fields
.filter((f) => f.visible?.(params) ?? true)
.map((f) => (
<Field key={f.key} label={f.label} error={errors[f.key]} top={f.control === "textarea"}>
<Field
key={f.key}
label={f.label}
error={errors[f.key]}
hint={f.hint}
top={f.control === "textarea" || f.control === "output"}
>
<TypeField
field={f}
value={params[f.key] ?? ""}
@@ -375,7 +383,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
</>
)}
{!compound && (
{!noProcess && (
<>
{!type?.ownsWorkingDirectory && (
<Field label="Working directory" error={errors.cwd}>
@@ -545,7 +553,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
</div>
)}
{!compound && !template && (
{!noProcess && !template && (
<div className={styles.editPreview}>
<span className={styles.chooserMuted}>
<T>Command line</T>
@@ -586,6 +594,7 @@ function TypeField({
}) {
const tx = useTx();
const listId = `run-field-${field.key}`;
if (field.control === "output") return <OutputField label={field.label} text={field.value?.(params) ?? ""} />;
if (field.control === "select") {
const options = [...(field.options ?? [])];
if (value && !options.some((o) => o.value === value)) options.push({ value, label: value });
@@ -635,6 +644,39 @@ function TypeField({
);
}
/** Read-only text the form computes (e.g. the JDWP agent for the remote JVM), with Copy. */
function OutputField({ label, text }: { label: string; text: string }) {
const tx = useTx();
const [copied, setCopied] = useState(false);
const copy = () => {
void navigator.clipboard
?.writeText(text)
.then(() => setCopied(true))
.catch(() => undefined);
};
return (
<div className={styles.inputWithButton}>
<textarea
className={styles.textarea}
aria-label={tx(label)}
readOnly
spellCheck={false}
value={text}
rows={Math.min(4, Math.max(1, Math.ceil(text.length / 72)))}
onFocus={(e) => e.currentTarget.select()}
/>
<IconButton
icon={copied ? "check" : "copy"}
size="small"
iconSize={14}
title={copied ? "Copied" : "Copy"}
onClick={copy}
onBlur={() => setCopied(false)}
/>
</div>
);
}
function CompoundMembers({
config,
all,
+13 -2
Переглянути файл
@@ -8,6 +8,7 @@ import { shortcutLabel } from "@/platform/keymap";
import { runExecutors, useRunExecutors } from "@/workbench/contributions";
import { configIcon } from "./configs";
import { CURRENT_FILE, rerunSession, sessionsOf, startRun, stopSession, useRun } from "./store";
import { isDebugOnly } from "./types";
import styles from "./Run.module.css";
/** Title bar: configuration chooser, Run / Debug, and Rerun / Stop while running. */
@@ -19,6 +20,8 @@ export function RunWidget() {
const chooserOpen = useRun((s) => s.chooserOpen);
const selected = configs.find((c) => c.id === selectedId);
const currentFile = selectedId === CURRENT_FILE;
// Remote JVM Debug and the like: only Debug applies, as in IDEA.
const debugOnly = !!selected && isDebugOnly(selected);
const running = sessions.filter((s) => s.status === "running" || s.status === "starting");
// A compound is running while any of its configurations is; Stop stops them all.
const compoundRunning = selected?.kind === "compound" ? sessionsOf(selected, running) : [];
@@ -149,9 +152,17 @@ export function RunWidget() {
size="large"
iconSize={18}
className={clsx(styles.widgetButton, styles.widgetRun)}
title={selected ? `Run '${selected.name}'` : currentFile ? "Run Current File" : "Run"}
title={
selected
? debugOnly
? `'${selected.name}' can only be debugged`
: `Run '${selected.name}'`
: currentFile
? "Run Current File"
: "Run"
}
shortcut={shortcutLabel("run.run")}
disabled={!selected && !currentFile}
disabled={(!selected && !currentFile) || debugOnly}
onClick={() => void executeAction("run.run")}
/>
<IconButton
+2 -1
Переглянути файл
@@ -234,7 +234,8 @@ export function validateConfig(config: RunConfig, all: readonly RunConfig[] = []
else if (compoundCycle(config, all)) errors.compound = "A compound configuration can't include itself";
else if (children.some((id) => !all.some((c) => c.id === id)))
errors.compound = "Some configurations no longer exist";
} else if (!config.command.trim()) errors.command = "Command is required";
} else if (!config.command.trim() && !useRunConfigTypes.getState().types.get(config.kind)?.debugOnly)
errors.command = "Command is required";
for (const t of config.beforeLaunch ?? []) {
if (t.kind === "run" && (t.target === config.id || !all.some((c) => c.id === t.target)))
errors.beforeLaunch = "A before launch task runs a missing configuration or this one";
+6 -1
Переглянути файл
@@ -39,7 +39,7 @@ import { runConfigsRunAnything } from "./runAnything";
import { runServiceProvider } from "./services";
import { runBeforeLaunch } from "./beforeLaunch";
import { compoundChildren } from "./configs";
import { registerBuiltinTypes } from "./types";
import { isDebugOnly, registerBuiltinTypes } from "./types";
import { runGutter } from "./runGutter";
import { activateStackTrace, useStackTraces } from "./stackTrace";
import { clearRuntimeCache } from "./runtimes";
@@ -93,6 +93,11 @@ async function debugConfig(config: RunConfig, chain: readonly string[] = []) {
return;
}
if (!(await prepareLaunch(config, chain))) return;
// Remote JVM Debug, Go Remote…: the debugger attaches, nothing is launched here.
if (isDebugOnly(config) && getAction("debug.startConfig")) {
await executeAction("debug.startConfig", config);
return;
}
// A single compiled file (rustc / clang): build with debug info, then debug the binary.
const compiled = await compiledDebugPlan(config);
if (compiled?.debug) {
+50 -1
Переглянути файл
@@ -6,8 +6,10 @@ import type { RunConfig } from "@/platform/ipc";
import {
provideNotificationService,
provideToolWindowService,
registerRunConfigType,
registerToolWindow,
runConfigTypes,
type RunConfigTypeContribution,
type ToolWindowService,
} from "@/workbench/contributions";
import { typeRows } from "./AddConfigPopup";
@@ -16,7 +18,7 @@ import { buildRows } from "./ConfigChooser";
import { compoundChildren, compoundCycle, formatEnv, parseEnv, validateConfig } from "./configs";
import { EditConfigsOverlay, moveConfig, treeRows } from "./EditConfigsDialog";
import { pasteRows } from "./EnvDialog";
import { CURRENT_FILE, resetRuns, selectConfig, sessionsOf, startCompound, useRun } from "./store";
import { CURRENT_FILE, resetRuns, selectConfig, sessionsOf, startCompound, startRun, useRun } from "./store";
import {
BUILTIN_TYPES,
formParams,
@@ -294,6 +296,53 @@ describe("Run/Debug Configurations tree", () => {
});
});
describe("debug-only types (Remote JVM Debug)", () => {
const remote: RunConfigTypeContribution = {
id: "test-remote",
label: "Test Remote",
icon: "debug",
defaults: { port: "5005" },
debugOnly: true,
debugAdapter: "java-debug",
fields: [
{ key: "port", label: "Port", validate: (v) => (/^\d+$/.test(v) ? null : "Port must be a number") },
{ key: "agent", label: "Agent", control: "output", value: (p) => `address=*:${p.port ?? ""}` },
],
commandLine: () => null,
};
let dispose: () => void;
beforeEach(() => {
dispose = registerRunConfigType(remote);
});
afterEach(() => dispose());
it("edit only their fields; output fields follow the form", async () => {
useRun.setState({ configs: [], loaded: true, editorOpen: true });
render(<EditConfigsOverlay />);
fireEvent.click(screen.getByRole("button", { name: "Add New Configuration" }));
fireEvent.click(await screen.findByText("Test Remote"));
expect((screen.getByLabelText("Agent") as HTMLTextAreaElement).value).toBe("address=*:5005");
fireEvent.change(screen.getByLabelText("Port"), { target: { value: "6006" } });
expect((screen.getByLabelText("Agent") as HTMLTextAreaElement).value).toBe("address=*:6006");
expect((screen.getByLabelText("Agent") as HTMLTextAreaElement).readOnly).toBe(true);
expect(screen.getByRole("button", { name: "Copy" })).toBeTruthy();
// No process: no working directory, environment, debug adapter or command line.
expect(screen.queryByLabelText("Working directory")).toBeNull();
expect(screen.queryByLabelText("Debug adapter")).toBeNull();
expect(screen.queryByText("Command line")).toBeNull();
fireEvent.change(screen.getByLabelText("Port"), { target: { value: "x" } });
expect(screen.getByRole("alert").textContent).toBe("Port must be a number");
});
it("are valid without a command, get the type's adapter and are not run", async () => {
const c = newConfigOfType(remote, []);
expect(c.debugAdapter).toBe("java-debug");
expect(validateConfig(c, [c])).toEqual({});
expect(await startRun(c)).toBeNull();
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ title: "'Unnamed' can only be debugged" }));
});
});
describe("Current File", () => {
it("is always offered in the chooser", () => {
expect(buildRows([], "")[0]).toMatchObject({ type: "current" });
+12
Переглянути файл
@@ -417,6 +417,18 @@ export async function startRun(
await startCompound(config, options.chain);
return null;
}
// IDEA offers only Debug for Remote JVM Debug and the like: there is nothing to run.
if (typeFor(config.kind)?.debugOnly && !options.launch) {
notifications().notify({
group: "run",
level: "info",
key: "run.debugOnly",
title: `'${config.name}' can only be debugged`,
body: "It attaches the debugger to a process started elsewhere.",
actions: [{ label: "Debug", run: () => void executeAction("run.debugConfig", config.id) }],
});
return null;
}
if (!options.launch && !options.prepared && !(await prepareLaunch(config, options.chain))) return null;
const state = useRun.getState();
const busy = (s: RunSession) => s.status === "running" || s.status === "starting";
+15 -5
Переглянути файл
@@ -243,6 +243,9 @@ export { runConfigTypes };
export const isCompound = (c: Pick<RunConfig, "kind">) => c.kind === COMPOUND;
/** Only Debug applies to `c` (Remote JVM Debug, Go Remote…): it attaches and launches no process. */
export const isDebugOnly = (c: Pick<RunConfig, "kind">, type = typeFor(c.kind)) => !!type?.debugOnly;
/**
* The params the type form edits for `config`: its own, else derived from its command
* line; null when the type can't reproduce that command line (edited as a plain command).
@@ -279,16 +282,22 @@ export function withParams(config: RunConfig, params: Record<string, string>, ty
/** Whether "Emulate terminal in output console" applies to `config`. */
export function terminalApplies(config: RunConfig, type = typeFor(config.kind)): boolean {
return config.kind !== COMPOUND && !config.isTest && type?.terminal !== false;
return config.kind !== COMPOUND && !config.isTest && type?.terminal !== false && !type?.debugOnly;
}
/** Required params that are empty, by key. */
/** Required params that are empty and values the field rejects, by key. */
export function missingParams(config: RunConfig, type = typeFor(config.kind)): Record<string, string> {
const out: Record<string, string> = {};
const params = formParams(config, type);
if (!type || !params) return out;
for (const f of type.fields) {
if (f.required && (f.visible?.(params) ?? true) && !params[f.key]?.trim()) out[f.key] = `${f.label} is required`;
if (f.control === "output" || !(f.visible?.(params) ?? true)) continue;
const value = params[f.key]?.trim() ?? "";
if (f.required && !value) out[f.key] = `${f.label} is required`;
else if (value && f.validate) {
const error = f.validate(value, params);
if (error) out[f.key] = error;
}
}
return out;
}
@@ -327,7 +336,7 @@ export function blankTemplate(type: RunConfigTypeContribution): RunConfig {
env: {},
detected: false,
isTest: false,
debugAdapter: null,
debugAdapter: type.debugAdapter ?? null,
},
{ ...type.defaults },
type,
@@ -363,5 +372,6 @@ export function newConfigOfType(
}
}
const params = { ...type.defaults, ...(template?.params ?? {}) };
return withParams({ ...base, ...copied }, params, type);
const next = withParams({ ...base, ...copied }, params, type);
return type.debugAdapter ? { ...next, debugAdapter: type.debugAdapter } : next;
}
+30 -1
Переглянути файл
@@ -3258,5 +3258,34 @@
"(or": "(或",
"inserts a template from the Includes or Code tab.": "插入“包含”或“代码”标签页中的模板。",
"File templates insert this with {code}, indented like the directive; a directive line whose include is empty is dropped. The file template variables apply.": "文件模板通过 {code} 插入此内容,缩进与该指令一致;所含内容为空的指令行将被删除。文件模板变量同样适用。",
"Inserted where a file template says {code}.": "插入到文件模板中写有 {code} 的位置。"
"Inserted where a file template says {code}.": "插入到文件模板中写有 {code} 的位置。",
"Remote JVM Debug": "远程 JVM 调试",
"Attach the debugger to a JVM started with a JDWP agent, here or on another machine": "将调试器附加到以 JDWP 代理启动的 JVM(本机或其他机器)",
"Debugger mode": "调试器模式",
"Attach to remote JVM": "附加到远程 JVM",
"Listen to remote JVM": "监听远程 JVM",
"Host": "主机",
"Port": "端口",
"Port must be a number from 1 to 65535": "端口必须是 1 到 65535 之间的数字",
"JDK version of the remote JVM": "远程 JVM 的 JDK 版本",
"JDK 9 or later": "JDK 9 或更高版本",
"Command line arguments for remote JVM": "远程 JVM 的命令行参数",
"Add to the remote JVM's command line (or JAVA_TOOL_OPTIONS)": "添加到远程 JVM 的命令行(或 JAVA_TOOL_OPTIONS)",
"Use module classpath": "使用模块类路径",
"Module directory whose sources the debugger shows": "调试器显示其源代码的模块目录",
"Go Remote": "Go 远程",
"Attach to a Delve server (dlv --headless) here or on another machine": "附加到 Delve 服务器(dlv --headless,本机或其他机器)",
"Start the remote side with": "远程端启动命令",
"Delve 1.7.3 or later; dlv exec ./app or dlv attach <pid> take the same flags": "需要 Delve 1.7.3 或更高版本;dlv exec ./app 或 dlv attach <pid> 使用相同参数",
"Local root": "本地根目录",
"Remote root": "远程根目录",
"Where the project's sources are on the remote machine, when the paths differ": "路径不同时,项目源代码在远程机器上的位置",
"Python Remote Debug (debugpy)": "Python 远程调试 (debugpy)",
"Attach to a Python process listening with debugpy here or on another machine": "附加到以 debugpy 监听的 Python 进程(本机或其他机器)",
"pip install debugpy on the remote machine": "在远程机器上执行 pip install debugpy",
"Attach to Node.js": "附加到 Node.js",
"Attach to a Node.js process started with --inspect here or on another machine": "附加到以 --inspect 启动的 Node.js 进程(本机或其他机器)",
"Copied": "已复制",
"'{name}' can only be debugged": "'{name}' 只能调试",
"It attaches the debugger to a process started elsewhere.": "它将调试器附加到在别处启动的进程。"
}
+18 -2
Переглянути файл
@@ -799,8 +799,11 @@ export interface RunConfigTypeField {
/** Key in `RunConfig.params`. */
key: string;
label: string;
/** `checkbox` values are `"true"` or absent. */
control?: "text" | "textarea" | "select" | "checkbox";
/**
* `checkbox` values are `"true"` or absent. `output` is read-only text computed by
* `value` (not stored), with a Copy button (e.g. the JDWP agent for the remote JVM).
*/
control?: "text" | "textarea" | "select" | "checkbox" | "output";
mono?: boolean;
placeholder?: string;
options?: ReadonlyArray<{ value: string; label: string }>;
@@ -808,6 +811,12 @@ export interface RunConfigTypeField {
suggestions?(configs: readonly RunConfig[], params: Readonly<Record<string, string>>): string[];
required?: boolean;
visible?(params: Readonly<Record<string, string>>): boolean;
/** An `output` field's text. */
value?(params: Readonly<Record<string, string>>): string;
/** Why a non-empty value is invalid, or null. */
validate?(value: string, params: Readonly<Record<string, string>>): string | null;
/** Shown under the control. */
hint?: string;
}
export interface RunConfigTypeContribution {
@@ -836,6 +845,13 @@ export interface RunConfigTypeContribution {
terminal?: boolean;
/** Adjusts what is launched right before it starts (e.g. the environment selecting a Docker daemon). */
prepareLaunch?(config: RunConfig): Promise<RunConfig>;
/**
* Only Debug applies: the configuration attaches a debugger and launches no process
* (Remote JVM Debug, Go Remote). `commandLine` returns null.
*/
debugOnly?: boolean;
/** `RunConfig.debugAdapter` of a new configuration. */
debugAdapter?: string;
}
export const useRunConfigTypes = create<{ types: Map<string, RunConfigTypeContribution> }>(() => ({ types: new Map() }));