diff --git a/crates/dap/src/adapters.rs b/crates/dap/src/adapters.rs index 403c0034fffb79c959d9a30cd4bd9fadbc306f85..6d1b89ef99920ecdd7bffedc643ade878294a6a3 100644 --- a/crates/dap/src/adapters.rs +++ b/crates/dap/src/adapters.rs @@ -46,6 +46,7 @@ pub trait DapDelegate: Send + Sync + 'static { async fn which(&self, command: &OsStr) -> Option; async fn read_text_file(&self, path: &RelPath) -> Result; async fn shell_env(&self) -> collections::HashMap; + fn is_headless(&self) -> bool; } #[derive( diff --git a/crates/dap_adapters/src/javascript.rs b/crates/dap_adapters/src/javascript.rs index 9a19b9594948bca7b5a1c7b77bdc1ec3a6f83dd6..4e3dc30a7929683cc030558bed5034fe8ed69349 100644 --- a/crates/dap_adapters/src/javascript.rs +++ b/crates/dap_adapters/src/javascript.rs @@ -120,6 +120,13 @@ impl JsDebugAdapter { configuration .entry("sourceMapRenames") .or_insert(true.into()); + + // Set up remote browser debugging + if delegate.is_headless() { + configuration + .entry("browserLaunchLocation") + .or_insert("ui".into()); + } } let adapter_path = if let Some(user_installed_path) = user_installed_path { diff --git a/crates/project/src/debugger/breakpoint_store.rs b/crates/project/src/debugger/breakpoint_store.rs index 42663ab9852a5dc2e9850d20dd20940c6723d03c..b7f5360d189489415032be6e5271b3880a421e57 100644 --- a/crates/project/src/debugger/breakpoint_store.rs +++ b/crates/project/src/debugger/breakpoint_store.rs @@ -164,6 +164,7 @@ pub struct BreakpointStore { impl BreakpointStore { pub fn init(client: &AnyProtoClient) { + log::error!("breakpoint store init"); client.add_entity_request_handler(Self::handle_toggle_breakpoint); client.add_entity_message_handler(Self::handle_breakpoints_for_file); } diff --git a/crates/project/src/debugger/dap_store.rs b/crates/project/src/debugger/dap_store.rs index 923de3190cdf8d7f6bf4536a8ca8c67ebb924513..c6fc1ddf73ec7e619bf9c13a60db6fe024fa20f1 100644 --- a/crates/project/src/debugger/dap_store.rs +++ b/crates/project/src/debugger/dap_store.rs @@ -22,9 +22,9 @@ use dap::{ inline_value::VariableLookupKind, messages::Message, }; -use fs::Fs; +use fs::{Fs, RemoveOptions}; use futures::{ - StreamExt, + StreamExt, TryStreamExt as _, channel::mpsc::{self, UnboundedSender}, future::{Shared, join_all}, }; @@ -78,12 +78,15 @@ pub struct LocalDapStore { http_client: Arc, environment: Entity, toolchain_store: Arc, + is_headless: bool, } pub struct RemoteDapStore { remote_client: Entity, upstream_client: AnyProtoClient, upstream_project_id: u64, + node_runtime: NodeRuntime, + http_client: Arc, } pub struct DapStore { @@ -134,17 +137,19 @@ impl DapStore { toolchain_store: Arc, worktree_store: Entity, breakpoint_store: Entity, + is_headless: bool, cx: &mut Context, ) -> Self { let mode = DapStoreMode::Local(LocalDapStore { - fs, + fs: fs.clone(), environment, http_client, node_runtime, toolchain_store, + is_headless, }); - Self::new(mode, breakpoint_store, worktree_store, cx) + Self::new(mode, breakpoint_store, worktree_store, fs, cx) } pub fn new_remote( @@ -152,15 +157,20 @@ impl DapStore { remote_client: Entity, breakpoint_store: Entity, worktree_store: Entity, + node_runtime: NodeRuntime, + http_client: Arc, + fs: Arc, cx: &mut Context, ) -> Self { let mode = DapStoreMode::Remote(RemoteDapStore { upstream_client: remote_client.read(cx).proto_client(), remote_client, upstream_project_id: project_id, + node_runtime, + http_client, }); - Self::new(mode, breakpoint_store, worktree_store, cx) + Self::new(mode, breakpoint_store, worktree_store, fs, cx) } pub fn new_collab( @@ -168,17 +178,55 @@ impl DapStore { _upstream_client: AnyProtoClient, breakpoint_store: Entity, worktree_store: Entity, + fs: Arc, cx: &mut Context, ) -> Self { - Self::new(DapStoreMode::Collab, breakpoint_store, worktree_store, cx) + Self::new( + DapStoreMode::Collab, + breakpoint_store, + worktree_store, + fs, + cx, + ) } fn new( mode: DapStoreMode, breakpoint_store: Entity, worktree_store: Entity, - _cx: &mut Context, + fs: Arc, + cx: &mut Context, ) -> Self { + cx.background_spawn(async move { + let dir = paths::debug_adapters_dir().join("js-debug-companion"); + + let mut children = fs.read_dir(&dir).await?.try_collect::>().await?; + children.sort_by_key(|child| semver::Version::parse(child.file_name()?.to_str()?).ok()); + + if let Some(child) = children.last() + && let Some(name) = child.file_name() + && let Some(name) = name.to_str() + && semver::Version::parse(name).is_ok() + { + children.pop(); + } + + for child in children { + fs.remove_dir( + &child, + RemoveOptions { + recursive: true, + ignore_if_not_exists: true, + }, + ) + .await + .ok(); + } + + anyhow::Ok(()) + }) + .detach(); + Self { mode, next_session_id: 0, @@ -401,6 +449,15 @@ impl DapStore { }); } + let (remote_client, node_runtime, http_client) = match &self.mode { + DapStoreMode::Local(_) => (None, None, None), + DapStoreMode::Remote(remote_dap_store) => ( + Some(remote_dap_store.remote_client.clone()), + Some(remote_dap_store.node_runtime.clone()), + Some(remote_dap_store.http_client.clone()), + ), + DapStoreMode::Collab => (None, None, None), + }; let session = Session::new( self.breakpoint_store.clone(), session_id, @@ -409,6 +466,9 @@ impl DapStore { adapter, task_context, quirks, + remote_client, + node_runtime, + http_client, cx, ); @@ -538,6 +598,7 @@ impl DapStore { local_store.environment.update(cx, |env, cx| { env.get_worktree_environment(worktree.clone(), cx) }), + local_store.is_headless, )) } @@ -870,6 +931,7 @@ pub struct DapAdapterDelegate { http_client: Arc, toolchain_store: Arc, load_shell_env_task: Shared>>>, + is_headless: bool, } impl DapAdapterDelegate { @@ -881,6 +943,7 @@ impl DapAdapterDelegate { http_client: Arc, toolchain_store: Arc, load_shell_env_task: Shared>>>, + is_headless: bool, ) -> Self { Self { fs, @@ -890,6 +953,7 @@ impl DapAdapterDelegate { node_runtime, toolchain_store, load_shell_env_task, + is_headless, } } } @@ -953,4 +1017,8 @@ impl dap::adapters::DapDelegate for DapAdapterDelegate { self.fs.load(&abs_path).await } + + fn is_headless(&self) -> bool { + self.is_headless + } } diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index c0d08ed5c28755074d5dd54a3c08ec856a4e1e05..19c088e6e8767bd56bf19759fbddd9947c4ef0ba 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -31,21 +31,28 @@ use dap::{ RunInTerminalRequestArguments, StackFramePresentationHint, StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest, VariablePresentationHint, WriteMemoryArguments, }; -use futures::SinkExt; use futures::channel::mpsc::UnboundedSender; use futures::channel::{mpsc, oneshot}; +use futures::io::BufReader; +use futures::{AsyncBufReadExt as _, SinkExt, StreamExt, TryStreamExt}; use futures::{FutureExt, future::Shared}; use gpui::{ App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, SharedString, Task, WeakEntity, }; +use http_client::HttpClient; +use node_runtime::NodeRuntime; +use remote::RemoteClient; use rpc::ErrorExt; +use serde::{Deserialize, Serialize}; use serde_json::Value; -use smol::stream::StreamExt; +use smol::net::TcpListener; use std::any::TypeId; use std::collections::BTreeMap; use std::ops::RangeInclusive; +use std::path::PathBuf; +use std::process::Stdio; use std::u64; use std::{ any::Any, @@ -56,6 +63,7 @@ use std::{ }; use task::TaskContext; use text::{PointUtf16, ToPointUtf16}; +use util::command::new_smol_command; use util::{ResultExt, debug_panic, maybe}; use worktree::Worktree; @@ -696,6 +704,10 @@ pub struct Session { task_context: TaskContext, memory: memory::Memory, quirks: SessionQuirks, + remote_client: Option>, + node_runtime: Option, + http_client: Option>, + companion_port: Option, } trait CacheableCommand: Any + Send + Sync { @@ -812,6 +824,9 @@ impl Session { adapter: DebugAdapterName, task_context: TaskContext, quirks: SessionQuirks, + remote_client: Option>, + node_runtime: Option, + http_client: Option>, cx: &mut App, ) -> Entity { cx.new::(|cx| { @@ -867,6 +882,10 @@ impl Session { task_context, memory: memory::Memory::new(), quirks, + remote_client, + node_runtime, + http_client, + companion_port: None, } }) } @@ -1557,7 +1576,21 @@ impl Session { Events::ProgressStart(_) => {} Events::ProgressUpdate(_) => {} Events::Invalidated(_) => {} - Events::Other(_) => {} + Events::Other(event) => { + if event.event == "launchBrowserInCompanion" { + let Some(request) = serde_json::from_value(event.body).ok() else { + log::error!("failed to deserialize launchBrowserInCompanion event"); + return; + }; + self.launch_browser_for_remote_server(request, cx); + } else if event.event == "killCompanionBrowser" { + let Some(request) = serde_json::from_value(event.body).ok() else { + log::error!("failed to deserialize killCompanionBrowser event"); + return; + }; + self.kill_browser(request, cx); + } + } } } @@ -2716,4 +2749,304 @@ impl Session { pub fn quirks(&self) -> SessionQuirks { self.quirks } + + fn launch_browser_for_remote_server( + &mut self, + mut request: LaunchBrowserInCompanionParams, + cx: &mut Context, + ) { + let Some(remote_client) = self.remote_client.clone() else { + log::error!("can't launch browser in companion for non-remote project"); + return; + }; + let Some(http_client) = self.http_client.clone() else { + return; + }; + let Some(node_runtime) = self.node_runtime.clone() else { + return; + }; + + let mut console_output = self.console_output(cx); + let task = cx.spawn(async move |this, cx| { + let (dap_port, _child) = + if remote_client.read_with(cx, |client, _| client.shares_network_interface())? { + (request.server_port, None) + } else { + let port = { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .context("getting port for DAP")?; + listener.local_addr()?.port() + }; + let child = remote_client.update(cx, |client, _| { + let command = client.build_forward_port_command( + port, + "localhost".into(), + request.server_port, + )?; + let child = new_smol_command(command.program) + .args(command.args) + .envs(command.env) + .spawn() + .context("spawning port forwarding process")?; + anyhow::Ok(child) + })??; + (port, Some(child)) + }; + + let mut companion_process = None; + let companion_port = + if let Some(companion_port) = this.read_with(cx, |this, _| this.companion_port)? { + companion_port + } else { + let task = cx.spawn(async move |cx| spawn_companion(node_runtime, cx).await); + match task.await { + Ok((port, child)) => { + companion_process = Some(child); + port + } + Err(e) => { + console_output + .send(format!("Failed to launch browser companion process: {e}")) + .await + .ok(); + return Err(e); + } + } + }; + this.update(cx, |this, cx| { + this.companion_port = Some(companion_port); + let Some(mut child) = companion_process else { + return; + }; + if let Some(stderr) = child.stderr.take() { + let mut console_output = console_output.clone(); + this.background_tasks.push(cx.spawn(async move |_, _| { + let mut stderr = BufReader::new(stderr); + let mut line = String::new(); + while let Ok(n) = stderr.read_line(&mut line).await + && n > 0 + { + console_output + .send(format!("companion stderr: {line}")) + .await + .ok(); + line.clear(); + } + })); + } + this.background_tasks.push(cx.spawn({ + let mut console_output = console_output.clone(); + async move |_, _| match child.status().await { + Ok(status) => { + if status.success() { + console_output + .send("Companion process exited normally".into()) + .await + .ok(); + } else { + console_output + .send(format!( + "Companion process exited abnormally with {status:?}" + )) + .await + .ok(); + } + } + Err(e) => { + console_output + .send(format!("Failed to join companion process: {e}")) + .await + .ok(); + } + } + })) + })?; + + request + .other + .insert("proxyUri".into(), format!("127.0.0.1:{dap_port}").into()); + // TODO pass wslInfo as needed + + let response = http_client + .post_json( + &format!("http://127.0.0.1:{companion_port}/launch-and-attach"), + serde_json::to_string(&request) + .context("serializing request")? + .into(), + ) + .await; + match response { + Ok(response) => { + if !response.status().is_success() { + console_output + .send("Launch request to companion failed".into()) + .await + .ok(); + return Err(anyhow!("launch request failed")); + } + } + Err(e) => { + console_output + .send("Failed to read response from companion".into()) + .await + .ok(); + return Err(e); + } + } + + anyhow::Ok(()) + }); + self.background_tasks.push(cx.spawn(async move |_, _| { + task.await.log_err(); + })); + } + + fn kill_browser(&self, request: KillCompanionBrowserParams, cx: &mut App) { + let Some(companion_port) = self.companion_port else { + log::error!("received killCompanionBrowser but js-debug-companion is not running"); + return; + }; + let Some(http_client) = self.http_client.clone() else { + return; + }; + + cx.spawn(async move |_| { + http_client + .post_json( + &format!("http://127.0.0.1:{companion_port}/kill"), + serde_json::to_string(&request) + .context("serializing request")? + .into(), + ) + .await?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LaunchBrowserInCompanionParams { + server_port: u16, + #[serde(flatten)] + other: HashMap, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct KillCompanionBrowserParams { + launch_id: u64, +} + +async fn spawn_companion( + node_runtime: NodeRuntime, + cx: &mut AsyncApp, +) -> Result<(u16, smol::process::Child)> { + let binary_path = node_runtime + .binary_path() + .await + .context("getting node path")?; + let path = cx + .spawn(async move |cx| get_or_install_companion(node_runtime, cx).await) + .await?; + log::info!("will launch js-debug-companion version {path:?}"); + + let port = { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .context("getting port for companion")?; + listener.local_addr()?.port() + }; + + let dir = paths::data_dir() + .join("js_debug_companion_state") + .to_string_lossy() + .to_string(); + + let child = new_smol_command(binary_path) + .arg(path) + .args([ + format!("--listen=127.0.0.1:{port}"), + format!("--state={dir}"), + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("spawning companion child process")?; + + Ok((port, child)) +} + +async fn get_or_install_companion(node: NodeRuntime, cx: &mut AsyncApp) -> Result { + const PACKAGE_NAME: &str = "@zed-industries/js-debug-companion-cli"; + + async fn install_latest_version(dir: PathBuf, node: NodeRuntime) -> Result { + let temp_dir = tempfile::tempdir().context("creating temporary directory")?; + node.npm_install_packages(temp_dir.path(), &[(PACKAGE_NAME, "latest")]) + .await + .context("installing latest companion package")?; + let version = node + .npm_package_installed_version(temp_dir.path(), PACKAGE_NAME) + .await + .context("getting installed companion version")? + .context("companion was not installed")?; + smol::fs::rename(temp_dir.path(), dir.join(&version)) + .await + .context("moving companion package into place")?; + Ok(dir.join(version)) + } + + let dir = paths::debug_adapters_dir().join("js-debug-companion"); + let (latest_installed_version, latest_version) = cx + .background_spawn({ + let dir = dir.clone(); + let node = node.clone(); + async move { + smol::fs::create_dir_all(&dir) + .await + .context("creating companion installation directory")?; + + let mut children = smol::fs::read_dir(&dir) + .await + .context("reading companion installation directory")? + .try_collect::>() + .await + .context("reading companion installation directory entries")?; + children + .sort_by_key(|child| semver::Version::parse(child.file_name().to_str()?).ok()); + + let latest_installed_version = children.last().and_then(|child| { + let version = child.file_name().into_string().ok()?; + Some((child.path(), version)) + }); + let latest_version = node + .npm_package_latest_version(PACKAGE_NAME) + .await + .log_err(); + anyhow::Ok((latest_installed_version, latest_version)) + } + }) + .await?; + + let path = if let Some((installed_path, installed_version)) = latest_installed_version { + if let Some(latest_version) = latest_version + && latest_version != installed_version + { + cx.background_spawn(install_latest_version(dir.clone(), node.clone())) + .detach(); + } + Ok(installed_path) + } else { + cx.background_spawn(install_latest_version(dir.clone(), node.clone())) + .await + }; + + Ok(path? + .join("node_modules") + .join(PACKAGE_NAME) + .join("out") + .join("cli.js")) } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 2b6c9bfe6c45bfff8b17f05ba115923b41efc6ec..7367bd7f04450a6d26d8f4b87d5de7e1a4c84954 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1084,6 +1084,7 @@ impl Project { toolchain_store.read(cx).as_language_toolchain_store(), worktree_store.clone(), breakpoint_store.clone(), + false, cx, ) }); @@ -1306,6 +1307,9 @@ impl Project { remote.clone(), breakpoint_store.clone(), worktree_store.clone(), + node.clone(), + client.http_client(), + fs.clone(), cx, ) }); @@ -1503,6 +1507,7 @@ impl Project { client.clone().into(), breakpoint_store.clone(), worktree_store.clone(), + fs.clone(), cx, ) })?; diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index fb39fdbb524e91260a83ea6b6eda3ff1a7c13cda..e9843d06d43be1376bddf6e57c1a71952c1e1fa0 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -3426,17 +3426,20 @@ impl ProjectPanel { new_state.max_width_item_index = Some(visited_worktrees_length + index); } } - if let Some((worktree_id, entry_id)) = new_selected_entry { - new_state.selection = Some(SelectedEntry { - worktree_id, - entry_id, - }); - } new_state }) .await; this.update_in(cx, |this, window, cx| { + let current_selection = this.state.selection; this.state = new_state; + if let Some((worktree_id, entry_id)) = new_selected_entry { + this.state.selection = Some(SelectedEntry { + worktree_id, + entry_id, + }); + } else { + this.state.selection = current_selection; + } let elapsed = now.elapsed(); if this.last_reported_update.elapsed() > Duration::from_secs(3600) { telemetry::event!( diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index 8900d04ae56ebce93164d0840efd13c9f70d4d72..e2f51c8e2ba59d02a4d6ac8e4bdbea2e443a4590 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -836,6 +836,18 @@ impl RemoteClient { connection.build_command(program, args, env, working_dir, port_forward) } + pub fn build_forward_port_command( + &self, + local_port: u16, + host: String, + remote_port: u16, + ) -> Result { + let Some(connection) = self.remote_connection() else { + return Err(anyhow!("no ssh connection")); + }; + connection.build_forward_port_command(local_port, host, remote_port) + } + pub fn upload_directory( &self, src_path: PathBuf, @@ -1104,6 +1116,12 @@ pub(crate) trait RemoteConnection: Send + Sync { working_dir: Option, port_forward: Option<(u16, String, u16)>, ) -> Result; + fn build_forward_port_command( + &self, + local_port: u16, + remote: String, + remote_port: u16, + ) -> Result; fn connection_options(&self) -> RemoteConnectionOptions; fn path_style(&self) -> PathStyle; fn shell(&self) -> String; @@ -1533,6 +1551,23 @@ mod fake { }) } + fn build_forward_port_command( + &self, + local_port: u16, + host: String, + remote_port: u16, + ) -> anyhow::Result { + Ok(CommandTemplate { + program: "ssh".into(), + args: vec![ + "-N".into(), + "-L".into(), + format!("{local_port}:{host}:{remote_port}"), + ], + env: Default::default(), + }) + } + fn upload_directory( &self, _src_path: PathBuf, diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs index d18b46b3d011c857023c0f091730ea10014c931a..909ff93169a8a93cea1474348008981a4fdaa36b 100644 --- a/crates/remote/src/transport/ssh.rs +++ b/crates/remote/src/transport/ssh.rs @@ -145,6 +145,23 @@ impl RemoteConnection for SshRemoteConnection { ) } + fn build_forward_port_command( + &self, + local_port: u16, + host: String, + remote_port: u16, + ) -> Result { + Ok(CommandTemplate { + program: "ssh".into(), + args: vec![ + "-N".into(), + "-L".into(), + format!("{local_port}:{host}:{remote_port}"), + ], + env: Default::default(), + }) + } + fn upload_directory( &self, src_path: PathBuf, diff --git a/crates/remote/src/transport/wsl.rs b/crates/remote/src/transport/wsl.rs index 9c0cba8a3acac10087210942e9c5bb800a49906d..2ec2571aae0b91f8d8c7b1c75cd94d45f73531f6 100644 --- a/crates/remote/src/transport/wsl.rs +++ b/crates/remote/src/transport/wsl.rs @@ -433,6 +433,15 @@ impl RemoteConnection for WslRemoteConnection { }) } + fn build_forward_port_command( + &self, + _: u16, + _: String, + _: u16, + ) -> anyhow::Result { + Err(anyhow!("WSL shares a network interface with the host")) + } + fn connection_options(&self) -> RemoteConnectionOptions { RemoteConnectionOptions::Wsl(self.connection_options.clone()) } diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 1d5e72ff9bd2457e6b1d4fbf313669742737379b..5a767275003726da499b2ad8acf805ed41201395 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -123,6 +123,7 @@ impl HeadlessProject { toolchain_store.read(cx).as_language_toolchain_store(), worktree_store.clone(), breakpoint_store.clone(), + true, cx, ); dap_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx); diff --git a/crates/zeta2/src/zeta2.rs b/crates/zeta2/src/zeta2.rs index e4789aa085a27ca11e443c84f487b9f7c2c82538..ab786fa80a6520f2b2ceb3cda177dab4b7120bc2 100644 --- a/crates/zeta2/src/zeta2.rs +++ b/crates/zeta2/src/zeta2.rs @@ -1387,7 +1387,7 @@ mod tests { let (res_tx, res_rx) = oneshot::channel(); req_tx.unbounded_send((req, res_tx)).unwrap(); - serde_json::to_string(&res_rx.await.unwrap()).unwrap() + serde_json::to_string(&res_rx.await?).unwrap() } _ => { panic!("Unexpected path: {}", uri)