diff --git a/.gitignore b/.gitignore index d19c5a102aac8..e9eb4c3ef124a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ DerivedData/ .vscode .wrangler .flatpak-builder +.zed/debug.json # Don't commit any secrets to the repo. .env.secret.toml diff --git a/Cargo.lock b/Cargo.lock index db14b8c7ae431..4f2d0c5c9f269 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3340,6 +3340,56 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96a6ac251f4a2aca6b3f91340350eab87ae57c3f127ffeb585e92bd336717991" +[[package]] +name = "dap" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "dap-types", + "fs", + "futures 0.3.30", + "gpui", + "http_client", + "log", + "node_runtime", + "parking_lot", + "schemars", + "serde", + "serde_json", + "settings", + "smol", + "task", +] + +[[package]] +name = "dap-types" +version = "0.0.1" +source = "git+https://github.com/zed-industries/dap-types?rev=b95818130022bfc72bbcd639bdd0c0358c7549fc#b95818130022bfc72bbcd639bdd0c0358c7549fc" +dependencies = [ + "schemars", + "serde", + "serde_json", +] + +[[package]] +name = "dap_adapters" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "dap", + "fs", + "futures 0.3.30", + "gpui", + "http_client", + "paths", + "serde", + "serde_json", + "smol", + "task", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -3413,6 +3463,30 @@ dependencies = [ "winapi", ] +[[package]] +name = "debugger_ui" +version = "0.1.0" +dependencies = [ + "anyhow", + "dap", + "editor", + "futures 0.3.30", + "fuzzy", + "gpui", + "language", + "menu", + "parking_lot", + "project", + "serde", + "serde_json", + "settings", + "task", + "tasks_ui", + "theme", + "ui", + "workspace", +] + [[package]] name = "deflate64" version = "0.1.9" @@ -3689,6 +3763,7 @@ dependencies = [ "log", "lsp", "markdown", + "menu", "multi_buffer", "ordered-float 2.10.1", "parking_lot", @@ -8429,6 +8504,8 @@ dependencies = [ "client", "clock", "collections", + "dap", + "dap_adapters", "dev_server_projects", "env_logger", "fs", @@ -8966,6 +9043,7 @@ dependencies = [ "anyhow", "auto_update", "client", + "dap", "dev_server_projects", "editor", "futures 0.3.30", @@ -10716,6 +10794,7 @@ dependencies = [ "indoc", "libsqlite3-sys", "parking_lot", + "project", "smol", "sqlformat", "thread_local", @@ -11471,6 +11550,7 @@ dependencies = [ "parking_lot", "schemars", "serde", + "serde_json", "serde_json_lenient", "sha2", "shellexpand 2.1.2", @@ -14586,6 +14666,7 @@ dependencies = [ "command_palette_hooks", "copilot", "db", + "debugger_ui", "dev_server_projects", "diagnostics", "editor", diff --git a/Cargo.toml b/Cargo.toml index 47cd3f915f69a..530605f1f223f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,9 @@ members = [ "crates/command_palette_hooks", "crates/context_servers", "crates/copilot", + "crates/dap", + "crates/dap_adapters", + "crates/debugger_ui", "crates/db", "crates/dev_server_projects", "crates/diagnostics", @@ -203,7 +206,10 @@ command_palette = { path = "crates/command_palette" } command_palette_hooks = { path = "crates/command_palette_hooks" } context_servers = { path = "crates/context_servers" } copilot = { path = "crates/copilot" } +dap = { path = "crates/dap" } +dap_adapters = { path = "crates/dap_adapters" } db = { path = "crates/db" } +debugger_ui = { path = "crates/debugger_ui" } dev_server_projects = { path = "crates/dev_server_projects" } diagnostics = { path = "crates/diagnostics" } editor = { path = "crates/editor" } diff --git a/assets/icons/debug.svg b/assets/icons/debug.svg new file mode 100644 index 0000000000000..8cea0c460402f --- /dev/null +++ b/assets/icons/debug.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_breakpoint.svg b/assets/icons/debug_breakpoint.svg new file mode 100644 index 0000000000000..f6a7b35658eef --- /dev/null +++ b/assets/icons/debug_breakpoint.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_continue.svg b/assets/icons/debug_continue.svg new file mode 100644 index 0000000000000..e2a99c38d032f --- /dev/null +++ b/assets/icons/debug_continue.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_disconnect.svg b/assets/icons/debug_disconnect.svg new file mode 100644 index 0000000000000..0eb253715288f --- /dev/null +++ b/assets/icons/debug_disconnect.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_log_breakpoint.svg b/assets/icons/debug_log_breakpoint.svg new file mode 100644 index 0000000000000..a878ce3e04189 --- /dev/null +++ b/assets/icons/debug_log_breakpoint.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_pause.svg b/assets/icons/debug_pause.svg new file mode 100644 index 0000000000000..bea531bc5a755 --- /dev/null +++ b/assets/icons/debug_pause.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_restart.svg b/assets/icons/debug_restart.svg new file mode 100644 index 0000000000000..4eff13b94b698 --- /dev/null +++ b/assets/icons/debug_restart.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_step_into.svg b/assets/icons/debug_step_into.svg new file mode 100644 index 0000000000000..69e5cff3f176c --- /dev/null +++ b/assets/icons/debug_step_into.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/debug_step_out.svg b/assets/icons/debug_step_out.svg new file mode 100644 index 0000000000000..680e13e65e041 --- /dev/null +++ b/assets/icons/debug_step_out.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/debug_step_over.svg b/assets/icons/debug_step_over.svg new file mode 100644 index 0000000000000..005b901da3c49 --- /dev/null +++ b/assets/icons/debug_step_over.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/debug_stop.svg b/assets/icons/debug_stop.svg new file mode 100644 index 0000000000000..fef651c5864a1 --- /dev/null +++ b/assets/icons/debug_stop.svg @@ -0,0 +1 @@ + diff --git a/assets/settings/default.json b/assets/settings/default.json index d299f05766b5c..f71c15f3b4887 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1109,6 +1109,13 @@ // } // ] "ssh_connections": null, + + "debugger": { + "stepping_granularity": "line", + "save_breakpoints": true, + "button": true + }, + // Configures the Context Server Protocol binaries // // Examples: diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 943cbbb29ff01..e3ad58117f364 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -350,7 +350,7 @@ impl AssistantPanel { workspace.project().clone(), Default::default(), None, - NewContext.boxed_clone(), + Some(NewContext.boxed_clone()), cx, ); diff --git a/crates/dap/Cargo.toml b/crates/dap/Cargo.toml new file mode 100644 index 0000000000000..33971d417bfd1 --- /dev/null +++ b/crates/dap/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "dap" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +async-trait.workspace = true +dap-types = { git = "https://github.com/zed-industries/dap-types", rev = "b95818130022bfc72bbcd639bdd0c0358c7549fc" } +fs.workspace = true +futures.workspace = true +gpui.workspace = true +http_client.workspace = true +log.workspace = true +node_runtime.workspace = true +parking_lot.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +smol.workspace = true +task.workspace = true diff --git a/crates/dap/LICENSE-GPL b/crates/dap/LICENSE-GPL new file mode 120000 index 0000000000000..e0f9dbd5d63fe --- /dev/null +++ b/crates/dap/LICENSE-GPL @@ -0,0 +1 @@ +LICENSE-GPL \ No newline at end of file diff --git a/crates/dap/docs/breakpoints.md b/crates/dap/docs/breakpoints.md new file mode 100644 index 0000000000000..8b819b089bf8c --- /dev/null +++ b/crates/dap/docs/breakpoints.md @@ -0,0 +1,9 @@ +# Overview + +The active `Project` is responsible for maintain opened and closed breakpoints +as well as serializing breakpoints to save. At a high level project serializes +the positions of breakpoints that don't belong to any active buffers and handles +converting breakpoints from serializing to active whenever a buffer is opened/closed. + +`Project` also handles sending all relevant breakpoint information to debug adapter's +during debugging or when starting a debugger. diff --git a/crates/dap/src/adapters.rs b/crates/dap/src/adapters.rs new file mode 100644 index 0000000000000..236297eba0151 --- /dev/null +++ b/crates/dap/src/adapters.rs @@ -0,0 +1,204 @@ +use crate::client::TransportParams; +use ::fs::Fs; +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use futures::AsyncReadExt; +use gpui::AsyncAppContext; +use http_client::HttpClient; +use node_runtime::NodeRuntime; +use serde_json::Value; +use smol::{ + self, + io::BufReader, + net::{TcpListener, TcpStream}, + process, +}; +use std::{ + collections::HashMap, + ffi::OsString, + fmt::Debug, + net::{Ipv4Addr, SocketAddrV4}, + path::Path, + process::Stdio, + sync::Arc, + time::Duration, +}; + +use task::{DebugAdapterConfig, TCPHost}; + +/// Get an open port to use with the tcp client when not supplied by debug config +async fn get_open_port(host: Ipv4Addr) -> Option { + Some( + TcpListener::bind(SocketAddrV4::new(host, 0)) + .await + .ok()? + .local_addr() + .ok()? + .port(), + ) +} + +pub trait DapDelegate { + fn http_client(&self) -> Option>; + fn node_runtime(&self) -> Option; + fn fs(&self) -> Arc; +} + +/// TCP clients don't have an error communication stream with an adapter +/// # Parameters +/// - `host`: The ip/port that that the client will connect too +/// - `adapter_binary`: The debug adapter binary to start +/// - `cx`: The context that the new client belongs too +pub async fn create_tcp_client( + host: TCPHost, + adapter_binary: &DebugAdapterBinary, + cx: &mut AsyncAppContext, +) -> Result { + let host_address = host.host.unwrap_or_else(|| Ipv4Addr::new(127, 0, 0, 1)); + + let mut port = host.port; + if port.is_none() { + port = get_open_port(host_address).await; + } + + let mut command = process::Command::new(&adapter_binary.command); + + if let Some(args) = &adapter_binary.arguments { + command.args(args); + } + + if let Some(envs) = &adapter_binary.envs { + command.envs(envs); + } + + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true); + + let process = command + .spawn() + .with_context(|| "failed to start debug adapter.")?; + + if let Some(delay) = host.delay { + // some debug adapters need some time to start the TCP server + // so we have to wait few milliseconds before we can connect to it + cx.background_executor() + .timer(Duration::from_millis(delay)) + .await; + } + + let address = SocketAddrV4::new( + host_address, + port.ok_or(anyhow!("Port is required to connect to TCP server"))?, + ); + + let (rx, tx) = TcpStream::connect(address).await?.split(); + log::info!("Debug adapter has connected to tcp server"); + + Ok(TransportParams::new( + Box::new(BufReader::new(rx)), + Box::new(tx), + None, + Some(process), + )) +} + +/// Creates a debug client that connects to an adapter through std input/output +/// +/// # Parameters +/// - `adapter_binary`: The debug adapter binary to start +pub fn create_stdio_client(adapter_binary: &DebugAdapterBinary) -> Result { + let mut command = process::Command::new(&adapter_binary.command); + + if let Some(args) = &adapter_binary.arguments { + command.args(args); + } + + if let Some(envs) = &adapter_binary.envs { + command.envs(envs); + } + + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let mut process = command + .spawn() + .with_context(|| "failed to spawn command.")?; + + let stdin = process + .stdin + .take() + .ok_or_else(|| anyhow!("Failed to open stdin"))?; + let stdout = process + .stdout + .take() + .ok_or_else(|| anyhow!("Failed to open stdout"))?; + let stderr = process + .stderr + .take() + .ok_or_else(|| anyhow!("Failed to open stderr"))?; + + log::info!("Debug adapter has connected to stdio adapter"); + + Ok(TransportParams::new( + Box::new(BufReader::new(stdout)), + Box::new(stdin), + Some(Box::new(BufReader::new(stderr))), + Some(process), + )) +} + +pub struct DebugAdapterName(pub Arc); + +impl AsRef for DebugAdapterName { + fn as_ref(&self) -> &Path { + Path::new(&*self.0) + } +} + +impl std::fmt::Display for DebugAdapterName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +#[derive(Debug, Clone)] +pub struct DebugAdapterBinary { + pub command: String, + pub arguments: Option>, + pub envs: Option>, +} + +#[async_trait(?Send)] +pub trait DebugAdapter: 'static + Send + Sync { + fn id(&self) -> String { + "".to_string() + } + + fn name(&self) -> DebugAdapterName; + + async fn connect( + &self, + adapter_binary: &DebugAdapterBinary, + cx: &mut AsyncAppContext, + ) -> anyhow::Result; + + /// Installs the binary for the debug adapter. + /// This method is called when the adapter binary is not found or needs to be updated. + /// It should download and install the necessary files for the debug adapter to function. + async fn install_binary(&self, delegate: &dyn DapDelegate) -> Result<()>; + + async fn fetch_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + ) -> Result; + + /// Should return base configuration to make the debug adapter work + fn request_args(&self, config: &DebugAdapterConfig) -> Value; +} diff --git a/crates/dap/src/client.rs b/crates/dap/src/client.rs new file mode 100644 index 0000000000000..ddf987c02dcfb --- /dev/null +++ b/crates/dap/src/client.rs @@ -0,0 +1,233 @@ +use crate::transport::Transport; +use anyhow::{anyhow, Result}; +use dap_types::{ + messages::{Message, Response}, + requests::Request, +}; +use futures::{AsyncBufRead, AsyncWrite}; +use gpui::{AppContext, AsyncAppContext}; +use parking_lot::Mutex; +use serde_json::Value; +use smol::{ + channel::{bounded, Receiver, Sender}, + process::Child, +}; +use std::{ + hash::Hash, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, +}; +use task::{DebugAdapterConfig, DebugRequestType}; + +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ThreadStatus { + #[default] + Running, + Stopped, + Exited, + Ended, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(transparent)] +pub struct DebugAdapterClientId(pub usize); + +pub struct DebugAdapterClient { + id: DebugAdapterClientId, + adapter_id: String, + request_args: Value, + transport: Arc, + _process: Arc>>, + sequence_count: AtomicU64, + config: DebugAdapterConfig, +} + +pub struct TransportParams { + rx: Box, + tx: Box, + err: Option>, + process: Option, +} + +impl TransportParams { + pub fn new( + rx: Box, + tx: Box, + err: Option>, + process: Option, + ) -> Self { + TransportParams { + rx, + tx, + err, + process, + } + } +} + +impl DebugAdapterClient { + pub async fn new( + id: DebugAdapterClientId, + adapter_id: String, + request_args: Value, + config: DebugAdapterConfig, + transport_params: TransportParams, + event_handler: F, + cx: &mut AsyncAppContext, + ) -> Result> + where + F: FnMut(Message, &mut AppContext) + 'static + Send + Sync + Clone, + { + let transport = Self::handle_transport( + transport_params.rx, + transport_params.tx, + transport_params.err, + event_handler, + cx, + ); + Ok(Arc::new(Self { + id, + adapter_id, + request_args, + config, + transport, + sequence_count: AtomicU64::new(1), + _process: Arc::new(Mutex::new(transport_params.process)), + })) + } + + pub fn handle_transport( + rx: Box, + tx: Box, + err: Option>, + event_handler: F, + cx: &mut AsyncAppContext, + ) -> Arc + where + F: FnMut(Message, &mut AppContext) + 'static + Send + Sync + Clone, + { + let transport = Transport::start(rx, tx, err, cx); + + let server_rx = transport.server_rx.clone(); + let server_tr = transport.server_tx.clone(); + cx.spawn(|mut cx| async move { + Self::handle_recv(server_rx, server_tr, event_handler, &mut cx).await + }) + .detach(); + + transport + } + + async fn handle_recv( + server_rx: Receiver, + client_tx: Sender, + mut event_handler: F, + cx: &mut AsyncAppContext, + ) -> Result<()> + where + F: FnMut(Message, &mut AppContext) + 'static + Send + Sync + Clone, + { + while let Ok(payload) = server_rx.recv().await { + match payload { + Message::Event(ev) => cx.update(|cx| event_handler(Message::Event(ev), cx))?, + Message::Response(_) => unreachable!(), + Message::Request(req) => { + cx.update(|cx| event_handler(Message::Request(req), cx))? + } + }; + } + + drop(client_tx); + + anyhow::Ok(()) + } + + /// Send a request to an adapter and get a response back + /// Note: This function will block until a response is sent back from the adapter + pub async fn request(&self, arguments: R::Arguments) -> Result { + let serialized_arguments = serde_json::to_value(arguments)?; + + let (callback_tx, callback_rx) = bounded::>(1); + + let sequence_id = self.next_sequence_id(); + + let request = crate::messages::Request { + seq: sequence_id, + command: R::COMMAND.to_string(), + arguments: Some(serialized_arguments), + }; + + { + self.transport + .current_requests + .lock() + .await + .insert(sequence_id, callback_tx); + } + + self.transport + .server_tx + .send(Message::Request(request)) + .await?; + + let response = callback_rx.recv().await??; + + match response.success { + true => Ok(serde_json::from_value(response.body.unwrap_or_default())?), + false => Err(anyhow!("Request failed")), + } + } + + pub fn id(&self) -> DebugAdapterClientId { + self.id + } + + pub fn config(&self) -> DebugAdapterConfig { + self.config.clone() + } + + pub fn adapter_id(&self) -> String { + self.adapter_id.clone() + } + + pub fn request_args(&self) -> Value { + self.request_args.clone() + } + + pub fn request_type(&self) -> DebugRequestType { + self.config.request.clone() + } + + /// Get the next sequence id to be used in a request + pub fn next_sequence_id(&self) -> u64 { + self.sequence_count.fetch_add(1, Ordering::Relaxed) + } + + pub async fn shutdown(&self) -> Result<()> { + self.transport.server_tx.close(); + self.transport.server_rx.close(); + + let mut adapter = self._process.lock().take(); + + async move { + let mut current_requests = self.transport.current_requests.lock().await; + let mut pending_requests = self.transport.pending_requests.lock().await; + + current_requests.clear(); + pending_requests.clear(); + + if let Some(mut adapter) = adapter.take() { + adapter.kill()?; + } + + drop(current_requests); + drop(pending_requests); + drop(adapter); + + anyhow::Ok(()) + } + .await + } +} diff --git a/crates/dap/src/debugger_settings.rs b/crates/dap/src/debugger_settings.rs new file mode 100644 index 0000000000000..0d5a744a84936 --- /dev/null +++ b/crates/dap/src/debugger_settings.rs @@ -0,0 +1,47 @@ +use dap_types::SteppingGranularity; +use gpui::{AppContext, Global}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::{Settings, SettingsSources}; + +#[derive(Serialize, Deserialize, JsonSchema, Clone, Copy)] +#[serde(default)] +pub struct DebuggerSettings { + /// Determines the stepping granularity. + /// + /// Default: line + pub stepping_granularity: SteppingGranularity, + /// Whether the breakpoints should be reused across Zed sessions. + /// + /// Default: true + pub save_breakpoints: bool, + /// Whether to show the debug button in the status bar. + /// + /// Default: true + pub button: bool, +} + +impl Default for DebuggerSettings { + fn default() -> Self { + Self { + button: true, + save_breakpoints: true, + stepping_granularity: SteppingGranularity::Line, + } + } +} + +impl Settings for DebuggerSettings { + const KEY: Option<&'static str> = Some("debugger"); + + type FileContent = Self; + + fn load( + sources: SettingsSources, + _: &mut AppContext, + ) -> anyhow::Result { + sources.json_merge() + } +} + +impl Global for DebuggerSettings {} diff --git a/crates/dap/src/lib.rs b/crates/dap/src/lib.rs new file mode 100644 index 0000000000000..df62861da7bf1 --- /dev/null +++ b/crates/dap/src/lib.rs @@ -0,0 +1,5 @@ +pub mod adapters; +pub mod client; +pub mod transport; +pub use dap_types::*; +pub mod debugger_settings; diff --git a/crates/dap/src/transport.rs b/crates/dap/src/transport.rs new file mode 100644 index 0000000000000..5a7dbbd4ff313 --- /dev/null +++ b/crates/dap/src/transport.rs @@ -0,0 +1,226 @@ +use anyhow::{anyhow, Context, Result}; +use dap_types::{ + messages::{Message, Response}, + ErrorResponse, +}; +use futures::{AsyncBufRead, AsyncWrite}; +use gpui::AsyncAppContext; +use smol::{ + channel::{unbounded, Receiver, Sender}, + io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt}, + lock::Mutex, +}; +use std::{collections::HashMap, sync::Arc}; + +#[derive(Debug)] +pub struct Transport { + pub server_tx: Sender, + pub server_rx: Receiver, + pub current_requests: Arc>>>>, + pub pending_requests: Arc>>>>, +} + +impl Transport { + pub fn start( + server_stdout: Box, + server_stdin: Box, + server_stderr: Option>, + cx: &mut AsyncAppContext, + ) -> Arc { + let (client_tx, server_rx) = unbounded::(); + let (server_tx, client_rx) = unbounded::(); + + let current_requests = Arc::new(Mutex::new(HashMap::default())); + let pending_requests = Arc::new(Mutex::new(HashMap::default())); + + cx.background_executor() + .spawn(Self::receive( + pending_requests.clone(), + server_stdout, + client_tx, + )) + .detach(); + + if let Some(stderr) = server_stderr { + cx.background_executor().spawn(Self::err(stderr)).detach(); + } + + cx.background_executor() + .spawn(Self::send( + current_requests.clone(), + pending_requests.clone(), + server_stdin, + client_rx, + )) + .detach(); + + Arc::new(Self { + server_rx, + server_tx, + current_requests, + pending_requests, + }) + } + + async fn recv_server_message( + reader: &mut Box, + buffer: &mut String, + ) -> Result { + let mut content_length = None; + loop { + buffer.truncate(0); + + if reader + .read_line(buffer) + .await + .with_context(|| "reading a message from server")? + == 0 + { + return Err(anyhow!("debugger reader stream closed")); + }; + + if buffer == "\r\n" { + break; + } + + let parts = buffer.trim().split_once(": "); + + match parts { + Some(("Content-Length", value)) => { + content_length = Some(value.parse().context("invalid content length")?); + } + _ => {} + } + } + + let content_length = content_length.context("missing content length")?; + + let mut content = vec![0; content_length]; + reader + .read_exact(&mut content) + .await + .with_context(|| "reading after a loop")?; + + let msg = std::str::from_utf8(&content).context("invalid utf8 from server")?; + Ok(serde_json::from_str::(msg)?) + } + + async fn recv_server_error( + err: &mut (impl AsyncBufRead + Unpin + Send), + buffer: &mut String, + ) -> Result<()> { + buffer.truncate(0); + if err.read_line(buffer).await? == 0 { + return Err(anyhow!("debugger error stream closed")); + }; + + Ok(()) + } + + async fn send_payload_to_server( + current_requests: &Mutex>>>, + pending_requests: &Mutex>>>, + server_stdin: &mut Box, + mut payload: Message, + ) -> Result<()> { + if let Message::Request(request) = &mut payload { + if let Some(sender) = current_requests.lock().await.remove(&request.seq) { + pending_requests.lock().await.insert(request.seq, sender); + } + } + Self::send_string_to_server(server_stdin, serde_json::to_string(&payload)?).await + } + + async fn send_string_to_server( + server_stdin: &mut Box, + request: String, + ) -> Result<()> { + server_stdin + .write_all(format!("Content-Length: {}\r\n\r\n{}", request.len(), request).as_bytes()) + .await?; + + server_stdin.flush().await?; + Ok(()) + } + + fn process_response(response: Response) -> Result { + if response.success { + Ok(response) + } else { + if let Some(body) = response.body { + if let Ok(error) = serde_json::from_value::(body) { + if let Some(message) = error.error { + return Err(anyhow!(message.format)); + }; + }; + } + + Err(anyhow!("Received error response from adapter")) + } + } + + async fn process_server_message( + pending_requests: &Arc>>>>, + client_tx: &Sender, + message: Message, + ) -> Result<()> { + match message { + Message::Response(res) => { + if let Some(tx) = pending_requests.lock().await.remove(&res.request_seq) { + tx.send(Self::process_response(res)).await?; + } else { + client_tx.send(Message::Response(res)).await?; + }; + } + Message::Request(_) => { + client_tx.send(message).await?; + } + Message::Event(_) => { + client_tx.send(message).await?; + } + } + Ok(()) + } + + async fn receive( + pending_requests: Arc>>>>, + mut server_stdout: Box, + client_tx: Sender, + ) -> Result<()> { + let mut recv_buffer = String::new(); + + while let Ok(msg) = Self::recv_server_message(&mut server_stdout, &mut recv_buffer).await { + Self::process_server_message(&pending_requests, &client_tx, msg) + .await + .context("Process server message failed in transport::receive")?; + } + + Ok(()) + } + + async fn send( + current_requests: Arc>>>>, + pending_requests: Arc>>>>, + mut server_stdin: Box, + client_rx: Receiver, + ) -> Result<()> { + while let Ok(payload) = client_rx.recv().await { + Self::send_payload_to_server( + ¤t_requests, + &pending_requests, + &mut server_stdin, + payload, + ) + .await?; + } + + Ok(()) + } + + async fn err(mut server_stderr: Box) -> Result<()> { + let mut recv_buffer = String::new(); + loop { + Self::recv_server_error(&mut server_stderr, &mut recv_buffer).await?; + } + } +} diff --git a/crates/dap_adapters/Cargo.toml b/crates/dap_adapters/Cargo.toml new file mode 100644 index 0000000000000..d6515c69b8a41 --- /dev/null +++ b/crates/dap_adapters/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "dap_adapters" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + + +[lints] +workspace = true + +[lib] +path = "src/dap_adapters.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +async-trait.workspace = true +dap.workspace = true +fs.workspace = true +futures.workspace = true +gpui.workspace = true +http_client.workspace = true +task.workspace = true +smol.workspace = true +serde.workspace = true +serde_json.workspace = true +paths.workspace = true diff --git a/crates/dap_adapters/LICENSE-GPL b/crates/dap_adapters/LICENSE-GPL new file mode 120000 index 0000000000000..e0f9dbd5d63fe --- /dev/null +++ b/crates/dap_adapters/LICENSE-GPL @@ -0,0 +1 @@ +LICENSE-GPL \ No newline at end of file diff --git a/crates/dap_adapters/src/custom.rs b/crates/dap_adapters/src/custom.rs new file mode 100644 index 0000000000000..a33e1802839b5 --- /dev/null +++ b/crates/dap_adapters/src/custom.rs @@ -0,0 +1,63 @@ +use std::ffi::OsString; + +use serde_json::Value; +use task::DebugAdapterConfig; + +use crate::*; + +#[derive(Debug, Eq, PartialEq, Clone)] +pub(crate) struct CustomDebugAdapter { + custom_args: CustomArgs, +} + +impl CustomDebugAdapter { + const ADAPTER_NAME: &'static str = "custom_dap"; + + pub(crate) fn new(custom_args: CustomArgs) -> Self { + CustomDebugAdapter { custom_args } + } +} + +#[async_trait(?Send)] +impl DebugAdapter for CustomDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn connect( + &self, + adapter_binary: &DebugAdapterBinary, + cx: &mut AsyncAppContext, + ) -> Result { + match &self.custom_args.connection { + DebugConnectionType::STDIO => create_stdio_client(adapter_binary), + DebugConnectionType::TCP(tcp_host) => { + create_tcp_client(tcp_host.clone(), adapter_binary, cx).await + } + } + } + + async fn install_binary(&self, _: &dyn DapDelegate) -> Result<()> { + Ok(()) + } + + async fn fetch_binary( + &self, + _: &dyn DapDelegate, + _: &DebugAdapterConfig, + ) -> Result { + Ok(DebugAdapterBinary { + command: self.custom_args.command.clone(), + arguments: self + .custom_args + .args + .clone() + .map(|args| args.iter().map(OsString::from).collect()), + envs: self.custom_args.envs.clone(), + }) + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + json!({"program": config.program}) + } +} diff --git a/crates/dap_adapters/src/dap_adapters.rs b/crates/dap_adapters/src/dap_adapters.rs new file mode 100644 index 0000000000000..fac4f3c77c737 --- /dev/null +++ b/crates/dap_adapters/src/dap_adapters.rs @@ -0,0 +1,42 @@ +mod custom; +mod javascript; +mod lldb; +mod php; +mod python; + +use custom::CustomDebugAdapter; +use javascript::JsDebugAdapter; +use lldb::LldbDebugAdapter; +use php::PhpDebugAdapter; +use python::PythonDebugAdapter; + +use anyhow::{anyhow, bail, Context, Result}; +use async_trait::async_trait; +use dap::{ + adapters::{ + create_stdio_client, create_tcp_client, DapDelegate, DebugAdapter, DebugAdapterBinary, + DebugAdapterName, + }, + client::TransportParams, +}; +use gpui::AsyncAppContext; +use http_client::github::latest_github_release; +use serde_json::{json, Value}; +use smol::{ + fs::{self, File}, + process, +}; +use std::{fmt::Debug, process::Stdio}; +use task::{CustomArgs, DebugAdapterConfig, DebugAdapterKind, DebugConnectionType, TCPHost}; + +pub fn build_adapter(adapter_config: &DebugAdapterConfig) -> Result> { + match &adapter_config.kind { + DebugAdapterKind::Custom(start_args) => { + Ok(Box::new(CustomDebugAdapter::new(start_args.clone()))) + } + DebugAdapterKind::Python => Ok(Box::new(PythonDebugAdapter::new())), + DebugAdapterKind::PHP => Ok(Box::new(PhpDebugAdapter::new())), + DebugAdapterKind::Javascript => Ok(Box::new(JsDebugAdapter::new())), + DebugAdapterKind::Lldb => Ok(Box::new(LldbDebugAdapter::new())), + } +} diff --git a/crates/dap_adapters/src/javascript.rs b/crates/dap_adapters/src/javascript.rs new file mode 100644 index 0000000000000..288c2c0508024 --- /dev/null +++ b/crates/dap_adapters/src/javascript.rs @@ -0,0 +1,169 @@ +use crate::*; + +#[derive(Debug, Eq, PartialEq, Clone)] +pub(crate) struct JsDebugAdapter {} + +impl JsDebugAdapter { + const ADAPTER_NAME: &'static str = "vscode-js-debug"; + const ADAPTER_PATH: &'static str = "src/dapDebugServer.js"; + + pub(crate) fn new() -> Self { + JsDebugAdapter {} + } +} + +#[async_trait(?Send)] +impl DebugAdapter for JsDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn connect( + &self, + adapter_binary: &DebugAdapterBinary, + cx: &mut AsyncAppContext, + ) -> Result { + let host = TCPHost { + port: Some(8133), + host: None, + delay: Some(1000), + }; + + create_tcp_client(host, adapter_binary, cx).await + } + + async fn fetch_binary( + &self, + delegate: &dyn DapDelegate, + _: &DebugAdapterConfig, + ) -> Result { + let node_runtime = delegate + .node_runtime() + .ok_or(anyhow!("Couldn't get npm runtime"))?; + + let adapter_path = paths::debug_adapters_dir().join(self.name()); + + Ok(DebugAdapterBinary { + command: node_runtime + .binary_path() + .await? + .to_string_lossy() + .into_owned(), + arguments: Some(vec![ + adapter_path.join(Self::ADAPTER_PATH).into(), + "8133".into(), + ]), + envs: None, + }) + } + + async fn install_binary(&self, delegate: &dyn DapDelegate) -> Result<()> { + let adapter_path = paths::debug_adapters_dir().join(self.name()); + let fs = delegate.fs(); + + if fs.is_dir(adapter_path.as_path()).await { + return Ok(()); + } + + if let Some(http_client) = delegate.http_client() { + if !adapter_path.exists() { + fs.create_dir(&adapter_path.as_path()).await?; + } + + let release = latest_github_release( + "microsoft/vscode-js-debug", + false, + false, + http_client.clone(), + ) + .await?; + + let asset_name = format!("{}-{}", self.name(), release.tag_name); + let zip_path = adapter_path.join(asset_name); + + if fs::metadata(&zip_path).await.is_err() { + let mut response = http_client + .get(&release.zipball_url, Default::default(), true) + .await + .context("Error downloading release")?; + + let mut file = File::create(&zip_path).await?; + futures::io::copy(response.body_mut(), &mut file).await?; + + let _unzip_status = process::Command::new("unzip") + .current_dir(&adapter_path) + .arg(&zip_path) + .output() + .await? + .status; + + let mut ls = process::Command::new("ls") + .current_dir(&adapter_path) + .stdout(Stdio::piped()) + .spawn()?; + + let std = ls + .stdout + .take() + .ok_or(anyhow!("Failed to list directories"))? + .into_stdio() + .await?; + + let file_name = String::from_utf8( + process::Command::new("grep") + .arg("microsoft-vscode-js-debug") + .stdin(std) + .output() + .await? + .stdout, + )?; + + let file_name = file_name.trim_end(); + + process::Command::new("sh") + .current_dir(&adapter_path) + .arg("-c") + .arg(format!("mv {file_name}/* .")) + .output() + .await?; + + process::Command::new("rm") + .current_dir(&adapter_path) + .arg("-rf") + .arg(file_name) + .arg(zip_path) + .output() + .await?; + + let _ = delegate + .node_runtime() + .ok_or(anyhow!("Couldn't get npm runtime"))? + .run_npm_subcommand(&adapter_path, "install", &[]) + .await + .ok(); + + let _ = delegate + .node_runtime() + .ok_or(anyhow!("Couldn't get npm runtime"))? + .run_npm_subcommand(&adapter_path, "run", &["compile"]) + .await + .ok(); + + return Ok(()); + } + } + + bail!("Install or fetch not implemented for Javascript debug adapter (yet)"); + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + json!({ + "program": config.program, + "type": "pwa-node", + "skipFiles": [ + "/**", + "**/node_modules/**" + ] + }) + } +} diff --git a/crates/dap_adapters/src/lldb.rs b/crates/dap_adapters/src/lldb.rs new file mode 100644 index 0000000000000..56cda6750a275 --- /dev/null +++ b/crates/dap_adapters/src/lldb.rs @@ -0,0 +1,47 @@ +use anyhow::Result; +use async_trait::async_trait; +use task::DebugAdapterConfig; + +use crate::*; + +#[derive(Debug, Eq, PartialEq, Clone)] +pub(crate) struct LldbDebugAdapter {} + +impl LldbDebugAdapter { + const ADAPTER_NAME: &'static str = "lldb"; + + pub(crate) fn new() -> Self { + LldbDebugAdapter {} + } +} + +#[async_trait(?Send)] +impl DebugAdapter for LldbDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn connect( + &self, + adapter_binary: &DebugAdapterBinary, + _: &mut AsyncAppContext, + ) -> Result { + create_stdio_client(adapter_binary) + } + + async fn install_binary(&self, _: &dyn DapDelegate) -> Result<()> { + bail!("Install or fetch not implemented for lldb debug adapter (yet)") + } + + async fn fetch_binary( + &self, + _: &dyn DapDelegate, + _: &DebugAdapterConfig, + ) -> Result { + bail!("Install or fetch not implemented for lldb debug adapter (yet)") + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + json!({"program": config.program}) + } +} diff --git a/crates/dap_adapters/src/php.rs b/crates/dap_adapters/src/php.rs new file mode 100644 index 0000000000000..62ecb16506bb8 --- /dev/null +++ b/crates/dap_adapters/src/php.rs @@ -0,0 +1,158 @@ +use crate::*; + +#[derive(Debug, Eq, PartialEq, Clone)] +pub(crate) struct PhpDebugAdapter {} + +impl PhpDebugAdapter { + const ADAPTER_NAME: &'static str = "vscode-php-debug"; + const ADAPTER_PATH: &'static str = "out/phpDebug.js"; + + pub(crate) fn new() -> Self { + PhpDebugAdapter {} + } +} + +#[async_trait(?Send)] +impl DebugAdapter for PhpDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn connect( + &self, + adapter_binary: &DebugAdapterBinary, + cx: &mut AsyncAppContext, + ) -> Result { + let host = TCPHost { + port: Some(8132), + host: None, + delay: Some(1000), + }; + + create_tcp_client(host, adapter_binary, cx).await + } + + async fn fetch_binary( + &self, + delegate: &dyn DapDelegate, + _: &DebugAdapterConfig, + ) -> Result { + let node_runtime = delegate + .node_runtime() + .ok_or(anyhow!("Couldn't get npm runtime"))?; + + let adapter_path = paths::debug_adapters_dir().join(self.name()); + + Ok(DebugAdapterBinary { + command: node_runtime + .binary_path() + .await? + .to_string_lossy() + .into_owned(), + arguments: Some(vec![ + adapter_path.join(Self::ADAPTER_PATH).into(), + "--server=8132".into(), + ]), + envs: None, + }) + } + + async fn install_binary(&self, delegate: &dyn DapDelegate) -> Result<()> { + let adapter_path = paths::debug_adapters_dir().join(self.name()); + let fs = delegate.fs(); + + if fs.is_dir(adapter_path.as_path()).await { + return Ok(()); + } + + if let Some(http_client) = delegate.http_client() { + if !adapter_path.exists() { + fs.create_dir(&adapter_path.as_path()).await?; + } + + let release = + latest_github_release("xdebug/vscode-php-debug", false, false, http_client.clone()) + .await?; + + let asset_name = format!("{}-{}", self.name(), release.tag_name); + let zip_path = adapter_path.join(asset_name); + + if fs::metadata(&zip_path).await.is_err() { + let mut response = http_client + .get(&release.zipball_url, Default::default(), true) + .await + .context("Error downloading release")?; + + let mut file = File::create(&zip_path).await?; + futures::io::copy(response.body_mut(), &mut file).await?; + + let _unzip_status = process::Command::new("unzip") + .current_dir(&adapter_path) + .arg(&zip_path) + .output() + .await? + .status; + + let mut ls = process::Command::new("ls") + .current_dir(&adapter_path) + .stdout(Stdio::piped()) + .spawn()?; + + let std = ls + .stdout + .take() + .ok_or(anyhow!("Failed to list directories"))? + .into_stdio() + .await?; + + let file_name = String::from_utf8( + process::Command::new("grep") + .arg("xdebug-vscode-php-debug") + .stdin(std) + .output() + .await? + .stdout, + )?; + + let file_name = file_name.trim_end(); + + process::Command::new("sh") + .current_dir(&adapter_path) + .arg("-c") + .arg(format!("mv {file_name}/* .")) + .output() + .await?; + + process::Command::new("rm") + .current_dir(&adapter_path) + .arg("-rf") + .arg(file_name) + .arg(zip_path) + .output() + .await?; + + let _ = delegate + .node_runtime() + .ok_or(anyhow!("Couldn't get npm runtime"))? + .run_npm_subcommand(&adapter_path, "install", &[]) + .await + .is_ok(); + + let _ = delegate + .node_runtime() + .ok_or(anyhow!("Couldn't get npm runtime"))? + .run_npm_subcommand(&adapter_path, "run", &["build"]) + .await + .is_ok(); + + return Ok(()); + } + } + + bail!("Install or fetch not implemented for PHP debug adapter (yet)"); + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + json!({"program": config.program}) + } +} diff --git a/crates/dap_adapters/src/python.rs b/crates/dap_adapters/src/python.rs new file mode 100644 index 0000000000000..12b16280f0de0 --- /dev/null +++ b/crates/dap_adapters/src/python.rs @@ -0,0 +1,128 @@ +use crate::*; + +#[derive(Debug, Eq, PartialEq, Clone)] +pub(crate) struct PythonDebugAdapter {} + +impl PythonDebugAdapter { + const ADAPTER_NAME: &'static str = "debugpy"; + const ADAPTER_PATH: &'static str = "src/debugpy/adapter"; + + pub(crate) fn new() -> Self { + PythonDebugAdapter {} + } +} + +#[async_trait(?Send)] +impl DebugAdapter for PythonDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn connect( + &self, + adapter_binary: &DebugAdapterBinary, + _: &mut AsyncAppContext, + ) -> Result { + create_stdio_client(adapter_binary) + } + + async fn fetch_binary( + &self, + _: &dyn DapDelegate, + _: &DebugAdapterConfig, + ) -> Result { + let adapter_path = paths::debug_adapters_dir().join(self.name()); + + Ok(DebugAdapterBinary { + command: "python3".to_string(), + arguments: Some(vec![adapter_path.join(Self::ADAPTER_PATH).into()]), + envs: None, + }) + } + + async fn install_binary(&self, delegate: &dyn DapDelegate) -> Result<()> { + let adapter_path = paths::debug_adapters_dir().join(self.name()); + let fs = delegate.fs(); + + if fs.is_dir(adapter_path.as_path()).await { + return Ok(()); + } + + if let Some(http_client) = delegate.http_client() { + let debugpy_dir = paths::debug_adapters_dir().join("debugpy"); + + if !debugpy_dir.exists() { + fs.create_dir(&debugpy_dir.as_path()).await?; + } + + let release = + latest_github_release("microsoft/debugpy", false, false, http_client.clone()) + .await?; + let asset_name = format!("{}.zip", release.tag_name); + + let zip_path = debugpy_dir.join(asset_name); + + if fs::metadata(&zip_path).await.is_err() { + let mut response = http_client + .get(&release.zipball_url, Default::default(), true) + .await + .context("Error downloading release")?; + + let mut file = File::create(&zip_path).await?; + futures::io::copy(response.body_mut(), &mut file).await?; + + let _unzip_status = process::Command::new("unzip") + .current_dir(&debugpy_dir) + .arg(&zip_path) + .output() + .await? + .status; + + let mut ls = process::Command::new("ls") + .current_dir(&debugpy_dir) + .stdout(Stdio::piped()) + .spawn()?; + + let std = ls + .stdout + .take() + .ok_or(anyhow!("Failed to list directories"))? + .into_stdio() + .await?; + + let file_name = String::from_utf8( + process::Command::new("grep") + .arg("microsoft-debugpy") + .stdin(std) + .output() + .await? + .stdout, + )?; + + let file_name = file_name.trim_end(); + process::Command::new("sh") + .current_dir(&debugpy_dir) + .arg("-c") + .arg(format!("mv {file_name}/* .")) + .output() + .await?; + + process::Command::new("rm") + .current_dir(&debugpy_dir) + .arg("-rf") + .arg(file_name) + .arg(zip_path) + .output() + .await?; + + return Ok(()); + } + } + + bail!("Install or fetch not implemented for Python debug adapter (yet)"); + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + json!({"program": config.program, "subProcess": true}) + } +} diff --git a/crates/debugger_ui/Cargo.toml b/crates/debugger_ui/Cargo.toml new file mode 100644 index 0000000000000..1e720b53cda99 --- /dev/null +++ b/crates/debugger_ui/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "debugger_ui" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +dap.workspace = true +editor.workspace = true +futures.workspace = true +fuzzy.workspace = true +gpui.workspace = true +language.workspace = true +menu.workspace = true +parking_lot.workspace = true +project.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +task.workspace = true +tasks_ui.workspace = true +theme.workspace = true +ui.workspace = true +workspace.workspace = true + +[dev-dependencies] +editor = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/debugger_ui/LICENSE-GPL b/crates/debugger_ui/LICENSE-GPL new file mode 120000 index 0000000000000..e0f9dbd5d63fe --- /dev/null +++ b/crates/debugger_ui/LICENSE-GPL @@ -0,0 +1 @@ +LICENSE-GPL \ No newline at end of file diff --git a/crates/debugger_ui/src/console.rs b/crates/debugger_ui/src/console.rs new file mode 100644 index 0000000000000..00b2c37015629 --- /dev/null +++ b/crates/debugger_ui/src/console.rs @@ -0,0 +1,383 @@ +use crate::{ + stack_frame_list::{StackFrameList, StackFrameListEvent}, + variable_list::VariableList, +}; +use dap::client::DebugAdapterClientId; +use editor::{CompletionProvider, Editor, EditorElement, EditorStyle}; +use fuzzy::StringMatchCandidate; +use gpui::{Model, Render, Subscription, Task, TextStyle, View, ViewContext, WeakView}; +use language::{Buffer, CodeLabel, LanguageServerId, ToOffsetUtf16}; +use menu::Confirm; +use parking_lot::RwLock; +use project::{dap_store::DapStore, Completion}; +use settings::Settings; +use std::{collections::HashMap, sync::Arc}; +use theme::ThemeSettings; +use ui::prelude::*; + +pub struct Console { + console: View, + query_bar: View, + dap_store: Model, + client_id: DebugAdapterClientId, + _subscriptions: Vec, + variable_list: View, + stack_frame_list: View, +} + +impl Console { + pub fn new( + stack_frame_list: &View, + client_id: &DebugAdapterClientId, + variable_list: View, + dap_store: Model, + cx: &mut ViewContext, + ) -> Self { + let console = cx.new_view(|cx| { + let mut editor = Editor::multi_line(cx); + editor.move_to_end(&editor::actions::MoveToEnd, cx); + editor.set_read_only(true); + editor.set_show_gutter(false, cx); + editor.set_use_autoclose(false); + editor.set_show_wrap_guides(false, cx); + editor.set_show_indent_guides(false, cx); + editor.set_show_inline_completions(Some(false), cx); + editor + }); + + let this = cx.view().downgrade(); + let query_bar = cx.new_view(|cx| { + let mut editor = Editor::single_line(cx); + editor.set_placeholder_text("Evaluate an expression", cx); + editor.set_use_autoclose(false); + editor.set_show_gutter(false, cx); + editor.set_show_wrap_guides(false, cx); + editor.set_show_indent_guides(false, cx); + editor.set_completion_provider(Some(Box::new(ConsoleQueryBarCompletionProvider(this)))); + + editor + }); + + let _subscriptions = + vec![cx.subscribe(stack_frame_list, Self::handle_stack_frame_list_events)]; + + Self { + console, + dap_store, + query_bar, + variable_list, + _subscriptions, + client_id: *client_id, + stack_frame_list: stack_frame_list.clone(), + } + } + + fn handle_stack_frame_list_events( + &mut self, + _: View, + event: &StackFrameListEvent, + cx: &mut ViewContext, + ) { + match event { + StackFrameListEvent::ChangedStackFrame => cx.notify(), + StackFrameListEvent::StackFramesUpdated => { + // TODO debugger: check if we need to do something here + } + } + } + + pub fn add_message(&mut self, message: &str, cx: &mut ViewContext) { + self.console.update(cx, |console, cx| { + console.set_read_only(false); + console.move_to_end(&editor::actions::MoveToEnd, cx); + console.insert(format!("{}\n", message.trim_end()).as_str(), cx); + console.set_read_only(true); + }); + } + + fn evaluate(&mut self, _: &Confirm, cx: &mut ViewContext) { + let expression = self.query_bar.update(cx, |editor, cx| { + let expression = editor.text(cx); + + editor.clear(cx); + + expression + }); + + let evaluate_task = self.dap_store.update(cx, |store, cx| { + store.evaluate( + &self.client_id, + self.stack_frame_list.read(cx).current_stack_frame_id(), + expression, + dap::EvaluateArgumentsContext::Variables, + cx, + ) + }); + + cx.spawn(|this, mut cx| async move { + let response = evaluate_task.await?; + + this.update(&mut cx, |console, cx| { + console.add_message(&response.result, cx); + + console.variable_list.update(cx, |variable_list, cx| { + variable_list + .refetch_existing_variables(cx) + .detach_and_log_err(cx); + }) + }) + }) + .detach_and_log_err(cx); + } + + fn render_console(&self, cx: &ViewContext) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: if self.console.read(cx).read_only(cx) { + cx.theme().colors().text_disabled + } else { + cx.theme().colors().text + }, + font_family: settings.buffer_font.family.clone(), + font_features: settings.buffer_font.features.clone(), + font_size: settings.buffer_font_size.into(), + font_weight: settings.buffer_font.weight, + line_height: relative(settings.buffer_line_height.value()), + ..Default::default() + }; + + EditorElement::new( + &self.console, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + ..Default::default() + }, + ) + } + + fn render_query_bar(&self, cx: &ViewContext) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: if self.console.read(cx).read_only(cx) { + cx.theme().colors().text_disabled + } else { + cx.theme().colors().text + }, + font_family: settings.ui_font.family.clone(), + font_features: settings.ui_font.features.clone(), + font_fallbacks: settings.ui_font.fallbacks.clone(), + font_size: TextSize::Editor.rems(cx).into(), + font_weight: settings.ui_font.weight, + line_height: relative(1.3), + ..Default::default() + }; + + EditorElement::new( + &self.query_bar, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + ..Default::default() + }, + ) + } +} + +impl Render for Console { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + v_flex() + .key_context("DebugConsole") + .on_action(cx.listener(Self::evaluate)) + .size_full() + .child(self.render_console(cx)) + .child( + div() + .child(self.render_query_bar(cx)) + .pt(Spacing::XSmall.rems(cx)), + ) + .border_2() + } +} + +struct ConsoleQueryBarCompletionProvider(WeakView); + +impl CompletionProvider for ConsoleQueryBarCompletionProvider { + fn completions( + &self, + buffer: &Model, + buffer_position: language::Anchor, + _trigger: editor::CompletionContext, + cx: &mut ViewContext, + ) -> gpui::Task>> { + let Some(console) = self.0.upgrade() else { + return Task::ready(Ok(Vec::new())); + }; + + let support_completions = console.update(cx, |this, cx| { + this.dap_store + .read(cx) + .capabilities_by_id(&this.client_id) + .supports_completions_request + .unwrap_or_default() + }); + + if support_completions { + self.client_completions(&console, buffer, buffer_position, cx) + } else { + self.variable_list_completions(&console, buffer, buffer_position, cx) + } + } + + fn resolve_completions( + &self, + _buffer: Model, + _completion_indices: Vec, + _completions: Arc>>, + _cx: &mut ViewContext, + ) -> gpui::Task> { + Task::ready(Ok(false)) + } + + fn apply_additional_edits_for_completion( + &self, + _buffer: Model, + _completion: project::Completion, + _push_to_history: bool, + _cx: &mut ViewContext, + ) -> gpui::Task>> { + Task::ready(Ok(None)) + } + + fn is_completion_trigger( + &self, + _buffer: &Model, + _position: language::Anchor, + _text: &str, + _trigger_in_words: bool, + _cx: &mut ViewContext, + ) -> bool { + true + } +} + +impl ConsoleQueryBarCompletionProvider { + fn variable_list_completions( + &self, + console: &View, + buffer: &Model, + buffer_position: language::Anchor, + cx: &mut ViewContext, + ) -> gpui::Task>> { + let (variables, string_matches) = console.update(cx, |console, cx| { + let mut variables = HashMap::new(); + let mut string_matches = Vec::new(); + + for variable in console.variable_list.update(cx, |v, cx| v.variables(cx)) { + if let Some(evaluate_name) = &variable.variable.evaluate_name { + variables.insert(evaluate_name.clone(), variable.variable.value.clone()); + string_matches.push(StringMatchCandidate { + id: 0, + string: evaluate_name.clone(), + char_bag: evaluate_name.chars().collect(), + }); + } + + variables.insert( + variable.variable.name.clone(), + variable.variable.value.clone(), + ); + + string_matches.push(StringMatchCandidate { + id: 0, + string: variable.variable.name.clone(), + char_bag: variable.variable.name.chars().collect(), + }); + } + + (variables, string_matches) + }); + + let query = buffer.read(cx).text(); + let start_position = buffer.read(cx).anchor_before(0); + + cx.spawn(|_, cx| async move { + let matches = fuzzy::match_strings( + &string_matches, + &query, + true, + 10, + &Default::default(), + cx.background_executor().clone(), + ) + .await; + + Ok(matches + .iter() + .filter_map(|string_match| { + let variable_value = variables.get(&string_match.string)?; + + Some(project::Completion { + old_range: start_position..buffer_position, + new_text: string_match.string.clone(), + label: CodeLabel { + filter_range: 0..string_match.string.len(), + text: format!("{} {}", string_match.string.clone(), variable_value), + runs: Vec::new(), + }, + server_id: LanguageServerId(0), // TODO debugger: read from client + documentation: None, + lsp_completion: Default::default(), + confirm: None, + }) + }) + .collect()) + }) + } + + fn client_completions( + &self, + console: &View, + buffer: &Model, + buffer_position: language::Anchor, + cx: &mut ViewContext, + ) -> gpui::Task>> { + let text = buffer.read(cx).text(); + let start_position = buffer.read(cx).anchor_before(0); + let snapshot = buffer.read(cx).snapshot(); + + let completion_task = console.update(cx, |console, cx| { + console.dap_store.update(cx, |store, cx| { + store.completions( + &console.client_id, + console.stack_frame_list.read(cx).current_stack_frame_id(), + text, + buffer_position.to_offset_utf16(&snapshot).0 as u64, + cx, + ) + }) + }); + + cx.background_executor().spawn(async move { + Ok(completion_task + .await? + .iter() + .map(|completion| project::Completion { + old_range: start_position..buffer_position, + new_text: completion.text.clone().unwrap_or(completion.label.clone()), + label: CodeLabel { + filter_range: 0..completion.label.len(), + text: completion.label.clone(), + runs: Vec::new(), + }, + server_id: LanguageServerId(0), // TODO debugger: read from client + documentation: None, + lsp_completion: Default::default(), + confirm: None, + }) + .collect()) + }) + } +} diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs new file mode 100644 index 0000000000000..aa70e43fe5c4e --- /dev/null +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -0,0 +1,663 @@ +use crate::debugger_panel_item::DebugPanelItem; +use anyhow::Result; +use dap::client::DebugAdapterClient; +use dap::client::{DebugAdapterClientId, ThreadStatus}; +use dap::debugger_settings::DebuggerSettings; +use dap::messages::{Events, Message}; +use dap::requests::{Request, StartDebugging}; +use dap::{ + Capabilities, CapabilitiesEvent, ContinuedEvent, ExitedEvent, LoadedSourceEvent, ModuleEvent, + OutputEvent, StoppedEvent, TerminatedEvent, ThreadEvent, ThreadEventReason, +}; +use gpui::{ + actions, Action, AppContext, AsyncWindowContext, EventEmitter, FocusHandle, FocusableView, + FontWeight, Model, Subscription, Task, View, ViewContext, WeakView, +}; +use project::dap_store::DapStore; +use serde_json::Value; +use settings::Settings; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::u64; +use ui::prelude::*; +use workspace::{ + dock::{DockPosition, Panel, PanelEvent}, + Workspace, +}; +use workspace::{pane, Pane, Start}; + +pub enum DebugPanelEvent { + Exited(DebugAdapterClientId), + Terminated(DebugAdapterClientId), + Stopped { + client_id: DebugAdapterClientId, + event: StoppedEvent, + go_to_stack_frame: bool, + }, + Thread((DebugAdapterClientId, ThreadEvent)), + Continued((DebugAdapterClientId, ContinuedEvent)), + Output((DebugAdapterClientId, OutputEvent)), + Module((DebugAdapterClientId, ModuleEvent)), + LoadedSource((DebugAdapterClientId, LoadedSourceEvent)), + ClientStopped(DebugAdapterClientId), + CapabilitiesChanged(DebugAdapterClientId), +} + +actions!(debug_panel, [ToggleFocus]); + +#[derive(Debug, Default, Clone)] +pub struct ThreadState { + pub status: ThreadStatus, + // we update this value only once we stopped, + // we will use this to indicated if we should show a warning when debugger thread was exited + pub stopped: bool, +} + +pub struct DebugPanel { + size: Pixels, + pane: View, + focus_handle: FocusHandle, + dap_store: Model, + workspace: WeakView, + show_did_not_stop_warning: bool, + _subscriptions: Vec, + thread_states: BTreeMap<(DebugAdapterClientId, u64), Model>, +} + +impl DebugPanel { + pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> View { + cx.new_view(|cx| { + let pane = cx.new_view(|cx| { + let mut pane = Pane::new( + workspace.weak_handle(), + workspace.project().clone(), + Default::default(), + None, + None, + cx, + ); + pane.set_can_split(false, cx); + pane.set_can_navigate(true, cx); + pane.display_nav_history_buttons(None); + pane.set_should_display_tab_bar(|_| true); + pane.set_close_pane_if_empty(false, cx); + + pane + }); + + let project = workspace.project().clone(); + + let _subscriptions = vec![ + cx.observe(&pane, |_, _, cx| cx.notify()), + cx.subscribe(&pane, Self::handle_pane_event), + cx.subscribe(&project, { + move |this: &mut Self, _, event, cx| match event { + project::Event::DebugClientEvent { message, client_id } => { + let Some(client) = this.debug_client_by_id(client_id, cx) else { + return cx.emit(DebugPanelEvent::ClientStopped(*client_id)); + }; + + match message { + Message::Event(event) => { + this.handle_debug_client_events(client_id, event, cx); + } + Message::Request(request) => { + if StartDebugging::COMMAND == request.command { + Self::handle_start_debugging_request( + this, + client, + request.arguments.clone(), + cx, + ); + } + } + _ => unreachable!(), + } + } + project::Event::DebugClientStopped(client_id) => { + cx.emit(DebugPanelEvent::ClientStopped(*client_id)); + + this.thread_states + .retain(|&(client_id_, _), _| client_id_ != *client_id); + + cx.notify(); + } + _ => {} + } + }), + ]; + + Self { + pane, + size: px(300.), + _subscriptions, + dap_store: project.read(cx).dap_store(), + focus_handle: cx.focus_handle(), + show_did_not_stop_warning: false, + thread_states: Default::default(), + workspace: workspace.weak_handle(), + } + }) + } + + pub fn load( + workspace: WeakView, + cx: AsyncWindowContext, + ) -> Task>> { + cx.spawn(|mut cx| async move { + workspace.update(&mut cx, |workspace, cx| DebugPanel::new(workspace, cx)) + }) + } + + pub fn active_debug_panel_item( + &self, + cx: &mut ViewContext, + ) -> Option> { + self.pane + .read(cx) + .active_item() + .and_then(|panel| panel.downcast::()) + } + + fn debug_client_by_id( + &self, + client_id: &DebugAdapterClientId, + cx: &mut ViewContext, + ) -> Option> { + self.workspace + .update(cx, |this, cx| { + this.project() + .read(cx) + .dap_store() + .read(cx) + .client_by_id(client_id) + }) + .ok() + .flatten() + } + + fn handle_pane_event( + &mut self, + _: View, + event: &pane::Event, + cx: &mut ViewContext, + ) { + match event { + pane::Event::RemovedItem { item } => { + let thread_panel = item.downcast::().unwrap(); + + let thread_id = thread_panel.read(cx).thread_id(); + let client_id = thread_panel.read(cx).client_id(); + + self.thread_states.remove(&(client_id, thread_id)); + + cx.notify(); + + self.dap_store.update(cx, |store, cx| { + store + .terminate_threads(&client_id, Some(vec![thread_id; 1]), cx) + .detach() + }); + } + pane::Event::Remove { .. } => cx.emit(PanelEvent::Close), + pane::Event::ZoomIn => cx.emit(PanelEvent::ZoomIn), + pane::Event::ZoomOut => cx.emit(PanelEvent::ZoomOut), + pane::Event::AddItem { item } => { + self.workspace + .update(cx, |workspace, cx| { + item.added_to_pane(workspace, self.pane.clone(), cx) + }) + .ok(); + } + pane::Event::ActivateItem { local } => { + if !local { + return; + } + + if let Some(active_item) = self.pane.read(cx).active_item() { + if let Some(debug_item) = active_item.downcast::() { + debug_item.update(cx, |panel, cx| { + panel.go_to_current_stack_frame(cx); + }); + } + } + } + _ => {} + } + } + + fn handle_start_debugging_request( + this: &mut Self, + client: Arc, + request_args: Option, + cx: &mut ViewContext, + ) { + let start_args = if let Some(args) = request_args { + serde_json::from_value(args.clone()).ok() + } else { + None + }; + + this.dap_store.update(cx, |store, cx| { + store.start_client(client.config(), start_args, cx); + }); + } + + fn handle_debug_client_events( + &mut self, + client_id: &DebugAdapterClientId, + event: &Events, + cx: &mut ViewContext, + ) { + match event { + Events::Initialized(event) => self.handle_initialized_event(&client_id, event, cx), + Events::Stopped(event) => self.handle_stopped_event(&client_id, event, cx), + Events::Continued(event) => self.handle_continued_event(&client_id, event, cx), + Events::Exited(event) => self.handle_exited_event(&client_id, event, cx), + Events::Terminated(event) => self.handle_terminated_event(&client_id, event, cx), + Events::Thread(event) => self.handle_thread_event(&client_id, event, cx), + Events::Output(event) => self.handle_output_event(&client_id, event, cx), + Events::Breakpoint(_) => {} + Events::Module(event) => self.handle_module_event(&client_id, event, cx), + Events::LoadedSource(event) => self.handle_loaded_source_event(&client_id, event, cx), + Events::Capabilities(event) => { + self.handle_capabilities_changed_event(client_id, event, cx); + } + Events::Memory(_) => {} + Events::Process(_) => {} + Events::ProgressEnd(_) => {} + Events::ProgressStart(_) => {} + Events::ProgressUpdate(_) => {} + Events::Invalidated(_) => {} + Events::Other(_) => {} + } + } + + fn handle_initialized_event( + &mut self, + client_id: &DebugAdapterClientId, + capabilities: &Option, + cx: &mut ViewContext, + ) { + if let Some(capabilities) = capabilities { + self.dap_store.update(cx, |store, cx| { + store.merge_capabilities_for_client(&client_id, capabilities, cx); + }); + + cx.emit(DebugPanelEvent::CapabilitiesChanged(*client_id)); + } + + let send_breakpoints_task = self.workspace.update(cx, |workspace, cx| { + workspace + .project() + .update(cx, |project, cx| project.send_breakpoints(&client_id, cx)) + }); + + let configuration_done_task = self + .dap_store + .update(cx, |store, cx| store.configuration_done(&client_id, cx)); + + cx.background_executor() + .spawn(async move { + send_breakpoints_task?.await; + + configuration_done_task.await + }) + .detach_and_log_err(cx); + } + + fn handle_continued_event( + &mut self, + client_id: &DebugAdapterClientId, + event: &ContinuedEvent, + cx: &mut ViewContext, + ) { + cx.emit(DebugPanelEvent::Continued((*client_id, event.clone()))); + } + + fn handle_stopped_event( + &mut self, + client_id: &DebugAdapterClientId, + event: &StoppedEvent, + cx: &mut ViewContext, + ) { + let Some(thread_id) = event.thread_id else { + return; + }; + + let Some(client_kind) = self + .dap_store + .read(cx) + .client_by_id(client_id) + .map(|c| c.config().kind) + else { + return; // this can never happen + }; + + let client_id = *client_id; + + cx.spawn({ + let event = event.clone(); + |this, mut cx| async move { + let workspace = this.update(&mut cx, |this, cx| { + let thread_state = this + .thread_states + .entry((client_id, thread_id)) + .or_insert(cx.new_model(|_| ThreadState::default())) + .clone(); + + thread_state.update(cx, |thread_state, cx| { + thread_state.stopped = true; + thread_state.status = ThreadStatus::Stopped; + + cx.notify(); + }); + + let existing_item = this + .pane + .read(cx) + .items() + .filter_map(|item| item.downcast::()) + .any(|item| { + let item = item.read(cx); + + item.client_id() == client_id && item.thread_id() == thread_id + }); + + if !existing_item { + let debug_panel = cx.view().clone(); + this.pane.update(cx, |pane, cx| { + let tab = cx.new_view(|cx| { + DebugPanelItem::new( + debug_panel, + this.workspace.clone(), + this.dap_store.clone(), + thread_state.clone(), + &client_id, + &client_kind, + thread_id, + cx, + ) + }); + + pane.add_item(Box::new(tab), true, true, None, cx); + }); + } + + let go_to_stack_frame = if let Some(item) = this.pane.read(cx).active_item() { + item.downcast::().map_or(false, |pane| { + let pane = pane.read(cx); + pane.thread_id() == thread_id && pane.client_id() == client_id + }) + } else { + true + }; + + cx.emit(DebugPanelEvent::Stopped { + client_id, + event, + go_to_stack_frame, + }); + + cx.notify(); + + this.workspace.clone() + })?; + + cx.update(|cx| { + workspace.update(cx, |workspace, cx| { + workspace.focus_panel::(cx); + }) + }) + } + }) + .detach_and_log_err(cx); + } + + fn handle_thread_event( + &mut self, + client_id: &DebugAdapterClientId, + event: &ThreadEvent, + cx: &mut ViewContext, + ) { + let thread_id = event.thread_id; + + if let Some(thread_state) = self.thread_states.get(&(*client_id, thread_id)) { + if !thread_state.read(cx).stopped && event.reason == ThreadEventReason::Exited { + self.show_did_not_stop_warning = true; + cx.notify(); + }; + } + + if event.reason == ThreadEventReason::Started { + self.thread_states.insert( + (*client_id, thread_id), + cx.new_model(|_| ThreadState::default()), + ); + } + + cx.emit(DebugPanelEvent::Thread((*client_id, event.clone()))); + } + + fn handle_exited_event( + &mut self, + client_id: &DebugAdapterClientId, + _: &ExitedEvent, + cx: &mut ViewContext, + ) { + cx.emit(DebugPanelEvent::Exited(*client_id)); + } + + fn handle_terminated_event( + &mut self, + client_id: &DebugAdapterClientId, + event: &Option, + cx: &mut ViewContext, + ) { + let restart_args = event.clone().and_then(|e| e.restart); + + for (_, thread_state) in self + .thread_states + .range_mut(&(*client_id, u64::MIN)..&(*client_id, u64::MAX)) + { + thread_state.update(cx, |thread_state, cx| { + thread_state.status = ThreadStatus::Ended; + + cx.notify(); + }); + } + + self.dap_store.update(cx, |store, cx| { + if restart_args.is_some() { + store + .restart(&client_id, restart_args, cx) + .detach_and_log_err(cx); + } else { + store.shutdown_client(&client_id, cx).detach_and_log_err(cx); + } + }); + + cx.emit(DebugPanelEvent::Terminated(*client_id)); + } + + fn handle_output_event( + &mut self, + client_id: &DebugAdapterClientId, + event: &OutputEvent, + cx: &mut ViewContext, + ) { + cx.emit(DebugPanelEvent::Output((*client_id, event.clone()))); + } + + fn handle_module_event( + &mut self, + client_id: &DebugAdapterClientId, + event: &ModuleEvent, + cx: &mut ViewContext, + ) { + cx.emit(DebugPanelEvent::Module((*client_id, event.clone()))); + } + + fn handle_loaded_source_event( + &mut self, + client_id: &DebugAdapterClientId, + event: &LoadedSourceEvent, + cx: &mut ViewContext, + ) { + cx.emit(DebugPanelEvent::LoadedSource((*client_id, event.clone()))); + } + + fn handle_capabilities_changed_event( + &mut self, + client_id: &DebugAdapterClientId, + event: &CapabilitiesEvent, + cx: &mut ViewContext, + ) { + self.dap_store.update(cx, |store, cx| { + store.merge_capabilities_for_client(client_id, &event.capabilities, cx); + }); + + cx.emit(DebugPanelEvent::CapabilitiesChanged(*client_id)); + } + + fn render_did_not_stop_warning(&self, cx: &mut ViewContext) -> impl IntoElement { + const TITLE: &'static str = "Debug session exited without hitting any breakpoints"; + const DESCRIPTION: &'static str = + "Try adding a breakpoint, or define the correct path mapping for your debugger."; + + div() + .absolute() + .right_3() + .bottom_12() + .max_w_96() + .py_2() + .px_3() + .elevation_2(cx) + .occlude() + .child( + v_flex() + .gap_0p5() + .child( + h_flex() + .gap_1p5() + .items_center() + .child(Icon::new(IconName::Warning).color(Color::Conflict)) + .child(Label::new(TITLE).weight(FontWeight::MEDIUM)), + ) + .child( + Label::new(DESCRIPTION) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + h_flex().justify_end().mt_1().child( + Button::new("dismiss", "Dismiss") + .color(Color::Muted) + .on_click(cx.listener(|this, _, cx| { + this.show_did_not_stop_warning = false; + cx.notify(); + })), + ), + ), + ) + } +} + +impl EventEmitter for DebugPanel {} +impl EventEmitter for DebugPanel {} +impl EventEmitter for DebugPanel {} + +impl FocusableView for DebugPanel { + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Panel for DebugPanel { + fn pane(&self) -> Option> { + Some(self.pane.clone()) + } + + fn persistent_name() -> &'static str { + "DebugPanel" + } + + fn position(&self, _cx: &WindowContext) -> DockPosition { + DockPosition::Bottom + } + + fn position_is_valid(&self, position: DockPosition) -> bool { + position == DockPosition::Bottom + } + + fn set_position(&mut self, _position: DockPosition, _cx: &mut ViewContext) {} + + fn size(&self, _cx: &WindowContext) -> Pixels { + self.size + } + + fn set_size(&mut self, size: Option, _cx: &mut ViewContext) { + self.size = size.unwrap(); + } + + fn icon(&self, _cx: &WindowContext) -> Option { + Some(IconName::Debug) + } + + fn icon_tooltip(&self, cx: &WindowContext) -> Option<&'static str> { + if DebuggerSettings::get_global(cx).button { + Some("Debug Panel") + } else { + None + } + } + + fn toggle_action(&self) -> Box { + Box::new(ToggleFocus) + } +} + +impl Render for DebugPanel { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + v_flex() + .key_context("DebugPanel") + .track_focus(&self.focus_handle) + .size_full() + .when(self.show_did_not_stop_warning, |this| { + this.child(self.render_did_not_stop_warning(cx)) + }) + .map(|this| { + if self.pane.read(cx).items_len() == 0 { + this.child( + h_flex().size_full().items_center().justify_center().child( + v_flex() + .gap_2() + .rounded_md() + .max_w_64() + .items_start() + .child( + Label::new("You can create a debug task by creating a new task and setting the `type` key to `debug`") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + h_flex().w_full().justify_end().child( + Button::new( + "start-debugger", + "Choose a debugger", + ) + .label_size(LabelSize::Small) + .on_click(move |_, cx| { + cx.dispatch_action(Start.boxed_clone()); + }) + ), + ), + ), + ) + } else { + this.child(self.pane.clone()) + } + }) + .into_any() + } +} diff --git a/crates/debugger_ui/src/debugger_panel_item.rs b/crates/debugger_ui/src/debugger_panel_item.rs new file mode 100644 index 0000000000000..c203817fa2fad --- /dev/null +++ b/crates/debugger_ui/src/debugger_panel_item.rs @@ -0,0 +1,713 @@ +use crate::console::Console; +use crate::debugger_panel::{DebugPanel, DebugPanelEvent, ThreadState}; +use crate::loaded_source_list::LoadedSourceList; +use crate::module_list::ModuleList; +use crate::stack_frame_list::{StackFrameList, StackFrameListEvent}; +use crate::variable_list::VariableList; + +use dap::client::{DebugAdapterClientId, ThreadStatus}; +use dap::debugger_settings::DebuggerSettings; +use dap::{ + Capabilities, ContinuedEvent, LoadedSourceEvent, ModuleEvent, OutputEvent, OutputEventCategory, + StoppedEvent, ThreadEvent, +}; +use editor::Editor; +use gpui::{ + AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, Model, Subscription, View, + WeakView, +}; +use project::dap_store::DapStore; +use settings::Settings; +use task::DebugAdapterKind; +use ui::WindowContext; +use ui::{prelude::*, Tooltip}; +use workspace::item::{Item, ItemEvent}; +use workspace::Workspace; + +#[derive(Debug)] +pub enum DebugPanelItemEvent { + Close, + Stopped { go_to_stack_frame: bool }, +} + +#[derive(Clone, PartialEq, Eq)] +enum ThreadItem { + Console, + LoadedSource, + Modules, + Output, + Variables, +} + +pub struct DebugPanelItem { + thread_id: u64, + console: View, + focus_handle: FocusHandle, + dap_store: Model, + output_editor: View, + module_list: View, + client_kind: DebugAdapterKind, + active_thread_item: ThreadItem, + workspace: WeakView, + client_id: DebugAdapterClientId, + thread_state: Model, + variable_list: View, + _subscriptions: Vec, + stack_frame_list: View, + loaded_source_list: View, +} + +impl DebugPanelItem { + #[allow(clippy::too_many_arguments)] + pub fn new( + debug_panel: View, + workspace: WeakView, + dap_store: Model, + thread_state: Model, + client_id: &DebugAdapterClientId, + client_kind: &DebugAdapterKind, + thread_id: u64, + cx: &mut ViewContext, + ) -> Self { + let focus_handle = cx.focus_handle(); + + let this = cx.view().clone(); + let stack_frame_list = cx.new_view(|cx| { + StackFrameList::new(&workspace, &this, &dap_store, client_id, thread_id, cx) + }); + + let variable_list = cx + .new_view(|cx| VariableList::new(&stack_frame_list, dap_store.clone(), &client_id, cx)); + + let module_list = cx.new_view(|cx| ModuleList::new(dap_store.clone(), &client_id, cx)); + + let loaded_source_list = + cx.new_view(|cx| LoadedSourceList::new(&this, dap_store.clone(), &client_id, cx)); + + let console = cx.new_view(|cx| { + Console::new( + &stack_frame_list, + client_id, + variable_list.clone(), + dap_store.clone(), + cx, + ) + }); + + let _subscriptions = vec![ + cx.subscribe(&debug_panel, { + move |this: &mut Self, _, event: &DebugPanelEvent, cx| { + match event { + DebugPanelEvent::Stopped { + client_id, + event, + go_to_stack_frame, + } => this.handle_stopped_event(client_id, event, *go_to_stack_frame, cx), + DebugPanelEvent::Thread((client_id, event)) => { + this.handle_thread_event(client_id, event, cx) + } + DebugPanelEvent::Output((client_id, event)) => { + this.handle_output_event(client_id, event, cx) + } + DebugPanelEvent::Module((client_id, event)) => { + this.handle_module_event(client_id, event, cx) + } + DebugPanelEvent::LoadedSource((client_id, event)) => { + this.handle_loaded_source_event(client_id, event, cx) + } + DebugPanelEvent::ClientStopped(client_id) => { + this.handle_client_stopped_event(client_id, cx) + } + DebugPanelEvent::Continued((client_id, event)) => { + this.handle_thread_continued_event(client_id, event, cx); + } + DebugPanelEvent::Exited(client_id) + | DebugPanelEvent::Terminated(client_id) => { + this.handle_client_exited_and_terminated_event(client_id, cx); + } + DebugPanelEvent::CapabilitiesChanged(client_id) => { + this.handle_capabilities_changed_event(client_id, cx); + } + }; + } + }), + cx.subscribe( + &stack_frame_list, + move |this: &mut Self, _, event: &StackFrameListEvent, cx| match event { + StackFrameListEvent::ChangedStackFrame => this.clear_highlights(cx), + _ => {} + }, + ), + ]; + + let output_editor = cx.new_view(|cx| { + let mut editor = Editor::multi_line(cx); + editor.set_placeholder_text("Debug adapter and script output", cx); + editor.set_read_only(true); + editor.set_show_inline_completions(Some(false), cx); + editor.set_searchable(false); + editor.set_auto_replace_emoji_shortcode(false); + editor.set_show_indent_guides(false, cx); + editor.set_autoindent(false); + editor.set_show_gutter(false, cx); + editor.set_show_line_numbers(false, cx); + editor + }); + + Self { + console, + thread_id, + dap_store, + workspace, + module_list, + thread_state, + focus_handle, + output_editor, + variable_list, + _subscriptions, + stack_frame_list, + loaded_source_list, + client_id: *client_id, + client_kind: client_kind.clone(), + active_thread_item: ThreadItem::Variables, + } + } + + pub fn update_thread_state_status(&mut self, status: ThreadStatus, cx: &mut ViewContext) { + self.thread_state.update(cx, |thread_state, cx| { + thread_state.status = status; + + cx.notify(); + }); + + if status == ThreadStatus::Exited + || status == ThreadStatus::Ended + || status == ThreadStatus::Stopped + { + self.clear_highlights(cx); + } + } + + fn should_skip_event(&self, client_id: &DebugAdapterClientId, thread_id: u64) -> bool { + thread_id != self.thread_id || *client_id != self.client_id + } + + fn handle_thread_continued_event( + &mut self, + client_id: &DebugAdapterClientId, + event: &ContinuedEvent, + cx: &mut ViewContext, + ) { + if self.should_skip_event(client_id, event.thread_id) { + return; + } + + self.update_thread_state_status(ThreadStatus::Running, cx); + } + + fn handle_stopped_event( + &mut self, + client_id: &DebugAdapterClientId, + event: &StoppedEvent, + go_to_stack_frame: bool, + cx: &mut ViewContext, + ) { + if self.should_skip_event(client_id, event.thread_id.unwrap_or(self.thread_id)) { + return; + } + + cx.emit(DebugPanelItemEvent::Stopped { go_to_stack_frame }); + } + + fn handle_thread_event( + &mut self, + client_id: &DebugAdapterClientId, + event: &ThreadEvent, + cx: &mut ViewContext, + ) { + if self.should_skip_event(client_id, event.thread_id) { + return; + } + + self.update_thread_state_status(ThreadStatus::Running, cx); + } + + fn handle_output_event( + &mut self, + client_id: &DebugAdapterClientId, + event: &OutputEvent, + cx: &mut ViewContext, + ) { + if self.should_skip_event(client_id, self.thread_id) { + return; + } + + // The default value of an event category is console + // so we assume that is the output type if it doesn't exist + let output_category = event + .category + .as_ref() + .unwrap_or(&OutputEventCategory::Console); + + match output_category { + OutputEventCategory::Console => { + self.console.update(cx, |console, cx| { + console.add_message(&event.output, cx); + }); + } + _ => { + self.output_editor.update(cx, |editor, cx| { + editor.set_read_only(false); + editor.move_to_end(&editor::actions::MoveToEnd, cx); + editor.insert(format!("{}\n", &event.output.trim_end()).as_str(), cx); + editor.set_read_only(true); + + cx.notify(); + }); + } + } + } + + fn handle_module_event( + &mut self, + client_id: &DebugAdapterClientId, + event: &ModuleEvent, + cx: &mut ViewContext, + ) { + if self.should_skip_event(client_id, self.thread_id) { + return; + } + + self.module_list.update(cx, |variable_list, cx| { + variable_list.on_module_event(event, cx); + }); + } + + fn handle_loaded_source_event( + &mut self, + client_id: &DebugAdapterClientId, + event: &LoadedSourceEvent, + cx: &mut ViewContext, + ) { + if self.should_skip_event(client_id, self.thread_id) { + return; + } + + self.loaded_source_list + .update(cx, |loaded_source_list, cx| { + loaded_source_list.on_loaded_source_event(event, cx); + }); + } + + fn handle_client_stopped_event( + &mut self, + client_id: &DebugAdapterClientId, + cx: &mut ViewContext, + ) { + if self.should_skip_event(client_id, self.thread_id) { + return; + } + + self.update_thread_state_status(ThreadStatus::Stopped, cx); + + cx.emit(DebugPanelItemEvent::Close); + } + + fn handle_client_exited_and_terminated_event( + &mut self, + client_id: &DebugAdapterClientId, + cx: &mut ViewContext, + ) { + if Self::should_skip_event(self, client_id, self.thread_id) { + return; + } + + self.update_thread_state_status(ThreadStatus::Exited, cx); + + cx.emit(DebugPanelItemEvent::Close); + } + + fn handle_capabilities_changed_event( + &mut self, + client_id: &DebugAdapterClientId, + cx: &mut ViewContext, + ) { + if Self::should_skip_event(self, client_id, self.thread_id) { + return; + } + + cx.notify(); + } + + pub fn client_id(&self) -> DebugAdapterClientId { + self.client_id + } + + pub fn thread_id(&self) -> u64 { + self.thread_id + } + + pub fn capabilities(&self, cx: &mut ViewContext) -> Capabilities { + self.dap_store + .read_with(cx, |store, _| store.capabilities_by_id(&self.client_id)) + } + + fn clear_highlights(&self, cx: &mut ViewContext) { + self.workspace + .update(cx, |workspace, cx| { + let editor_views = workspace + .items_of_type::(cx) + .collect::>>(); + + for editor_view in editor_views { + editor_view.update(cx, |editor, _| { + editor.clear_row_highlights::(); + }); + } + }) + .ok(); + } + + pub fn go_to_current_stack_frame(&self, cx: &mut ViewContext) { + self.stack_frame_list.update(cx, |stack_frame_list, cx| { + stack_frame_list + .go_to_stack_frame(cx) + .detach_and_log_err(cx); + }); + } + + fn render_entry_button( + &self, + label: &SharedString, + thread_item: ThreadItem, + cx: &mut ViewContext, + ) -> AnyElement { + div() + .id(label.clone()) + .px_2() + .py_1() + .cursor_pointer() + .border_b_2() + .when(self.active_thread_item == thread_item, |this| { + this.border_color(cx.theme().colors().border) + }) + .child(Button::new(label.clone(), label.clone())) + .on_click(cx.listener(move |this, _, cx| { + this.active_thread_item = thread_item.clone(); + + cx.notify(); + })) + .into_any_element() + } + + pub fn continue_thread(&mut self, cx: &mut ViewContext) { + self.update_thread_state_status(ThreadStatus::Running, cx); + + self.dap_store.update(cx, |store, cx| { + store + .continue_thread(&self.client_id, self.thread_id, cx) + .detach_and_log_err(cx); + }); + } + + pub fn step_over(&mut self, cx: &mut ViewContext) { + self.update_thread_state_status(ThreadStatus::Running, cx); + + let granularity = DebuggerSettings::get_global(cx).stepping_granularity; + + self.dap_store.update(cx, |store, cx| { + store + .step_over(&self.client_id, self.thread_id, granularity, cx) + .detach_and_log_err(cx); + }); + } + + pub fn step_in(&mut self, cx: &mut ViewContext) { + self.update_thread_state_status(ThreadStatus::Running, cx); + + let granularity = DebuggerSettings::get_global(cx).stepping_granularity; + + self.dap_store.update(cx, |store, cx| { + store + .step_in(&self.client_id, self.thread_id, granularity, cx) + .detach_and_log_err(cx); + }); + } + + pub fn step_out(&mut self, cx: &mut ViewContext) { + self.update_thread_state_status(ThreadStatus::Running, cx); + + let granularity = DebuggerSettings::get_global(cx).stepping_granularity; + + self.dap_store.update(cx, |store, cx| { + store + .step_out(&self.client_id, self.thread_id, granularity, cx) + .detach_and_log_err(cx); + }); + } + + pub fn restart_client(&self, cx: &mut ViewContext) { + self.dap_store.update(cx, |store, cx| { + store + .restart(&self.client_id, None, cx) + .detach_and_log_err(cx); + }); + } + + pub fn pause_thread(&self, cx: &mut ViewContext) { + self.dap_store.update(cx, |store, cx| { + store + .pause_thread(&self.client_id, self.thread_id, cx) + .detach_and_log_err(cx) + }); + } + + pub fn stop_thread(&self, cx: &mut ViewContext) { + self.dap_store.update(cx, |store, cx| { + store + .terminate_threads(&self.client_id, Some(vec![self.thread_id; 1]), cx) + .detach_and_log_err(cx) + }); + } + + pub fn disconnect_client(&self, cx: &mut ViewContext) { + self.dap_store.update(cx, |store, cx| { + store + .disconnect_client(&self.client_id, cx) + .detach_and_log_err(cx); + }); + } +} + +impl EventEmitter for DebugPanelItem {} + +impl FocusableView for DebugPanelItem { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Item for DebugPanelItem { + type Event = DebugPanelItemEvent; + + fn tab_content( + &self, + params: workspace::item::TabContentParams, + _: &WindowContext, + ) -> AnyElement { + Label::new(format!( + "{:?} - Thread {}", + self.client_kind, self.thread_id + )) + .color(if params.selected { + Color::Default + } else { + Color::Muted + }) + .into_any_element() + } + + fn tab_tooltip_text(&self, cx: &AppContext) -> Option { + Some(SharedString::from(format!( + "{:?} Thread {} - {:?}", + self.client_kind, + self.thread_id, + self.thread_state.read(cx).status, + ))) + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { + match event { + DebugPanelItemEvent::Close => f(ItemEvent::CloseItem), + DebugPanelItemEvent::Stopped { .. } => {} + } + } +} + +impl Render for DebugPanelItem { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let thread_status = self.thread_state.read(cx).status; + let active_thread_item = &self.active_thread_item; + + let capabilities = self.capabilities(cx); + + h_flex() + .key_context("DebugPanelItem") + .track_focus(&self.focus_handle) + .size_full() + .items_start() + .child( + v_flex() + .size_full() + .items_start() + .child( + h_flex() + .p_1() + .border_b_1() + .w_full() + .border_color(cx.theme().colors().border_variant) + .gap_2() + .map(|this| { + if thread_status == ThreadStatus::Running { + this.child( + IconButton::new("debug-pause", IconName::DebugPause) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, cx| { + this.pause_thread(cx); + })) + .tooltip(move |cx| Tooltip::text("Pause program", cx)), + ) + } else { + this.child( + IconButton::new("debug-continue", IconName::DebugContinue) + .icon_size(IconSize::Small) + .on_click( + cx.listener(|this, _, cx| this.continue_thread(cx)), + ) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |cx| { + Tooltip::text("Continue program", cx) + }), + ) + } + }) + .child( + IconButton::new("debug-step-over", IconName::DebugStepOver) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, cx| { + this.step_over(cx); + })) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |cx| Tooltip::text("Step over", cx)), + ) + .child( + IconButton::new("debug-step-in", IconName::DebugStepInto) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, cx| { + this.step_in(cx); + })) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |cx| Tooltip::text("Step in", cx)), + ) + .child( + IconButton::new("debug-step-out", IconName::DebugStepOut) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, cx| { + this.step_out(cx); + })) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |cx| Tooltip::text("Step out", cx)), + ) + .child( + IconButton::new("debug-restart", IconName::DebugRestart) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, cx| { + this.restart_client(cx); + })) + .disabled( + !capabilities.supports_restart_request.unwrap_or_default(), + ) + .tooltip(move |cx| Tooltip::text("Restart", cx)), + ) + .child( + IconButton::new("debug-stop", IconName::DebugStop) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, cx| { + this.stop_thread(cx); + })) + .disabled( + thread_status != ThreadStatus::Stopped + && thread_status != ThreadStatus::Running, + ) + .tooltip(move |cx| Tooltip::text("Stop", cx)), + ) + .child( + IconButton::new("debug-disconnect", IconName::DebugDisconnect) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, cx| { + this.disconnect_client(cx); + })) + .disabled( + thread_status == ThreadStatus::Exited + || thread_status == ThreadStatus::Ended, + ) + .tooltip(move |cx| Tooltip::text("Disconnect", cx)), + ), + ) + .child( + h_flex() + .size_full() + .items_start() + .p_1() + .gap_4() + .child(self.stack_frame_list.clone()), + ), + ) + .child( + v_flex() + .border_l_1() + .border_color(cx.theme().colors().border_variant) + .size_full() + .items_start() + .child( + h_flex() + .border_b_1() + .w_full() + .border_color(cx.theme().colors().border_variant) + .child(self.render_entry_button( + &SharedString::from("Variables"), + ThreadItem::Variables, + cx, + )) + .when( + capabilities.supports_modules_request.unwrap_or_default(), + |this| { + this.child(self.render_entry_button( + &SharedString::from("Modules"), + ThreadItem::Modules, + cx, + )) + }, + ) + .when( + capabilities + .supports_loaded_sources_request + .unwrap_or_default(), + |this| { + this.child(self.render_entry_button( + &SharedString::from("Loaded Sources"), + ThreadItem::LoadedSource, + cx, + )) + }, + ) + .child(self.render_entry_button( + &SharedString::from("Console"), + ThreadItem::Console, + cx, + )) + .child(self.render_entry_button( + &SharedString::from("Output"), + ThreadItem::Output, + cx, + )), + ) + .when(*active_thread_item == ThreadItem::Variables, |this| { + this.size_full().child(self.variable_list.clone()) + }) + .when(*active_thread_item == ThreadItem::Modules, |this| { + this.size_full().child(self.module_list.clone()) + }) + .when(*active_thread_item == ThreadItem::LoadedSource, |this| { + this.size_full().child(self.loaded_source_list.clone()) + }) + .when(*active_thread_item == ThreadItem::Output, |this| { + this.child(self.output_editor.clone()) + }) + .when(*active_thread_item == ThreadItem::Console, |this| { + this.child(self.console.clone()) + }), + ) + .into_any() + } +} diff --git a/crates/debugger_ui/src/lib.rs b/crates/debugger_ui/src/lib.rs new file mode 100644 index 0000000000000..d7e3e59b4f8b0 --- /dev/null +++ b/crates/debugger_ui/src/lib.rs @@ -0,0 +1,118 @@ +use dap::debugger_settings::DebuggerSettings; +use debugger_panel::{DebugPanel, ToggleFocus}; +use gpui::AppContext; +use settings::Settings; +use ui::ViewContext; +use workspace::{ + Continue, Pause, Restart, Start, StepInto, StepOut, StepOver, Stop, StopDebugAdapters, + Workspace, +}; + +mod console; +pub mod debugger_panel; +mod debugger_panel_item; +mod loaded_source_list; +mod module_list; +mod stack_frame_list; +mod variable_list; + +pub fn init(cx: &mut AppContext) { + DebuggerSettings::register(cx); + + cx.observe_new_views( + |workspace: &mut Workspace, _cx: &mut ViewContext| { + workspace + .register_action(|workspace, _: &ToggleFocus, cx| { + workspace.toggle_panel_focus::(cx); + }) + .register_action(|workspace: &mut Workspace, _: &Start, cx| { + tasks_ui::toggle_modal(workspace, task::TaskModal::DebugModal, cx).detach(); + }) + .register_action(|workspace: &mut Workspace, _: &StopDebugAdapters, cx| { + workspace.project().update(cx, |project, cx| { + project.dap_store().update(cx, |store, cx| { + store.shutdown_clients(cx).detach(); + }) + }) + }) + .register_action(|workspace: &mut Workspace, _: &Stop, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + debug_panel.update(cx, |panel, cx| { + let Some(active_item) = panel.active_debug_panel_item(cx) else { + return; + }; + + active_item.update(cx, |item, cx| item.stop_thread(cx)) + }); + }) + .register_action(|workspace: &mut Workspace, _: &Continue, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + debug_panel.update(cx, |panel, cx| { + let Some(active_item) = panel.active_debug_panel_item(cx) else { + return; + }; + + active_item.update(cx, |item, cx| item.continue_thread(cx)) + }); + }) + .register_action(|workspace: &mut Workspace, _: &StepInto, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + debug_panel.update(cx, |panel, cx| { + let Some(active_item) = panel.active_debug_panel_item(cx) else { + return; + }; + + active_item.update(cx, |item, cx| item.step_in(cx)) + }); + }) + .register_action(|workspace: &mut Workspace, _: &StepOut, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + debug_panel.update(cx, |panel, cx| { + let Some(active_item) = panel.active_debug_panel_item(cx) else { + return; + }; + + active_item.update(cx, |item, cx| item.step_out(cx)) + }); + }) + .register_action(|workspace: &mut Workspace, _: &StepOver, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + debug_panel.update(cx, |panel, cx| { + let Some(active_item) = panel.active_debug_panel_item(cx) else { + return; + }; + + active_item.update(cx, |item, cx| item.step_over(cx)) + }); + }) + .register_action(|workspace: &mut Workspace, _: &Restart, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + debug_panel.update(cx, |panel, cx| { + let Some(active_item) = panel.active_debug_panel_item(cx) else { + return; + }; + + active_item.update(cx, |item, cx| item.restart_client(cx)) + }); + }) + .register_action(|workspace: &mut Workspace, _: &Pause, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + debug_panel.update(cx, |panel, cx| { + let Some(active_item) = panel.active_debug_panel_item(cx) else { + return; + }; + + active_item.update(cx, |item, cx| item.pause_thread(cx)) + }); + }); + }, + ) + .detach(); +} diff --git a/crates/debugger_ui/src/loaded_source_list.rs b/crates/debugger_ui/src/loaded_source_list.rs new file mode 100644 index 0000000000000..761a33be05e5d --- /dev/null +++ b/crates/debugger_ui/src/loaded_source_list.rs @@ -0,0 +1,155 @@ +use anyhow::Result; +use dap::{client::DebugAdapterClientId, LoadedSourceEvent, Source}; +use gpui::{ + list, AnyElement, FocusHandle, FocusableView, ListState, Model, Subscription, Task, View, +}; +use project::dap_store::DapStore; +use ui::prelude::*; + +use crate::debugger_panel_item::{self, DebugPanelItem, DebugPanelItemEvent}; + +pub struct LoadedSourceList { + list: ListState, + sources: Vec, + focus_handle: FocusHandle, + dap_store: Model, + client_id: DebugAdapterClientId, + _subscriptions: Vec, +} + +impl LoadedSourceList { + pub fn new( + debug_panel_item: &View, + dap_store: Model, + client_id: &DebugAdapterClientId, + cx: &mut ViewContext, + ) -> Self { + let weakview = cx.view().downgrade(); + let focus_handle = cx.focus_handle(); + + let list = ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| { + weakview + .upgrade() + .map(|view| view.update(cx, |this, cx| this.render_entry(ix, cx))) + .unwrap_or(div().into_any()) + }); + + let _subscriptions = + vec![cx.subscribe(debug_panel_item, Self::handle_debug_panel_item_event)]; + + Self { + list, + dap_store, + focus_handle, + _subscriptions, + client_id: *client_id, + sources: Vec::default(), + } + } + + fn handle_debug_panel_item_event( + &mut self, + _: View, + event: &debugger_panel_item::DebugPanelItemEvent, + cx: &mut ViewContext, + ) { + match event { + DebugPanelItemEvent::Stopped { .. } => { + self.fetch_loaded_sources(cx).detach_and_log_err(cx); + } + _ => {} + } + } + + pub fn on_loaded_source_event( + &mut self, + event: &LoadedSourceEvent, + cx: &mut ViewContext, + ) { + match event.reason { + dap::LoadedSourceEventReason::New => self.sources.push(event.source.clone()), + dap::LoadedSourceEventReason::Changed => { + let updated_source = + if let Some(ref_id) = event.source.source_reference.filter(|&r| r != 0) { + self.sources + .iter_mut() + .find(|s| s.source_reference == Some(ref_id)) + } else if let Some(path) = &event.source.path { + self.sources + .iter_mut() + .find(|s| s.path.as_ref() == Some(path)) + } else { + self.sources + .iter_mut() + .find(|s| s.name == event.source.name) + }; + + if let Some(loaded_source) = updated_source { + *loaded_source = event.source.clone(); + } + } + dap::LoadedSourceEventReason::Removed => { + self.sources.retain(|source| *source != event.source) + } + } + + self.list.reset(self.sources.len()); + cx.notify(); + } + + fn fetch_loaded_sources(&self, cx: &mut ViewContext) -> Task> { + let task = self + .dap_store + .update(cx, |store, cx| store.loaded_sources(&self.client_id, cx)); + + cx.spawn(|this, mut cx| async move { + let mut sources = task.await?; + + this.update(&mut cx, |this, cx| { + std::mem::swap(&mut this.sources, &mut sources); + this.list.reset(this.sources.len()); + + cx.notify(); + }) + }) + } + + fn render_entry(&mut self, ix: usize, cx: &mut ViewContext) -> AnyElement { + let source = &self.sources[ix]; + + v_flex() + .rounded_md() + .w_full() + .group("") + .p_1() + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .child( + h_flex() + .gap_0p5() + .text_ui_sm(cx) + .when_some(source.name.clone(), |this, name| this.child(name)), + ) + .child( + h_flex() + .text_ui_xs(cx) + .text_color(cx.theme().colors().text_muted) + .when_some(source.path.clone(), |this, path| this.child(path)), + ) + .into_any() + } +} + +impl FocusableView for LoadedSourceList { + fn focus_handle(&self, _: &gpui::AppContext) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for LoadedSourceList { + fn render(&mut self, _: &mut ViewContext) -> impl IntoElement { + div() + .size_full() + .p_1() + .child(list(self.list.clone()).size_full()) + } +} diff --git a/crates/debugger_ui/src/module_list.rs b/crates/debugger_ui/src/module_list.rs new file mode 100644 index 0000000000000..5973cfd7682b5 --- /dev/null +++ b/crates/debugger_ui/src/module_list.rs @@ -0,0 +1,109 @@ +use anyhow::Result; +use dap::{client::DebugAdapterClientId, Module, ModuleEvent}; +use gpui::{list, AnyElement, FocusHandle, FocusableView, ListState, Model, Task}; +use project::dap_store::DapStore; +use ui::prelude::*; + +pub struct ModuleList { + list: ListState, + modules: Vec, + focus_handle: FocusHandle, + dap_store: Model, + client_id: DebugAdapterClientId, +} + +impl ModuleList { + pub fn new( + dap_store: Model, + client_id: &DebugAdapterClientId, + cx: &mut ViewContext, + ) -> Self { + let weakview = cx.view().downgrade(); + let focus_handle = cx.focus_handle(); + + let list = ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| { + weakview + .upgrade() + .map(|view| view.update(cx, |this, cx| this.render_entry(ix, cx))) + .unwrap_or(div().into_any()) + }); + + let this = Self { + list, + dap_store, + focus_handle, + client_id: *client_id, + modules: Vec::default(), + }; + + this.fetch_modules(cx).detach_and_log_err(cx); + + this + } + + pub fn on_module_event(&mut self, event: &ModuleEvent, cx: &mut ViewContext) { + match event.reason { + dap::ModuleEventReason::New => self.modules.push(event.module.clone()), + dap::ModuleEventReason::Changed => { + if let Some(module) = self.modules.iter_mut().find(|m| m.id == event.module.id) { + *module = event.module.clone(); + } + } + dap::ModuleEventReason::Removed => self.modules.retain(|m| m.id != event.module.id), + } + + self.list.reset(self.modules.len()); + cx.notify(); + } + + fn fetch_modules(&self, cx: &mut ViewContext) -> Task> { + let task = self + .dap_store + .update(cx, |store, cx| store.modules(&self.client_id, cx)); + + cx.spawn(|this, mut cx| async move { + let mut modules = task.await?; + + this.update(&mut cx, |this, cx| { + std::mem::swap(&mut this.modules, &mut modules); + this.list.reset(this.modules.len()); + + cx.notify(); + }) + }) + } + + fn render_entry(&mut self, ix: usize, cx: &mut ViewContext) -> AnyElement { + let module = &self.modules[ix]; + + v_flex() + .rounded_md() + .w_full() + .group("") + .p_1() + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .child(h_flex().gap_0p5().text_ui_sm(cx).child(module.name.clone())) + .child( + h_flex() + .text_ui_xs(cx) + .text_color(cx.theme().colors().text_muted) + .when_some(module.path.clone(), |this, path| this.child(path)), + ) + .into_any() + } +} + +impl FocusableView for ModuleList { + fn focus_handle(&self, _: &gpui::AppContext) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ModuleList { + fn render(&mut self, _: &mut ViewContext) -> impl IntoElement { + div() + .size_full() + .p_1() + .child(list(self.list.clone()).size_full()) + } +} diff --git a/crates/debugger_ui/src/stack_frame_list.rs b/crates/debugger_ui/src/stack_frame_list.rs new file mode 100644 index 0000000000000..6d31097339582 --- /dev/null +++ b/crates/debugger_ui/src/stack_frame_list.rs @@ -0,0 +1,260 @@ +use std::path::Path; + +use anyhow::{anyhow, Result}; +use dap::client::DebugAdapterClientId; +use dap::StackFrame; +use editor::Editor; +use gpui::{ + list, AnyElement, EventEmitter, FocusHandle, ListState, Subscription, Task, View, WeakView, +}; +use gpui::{FocusableView, Model}; +use project::dap_store::DapStore; +use project::ProjectPath; +use ui::ViewContext; +use ui::{prelude::*, Tooltip}; +use workspace::Workspace; + +use crate::debugger_panel_item::DebugPanelItemEvent::Stopped; +use crate::debugger_panel_item::{self, DebugPanelItem}; + +#[derive(Debug)] +pub enum StackFrameListEvent { + ChangedStackFrame, + StackFramesUpdated, +} + +pub struct StackFrameList { + thread_id: u64, + list: ListState, + focus_handle: FocusHandle, + dap_store: Model, + current_stack_frame_id: u64, + stack_frames: Vec, + workspace: WeakView, + client_id: DebugAdapterClientId, + _subscriptions: Vec, +} + +impl StackFrameList { + pub fn new( + workspace: &WeakView, + debug_panel_item: &View, + dap_store: &Model, + client_id: &DebugAdapterClientId, + thread_id: u64, + cx: &mut ViewContext, + ) -> Self { + let weakview = cx.view().downgrade(); + let focus_handle = cx.focus_handle(); + + let list = ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| { + weakview + .upgrade() + .map(|view| view.update(cx, |this, cx| this.render_entry(ix, cx))) + .unwrap_or(div().into_any()) + }); + + let _subscriptions = + vec![cx.subscribe(debug_panel_item, Self::handle_debug_panel_item_event)]; + + Self { + list, + thread_id, + focus_handle, + _subscriptions, + client_id: *client_id, + workspace: workspace.clone(), + dap_store: dap_store.clone(), + stack_frames: Default::default(), + current_stack_frame_id: Default::default(), + } + } + + pub fn stack_frames(&self) -> &Vec { + &self.stack_frames + } + + pub fn current_stack_frame_id(&self) -> u64 { + self.current_stack_frame_id + } + + fn handle_debug_panel_item_event( + &mut self, + _: View, + event: &debugger_panel_item::DebugPanelItemEvent, + cx: &mut ViewContext, + ) { + match event { + Stopped { go_to_stack_frame } => { + self.fetch_stack_frames(*go_to_stack_frame, cx) + .detach_and_log_err(cx); + } + _ => {} + } + } + + fn fetch_stack_frames( + &self, + go_to_stack_frame: bool, + cx: &mut ViewContext, + ) -> Task> { + let task = self.dap_store.update(cx, |store, cx| { + store.stack_frames(&self.client_id, self.thread_id, cx) + }); + + cx.spawn(|this, mut cx| async move { + let mut stack_frames = task.await?; + + let task = this.update(&mut cx, |this, cx| { + std::mem::swap(&mut this.stack_frames, &mut stack_frames); + + if let Some(stack_frame) = this.stack_frames.first() { + this.current_stack_frame_id = stack_frame.id; + cx.emit(StackFrameListEvent::ChangedStackFrame); + } + + this.list.reset(this.stack_frames.len()); + cx.notify(); + + cx.emit(StackFrameListEvent::StackFramesUpdated); + + if go_to_stack_frame { + Some(this.go_to_stack_frame(cx)) + } else { + None + } + })?; + + if let Some(task) = task { + task.await?; + } + + Ok(()) + }) + } + + pub fn go_to_stack_frame(&mut self, cx: &mut ViewContext) -> Task> { + let stack_frame = self + .stack_frames + .iter() + .find(|s| s.id == self.current_stack_frame_id) + .cloned(); + + let Some(stack_frame) = stack_frame else { + return Task::ready(Ok(())); // this could never happen + }; + + let row = (stack_frame.line.saturating_sub(1)) as u32; + let column = (stack_frame.column.saturating_sub(1)) as u32; + + let Some(project_path) = self.project_path_from_stack_frame(&stack_frame, cx) else { + return Task::ready(Err(anyhow!("Project path not found"))); + }; + + self.dap_store.update(cx, |store, cx| { + store.set_active_debug_line(&project_path, row, column, cx); + }); + + cx.spawn({ + let workspace = self.workspace.clone(); + move |_, mut cx| async move { + let task = workspace.update(&mut cx, |workspace, cx| { + workspace.open_path_preview(project_path, None, false, true, cx) + })?; + + let editor = task.await?.downcast::().unwrap(); + + workspace.update(&mut cx, |_, cx| { + editor.update(cx, |editor, cx| editor.go_to_active_debug_line(cx)) + }) + } + }) + } + + pub fn project_path_from_stack_frame( + &self, + stack_frame: &StackFrame, + cx: &mut ViewContext, + ) -> Option { + let path = stack_frame.source.as_ref().and_then(|s| s.path.as_ref())?; + + self.workspace + .update(cx, |workspace, cx| { + workspace.project().read_with(cx, |project, cx| { + project.project_path_for_absolute_path(&Path::new(path), cx) + }) + }) + .ok()? + } + + fn render_entry(&self, ix: usize, cx: &mut ViewContext) -> AnyElement { + let stack_frame = &self.stack_frames[ix]; + + let source = stack_frame.source.clone(); + let is_selected_frame = stack_frame.id == self.current_stack_frame_id; + + let formatted_path = format!( + "{}:{}", + source.clone().and_then(|s| s.name).unwrap_or_default(), + stack_frame.line, + ); + + v_flex() + .rounded_md() + .w_full() + .group("") + .id(("stack-frame", stack_frame.id)) + .tooltip({ + let formatted_path = formatted_path.clone(); + move |cx| Tooltip::text(formatted_path.clone(), cx) + }) + .p_1() + .when(is_selected_frame, |this| { + this.bg(cx.theme().colors().element_hover) + }) + .on_click(cx.listener({ + let stack_frame_id = stack_frame.id; + move |this, _, cx| { + this.current_stack_frame_id = stack_frame_id; + + this.go_to_stack_frame(cx).detach_and_log_err(cx); + + cx.notify(); + + cx.emit(StackFrameListEvent::ChangedStackFrame); + } + })) + .hover(|s| s.bg(cx.theme().colors().element_hover).cursor_pointer()) + .child( + h_flex() + .gap_0p5() + .text_ui_sm(cx) + .child(stack_frame.name.clone()) + .child(formatted_path), + ) + .child( + h_flex() + .text_ui_xs(cx) + .text_color(cx.theme().colors().text_muted) + .when_some(source.and_then(|s| s.path), |this, path| this.child(path)), + ) + .into_any() + } +} + +impl Render for StackFrameList { + fn render(&mut self, _: &mut ViewContext) -> impl IntoElement { + div() + .size_full() + .p_1() + .child(list(self.list.clone()).size_full()) + } +} + +impl FocusableView for StackFrameList { + fn focus_handle(&self, _: &gpui::AppContext) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for StackFrameList {} diff --git a/crates/debugger_ui/src/variable_list.rs b/crates/debugger_ui/src/variable_list.rs new file mode 100644 index 0000000000000..ab713f965fdd7 --- /dev/null +++ b/crates/debugger_ui/src/variable_list.rs @@ -0,0 +1,801 @@ +use crate::stack_frame_list::{StackFrameList, StackFrameListEvent}; +use anyhow::Result; +use dap::{client::DebugAdapterClientId, Scope, Variable}; +use editor::{ + actions::{self, SelectAll}, + Editor, EditorEvent, +}; +use futures::future::try_join_all; +use gpui::{ + anchored, deferred, list, AnyElement, ClipboardItem, DismissEvent, FocusHandle, FocusableView, + ListState, Model, MouseDownEvent, Point, Subscription, Task, View, +}; +use menu::Confirm; +use project::dap_store::DapStore; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + sync::Arc, +}; +use ui::{prelude::*, ContextMenu, ListItem}; + +#[derive(Debug, Clone)] +pub struct VariableContainer { + pub container_reference: u64, + pub variable: Variable, + pub depth: usize, +} + +#[derive(Debug, Clone)] +pub struct SetVariableState { + name: String, + scope: Scope, + value: String, + stack_frame_id: u64, + evaluate_name: Option, + parent_variables_reference: u64, +} + +#[derive(Debug, Clone)] +pub enum VariableListEntry { + Scope(Scope), + SetVariableEditor { + depth: usize, + state: SetVariableState, + }, + Variable { + depth: usize, + scope: Arc, + variable: Arc, + has_children: bool, + container_reference: u64, + }, +} + +pub struct VariableList { + list: ListState, + dap_store: Model, + focus_handle: FocusHandle, + client_id: DebugAdapterClientId, + open_entries: Vec, + scopes: HashMap>, + set_variable_editor: View, + _subscriptions: Vec, + fetched_variable_ids: HashSet, + stack_frame_list: View, + set_variable_state: Option, + entries: HashMap>, + fetch_variables_task: Option>>, + // (stack_frame_id, scope.variables_reference) -> variables + variables: BTreeMap<(u64, u64), Vec>, + open_context_menu: Option<(View, Point, Subscription)>, +} + +impl VariableList { + pub fn new( + stack_frame_list: &View, + dap_store: Model, + client_id: &DebugAdapterClientId, + cx: &mut ViewContext, + ) -> Self { + let weakview = cx.view().downgrade(); + let focus_handle = cx.focus_handle(); + + let list = ListState::new(0, gpui::ListAlignment::Top, px(1000.), move |ix, cx| { + weakview + .upgrade() + .map(|view| view.update(cx, |this, cx| this.render_entry(ix, cx))) + .unwrap_or(div().into_any()) + }); + + let set_variable_editor = cx.new_view(Editor::single_line); + + cx.subscribe( + &set_variable_editor, + |this: &mut Self, _, event: &EditorEvent, cx| { + if *event == EditorEvent::Blurred { + this.cancel_set_variable_value(cx); + } + }, + ) + .detach(); + + let _subscriptions = + vec![cx.subscribe(stack_frame_list, Self::handle_stack_frame_list_events)]; + + Self { + list, + dap_store, + focus_handle, + _subscriptions, + set_variable_editor, + client_id: *client_id, + open_context_menu: None, + set_variable_state: None, + fetch_variables_task: None, + scopes: Default::default(), + entries: Default::default(), + variables: Default::default(), + open_entries: Default::default(), + fetched_variable_ids: Default::default(), + stack_frame_list: stack_frame_list.clone(), + } + } + + fn handle_stack_frame_list_events( + &mut self, + _: View, + event: &StackFrameListEvent, + cx: &mut ViewContext, + ) { + match event { + StackFrameListEvent::ChangedStackFrame => { + self.build_entries(true, false, cx); + } + StackFrameListEvent::StackFramesUpdated => { + self.fetch_variables(cx); + } + } + } + + pub fn variables(&self, cx: &mut ViewContext) -> Vec { + let stack_frame_id = self.stack_frame_list.read(cx).current_stack_frame_id(); + + self.variables + .range((stack_frame_id, u64::MIN)..(stack_frame_id, u64::MAX)) + .flat_map(|(_, containers)| containers.iter().cloned()) + .collect() + } + + fn render_entry(&mut self, ix: usize, cx: &mut ViewContext) -> AnyElement { + let stack_frame_id = self.stack_frame_list.read(cx).current_stack_frame_id(); + + let Some(entries) = self.entries.get(&stack_frame_id) else { + return div().into_any_element(); + }; + + match &entries[ix] { + VariableListEntry::Scope(scope) => self.render_scope(scope, cx), + VariableListEntry::SetVariableEditor { depth, state } => { + self.render_set_variable_editor(*depth, state, cx) + } + VariableListEntry::Variable { + depth, + scope, + variable, + has_children, + container_reference: parent_variables_reference, + } => self.render_variable( + ix, + *parent_variables_reference, + variable, + scope, + *depth, + *has_children, + cx, + ), + } + } + + fn toggle_entry_collapsed(&mut self, entry_id: &SharedString, cx: &mut ViewContext) { + match self.open_entries.binary_search(&entry_id) { + Ok(ix) => { + self.open_entries.remove(ix); + } + Err(ix) => { + self.open_entries.insert(ix, entry_id.clone()); + } + }; + + self.build_entries(false, true, cx); + } + + pub fn build_entries( + &mut self, + open_first_scope: bool, + keep_open_entries: bool, + cx: &mut ViewContext, + ) { + let stack_frame_id = self.stack_frame_list.read(cx).current_stack_frame_id(); + + let Some(scopes) = self.scopes.get(&stack_frame_id) else { + return; + }; + + if !keep_open_entries { + self.open_entries.clear(); + } + + let mut entries: Vec = Vec::default(); + for scope in scopes { + let Some(variables) = self + .variables + .get(&(stack_frame_id, scope.variables_reference)) + else { + continue; + }; + + if variables.is_empty() { + continue; + } + + if open_first_scope && entries.is_empty() { + self.open_entries.push(scope_entry_id(scope)); + } + entries.push(VariableListEntry::Scope(scope.clone())); + + if self + .open_entries + .binary_search(&scope_entry_id(scope)) + .is_err() + { + continue; + } + + let mut depth_check: Option = None; + + for variable_container in variables { + let depth = variable_container.depth; + let variable = &variable_container.variable; + let container_reference = variable_container.container_reference; + + if depth_check.is_some_and(|d| depth > d) { + continue; + } + + if depth_check.is_some_and(|d| d >= depth) { + depth_check = None; + } + + if self + .open_entries + .binary_search(&variable_entry_id(scope, variable, depth)) + .is_err() + { + if depth_check.is_none() || depth_check.is_some_and(|d| d > depth) { + depth_check = Some(depth); + } + } + + if let Some(state) = self.set_variable_state.as_ref() { + if state.parent_variables_reference == container_reference + && state.scope.variables_reference == scope.variables_reference + && state.name == variable.name + { + entries.push(VariableListEntry::SetVariableEditor { + depth, + state: state.clone(), + }); + } + } + + entries.push(VariableListEntry::Variable { + depth, + scope: Arc::new(scope.clone()), + variable: Arc::new(variable.clone()), + has_children: variable.variables_reference > 0, + container_reference, + }); + } + } + + let len = entries.len(); + self.entries.insert(stack_frame_id, entries); + self.list.reset(len); + + cx.notify(); + } + + fn fetch_variables(&mut self, cx: &mut ViewContext) { + let stack_frames = self.stack_frame_list.read(cx).stack_frames().clone(); + + self.fetch_variables_task = Some(cx.spawn(|this, mut cx| async move { + let mut scope_tasks = Vec::with_capacity(stack_frames.len()); + for stack_frame in stack_frames.clone().into_iter() { + let stack_frame_scopes_task = this.update(&mut cx, |this, cx| { + this.dap_store.update(cx, |store, cx| { + store.scopes(&this.client_id, stack_frame.id, cx) + }) + }); + + scope_tasks.push(async move { + anyhow::Ok((stack_frame.id, stack_frame_scopes_task?.await?)) + }); + } + + let mut stack_frame_tasks = Vec::with_capacity(scope_tasks.len()); + for (stack_frame_id, scopes) in try_join_all(scope_tasks).await? { + let variable_tasks = this.update(&mut cx, |this, cx| { + this.dap_store.update(cx, |store, cx| { + let mut tasks = Vec::with_capacity(scopes.len()); + + for scope in scopes { + let variables_task = + store.variables(&this.client_id, scope.variables_reference, cx); + tasks.push(async move { anyhow::Ok((scope, variables_task.await?)) }); + } + + tasks + }) + })?; + + stack_frame_tasks.push(async move { + anyhow::Ok((stack_frame_id, try_join_all(variable_tasks).await?)) + }); + } + + let result = try_join_all(stack_frame_tasks).await?; + + this.update(&mut cx, |this, cx| { + this.variables.clear(); + this.scopes.clear(); + this.fetched_variable_ids.clear(); + + for (stack_frame_id, scopes) in result { + for (scope, variables) in scopes { + this.scopes + .entry(stack_frame_id) + .or_default() + .push(scope.clone()); + + this.fetched_variable_ids.insert(scope.variables_reference); + + this.variables.insert( + (stack_frame_id, scope.variables_reference), + variables + .into_iter() + .map(|v| VariableContainer { + container_reference: scope.variables_reference, + variable: v, + depth: 1, + }) + .collect::>(), + ); + } + } + + this.build_entries(true, false, cx); + + this.fetch_variables_task.take(); + + cx.notify(); + }) + })); + } + + fn deploy_variable_context_menu( + &mut self, + parent_variables_reference: u64, + scope: &Scope, + variable: &Variable, + position: Point, + cx: &mut ViewContext, + ) { + let this = cx.view().clone(); + + let support_set_variable = self.dap_store.read_with(cx, |store, _| { + store + .capabilities_by_id(&self.client_id) + .supports_set_variable + .unwrap_or_default() + }); + + let context_menu = ContextMenu::build(cx, |menu, cx| { + menu.entry( + "Copy name", + None, + cx.handler_for(&this, { + let variable_name = variable.name.clone(); + move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(variable_name.clone())) + } + }), + ) + .entry( + "Copy value", + None, + cx.handler_for(&this, { + let variable_value = variable.value.clone(); + move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(variable_value.clone())) + } + }), + ) + .when_some( + variable.memory_reference.clone(), + |menu, memory_reference| { + menu.entry( + "Copy memory reference", + None, + cx.handler_for(&this, move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + memory_reference.clone(), + )) + }), + ) + }, + ) + .when(support_set_variable, move |menu| { + let variable = variable.clone(); + let scope = scope.clone(); + + menu.entry( + "Set value", + None, + cx.handler_for(&this, move |this, cx| { + this.set_variable_state = Some(SetVariableState { + parent_variables_reference, + name: variable.name.clone(), + scope: scope.clone(), + evaluate_name: variable.evaluate_name.clone(), + value: variable.value.clone(), + stack_frame_id: this.stack_frame_list.read(cx).current_stack_frame_id(), + }); + + this.set_variable_editor.update(cx, |editor, cx| { + editor.set_text(variable.value.clone(), cx); + editor.select_all(&SelectAll, cx); + editor.focus(cx); + }); + + this.build_entries(false, true, cx); + }), + ) + }) + }); + + cx.focus_view(&context_menu); + let subscription = + cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| { + if this.open_context_menu.as_ref().is_some_and(|context_menu| { + context_menu.0.focus_handle(cx).contains_focused(cx) + }) { + cx.focus_self(); + } + this.open_context_menu.take(); + cx.notify(); + }); + + self.open_context_menu = Some((context_menu, position, subscription)); + } + + fn cancel_set_variable_value(&mut self, cx: &mut ViewContext) { + if self.set_variable_state.take().is_none() { + return; + }; + + self.build_entries(false, true, cx); + } + + fn set_variable_value(&mut self, _: &Confirm, cx: &mut ViewContext) { + let new_variable_value = self.set_variable_editor.update(cx, |editor, cx| { + let new_variable_value = editor.text(cx); + + editor.clear(cx); + + new_variable_value + }); + + let Some(state) = self.set_variable_state.take() else { + return cx.notify(); + }; + + if new_variable_value == state.value + || state.stack_frame_id != self.stack_frame_list.read(cx).current_stack_frame_id() + { + return cx.notify(); + } + + let client_id = self.client_id; + let variables_reference = state.parent_variables_reference; + let name = state.name; + let evaluate_name = state.evaluate_name; + let stack_frame_id = state.stack_frame_id; + + cx.spawn(|this, mut cx| async move { + let set_value_task = this.update(&mut cx, |this, cx| { + this.dap_store.update(cx, |store, cx| { + store.set_variable_value( + &client_id, + stack_frame_id, + variables_reference, + name, + new_variable_value, + evaluate_name, + cx, + ) + }) + }); + + set_value_task?.await?; + + this.update(&mut cx, |this, cx| this.refetch_existing_variables(cx))? + .await?; + + this.update(&mut cx, |this, cx| { + this.build_entries(false, true, cx); + }) + }) + .detach_and_log_err(cx); + } + + pub fn refetch_existing_variables(&mut self, cx: &mut ViewContext) -> Task> { + let mut scope_tasks = Vec::with_capacity(self.variables.len()); + + for ((stack_frame_id, scope_id), variable_containers) in self.variables.clone().into_iter() + { + let mut variable_tasks = Vec::with_capacity(variable_containers.len()); + + for variable_container in variable_containers { + let fetch_variables_task = self.dap_store.update(cx, |store, cx| { + store.variables(&self.client_id, variable_container.container_reference, cx) + }); + + variable_tasks.push(async move { + let depth = variable_container.depth; + let container_reference = variable_container.container_reference; + + anyhow::Ok( + fetch_variables_task + .await? + .into_iter() + .map(move |variable| VariableContainer { + container_reference, + variable, + depth, + }) + .collect::>(), + ) + }); + } + + scope_tasks.push(async move { + anyhow::Ok(( + (stack_frame_id, scope_id), + try_join_all(variable_tasks).await?, + )) + }); + } + + cx.spawn(|this, mut cx| async move { + let updated_variables = try_join_all(scope_tasks).await?; + + this.update(&mut cx, |this, cx| { + for (entry_id, variable_containers) in updated_variables { + for variables in variable_containers { + this.variables.insert(entry_id, variables); + } + } + + this.build_entries(false, true, cx); + }) + }) + } + + fn render_set_variable_editor( + &self, + depth: usize, + state: &SetVariableState, + cx: &mut ViewContext, + ) -> AnyElement { + div() + .h_4() + .size_full() + .on_action(cx.listener(Self::set_variable_value)) + .child( + ListItem::new(SharedString::from(state.name.clone())) + .indent_level(depth + 1) + .indent_step_size(px(20.)) + .child(self.set_variable_editor.clone()), + ) + .into_any_element() + } + + fn on_toggle_variable( + &mut self, + ix: usize, + variable_id: &SharedString, + variable_reference: u64, + has_children: bool, + disclosed: Option, + cx: &mut ViewContext, + ) { + if !has_children { + return; + } + + // if we already opened the variable/we already fetched it + // we can just toggle it because we already have the nested variable + if disclosed.unwrap_or(true) || self.fetched_variable_ids.contains(&variable_reference) { + return self.toggle_entry_collapsed(&variable_id, cx); + } + + let stack_frame_id = self.stack_frame_list.read(cx).current_stack_frame_id(); + + let Some(entries) = self.entries.get(&stack_frame_id) else { + return; + }; + + let Some(entry) = entries.get(ix) else { + return; + }; + + if let VariableListEntry::Variable { scope, depth, .. } = entry { + let variable_id = variable_id.clone(); + let scope = scope.clone(); + let depth = *depth; + + let fetch_variables_task = self.dap_store.update(cx, |store, cx| { + store.variables(&self.client_id, variable_reference, cx) + }); + + cx.spawn(|this, mut cx| async move { + let new_variables = fetch_variables_task.await?; + + this.update(&mut cx, |this, cx| { + let Some(variables) = this + .variables + .get_mut(&(stack_frame_id, scope.variables_reference)) + else { + return; + }; + + let position = variables.iter().position(|v| { + variable_entry_id(&scope, &v.variable, v.depth) == variable_id + }); + + if let Some(position) = position { + variables.splice( + position + 1..position + 1, + new_variables + .clone() + .into_iter() + .map(|variable| VariableContainer { + container_reference: variable_reference, + variable, + depth: depth + 1, + }), + ); + + this.fetched_variable_ids.insert(variable_reference); + } + + this.toggle_entry_collapsed(&variable_id, cx); + }) + }) + .detach_and_log_err(cx); + } + } + + #[allow(clippy::too_many_arguments)] + fn render_variable( + &self, + ix: usize, + parent_variables_reference: u64, + variable: &Variable, + scope: &Scope, + depth: usize, + has_children: bool, + cx: &mut ViewContext, + ) -> AnyElement { + let variable_reference = variable.variables_reference; + let variable_id = variable_entry_id(scope, variable, depth); + + let disclosed = has_children.then(|| { + self.open_entries + .binary_search(&variable_entry_id(scope, variable, depth)) + .is_ok() + }); + + div() + .id(variable_id.clone()) + .group("") + .h_4() + .size_full() + .child( + ListItem::new(variable_id.clone()) + .indent_level(depth + 1) + .indent_step_size(px(20.)) + .always_show_disclosure_icon(true) + .toggle(disclosed) + .on_toggle(cx.listener(move |this, _, cx| { + this.on_toggle_variable( + ix, + &variable_id, + variable_reference, + has_children, + disclosed, + cx, + ) + })) + .on_secondary_mouse_down(cx.listener({ + let scope = scope.clone(); + let variable = variable.clone(); + move |this, event: &MouseDownEvent, cx| { + this.deploy_variable_context_menu( + parent_variables_reference, + &scope, + &variable, + event.position, + cx, + ) + } + })) + .child( + h_flex() + .gap_1() + .text_ui_sm(cx) + .child(variable.name.clone()) + .child( + div() + .text_ui_xs(cx) + .text_color(cx.theme().colors().text_muted) + .child(variable.value.replace("\n", " ").clone()), + ), + ), + ) + .into_any() + } + + fn render_scope(&self, scope: &Scope, cx: &mut ViewContext) -> AnyElement { + let element_id = scope.variables_reference; + + let scope_id = scope_entry_id(scope); + let disclosed = self.open_entries.binary_search(&scope_id).is_ok(); + + div() + .id(element_id as usize) + .group("") + .flex() + .w_full() + .h_full() + .child( + ListItem::new(scope_id.clone()) + .indent_level(1) + .indent_step_size(px(20.)) + .always_show_disclosure_icon(true) + .toggle(disclosed) + .on_toggle( + cx.listener(move |this, _, cx| this.toggle_entry_collapsed(&scope_id, cx)), + ) + .child(div().text_ui(cx).w_full().child(scope.name.clone())), + ) + .into_any() + } +} + +impl FocusableView for VariableList { + fn focus_handle(&self, _: &gpui::AppContext) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for VariableList { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + div() + .size_full() + .on_action( + cx.listener(|this, _: &actions::Cancel, cx| this.cancel_set_variable_value(cx)), + ) + .child(list(self.list.clone()).gap_1_5().size_full()) + .children(self.open_context_menu.as_ref().map(|(menu, position, _)| { + deferred( + anchored() + .position(*position) + .anchor(gpui::AnchorCorner::TopLeft) + .child(menu.clone()), + ) + .with_priority(1) + })) + } +} + +pub fn variable_entry_id(scope: &Scope, variable: &Variable, depth: usize) -> SharedString { + SharedString::from(format!( + "variable-{}-{}-{}", + scope.variables_reference, variable.name, depth + )) +} + +fn scope_entry_id(scope: &Scope) -> SharedString { + SharedString::from(format!("scope-{}", scope.variables_reference)) +} diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index cfd9284f80765..ca9f264789b8e 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -52,6 +52,7 @@ linkify.workspace = true log.workspace = true lsp.workspace = true markdown.workspace = true +menu.workspace = true multi_buffer.workspace = true ordered-float.workspace = true parking_lot.workspace = true diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 502b70361b4f8..86715b3389743 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -330,6 +330,7 @@ gpui::actions!( SwitchSourceHeader, Tab, TabPrev, + ToggleBreakpoint, ToggleAutoSignatureHelp, ToggleGitBlame, ToggleGitBlameInline, diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 790a0a6a1eba7..f983dac0c2edb 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -592,6 +592,17 @@ impl DisplaySnapshot { .anchor_at(point.to_offset(self, bias), bias) } + pub fn display_point_to_breakpoint_anchor(&self, point: DisplayPoint) -> Anchor { + let bias = if point.is_zero() { + Bias::Right + } else { + Bias::Left + }; + + self.buffer_snapshot + .anchor_at(point.to_offset(self, bias), bias) + } + fn display_point_to_inlay_point(&self, point: DisplayPoint, bias: Bias) -> InlayPoint { let block_point = point.0; let wrap_point = self.block_snapshot.to_wrap_point(block_point); @@ -1035,7 +1046,7 @@ impl DisplaySnapshot { } } -#[derive(Copy, Clone, Default, Eq, Ord, PartialOrd, PartialEq)] +#[derive(Copy, Clone, Default, Eq, Ord, PartialOrd, PartialEq, Hash)] pub struct DisplayPoint(BlockPoint); impl Debug for DisplayPoint { diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 52e0ca2486d25..17f81d2a2f118 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -66,7 +66,7 @@ impl From for ElementId { } } -#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)] +#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq, Hash)] pub struct BlockPoint(pub Point); #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)] diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index e2818bcd96263..e9e7439738197 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -73,15 +73,16 @@ use fuzzy::{StringMatch, StringMatchCandidate}; use git::blame::GitBlame; use gpui::{ div, impl_actions, point, prelude::*, px, relative, size, uniform_list, Action, AnyElement, - AppContext, AsyncWindowContext, AvailableSpace, BackgroundExecutor, Bounds, ClipboardEntry, - ClipboardItem, Context, DispatchPhase, ElementId, EntityId, EventEmitter, FocusHandle, - FocusOutEvent, FocusableView, FontId, FontWeight, HighlightStyle, Hsla, InteractiveText, - KeyContext, ListSizingBehavior, Model, MouseButton, PaintQuad, ParentElement, Pixels, Render, - SharedString, Size, StrikethroughStyle, Styled, StyledText, Subscription, Task, TextStyle, - UTF16Selection, UnderlineStyle, UniformListScrollHandle, View, ViewContext, ViewInputHandler, - VisualContext, WeakFocusHandle, WeakView, WindowContext, + AppContext, AsyncWindowContext, AvailableSpace, BackgroundExecutor, Bounds, ClickEvent, + ClipboardEntry, ClipboardItem, Context, DispatchPhase, ElementId, EntityId, EventEmitter, + FocusHandle, FocusOutEvent, FocusableView, FontId, FontWeight, HighlightStyle, Hsla, + InteractiveText, KeyContext, ListSizingBehavior, Model, MouseButton, PaintQuad, ParentElement, + Pixels, Render, SharedString, Size, StrikethroughStyle, Styled, StyledText, Subscription, Task, + TextStyle, UTF16Selection, UnderlineStyle, UniformListScrollHandle, View, ViewContext, + ViewInputHandler, VisualContext, WeakFocusHandle, WeakView, WindowContext, }; use highlight_matching_bracket::refresh_matching_bracket_highlights; +use hover_links::{find_file, HoverLink, HoveredLinkState, InlayHighlight}; use hover_popover::{hide_hover, HoverState}; pub(crate) use hunk_diff::HoveredHunk; use hunk_diff::{diff_hunk_to_display, ExpandedHunks}; @@ -98,13 +99,13 @@ use language::{ }; use language::{point_to_lsp, BufferRow, CharClassifier, Runnable, RunnableRange}; use linked_editing_ranges::refresh_linked_ranges; +use project::dap_store::BreakpointEditAction; pub use proposed_changes_editor::{ ProposedChangesBuffer, ProposedChangesEditor, ProposedChangesEditorToolbar, }; use similar::{ChangeTag, TextDiff}; use task::{ResolvedTask, TaskTemplate, TaskVariables}; -use hover_links::{find_file, HoverLink, HoveredLinkState, InlayHighlight}; pub use lsp::CompletionContext; use lsp::{ CompletionItemKind, CompletionTriggerKind, DiagnosticSeverity, InsertTextFormat, @@ -122,6 +123,7 @@ use multi_buffer::{ use ordered_float::OrderedFloat; use parking_lot::{Mutex, RwLock}; use project::{ + dap_store::{Breakpoint, BreakpointKind, DapStore}, lsp_store::FormatTrigger, project_settings::{GitGutterSetting, ProjectSettings}, CodeAction, Completion, CompletionIntent, DocumentHighlight, InlayHint, Item, Location, @@ -135,6 +137,7 @@ use serde::{Deserialize, Serialize}; use settings::{update_settings_file, Settings, SettingsLocation, SettingsStore}; use smallvec::SmallVec; use snippet::Snippet; +use std::sync::Arc; use std::{ any::TypeId, borrow::Cow, @@ -145,7 +148,6 @@ use std::{ ops::{ControlFlow, Deref, DerefMut, Not as _, Range, RangeInclusive}, path::{Path, PathBuf}, rc::Rc, - sync::Arc, time::{Duration, Instant}, }; pub use sum_tree::Bias; @@ -265,6 +267,7 @@ impl InlayId { } } +pub enum DebugCurrentRowHighlight {} enum DiffRowHighlight {} enum DocumentHighlightRead {} enum DocumentHighlightWrite {} @@ -507,6 +510,7 @@ struct ResolvedTasks { templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>, position: Anchor, } + #[derive(Copy, Clone, Debug)] struct MultiBufferOffset(usize); #[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] @@ -638,6 +642,11 @@ pub struct Editor { expect_bounds_change: Option>, tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>, tasks_update_task: Option>, + dap_store: Option>, + /// Allow's a user to create a breakpoint by selecting this indicator + /// It should be None while a user is not hovering over the gutter + /// Otherwise it represents the point that the breakpoint will be shown + pub gutter_breakpoint_indicator: Option, previous_search_ranges: Option]>>, file_header_size: u32, breadcrumb_header: Option, @@ -1928,6 +1937,11 @@ impl Editor { None }; + let dap_store = if mode == EditorMode::Full { + project.as_ref().map(|project| project.read(cx).dap_store()) + } else { + None + }; let mut code_action_providers = Vec::new(); if let Some(project) = project.clone() { code_action_providers.push(Arc::new(project) as Arc<_>); @@ -2039,6 +2053,8 @@ impl Editor { blame_subscription: None, file_header_size, tasks: Default::default(), + dap_store, + gutter_breakpoint_indicator: None, _subscriptions: vec![ cx.observe(&buffer, Self::on_buffer_changed), cx.subscribe(&buffer, Self::on_buffer_event), @@ -2080,6 +2096,8 @@ impl Editor { this.git_blame_inline_enabled = true; this.start_git_blame_inline(false, cx); } + + this.go_to_active_debug_line(cx); } this.report_editor_event("open", None, cx); @@ -5452,17 +5470,251 @@ impl Editor { } } + /// Get all display points of breakpoints that will be rendered within editor + /// + /// This function is used to handle overlaps between breakpoints and Code action/runner symbol. + /// It's also used to set the color of line numbers with breakpoints to the breakpoint color. + /// TODO debugger: Use this function to color toggle symbols that house nested breakpoints + fn active_breakpoint_points( + &mut self, + cx: &mut ViewContext, + ) -> HashMap { + let mut breakpoint_display_points = HashMap::default(); + + let Some(dap_store) = self.dap_store.clone() else { + return breakpoint_display_points; + }; + + let snapshot = self.snapshot(cx); + + let opened_breakpoints = dap_store.read(cx).breakpoints(); + + if let Some(buffer) = self.buffer.read(cx).as_singleton() { + let buffer = buffer.read(cx); + + if let Some(project_path) = buffer.project_path(cx) { + if let Some(breakpoints) = opened_breakpoints.get(&project_path) { + for breakpoint in breakpoints { + let point = breakpoint.point_for_buffer(&buffer); + + breakpoint_display_points + .insert(point.to_display_point(&snapshot).row(), breakpoint.clone()); + } + }; + }; + + return breakpoint_display_points; + } + + let multi_buffer_snapshot = &snapshot.display_snapshot.buffer_snapshot; + let Some(project) = self.project.as_ref() else { + return breakpoint_display_points; + }; + + for excerpt_boundary in + multi_buffer_snapshot.excerpt_boundaries_in_range(Point::new(0, 0)..) + { + let info = excerpt_boundary.next.as_ref(); + + if let Some(info) = info { + let Some(excerpt_ranges) = + multi_buffer_snapshot.range_for_excerpt::(info.id) + else { + continue; + }; + + // To translate a breakpoint's position within a singular buffer to a multi buffer + // position we need to know it's excerpt starting location, it's position within + // the singular buffer, and if that position is within the excerpt's range. + let excerpt_head = excerpt_ranges + .start + .to_display_point(&snapshot.display_snapshot); + let buffer_range = info // Buffer lines being shown within the excerpt + .buffer + .summary_for_anchor::(&info.range.context.start) + ..info + .buffer + .summary_for_anchor::(&info.range.context.end); + + let Some(project_path) = project.read_with(cx, |this, cx| { + this.buffer_for_id(info.buffer_id, cx) + .and_then(|buffer| buffer.read_with(cx, |b, cx| b.project_path(cx))) + }) else { + continue; + }; + + if let Some(breakpoints) = opened_breakpoints.get(&project_path) { + for breakpoint in breakpoints { + let breakpoint_position = + breakpoint.point_for_buffer_snapshot(&info.buffer); + + if buffer_range.contains(&breakpoint_position) { + // Translated breakpoint position from singular buffer to multi buffer + let delta = breakpoint_position.row - buffer_range.start.row; + + let position = excerpt_head + DisplayPoint::new(DisplayRow(delta), 0); + + breakpoint_display_points.insert(position.row(), breakpoint.clone()); + } + } + }; + }; + } + + breakpoint_display_points + } + + fn render_breakpoint( + &self, + position: text::Anchor, + row: DisplayRow, + kind: &BreakpointKind, + cx: &mut ViewContext, + ) -> IconButton { + let color = if self + .gutter_breakpoint_indicator + .is_some_and(|gutter_bp| gutter_bp.row() == row) + { + Color::Hint + } else { + Color::Debugger + }; + + let icon = match &kind { + BreakpointKind::Standard => ui::IconName::DebugBreakpoint, + BreakpointKind::Log(_) => ui::IconName::DebugLogBreakpoint, + }; + let arc_kind = Arc::new(kind.clone()); + let arc_kind2 = arc_kind.clone(); + + IconButton::new(("breakpoint_indicator", row.0 as usize), icon) + .icon_size(IconSize::XSmall) + .size(ui::ButtonSize::None) + .icon_color(color) + .style(ButtonStyle::Transparent) + .on_click(cx.listener(move |editor, _e, cx| { + editor.focus(cx); + editor.edit_breakpoint_at_anchor( + position, + (*arc_kind).clone(), + BreakpointEditAction::Toggle, + cx, + ); + })) + .on_right_click(cx.listener(move |editor, event: &ClickEvent, cx| { + let source = editor + .buffer + .read(cx) + .snapshot(cx) + .anchor_at(Point::new(row.0, 0u32), Bias::Left); + + let clicked_point = event.down.position; + let focus_handle = editor.focus_handle.clone(); + let editor_weak = cx.view().downgrade(); + let second_weak = editor_weak.clone(); + + let log_message = arc_kind2.log_message(); + + let second_entry_msg = if log_message.is_some() { + "Edit Log Breakpoint" + } else { + "Toggle Log Breakpoint" + }; + + let context_menu = ui::ContextMenu::build(cx, move |menu, _cx| { + let anchor = position; + menu.on_blur_subscription(Subscription::new(|| {})) + .context(focus_handle) + .entry("Toggle Breakpoint", None, move |cx| { + if let Some(editor) = editor_weak.upgrade() { + editor.update(cx, |this, cx| { + this.edit_breakpoint_at_anchor( + anchor, + BreakpointKind::Standard, + BreakpointEditAction::Toggle, + cx, + ); + }) + } + }) + .entry(second_entry_msg, None, move |cx| { + if let Some(editor) = second_weak.clone().upgrade() { + let log_message = log_message.clone(); + editor.update(cx, |this, cx| { + let position = this.snapshot(cx).display_point_to_anchor( + DisplayPoint::new(row, 0), + Bias::Right, + ); + + let weak_editor = cx.view().downgrade(); + let bp_prompt = cx.new_view(|cx| { + BreakpointPromptEditor::new( + weak_editor, + anchor, + log_message, + cx, + ) + }); + + let height = bp_prompt.update(cx, |this, cx| { + this.prompt.update(cx, |prompt, cx| { + prompt.max_point(cx).row().0 + 1 + 2 + }) + }); + let cloned_prompt = bp_prompt.clone(); + let blocks = vec![BlockProperties { + style: BlockStyle::Sticky, + position, + height, + render: Box::new(move |cx| { + *cloned_prompt.read(cx).gutter_dimensions.lock() = + *cx.gutter_dimensions; + cloned_prompt.clone().into_any_element() + }), + disposition: BlockDisposition::Above, + priority: 0, + }]; + + let focus_handle = bp_prompt.focus_handle(cx); + cx.focus(&focus_handle); + + let block_ids = this.insert_blocks(blocks, None, cx); + bp_prompt.update(cx, |prompt, _| { + prompt.add_block_ids(block_ids); + }); + }); + } + }) + }); + + editor.mouse_context_menu = MouseContextMenu::pinned_to_editor( + editor, + source, + clicked_point, + context_menu, + cx, + ) + })) + } + fn render_run_indicator( &self, _style: &EditorStyle, is_active: bool, row: DisplayRow, + overlaps_breakpoint: bool, cx: &mut ViewContext, ) -> IconButton { + let color = if overlaps_breakpoint { + Color::Debugger + } else { + Color::Muted + }; + IconButton::new(("run_indicator", row.0 as usize), ui::IconName::Play) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .icon_color(color) .selected(is_active) .on_click(cx.listener(move |editor, _e, cx| { editor.focus(cx); @@ -6291,6 +6543,102 @@ impl Editor { } } + pub fn toggle_breakpoint(&mut self, _: &ToggleBreakpoint, cx: &mut ViewContext) { + let cursor_position: Point = self.selections.newest(cx).head(); + + // We Set the column position to zero so this function interacts correctly + // between calls by clicking on the gutter & using an action to toggle a + // breakpoint. Otherwise, toggling a breakpoint through an action wouldn't + // untoggle a breakpoint that was added through clicking on the gutter + let breakpoint_position = self + .snapshot(cx) + .display_snapshot + .buffer_snapshot + .breakpoint_anchor(Point::new(cursor_position.row, 0)) + .text_anchor; + + let project = self.project.clone(); + + let found_bp = maybe!({ + let buffer_id = breakpoint_position.buffer_id?; + let buffer = + project?.read_with(cx, |project, cx| project.buffer_for_id(buffer_id, cx))?; + let (buffer_snapshot, project_path) = ( + buffer.read(cx).snapshot(), + buffer.read(cx).project_path(cx)?, + ); + + let row = buffer_snapshot + .summary_for_anchor::(&breakpoint_position) + .row; + + let bp = self.dap_store.clone()?.read_with(cx, |store, _cx| { + store.breakpoint_at_row(row, &project_path, buffer_snapshot) + })?; + + Some((bp.active_position?, bp.kind)) + }); + + let edit_action = BreakpointEditAction::Toggle; + + if let Some((anchor, kind)) = found_bp { + self.edit_breakpoint_at_anchor(anchor, kind, edit_action, cx); + } else { + self.edit_breakpoint_at_anchor( + breakpoint_position, + BreakpointKind::Standard, + edit_action, + cx, + ); + } + } + + pub fn edit_breakpoint_at_anchor( + &mut self, + breakpoint_position: text::Anchor, + kind: BreakpointKind, + edit_action: BreakpointEditAction, + cx: &mut ViewContext, + ) { + let Some(project) = &self.project else { + return; + }; + + if self.dap_store.is_none() { + return; + } + + let Some(buffer_id) = breakpoint_position.buffer_id else { + return; + }; + + let Some(cache_position) = self.buffer.read_with(cx, |buffer, cx| { + buffer.buffer(buffer_id).map(|buffer| { + buffer + .read(cx) + .summary_for_anchor::(&breakpoint_position) + .row + }) + }) else { + return; + }; + + project.update(cx, |project, cx| { + project.toggle_breakpoint( + buffer_id, + Breakpoint { + cache_position, + active_position: Some(breakpoint_position), + kind, + }, + edit_action, + cx, + ); + }); + + cx.notify(); + } + fn gather_revert_changes( &mut self, selections: &[Selection], @@ -9544,6 +9892,32 @@ impl Editor { } } + pub fn go_to_line( + &mut self, + row: u32, + column: u32, + highlight_color: Option, + cx: &mut ViewContext, + ) { + let snapshot = self.snapshot(cx).display_snapshot; + let start = snapshot + .buffer_snapshot + .clip_point(Point::new(row, column), Bias::Left); + let end = start + Point::new(1, 0); + let start = snapshot.buffer_snapshot.anchor_before(start); + let end = snapshot.buffer_snapshot.anchor_before(end); + + self.clear_row_highlights::(); + self.highlight_rows::( + start..end, + highlight_color + .unwrap_or_else(|| cx.theme().colors().editor_highlighted_line_background), + true, + cx, + ); + self.request_autoscroll(Autoscroll::center(), cx); + } + pub fn go_to_definition( &mut self, _: &GoToDefinition, @@ -11336,6 +11710,31 @@ impl Editor { } } + pub fn go_to_active_debug_line(&mut self, cx: &mut ViewContext) { + let Some(dap_store) = self.dap_store.as_ref() else { + return; + }; + + let Some(buffer) = self.buffer.read(cx).as_singleton() else { + return; + }; + + let Some(project_path) = buffer.read_with(cx, |buffer, cx| buffer.project_path(cx)) else { + return; + }; + + if let Some((path, position)) = dap_store.read(cx).active_debug_line() { + if path == project_path { + self.go_to_line::( + position.row, + position.column, + Some(cx.theme().colors().editor_debugger_active_line_background), + cx, + ); + } + } + } + pub fn toggle_git_blame(&mut self, _: &ToggleGitBlame, cx: &mut ViewContext) { self.show_git_blame_gutter = !self.show_git_blame_gutter; @@ -14413,3 +14812,150 @@ fn check_multiline_range(buffer: &Buffer, range: Range) -> Range { range.start..range.start } } + +struct BreakpointPromptEditor { + pub(crate) prompt: View, + editor: WeakView, + breakpoint_anchor: text::Anchor, + block_ids: HashSet, + gutter_dimensions: Arc>, + _subscriptions: Vec, +} + +impl BreakpointPromptEditor { + const MAX_LINES: u8 = 4; + + fn new( + editor: WeakView, + breakpoint_anchor: text::Anchor, + log_message: Option>, + cx: &mut ViewContext, + ) -> Self { + let buffer = cx.new_model(|cx| { + Buffer::local( + log_message.map(|msg| msg.to_string()).unwrap_or_default(), + cx, + ) + }); + let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx)); + + let prompt = cx.new_view(|cx| { + let mut prompt = Editor::new( + EditorMode::AutoHeight { + max_lines: Self::MAX_LINES as usize, + }, + buffer, + None, + false, + cx, + ); + prompt.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); + // Since the prompt editors for all inline assistants are linked, + // always show the cursor (even when it isn't focused) because + // typing in one will make what you typed appear in all of them. + prompt.set_show_cursor_when_unfocused(true, cx); + prompt.set_placeholder_text( + "Message to log when breakpoint is hit. Expressions within {} are interpolated.", + cx, + ); + + prompt + }); + + Self { + prompt, + editor, + breakpoint_anchor, + gutter_dimensions: Arc::new(Mutex::new(GutterDimensions::default())), + block_ids: Default::default(), + _subscriptions: vec![], + } + } + + pub(crate) fn add_block_ids(&mut self, block_ids: Vec) { + self.block_ids.extend(block_ids) + } + + fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { + if let Some(editor) = self.editor.upgrade() { + let log_message = self + .prompt + .read(cx) + .buffer + .read(cx) + .as_singleton() + .expect("A multi buffer in breakpoint prompt isn't possible") + .read(cx) + .as_rope() + .to_string(); + + editor.update(cx, |editor, cx| { + editor.edit_breakpoint_at_anchor( + self.breakpoint_anchor, + BreakpointKind::Log(log_message.into()), + BreakpointEditAction::EditLogMessage, + cx, + ); + + editor.remove_blocks(self.block_ids.clone(), None, cx); + }); + } + } + + fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { + if let Some(editor) = self.editor.upgrade() { + editor.update(cx, |editor, cx| { + editor.remove_blocks(self.block_ids.clone(), None, cx); + }); + } + } + + fn render_prompt_editor(&self, cx: &mut ViewContext) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: if self.prompt.read(cx).read_only(cx) { + cx.theme().colors().text_disabled + } else { + cx.theme().colors().text + }, + font_family: settings.buffer_font.family.clone(), + font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_size: settings.buffer_font_size.into(), + font_weight: settings.buffer_font.weight, + line_height: relative(settings.buffer_line_height.value()), + ..Default::default() + }; + EditorElement::new( + &self.prompt, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + ..Default::default() + }, + ) + } +} + +impl Render for BreakpointPromptEditor { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let gutter_dimensions = *self.gutter_dimensions.lock(); + h_flex() + .key_context("Editor") + .bg(cx.theme().colors().editor_background) + .border_y_1() + .border_color(cx.theme().status().info_border) + .size_full() + .py(cx.line_height() / 2.5) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::cancel)) + .child(h_flex().w(gutter_dimensions.full_width() + (gutter_dimensions.margin / 2.0))) + .child(div().flex_1().child(self.render_prompt_editor(cx))) + } +} + +impl FocusableView for BreakpointPromptEditor { + fn focus_handle(&self, cx: &AppContext) -> FocusHandle { + self.prompt.focus_handle(cx) + } +} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 89fa23ab1caac..162b117e3bb34 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -48,6 +48,7 @@ use language::{ use lsp::DiagnosticSeverity; use multi_buffer::{Anchor, MultiBufferPoint, MultiBufferRow}; use project::{ + dap_store::{Breakpoint, BreakpointKind}, project_settings::{GitGutterSetting, ProjectSettings}, ProjectPath, }; @@ -437,7 +438,8 @@ impl EditorElement { register_action(view, cx, Editor::revert_file); register_action(view, cx, Editor::revert_selected_hunks); register_action(view, cx, Editor::apply_selected_diff_hunks); - register_action(view, cx, Editor::open_active_item_in_terminal) + register_action(view, cx, Editor::open_active_item_in_terminal); + register_action(view, cx, Editor::toggle_breakpoint); } fn register_key_listeners(&self, cx: &mut WindowContext, layout: &EditorLayout) { @@ -719,6 +721,17 @@ impl EditorElement { let gutter_hovered = gutter_hitbox.is_hovered(cx); editor.set_gutter_hovered(gutter_hovered, cx); + if gutter_hovered { + let point_for_position = + position_map.point_for_position(text_hitbox.bounds, event.position); + let position = point_for_position.previous_valid; + editor.gutter_breakpoint_indicator = Some(position); + } else { + editor.gutter_breakpoint_indicator = None; + } + + cx.notify(); + // Don't trigger hover popover if mouse is hovering over context menu if text_hitbox.is_hovered(cx) { let point_for_position = @@ -1626,6 +1639,64 @@ impl EditorElement { (offset_y, length) } + #[allow(clippy::too_many_arguments)] + fn layout_breakpoints( + &self, + line_height: Pixels, + range: Range, + scroll_pixel_position: gpui::Point, + gutter_dimensions: &GutterDimensions, + gutter_hitbox: &Hitbox, + rows_with_hunk_bounds: &HashMap>, + snapshot: &EditorSnapshot, + breakpoints: HashMap, + cx: &mut WindowContext, + ) -> Vec { + self.editor.update(cx, |editor, cx| { + if editor.dap_store.is_none() { + return Vec::new(); + }; + + breakpoints + .iter() + .filter_map(|(point, bp)| { + let row = MultiBufferRow { 0: point.0 }; + + if range.start > *point || range.end < *point { + return None; + } + + if snapshot.is_line_folded(row) { + return None; + } + + let backup_position = snapshot + .display_point_to_breakpoint_anchor(DisplayPoint::new(*point, 0)) + .text_anchor; + + let button = editor.render_breakpoint( + bp.active_position.unwrap_or(backup_position), + *point, + &bp.kind, + cx, + ); + + let button = prepaint_gutter_button( + button, + *point, + line_height, + gutter_dimensions, + scroll_pixel_position, + gutter_hitbox, + rows_with_hunk_bounds, + cx, + ); + Some(button) + }) + .collect_vec() + }) + } + #[allow(clippy::too_many_arguments)] fn layout_run_indicators( &self, @@ -1636,6 +1707,7 @@ impl EditorElement { gutter_hitbox: &Hitbox, rows_with_hunk_bounds: &HashMap>, snapshot: &EditorSnapshot, + breakpoints: &mut HashMap, cx: &mut WindowContext, ) -> Vec { self.editor.update(cx, |editor, cx| { @@ -1677,10 +1749,13 @@ impl EditorElement { return None; } } + + let display_row = multibuffer_point.to_display_point(snapshot).row(); let button = editor.render_run_indicator( &self.style, Some(display_row) == active_task_indicator_row, display_row, + breakpoints.remove(&display_row).is_some(), cx, ); @@ -1709,6 +1784,7 @@ impl EditorElement { gutter_dimensions: &GutterDimensions, gutter_hitbox: &Hitbox, rows_with_hunk_bounds: &HashMap>, + breakpoint_points: &mut HashMap, cx: &mut WindowContext, ) -> Option { let mut active = false; @@ -1725,8 +1801,15 @@ impl EditorElement { button = editor.render_code_actions_indicator(&self.style, row, active, cx); }); + let button = button?; + let button = if breakpoint_points.remove(&row).is_some() { + button.icon_color(Color::Debugger) + } else { + button + }; + let button = prepaint_gutter_button( - button?, + button, row, line_height, gutter_dimensions, @@ -1800,6 +1883,7 @@ impl EditorElement { relative_rows } + #[allow(clippy::too_many_arguments)] fn layout_line_numbers( &self, rows: Range, @@ -1807,6 +1891,7 @@ impl EditorElement { active_rows: &BTreeMap, newest_selection_head: Option, snapshot: &EditorSnapshot, + breakpoint_rows: &HashMap, cx: &mut WindowContext, ) -> Vec> { let include_line_numbers = snapshot.show_line_numbers.unwrap_or_else(|| { @@ -1846,7 +1931,9 @@ impl EditorElement { .map(|(ix, multibuffer_row)| { let multibuffer_row = multibuffer_row?; let display_row = DisplayRow(rows.start.0 + ix as u32); - let color = if active_rows.contains_key(&display_row) { + let color = if breakpoint_rows.contains_key(&display_row) { + cx.theme().colors().debugger_accent + } else if active_rows.contains_key(&display_row) { cx.theme().colors().editor_active_line_number } else { cx.theme().colors().editor_line_number @@ -3378,6 +3465,9 @@ impl EditorElement { } }); + for breakpoint in layout.breakpoints.iter_mut() { + breakpoint.paint(cx); + } for test_indicator in layout.test_indicators.iter_mut() { test_indicator.paint(cx); } @@ -4180,6 +4270,7 @@ fn prepaint_gutter_button( cx: &mut WindowContext<'_>, ) -> AnyElement { let mut button = button.into_any_element(); + let available_space = size( AvailableSpace::MinContent, AvailableSpace::Definite(line_height), @@ -4978,6 +5069,10 @@ impl Element for EditorElement { cx.set_view_id(self.editor.entity_id()); cx.set_focus_handle(&focus_handle); + let mut breakpoint_rows = self + .editor + .update(cx, |editor, cx| editor.active_breakpoint_points(cx)); + let rem_size = self.rem_size(cx); cx.with_rem_size(rem_size, |cx| { cx.with_text_style(Some(text_style), |cx| { @@ -5164,9 +5259,29 @@ impl Element for EditorElement { &active_rows, newest_selection_head, &snapshot, + &breakpoint_rows, cx, ); + // We add the gutter breakpoint indicator to breakpoint_rows after painting + // line numbers so we don't paint a line number debug accent color if a user + // has their mouse over that line when a breakpoint isn't there + let gutter_breakpoint_indicator = + self.editor.read(cx).gutter_breakpoint_indicator; + if let Some(gutter_breakpoint_point) = gutter_breakpoint_indicator { + breakpoint_rows + .entry(gutter_breakpoint_point.row()) + .or_insert(Breakpoint { + active_position: Some( + snapshot + .display_point_to_breakpoint_anchor(gutter_breakpoint_point) + .text_anchor, + ), + cache_position: 0, + kind: BreakpointKind::Standard, + }); + } + let mut gutter_fold_toggles = cx.with_element_namespace("gutter_fold_toggles", |cx| { self.layout_gutter_fold_toggles( @@ -5491,6 +5606,7 @@ impl Element for EditorElement { &gutter_dimensions, &gutter_hitbox, &rows_with_hunk_bounds, + &mut breakpoint_rows, cx, ); } @@ -5509,12 +5625,25 @@ impl Element for EditorElement { &gutter_hitbox, &rows_with_hunk_bounds, &snapshot, + &mut breakpoint_rows, cx, ) } else { Vec::new() }; + let breakpoints = self.layout_breakpoints( + line_height, + start_row..end_row, + scroll_pixel_position, + &gutter_dimensions, + &gutter_hitbox, + &rows_with_hunk_bounds, + &snapshot, + breakpoint_rows, + cx, + ); + self.layout_signature_help( &hitbox, content_origin, @@ -5626,6 +5755,7 @@ impl Element for EditorElement { selections, mouse_context_menu, test_indicators, + breakpoints, code_actions_indicator, gutter_fold_toggles, crease_trailers, @@ -5767,6 +5897,7 @@ pub struct EditorLayout { selections: Vec<(PlayerColor, Vec)>, code_actions_indicator: Option, test_indicators: Vec, + breakpoints: Vec, gutter_fold_toggles: Vec>, crease_trailers: Vec>, mouse_context_menu: Option, @@ -6376,6 +6507,7 @@ mod tests { let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); let element = EditorElement::new(&editor, style); let snapshot = window.update(cx, |editor, cx| editor.snapshot(cx)).unwrap(); + let breakpoint_rows = HashMap::default(); let layouts = cx .update_window(*window, |_, cx| { @@ -6385,6 +6517,7 @@ mod tests { &Default::default(), Some(DisplayPoint::new(DisplayRow(0), 0)), &snapshot, + &breakpoint_rows, cx, ) }) diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index b3f4cc813fe8a..0425ab53f77c7 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -1055,7 +1055,6 @@ impl SerializableItem for Editor { pane.update(&mut cx, |_, cx| { cx.new_view(|cx| { let mut editor = Editor::for_buffer(buffer, Some(project), cx); - editor.read_scroll_position_from_db(item_id, workspace_id, cx); editor }) diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index 95c4070b13a33..0ebe18d0a8d25 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -87,6 +87,7 @@ impl JsonLspAdapter { cx, ); let tasks_schema = task::TaskTemplates::generate_json_schema(); + let debug_schema = task::DebugTaskFile::generate_json_schema(); let tsconfig_schema = serde_json::Value::from_str(TSCONFIG_SCHEMA).unwrap(); let package_json_schema = serde_json::Value::from_str(PACKAGE_JSON_SCHEMA).unwrap(); @@ -125,8 +126,14 @@ impl JsonLspAdapter { paths::local_tasks_file_relative_path() ], "schema": tasks_schema, + }, + { + "fileMatch": [ + schema_file_match(paths::debug_tasks_file()), + paths::local_debug_file_relative_path() + ], + "schema": debug_schema, } - ] } }) diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index a239b5b77049e..ab00c5ffe266f 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -3166,6 +3166,16 @@ impl MultiBufferSnapshot { self.anchor_at(position, Bias::Right) } + pub fn breakpoint_anchor(&self, position: T) -> Anchor { + let bias = if position.to_offset(self) == 0usize { + Bias::Right + } else { + Bias::Left + }; + + self.anchor_at(position, bias) + } + pub fn anchor_at(&self, position: T, mut bias: Bias) -> Anchor { let offset = position.to_offset(self); if let Some((excerpt_id, buffer_id, buffer)) = self.as_singleton() { diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index 7f662d0325d1b..80485f149e9b7 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -146,6 +146,12 @@ pub fn tasks_file() -> &'static PathBuf { TASKS_FILE.get_or_init(|| config_dir().join("tasks.json")) } +/// Returns the path to the `debug.json` file. +pub fn debug_tasks_file() -> &'static PathBuf { + static DEBUG_TASKS_FILE: OnceLock = OnceLock::new(); + DEBUG_TASKS_FILE.get_or_init(|| config_dir().join("debug.json")) +} + /// Returns the path to the extensions directory. /// /// This is where installed extensions are stored. @@ -239,6 +245,14 @@ pub fn languages_dir() -> &'static PathBuf { LANGUAGES_DIR.get_or_init(|| support_dir().join("languages")) } +/// Returns the path to the debug adapters directory +/// +/// This is where debug adapters are downloaded to for DAPs that are built-in to Zed. +pub fn debug_adapters_dir() -> &'static PathBuf { + static DEBUG_ADAPTERS_DIR: OnceLock = OnceLock::new(); + DEBUG_ADAPTERS_DIR.get_or_init(|| support_dir().join("debug_adapters")) +} + /// Returns the path to the Copilot directory. pub fn copilot_dir() -> &'static PathBuf { static COPILOT_DIR: OnceLock = OnceLock::new(); @@ -282,3 +296,15 @@ pub fn local_tasks_file_relative_path() -> &'static Path { pub fn local_vscode_tasks_file_relative_path() -> &'static Path { Path::new(".vscode/tasks.json") } + +/// Returns the relative path to a `launch.json` file within a project. +pub fn local_debug_file_relative_path() -> &'static Path { + static LOCAL_LAUNCH_FILE_RELATIVE_PATH: OnceLock<&Path> = OnceLock::new(); + LOCAL_LAUNCH_FILE_RELATIVE_PATH.get_or_init(|| Path::new(".zed/debug.json")) +} + +/// Returns the relative path to a `.vscode/launch.json` file within a project. +pub fn local_vscode_launch_file_relative_path() -> &'static Path { + static LOCAL_VSCODE_LAUNCH_FILE_RELATIVE_PATH: OnceLock<&Path> = OnceLock::new(); + LOCAL_VSCODE_LAUNCH_FILE_RELATIVE_PATH.get_or_init(|| Path::new(".vscode/launch.json")) +} diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 1e7801e90888d..2795ecf1218cd 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -30,6 +30,8 @@ async-trait.workspace = true client.workspace = true clock.workspace = true collections.workspace = true +dap.workspace = true +dap_adapters.workspace = true dev_server_projects.workspace = true fs.workspace = true futures.workspace = true diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index aa86a8f7e256e..c6125b6f73a8b 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -1,4 +1,5 @@ use crate::{ + dap_store::DapStore, search::SearchQuery, worktree_store::{WorktreeStore, WorktreeStoreEvent}, Item, NoRepositoryError, ProjectPath, @@ -77,6 +78,7 @@ struct LocalBufferStore { local_buffer_ids_by_path: HashMap, local_buffer_ids_by_entry_id: HashMap, buffer_store: WeakModel, + dap_store: Model, worktree_store: Model, _subscription: Subscription, } @@ -757,6 +759,10 @@ impl LocalBufferStore { .clone() } + fn buffer_id_for_project_path(&self, project_path: &ProjectPath) -> Option<&BufferId> { + self.local_buffer_ids_by_path.get(project_path) + } + fn buffer_changed_file(&mut self, buffer: Model, cx: &mut AppContext) -> Option<()> { let file = File::from_dyn(buffer.read(cx).file())?; @@ -974,7 +980,11 @@ impl BufferStore { } /// Creates a buffer store, optionally retaining its buffers. - pub fn local(worktree_store: Model, cx: &mut ModelContext) -> Self { + pub fn local( + worktree_store: Model, + dap_store: Model, + cx: &mut ModelContext, + ) -> Self { let this = cx.weak_model(); Self { state: Box::new(cx.new_model(|cx| { @@ -993,6 +1003,7 @@ impl BufferStore { buffer_store: this, worktree_store: worktree_store.clone(), _subscription: subscription, + dap_store, } })), downstream_client: None, @@ -1028,6 +1039,21 @@ impl BufferStore { } } + pub fn dap_on_buffer_open( + &mut self, + project_path: &ProjectPath, + buffer: &Model, + cx: &mut ModelContext, + ) { + if let Some(local_store_model) = self.state.as_local() { + local_store_model.update(cx, |local_store, cx| { + local_store.dap_store.update(cx, |store, cx| { + store.on_open_buffer(&project_path, buffer, cx); + }); + }); + } + } + pub fn open_buffer( &mut self, project_path: ProjectPath, @@ -1035,6 +1061,8 @@ impl BufferStore { ) -> Task>> { let existing_buffer = self.get_by_path(&project_path, cx); if let Some(existing_buffer) = existing_buffer { + self.dap_on_buffer_open(&project_path, &existing_buffer, cx); + return Task::ready(Ok(existing_buffer)); } @@ -1063,10 +1091,11 @@ impl BufferStore { cx.spawn(move |this, mut cx| async move { let load_result = load_buffer.await; - *tx.borrow_mut() = Some(this.update(&mut cx, |this, _cx| { + *tx.borrow_mut() = Some(this.update(&mut cx, |this, cx| { // Record the fact that the buffer is no longer loading. this.loading_buffers_by_path.remove(&project_path); let buffer = load_result.map_err(Arc::new)?; + this.dap_on_buffer_open(&project_path, &buffer, cx); Ok(buffer) })?); anyhow::Ok(()) @@ -1230,6 +1259,16 @@ impl BufferStore { .map(|(path, rx)| (path, rx.clone())) } + pub fn buffer_id_for_project_path<'a>( + &'a self, + project_path: &'a ProjectPath, + cx: &'a AppContext, + ) -> Option<&'a BufferId> { + self.state + .as_local() + .and_then(move |state| state.read(cx).buffer_id_for_project_path(project_path)) + } + pub fn get_by_path(&self, path: &ProjectPath, cx: &AppContext) -> Option> { self.buffers().find_map(|buffer| { let file = File::from_dyn(buffer.read(cx).file())?; diff --git a/crates/project/src/dap_store.rs b/crates/project/src/dap_store.rs new file mode 100644 index 0000000000000..e401f54cae661 --- /dev/null +++ b/crates/project/src/dap_store.rs @@ -0,0 +1,1196 @@ +use crate::ProjectPath; +use anyhow::{anyhow, Context as _, Result}; +use collections::{HashMap, HashSet}; +use dap::client::{DebugAdapterClient, DebugAdapterClientId}; +use dap::messages::Message; +use dap::requests::{ + Attach, Completions, ConfigurationDone, Continue, Disconnect, Evaluate, Initialize, Launch, + LoadedSources, Modules, Next, Pause, Scopes, SetBreakpoints, SetExpression, SetVariable, + StackTrace, StepIn, StepOut, Terminate, TerminateThreads, Variables, +}; +use dap::{ + AttachRequestArguments, Capabilities, CompletionItem, CompletionsArguments, + ConfigurationDoneArguments, ContinueArguments, DisconnectArguments, EvaluateArguments, + EvaluateArgumentsContext, EvaluateResponse, InitializeRequestArguments, + InitializeRequestArgumentsPathFormat, LaunchRequestArguments, LoadedSourcesArguments, Module, + ModulesArguments, NextArguments, PauseArguments, Scope, ScopesArguments, + SetBreakpointsArguments, SetExpressionArguments, SetVariableArguments, Source, + SourceBreakpoint, StackFrame, StackTraceArguments, StartDebuggingRequestArguments, + StepInArguments, StepOutArguments, SteppingGranularity, TerminateArguments, + TerminateThreadsArguments, Variable, VariablesArguments, +}; +use dap_adapters::build_adapter; +use fs::Fs; +use gpui::{EventEmitter, Model, ModelContext, Task}; +use http_client::HttpClient; +use language::{Buffer, BufferSnapshot}; +use node_runtime::NodeRuntime; +use serde_json::{json, Value}; +use settings::WorktreeId; +use std::{ + collections::BTreeMap, + hash::{Hash, Hasher}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicUsize, Ordering::SeqCst}, + Arc, + }, +}; +use task::{DebugAdapterConfig, DebugRequestType}; +use text::Point; +use util::{merge_json_value_into, ResultExt}; + +pub enum DapStoreEvent { + DebugClientStarted(DebugAdapterClientId), + DebugClientStopped(DebugAdapterClientId), + DebugClientEvent { + client_id: DebugAdapterClientId, + message: Message, + }, +} + +pub enum DebugAdapterClientState { + Starting(Task>>), + Running(Arc), +} + +#[derive(Clone, Debug)] +pub struct DebugPosition { + pub row: u32, + pub column: u32, +} + +pub struct DapStore { + next_client_id: AtomicUsize, + clients: HashMap, + breakpoints: BTreeMap>, + capabilities: HashMap, + active_debug_line: Option<(ProjectPath, DebugPosition)>, + http_client: Option>, + node_runtime: Option, + fs: Arc, +} + +impl EventEmitter for DapStore {} + +impl DapStore { + pub fn new( + http_client: Option>, + node_runtime: Option, + fs: Arc, + cx: &mut ModelContext, + ) -> Self { + cx.on_app_quit(Self::shutdown_clients).detach(); + + Self { + active_debug_line: None, + clients: Default::default(), + breakpoints: Default::default(), + capabilities: HashMap::default(), + next_client_id: Default::default(), + http_client, + node_runtime, + fs, + } + } + + pub fn next_client_id(&self) -> DebugAdapterClientId { + DebugAdapterClientId(self.next_client_id.fetch_add(1, SeqCst)) + } + + pub fn running_clients(&self) -> impl Iterator> + '_ { + self.clients.values().filter_map(|state| match state { + DebugAdapterClientState::Starting(_) => None, + DebugAdapterClientState::Running(client) => Some(client.clone()), + }) + } + + pub fn client_by_id(&self, id: &DebugAdapterClientId) -> Option> { + self.clients.get(id).and_then(|state| match state { + DebugAdapterClientState::Starting(_) => None, + DebugAdapterClientState::Running(client) => Some(client.clone()), + }) + } + + pub fn capabilities_by_id(&self, client_id: &DebugAdapterClientId) -> Capabilities { + self.capabilities + .get(client_id) + .cloned() + .unwrap_or_default() + } + + pub fn merge_capabilities_for_client( + &mut self, + client_id: &DebugAdapterClientId, + other: &Capabilities, + cx: &mut ModelContext, + ) { + if let Some(capabilities) = self.capabilities.get_mut(client_id) { + *capabilities = capabilities.merge(other.clone()); + + cx.notify(); + } + } + + pub fn active_debug_line(&self) -> Option<(ProjectPath, DebugPosition)> { + self.active_debug_line.clone() + } + + pub fn set_active_debug_line( + &mut self, + project_path: &ProjectPath, + row: u32, + column: u32, + cx: &mut ModelContext, + ) { + self.active_debug_line = Some((project_path.clone(), DebugPosition { row, column })); + + cx.notify(); + } + + pub fn remove_active_debug_line(&mut self) { + self.active_debug_line.take(); + } + + pub fn breakpoints(&self) -> &BTreeMap> { + &self.breakpoints + } + + pub fn breakpoint_at_row( + &self, + row: u32, + project_path: &ProjectPath, + buffer_snapshot: BufferSnapshot, + ) -> Option { + let breakpoint_set = self.breakpoints.get(project_path)?; + + breakpoint_set + .iter() + .find(|bp| bp.point_for_buffer_snapshot(&buffer_snapshot).row == row) + .cloned() + } + + pub fn on_open_buffer( + &mut self, + project_path: &ProjectPath, + buffer: &Model, + cx: &mut ModelContext, + ) { + let entry = self.breakpoints.remove(project_path).unwrap_or_default(); + let mut set_bp: HashSet = HashSet::default(); + + let buffer = buffer.read(cx); + + for mut bp in entry.into_iter() { + bp.set_active_position(&buffer); + set_bp.insert(bp); + } + + self.breakpoints.insert(project_path.clone(), set_bp); + + cx.notify(); + } + + pub fn deserialize_breakpoints( + &mut self, + worktree_id: WorktreeId, + serialize_breakpoints: Vec, + ) { + for serialize_breakpoint in serialize_breakpoints { + self.breakpoints + .entry(ProjectPath { + worktree_id, + path: serialize_breakpoint.path.clone(), + }) + .or_default() + .insert(Breakpoint { + active_position: None, + cache_position: serialize_breakpoint.position.saturating_sub(1u32), + kind: serialize_breakpoint.kind, + }); + } + } + + pub fn sync_open_breakpoints_to_closed_breakpoints( + &mut self, + project_path: &ProjectPath, + buffer: &mut Buffer, + ) { + if let Some(breakpoint_set) = self.breakpoints.remove(project_path) { + let breakpoint_iter = breakpoint_set.into_iter().map(|mut bp| { + bp.cache_position = bp.point_for_buffer(&buffer).row; + bp.active_position = None; + bp + }); + + self.breakpoints.insert( + project_path.clone(), + breakpoint_iter.collect::>(), + ); + } + } + + pub fn start_client( + &mut self, + config: DebugAdapterConfig, + args: Option, + cx: &mut ModelContext, + ) { + let client_id = self.next_client_id(); + let adapter_delegate = DapAdapterDelegate::new( + self.http_client.clone(), + self.node_runtime.clone(), + self.fs.clone(), + ); + let start_client_task = cx.spawn(|this, mut cx| async move { + let dap_store = this.clone(); + let adapter = Arc::new( + build_adapter(&config) + .context("Creating debug adapter") + .log_err()?, + ); + let _ = adapter + .install_binary(&adapter_delegate) + .await + .context("Failed to install debug adapter binary") + .log_err()?; + + let binary = adapter + .fetch_binary(&adapter_delegate, &config) + .await + .context("Failed to get debug adapter binary") + .log_err()?; + + let mut request_args = json!({}); + if let Some(config_args) = config.initialize_args.clone() { + merge_json_value_into(config_args, &mut request_args); + } + + merge_json_value_into(adapter.request_args(&config), &mut request_args); + + if let Some(args) = args { + merge_json_value_into(args.configuration, &mut request_args); + } + + let transport_params = adapter.connect(&binary, &mut cx).await.log_err()?; + + let client = DebugAdapterClient::new( + client_id, + adapter.id(), + request_args, + config, + transport_params, + move |message, cx| { + dap_store + .update(cx, |_, cx| { + cx.emit(DapStoreEvent::DebugClientEvent { client_id, message }) + }) + .log_err(); + }, + &mut cx, + ) + .await + .log_err()?; + + this.update(&mut cx, |store, cx| { + let handle = store + .clients + .get_mut(&client_id) + .with_context(|| "Failed to find starting debug client")?; + + *handle = DebugAdapterClientState::Running(client.clone()); + + cx.emit(DapStoreEvent::DebugClientStarted(client_id)); + + anyhow::Ok(()) + }) + .log_err(); + + Some(client) + }); + + self.clients.insert( + client_id, + DebugAdapterClientState::Starting(start_client_task), + ); + } + + pub fn initialize( + &mut self, + client_id: &DebugAdapterClientId, + cx: &mut ModelContext, + ) -> Task> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Could not found client"))); + }; + + cx.spawn(|this, mut cx| async move { + let capabilities = client + .request::(InitializeRequestArguments { + client_id: Some("zed".to_owned()), + client_name: Some("Zed".to_owned()), + adapter_id: client.adapter_id(), + locale: Some("en-US".to_owned()), + path_format: Some(InitializeRequestArgumentsPathFormat::Path), + supports_variable_type: Some(true), + supports_variable_paging: Some(false), + supports_run_in_terminal_request: Some(false), + supports_memory_references: Some(true), + supports_progress_reporting: Some(false), + supports_invalidated_event: Some(false), + lines_start_at1: Some(false), + columns_start_at1: Some(false), + supports_memory_event: Some(false), + supports_args_can_be_interpreted_by_shell: Some(true), + supports_start_debugging_request: Some(true), + }) + .await?; + + this.update(&mut cx, |store, cx| { + store.capabilities.insert(client.id(), capabilities); + + cx.notify(); + })?; + + // send correct request based on adapter config + match client.config().request { + DebugRequestType::Launch => { + client + .request::(LaunchRequestArguments { + raw: client.request_args(), + }) + .await? + } + DebugRequestType::Attach => { + client + .request::(AttachRequestArguments { + raw: client.request_args(), + }) + .await? + } + } + + Ok(()) + }) + } + + pub fn modules( + &mut self, + client_id: &DebugAdapterClientId, + cx: &mut ModelContext, + ) -> Task>> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Client was not found"))); + }; + + let capabilities = self.capabilities_by_id(client_id); + + if !capabilities.supports_modules_request.unwrap_or_default() { + return Task::ready(Ok(Vec::default())); + } + + cx.spawn(|_, _| async move { + Ok(client + .request::(ModulesArguments { + start_module: None, + module_count: None, + }) + .await? + .modules) + }) + } + + pub fn loaded_sources( + &mut self, + client_id: &DebugAdapterClientId, + cx: &mut ModelContext, + ) -> Task>> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Client was not found"))); + }; + + let capabilities = self.capabilities_by_id(client_id); + + if !capabilities + .supports_loaded_sources_request + .unwrap_or_default() + { + return Task::ready(Ok(Vec::default())); + } + + cx.spawn(|_, _| async move { + Ok(client + .request::(LoadedSourcesArguments {}) + .await? + .sources) + }) + } + + pub fn stack_frames( + &mut self, + client_id: &DebugAdapterClientId, + thread_id: u64, + cx: &mut ModelContext, + ) -> Task>> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Client was not found"))); + }; + + cx.spawn(|_, _| async move { + Ok(client + .request::(StackTraceArguments { + thread_id, + start_frame: None, + levels: None, + format: None, + }) + .await? + .stack_frames) + }) + } + + pub fn scopes( + &mut self, + client_id: &DebugAdapterClientId, + stack_frame_id: u64, + cx: &mut ModelContext, + ) -> Task>> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Client was not found"))); + }; + + cx.spawn(|_, _| async move { + Ok(client + .request::(ScopesArguments { + frame_id: stack_frame_id, + }) + .await? + .scopes) + }) + } + + pub fn configuration_done( + &self, + client_id: &DebugAdapterClientId, + cx: &mut ModelContext, + ) -> Task> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Could not found client"))); + }; + + let capabilities = self.capabilities_by_id(client_id); + + cx.spawn(|_, _| async move { + let support_configuration_done_request = capabilities + .supports_configuration_done_request + .unwrap_or_default(); + + if support_configuration_done_request { + client + .request::(ConfigurationDoneArguments) + .await + } else { + Ok(()) + } + }) + } + + pub fn continue_thread( + &self, + client_id: &DebugAdapterClientId, + thread_id: u64, + cx: &mut ModelContext, + ) -> Task> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Could not found client"))); + }; + + cx.spawn(|_, _| async move { + client + .request::(ContinueArguments { + thread_id, + single_thread: Some(true), + }) + .await?; + + Ok(()) + }) + } + + pub fn step_over( + &self, + client_id: &DebugAdapterClientId, + thread_id: u64, + granularity: SteppingGranularity, + cx: &mut ModelContext, + ) -> Task> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Could not found client"))); + }; + + let capabilities = self.capabilities_by_id(client_id); + + let supports_single_thread_execution_requests = capabilities + .supports_single_thread_execution_requests + .unwrap_or_default(); + let supports_stepping_granularity = capabilities + .supports_stepping_granularity + .unwrap_or_default(); + + cx.spawn(|_, _| async move { + client + .request::(NextArguments { + thread_id, + granularity: supports_stepping_granularity.then(|| granularity), + single_thread: supports_single_thread_execution_requests.then(|| true), + }) + .await + }) + } + + pub fn step_in( + &self, + client_id: &DebugAdapterClientId, + thread_id: u64, + granularity: SteppingGranularity, + cx: &mut ModelContext, + ) -> Task> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Could not found client"))); + }; + + let capabilities = self.capabilities_by_id(client_id); + + let supports_single_thread_execution_requests = capabilities + .supports_single_thread_execution_requests + .unwrap_or_default(); + let supports_stepping_granularity = capabilities + .supports_stepping_granularity + .unwrap_or_default(); + + cx.spawn(|_, _| async move { + client + .request::(StepInArguments { + thread_id, + granularity: supports_stepping_granularity.then(|| granularity), + single_thread: supports_single_thread_execution_requests.then(|| true), + target_id: None, + }) + .await + }) + } + + pub fn step_out( + &self, + client_id: &DebugAdapterClientId, + thread_id: u64, + granularity: SteppingGranularity, + cx: &mut ModelContext, + ) -> Task> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Could not found client"))); + }; + + let capabilities = self.capabilities_by_id(client_id); + + let supports_single_thread_execution_requests = capabilities + .supports_single_thread_execution_requests + .unwrap_or_default(); + let supports_stepping_granularity = capabilities + .supports_stepping_granularity + .unwrap_or_default(); + + cx.spawn(|_, _| async move { + client + .request::(StepOutArguments { + thread_id, + granularity: supports_stepping_granularity.then(|| granularity), + single_thread: supports_single_thread_execution_requests.then(|| true), + }) + .await + }) + } + + pub fn variables( + &self, + client_id: &DebugAdapterClientId, + variables_reference: u64, + cx: &mut ModelContext, + ) -> Task>> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Could not found client"))); + }; + + cx.spawn(|_, _| async move { + Ok(client + .request::(VariablesArguments { + variables_reference, + filter: None, + start: None, + count: None, + format: None, + }) + .await? + .variables) + }) + } + + pub fn evaluate( + &self, + client_id: &DebugAdapterClientId, + stack_frame_id: u64, + expression: String, + context: EvaluateArgumentsContext, + cx: &mut ModelContext, + ) -> Task> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Could not found client"))); + }; + + cx.spawn(|_, _| async move { + client + .request::(EvaluateArguments { + expression: expression.clone(), + frame_id: Some(stack_frame_id), + context: Some(context), + format: None, + line: None, + column: None, + source: None, + }) + .await + }) + } + + pub fn completions( + &self, + client_id: &DebugAdapterClientId, + stack_frame_id: u64, + text: String, + completion_column: u64, + cx: &mut ModelContext, + ) -> Task>> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Could not found client"))); + }; + + cx.spawn(|_, _| async move { + Ok(client + .request::(CompletionsArguments { + frame_id: Some(stack_frame_id), + line: None, + text, + column: completion_column, + }) + .await? + .targets) + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn set_variable_value( + &self, + client_id: &DebugAdapterClientId, + stack_frame_id: u64, + variables_reference: u64, + name: String, + value: String, + evaluate_name: Option, + cx: &mut ModelContext, + ) -> Task> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Could not found client"))); + }; + + let supports_set_expression = self + .capabilities_by_id(client_id) + .supports_set_expression + .unwrap_or_default(); + + cx.spawn(|_, _| async move { + if let Some(evaluate_name) = supports_set_expression.then(|| evaluate_name).flatten() { + client + .request::(SetExpressionArguments { + expression: evaluate_name, + value, + frame_id: Some(stack_frame_id), + format: None, + }) + .await?; + } else { + client + .request::(SetVariableArguments { + variables_reference, + name, + value, + format: None, + }) + .await?; + } + + Ok(()) + }) + } + + pub fn pause_thread( + &mut self, + client_id: &DebugAdapterClientId, + thread_id: u64, + cx: &mut ModelContext, + ) -> Task> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Could not found client"))); + }; + + cx.spawn(|_, _| async move { client.request::(PauseArguments { thread_id }).await }) + } + + pub fn terminate_threads( + &mut self, + client_id: &DebugAdapterClientId, + thread_ids: Option>, + cx: &mut ModelContext, + ) -> Task> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Could not found client"))); + }; + + let capabilities = self.capabilities_by_id(client_id); + + if capabilities + .supports_terminate_threads_request + .unwrap_or_default() + { + cx.spawn(|_, _| async move { + client + .request::(TerminateThreadsArguments { thread_ids }) + .await + }) + } else { + self.shutdown_client(client_id, cx) + } + } + + pub fn disconnect_client( + &mut self, + client_id: &DebugAdapterClientId, + cx: &mut ModelContext, + ) -> Task> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Could not found client"))); + }; + + cx.spawn(|_, _| async move { + client + .request::(DisconnectArguments { + restart: Some(false), + terminate_debuggee: Some(true), + suspend_debuggee: Some(false), + }) + .await + }) + } + + pub fn restart( + &mut self, + client_id: &DebugAdapterClientId, + args: Option, + cx: &mut ModelContext, + ) -> Task> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Could not found client"))); + }; + + let restart_args = args.unwrap_or(Value::Null); + + cx.spawn(|_, _| async move { + client + .request::(DisconnectArguments { + restart: Some(true), + terminate_debuggee: Some(false), + suspend_debuggee: Some(false), + }) + .await?; + + match client.request_type() { + DebugRequestType::Launch => { + client + .request::(LaunchRequestArguments { raw: restart_args }) + .await? + } + DebugRequestType::Attach => { + client + .request::(AttachRequestArguments { raw: restart_args }) + .await? + } + } + + Ok(()) + }) + } + + pub fn shutdown_clients(&mut self, cx: &mut ModelContext) -> Task<()> { + let mut tasks = Vec::new(); + + for client_id in self.clients.keys().cloned().collect::>() { + tasks.push(self.shutdown_client(&client_id, cx)); + } + + cx.background_executor().spawn(async move { + futures::future::join_all(tasks).await; + }) + } + + pub fn shutdown_client( + &mut self, + client_id: &DebugAdapterClientId, + cx: &mut ModelContext, + ) -> Task> { + let Some(client) = self.clients.remove(&client_id) else { + return Task::ready(Err(anyhow!("Could not found client"))); + }; + + cx.emit(DapStoreEvent::DebugClientStopped(*client_id)); + + let capabilities = self.capabilities.remove(client_id); + + cx.notify(); + + cx.spawn(|_, _| async move { + let client = match client { + DebugAdapterClientState::Starting(task) => task.await, + DebugAdapterClientState::Running(client) => Some(client), + }; + + let Some(client) = client else { + return Ok(()); + }; + + if capabilities + .and_then(|c| c.supports_terminate_request) + .unwrap_or_default() + { + let _ = client + .request::(TerminateArguments { + restart: Some(false), + }) + .await; + } + + client.shutdown().await + }) + } + + pub fn toggle_breakpoint_for_buffer( + &mut self, + project_path: &ProjectPath, + breakpoint: Breakpoint, + buffer_path: PathBuf, + buffer_snapshot: BufferSnapshot, + edit_action: BreakpointEditAction, + cx: &mut ModelContext, + ) { + let breakpoint_set = self.breakpoints.entry(project_path.clone()).or_default(); + + match edit_action { + BreakpointEditAction::Toggle => { + if !breakpoint_set.remove(&breakpoint) { + breakpoint_set.insert(breakpoint); + } + } + BreakpointEditAction::EditLogMessage => { + breakpoint_set.remove(&breakpoint); + breakpoint_set.insert(breakpoint); + } + } + + cx.notify(); + + self.send_changed_breakpoints(project_path, buffer_path, buffer_snapshot, cx) + .detach(); + } + + pub fn send_breakpoints( + &self, + client_id: &DebugAdapterClientId, + absolute_file_path: Arc, + breakpoints: Vec, + cx: &mut ModelContext, + ) -> Task> { + let Some(client) = self.client_by_id(client_id) else { + return Task::ready(Err(anyhow!("Could not found client"))); + }; + + cx.spawn(|_, _| async move { + client + .request::(SetBreakpointsArguments { + source: Source { + path: Some(String::from(absolute_file_path.to_string_lossy())), + name: None, + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }, + breakpoints: Some(breakpoints), + source_modified: None, + lines: None, + }) + .await?; + + Ok(()) + }) + } + + pub fn send_changed_breakpoints( + &self, + project_path: &ProjectPath, + buffer_path: PathBuf, + buffer_snapshot: BufferSnapshot, + cx: &mut ModelContext, + ) -> Task<()> { + let clients = self.running_clients().collect::>(); + + if clients.is_empty() { + return Task::ready(()); + } + + let Some(breakpoints) = self.breakpoints.get(project_path) else { + return Task::ready(()); + }; + + let source_breakpoints = breakpoints + .iter() + .map(|bp| bp.source_for_snapshot(&buffer_snapshot)) + .collect::>(); + + let mut tasks = Vec::new(); + for client in clients { + tasks.push(self.send_breakpoints( + &client.id(), + Arc::from(buffer_path.clone()), + source_breakpoints.clone(), + cx, + )) + } + + cx.background_executor().spawn(async move { + futures::future::join_all(tasks).await; + }) + } +} + +type LogMessage = Arc; + +#[derive(Clone, Debug)] +pub enum BreakpointEditAction { + Toggle, + EditLogMessage, +} + +#[derive(Clone, Debug)] +pub enum BreakpointKind { + Standard, + Log(LogMessage), +} + +impl BreakpointKind { + pub fn to_int(&self) -> i32 { + match self { + BreakpointKind::Standard => 0, + BreakpointKind::Log(_) => 1, + } + } + + pub fn log_message(&self) -> Option { + match self { + BreakpointKind::Standard => None, + BreakpointKind::Log(message) => Some(message.clone()), + } + } +} + +impl PartialEq for BreakpointKind { + fn eq(&self, other: &Self) -> bool { + std::mem::discriminant(self) == std::mem::discriminant(other) + } +} + +impl Eq for BreakpointKind {} + +impl Hash for BreakpointKind { + fn hash(&self, state: &mut H) { + std::mem::discriminant(self).hash(state); + } +} + +#[derive(Clone, Debug)] +pub struct Breakpoint { + pub active_position: Option, + pub cache_position: u32, + pub kind: BreakpointKind, +} + +// Custom implementation for PartialEq, Eq, and Hash is done +// to get toggle breakpoint to solely be based on a breakpoint's +// location. Otherwise, a user can get in situation's where there's +// overlapping breakpoint's with them being aware. +impl PartialEq for Breakpoint { + fn eq(&self, other: &Self) -> bool { + match (&self.active_position, &other.active_position) { + (None, None) => self.cache_position == other.cache_position, + (None, Some(_)) => false, + (Some(_), None) => false, + (Some(self_position), Some(other_position)) => self_position == other_position, + } + } +} + +impl Eq for Breakpoint {} + +impl Hash for Breakpoint { + fn hash(&self, state: &mut H) { + if self.active_position.is_some() { + self.active_position.hash(state); + } else { + self.cache_position.hash(state); + } + } +} + +impl Breakpoint { + pub fn to_source_breakpoint(&self, buffer: &Buffer) -> SourceBreakpoint { + let line = self + .active_position + .map(|position| buffer.summary_for_anchor::(&position).row) + .unwrap_or(self.cache_position) as u64; + + SourceBreakpoint { + line, + condition: None, + hit_condition: None, + log_message: None, + column: None, + mode: None, + } + } + + pub fn set_active_position(&mut self, buffer: &Buffer) { + if self.active_position.is_none() { + self.active_position = + Some(buffer.breakpoint_anchor(Point::new(self.cache_position, 0))); + } + } + + pub fn point_for_buffer(&self, buffer: &Buffer) -> Point { + self.active_position + .map(|position| buffer.summary_for_anchor::(&position)) + .unwrap_or(Point::new(self.cache_position, 0)) + } + + pub fn point_for_buffer_snapshot(&self, buffer_snapshot: &BufferSnapshot) -> Point { + self.active_position + .map(|position| buffer_snapshot.summary_for_anchor::(&position)) + .unwrap_or(Point::new(self.cache_position, 0)) + } + + pub fn source_for_snapshot(&self, snapshot: &BufferSnapshot) -> SourceBreakpoint { + let line = self + .active_position + .map(|position| snapshot.summary_for_anchor::(&position).row) + .unwrap_or(self.cache_position) as u64; + + let log_message = match &self.kind { + BreakpointKind::Standard => None, + BreakpointKind::Log(log_message) => Some(log_message.clone().to_string()), + }; + + SourceBreakpoint { + line, + condition: None, + hit_condition: None, + log_message, + column: None, + mode: None, + } + } + + pub fn to_serialized(&self, buffer: Option<&Buffer>, path: Arc) -> SerializedBreakpoint { + match buffer { + Some(buffer) => SerializedBreakpoint { + position: self + .active_position + .map(|position| buffer.summary_for_anchor::(&position).row + 1u32) + .unwrap_or(self.cache_position), + path, + kind: self.kind.clone(), + }, + None => SerializedBreakpoint { + position: self.cache_position, + path, + kind: self.kind.clone(), + }, + } + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct SerializedBreakpoint { + pub position: u32, + pub path: Arc, + pub kind: BreakpointKind, +} + +impl SerializedBreakpoint { + pub fn to_source_breakpoint(&self) -> SourceBreakpoint { + let log_message = match &self.kind { + BreakpointKind::Standard => None, + BreakpointKind::Log(message) => Some(message.clone().to_string()), + }; + + SourceBreakpoint { + line: self.position as u64, + condition: None, + hit_condition: None, + log_message, + column: None, + mode: None, + } + } +} + +pub struct DapAdapterDelegate { + fs: Arc, + http_client: Option>, + node_runtime: Option, +} + +impl DapAdapterDelegate { + pub fn new( + http_client: Option>, + node_runtime: Option, + fs: Arc, + ) -> Self { + Self { + fs, + http_client, + node_runtime, + } + } +} + +impl dap::adapters::DapDelegate for DapAdapterDelegate { + fn http_client(&self) -> Option> { + self.http_client.clone() + } + + fn node_runtime(&self) -> Option { + self.node_runtime.clone() + } + + fn fs(&self) -> Arc { + self.fs.clone() + } +} diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 01e40ec6a0048..a970bd1c0f492 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -1,5 +1,6 @@ use crate::{ buffer_store::{BufferStore, BufferStoreEvent}, + dap_store::DapStore, deserialize_code_actions, environment::ProjectEnvironment, lsp_command::{self, *}, @@ -128,6 +129,7 @@ pub struct LocalLspStore { HashMap>>, supplementary_language_servers: HashMap)>, + dap_store: Model, prettier_store: Model, current_lsp_settings: HashMap, last_formatting_failure: Option, @@ -835,6 +837,7 @@ impl LspStore { pub fn new_local( buffer_store: Model, worktree_store: Model, + dap_store: Model, prettier_store: Model, environment: Model, languages: Arc, @@ -863,6 +866,7 @@ impl LspStore { buffers_being_formatted: Default::default(), last_formatting_failure: None, prettier_store, + dap_store, environment, http_client, fs, @@ -1067,6 +1071,14 @@ impl LspStore { self.register_buffer_with_language_servers(buffer, cx); cx.observe_release(buffer, |this, buffer, cx| { + if let Some(lsp_store) = this.as_local_mut() { + if let Some(project_path) = buffer.project_path(cx) { + lsp_store.dap_store.update(cx, |store, _cx| { + store.sync_open_breakpoints_to_closed_breakpoints(&project_path, buffer); + }); + }; + } + if let Some(file) = File::from_dyn(buffer.file()) { if file.is_local() { let uri = lsp::Url::from_file_path(file.abs_path(cx)).unwrap(); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 2692c711bf531..d8006fdd38227 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1,6 +1,7 @@ pub mod buffer_store; mod color_extractor; pub mod connection_manager; +pub mod dap_store; pub mod debounced_delay; pub mod lsp_command; pub mod lsp_ext_command; @@ -29,12 +30,20 @@ use client::{ TypedEnvelope, UserStore, }; use clock::ReplicaId; + +use dap::{ + client::{DebugAdapterClient, DebugAdapterClientId}, + debugger_settings::DebuggerSettings, + messages::Message, +}; + use collections::{BTreeSet, HashMap, HashSet}; +use dap_store::{Breakpoint, BreakpointEditAction, DapStore, DapStoreEvent, SerializedBreakpoint}; use debounced_delay::DebouncedDelay; pub use environment::ProjectEnvironment; use futures::{ channel::mpsc::{self, UnboundedReceiver}, - future::try_join_all, + future::{join_all, try_join_all}, StreamExt, }; @@ -77,7 +86,7 @@ use std::{ use task_store::TaskStore; use terminals::Terminals; use text::{Anchor, BufferId}; -use util::{paths::compare_paths, ResultExt as _}; +use util::{maybe, paths::compare_paths, ResultExt as _}; use worktree::{CreatedEntry, Snapshot, Traversal}; use worktree_store::{WorktreeStore, WorktreeStoreEvent}; @@ -133,6 +142,7 @@ pub struct Project { active_entry: Option, buffer_ordered_messages_tx: mpsc::UnboundedSender, languages: Arc, + dap_store: Model, client: Arc, join_project_response_message_id: u32, task_store: Model, @@ -226,6 +236,11 @@ pub enum Event { LocalSettingsUpdated(Result<(), InvalidSettingsError>), LanguageServerPrompt(LanguageServerPromptRequest), LanguageNotFound(Model), + DebugClientStopped(DebugAdapterClientId), + DebugClientEvent { + client_id: DebugAdapterClientId, + message: Message, + }, ActiveEntryChanged(Option), ActivateProjectPanel, WorktreeAdded, @@ -262,6 +277,11 @@ pub enum Event { SnippetEdit(BufferId, Vec<(lsp::Range, Snippet)>), } +pub enum DebugAdapterClientState { + Starting(Task>>), + Running(Arc), +} + #[derive(Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord)] pub struct ProjectPath { pub worktree_id: WorktreeId, @@ -589,7 +609,17 @@ impl Project { cx.subscribe(&worktree_store, Self::on_worktree_store_event) .detach(); - let buffer_store = cx.new_model(|cx| BufferStore::local(worktree_store.clone(), cx)); + let dap_store = cx.new_model(|cx| { + DapStore::new( + Some(client.http_client()), + Some(node.clone()), + fs.clone(), + cx, + ) + }); + + let buffer_store = cx + .new_model(|cx| BufferStore::local(worktree_store.clone(), dap_store.clone(), cx)); cx.subscribe(&buffer_store, Self::on_buffer_store_event) .detach(); @@ -626,10 +656,13 @@ impl Project { cx.subscribe(&settings_observer, Self::on_settings_observer_event) .detach(); + cx.subscribe(&dap_store, Self::on_dap_store_event).detach(); + let lsp_store = cx.new_model(|cx| { LspStore::new_local( buffer_store.clone(), worktree_store.clone(), + dap_store.clone(), prettier_store.clone(), environment.clone(), languages.clone(), @@ -659,6 +692,7 @@ impl Project { settings_observer, fs, ssh_client: None, + dap_store, buffers_needing_diff: Default::default(), git_diff_debouncer: DebouncedDelay::new(), terminals: Terminals { @@ -746,6 +780,15 @@ impl Project { }); cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach(); + let dap_store = cx.new_model(|cx| { + DapStore::new( + Some(client.http_client()), + Some(node.clone()), + fs.clone(), + cx, + ) + }); + cx.subscribe(&ssh, Self::on_ssh_event).detach(); cx.observe(&ssh, |_, _, cx| cx.notify()).detach(); @@ -755,6 +798,7 @@ impl Project { worktree_store, buffer_store, lsp_store, + dap_store, join_project_response_message_id: 0, client_state: ProjectClientState::Local, client_subscriptions: Vec::new(), @@ -905,6 +949,9 @@ impl Project { BufferStore::remote(worktree_store.clone(), client.clone().into(), remote_id, cx) })?; + let dap_store = + cx.new_model(|cx| DapStore::new(Some(client.http_client()), None, fs.clone(), cx))?; + let lsp_store = cx.new_model(|cx| { let mut lsp_store = LspStore::new_remote( buffer_store.clone(), @@ -962,6 +1009,8 @@ impl Project { cx.subscribe(&settings_observer, Self::on_settings_observer_event) .detach(); + cx.subscribe(&dap_store, Self::on_dap_store_event).detach(); + let mut this = Self { buffer_ordered_messages_tx: tx, buffer_store: buffer_store.clone(), @@ -987,6 +1036,7 @@ impl Project { replica_id, in_room: response.payload.dev_server_project_id.is_none(), }, + dap_store, buffers_needing_diff: Default::default(), git_diff_debouncer: DebouncedDelay::new(), terminals: Terminals { @@ -1126,6 +1176,195 @@ impl Project { } } + pub fn all_breakpoints( + &self, + as_abs_path: bool, + cx: &mut ModelContext, + ) -> HashMap, Vec> { + let mut all_breakpoints: HashMap, Vec> = Default::default(); + + let open_breakpoints = self.dap_store.read(cx).breakpoints(); + for (project_path, breakpoints) in open_breakpoints.iter() { + let buffer = maybe!({ + let buffer_store = self.buffer_store.read(cx); + let buffer_id = buffer_store.buffer_id_for_project_path(project_path, cx)?; + let buffer = self.buffer_for_id(*buffer_id, cx)?; + Some(buffer.read(cx)) + }); + + let Some(path) = maybe!({ + if as_abs_path { + let worktree = self.worktree_for_id(project_path.worktree_id, cx)?; + Some(Arc::from( + worktree + .read(cx) + .absolutize(&project_path.path) + .ok()? + .as_path(), + )) + } else { + Some(project_path.clone().path) + } + }) else { + continue; + }; + + all_breakpoints.entry(path).or_default().extend( + breakpoints + .into_iter() + .map(|bp| bp.to_serialized(buffer, project_path.clone().path)), + ); + } + + all_breakpoints + } + + pub fn send_breakpoints( + &self, + client_id: &DebugAdapterClientId, + cx: &mut ModelContext, + ) -> Task<()> { + let mut tasks = Vec::new(); + + for (abs_path, serialized_breakpoints) in self.all_breakpoints(true, cx) { + let source_breakpoints = serialized_breakpoints + .iter() + .map(|bp| bp.to_source_breakpoint()) + .collect::>(); + + tasks.push(self.dap_store.update(cx, |store, cx| { + store.send_breakpoints(client_id, abs_path, source_breakpoints, cx) + })); + } + + cx.background_executor().spawn(async move { + join_all(tasks).await; + }) + } + + pub fn start_debug_adapter_client_from_task( + &mut self, + debug_task: task::ResolvedTask, + cx: &mut ModelContext, + ) { + if let Some(adapter_config) = debug_task.debug_adapter_config() { + self.dap_store + .update(cx, |store, cx| store.start_client(adapter_config, None, cx)); + } + } + + /// Get all serialized breakpoints that belong to a buffer + /// + /// # Parameters + /// `buffer_id`: The buffer id to get serialized breakpoints of + /// `cx`: The context of the editor + /// + /// # Return + /// `None`: If the buffer associated with buffer id doesn't exist or this editor + /// doesn't belong to a project + /// + /// `(Path, Vec, + ) -> Option<(Arc, Vec)> { + let buffer = maybe!({ + let buffer_id = self + .buffer_store + .read(cx) + .buffer_id_for_project_path(project_path, cx)?; + Some(self.buffer_for_id(*buffer_id, cx)?.read(cx)) + }); + + let worktree_path = self + .worktree_for_id(project_path.worktree_id, cx)? + .read(cx) + .abs_path(); + + let breakpoints = self.dap_store.read(cx).breakpoints(); + + Some(( + worktree_path, + breakpoints + .get(&project_path)? + .iter() + .map(|bp| bp.to_serialized(buffer, project_path.path.clone())) + .collect(), + )) + } + + /// Serialize all breakpoints to save within workspace's database + /// + /// # Return + /// HashMap: + /// Key: A valid worktree path + /// Value: All serialized breakpoints that belong to a worktree + pub fn serialize_breakpoints( + &self, + cx: &ModelContext, + ) -> HashMap, Vec> { + let mut result: HashMap, Vec> = Default::default(); + + if !DebuggerSettings::get_global(cx).save_breakpoints { + return result; + } + + let breakpoints = self.dap_store.read(cx).breakpoints(); + for project_path in breakpoints.keys() { + if let Some((worktree_path, mut serialized_breakpoint)) = + self.serialize_breakpoints_for_project_path(&project_path, cx) + { + result + .entry(worktree_path.clone()) + .or_default() + .append(&mut serialized_breakpoint) + } + } + + result + } + + /// Sends updated breakpoint information of one file to all active debug adapters + /// + /// This function is called whenever a breakpoint is toggled, and it doesn't need + /// to send breakpoints from closed files because those breakpoints can't change + /// without opening a buffer. + pub fn toggle_breakpoint( + &self, + buffer_id: BufferId, + breakpoint: Breakpoint, + edit_action: BreakpointEditAction, + cx: &mut ModelContext, + ) { + let Some(buffer) = self.buffer_for_id(buffer_id, cx) else { + return; + }; + + let Some((project_path, buffer_path)) = maybe!({ + let project_path = buffer.read(cx).project_path(cx)?; + let worktree = self.worktree_for_id(project_path.clone().worktree_id, cx)?; + Some(( + project_path.clone(), + worktree.read(cx).absolutize(&project_path.path).ok()?, + )) + }) else { + return; + }; + + self.dap_store.update(cx, |store, cx| { + store.toggle_breakpoint_for_buffer( + &project_path, + breakpoint, + buffer_path, + buffer.read(cx).snapshot(), + edit_action, + cx, + ); + }); + } + #[cfg(any(test, feature = "test-support"))] pub async fn example( root_paths: impl IntoIterator, @@ -1216,6 +1455,10 @@ impl Project { project } + pub fn dap_store(&self) -> Model { + self.dap_store.clone() + } + pub fn lsp_store(&self) -> Model { self.lsp_store.clone() } @@ -1814,7 +2057,7 @@ impl Project { cx: &mut ModelContext, ) -> Task, AnyModel)>> { let task = self.open_buffer(path.clone(), cx); - cx.spawn(move |_, cx| async move { + cx.spawn(move |_project, cx| async move { let buffer = task.await?; let project_entry_id = buffer.read_with(&cx, |buffer, cx| { File::from_dyn(buffer.file()).and_then(|file| file.project_entry_id(cx)) @@ -2076,6 +2319,30 @@ impl Project { } } + fn on_dap_store_event( + &mut self, + _: Model, + event: &DapStoreEvent, + cx: &mut ModelContext, + ) { + match event { + DapStoreEvent::DebugClientStarted(client_id) => { + self.dap_store.update(cx, |store, cx| { + store.initialize(client_id, cx).detach_and_log_err(cx) + }); + } + DapStoreEvent::DebugClientStopped(client_id) => { + cx.emit(Event::DebugClientStopped(*client_id)); + } + DapStoreEvent::DebugClientEvent { client_id, message } => { + cx.emit(Event::DebugClientEvent { + client_id: *client_id, + message: message.clone(), + }); + } + } + } + fn on_lsp_store_event( &mut self, _: Model, @@ -3378,6 +3645,37 @@ impl Project { None } + pub fn project_path_for_absolute_path( + &self, + abs_path: &Path, + cx: &AppContext, + ) -> Option { + self.find_local_worktree(abs_path, cx) + .map(|(worktree, relative_path)| ProjectPath { + worktree_id: worktree.read(cx).id(), + path: relative_path.into(), + }) + } + + pub fn find_local_worktree( + &self, + abs_path: &Path, + cx: &AppContext, + ) -> Option<(Model, PathBuf)> { + let trees = self.worktrees(cx); + + for tree in trees { + if let Some(relative_path) = tree + .read(cx) + .as_local() + .and_then(|t| abs_path.strip_prefix(t.abs_path()).ok()) + { + return Some((tree.clone(), relative_path.into())); + } + } + None + } + pub fn get_workspace_root( &self, project_path: &ProjectPath, diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index 2eea6321a0776..7d1ec55027e8a 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -41,9 +41,10 @@ util.workspace = true workspace.workspace = true [dev-dependencies] +dap = { workspace = true } editor = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } -settings = { workspace = true, features = ["test-support"] } serde_json.workspace = true +settings = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/recent_projects/src/dev_servers.rs b/crates/recent_projects/src/dev_servers.rs index b80a011ca35e2..51dcab6c29540 100644 --- a/crates/recent_projects/src/dev_servers.rs +++ b/crates/recent_projects/src/dev_servers.rs @@ -1032,6 +1032,7 @@ pub async fn spawn_ssh_task( hide: HideStrategy::Never, env: Default::default(), shell: Default::default(), + program: None, }, cx, ) diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index d763d24234342..53508d0f9816f 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -744,6 +744,7 @@ impl Render for MatchTooltip { mod tests { use std::path::PathBuf; + use dap::debugger_settings::DebuggerSettings; use editor::Editor; use gpui::{TestAppContext, UpdateGlobal, WindowHandle}; use project::{project_settings::ProjectSettings, Project}; @@ -892,6 +893,7 @@ mod tests { crate::init(cx); editor::init(cx); workspace::init_settings(cx); + DebuggerSettings::register(cx); Project::init_settings(cx); state }) diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 96d769a50cae8..3be73d87dd9a8 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -5,6 +5,7 @@ use language::{proto::serialize_operation, Buffer, BufferEvent, LanguageRegistry use node_runtime::NodeRuntime; use project::{ buffer_store::{BufferStore, BufferStoreEvent}, + dap_store::DapStore, project_settings::SettingsObserver, search::SearchQuery, task_store::TaskStore, @@ -55,8 +56,11 @@ impl HeadlessProject { store.shared(SSH_PROJECT_ID, session.clone().into(), cx); store }); + + let dap_store = cx.new_model(|cx| DapStore::new(None, None, fs.clone(), cx)); let buffer_store = cx.new_model(|cx| { - let mut buffer_store = BufferStore::local(worktree_store.clone(), cx); + let mut buffer_store = + BufferStore::local(worktree_store.clone(), dap_store.clone(), cx); buffer_store.shared(SSH_PROJECT_ID, session.clone().into(), cx); buffer_store }); @@ -96,6 +100,7 @@ impl HeadlessProject { let mut lsp_store = LspStore::new_local( buffer_store.clone(), worktree_store.clone(), + dap_store.clone(), prettier_store.clone(), environment, languages.clone(), diff --git a/crates/sqlez/Cargo.toml b/crates/sqlez/Cargo.toml index 43626d7747e9e..ab3962038e133 100644 --- a/crates/sqlez/Cargo.toml +++ b/crates/sqlez/Cargo.toml @@ -15,6 +15,7 @@ futures.workspace = true indoc.workspace = true libsqlite3-sys = { version = "0.28", features = ["bundled"] } parking_lot.workspace = true +project.workspace = true smol.workspace = true sqlformat.workspace = true thread_local = "1.1.4" diff --git a/crates/sqlez/src/bindable.rs b/crates/sqlez/src/bindable.rs index 8cf4329f92989..3bb657ffbdc52 100644 --- a/crates/sqlez/src/bindable.rs +++ b/crates/sqlez/src/bindable.rs @@ -1,3 +1,4 @@ +use project::dap_store::BreakpointKind; use std::{ path::{Path, PathBuf}, sync::Arc, @@ -8,12 +9,14 @@ use util::paths::PathExt; use crate::statement::{SqlType, Statement}; +/// Define the number of columns that a type occupies in a query/database pub trait StaticColumnCount { fn column_count() -> usize { 1 } } +/// Bind values of different types to placeholders in a prepared SQL statement. pub trait Bind { fn bind(&self, statement: &Statement, start_index: i32) -> Result; } @@ -379,6 +382,40 @@ impl Column for () { } } +impl StaticColumnCount for BreakpointKind { + fn column_count() -> usize { + 1 + } +} + +impl Bind for BreakpointKind { + fn bind(&self, statement: &Statement, start_index: i32) -> anyhow::Result { + let next_index = statement.bind(&self.to_int(), start_index)?; + + match self { + BreakpointKind::Standard => { + statement.bind_null(next_index)?; + Ok(next_index + 1) + } + BreakpointKind::Log(message) => statement.bind(message, next_index), + } + } +} + +impl Column for BreakpointKind { + fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> { + let kind = statement.column_int(start_index)?; + match kind { + 0 => Ok((BreakpointKind::Standard, start_index + 2)), + 1 => { + let message = statement.column_text(start_index + 1)?.to_string(); + Ok((BreakpointKind::Log(message.into()), start_index + 1)) + } + _ => Err(anyhow::anyhow!("Invalid BreakpointKind discriminant")), + } + } +} + macro_rules! impl_tuple_row_traits { ( $($local:ident: $type:ident),+ ) => { impl<$($type: StaticColumnCount),+> StaticColumnCount for ($($type,)+) { diff --git a/crates/sqlez/src/statement.rs b/crates/sqlez/src/statement.rs index 462f902239f32..5bebfa84a6a90 100644 --- a/crates/sqlez/src/statement.rs +++ b/crates/sqlez/src/statement.rs @@ -9,9 +9,15 @@ use crate::bindable::{Bind, Column}; use crate::connection::Connection; pub struct Statement<'a> { + /// vector of pointers to the raw SQLite statement objects. + /// it holds the actual prepared statements that will be executed. raw_statements: Vec<*mut sqlite3_stmt>, + /// Index of the current statement being executed from the `raw_statements` vector. current_statement: usize, + /// A reference to the database connection. + /// This is used to execute the statements and check for errors. connection: &'a Connection, + ///Indicates that the `Statement` struct is tied to the lifetime of the SQLite statement phantom: PhantomData, } diff --git a/crates/task/Cargo.toml b/crates/task/Cargo.toml index 43e3060a4e485..7a22ea6157e4e 100644 --- a/crates/task/Cargo.toml +++ b/crates/task/Cargo.toml @@ -17,6 +17,7 @@ hex.workspace = true parking_lot.workspace = true schemars.workspace = true serde.workspace = true +serde_json.workspace = true serde_json_lenient.workspace = true sha2.workspace = true shellexpand.workspace = true diff --git a/crates/task/src/debug_format.rs b/crates/task/src/debug_format.rs new file mode 100644 index 0000000000000..dca06f15da99d --- /dev/null +++ b/crates/task/src/debug_format.rs @@ -0,0 +1,163 @@ +use schemars::{gen::SchemaSettings, JsonSchema}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::net::Ipv4Addr; +use util::ResultExt; + +use crate::{TaskTemplate, TaskTemplates, TaskType}; + +impl Default for DebugConnectionType { + fn default() -> Self { + DebugConnectionType::TCP(TCPHost::default()) + } +} + +/// Represents the host information of the debug adapter +#[derive(Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +pub struct TCPHost { + /// The port that the debug adapter is listening on + pub port: Option, + /// The host that the debug adapter is listening too + pub host: Option, + /// The delay in ms between starting and connecting to the debug adapter + pub delay: Option, +} + +/// Represents the type that will determine which request to call on the debug adapter +#[derive(Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +#[serde(rename_all = "lowercase")] +pub enum DebugRequestType { + /// Call the `launch` request on the debug adapter + #[default] + Launch, + /// Call the `attach` request on the debug adapter + Attach, +} + +/// The Debug adapter to use +#[derive(Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +#[serde(rename_all = "lowercase", tag = "kind")] +pub enum DebugAdapterKind { + /// Manually setup starting a debug adapter + /// The argument within is used to start the DAP + Custom(CustomArgs), + /// Use debugpy + Python, + /// Use vscode-php-debug + PHP, + /// Use vscode-js-debug + Javascript, + /// Use lldb + Lldb, +} + +/// Custom arguments used to setup a custom debugger +#[derive(Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +pub struct CustomArgs { + /// The connection that a custom debugger should use + #[serde(flatten)] + pub connection: DebugConnectionType, + /// The cli command used to start the debug adapter e.g. `python3`, `node` or the adapter binary + pub command: String, + /// The cli arguments used to start the debug adapter + pub args: Option>, + /// The cli envs used to start the debug adapter + pub envs: Option>, +} + +/// Represents the configuration for the debug adapter +#[derive(Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub struct DebugAdapterConfig { + /// Unique id of for the debug adapter, + /// that will be send with the `initialize` request + #[serde(flatten)] + pub kind: DebugAdapterKind, + /// The type of connection the adapter should use + /// The type of request that should be called on the debug adapter + #[serde(default)] + pub request: DebugRequestType, + /// The program that you trying to debug + pub program: String, + /// Additional initialization arguments to be sent on DAP initialization + pub initialize_args: Option, +} + +/// Represents the type of the debugger adapter connection +#[derive(Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +#[serde(rename_all = "lowercase", tag = "connection")] +pub enum DebugConnectionType { + /// Connect to the debug adapter via TCP + TCP(TCPHost), + /// Connect to the debug adapter via STDIO + STDIO, +} + +#[derive(Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub struct DebugTaskDefinition { + /// Name of the debug tasks + label: String, + /// Program to run the debugger on + program: String, + /// Launch | Request depending on the session the adapter should be ran as + #[serde(default)] + session_type: DebugRequestType, + /// The adapter to run + adapter: DebugAdapterKind, + /// Additional initialization arguments to be sent on DAP initialization + initialize_args: Option, +} + +impl DebugTaskDefinition { + fn to_zed_format(self) -> anyhow::Result { + let command = "".to_string(); + let task_type = TaskType::Debug(DebugAdapterConfig { + kind: self.adapter, + request: self.session_type, + program: self.program, + initialize_args: self.initialize_args, + }); + + let args: Vec = Vec::new(); + + Ok(TaskTemplate { + label: self.label, + command, + args, + task_type, + ..Default::default() + }) + } +} + +/// A group of Debug Tasks defined in a JSON file. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct DebugTaskFile(pub Vec); + +impl DebugTaskFile { + /// Generates JSON schema of Tasks JSON template format. + pub fn generate_json_schema() -> serde_json_lenient::Value { + let schema = SchemaSettings::draft07() + .with(|settings| settings.option_add_null_type = false) + .into_generator() + .into_root_schema_for::(); + + serde_json_lenient::to_value(schema).unwrap() + } +} + +impl TryFrom for TaskTemplates { + type Error = anyhow::Error; + + fn try_from(value: DebugTaskFile) -> Result { + let templates = value + .0 + .into_iter() + .filter_map(|debug_definition| debug_definition.to_zed_format().log_err()) + .collect(); + + Ok(Self(templates)) + } +} diff --git a/crates/task/src/lib.rs b/crates/task/src/lib.rs index 1687f8f6964e6..bf094775b7bb5 100644 --- a/crates/task/src/lib.rs +++ b/crates/task/src/lib.rs @@ -1,6 +1,7 @@ //! Baseline interface of Tasks in Zed: all tasks in Zed are intended to use those for implementing their own logic. #![deny(missing_docs)] +mod debug_format; pub mod static_source; mod task_template; mod vscode_format; @@ -13,7 +14,13 @@ use std::borrow::Cow; use std::path::PathBuf; use std::str::FromStr; -pub use task_template::{HideStrategy, RevealStrategy, TaskTemplate, TaskTemplates}; +pub use debug_format::{ + CustomArgs, DebugAdapterConfig, DebugAdapterKind, DebugConnectionType, DebugRequestType, + DebugTaskFile, TCPHost, +}; +pub use task_template::{ + HideStrategy, RevealStrategy, TaskModal, TaskTemplate, TaskTemplates, TaskType, +}; pub use vscode_format::VsCodeTaskFile; /// Task identifier, unique within the application. @@ -51,6 +58,8 @@ pub struct SpawnInTerminal { pub hide: HideStrategy, /// Which shell to use when spawning the task. pub shell: Shell, + /// Tells debug tasks which program to debug + pub program: Option, } /// A final form of the [`TaskTemplate`], that got resolved with a particualar [`TaskContext`] and now is ready to spawn the actual task. @@ -79,6 +88,29 @@ impl ResolvedTask { &self.original_task } + /// Get the task type that determines what this task is used for + /// And where is it shown in the UI + pub fn task_type(&self) -> TaskType { + self.original_task.task_type.clone() + } + + /// Get the configuration for the debug adapter that should be used for this task. + pub fn debug_adapter_config(&self) -> Option { + match self.original_task.task_type.clone() { + TaskType::Script => None, + TaskType::Debug(mut adapter_config) => { + adapter_config.program = match &self.resolved { + None => adapter_config.program, + Some(spawn_in_terminal) => spawn_in_terminal + .program + .clone() + .unwrap_or(adapter_config.program), + }; + Some(adapter_config) + } + } + } + /// Variables that were substituted during the task template resolution. pub fn substituted_variables(&self) -> &HashSet { &self.substituted_variables diff --git a/crates/task/src/static_source.rs b/crates/task/src/static_source.rs index 48f8fdfa01073..8b2c6563110f7 100644 --- a/crates/task/src/static_source.rs +++ b/crates/task/src/static_source.rs @@ -78,6 +78,7 @@ impl TrackedFile { cx.background_executor() .spawn({ let parsed_contents = parsed_contents.clone(); + async move { while let Some(new_contents) = tracker.next().await { if Arc::strong_count(&parsed_contents) == 1 { @@ -94,6 +95,7 @@ impl TrackedFile { let Some(new_contents) = new_contents.try_into().log_err() else { continue; }; + let mut contents = parsed_contents.write(); if *contents != new_contents { *contents = new_contents; @@ -108,9 +110,7 @@ impl TrackedFile { } }) .detach_and_log_err(cx); - Self { - parsed_contents: Default::default(), - } + Self { parsed_contents } } } diff --git a/crates/task/src/task_template.rs b/crates/task/src/task_template.rs index 2bf40f52ae1a5..b599c68661ea6 100644 --- a/crates/task/src/task_template.rs +++ b/crates/task/src/task_template.rs @@ -8,8 +8,8 @@ use sha2::{Digest, Sha256}; use util::{truncate_and_remove_front, ResultExt}; use crate::{ - ResolvedTask, Shell, SpawnInTerminal, TaskContext, TaskId, VariableName, - ZED_VARIABLE_NAME_PREFIX, + debug_format::DebugAdapterConfig, ResolvedTask, Shell, SpawnInTerminal, TaskContext, TaskId, + VariableName, ZED_VARIABLE_NAME_PREFIX, }; /// A template definition of a Zed task to run. @@ -51,6 +51,9 @@ pub struct TaskTemplate { /// * `on_success` — hide the terminal tab on task success only, otherwise behaves similar to `always`. #[serde(default)] pub hide: HideStrategy, + /// If this task should start a debugger or not + #[serde(default)] + pub task_type: TaskType, /// Represents the tags which this template attaches to. Adding this removes this task from other UI. #[serde(default)] pub tags: Vec, @@ -59,6 +62,68 @@ pub struct TaskTemplate { pub shell: Shell, } +/// Represents the type of task that is being ran +#[derive(Default, Deserialize, Serialize, Eq, PartialEq, JsonSchema, Clone, Debug)] +#[serde(rename_all = "snake_case", tag = "type")] +pub enum TaskType { + /// Act like a typically task that runs commands + #[default] + Script, + /// This task starts the debugger for a language + Debug(DebugAdapterConfig), +} + +#[cfg(test)] +mod deserialization_tests { + use crate::DebugAdapterKind; + + use super::*; + use serde_json::json; + + #[test] + fn deserialize_task_type_script() { + let json = json!({"type": "script"}); + + let task_type: TaskType = + serde_json::from_value(json).expect("Failed to deserialize TaskType::Script"); + assert_eq!(task_type, TaskType::Script); + } + + #[test] + fn deserialize_task_type_debug() { + let adapter_config = DebugAdapterConfig { + kind: DebugAdapterKind::Python, + request: crate::DebugRequestType::Launch, + program: "main".to_string(), + initialize_args: None, + }; + let json = json!({ + "type": "debug", + "kind": "python", + "request": "launch", + "program": "main" + + }); + + let task_type: TaskType = + serde_json::from_value(json).expect("Failed to deserialize TaskType::Debug"); + if let TaskType::Debug(config) = task_type { + assert_eq!(config, adapter_config); + } else { + panic!("Expected TaskType::Debug"); + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +/// The type of task modal to spawn +pub enum TaskModal { + /// Show regular tasks + ScriptModal, + /// Show debug tasks + DebugModal, +} + /// What to do with the terminal pane and tab, after the command was started. #[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -107,7 +172,9 @@ impl TaskTemplate { /// Every [`ResolvedTask`] gets a [`TaskId`], based on the `id_base` (to avoid collision with various task sources), /// and hashes of its template and [`TaskContext`], see [`ResolvedTask`] fields' documentation for more details. pub fn resolve_task(&self, id_base: &str, cx: &TaskContext) -> Option { - if self.label.trim().is_empty() || self.command.trim().is_empty() { + if self.label.trim().is_empty() + || (self.command.trim().is_empty() && matches!(self.task_type, TaskType::Script)) + { return None; } @@ -174,6 +241,16 @@ impl TaskTemplate { &mut substituted_variables, )?; + let program = match &self.task_type { + TaskType::Script => None, + TaskType::Debug(adapter_config) => Some(substitute_all_template_variables_in_str( + &adapter_config.program, + &task_variables, + &variable_names, + &mut substituted_variables, + )?), + }; + let task_hash = to_hex_hash(self) .context("hashing task template") .log_err()?; @@ -228,6 +305,7 @@ impl TaskTemplate { reveal: self.reveal, hide: self.hide, shell: self.shell.clone(), + program, }), }) } diff --git a/crates/tasks_ui/src/lib.rs b/crates/tasks_ui/src/lib.rs index 38b15403e27ed..133f27eb40efc 100644 --- a/crates/tasks_ui/src/lib.rs +++ b/crates/tasks_ui/src/lib.rs @@ -3,6 +3,7 @@ use editor::{tasks::task_context, Editor}; use gpui::{AppContext, Task as AsyncTask, ViewContext, WindowContext}; use modal::TasksModal; use project::{Location, WorktreeId}; +use task::TaskModal; use workspace::tasks::schedule_task; use workspace::{tasks::schedule_resolved_task, Workspace}; @@ -74,7 +75,7 @@ pub fn init(cx: &mut AppContext) { ); } } else { - toggle_modal(workspace, cx).detach(); + toggle_modal(workspace, TaskModal::ScriptModal, cx).detach(); }; }); }, @@ -85,11 +86,15 @@ pub fn init(cx: &mut AppContext) { fn spawn_task_or_modal(workspace: &mut Workspace, action: &Spawn, cx: &mut ViewContext) { match &action.task_name { Some(name) => spawn_task_with_name(name.clone(), cx).detach_and_log_err(cx), - None => toggle_modal(workspace, cx).detach(), + None => toggle_modal(workspace, TaskModal::ScriptModal, cx).detach(), } } -fn toggle_modal(workspace: &mut Workspace, cx: &mut ViewContext<'_, Workspace>) -> AsyncTask<()> { +pub fn toggle_modal( + workspace: &mut Workspace, + task_type: TaskModal, + cx: &mut ViewContext<'_, Workspace>, +) -> AsyncTask<()> { let task_store = workspace.project().read(cx).task_store().clone(); let workspace_handle = workspace.weak_handle(); let can_open_modal = workspace.project().update(cx, |project, cx| { @@ -102,7 +107,13 @@ fn toggle_modal(workspace: &mut Workspace, cx: &mut ViewContext<'_, Workspace>) workspace .update(&mut cx, |workspace, cx| { workspace.toggle_modal(cx, |cx| { - TasksModal::new(task_store.clone(), task_context, workspace_handle, cx) + TasksModal::new( + task_store.clone(), + task_context, + workspace_handle, + task_type, + cx, + ) }) }) .ok(); diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index f11f58e010cdc..468622ab63367 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -9,7 +9,7 @@ use gpui::{ }; use picker::{highlighted_match_with_paths::HighlightedText, Picker, PickerDelegate}; use project::{task_store::TaskStore, TaskSourceKind}; -use task::{ResolvedTask, TaskContext, TaskId, TaskTemplate}; +use task::{ResolvedTask, TaskContext, TaskId, TaskModal, TaskTemplate, TaskType}; use ui::{ div, h_flex, v_flex, ActiveTheme, Button, ButtonCommon, ButtonSize, Clickable, Color, FluentBuilder as _, Icon, IconButton, IconButtonShape, IconName, IconSize, IntoElement, @@ -73,6 +73,8 @@ pub(crate) struct TasksModalDelegate { prompt: String, task_context: TaskContext, placeholder_text: Arc, + /// If this delegate is responsible for running a scripting task or a debugger + task_modal_type: TaskModal, } impl TasksModalDelegate { @@ -80,6 +82,7 @@ impl TasksModalDelegate { task_store: Model, task_context: TaskContext, workspace: WeakView, + task_modal_type: TaskModal, ) -> Self { Self { task_store, @@ -92,6 +95,7 @@ impl TasksModalDelegate { prompt: String::default(), task_context, placeholder_text: Arc::from("Find a task, or run a command"), + task_modal_type, } } @@ -142,11 +146,12 @@ impl TasksModal { task_store: Model, task_context: TaskContext, workspace: WeakView, + task_modal_type: TaskModal, cx: &mut ViewContext, ) -> Self { let picker = cx.new_view(|cx| { Picker::uniform_list( - TasksModalDelegate::new(task_store, task_context, workspace), + TasksModalDelegate::new(task_store, task_context, workspace, task_modal_type), cx, ) }); @@ -203,11 +208,12 @@ impl PickerDelegate for TasksModalDelegate { query: String, cx: &mut ViewContext>, ) -> Task<()> { + let task_type = self.task_modal_type.clone(); cx.spawn(move |picker, mut cx| async move { let Some(candidates) = picker .update(&mut cx, |picker, cx| { match &mut picker.delegate.candidates { - Some(candidates) => string_match_candidates(candidates.iter()), + Some(candidates) => string_match_candidates(candidates.iter(), task_type), None => { let Ok((worktree, location)) = picker.delegate.workspace.update(cx, |workspace, cx| { @@ -241,7 +247,8 @@ impl PickerDelegate for TasksModalDelegate { let mut new_candidates = used; new_candidates.extend(current); - let match_candidates = string_match_candidates(new_candidates.iter()); + let match_candidates = + string_match_candidates(new_candidates.iter(), task_type); let _ = picker.delegate.candidates.insert(new_candidates); match_candidates } @@ -304,7 +311,20 @@ impl PickerDelegate for TasksModalDelegate { self.workspace .update(cx, |workspace, cx| { - schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx); + match task.task_type() { + TaskType::Script => schedule_resolved_task( + workspace, + task_source_kind, + task, + omit_history_entry, + cx, + ), + // TODO: Should create a schedule_resolved_debug_task function + // This would allow users to access to debug history and other issues + TaskType::Debug(_) => workspace.project().update(cx, |project, cx| { + project.start_debug_adapter_client_from_task(task, cx) + }), + }; }) .ok(); cx.emit(DismissEvent); @@ -443,9 +463,23 @@ impl PickerDelegate for TasksModalDelegate { let Some((task_source_kind, task)) = self.spawn_oneshot() else { return; }; + self.workspace .update(cx, |workspace, cx| { - schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx); + match task.task_type() { + TaskType::Script => schedule_resolved_task( + workspace, + task_source_kind, + task, + omit_history_entry, + cx, + ), + // TODO: Should create a schedule_resolved_debug_task function + // This would allow users to access to debug history and other issues + TaskType::Debug(_) => workspace.project().update(cx, |project, cx| { + project.start_debug_adapter_client_from_task(task, cx) + }), + }; }) .ok(); cx.emit(DismissEvent); @@ -554,9 +588,14 @@ impl PickerDelegate for TasksModalDelegate { fn string_match_candidates<'a>( candidates: impl Iterator + 'a, + task_modal_type: TaskModal, ) -> Vec { candidates .enumerate() + .filter(|(_, (_, candidate))| match candidate.task_type() { + TaskType::Script => task_modal_type == TaskModal::ScriptModal, + TaskType::Debug(_) => task_modal_type == TaskModal::DebugModal, + }) .map(|(index, (_, candidate))| StringMatchCandidate { id: index, char_bag: candidate.resolved_label.chars().collect(), diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 7d95613804414..cfe5638af34cb 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -81,7 +81,7 @@ impl TerminalPanel { workspace.project().clone(), Default::default(), None, - NewTerminal.boxed_clone(), + Some(NewTerminal.boxed_clone()), cx, ); pane.set_can_split(false, cx); diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index b1e6be8a1cb74..637352db5e8bf 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -2173,7 +2173,7 @@ impl BufferSnapshot { }) } - fn summary_for_anchor(&self, anchor: &Anchor) -> D + pub fn summary_for_anchor(&self, anchor: &Anchor) -> D where D: TextDimension, { @@ -2276,6 +2276,18 @@ impl BufferSnapshot { self.anchor_at_offset(position.to_offset(self), bias) } + pub fn breakpoint_anchor(&self, position: T) -> Anchor { + let offset = position.to_offset(self); + + let bias = if offset == 0usize { + Bias::Right + } else { + Bias::Left + }; + + self.anchor_at_offset(offset, bias) + } + fn anchor_at_offset(&self, offset: usize, bias: Bias) -> Anchor { if bias == Bias::Left && offset == 0 { Anchor::MIN diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index 49c216c0e07e7..69517ed5ef776 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -49,6 +49,7 @@ impl ThemeColors { icon_disabled: neutral().light().step_9(), icon_placeholder: neutral().light().step_10(), icon_accent: blue().light().step_11(), + debugger_accent: red().light().step_10(), status_bar_background: neutral().light().step_2(), title_bar_background: neutral().light().step_2(), title_bar_inactive_background: neutral().light().step_3(), @@ -72,6 +73,7 @@ impl ThemeColors { editor_subheader_background: neutral().light().step_2(), editor_active_line_background: neutral().light_alpha().step_3(), editor_highlighted_line_background: neutral().light_alpha().step_3(), + editor_debugger_active_line_background: yellow().dark_alpha().step_3(), editor_line_number: neutral().light().step_10(), editor_active_line_number: neutral().light().step_11(), editor_invisible: neutral().light().step_10(), @@ -152,6 +154,7 @@ impl ThemeColors { icon_disabled: neutral().dark().step_9(), icon_placeholder: neutral().dark().step_10(), icon_accent: blue().dark().step_11(), + debugger_accent: red().light().step_10(), status_bar_background: neutral().dark().step_2(), title_bar_background: neutral().dark().step_2(), title_bar_inactive_background: neutral().dark().step_3(), @@ -174,7 +177,8 @@ impl ThemeColors { editor_gutter_background: neutral().dark().step_1(), editor_subheader_background: neutral().dark().step_3(), editor_active_line_background: neutral().dark_alpha().step_3(), - editor_highlighted_line_background: neutral().dark_alpha().step_4(), + editor_highlighted_line_background: yellow().dark_alpha().step_4(), + editor_debugger_active_line_background: yellow().dark_alpha().step_3(), editor_line_number: neutral().dark_alpha().step_10(), editor_active_line_number: neutral().dark_alpha().step_12(), editor_invisible: neutral().dark_alpha().step_4(), diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index 553c75623381d..416103294a6c5 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -74,6 +74,7 @@ pub(crate) fn zed_default_dark() -> Theme { icon_disabled: hsla(220.0 / 360., 6.4 / 100., 45.7 / 100., 1.0), icon_placeholder: hsla(220.0 / 360., 6.4 / 100., 45.7 / 100., 1.0), icon_accent: blue, + debugger_accent: red, status_bar_background: bg, title_bar_background: bg, title_bar_inactive_background: bg, @@ -88,6 +89,12 @@ pub(crate) fn zed_default_dark() -> Theme { editor_subheader_background: bg, editor_active_line_background: hsla(222.9 / 360., 13.5 / 100., 20.4 / 100., 1.0), editor_highlighted_line_background: hsla(207.8 / 360., 81. / 100., 66. / 100., 0.1), + editor_debugger_active_line_background: hsla( + 207.8 / 360., + 81. / 100., + 66. / 100., + 0.2, + ), editor_line_number: hsla(222.0 / 360., 11.5 / 100., 34.1 / 100., 1.0), editor_active_line_number: hsla(216.0 / 360., 5.9 / 100., 49.6 / 100., 1.0), editor_invisible: hsla(222.0 / 360., 11.5 / 100., 34.1 / 100., 1.0), diff --git a/crates/theme/src/schema.rs b/crates/theme/src/schema.rs index af334d8aed54b..5e3e3746dd3b2 100644 --- a/crates/theme/src/schema.rs +++ b/crates/theme/src/schema.rs @@ -292,6 +292,11 @@ pub struct ThemeColorsContent { #[serde(rename = "icon.accent")] pub icon_accent: Option, + /// Color used to accent some of the debuggers elements + /// Only accent breakpoint & breakpoint related symbols right now + #[serde(rename = "debugger.accent")] + pub debugger_accent: Option, + #[serde(rename = "status_bar.background")] pub status_bar_background: Option, @@ -373,6 +378,10 @@ pub struct ThemeColorsContent { #[serde(rename = "editor.highlighted_line.background")] pub editor_highlighted_line_background: Option, + /// Background of active line of debugger + #[serde(rename = "editor.debugger_active_line.background")] + pub editor_debugger_active_line_background: Option, + /// Text Color. Used for the text of the line number in the editor gutter. #[serde(rename = "editor.line_number")] pub editor_line_number: Option, @@ -670,6 +679,10 @@ impl ThemeColorsContent { .icon_accent .as_ref() .and_then(|color| try_parse_color(color).ok()), + debugger_accent: self + .debugger_accent + .as_ref() + .and_then(|color| try_parse_color(color).ok()), status_bar_background: self .status_bar_background .as_ref() @@ -768,6 +781,10 @@ impl ThemeColorsContent { .editor_highlighted_line_background .as_ref() .and_then(|color| try_parse_color(color).ok()), + editor_debugger_active_line_background: self + .editor_debugger_active_line_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), editor_line_number: self .editor_line_number .as_ref() diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index 881a68334dcf6..8700ac6963914 100644 --- a/crates/theme/src/styles/colors.rs +++ b/crates/theme/src/styles/colors.rs @@ -109,6 +109,9 @@ pub struct ThemeColors { /// /// This might be used to show when a toggleable icon button is selected. pub icon_accent: Hsla, + /// Color used to accent some debugger elements + /// Is used by breakpoints + pub debugger_accent: Hsla, // === // UI Elements @@ -149,6 +152,8 @@ pub struct ThemeColors { pub editor_subheader_background: Hsla, pub editor_active_line_background: Hsla, pub editor_highlighted_line_background: Hsla, + /// Line color of the line a debugger is currently stopped at + pub editor_debugger_active_line_background: Hsla, /// Text Color. Used for the text of the line number in the editor gutter. pub editor_line_number: Hsla, /// Text Color. Used for the text of the line number in the editor gutter when the line is highlighted. diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index 22e8421391318..396d4dd017d53 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -1,5 +1,5 @@ #![allow(missing_docs)] -use gpui::{relative, CursorStyle, DefiniteLength, MouseButton}; +use gpui::{relative, CursorStyle, DefiniteLength, MouseButton, MouseDownEvent, MouseUpEvent}; use gpui::{transparent_black, AnyElement, AnyView, ClickEvent, Hsla, Rems}; use smallvec::SmallVec; @@ -347,6 +347,7 @@ pub struct ButtonLike { tooltip: Option AnyView>>, cursor_style: CursorStyle, on_click: Option>, + on_right_click: Option>, children: SmallVec<[AnyElement; 2]>, } @@ -367,6 +368,7 @@ impl ButtonLike { children: SmallVec::new(), cursor_style: CursorStyle::PointingHand, on_click: None, + on_right_click: None, layer: None, } } @@ -388,6 +390,14 @@ impl ButtonLike { self.rounding = rounding.into(); self } + + pub fn on_right_click( + mut self, + handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static, + ) -> Self { + self.on_right_click = Some(Box::new(handler)); + self + } } impl Disableable for ButtonLike { @@ -506,6 +516,34 @@ impl RenderOnce for ButtonLike { .hover(|hover| hover.bg(style.hovered(self.layer, cx).background)) .active(|active| active.bg(style.active(cx).background)) }) + .when_some( + self.on_right_click.filter(|_| !self.disabled), + |this, on_right_click| { + this.on_mouse_down(MouseButton::Right, |_event, cx| { + cx.prevent_default(); + cx.stop_propagation(); + }) + .on_mouse_up(MouseButton::Right, move |event, cx| { + cx.stop_propagation(); + let click_event = ClickEvent { + down: MouseDownEvent { + button: MouseButton::Right, + position: event.position, + modifiers: event.modifiers, + click_count: 1, + first_mouse: false, + }, + up: MouseUpEvent { + button: MouseButton::Right, + position: event.position, + modifiers: event.modifiers, + click_count: 1, + }, + }; + (on_right_click)(&click_event, cx) + }) + }, + ) .when_some( self.on_click.filter(|_| !self.disabled), |this, on_click| { diff --git a/crates/ui/src/components/button/icon_button.rs b/crates/ui/src/components/button/icon_button.rs index bad10d6fb43b8..539d5aa675f8f 100644 --- a/crates/ui/src/components/button/icon_button.rs +++ b/crates/ui/src/components/button/icon_button.rs @@ -57,6 +57,14 @@ impl IconButton { self.selected_icon = icon.into(); self } + + pub fn on_right_click( + mut self, + handler: impl Fn(&gpui::ClickEvent, &mut WindowContext) + 'static, + ) -> Self { + self.base = self.base.on_right_click(handler); + self + } } impl Disableable for IconButton { diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 8d374ef67ca41..e0a1c7cb0d78a 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -168,6 +168,17 @@ pub enum IconName { CursorIBeam, TextSnippet, Dash, + DebugBreakpoint, + DebugPause, + DebugContinue, + DebugStepOver, + DebugStepInto, + DebugStepOut, + DebugRestart, + Debug, + DebugStop, + DebugDisconnect, + DebugLogBreakpoint, DatabaseZap, Delete, Disconnected, diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index 37076737a6be0..9d8924f1ddad6 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -38,6 +38,7 @@ pub struct ListItem { on_secondary_mouse_down: Option>, children: SmallVec<[AnyElement; 2]>, selectable: bool, + always_show_disclosure_icon: bool, overflow_x: bool, } @@ -61,6 +62,7 @@ impl ListItem { tooltip: None, children: SmallVec::new(), selectable: true, + always_show_disclosure_icon: false, overflow_x: false, } } @@ -75,6 +77,11 @@ impl ListItem { self } + pub fn always_show_disclosure_icon(mut self, show: bool) -> Self { + self.always_show_disclosure_icon = show; + self + } + pub fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self { self.on_click = Some(Box::new(handler)); self @@ -239,7 +246,9 @@ impl RenderOnce for ListItem { .flex() .absolute() .left(rems(-1.)) - .when(is_open, |this| this.visible_on_hover("")) + .when(is_open && !self.always_show_disclosure_icon, |this| { + this.visible_on_hover("") + }) .child(Disclosure::new("toggle", is_open).on_toggle(self.on_toggle)) })) .child( diff --git a/crates/ui/src/styles/color.rs b/crates/ui/src/styles/color.rs index fe8de2ff73496..4751f23ea2d86 100644 --- a/crates/ui/src/styles/color.rs +++ b/crates/ui/src/styles/color.rs @@ -22,6 +22,8 @@ pub enum Color { /// /// A custom color specified by an HSLA value. Custom(Hsla), + /// A color used for all debugger UI elements. + Debugger, /// A color used to indicate a deleted item, such as a file removed from version control. Deleted, /// A color used for disabled UI elements or text, like a disabled button or menu item. @@ -70,6 +72,7 @@ impl Color { Color::Modified => cx.theme().status().modified, Color::Conflict => cx.theme().status().conflict, Color::Ignored => cx.theme().status().ignored, + Color::Debugger => cx.theme().colors().debugger_accent, Color::Deleted => cx.theme().status().deleted, Color::Disabled => cx.theme().colors().text_disabled, Color::Hidden => cx.theme().status().hidden, diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index c205f2a8e59a0..360375acd3598 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -189,7 +189,7 @@ pub enum Event { idx: usize, }, RemovedItem { - item_id: EntityId, + item: Box, }, Split(SplitDirection), JoinAll, @@ -217,9 +217,9 @@ impl fmt::Debug for Event { .finish(), Event::Remove { .. } => f.write_str("Remove"), Event::RemoveItem { idx } => f.debug_struct("RemoveItem").field("idx", idx).finish(), - Event::RemovedItem { item_id } => f + Event::RemovedItem { item } => f .debug_struct("RemovedItem") - .field("item_id", item_id) + .field("item", &item.item_id()) .finish(), Event::Split(direction) => f .debug_struct("Split") @@ -275,8 +275,9 @@ pub struct Pane { /// Is None if navigation buttons are permanently turned off (and should not react to setting changes). /// Otherwise, when `display_nav_history_buttons` is Some, it determines whether nav buttons should be displayed. display_nav_history_buttons: Option, - double_click_dispatch_action: Box, + double_click_dispatch_action: Option>, save_modals_spawned: HashSet, + close_pane_if_empty: bool, pub new_item_context_menu_handle: PopoverMenuHandle, split_item_context_menu_handle: PopoverMenuHandle, pinned_tab_count: usize, @@ -346,7 +347,7 @@ impl Pane { project: Model, next_timestamp: Arc, can_drop_predicate: Option bool + 'static>>, - double_click_dispatch_action: Box, + double_click_dispatch_action: Option>, cx: &mut ViewContext, ) -> Self { let focus_handle = cx.focus_handle(); @@ -476,6 +477,7 @@ impl Pane { _subscriptions: subscriptions, double_click_dispatch_action, save_modals_spawned: HashSet::default(), + close_pane_if_empty: true, split_item_context_menu_handle: Default::default(), new_item_context_menu_handle: Default::default(), pinned_tab_count: 0, @@ -603,6 +605,15 @@ impl Pane { cx.notify(); } + pub fn set_close_pane_if_empty( + &mut self, + close_pane_if_empty: bool, + cx: &mut ViewContext, + ) { + self.close_pane_if_empty = close_pane_if_empty; + cx.notify(); + } + pub fn set_can_navigate(&mut self, can_navigate: bool, cx: &mut ViewContext) { self.toolbar.update(cx, |toolbar, cx| { toolbar.set_can_navigate(can_navigate, cx); @@ -1355,7 +1366,7 @@ impl Pane { .iter() .position(|i| i.item_id() == item.item_id()) { - pane.remove_item(item_ix, false, true, cx); + pane.remove_item(item_ix, false, pane.close_pane_if_empty, cx); } }) .ok(); @@ -1438,13 +1449,9 @@ impl Pane { } } - cx.emit(Event::RemoveItem { idx: item_index }); - let item = self.items.remove(item_index); - cx.emit(Event::RemovedItem { - item_id: item.item_id(), - }); + cx.emit(Event::RemovedItem { item: item.clone() }); if self.items.is_empty() { item.deactivated(cx); if close_pane_if_empty { @@ -2261,9 +2268,9 @@ impl Pane { })) .on_click(cx.listener(move |this, event: &ClickEvent, cx| { if event.up.click_count == 2 { - cx.dispatch_action( - this.double_click_dispatch_action.boxed_clone(), - ) + if let Some(action) = &this.double_click_dispatch_action { + cx.dispatch_action(action.boxed_clone()); + } } })), ), diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 69217fcc45796..4aa71aecd6c49 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -1,16 +1,21 @@ pub mod model; -use std::path::Path; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; use anyhow::{anyhow, bail, Context, Result}; use client::DevServerProjectId; +use collections::HashMap; use db::{define_connection, query, sqlez::connection::Connection, sqlez_macros::sql}; use gpui::{point, size, Axis, Bounds, WindowBounds, WindowId}; +use project::dap_store::{BreakpointKind, SerializedBreakpoint}; use remote::ssh_session::SshProjectId; use sqlez::{ bindable::{Bind, Column, StaticColumnCount}, - statement::Statement, + statement::{SqlType, Statement}, }; use ui::px; @@ -136,6 +141,72 @@ impl Column for SerializedWindowBounds { } } +#[derive(Debug)] +pub struct Breakpoint { + pub position: u32, + pub kind: BreakpointKind, +} + +/// This struct is used to implement traits on Vec +#[derive(Debug)] +#[allow(dead_code)] +struct Breakpoints(Vec); + +impl sqlez::bindable::StaticColumnCount for Breakpoint { + fn column_count() -> usize { + 1 + BreakpointKind::column_count() + } +} + +impl sqlez::bindable::Bind for Breakpoint { + fn bind( + &self, + statement: &sqlez::statement::Statement, + start_index: i32, + ) -> anyhow::Result { + let next_index = statement.bind(&self.position, start_index)?; + statement.bind(&self.kind, next_index) + } +} + +impl Column for Breakpoint { + fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { + let position = statement + .column_int(start_index) + .with_context(|| format!("Failed to read BreakPoint at index {start_index}"))? + as u32; + + let (kind, next_index) = BreakpointKind::column(statement, start_index + 1)?; + + Ok((Breakpoint { position, kind }, next_index)) + } +} + +impl Column for Breakpoints { + fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { + let mut breakpoints = Vec::new(); + let mut index = start_index; + + loop { + match statement.column_type(index) { + Ok(SqlType::Null) => break, + _ => { + let position = statement + .column_int(index) + .with_context(|| format!("Failed to read BreakPoint at index {index}"))? + as u32; + + let (kind, next_index) = BreakpointKind::column(statement, index + 1)?; + + breakpoints.push(Breakpoint { position, kind }); + index = next_index; + } + } + } + Ok((Breakpoints(breakpoints), index)) + } +} + #[derive(Clone, Debug, PartialEq)] struct SerializedPixels(gpui::Pixels); impl sqlez::bindable::StaticColumnCount for SerializedPixels {} @@ -205,6 +276,15 @@ define_connection! { // active: bool, // Indicates if this item is the active one in the pane // preview: bool // Indicates if this item is a preview item // ) + // + // CREATE TABLE breakpoints( + // workspace_id: usize Foreign Key, // References workspace table + // worktree_path: PathBuf, // Path of worktree that this breakpoint belong's too. Used to determine the absolute path of a breakpoint + // relative_path: PathBuf, // References the file that the breakpoints belong too + // breakpoint_location: Vec, // A list of the locations of breakpoints + // kind: int, // The kind of breakpoint (standard, log) + // log_message: String, // log message for log breakpoints, otherwise it's Null + // ) pub static ref DB: WorkspaceDb<()> = &[sql!( CREATE TABLE workspaces( @@ -369,6 +449,18 @@ define_connection! { sql!( ALTER TABLE ssh_projects RENAME COLUMN path TO paths; ), + sql!(CREATE TABLE breakpoints ( + workspace_id INTEGER NOT NULL, + worktree_path BLOB NOT NULL, + relative_path BLOB NOT NULL, + breakpoint_location INTEGER NOT NULL, + kind INTEGER NOT NULL, + log_message TEXT, + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ON UPDATE CASCADE + ) STRICT; + ), ]; } @@ -433,6 +525,41 @@ impl WorkspaceDb { .warn_on_err() .flatten()?; + let breakpoints: Result> = self + .select_bound(sql! { + SELECT worktree_path, relative_path, breakpoint_location, kind, log_message + FROM breakpoints + WHERE workspace_id = ? + }) + .and_then(|mut prepared_statement| (prepared_statement)(workspace_id)); + + let serialized_breakpoints: HashMap, Vec> = + match breakpoints { + Ok(bp) => { + if bp.is_empty() { + log::error!("Breakpoints are empty after querying database for them"); + } + + let mut map: HashMap, Vec> = Default::default(); + + for (worktree_path, file_path, breakpoint) in bp { + map.entry(Arc::from(worktree_path.as_path())) + .or_default() + .push(SerializedBreakpoint { + position: breakpoint.position, + path: Arc::from(file_path.as_path()), + kind: breakpoint.kind, + }); + } + + map + } + Err(msg) => { + log::error!("Breakpoints query failed with msg: {msg}"); + Default::default() + } + }; + let local_paths = local_paths?; let location = match local_paths_order { Some(order) => SerializedWorkspaceLocation::Local(local_paths, order), @@ -454,6 +581,7 @@ impl WorkspaceDb { display, docks, session_id: None, + breakpoints: serialized_breakpoints, window_id, }) } @@ -537,6 +665,7 @@ impl WorkspaceDb { display, docks, session_id: None, + breakpoints: Default::default(), window_id, }) } @@ -594,6 +723,7 @@ impl WorkspaceDb { docks, session_id: None, window_id, + breakpoints: Default::default(), }) } @@ -602,12 +732,45 @@ impl WorkspaceDb { pub(crate) async fn save_workspace(&self, workspace: SerializedWorkspace) { self.write(move |conn| { conn.with_savepoint("update_worktrees", || { - // Clear out panes and pane_groups + // Clear out panes, pane_groups, and breakpoints conn.exec_bound(sql!( DELETE FROM pane_groups WHERE workspace_id = ?1; DELETE FROM panes WHERE workspace_id = ?1;))?(workspace.id) .context("Clearing old panes")?; + // Clear out breakpoints associated with this workspace + match conn.exec_bound(sql!( + DELETE FROM breakpoints + WHERE workspace_id = ?1;))?(workspace.id,) { + Err(err) => { + log::error!("Breakpoints failed to clear with error: {err}"); + } + Ok(_) => {} + } + + for (worktree_path, serialized_breakpoints) in workspace.breakpoints { + for serialized_breakpoint in serialized_breakpoints { + let relative_path = serialized_breakpoint.path; + + match conn.exec_bound(sql!( + INSERT INTO breakpoints (workspace_id, relative_path, worktree_path, breakpoint_location, kind, log_message) + VALUES (?1, ?2, ?3, ?4, ?5, ?6);))? + (( + workspace.id, + relative_path, + worktree_path.clone(), + Breakpoint { position: serialized_breakpoint.position, kind: serialized_breakpoint.kind}, + )) { + Err(err) => { + log::error!("{err}"); + continue; + } + Ok(_) => {} + } + } + } + + match workspace.location { SerializedWorkspaceLocation::Local(local_paths, local_paths_order) => { conn.exec_bound(sql!( @@ -841,6 +1004,37 @@ impl WorkspaceDb { } } + // TODO: Fix this query + // query! { + // pub fn all_breakpoints(id: WorkspaceId) -> Result)>> { + // SELECT local_path, GROUP_CONCAT(breakpoint_location) as breakpoint_locations + // FROM breakpoints + // WHERE workspace_id = ? + // GROUP BY local_path; + // } + // } + + query! { + pub fn breakpoints_for_file(id: WorkspaceId, file_path: &Path) -> Result> { + SELECT breakpoint_location + FROM breakpoints + WHERE workspace_id = ?1 AND file_path = ?2 + } + } + + query! { + pub fn clear_breakpoints(id: WorkspaceId, file_path: &Path) -> Result<()> { + DELETE FROM breakpoints + WHERE workspace_id = ?1 AND file_path = ?2 + } + } + + query! { + pub fn insert_breakpoint(id: WorkspaceId, file_path: &Path, breakpoint_location: Breakpoint) -> Result<()> { + INSERT INTO breakpoints (workspace_id, file_path, breakpoint_location) VALUES (?1, ?2, ?3) + } + } + query! { fn dev_server_projects() -> Result> { SELECT id, path, dev_server_name @@ -1304,6 +1498,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: None, + breakpoints: Default::default(), window_id: None, }; @@ -1316,6 +1511,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: None, + breakpoints: Default::default(), window_id: None, }; @@ -1423,6 +1619,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: None, + breakpoints: Default::default(), window_id: Some(999), }; @@ -1457,6 +1654,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: None, + breakpoints: Default::default(), window_id: Some(1), }; @@ -1469,6 +1667,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: None, + breakpoints: Default::default(), window_id: Some(2), }; @@ -1511,6 +1710,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: None, + breakpoints: Default::default(), window_id: Some(3), }; @@ -1547,6 +1747,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: Some("session-id-1".to_owned()), + breakpoints: Default::default(), window_id: Some(10), }; @@ -1559,6 +1760,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: Some("session-id-1".to_owned()), + breakpoints: Default::default(), window_id: Some(20), }; @@ -1571,6 +1773,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: Some("session-id-2".to_owned()), + breakpoints: Default::default(), window_id: Some(30), }; @@ -1583,6 +1786,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: None, + breakpoints: Default::default(), window_id: None, }; @@ -1601,6 +1805,7 @@ mod tests { centered_layout: false, session_id: Some("session-id-2".to_owned()), window_id: Some(50), + breakpoints: Default::default(), }; db.save_workspace(workspace_1.clone()).await; @@ -1639,6 +1844,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: None, + breakpoints: Default::default(), window_id: None, } } @@ -1669,6 +1875,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: Some("one-session".to_owned()), + breakpoints: Default::default(), window_id: Some(window_id), }) .collect::>(); @@ -1745,6 +1952,7 @@ mod tests { centered_layout: false, session_id: Some("one-session".to_owned()), window_id: Some(window_id), + breakpoints: Default::default(), }) .collect::>(); diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index 5efc77205c346..50774440917e1 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -5,11 +5,13 @@ use crate::{ use anyhow::{Context, Result}; use async_recursion::async_recursion; use client::DevServerProjectId; +use collections::HashMap; use db::sqlez::{ bindable::{Bind, Column, StaticColumnCount}, statement::Statement, }; use gpui::{AsyncWindowContext, Model, View, WeakView}; +use project::dap_store::SerializedBreakpoint; use project::Project; use remote::{ssh_session::SshProjectId, SshConnectionOptions}; use serde::{Deserialize, Serialize}; @@ -297,6 +299,8 @@ pub(crate) struct SerializedWorkspace { pub(crate) display: Option, pub(crate) docks: DockStructure, pub(crate) session_id: Option, + /// The key of this hashmap is an absolute worktree path that owns the breakpoint + pub(crate) breakpoints: HashMap, Vec>, pub(crate) window_id: Option, } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 3c3b26b4c137d..245a60aa8903a 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -126,6 +126,11 @@ pub struct RemoveWorktreeFromProject(pub WorktreeId); actions!(assistant, [ShowConfiguration]); +actions!( + debugger, + [Start, Continue, Disconnect, Pause, Restart, StepInto, StepOver, StepOut, Stop] +); + actions!( workspace, [ @@ -151,6 +156,7 @@ actions!( ReloadActiveItem, SaveAs, SaveWithoutFormat, + StopDebugAdapters, ToggleBottomDock, ToggleCenteredLayout, ToggleLeftDock, @@ -905,7 +911,7 @@ impl Workspace { project.clone(), pane_history_timestamp.clone(), None, - NewFile.boxed_clone(), + Some(NewFile.boxed_clone()), cx, ) }); @@ -1140,6 +1146,7 @@ impl Workspace { // Get project paths for all of the abs_paths let mut project_paths: Vec<(PathBuf, Option)> = Vec::with_capacity(paths_to_open.len()); + for path in paths_to_open.into_iter() { if let Some((_, project_entry)) = cx .update(|cx| { @@ -2451,7 +2458,7 @@ impl Workspace { self.project.clone(), self.pane_history_timestamp.clone(), None, - NewFile.boxed_clone(), + Some(NewFile.boxed_clone()), cx, ) }); @@ -3039,10 +3046,10 @@ impl Workspace { self.update_window_edited(cx); } pane::Event::RemoveItem { .. } => {} - pane::Event::RemovedItem { item_id } => { + pane::Event::RemovedItem { item } => { cx.emit(Event::ActiveItemChanged); self.update_window_edited(cx); - if let hash_map::Entry::Occupied(entry) = self.panes_by_item.entry(*item_id) { + if let hash_map::Entry::Occupied(entry) = self.panes_by_item.entry(item.item_id()) { if entry.get().entity_id() == pane.entity_id() { entry.remove(); } @@ -4166,6 +4173,10 @@ impl Workspace { }; if let Some(location) = location { + let breakpoint_lines = self + .project + .update(cx, |project, cx| project.serialize_breakpoints(cx)); + let center_group = build_serialized_pane_group(&self.center.root, cx); let docks = build_serialized_docks(self, cx); let window_bounds = Some(SerializedWindowBounds(cx.window_bounds())); @@ -4178,6 +4189,7 @@ impl Workspace { docks, centered_layout: self.centered_layout, session_id: self.session_id.clone(), + breakpoints: breakpoint_lines, window_id: Some(cx.window_handle().window_id().as_u64()), }; return cx.spawn(|_| persistence::DB.save_workspace(serialized_workspace)); @@ -4232,7 +4244,7 @@ impl Workspace { } pub(crate) fn load_workspace( - serialized_workspace: SerializedWorkspace, + mut serialized_workspace: SerializedWorkspace, paths_to_open: Vec>, cx: &mut ViewContext, ) -> Task>>>> { @@ -4242,6 +4254,24 @@ impl Workspace { let mut center_group = None; let mut center_items = None; + // Add unopened breakpoints to project before opening any items + workspace.update(&mut cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.dap_store().update(cx, |store, cx| { + for worktree in project.worktrees(cx) { + let (worktree_id, worktree_path) = + worktree.read_with(cx, |tree, _cx| (tree.id(), tree.abs_path())); + + if let Some(serialized_breakpoints) = + serialized_workspace.breakpoints.remove(&worktree_path) + { + store.deserialize_breakpoints(worktree_id, serialized_breakpoints); + } + } + }); + }) + })?; + // Traverse the splits tree and add to things if let Some((group, active_pane, items)) = serialized_workspace .center_group diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 69ca3aa98d266..f86dacef4104d 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -35,6 +35,7 @@ collections.workspace = true command_palette.workspace = true command_palette_hooks.workspace = true copilot.workspace = true +debugger_ui.workspace = true db.workspace = true dev_server_projects.workspace = true diagnostics.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 25baf74c68d89..e2ee098d05b85 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -522,6 +522,7 @@ fn main() { zed::init(cx); project::Project::init(&client, cx); + debugger_ui::init(cx); client::init(&client, cx); language::init(cx); let telemetry = client.telemetry(); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index f5bc3a1847c6d..4a9a798dbe797 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -14,6 +14,7 @@ use breadcrumbs::Breadcrumbs; use client::ZED_URL_SCHEME; use collections::VecDeque; use command_palette_hooks::CommandPaletteFilter; +use debugger_ui::debugger_panel::DebugPanel; use editor::ProposedChangesEditorToolbar; use editor::{scroll::Autoscroll, Editor, MultiBuffer}; use feature_flags::FeatureFlagAppExt; @@ -243,6 +244,7 @@ pub fn initialize_workspace( workspace_handle.clone(), cx.clone(), ); + let debug_panel = DebugPanel::load(workspace_handle.clone(), cx.clone()); let ( project_panel, @@ -252,6 +254,7 @@ pub fn initialize_workspace( channels_panel, chat_panel, notification_panel, + debug_panel, ) = futures::try_join!( project_panel, outline_panel, @@ -260,6 +263,7 @@ pub fn initialize_workspace( channels_panel, chat_panel, notification_panel, + debug_panel )?; workspace_handle.update(&mut cx, |workspace, cx| { @@ -270,6 +274,7 @@ pub fn initialize_workspace( workspace.add_panel(channels_panel, cx); workspace.add_panel(chat_panel, cx); workspace.add_panel(notification_panel, cx); + workspace.add_panel(debug_panel, cx); cx.focus_self(); }) }) @@ -3409,6 +3414,7 @@ mod tests { cx, ); tasks_ui::init(cx); + debugger_ui::init(cx); initialize_workspace(app_state.clone(), prompt_builder, cx); search::init(cx); app_state