дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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
Цей коміт міститься в:
1 джерело
642b80cef0
коміт
e150101244
20 files changed
+944
-36
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() ?? "",
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.": "它将调试器附加到在别处启动的进程。"
|
||||
}
|
||||
@@ -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() }));
|
||||
|
||||
Reference in new issue
Заблокувати користувача