diff --git a/.zed/debug.json b/.zed/debug.json new file mode 100644 index 00000000000000..b7646ee3bd7d28 --- /dev/null +++ b/.zed/debug.json @@ -0,0 +1,19 @@ +[ + { + "label": "Debug Zed with LLDB", + "adapter": "lldb", + "program": "$ZED_WORKTREE_ROOT/target/debug/zed", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" + }, + { + "label": "Debug Zed with GDB", + "adapter": "gdb", + "program": "$ZED_WORKTREE_ROOT/target/debug/zed", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT", + "initialize_args": { + "stopAtBeginningOfMainSubprogram": true + } + } +] diff --git a/Cargo.lock b/Cargo.lock index f7412a5fb88f38..b68d89267133b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,7 +13,6 @@ dependencies = [ "futures 0.3.31", "gpui", "language", - "lsp", "project", "smallvec", "ui", @@ -2772,9 +2771,12 @@ dependencies = [ "clock", "collab_ui", "collections", + "command_palette_hooks", "context_server", "ctor", + "dap", "dashmap 6.1.0", + "debugger_ui", "derive_more", "editor", "env_logger 0.11.6", @@ -3690,6 +3692,67 @@ dependencies = [ "syn 2.0.90", ] +[[package]] +name = "dap" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-compression", + "async-pipe", + "async-tar", + "async-trait", + "client", + "collections", + "dap-types", + "env_logger 0.11.6", + "fs", + "futures 0.3.31", + "gpui", + "http_client", + "language", + "log", + "node_runtime", + "parking_lot", + "paths", + "schemars", + "serde", + "serde_json", + "settings", + "smallvec", + "smol", + "sysinfo", + "task", + "util", +] + +[[package]] +name = "dap-types" +version = "0.0.1" +source = "git+https://github.com/zed-industries/dap-types?rev=bf5632dc19f806e8a435c9f04a4bfe7322badea2#bf5632dc19f806e8a435c9f04a4bfe7322badea2" +dependencies = [ + "schemars", + "serde", + "serde_json", +] + +[[package]] +name = "dap_adapters" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "dap", + "gpui", + "language", + "paths", + "regex", + "serde", + "serde_json", + "sysinfo", + "task", + "util", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -3763,6 +3826,58 @@ dependencies = [ "winapi", ] +[[package]] +name = "debugger_tools" +version = "0.1.0" +dependencies = [ + "anyhow", + "dap", + "editor", + "futures 0.3.31", + "gpui", + "project", + "serde_json", + "settings", + "smol", + "util", + "workspace", +] + +[[package]] +name = "debugger_ui" +version = "0.1.0" +dependencies = [ + "anyhow", + "client", + "collections", + "command_palette_hooks", + "dap", + "editor", + "env_logger 0.11.6", + "fuzzy", + "gpui", + "language", + "log", + "menu", + "picker", + "pretty_assertions", + "project", + "rpc", + "serde", + "serde_json", + "settings", + "sum_tree", + "sysinfo", + "task", + "tasks_ui", + "terminal_view", + "theme", + "ui", + "unindent", + "util", + "workspace", +] + [[package]] name = "deepseek" version = "0.1.0" @@ -4063,6 +4178,7 @@ dependencies = [ "log", "lsp", "markdown", + "menu", "multi_buffer", "ordered-float 2.10.1", "parking_lot", @@ -10115,6 +10231,8 @@ dependencies = [ "client", "clock", "collections", + "dap", + "dap_adapters", "env_logger 0.11.6", "fancy-regex 0.14.0", "fs", @@ -10126,6 +10244,7 @@ dependencies = [ "gpui", "http_client", "image", + "indexmap", "itertools 0.14.0", "language", "log", @@ -10774,6 +10893,7 @@ version = "0.1.0" dependencies = [ "anyhow", "auto_update", + "dap", "editor", "extension_host", "file_finder", @@ -13293,6 +13413,7 @@ dependencies = [ "parking_lot", "schemars", "serde", + "serde_json", "serde_json_lenient", "sha2", "shellexpand 2.1.2", @@ -16697,6 +16818,8 @@ dependencies = [ "component_preview", "copilot", "db", + "debugger_tools", + "debugger_ui", "diagnostics", "editor", "env_logger 0.11.6", diff --git a/Cargo.toml b/Cargo.toml index 448f653a519ef1..3949f1a08cc086 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,10 @@ members = [ "crates/context_server_settings", "crates/copilot", "crates/credentials_provider", + "crates/dap", + "crates/dap_adapters", + "crates/debugger_tools", + "crates/debugger_ui", "crates/db", "crates/deepseek", "crates/diagnostics", @@ -235,7 +239,11 @@ context_server = { path = "crates/context_server" } context_server_settings = { path = "crates/context_server_settings" } copilot = { path = "crates/copilot" } credentials_provider = { path = "crates/credentials_provider" } +dap = { path = "crates/dap" } +dap_adapters = { path = "crates/dap_adapters" } db = { path = "crates/db" } +debugger_ui = { path = "crates/debugger_ui" } +debugger_tools = { path = "crates/debugger_tools" } deepseek = { path = "crates/deepseek" } diagnostics = { path = "crates/diagnostics" } buffer_diff = { path = "crates/buffer_diff" } diff --git a/assets/icons/debug.svg b/assets/icons/debug.svg new file mode 100644 index 00000000000000..8cea0c460402fb --- /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 00000000000000..f6a7b35658eeff --- /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 00000000000000..e2a99c38d032fc --- /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 00000000000000..0eb253715288fc --- /dev/null +++ b/assets/icons/debug_disconnect.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_ignore_breakpoints.svg b/assets/icons/debug_ignore_breakpoints.svg new file mode 100644 index 00000000000000..ba7074e083c700 --- /dev/null +++ b/assets/icons/debug_ignore_breakpoints.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 00000000000000..a878ce3e04189d --- /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 00000000000000..bea531bc5a755b --- /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 00000000000000..4eff13b94b698d --- /dev/null +++ b/assets/icons/debug_restart.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_step_back.svg b/assets/icons/debug_step_back.svg new file mode 100644 index 00000000000000..bc7c9b8444cda2 --- /dev/null +++ b/assets/icons/debug_step_back.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 00000000000000..69e5cff3f176ca --- /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 00000000000000..680e13e65e0414 --- /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 00000000000000..005b901da3c49b --- /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 00000000000000..fef651c5864a15 --- /dev/null +++ b/assets/icons/debug_stop.svg @@ -0,0 +1 @@ + diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 889fa2e33e09c4..3157fc450f9158 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -724,6 +724,14 @@ "space": "project_panel::Open" } }, + { + "context": "VariableList", + "use_key_equivalents": true, + "bindings": { + "left": "variable_list::CollapseSelectedEntry", + "right": "variable_list::ExpandSelectedEntry" + } + }, { "context": "GitPanel && ChangesList", "use_key_equivalents": true, diff --git a/assets/settings/default.json b/assets/settings/default.json index d8c60e89848ec5..8b006f471966d6 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1377,6 +1377,12 @@ // } // ] "ssh_connections": [], + // Configures context servers for use in the Assistant. - "context_servers": {} + "context_servers": {}, + "debugger": { + "stepping_granularity": "line", + "save_breakpoints": true, + "button": true + } } diff --git a/assets/settings/initial_debug_tasks.json b/assets/settings/initial_debug_tasks.json new file mode 100644 index 00000000000000..e77d7c872767d8 --- /dev/null +++ b/assets/settings/initial_debug_tasks.json @@ -0,0 +1,32 @@ +[ + { + "label": "Debug active PHP file", + "adapter": "php", + "program": "$ZED_FILE", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" + }, + { + "label": "Debug active Python file", + "adapter": "python", + "program": "$ZED_FILE", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" + }, + { + "label": "Debug active JavaScript file", + "adapter": "javascript", + "program": "$ZED_FILE", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" + }, + { + "label": "JavaScript debug terminal", + "adapter": "javascript", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT", + "initialize_args": { + "console": "integratedTerminal" + } + } +] diff --git a/crates/activity_indicator/Cargo.toml b/crates/activity_indicator/Cargo.toml index e28c30ca886720..a17846690a3922 100644 --- a/crates/activity_indicator/Cargo.toml +++ b/crates/activity_indicator/Cargo.toml @@ -20,7 +20,6 @@ extension_host.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true -lsp.workspace = true project.workspace = true smallvec.workspace = true ui.workspace = true diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index 7a86e5019642b4..834e83f9887055 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -7,8 +7,7 @@ use gpui::{ EventEmitter, InteractiveElement as _, ParentElement as _, Render, SharedString, StatefulInteractiveElement, Styled, Transformation, Window, }; -use language::{LanguageRegistry, LanguageServerBinaryStatus, LanguageServerId}; -use lsp::LanguageServerName; +use language::{BinaryStatus, LanguageRegistry, LanguageServerId}; use project::{EnvironmentErrorMessage, LanguageServerProgress, Project, WorktreeId}; use smallvec::SmallVec; use std::{cmp::Reverse, fmt::Write, sync::Arc, time::Duration}; @@ -20,21 +19,21 @@ actions!(activity_indicator, [ShowErrorMessage]); pub enum Event { ShowError { - lsp_name: LanguageServerName, + server_name: SharedString, error: String, }, } pub struct ActivityIndicator { - statuses: Vec, + statuses: Vec, project: Entity, auto_updater: Option>, context_menu_handle: PopoverMenuHandle, } -struct LspStatus { - name: LanguageServerName, - status: LanguageServerBinaryStatus, +struct ServerStatus { + name: SharedString, + status: BinaryStatus, } struct PendingWork<'a> { @@ -65,7 +64,20 @@ impl ActivityIndicator { while let Some((name, status)) = status_events.next().await { this.update(&mut cx, |this: &mut ActivityIndicator, cx| { this.statuses.retain(|s| s.name != name); - this.statuses.push(LspStatus { name, status }); + this.statuses.push(ServerStatus { name, status }); + cx.notify(); + })?; + } + anyhow::Ok(()) + }) + .detach(); + + let mut status_events = languages.dap_server_binary_statuses(); + cx.spawn(|this, mut cx| async move { + while let Some((name, status)) = status_events.next().await { + this.update(&mut cx, |this, cx| { + this.statuses.retain(|s| s.name != name); + this.statuses.push(ServerStatus { name, status }); cx.notify(); })?; } @@ -88,18 +100,18 @@ impl ActivityIndicator { }); cx.subscribe_in(&this, window, move |_, _, event, window, cx| match event { - Event::ShowError { lsp_name, error } => { + Event::ShowError { server_name, error } => { let create_buffer = project.update(cx, |project, cx| project.create_buffer(cx)); let project = project.clone(); let error = error.clone(); - let lsp_name = lsp_name.clone(); + let server_name = server_name.clone(); cx.spawn_in(window, |workspace, mut cx| async move { let buffer = create_buffer.await?; buffer.update(&mut cx, |buffer, cx| { buffer.edit( [( 0..0, - format!("Language server error: {}\n\n{}", lsp_name, error), + format!("Language server error: {}\n\n{}", server_name, error), )], None, cx, @@ -129,9 +141,9 @@ impl ActivityIndicator { fn show_error_message(&mut self, _: &ShowErrorMessage, _: &mut Window, cx: &mut Context) { self.statuses.retain(|status| { - if let LanguageServerBinaryStatus::Failed { error } = &status.status { + if let BinaryStatus::Failed { error } = &status.status { cx.emit(Event::ShowError { - lsp_name: status.name.clone(), + server_name: status.name.clone(), error: error.clone(), }); false @@ -260,12 +272,10 @@ impl ActivityIndicator { let mut failed = SmallVec::<[_; 3]>::new(); for status in &self.statuses { match status.status { - LanguageServerBinaryStatus::CheckingForUpdate => { - checking_for_update.push(status.name.clone()) - } - LanguageServerBinaryStatus::Downloading => downloading.push(status.name.clone()), - LanguageServerBinaryStatus::Failed { .. } => failed.push(status.name.clone()), - LanguageServerBinaryStatus::None => {} + BinaryStatus::CheckingForUpdate => checking_for_update.push(status.name.clone()), + BinaryStatus::Downloading => downloading.push(status.name.clone()), + BinaryStatus::Failed { .. } => failed.push(status.name.clone()), + BinaryStatus::None => {} } } @@ -278,7 +288,7 @@ impl ActivityIndicator { ), message: format!( "Downloading {}...", - downloading.iter().map(|name| name.0.as_ref()).fold( + downloading.iter().map(|name| name.as_ref()).fold( String::new(), |mut acc, s| { if !acc.is_empty() { @@ -306,7 +316,7 @@ impl ActivityIndicator { ), message: format!( "Checking for updates to {}...", - checking_for_update.iter().map(|name| name.0.as_ref()).fold( + checking_for_update.iter().map(|name| name.as_ref()).fold( String::new(), |mut acc, s| { if !acc.is_empty() { @@ -336,7 +346,7 @@ impl ActivityIndicator { "Failed to run {}. Click to show error.", failed .iter() - .map(|name| name.0.as_ref()) + .map(|name| name.as_ref()) .fold(String::new(), |mut acc, s| { if !acc.is_empty() { acc.push_str(", "); diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 1af098416b7667..6cb75524c8a22b 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -89,8 +89,11 @@ channel.workspace = true client = { workspace = true, features = ["test-support"] } collab_ui = { workspace = true, features = ["test-support"] } collections = { workspace = true, features = ["test-support"] } +command_palette_hooks.workspace = true context_server.workspace = true ctor.workspace = true +dap = { workspace = true, features = ["test-support"] } +debugger_ui = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } env_logger.workspace = true extension.workspace = true diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 30d36cfe8c1070..750a21818c3e28 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -469,3 +469,14 @@ CREATE TABLE IF NOT EXISTS processed_stripe_events ( ); CREATE INDEX "ix_processed_stripe_events_on_stripe_event_created_timestamp" ON processed_stripe_events (stripe_event_created_timestamp); + +CREATE TABLE IF NOT EXISTS "breakpoints" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + "position" INTEGER NOT NULL, + "log_message" TEXT NULL, + "worktree_id" BIGINT NOT NULL, + "path" TEXT NOT NULL, + "kind" VARCHAR NOT NULL +); +CREATE INDEX "index_breakpoints_on_project_id" ON "breakpoints" ("project_id"); diff --git a/crates/collab/migrations/20241121185750_add_breakpoints.sql b/crates/collab/migrations/20241121185750_add_breakpoints.sql new file mode 100644 index 00000000000000..4b3071457392f4 --- /dev/null +++ b/crates/collab/migrations/20241121185750_add_breakpoints.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS "breakpoints" ( + "id" SERIAL PRIMARY KEY, + "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + "position" INTEGER NOT NULL, + "log_message" TEXT NULL, + "worktree_id" BIGINT NOT NULL, + "path" TEXT NOT NULL, + "kind" VARCHAR NOT NULL +); + +CREATE INDEX "index_breakpoints_on_project_id" ON "breakpoints" ("project_id"); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 908e488af6dd52..a1796617d12a45 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -659,6 +659,7 @@ pub struct RejoinedProject { pub collaborators: Vec, pub worktrees: Vec, pub language_servers: Vec, + pub breakpoints: HashMap>, } impl RejoinedProject { @@ -681,6 +682,17 @@ impl RejoinedProject { .map(|collaborator| collaborator.to_proto()) .collect(), language_servers: self.language_servers.clone(), + breakpoints: self + .breakpoints + .iter() + .map( + |(project_path, breakpoints)| proto::SynchronizeBreakpoints { + project_id: self.id.to_proto(), + breakpoints: breakpoints.iter().cloned().collect(), + project_path: Some(project_path.clone()), + }, + ) + .collect(), } } } @@ -727,6 +739,7 @@ pub struct Project { pub collaborators: Vec, pub worktrees: BTreeMap, pub language_servers: Vec, + pub breakpoints: HashMap>, } pub struct ProjectCollaborator { diff --git a/crates/collab/src/db/ids.rs b/crates/collab/src/db/ids.rs index 698b1c5693337e..36f3dad9e2fb77 100644 --- a/crates/collab/src/db/ids.rs +++ b/crates/collab/src/db/ids.rs @@ -94,6 +94,9 @@ id_type!(RoomParticipantId); id_type!(ServerId); id_type!(SignupId); id_type!(UserId); +id_type!(DebugClientId); +id_type!(SessionId); +id_type!(ThreadId); /// ChannelRole gives you permissions for both channels and calls. #[derive( diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 1cff5b53b09c12..53c51172677cfe 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -1,5 +1,5 @@ use anyhow::Context as _; - +use collections::{HashMap, HashSet}; use util::ResultExt; use super::*; @@ -571,6 +571,60 @@ impl Database { .await } + pub async fn update_breakpoints( + &self, + connection_id: ConnectionId, + update: &proto::SynchronizeBreakpoints, + ) -> Result>> { + let project_id = ProjectId::from_proto(update.project_id); + self.project_transaction(project_id, |tx| async move { + let project_path = update + .project_path + .as_ref() + .ok_or_else(|| anyhow!("invalid project path"))?; + + // Ensure the update comes from the host. + let project = project::Entity::find_by_id(project_id) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("no such project"))?; + + // remove all existing breakpoints + breakpoints::Entity::delete_many() + .filter(breakpoints::Column::ProjectId.eq(project.id)) + .exec(&*tx) + .await?; + + if !update.breakpoints.is_empty() { + breakpoints::Entity::insert_many(update.breakpoints.iter().map(|breakpoint| { + breakpoints::ActiveModel { + id: ActiveValue::NotSet, + project_id: ActiveValue::Set(project_id), + worktree_id: ActiveValue::Set(project_path.worktree_id as i64), + path: ActiveValue::Set(project_path.path.clone()), + kind: match proto::BreakpointKind::from_i32(breakpoint.kind) { + Some(proto::BreakpointKind::Log) => { + ActiveValue::Set(breakpoints::BreakpointKind::Log) + } + Some(proto::BreakpointKind::Standard) => { + ActiveValue::Set(breakpoints::BreakpointKind::Standard) + } + None => ActiveValue::Set(breakpoints::BreakpointKind::Standard), + }, + log_message: ActiveValue::Set(breakpoint.message.clone()), + position: ActiveValue::Set(breakpoint.cached_position as i32), + } + })) + .exec_without_returning(&*tx) + .await?; + } + + self.internal_project_connection_ids(project_id, connection_id, true, &tx) + .await + }) + .await + } + /// Updates the worktree settings for the given connection. pub async fn update_worktree_settings( &self, @@ -852,6 +906,33 @@ impl Database { } } + let mut breakpoints: HashMap> = + HashMap::default(); + + let db_breakpoints = project.find_related(breakpoints::Entity).all(tx).await?; + + for breakpoint in db_breakpoints.iter() { + let project_path = proto::ProjectPath { + worktree_id: breakpoint.worktree_id as u64, + path: breakpoint.path.clone(), + }; + + breakpoints + .entry(project_path) + .or_default() + .insert(proto::Breakpoint { + position: None, + cached_position: breakpoint.position as u32, + kind: match breakpoint.kind { + breakpoints::BreakpointKind::Standard => { + proto::BreakpointKind::Standard.into() + } + breakpoints::BreakpointKind::Log => proto::BreakpointKind::Log.into(), + }, + message: breakpoint.log_message.clone(), + }); + } + // Populate language servers. let language_servers = project .find_related(language_server::Entity) @@ -879,6 +960,7 @@ impl Database { worktree_id: None, }) .collect(), + breakpoints, }; Ok((project, replica_id as ReplicaId)) } @@ -1106,39 +1188,50 @@ impl Database { exclude_dev_server: bool, ) -> Result>> { self.project_transaction(project_id, |tx| async move { - let project = project::Entity::find_by_id(project_id) - .one(&*tx) - .await? - .ok_or_else(|| anyhow!("no such project"))?; + self.internal_project_connection_ids(project_id, connection_id, exclude_dev_server, &tx) + .await + }) + .await + } - let mut collaborators = project_collaborator::Entity::find() - .filter(project_collaborator::Column::ProjectId.eq(project_id)) - .stream(&*tx) - .await?; + async fn internal_project_connection_ids( + &self, + project_id: ProjectId, + connection_id: ConnectionId, + exclude_dev_server: bool, + tx: &DatabaseTransaction, + ) -> Result> { + let project = project::Entity::find_by_id(project_id) + .one(tx) + .await? + .ok_or_else(|| anyhow!("no such project"))?; - let mut connection_ids = HashSet::default(); - if let Some(host_connection) = project.host_connection().log_err() { - if !exclude_dev_server { - connection_ids.insert(host_connection); - } - } + let mut collaborators = project_collaborator::Entity::find() + .filter(project_collaborator::Column::ProjectId.eq(project_id)) + .stream(tx) + .await?; - while let Some(collaborator) = collaborators.next().await { - let collaborator = collaborator?; - connection_ids.insert(collaborator.connection()); + let mut connection_ids = HashSet::default(); + if let Some(host_connection) = project.host_connection().log_err() { + if !exclude_dev_server { + connection_ids.insert(host_connection); } + } - if connection_ids.contains(&connection_id) - || Some(connection_id) == project.host_connection().ok() - { - Ok(connection_ids) - } else { - Err(anyhow!( - "can only send project updates to a project you're in" - ))? - } - }) - .await + while let Some(collaborator) = collaborators.next().await { + let collaborator = collaborator?; + connection_ids.insert(collaborator.connection()); + } + + if connection_ids.contains(&connection_id) + || Some(connection_id) == project.host_connection().ok() + { + Ok(connection_ids) + } else { + Err(anyhow!( + "can only send project updates to a project you're in" + ))? + } } async fn project_guest_connection_ids( diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 3f65cc4258e6c1..9aba63aff4a89b 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -765,6 +765,33 @@ impl Database { worktrees.push(worktree); } + let mut breakpoints: HashMap> = + HashMap::default(); + + let db_breakpoints = project.find_related(breakpoints::Entity).all(tx).await?; + + for breakpoint in db_breakpoints.iter() { + let project_path = proto::ProjectPath { + worktree_id: breakpoint.worktree_id as u64, + path: breakpoint.path.clone(), + }; + + breakpoints + .entry(project_path) + .or_default() + .insert(proto::Breakpoint { + position: None, + cached_position: breakpoint.position as u32, + kind: match breakpoint.kind { + breakpoints::BreakpointKind::Standard => { + proto::BreakpointKind::Standard.into() + } + breakpoints::BreakpointKind::Log => proto::BreakpointKind::Log.into(), + }, + message: breakpoint.log_message.clone(), + }); + } + let language_servers = project .find_related(language_server::Entity) .all(tx) @@ -834,6 +861,7 @@ impl Database { collaborators, worktrees, language_servers, + breakpoints, })) } diff --git a/crates/collab/src/db/tables.rs b/crates/collab/src/db/tables.rs index 8a4ec29998ac86..fd4553c52b41c4 100644 --- a/crates/collab/src/db/tables.rs +++ b/crates/collab/src/db/tables.rs @@ -2,6 +2,7 @@ pub mod access_token; pub mod billing_customer; pub mod billing_preference; pub mod billing_subscription; +pub mod breakpoints; pub mod buffer; pub mod buffer_operation; pub mod buffer_snapshot; diff --git a/crates/collab/src/db/tables/breakpoints.rs b/crates/collab/src/db/tables/breakpoints.rs new file mode 100644 index 00000000000000..c00bfef9d6d477 --- /dev/null +++ b/crates/collab/src/db/tables/breakpoints.rs @@ -0,0 +1,47 @@ +use crate::db::ProjectId; +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "breakpoints")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(primary_key)] + pub project_id: ProjectId, + pub worktree_id: i64, + pub path: String, + pub kind: BreakpointKind, + pub log_message: Option, + pub position: i32, +} + +#[derive( + Copy, Clone, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum, Default, Hash, serde::Serialize, +)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum BreakpointKind { + #[default] + #[sea_orm(string_value = "standard")] + Standard, + #[sea_orm(string_value = "log")] + Log, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::project::Entity", + from = "Column::ProjectId", + to = "super::project::Column::Id" + )] + Project, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Project.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/tables/project.rs b/crates/collab/src/db/tables/project.rs index 10e3da50e1dd09..09d20bce625435 100644 --- a/crates/collab/src/db/tables/project.rs +++ b/crates/collab/src/db/tables/project.rs @@ -49,6 +49,8 @@ pub enum Relation { Collaborators, #[sea_orm(has_many = "super::language_server::Entity")] LanguageServers, + #[sea_orm(has_many = "super::breakpoints::Entity")] + Breakpoints, } impl Related for Entity { @@ -81,4 +83,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::Breakpoints.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 189a5e5471f9f8..19ce05f1eb8e59 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -423,7 +423,40 @@ impl Server { app_state.config.openai_api_key.clone(), ) } - }); + }) + .add_message_handler(update_breakpoints) + .add_message_handler(broadcast_project_message_from_host::) + .add_message_handler( + broadcast_project_message_from_host::, + ) + .add_message_handler(broadcast_project_message_from_host::) + .add_message_handler( + broadcast_project_message_from_host::, + ) + .add_message_handler(broadcast_project_message_from_host::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler( + forward_mutating_project_request::, + ) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_message_handler(broadcast_project_message_from_host::) + .add_request_handler(forward_mutating_project_request::) + .add_message_handler( + broadcast_project_message_from_host::, + ) + .add_message_handler( + broadcast_project_message_from_host::, + ) + .add_message_handler( + broadcast_project_message_from_host::, + ); Arc::new(server) } @@ -1863,6 +1896,18 @@ fn join_project_internal( .trace_err(); } + let breakpoints = project + .breakpoints + .iter() + .map( + |(project_path, breakpoint_set)| proto::SynchronizeBreakpoints { + project_id: project.id.0 as u64, + breakpoints: breakpoint_set.iter().map(|bp| bp.clone()).collect(), + project_path: Some(project_path.clone()), + }, + ) + .collect(); + // First, we send the metadata associated with each worktree. response.send(proto::JoinProjectResponse { project_id: project.id.0 as u64, @@ -1871,6 +1916,7 @@ fn join_project_internal( collaborators: collaborators.clone(), language_servers: project.language_servers.clone(), role: project.role.into(), + breakpoints, })?; for (worktree_id, worktree) in mem::take(&mut project.worktrees) { @@ -2057,7 +2103,7 @@ async fn update_worktree_settings( Ok(()) } -/// Notify other participants that a language server has started. +/// Notify other participants that a language server has started. async fn start_language_server( request: proto::StartLanguageServer, session: Session, @@ -2103,6 +2149,29 @@ async fn update_language_server( Ok(()) } +/// Notify other participants that breakpoints have changed. +async fn update_breakpoints( + request: proto::SynchronizeBreakpoints, + session: Session, +) -> Result<()> { + let guest_connection_ids = session + .db() + .await + .update_breakpoints(session.connection_id, &request) + .await?; + + broadcast( + Some(session.connection_id), + guest_connection_ids.iter().copied(), + |connection_id| { + session + .peer + .forward_send(session.connection_id, connection_id, request.clone()) + }, + ); + Ok(()) +} + /// forward a project request to the host. These requests should be read only /// as guests are allowed to send them. async fn forward_read_only_project_request( diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index 20fa289389aaf7..b9f0f79a9e23bc 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -11,6 +11,7 @@ mod channel_buffer_tests; mod channel_guest_tests; mod channel_message_tests; mod channel_tests; +mod debug_panel_tests; mod editor_tests; mod following_tests; mod integration_tests; diff --git a/crates/collab/src/tests/debug_panel_tests.rs b/crates/collab/src/tests/debug_panel_tests.rs new file mode 100644 index 00000000000000..a6d286d343d54b --- /dev/null +++ b/crates/collab/src/tests/debug_panel_tests.rs @@ -0,0 +1,2444 @@ +use call::ActiveCall; +use dap::requests::{Disconnect, Initialize, Launch, StackTrace}; +use dap::{ + requests::{RestartFrame, SetBreakpoints}, + SourceBreakpoint, StackFrame, +}; +use debugger_ui::debugger_panel::DebugPanel; +use debugger_ui::session::DebugSession; +use editor::Editor; +use gpui::{Entity, TestAppContext, VisualTestContext}; +use project::{Project, ProjectPath, WorktreeId}; +use serde_json::json; +use std::sync::Arc; +use std::{ + path::Path, + sync::atomic::{AtomicBool, Ordering}, +}; +use unindent::Unindent as _; +use workspace::{dock::Panel, Workspace}; + +use super::{TestClient, TestServer}; + +pub fn init_test(cx: &mut gpui::TestAppContext) { + if std::env::var("RUST_LOG").is_ok() { + env_logger::try_init().ok(); + } + + cx.update(|cx| { + theme::init(theme::LoadThemes::JustBase, cx); + command_palette_hooks::init(cx); + language::init(cx); + workspace::init_settings(cx); + project::Project::init_settings(cx); + debugger_ui::init(cx); + editor::init(cx); + }); +} + +async fn add_debugger_panel(workspace: &Entity, cx: &mut VisualTestContext) { + let debugger_panel = workspace + .update_in(cx, |_workspace, window, cx| { + cx.spawn_in(window, DebugPanel::load) + }) + .await + .unwrap(); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_panel(debugger_panel, window, cx); + }); +} + +pub fn active_debug_panel_item( + workspace: Entity, + cx: &mut VisualTestContext, +) -> Entity { + workspace.update_in(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap() + }) +} + +struct ZedInstance<'a> { + client: TestClient, + project: Option>, + active_call: Entity, + cx: &'a mut TestAppContext, +} + +impl<'a> ZedInstance<'a> { + fn new(client: TestClient, cx: &'a mut TestAppContext) -> Self { + ZedInstance { + project: None, + client, + active_call: cx.read(ActiveCall::global), + cx, + } + } + + async fn host_project( + &mut self, + project_files: Option, + ) -> (u64, WorktreeId) { + let (project, worktree_id) = self.client.build_local_project("/project", self.cx).await; + self.active_call + .update(self.cx, |call, cx| call.set_location(Some(&project), cx)) + .await + .unwrap(); + + if let Some(tree) = project_files { + self.client.fs().insert_tree("/project", tree).await; + } + + self.project = Some(project.clone()); + + let project_id = self + .active_call + .update(self.cx, |call, cx| call.share_project(project, cx)) + .await + .unwrap(); + + (project_id, worktree_id) + } + + async fn join_project(&mut self, project_id: u64) { + let remote_project = self.client.join_remote_project(project_id, self.cx).await; + self.project = Some(remote_project); + + self.active_call + .update(self.cx, |call, cx| { + call.set_location(self.project.as_ref(), cx) + }) + .await + .unwrap(); + } + + async fn expand( + &'a mut self, + ) -> ( + &'a TestClient, + Entity, + Entity, + &'a mut VisualTestContext, + ) { + let (workspace, cx) = self.client.build_workspace( + self.project + .as_ref() + .expect("Project should be hosted or built before expanding"), + self.cx, + ); + add_debugger_panel(&workspace, cx).await; + (&self.client, workspace, self.project.clone().unwrap(), cx) + } +} + +async fn setup_three_member_test<'a, 'b, 'c>( + server: &mut TestServer, + host_cx: &'a mut TestAppContext, + first_remote_cx: &'b mut TestAppContext, + second_remote_cx: &'c mut TestAppContext, +) -> (ZedInstance<'a>, ZedInstance<'b>, ZedInstance<'c>) { + let host_client = server.create_client(host_cx, "user_host").await; + let first_remote_client = server.create_client(first_remote_cx, "user_remote_1").await; + let second_remote_client = server + .create_client(second_remote_cx, "user_remote_2") + .await; + + init_test(host_cx); + init_test(first_remote_cx); + init_test(second_remote_cx); + + server + .create_room(&mut [ + (&host_client, host_cx), + (&first_remote_client, first_remote_cx), + (&second_remote_client, second_remote_cx), + ]) + .await; + + let host_zed = ZedInstance::new(host_client, host_cx); + let first_remote_zed = ZedInstance::new(first_remote_client, first_remote_cx); + let second_remote_zed = ZedInstance::new(second_remote_client, second_remote_cx); + + (host_zed, first_remote_zed, second_remote_zed) +} + +async fn setup_two_member_test<'a, 'b>( + server: &mut TestServer, + host_cx: &'a mut TestAppContext, + remote_cx: &'b mut TestAppContext, +) -> (ZedInstance<'a>, ZedInstance<'b>) { + let host_client = server.create_client(host_cx, "user_host").await; + let remote_client = server.create_client(remote_cx, "user_remote").await; + + init_test(host_cx); + init_test(remote_cx); + + server + .create_room(&mut [(&host_client, host_cx), (&remote_client, remote_cx)]) + .await; + + let host_zed = ZedInstance::new(host_client, host_cx); + let remote_zed = ZedInstance::new(remote_client, remote_cx); + + (host_zed, remote_zed) +} + +#[gpui::test] +async fn test_debug_panel_item_opens_on_remote( + host_cx: &mut TestAppContext, + remote_cx: &mut TestAppContext, +) { + let executor = host_cx.executor(); + let mut server = TestServer::start(executor).await; + + let (mut host_zed, mut remote_zed) = + setup_two_member_test(&mut server, host_cx, remote_cx).await; + + let (host_project_id, _) = host_zed.host_project(None).await; + remote_zed.join_project(host_project_id).await; + + let (_client_host, _host_workspace, host_project, host_cx) = host_zed.expand().await; + let (_client_remote, remote_workspace, _remote_project, remote_cx) = remote_zed.expand().await; + + remote_cx.run_until_parked(); + + let task = host_project.update(host_cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + remote_workspace.update(remote_cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + assert_eq!( + 1, + debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + ); + assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); + assert_eq!(1, active_debug_panel_item.read(cx).thread_id().0); + }); + + let shutdown_client = host_project.update(host_cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_client.await.unwrap(); +} + +#[gpui::test] +async fn test_active_debug_panel_item_set_on_join_project( + host_cx: &mut TestAppContext, + remote_cx: &mut TestAppContext, +) { + let executor = host_cx.executor(); + let mut server = TestServer::start(executor).await; + + let (mut host_zed, mut remote_zed) = + setup_two_member_test(&mut server, host_cx, remote_cx).await; + + let (host_project_id, _) = host_zed.host_project(None).await; + + let (_client_host, _host_workspace, host_project, host_cx) = host_zed.expand().await; + + host_cx.run_until_parked(); + + let task = host_project.update(host_cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + // Give host_client time to send a debug panel item to collab server + host_cx.run_until_parked(); + + remote_zed.join_project(host_project_id).await; + let (_client_remote, remote_workspace, _remote_project, remote_cx) = remote_zed.expand().await; + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + remote_workspace.update(remote_cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + assert_eq!( + 1, + debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + ); + assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); + assert_eq!(1, active_debug_panel_item.read(cx).thread_id().0); + }); + + let shutdown_client = host_project.update(host_cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_client.await.unwrap(); + + remote_cx.run_until_parked(); + + // assert we don't have a debug panel item anymore because the client shutdown + remote_workspace.update(remote_cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + debug_panel.update(cx, |this, cx| { + assert!(this.active_debug_panel_item(cx).is_none()); + assert_eq!(0, this.pane().unwrap().read(cx).items_len()); + }); + }); +} + +#[gpui::test] +async fn test_debug_panel_remote_button_presses( + host_cx: &mut TestAppContext, + remote_cx: &mut TestAppContext, +) { + let executor = host_cx.executor(); + let mut server = TestServer::start(executor).await; + + let (mut host_zed, mut remote_zed) = + setup_two_member_test(&mut server, host_cx, remote_cx).await; + + let (host_project_id, _) = host_zed.host_project(None).await; + remote_zed.join_project(host_project_id).await; + + let (_client_host, host_workspace, host_project, host_cx) = host_zed.expand().await; + let (_client_remote, remote_workspace, _remote_project, remote_cx) = remote_zed.expand().await; + + let task = host_project.update(host_cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (_, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(true), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + client + .on_request::(move |_, _| { + Ok(dap::ContinueResponse { + all_threads_continued: Some(true), + }) + }) + .await; + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + let remote_debug_item = remote_workspace.update(remote_cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + assert_eq!( + 1, + debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + ); + assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); + assert_eq!(1, active_debug_panel_item.read(cx).thread_id().0); + active_debug_panel_item + }); + + let local_debug_item = host_workspace.update(host_cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + assert_eq!( + 1, + debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + ); + assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); + assert_eq!(1, active_debug_panel_item.read(cx).thread_id().0); + active_debug_panel_item + }); + + remote_debug_item.update(remote_cx, |this, cx| { + this.continue_thread(cx); + }); + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + local_debug_item.update(host_cx, |debug_panel_item, cx| { + assert_eq!( + debugger_ui::debugger_panel::ThreadStatus::Running, + debug_panel_item.thread_state().read(cx).status, + ); + }); + + remote_debug_item.update(remote_cx, |debug_panel_item, cx| { + assert_eq!( + debugger_ui::debugger_panel::ThreadStatus::Running, + debug_panel_item.thread_state().read(cx).status, + ); + }); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + local_debug_item.update(host_cx, |debug_panel_item, cx| { + assert_eq!( + debugger_ui::debugger_panel::ThreadStatus::Stopped, + debug_panel_item.thread_state().read(cx).status, + ); + }); + + remote_debug_item.update(remote_cx, |debug_panel_item, cx| { + assert_eq!( + debugger_ui::debugger_panel::ThreadStatus::Stopped, + debug_panel_item.thread_state().read(cx).status, + ); + }); + + client + .on_request::(move |_, _| { + Ok(dap::ContinueResponse { + all_threads_continued: Some(true), + }) + }) + .await; + + local_debug_item.update(host_cx, |this, cx| { + this.continue_thread(cx); + }); + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + local_debug_item.update(host_cx, |debug_panel_item, cx| { + assert_eq!( + debugger_ui::debugger_panel::ThreadStatus::Running, + debug_panel_item.thread_state().read(cx).status, + ); + }); + + remote_debug_item.update(remote_cx, |debug_panel_item, cx| { + assert_eq!( + debugger_ui::debugger_panel::ThreadStatus::Running, + debug_panel_item.thread_state().read(cx).status, + ); + }); + + client + .on_request::(move |_, _| Ok(())) + .await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + remote_debug_item.update(remote_cx, |this, cx| { + this.pause_thread(cx); + }); + + remote_cx.run_until_parked(); + host_cx.run_until_parked(); + + client + .on_request::(move |_, _| Ok(())) + .await; + + remote_debug_item.update(remote_cx, |this, cx| { + this.step_out(cx); + }); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + remote_cx.run_until_parked(); + host_cx.run_until_parked(); + + client + .on_request::(move |_, _| Ok(())) + .await; + + remote_debug_item.update(remote_cx, |this, cx| { + this.step_over(cx); + }); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + remote_cx.run_until_parked(); + host_cx.run_until_parked(); + + client + .on_request::(move |_, _| Ok(())) + .await; + + remote_debug_item.update(remote_cx, |this, cx| { + this.step_in(cx); + }); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + remote_cx.run_until_parked(); + host_cx.run_until_parked(); + + client + .on_request::(move |_, _| Ok(())) + .await; + + remote_debug_item.update(remote_cx, |this, cx| { + this.step_back(cx); + }); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + remote_cx.run_until_parked(); + host_cx.run_until_parked(); + + remote_debug_item.update(remote_cx, |this, cx| { + this.stop_thread(cx); + }); + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + // assert we don't have a debug panel item anymore because the client shutdown + remote_workspace.update(remote_cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + debug_panel.update(cx, |this, cx| { + assert!(this.active_debug_panel_item(cx).is_none()); + assert_eq!(0, this.pane().unwrap().read(cx).items_len()); + }); + }); +} + +#[gpui::test] +async fn test_restart_stack_frame(host_cx: &mut TestAppContext, remote_cx: &mut TestAppContext) { + let executor = host_cx.executor(); + let mut server = TestServer::start(executor).await; + + let (mut host_zed, mut remote_zed) = + setup_two_member_test(&mut server, host_cx, remote_cx).await; + + let (host_project_id, _) = host_zed.host_project(None).await; + remote_zed.join_project(host_project_id).await; + + let (_client_host, _host_workspace, host_project, host_cx) = host_zed.expand().await; + let (_client_remote, remote_workspace, _remote_project, remote_cx) = remote_zed.expand().await; + + let called_restart_frame = Arc::new(AtomicBool::new(false)); + + let task = host_project.update(host_cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_restart_frame: Some(true), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + let stack_frames = vec![StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some("/project/src/test.js".into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 3, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + client + .on_request::({ + let called_restart_frame = called_restart_frame.clone(); + move |_, args| { + assert_eq!(1, args.frame_id); + + called_restart_frame.store(true, Ordering::SeqCst); + + Ok(()) + } + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + // try to restart stack frame 1 from the guest side + remote_workspace.update(remote_cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + active_debug_panel_item.update(cx, |debug_panel_item, cx| { + debug_panel_item + .stack_frame_list() + .update(cx, |stack_frame_list, cx| { + stack_frame_list.restart_stack_frame(1, cx); + }); + }); + }); + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + assert!( + called_restart_frame.load(std::sync::atomic::Ordering::SeqCst), + "Restart stack frame was not called" + ); + + let shutdown_client = host_project.update(host_cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_client.await.unwrap(); +} + +#[gpui::test] +async fn test_updated_breakpoints_send_to_dap( + host_cx: &mut TestAppContext, + remote_cx: &mut TestAppContext, +) { + let executor = host_cx.executor(); + let mut server = TestServer::start(executor).await; + + let (mut host_zed, mut remote_zed) = + setup_two_member_test(&mut server, host_cx, remote_cx).await; + + let (host_project_id, worktree_id) = host_zed + .host_project(Some(json!({"test.txt": "one\ntwo\nthree\nfour\nfive"}))) + .await; + + remote_zed.join_project(host_project_id).await; + + let (_client_host, host_workspace, host_project, host_cx) = host_zed.expand().await; + let (_client_remote, remote_workspace, _remote_project, remote_cx) = remote_zed.expand().await; + + let project_path = ProjectPath { + worktree_id, + path: Arc::from(Path::new(&"test.txt")), + }; + + let task = host_project.update(host_cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_restart_frame: Some(true), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + let called_set_breakpoints = Arc::new(AtomicBool::new(false)); + client + .on_request::({ + let called_set_breakpoints = called_set_breakpoints.clone(); + move |_, args| { + assert_eq!("/project/test.txt", args.source.path.unwrap()); + assert_eq!( + vec![SourceBreakpoint { + line: 3, + column: None, + condition: None, + hit_condition: None, + log_message: None, + mode: None + }], + args.breakpoints.unwrap() + ); + assert!(!args.source_modified.unwrap()); + + called_set_breakpoints.store(true, Ordering::SeqCst); + + Ok(dap::SetBreakpointsResponse { + breakpoints: Vec::default(), + }) + } + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + // Client B opens an editor. + let editor_b = remote_workspace + .update_in(remote_cx, |workspace, window, cx| { + workspace.open_path(project_path.clone(), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + editor_b.update_in(remote_cx, |editor, window, cx| { + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + }); + + // Client A opens an editor. + let editor_a = host_workspace + .update_in(host_cx, |workspace, window, cx| { + workspace.open_path(project_path.clone(), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + let called_set_breakpoints = Arc::new(AtomicBool::new(false)); + client + .on_request::({ + let called_set_breakpoints = called_set_breakpoints.clone(); + move |_, args| { + assert_eq!("/project/test.txt", args.source.path.unwrap()); + assert!(args.breakpoints.unwrap().is_empty()); + assert!(!args.source_modified.unwrap()); + + called_set_breakpoints.store(true, Ordering::SeqCst); + + Ok(dap::SetBreakpointsResponse { + breakpoints: Vec::default(), + }) + } + }) + .await; + + // remove the breakpoint that client B added + editor_a.update_in(host_cx, |editor, window, cx| { + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + }); + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + assert!( + called_set_breakpoints.load(std::sync::atomic::Ordering::SeqCst), + "SetBreakpoint request must be called" + ); + + let called_set_breakpoints = Arc::new(AtomicBool::new(false)); + client + .on_request::({ + let called_set_breakpoints = called_set_breakpoints.clone(); + move |_, args| { + assert_eq!("/project/test.txt", args.source.path.unwrap()); + let mut breakpoints = args.breakpoints.unwrap(); + breakpoints.sort_by_key(|b| b.line); + assert_eq!( + vec![ + SourceBreakpoint { + line: 2, + column: None, + condition: None, + hit_condition: None, + log_message: None, + mode: None + }, + SourceBreakpoint { + line: 3, + column: None, + condition: None, + hit_condition: None, + log_message: None, + mode: None + } + ], + breakpoints + ); + assert!(!args.source_modified.unwrap()); + + called_set_breakpoints.store(true, Ordering::SeqCst); + + Ok(dap::SetBreakpointsResponse { + breakpoints: Vec::default(), + }) + } + }) + .await; + + // Add our own breakpoint now + editor_a.update_in(host_cx, |editor, window, cx| { + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + editor.move_up(&editor::actions::MoveUp, window, cx); + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + }); + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + assert!( + called_set_breakpoints.load(std::sync::atomic::Ordering::SeqCst), + "SetBreakpoint request must be called" + ); + + let shutdown_client = host_project.update(host_cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_client.await.unwrap(); +} + +#[gpui::test] +async fn test_module_list( + host_cx: &mut TestAppContext, + remote_cx: &mut TestAppContext, + late_join_cx: &mut TestAppContext, +) { + let executor = host_cx.executor(); + let mut server = TestServer::start(executor).await; + + let (mut host_zed, mut remote_zed, mut late_join_zed) = + setup_three_member_test(&mut server, host_cx, remote_cx, late_join_cx).await; + + let (host_project_id, _worktree_id) = host_zed.host_project(None).await; + + remote_zed.join_project(host_project_id).await; + + let (_client_host, host_workspace, host_project, host_cx) = host_zed.expand().await; + let (_client_remote, remote_workspace, _remote_project, remote_cx) = remote_zed.expand().await; + + let task = host_project.update(host_cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + let called_initialize = Arc::new(AtomicBool::new(false)); + + client + .on_request::({ + let called_initialize = called_initialize.clone(); + move |_, _| { + called_initialize.store(true, Ordering::SeqCst); + Ok(dap::Capabilities { + supports_restart_frame: Some(true), + supports_modules_request: Some(true), + ..Default::default() + }) + } + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + let called_modules = Arc::new(AtomicBool::new(false)); + let modules = vec![ + dap::Module { + id: dap::ModuleId::Number(1), + name: "First Module".into(), + address_range: None, + date_time_stamp: None, + path: None, + symbol_file_path: None, + symbol_status: None, + version: None, + is_optimized: None, + is_user_code: None, + }, + dap::Module { + id: dap::ModuleId::Number(2), + name: "Second Module".into(), + address_range: None, + date_time_stamp: None, + path: None, + symbol_file_path: None, + symbol_status: None, + version: None, + is_optimized: None, + is_user_code: None, + }, + ]; + + client + .on_request::({ + let called_modules = called_modules.clone(); + let modules = modules.clone(); + move |_, _| unsafe { + static mut REQUEST_COUNT: i32 = 1; + assert_eq!( + 1, REQUEST_COUNT, + "This request should only be called once from the host" + ); + REQUEST_COUNT += 1; + called_modules.store(true, Ordering::SeqCst); + + Ok(dap::ModulesResponse { + modules: modules.clone(), + total_modules: Some(2u64), + }) + } + }) + .await; + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + assert!( + called_initialize.load(std::sync::atomic::Ordering::SeqCst), + "Request Initialize must be called" + ); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + assert!( + called_modules.load(std::sync::atomic::Ordering::SeqCst), + "Request Modules must be called" + ); + + host_workspace.update(host_cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + debug_panel_item.update(cx, |item, cx| { + assert_eq!( + true, + item.capabilities(cx).supports_modules_request.unwrap(), + "Local supports modules request should be true" + ); + + let local_module_list = item.module_list().update(cx, |list, cx| list.modules(cx)); + + assert_eq!( + 2usize, + local_module_list.len(), + "Local module list should have two items in it" + ); + assert_eq!( + modules.clone(), + local_module_list, + "Local module list should match module list from response" + ); + }) + }); + + remote_workspace.update(remote_cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + debug_panel_item.update(cx, |item, cx| { + assert_eq!( + true, + item.capabilities(cx).supports_modules_request.unwrap(), + "Remote capabilities supports modules request should be true" + ); + let remote_module_list = item.module_list().update(cx, |list, cx| list.modules(cx)); + + assert_eq!( + 2usize, + remote_module_list.len(), + "Remote module list should have two items in it" + ); + assert_eq!( + modules.clone(), + remote_module_list, + "Remote module list should match module list from response" + ); + }) + }); + + late_join_zed.join_project(host_project_id).await; + let (_late_join_client, late_join_workspace, _late_join_project, late_join_cx) = + late_join_zed.expand().await; + + late_join_workspace.update(late_join_cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + debug_panel_item.update(cx, |item, cx| { + assert_eq!( + true, + item.capabilities(cx).supports_modules_request.unwrap(), + "Remote (mid session join) capabilities supports modules request should be true" + ); + let remote_module_list = item.module_list().update(cx, |list, cx| list.modules(cx)); + + assert_eq!( + 2usize, + remote_module_list.len(), + "Remote (mid session join) module list should have two items in it" + ); + assert_eq!( + modules.clone(), + remote_module_list, + "Remote (mid session join) module list should match module list from response" + ); + }) + }); + + client.on_request::(move |_, _| Ok(())).await; + + let shutdown_client = host_project.update(host_cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_client.await.unwrap(); +} + +// #[gpui::test] +// async fn test_variable_list( +// host_cx: &mut TestAppContext, +// remote_cx: &mut TestAppContext, +// late_join_cx: &mut TestAppContext, +// ) { +// let executor = host_cx.executor(); +// let mut server = TestServer::start(executor).await; + +// let (mut host_zed, mut remote_zed, mut late_join_zed) = +// setup_three_member_test(&mut server, host_cx, remote_cx, late_join_cx).await; + +// let (host_project_id, _worktree_id) = host_zed +// .host_project(Some(json!({"test.txt": "one\ntwo\nthree\nfour\nfive"}))) +// .await; + +// remote_zed.join_project(host_project_id).await; + +// let (_client_host, host_workspace, host_project, host_cx) = host_zed.expand().await; +// let (_client_remote, remote_workspace, _remote_project, remote_cx) = remote_zed.expand().await; + +// let task = host_project.update(host_cx, |project, cx| { +// project.start_debug_session( +// dap::DebugAdapterConfig { +// label: "test config".into(), +// kind: dap::DebugAdapterKind::Fake, +// request: dap::DebugRequestType::Launch, +// program: None, +// cwd: None, +// initialize_args: None, +// }, +// cx, +// ) +// }); + +// let (session, client) = task.await.unwrap(); + +// client +// .on_request::(move |_, _| { +// Ok(dap::Capabilities { +// supports_step_back: Some(true), +// ..Default::default() +// }) +// }) +// .await; + +// client.on_request::(move |_, _| Ok(())).await; + +// let stack_frames = vec![dap::StackFrame { +// id: 1, +// name: "Stack Frame 1".into(), +// source: Some(dap::Source { +// name: Some("test.js".into()), +// path: Some("/project/src/test.js".into()), +// source_reference: None, +// presentation_hint: None, +// origin: None, +// sources: None, +// adapter_data: None, +// checksums: None, +// }), +// line: 1, +// column: 1, +// end_line: None, +// end_column: None, +// can_restart: None, +// instruction_pointer_reference: None, +// module_id: None, +// presentation_hint: None, +// }]; + +// let scopes = vec![Scope { +// name: "Scope 1".into(), +// presentation_hint: None, +// variables_reference: 1, +// named_variables: None, +// indexed_variables: None, +// expensive: false, +// source: None, +// line: None, +// column: None, +// end_line: None, +// end_column: None, +// }]; + +// let variable_1 = Variable { +// name: "variable 1".into(), +// value: "1".into(), +// type_: None, +// presentation_hint: None, +// evaluate_name: None, +// variables_reference: 2, +// named_variables: None, +// indexed_variables: None, +// memory_reference: None, +// }; + +// let variable_2 = Variable { +// name: "variable 2".into(), +// value: "2".into(), +// type_: None, +// presentation_hint: None, +// evaluate_name: None, +// variables_reference: 3, +// named_variables: None, +// indexed_variables: None, +// memory_reference: None, +// }; + +// let variable_3 = Variable { +// name: "variable 3".into(), +// value: "hello world".into(), +// type_: None, +// presentation_hint: None, +// evaluate_name: None, +// variables_reference: 4, +// named_variables: None, +// indexed_variables: None, +// memory_reference: None, +// }; + +// let variable_4 = Variable { +// name: "variable 4".into(), +// value: "hello world this is the final variable".into(), +// type_: None, +// presentation_hint: None, +// evaluate_name: None, +// variables_reference: 0, +// named_variables: None, +// indexed_variables: None, +// memory_reference: None, +// }; + +// client +// .on_request::({ +// let stack_frames = std::sync::Arc::new(stack_frames.clone()); +// move |_, args| { +// assert_eq!(1, args.thread_id); + +// Ok(dap::StackTraceResponse { +// stack_frames: (*stack_frames).clone(), +// total_frames: None, +// }) +// } +// }) +// .await; + +// client +// .on_request::({ +// let scopes = Arc::new(scopes.clone()); +// move |_, args| { +// assert_eq!(1, args.frame_id); + +// Ok(dap::ScopesResponse { +// scopes: (*scopes).clone(), +// }) +// } +// }) +// .await; + +// let first_variable_request = vec![variable_1.clone(), variable_2.clone()]; + +// client +// .on_request::({ +// move |_, args| { +// assert_eq!(1, args.variables_reference); + +// Ok(dap::VariablesResponse { +// variables: first_variable_request.clone(), +// }) +// } +// }) +// .await; + +// client +// .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { +// reason: dap::StoppedEventReason::Pause, +// description: None, +// thread_id: Some(1), +// preserve_focus_hint: None, +// text: None, +// all_threads_stopped: None, +// hit_breakpoint_ids: None, +// })) +// .await; + +// host_cx.run_until_parked(); +// remote_cx.run_until_parked(); + +// let local_debug_item = host_workspace.update(host_cx, |workspace, cx| { +// let debug_panel = workspace.panel::(cx).unwrap(); +// let active_debug_panel_item = debug_panel +// .update(cx, |this, cx| this.active_debug_panel_item(cx)) +// .unwrap(); + +// assert_eq!( +// 1, +// debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) +// ); +// assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); +// assert_eq!(1, active_debug_panel_item.read(cx).thread_id()); +// active_debug_panel_item +// }); + +// let remote_debug_item = remote_workspace.update(remote_cx, |workspace, cx| { +// let debug_panel = workspace.panel::(cx).unwrap(); +// let active_debug_panel_item = debug_panel +// .update(cx, |this, cx| this.active_debug_panel_item(cx)) +// .unwrap(); + +// assert_eq!( +// 1, +// debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) +// ); +// assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); +// assert_eq!(1, active_debug_panel_item.read(cx).thread_id()); +// active_debug_panel_item +// }); + +// let first_visual_entries = vec!["v Scope 1", " > variable 1", " > variable 2"]; +// let first_variable_containers = vec![ +// VariableContainer { +// container_reference: scopes[0].variables_reference, +// variable: variable_1.clone(), +// depth: 1, +// }, +// VariableContainer { +// container_reference: scopes[0].variables_reference, +// variable: variable_2.clone(), +// depth: 1, +// }, +// ]; + +// local_debug_item +// .update(host_cx, |this, _| this.variable_list().clone()) +// .update(host_cx, |variable_list, cx| { +// assert_eq!(1, variable_list.scopes().len()); +// assert_eq!(scopes, variable_list.scopes().get(&1).unwrap().clone()); +// assert_eq!(&first_variable_containers, &variable_list.variables()); + +// variable_list.assert_visual_entries(first_visual_entries.clone(), cx); +// }); + +// client +// .on_request::({ +// let variables = Arc::new(vec![variable_3.clone()]); +// move |_, args| { +// assert_eq!(2, args.variables_reference); + +// Ok(dap::VariablesResponse { +// variables: (*variables).clone(), +// }) +// } +// }) +// .await; + +// remote_debug_item +// .update(remote_cx, |this, _| this.variable_list().clone()) +// .update(remote_cx, |variable_list, cx| { +// assert_eq!(1, variable_list.scopes().len()); +// assert_eq!(scopes, variable_list.scopes().get(&1).unwrap().clone()); +// assert_eq!(&first_variable_containers, &variable_list.variables()); + +// variable_list.assert_visual_entries(first_visual_entries.clone(), cx); + +// variable_list.toggle_variable(&scopes[0], &variable_1, 1, cx); +// }); + +// host_cx.run_until_parked(); +// remote_cx.run_until_parked(); + +// let second_req_variable_list = vec![ +// VariableContainer { +// container_reference: scopes[0].variables_reference, +// variable: variable_1.clone(), +// depth: 1, +// }, +// VariableContainer { +// container_reference: variable_1.variables_reference, +// variable: variable_3.clone(), +// depth: 2, +// }, +// VariableContainer { +// container_reference: scopes[0].variables_reference, +// variable: variable_2.clone(), +// depth: 1, +// }, +// ]; + +// remote_debug_item +// .update(remote_cx, |this, _| this.variable_list().clone()) +// .update(remote_cx, |variable_list, cx| { +// assert_eq!(1, variable_list.scopes().len()); +// assert_eq!(3, variable_list.variables().len()); +// assert_eq!(scopes, variable_list.scopes().get(&1).unwrap().clone()); +// assert_eq!(&second_req_variable_list, &variable_list.variables()); + +// variable_list.assert_visual_entries( +// vec![ +// "v Scope 1", +// " v variable 1", +// " > variable 3", +// " > variable 2", +// ], +// cx, +// ); +// }); + +// client +// .on_request::({ +// let variables = Arc::new(vec![variable_4.clone()]); +// move |_, args| { +// assert_eq!(3, args.variables_reference); + +// Ok(dap::VariablesResponse { +// variables: (*variables).clone(), +// }) +// } +// }) +// .await; + +// local_debug_item +// .update(host_cx, |this, _| this.variable_list().clone()) +// .update(host_cx, |variable_list, cx| { +// assert_eq!(1, variable_list.scopes().len()); +// assert_eq!(3, variable_list.variables().len()); +// assert_eq!(scopes, variable_list.scopes().get(&1).unwrap().clone()); +// assert_eq!(&second_req_variable_list, &variable_list.variables()); + +// variable_list.assert_visual_entries(first_visual_entries.clone(), cx); + +// variable_list.toggle_variable(&scopes[0], &variable_2.clone(), 1, cx); +// }); + +// host_cx.run_until_parked(); +// remote_cx.run_until_parked(); + +// let final_variable_containers: Vec = vec![ +// VariableContainer { +// container_reference: scopes[0].variables_reference, +// variable: variable_1.clone(), +// depth: 1, +// }, +// VariableContainer { +// container_reference: variable_1.variables_reference, +// variable: variable_3.clone(), +// depth: 2, +// }, +// VariableContainer { +// container_reference: scopes[0].variables_reference, +// variable: variable_2.clone(), +// depth: 1, +// }, +// VariableContainer { +// container_reference: variable_2.variables_reference, +// variable: variable_4.clone(), +// depth: 2, +// }, +// ]; + +// remote_debug_item +// .update(remote_cx, |this, _| this.variable_list().clone()) +// .update(remote_cx, |variable_list, cx| { +// assert_eq!(1, variable_list.scopes().len()); +// assert_eq!(4, variable_list.variables().len()); +// assert_eq!(scopes, variable_list.scopes().get(&1).unwrap().clone()); +// assert_eq!(&final_variable_containers, &variable_list.variables()); + +// variable_list.assert_visual_entries( +// vec![ +// "v Scope 1", +// " v variable 1", +// " > variable 3", +// " > variable 2", +// ], +// cx, +// ); +// }); + +// local_debug_item +// .update(host_cx, |this, _| this.variable_list().clone()) +// .update(host_cx, |variable_list, cx| { +// assert_eq!(1, variable_list.scopes().len()); +// assert_eq!(4, variable_list.variables().len()); +// assert_eq!(scopes, variable_list.scopes().get(&1).unwrap().clone()); +// assert_eq!(&final_variable_containers, &variable_list.variables()); + +// variable_list.assert_visual_entries( +// vec![ +// "v Scope 1", +// " > variable 1", +// " v variable 2", +// " > variable 4", +// ], +// cx, +// ); +// }); + +// late_join_zed.join_project(host_project_id).await; +// let (_late_join_client, late_join_workspace, _late_join_project, late_join_cx) = +// late_join_zed.expand().await; + +// late_join_cx.run_until_parked(); + +// let last_join_remote_item = late_join_workspace.update(late_join_cx, |workspace, cx| { +// let debug_panel = workspace.panel::(cx).unwrap(); +// let active_debug_panel_item = debug_panel +// .update(cx, |this, cx| this.active_debug_panel_item(cx)) +// .unwrap(); + +// assert_eq!( +// 1, +// debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) +// ); +// assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); +// assert_eq!(1, active_debug_panel_item.read(cx).thread_id()); +// active_debug_panel_item +// }); + +// last_join_remote_item +// .update(late_join_cx, |this, _| this.variable_list().clone()) +// .update(late_join_cx, |variable_list, cx| { +// assert_eq!(1, variable_list.scopes().len()); +// assert_eq!(4, variable_list.variables().len()); +// assert_eq!(scopes, variable_list.scopes().get(&1).unwrap().clone()); +// assert_eq!(final_variable_containers, variable_list.variables()); + +// variable_list.assert_visual_entries(first_visual_entries, cx); +// }); + +// client.on_request::(move |_, _| Ok(())).await; + +// let shutdown_client = host_project.update(host_cx, |project, cx| { +// project.dap_store().update(cx, |dap_store, cx| { +// dap_store.shutdown_session(&session.read(cx).id(), cx) +// }) +// }); + +// shutdown_client.await.unwrap(); +// } + +#[gpui::test] +async fn test_ignore_breakpoints( + host_cx: &mut TestAppContext, + remote_cx: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + let executor = host_cx.executor(); + let mut server = TestServer::start(executor).await; + + let (mut host_zed, mut remote_zed, mut late_join_zed) = + setup_three_member_test(&mut server, host_cx, remote_cx, cx_c).await; + + let (host_project_id, worktree_id) = host_zed + .host_project(Some(json!({"test.txt": "one\ntwo\nthree\nfour\nfive"}))) + .await; + + remote_zed.join_project(host_project_id).await; + + let (_client_host, host_workspace, host_project, host_cx) = host_zed.expand().await; + let (_client_remote, remote_workspace, remote_project, remote_cx) = remote_zed.expand().await; + + let project_path = ProjectPath { + worktree_id, + path: Arc::from(Path::new(&"test.txt")), + }; + + let local_editor = host_workspace + .update_in(host_cx, |workspace, window, cx| { + workspace.open_path(project_path.clone(), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + local_editor.update_in(host_cx, |editor, window, cx| { + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); // Line 2 + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + // Line 3 + }); + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + let task = host_project.update(host_cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + let client_id = client.id(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_configuration_done_request: Some(true), + ..Default::default() + }) + }) + .await; + + let called_set_breakpoints = Arc::new(AtomicBool::new(false)); + client + .on_request::({ + let called_set_breakpoints = called_set_breakpoints.clone(); + move |_, args| { + assert_eq!("/project/test.txt", args.source.path.unwrap()); + + let mut actual_breakpoints = args.breakpoints.unwrap(); + actual_breakpoints.sort_by_key(|b| b.line); + + let expected_breakpoints = vec![ + SourceBreakpoint { + line: 2, + column: None, + condition: None, + hit_condition: None, + log_message: None, + mode: None, + }, + SourceBreakpoint { + line: 3, + column: None, + condition: None, + hit_condition: None, + log_message: None, + mode: None, + }, + ]; + + assert_eq!(actual_breakpoints, expected_breakpoints); + + called_set_breakpoints.store(true, Ordering::SeqCst); + + Ok(dap::SetBreakpointsResponse { + breakpoints: Vec::default(), + }) + } + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Initialized(Some( + dap::Capabilities { + supports_configuration_done_request: Some(true), + ..Default::default() + }, + ))) + .await; + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + assert!( + called_set_breakpoints.load(std::sync::atomic::Ordering::SeqCst), + "SetBreakpoint request must be called when starting debug session" + ); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + let remote_debug_item = remote_workspace.update(remote_cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + assert_eq!( + 1, + debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + ); + + let session_id = debug_panel.update(cx, |this, cx| { + this.dap_store() + .read(cx) + .session_by_client_id(client.id()) + .unwrap() + .read(cx) + .id() + }); + + let breakpoints_ignored = active_debug_panel_item.read(cx).are_breakpoints_ignored(cx); + + assert_eq!( + session_id, + active_debug_panel_item.read(cx).session().read(cx).id() + ); + assert_eq!(false, breakpoints_ignored); + assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); + assert_eq!(1, active_debug_panel_item.read(cx).thread_id().0); + active_debug_panel_item + }); + + called_set_breakpoints.store(false, Ordering::SeqCst); + + client + .on_request::({ + let called_set_breakpoints = called_set_breakpoints.clone(); + move |_, args| { + assert_eq!("/project/test.txt", args.source.path.unwrap()); + assert_eq!(args.breakpoints, Some(vec![])); + + called_set_breakpoints.store(true, Ordering::SeqCst); + + Ok(dap::SetBreakpointsResponse { + breakpoints: Vec::default(), + }) + } + }) + .await; + + let local_debug_item = host_workspace.update(host_cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + assert_eq!( + 1, + debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + ); + + assert_eq!( + false, + active_debug_panel_item.read(cx).are_breakpoints_ignored(cx) + ); + assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); + assert_eq!(1, active_debug_panel_item.read(cx).thread_id().0); + + active_debug_panel_item + }); + + local_debug_item.update(host_cx, |item, cx| { + item.toggle_ignore_breakpoints(cx); // Set to true + assert_eq!(true, item.are_breakpoints_ignored(cx)); + }); + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + assert!( + called_set_breakpoints.load(std::sync::atomic::Ordering::SeqCst), + "SetBreakpoint request must be called to ignore breakpoints" + ); + + client + .on_request::({ + let called_set_breakpoints = called_set_breakpoints.clone(); + move |_, _args| { + called_set_breakpoints.store(true, Ordering::SeqCst); + + Ok(dap::SetBreakpointsResponse { + breakpoints: Vec::default(), + }) + } + }) + .await; + + let remote_editor = remote_workspace + .update_in(remote_cx, |workspace, window, cx| { + workspace.open_path(project_path.clone(), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + called_set_breakpoints.store(false, std::sync::atomic::Ordering::SeqCst); + + remote_editor.update_in(remote_cx, |editor, window, cx| { + // Line 1 + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + }); + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + assert!( + called_set_breakpoints.load(std::sync::atomic::Ordering::SeqCst), + "SetBreakpoint request be called whenever breakpoints are toggled but with not breakpoints" + ); + + remote_debug_item.update(remote_cx, |debug_panel, cx| { + let breakpoints_ignored = debug_panel.are_breakpoints_ignored(cx); + + assert_eq!(true, breakpoints_ignored); + assert_eq!(client.id(), debug_panel.client_id()); + assert_eq!(1, debug_panel.thread_id().0); + }); + + client + .on_request::({ + let called_set_breakpoints = called_set_breakpoints.clone(); + move |_, args| { + assert_eq!("/project/test.txt", args.source.path.unwrap()); + + let mut actual_breakpoints = args.breakpoints.unwrap(); + actual_breakpoints.sort_by_key(|b| b.line); + + let expected_breakpoints = vec![ + SourceBreakpoint { + line: 1, + column: None, + condition: None, + hit_condition: None, + log_message: None, + mode: None, + }, + SourceBreakpoint { + line: 2, + column: None, + condition: None, + hit_condition: None, + log_message: None, + mode: None, + }, + SourceBreakpoint { + line: 3, + column: None, + condition: None, + hit_condition: None, + log_message: None, + mode: None, + }, + ]; + + assert_eq!(actual_breakpoints, expected_breakpoints); + + called_set_breakpoints.store(true, Ordering::SeqCst); + + Ok(dap::SetBreakpointsResponse { + breakpoints: Vec::default(), + }) + } + }) + .await; + + late_join_zed.join_project(host_project_id).await; + let (_late_join_client, late_join_workspace, late_join_project, late_join_cx) = + late_join_zed.expand().await; + + late_join_cx.run_until_parked(); + + let last_join_remote_item = late_join_workspace.update(late_join_cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + let breakpoints_ignored = active_debug_panel_item.read(cx).are_breakpoints_ignored(cx); + + assert_eq!(true, breakpoints_ignored); + + assert_eq!( + 1, + debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + ); + assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); + assert_eq!(1, active_debug_panel_item.read(cx).thread_id().0); + active_debug_panel_item + }); + + remote_debug_item.update(remote_cx, |item, cx| { + item.toggle_ignore_breakpoints(cx); + }); + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + late_join_cx.run_until_parked(); + + assert!( + called_set_breakpoints.load(std::sync::atomic::Ordering::SeqCst), + "SetBreakpoint request should be called to update breakpoints" + ); + + client + .on_request::({ + let called_set_breakpoints = called_set_breakpoints.clone(); + move |_, args| { + assert_eq!("/project/test.txt", args.source.path.unwrap()); + assert_eq!(args.breakpoints, Some(vec![])); + + called_set_breakpoints.store(true, Ordering::SeqCst); + + Ok(dap::SetBreakpointsResponse { + breakpoints: Vec::default(), + }) + } + }) + .await; + + local_debug_item.update(host_cx, |debug_panel_item, cx| { + assert_eq!( + false, + debug_panel_item.are_breakpoints_ignored(cx), + "Remote client set this to false" + ); + }); + + remote_debug_item.update(remote_cx, |debug_panel_item, cx| { + assert_eq!( + false, + debug_panel_item.are_breakpoints_ignored(cx), + "Remote client set this to false" + ); + }); + + last_join_remote_item.update(late_join_cx, |debug_panel_item, cx| { + assert_eq!( + false, + debug_panel_item.are_breakpoints_ignored(cx), + "Remote client set this to false" + ); + }); + + let shutdown_client = host_project.update(host_cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_client.await.unwrap(); + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + remote_project.update(remote_cx, |project, cx| { + project.dap_store().update(cx, |dap_store, _cx| { + let sessions = dap_store.sessions().collect::>(); + + assert_eq!( + None, + dap_store.session_by_client_id(&client_id), + "No client_id to session mapping should exist after shutdown" + ); + assert_eq!( + 0, + sessions.len(), + "No sessions should be left after shutdown" + ); + }) + }); + + late_join_project.update(late_join_cx, |project, cx| { + project.dap_store().update(cx, |dap_store, _cx| { + let sessions = dap_store.sessions().collect::>(); + + assert_eq!( + None, + dap_store.session_by_client_id(&client_id), + "No client_id to session mapping should exist after shutdown" + ); + assert_eq!( + 0, + sessions.len(), + "No sessions should be left after shutdown" + ); + }) + }); +} + +#[gpui::test] +async fn test_debug_panel_console(host_cx: &mut TestAppContext, remote_cx: &mut TestAppContext) { + let executor = host_cx.executor(); + let mut server = TestServer::start(executor).await; + + let (mut host_zed, mut remote_zed) = + setup_two_member_test(&mut server, host_cx, remote_cx).await; + + let (host_project_id, _) = host_zed.host_project(None).await; + remote_zed.join_project(host_project_id).await; + + let (_client_host, _host_workspace, host_project, host_cx) = host_zed.expand().await; + let (_client_remote, remote_workspace, _remote_project, remote_cx) = remote_zed.expand().await; + + remote_cx.run_until_parked(); + + let task = host_project.update(host_cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: None, + output: "First line".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "First group".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: Some(dap::OutputEventGroup::Start), + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "First item in group 1".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "Second item in group 1".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "Second group".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: Some(dap::OutputEventGroup::Start), + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "First item in group 2".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "Second item in group 2".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "End group 2".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: Some(dap::OutputEventGroup::End), + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "Third group".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: Some(dap::OutputEventGroup::StartCollapsed), + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "First item in group 3".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "Second item in group 3".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "End group 3".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: Some(dap::OutputEventGroup::End), + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "Third item in group 1".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "Second item".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: Some(dap::OutputEventGroup::End), + })) + .await; + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + active_debug_panel_item(remote_workspace, remote_cx).update( + remote_cx, + |debug_panel_item, cx| { + debug_panel_item.console().update(cx, |console, cx| { + console.editor().update(cx, |editor, cx| { + pretty_assertions::assert_eq!( + " + () + .unwrap(); + + // Client B opens same editor as A. + let editor_b = workspace_b + .update_in(cx_b, |workspace, window, cx| { + workspace.open_path(project_path.clone(), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + // Client A adds breakpoint on line (1) + editor_a.update_in(cx_a, |editor, window, cx| { + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + }); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let breakpoints_a = editor_a.update(cx_a, |editor, cx| { + editor + .breakpoint_store() + .clone() + .unwrap() + .read(cx) + .breakpoints() + .clone() + }); + let breakpoints_b = editor_b.update(cx_b, |editor, cx| { + editor + .breakpoint_store() + .clone() + .unwrap() + .read(cx) + .breakpoints() + .clone() + }); + + assert_eq!(1, breakpoints_a.len()); + assert_eq!(1, breakpoints_a.get(&project_path).unwrap().len()); + assert_eq!(breakpoints_a, breakpoints_b); + + // Client B adds breakpoint on line(2) + editor_b.update_in(cx_b, |editor, window, cx| { + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + }); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let breakpoints_a = editor_a.update(cx_a, |editor, cx| { + editor + .breakpoint_store() + .clone() + .unwrap() + .read(cx) + .breakpoints() + .clone() + }); + let breakpoints_b = editor_b.update(cx_b, |editor, cx| { + editor + .breakpoint_store() + .clone() + .unwrap() + .read(cx) + .breakpoints() + .clone() + }); + + assert_eq!(1, breakpoints_a.len()); + assert_eq!(breakpoints_a, breakpoints_b); + assert_eq!(2, breakpoints_a.get(&project_path).unwrap().len()); + + // Client A removes last added breakpoint from client B + editor_a.update_in(cx_a, |editor, window, cx| { + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + }); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let breakpoints_a = editor_a.update(cx_a, |editor, cx| { + editor + .breakpoint_store() + .clone() + .unwrap() + .read(cx) + .breakpoints() + .clone() + }); + let breakpoints_b = editor_b.update(cx_b, |editor, cx| { + editor + .breakpoint_store() + .clone() + .unwrap() + .read(cx) + .breakpoints() + .clone() + }); + + assert_eq!(1, breakpoints_a.len()); + assert_eq!(breakpoints_a, breakpoints_b); + assert_eq!(1, breakpoints_a.get(&project_path).unwrap().len()); + + // Client B removes first added breakpoint by client A + editor_b.update_in(cx_b, |editor, window, cx| { + editor.move_up(&editor::actions::MoveUp, window, cx); + editor.move_up(&editor::actions::MoveUp, window, cx); + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + }); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let breakpoints_a = editor_a.update(cx_a, |editor, cx| { + editor + .breakpoint_store() + .clone() + .unwrap() + .read(cx) + .breakpoints() + .clone() + }); + let breakpoints_b = editor_b.update(cx_b, |editor, cx| { + editor + .breakpoint_store() + .clone() + .unwrap() + .read(cx) + .breakpoints() + .clone() + }); + + assert_eq!(0, breakpoints_a.len()); + assert_eq!(breakpoints_a, breakpoints_b); +} + #[track_caller] fn tab_undo_assert( cx_a: &mut EditorTestContext, diff --git a/crates/dap/Cargo.toml b/crates/dap/Cargo.toml new file mode 100644 index 00000000000000..f2f1a7f613e8e1 --- /dev/null +++ b/crates/dap/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "dap" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[features] +test-support = [ + "gpui/test-support", + "util/test-support", + "task/test-support", + "async-pipe", + "settings/test-support", +] + +[dependencies] +anyhow.workspace = true +async-compression.workspace = true +async-pipe = { workspace = true, optional = true } +async-tar.workspace = true +async-trait.workspace = true +client.workspace = true +collections.workspace = true +dap-types = { git = "https://github.com/zed-industries/dap-types", rev = "bf5632dc19f806e8a435c9f04a4bfe7322badea2" } +fs.workspace = true +futures.workspace = true +gpui.workspace = true +http_client.workspace = true +language.workspace = true +log.workspace = true +node_runtime.workspace = true +parking_lot.workspace = true +paths.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +smallvec.workspace = true +smol.workspace = true +sysinfo.workspace = true +task.workspace = true +util.workspace = true + +[dev-dependencies] +async-pipe.workspace = true +env_logger.workspace = true +gpui = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } +task = { workspace = true, features = ["test-support"] } +util = { workspace = true, features = ["test-support"] } diff --git a/crates/dap/LICENSE-GPL b/crates/dap/LICENSE-GPL new file mode 120000 index 00000000000000..89e542f750cd38 --- /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 00000000000000..8b819b089bf8c4 --- /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 00000000000000..40b076b2919a73 --- /dev/null +++ b/crates/dap/src/adapters.rs @@ -0,0 +1,399 @@ +use ::fs::Fs; +use anyhow::{anyhow, Context as _, Ok, Result}; +use async_compression::futures::bufread::GzipDecoder; +use async_tar::Archive; +use async_trait::async_trait; +use futures::io::BufReader; +use gpui::{AsyncApp, SharedString}; +pub use http_client::{github::latest_github_release, HttpClient}; +use language::LanguageToolchainStore; +use node_runtime::NodeRuntime; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use settings::WorktreeId; +use smol::{self, fs::File, lock::Mutex}; +use std::{ + collections::{HashMap, HashSet}, + ffi::{OsStr, OsString}, + fmt::Debug, + net::Ipv4Addr, + ops::Deref, + path::{Path, PathBuf}, + sync::Arc, +}; +use sysinfo::{Pid, Process}; +use task::DebugAdapterConfig; +use util::ResultExt; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DapStatus { + None, + CheckingForUpdate, + Downloading, + Failed { error: String }, +} + +#[async_trait(?Send)] +pub trait DapDelegate { + fn worktree_id(&self) -> WorktreeId; + fn http_client(&self) -> Arc; + fn node_runtime(&self) -> NodeRuntime; + fn toolchain_store(&self) -> Arc; + fn fs(&self) -> Arc; + fn updated_adapters(&self) -> Arc>>; + fn update_status(&self, dap_name: DebugAdapterName, status: DapStatus); + fn which(&self, command: &OsStr) -> Option; + async fn shell_env(&self) -> collections::HashMap; +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)] +pub struct DebugAdapterName(pub Arc); + +impl Deref for DebugAdapterName { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef for DebugAdapterName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +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) + } +} + +impl From for SharedString { + fn from(name: DebugAdapterName) -> Self { + SharedString::from(name.0) + } +} + +impl<'a> From<&'a str> for DebugAdapterName { + fn from(str: &'a str) -> DebugAdapterName { + DebugAdapterName(str.to_string().into()) + } +} + +#[derive(Debug, Clone)] +pub struct TcpArguments { + pub host: Ipv4Addr, + pub port: Option, + pub timeout: Option, +} +#[derive(Debug, Clone)] +pub struct DebugAdapterBinary { + pub command: String, + pub arguments: Option>, + pub envs: Option>, + pub cwd: Option, + pub connection: Option, + #[cfg(any(test, feature = "test-support"))] + // todo(debugger) Find a way to remove this. It's a hack for FakeTransport + pub is_fake: bool, +} + +pub struct AdapterVersion { + pub tag_name: String, + pub url: String, +} + +pub enum DownloadedFileType { + Vsix, + GzipTar, + Zip, +} + +pub struct GithubRepo { + pub repo_name: String, + pub repo_owner: String, +} + +pub async fn download_adapter_from_github( + adapter_name: DebugAdapterName, + github_version: AdapterVersion, + file_type: DownloadedFileType, + delegate: &dyn DapDelegate, +) -> Result { + let adapter_path = paths::debug_adapters_dir().join(&adapter_name); + let version_path = adapter_path.join(format!("{}_{}", adapter_name, github_version.tag_name)); + let fs = delegate.fs(); + + if version_path.exists() { + return Ok(version_path); + } + + if !adapter_path.exists() { + fs.create_dir(&adapter_path.as_path()) + .await + .context("Failed creating adapter path")?; + } + + log::debug!( + "Downloading adapter {} from {}", + adapter_name, + &github_version.url, + ); + + let mut response = delegate + .http_client() + .get(&github_version.url, Default::default(), true) + .await + .context("Error downloading release")?; + if !response.status().is_success() { + Err(anyhow!( + "download failed with status {}", + response.status().to_string() + ))?; + } + + match file_type { + DownloadedFileType::GzipTar => { + let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut())); + let archive = Archive::new(decompressed_bytes); + archive.unpack(&version_path).await?; + } + DownloadedFileType::Zip | DownloadedFileType::Vsix => { + let zip_path = version_path.with_extension("zip"); + + let mut file = File::create(&zip_path).await?; + futures::io::copy(response.body_mut(), &mut file).await?; + + // we cannot check the status as some adapter include files with names that trigger `Illegal byte sequence` + util::command::new_smol_command("unzip") + .arg(&zip_path) + .arg("-d") + .arg(&version_path) + .output() + .await?; + + util::fs::remove_matching(&adapter_path, |entry| { + entry + .file_name() + .is_some_and(|file| file.to_string_lossy().ends_with(".zip")) + }) + .await; + } + } + + // remove older versions + util::fs::remove_matching(&adapter_path, |entry| { + entry.to_string_lossy() != version_path.to_string_lossy() + }) + .await; + + Ok(version_path) +} + +pub async fn fetch_latest_adapter_version_from_github( + github_repo: GithubRepo, + delegate: &dyn DapDelegate, +) -> Result { + let release = latest_github_release( + &format!("{}/{}", github_repo.repo_owner, github_repo.repo_name), + false, + false, + delegate.http_client(), + ) + .await?; + + Ok(AdapterVersion { + tag_name: release.tag_name, + url: release.zipball_url, + }) +} + +#[async_trait(?Send)] +pub trait DebugAdapter: 'static + Send + Sync { + fn name(&self) -> DebugAdapterName; + + async fn get_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + user_installed_path: Option, + cx: &mut AsyncApp, + ) -> Result { + if delegate + .updated_adapters() + .lock() + .await + .contains(&self.name()) + { + log::info!("Using cached debug adapter binary {}", self.name()); + + if let Some(binary) = self + .get_installed_binary(delegate, &config, user_installed_path.clone(), cx) + .await + .log_err() + { + return Ok(binary); + } + + log::info!( + "Cached binary {} is corrupt falling back to install", + self.name() + ); + } + + log::info!("Getting latest version of debug adapter {}", self.name()); + delegate.update_status(self.name(), DapStatus::CheckingForUpdate); + if let Some(version) = self.fetch_latest_adapter_version(delegate).await.log_err() { + log::info!( + "Installiing latest version of debug adapter {}", + self.name() + ); + delegate.update_status(self.name(), DapStatus::Downloading); + self.install_binary(version, delegate).await?; + + delegate + .updated_adapters() + .lock_arc() + .await + .insert(self.name()); + } + + self.get_installed_binary(delegate, &config, user_installed_path, cx) + .await + } + + async fn fetch_latest_adapter_version( + &self, + delegate: &dyn DapDelegate, + ) -> 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, + version: AdapterVersion, + delegate: &dyn DapDelegate, + ) -> Result<()>; + + async fn get_installed_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + user_installed_path: Option, + cx: &mut AsyncApp, + ) -> Result; + + /// Should return base configuration to make the debug adapter work + fn request_args(&self, config: &DebugAdapterConfig) -> Value; + + /// Filters out the processes that the adapter can attach to for debugging + fn attach_processes<'a>( + &self, + _: &'a HashMap, + ) -> Option> { + None + } +} + +#[cfg(any(test, feature = "test-support"))] +pub struct FakeAdapter {} + +#[cfg(any(test, feature = "test-support"))] +impl FakeAdapter { + const ADAPTER_NAME: &'static str = "fake-adapter"; + + pub fn new() -> Self { + Self {} + } +} + +#[cfg(any(test, feature = "test-support"))] +#[async_trait(?Send)] +impl DebugAdapter for FakeAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn get_binary( + &self, + _: &dyn DapDelegate, + _: &DebugAdapterConfig, + _: Option, + _: &mut AsyncApp, + ) -> Result { + Ok(DebugAdapterBinary { + command: "command".into(), + arguments: None, + connection: Some(TcpArguments { + host: Ipv4Addr::LOCALHOST, + port: None, + timeout: None, + }), + is_fake: true, + envs: None, + cwd: None, + }) + } + + async fn fetch_latest_adapter_version( + &self, + _delegate: &dyn DapDelegate, + ) -> Result { + unimplemented!("fetch latest adapter version"); + } + + async fn install_binary( + &self, + _version: AdapterVersion, + _delegate: &dyn DapDelegate, + ) -> Result<()> { + unimplemented!("install binary"); + } + + async fn get_installed_binary( + &self, + _: &dyn DapDelegate, + _: &DebugAdapterConfig, + _: Option, + _: &mut AsyncApp, + ) -> Result { + unimplemented!("get installed binary"); + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + use serde_json::json; + use task::DebugRequestType; + + json!({ + "request": match config.request { + DebugRequestType::Launch => "launch", + DebugRequestType::Attach(_) => "attach", + }, + "process_id": if let DebugRequestType::Attach(attach_config) = &config.request { + attach_config.process_id + } else { + None + }, + }) + } + + fn attach_processes<'a>( + &self, + processes: &'a HashMap, + ) -> Option> { + Some( + processes + .iter() + .filter(|(pid, _)| pid.as_u32() == std::process::id()) + .collect::>(), + ) + } +} diff --git a/crates/dap/src/client.rs b/crates/dap/src/client.rs new file mode 100644 index 00000000000000..5aad4935e90a0c --- /dev/null +++ b/crates/dap/src/client.rs @@ -0,0 +1,474 @@ +use crate::{ + adapters::DebugAdapterBinary, + transport::{IoKind, LogKind, TransportDelegate}, +}; +use anyhow::{anyhow, Result}; +use dap_types::{ + messages::{Message, Response}, + requests::Request, +}; +use futures::{channel::oneshot, select, FutureExt as _}; +use gpui::{App, AsyncApp, BackgroundExecutor}; +use smol::channel::{Receiver, Sender}; +use std::{ + hash::Hash, + sync::atomic::{AtomicU64, Ordering}, + time::Duration, +}; + +#[cfg(any(test, feature = "test-support"))] +const DAP_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); + +#[cfg(not(any(test, feature = "test-support")))] +const DAP_REQUEST_TIMEOUT: Duration = Duration::from_secs(12); + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(transparent)] +pub struct SessionId(pub u32); + +impl SessionId { + pub fn from_proto(client_id: u64) -> Self { + Self(client_id as u32) + } + + pub fn to_proto(&self) -> u64 { + self.0 as u64 + } +} + +/// Represents a connection to the debug adapter process, either via stdout/stdin or a socket. +pub struct DebugAdapterClient { + id: SessionId, + sequence_count: AtomicU64, + binary: DebugAdapterBinary, + executor: BackgroundExecutor, + transport_delegate: TransportDelegate, +} + +pub type DapMessageHandler = Box; + +impl DebugAdapterClient { + pub async fn start( + id: SessionId, + binary: DebugAdapterBinary, + message_handler: DapMessageHandler, + cx: AsyncApp, + ) -> Result { + let ((server_rx, server_tx), transport_delegate) = + TransportDelegate::start(&binary, cx.clone()).await?; + let this = Self { + id, + binary, + transport_delegate, + sequence_count: AtomicU64::new(1), + executor: cx.background_executor().clone(), + }; + log::info!("Successfully connected to debug adapter"); + + let client_id = this.id; + + // start handling events/reverse requests + cx.update(|cx| { + cx.spawn({ + let server_tx = server_tx.clone(); + |mut cx| async move { + Self::handle_receive_messages( + client_id, + server_rx, + server_tx, + message_handler, + &mut cx, + ) + .await + } + }) + .detach_and_log_err(cx); + + this + }) + } + + async fn handle_receive_messages( + client_id: SessionId, + server_rx: Receiver, + client_tx: Sender, + mut message_handler: DapMessageHandler, + cx: &mut AsyncApp, + ) -> Result<()> { + let result = loop { + let message = match server_rx.recv().await { + Ok(message) => message, + Err(e) => break Err(e.into()), + }; + + if let Err(e) = match message { + Message::Event(ev) => { + log::debug!("Client {} received event `{}`", client_id.0, &ev); + + cx.update(|cx| message_handler(Message::Event(ev), cx)) + } + Message::Request(req) => cx.update(|cx| message_handler(Message::Request(req), cx)), + Message::Response(response) => { + log::debug!("Received response after request timeout: {:#?}", response); + + Ok(()) + } + } { + break Err(e); + } + + smol::future::yield_now().await; + }; + + drop(client_tx); + + log::debug!("Handle receive messages dropped"); + + result + } + + /// 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) = oneshot::channel::>(); + + 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_delegate + .add_pending_request(sequence_id, callback_tx) + .await; + + log::debug!( + "Client {} send `{}` request with sequence_id: {}", + self.id.0, + R::COMMAND.to_string(), + sequence_id + ); + + self.send_message(Message::Request(request)).await?; + + let mut timeout = self.executor.timer(DAP_REQUEST_TIMEOUT).fuse(); + let command = R::COMMAND.to_string(); + + select! { + response = callback_rx.fuse() => { + log::debug!( + "Client {} received response for: `{}` sequence_id: {}", + self.id.0, + command, + sequence_id + ); + + let response = response??; + match response.success { + true => Ok(serde_json::from_value(response.body.unwrap_or_default())?), + false => Err(anyhow!("Request failed")), + } + } + + _ = timeout => { + self.transport_delegate.cancel_pending_request(&sequence_id).await; + log::error!("Cancelled DAP request for {command:?} id {sequence_id} which took over {DAP_REQUEST_TIMEOUT:?}"); + anyhow::bail!("DAP request timeout"); + } + } + } + + pub async fn send_message(&self, message: Message) -> Result<()> { + self.transport_delegate.send_message(message).await + } + + pub fn id(&self) -> SessionId { + self.id + } + + pub fn binary(&self) -> &DebugAdapterBinary { + &self.binary + } + + /// 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_delegate.shutdown().await + } + + pub fn has_adapter_logs(&self) -> bool { + self.transport_delegate.has_adapter_logs() + } + + pub fn add_log_handler(&self, f: F, kind: LogKind) + where + F: 'static + Send + FnMut(IoKind, &str), + { + self.transport_delegate.add_log_handler(f, kind); + } + + #[cfg(any(test, feature = "test-support"))] + pub async fn on_request(&self, handler: F) + where + F: 'static + + Send + + FnMut(u64, R::Arguments) -> Result, + { + let transport = self.transport_delegate.transport(); + + transport.on_request::(handler).await; + } + + #[cfg(any(test, feature = "test-support"))] + pub async fn fake_reverse_request(&self, args: R::Arguments) { + self.send_message(Message::Request(dap_types::messages::Request { + seq: self.sequence_count.load(Ordering::Relaxed), + command: R::COMMAND.into(), + arguments: serde_json::to_value(args).ok(), + })) + .await + .unwrap(); + } + + #[cfg(any(test, feature = "test-support"))] + pub async fn on_response(&self, handler: F) + where + F: 'static + Send + Fn(Response), + { + let transport = self.transport_delegate.transport(); + + transport.on_response::(handler).await; + } + + #[cfg(any(test, feature = "test-support"))] + pub async fn fake_event(&self, event: dap_types::messages::Events) { + self.send_message(Message::Event(Box::new(event))) + .await + .unwrap(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{client::DebugAdapterClient, debugger_settings::DebuggerSettings}; + use dap_types::{ + messages::Events, + requests::{Initialize, Request, RunInTerminal}, + Capabilities, InitializeRequestArguments, InitializeRequestArgumentsPathFormat, + RunInTerminalRequestArguments, + }; + use gpui::TestAppContext; + use serde_json::json; + use settings::{Settings, SettingsStore}; + use std::sync::atomic::{AtomicBool, Ordering}; + + pub fn init_test(cx: &mut gpui::TestAppContext) { + if std::env::var("RUST_LOG").is_ok() { + env_logger::try_init().ok(); + } + + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + DebuggerSettings::register(cx); + }); + } + + #[gpui::test] + pub async fn test_initialize_client(cx: &mut TestAppContext) { + init_test(cx); + + let mut client = DebugAdapterClient::start( + crate::client::SessionId(1), + DebugAdapterBinary { + command: "command".into(), + arguments: Default::default(), + envs: Default::default(), + connection: Some(crate::adapters::TcpArguments { + host: Ipv4Addr::LOCALHOST, + port: None, + timeout: None, + }), + is_fake: true, + cwd: None, + }, + |_, _| panic!("Did not expect to hit this code path"), + cx.to_async(), + ) + .await + .unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap_types::Capabilities { + supports_configuration_done_request: Some(true), + ..Default::default() + }) + }) + .await; + + cx.run_until_parked(); + + let response = client + .request::(InitializeRequestArguments { + client_id: Some("zed".to_owned()), + client_name: Some("Zed".to_owned()), + adapter_id: "fake-adapter".to_owned(), + 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(true), + supports_memory_references: Some(true), + supports_progress_reporting: Some(false), + supports_invalidated_event: Some(false), + lines_start_at1: Some(true), + columns_start_at1: Some(true), + supports_memory_event: Some(false), + supports_args_can_be_interpreted_by_shell: Some(false), + supports_start_debugging_request: Some(true), + }) + .await + .unwrap(); + + cx.run_until_parked(); + + assert_eq!( + dap_types::Capabilities { + supports_configuration_done_request: Some(true), + ..Default::default() + }, + response + ); + + client.shutdown().await.unwrap(); + } + + #[gpui::test] + pub async fn test_calls_event_handler(cx: &mut TestAppContext) { + init_test(cx); + + let called_event_handler = Arc::new(AtomicBool::new(false)); + + let client = DebugAdapterClient::start( + crate::client::SessionId(1), + DebugAdapterBinary { + command: "command".into(), + arguments: Default::default(), + envs: Default::default(), + connection: Some(TcpArguments { + host: Ipv4Addr::LOCALHOST, + port: None, + timeout: None, + }), + is_fake: true, + cwd: None, + }, + { + let called_event_handler = called_event_handler.clone(); + move |event, _| { + called_event_handler.store(true, Ordering::SeqCst); + + assert_eq!( + Message::Event(Box::new(Events::Initialized( + Some(Capabilities::default()) + ))), + event + ); + } + }, + cx.to_async(), + ) + .await + .unwrap(); + + cx.run_until_parked(); + + client + .fake_event(Events::Initialized(Some(Capabilities::default()))) + .await; + + cx.run_until_parked(); + + assert!( + called_event_handler.load(std::sync::atomic::Ordering::SeqCst), + "Event handler was not called" + ); + + client.shutdown().await.unwrap(); + } + + #[gpui::test] + pub async fn test_calls_event_handler_for_reverse_request(cx: &mut TestAppContext) { + init_test(cx); + + let called_event_handler = Arc::new(AtomicBool::new(false)); + + let client = DebugAdapterClient::start( + crate::client::SessionId(1), + DebugAdapterBinary { + command: "command".into(), + arguments: Default::default(), + envs: Default::default(), + connection: Some(TcpArguments { + host: Ipv4Addr::LOCALHOST, + port: None, + timeout: None, + }), + is_fake: true, + cwd: None, + }, + { + let called_event_handler = called_event_handler.clone(); + move |event, _| { + called_event_handler.store(true, Ordering::SeqCst); + + assert_eq!( + Message::Request(dap_types::messages::Request { + seq: 1, + command: RunInTerminal::COMMAND.into(), + arguments: Some(json!({ + "cwd": "/project/path/src", + "args": ["node", "test.js"], + })) + }), + event + ); + } + }, + cx.to_async(), + ) + .await + .unwrap(); + + cx.run_until_parked(); + + client + .fake_reverse_request::(RunInTerminalRequestArguments { + kind: None, + title: None, + cwd: "/project/path/src".into(), + args: vec!["node".into(), "test.js".into()], + env: None, + args_can_be_interpreted_by_shell: None, + }) + .await; + + cx.run_until_parked(); + + assert!( + called_event_handler.load(std::sync::atomic::Ordering::SeqCst), + "Event handler was not called" + ); + + client.shutdown().await.unwrap(); + } +} diff --git a/crates/dap/src/debugger_settings.rs b/crates/dap/src/debugger_settings.rs new file mode 100644 index 00000000000000..5b83927dfe8ebe --- /dev/null +++ b/crates/dap/src/debugger_settings.rs @@ -0,0 +1,59 @@ +use dap_types::SteppingGranularity; +use gpui::{App, 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, + /// Time in milliseconds until timeout error when connecting to a TCP debug adapter + /// + /// Default: 2000ms + pub timeout: u64, + /// Whether to log messages between active debug adapters and Zed + /// + /// Default: true + pub log_dap_communications: bool, + /// Whether to format dap messages in when adding them to debug adapter logger + /// + /// Default: true + pub format_dap_log_messages: bool, +} + +impl Default for DebuggerSettings { + fn default() -> Self { + Self { + button: true, + save_breakpoints: true, + stepping_granularity: SteppingGranularity::Line, + timeout: 2000, + log_dap_communications: true, + format_dap_log_messages: true, + } + } +} + +impl Settings for DebuggerSettings { + const KEY: Option<&'static str> = Some("debugger"); + + type FileContent = Self; + + fn load(sources: SettingsSources, _: &mut App) -> 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 00000000000000..d83c0931cb0efc --- /dev/null +++ b/crates/dap/src/lib.rs @@ -0,0 +1,24 @@ +pub mod adapters; +pub mod client; +pub mod debugger_settings; +pub mod proto_conversions; +pub mod transport; + +pub use dap_types::*; +pub use task::{DebugAdapterConfig, DebugAdapterKind, DebugRequestType}; + +#[cfg(any(test, feature = "test-support"))] +pub use adapters::FakeAdapter; + +#[cfg(any(test, feature = "test-support"))] +pub fn test_config() -> DebugAdapterConfig { + DebugAdapterConfig { + label: "test config".into(), + kind: DebugAdapterKind::Fake, + request: DebugRequestType::Launch, + program: None, + supports_attach: false, + cwd: None, + initialize_args: None, + } +} diff --git a/crates/dap/src/proto_conversions.rs b/crates/dap/src/proto_conversions.rs new file mode 100644 index 00000000000000..41af22cc7dd672 --- /dev/null +++ b/crates/dap/src/proto_conversions.rs @@ -0,0 +1,638 @@ +use anyhow::{anyhow, Result}; +use client::proto::{ + self, DapChecksum, DapChecksumAlgorithm, DapEvaluateContext, DapModule, DapScope, + DapScopePresentationHint, DapSource, DapSourcePresentationHint, DapStackFrame, DapVariable, + SetDebugClientCapabilities, +}; +use dap_types::{ + Capabilities, OutputEventCategory, OutputEventGroup, ScopePresentationHint, Source, +}; + +pub trait ProtoConversion { + type ProtoType; + type Output; + + fn to_proto(&self) -> Self::ProtoType; + fn from_proto(payload: Self::ProtoType) -> Self::Output; +} + +impl ProtoConversion for Vec +where + T: ProtoConversion, +{ + type ProtoType = Vec; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + self.iter().map(|item| item.to_proto()).collect() + } + + fn from_proto(payload: Self::ProtoType) -> Self { + payload + .into_iter() + .map(|item| T::from_proto(item)) + .collect() + } +} + +impl ProtoConversion for dap_types::Scope { + type ProtoType = DapScope; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + Self::ProtoType { + name: self.name.clone(), + presentation_hint: self + .presentation_hint + .as_ref() + .map(|hint| hint.to_proto().into()), + variables_reference: self.variables_reference, + named_variables: self.named_variables, + indexed_variables: self.indexed_variables, + expensive: self.expensive, + source: self.source.as_ref().map(Source::to_proto), + line: self.line, + end_line: self.end_line, + column: self.column, + end_column: self.end_column, + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + let presentation_hint = payload + .presentation_hint + .and_then(DapScopePresentationHint::from_i32); + Self { + name: payload.name, + presentation_hint: presentation_hint.map(ScopePresentationHint::from_proto), + variables_reference: payload.variables_reference, + named_variables: payload.named_variables, + indexed_variables: payload.indexed_variables, + expensive: payload.expensive, + source: payload.source.map(dap_types::Source::from_proto), + line: payload.line, + end_line: payload.end_line, + column: payload.column, + end_column: payload.end_column, + } + } +} + +impl ProtoConversion for dap_types::Variable { + type ProtoType = DapVariable; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + Self::ProtoType { + name: self.name.clone(), + value: self.value.clone(), + r#type: self.type_.clone(), + evaluate_name: self.evaluate_name.clone(), + variables_reference: self.variables_reference, + named_variables: self.named_variables, + indexed_variables: self.indexed_variables, + memory_reference: self.memory_reference.clone(), + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + Self { + name: payload.name, + value: payload.value, + type_: payload.r#type, + evaluate_name: payload.evaluate_name, + presentation_hint: None, // TODO Debugger Collab Add this + variables_reference: payload.variables_reference, + named_variables: payload.named_variables, + indexed_variables: payload.indexed_variables, + memory_reference: payload.memory_reference, + } + } +} + +impl ProtoConversion for dap_types::ScopePresentationHint { + type ProtoType = DapScopePresentationHint; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + match self { + dap_types::ScopePresentationHint::Locals => DapScopePresentationHint::Locals, + dap_types::ScopePresentationHint::Arguments => DapScopePresentationHint::Arguments, + dap_types::ScopePresentationHint::Registers => DapScopePresentationHint::Registers, + dap_types::ScopePresentationHint::ReturnValue => DapScopePresentationHint::ReturnValue, + dap_types::ScopePresentationHint::Unknown => DapScopePresentationHint::ScopeUnknown, + &_ => unreachable!(), + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + match payload { + DapScopePresentationHint::Locals => dap_types::ScopePresentationHint::Locals, + DapScopePresentationHint::Arguments => dap_types::ScopePresentationHint::Arguments, + DapScopePresentationHint::Registers => dap_types::ScopePresentationHint::Registers, + DapScopePresentationHint::ReturnValue => dap_types::ScopePresentationHint::ReturnValue, + DapScopePresentationHint::ScopeUnknown => dap_types::ScopePresentationHint::Unknown, + } + } +} + +impl ProtoConversion for dap_types::SourcePresentationHint { + type ProtoType = DapSourcePresentationHint; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + match self { + dap_types::SourcePresentationHint::Normal => DapSourcePresentationHint::SourceNormal, + dap_types::SourcePresentationHint::Emphasize => DapSourcePresentationHint::Emphasize, + dap_types::SourcePresentationHint::Deemphasize => { + DapSourcePresentationHint::Deemphasize + } + dap_types::SourcePresentationHint::Unknown => DapSourcePresentationHint::SourceUnknown, + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + match payload { + DapSourcePresentationHint::SourceNormal => dap_types::SourcePresentationHint::Normal, + DapSourcePresentationHint::Emphasize => dap_types::SourcePresentationHint::Emphasize, + DapSourcePresentationHint::Deemphasize => { + dap_types::SourcePresentationHint::Deemphasize + } + DapSourcePresentationHint::SourceUnknown => dap_types::SourcePresentationHint::Unknown, + } + } +} + +impl ProtoConversion for dap_types::Checksum { + type ProtoType = DapChecksum; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + DapChecksum { + algorithm: self.algorithm.to_proto().into(), + checksum: self.checksum.clone(), + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + Self { + algorithm: dap_types::ChecksumAlgorithm::from_proto(payload.algorithm()), + checksum: payload.checksum, + } + } +} + +impl ProtoConversion for dap_types::ChecksumAlgorithm { + type ProtoType = DapChecksumAlgorithm; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + match self { + dap_types::ChecksumAlgorithm::Md5 => DapChecksumAlgorithm::Md5, + dap_types::ChecksumAlgorithm::Sha1 => DapChecksumAlgorithm::Sha1, + dap_types::ChecksumAlgorithm::Sha256 => DapChecksumAlgorithm::Sha256, + dap_types::ChecksumAlgorithm::Timestamp => DapChecksumAlgorithm::Timestamp, + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + match payload { + DapChecksumAlgorithm::Md5 => dap_types::ChecksumAlgorithm::Md5, + DapChecksumAlgorithm::Sha1 => dap_types::ChecksumAlgorithm::Sha1, + DapChecksumAlgorithm::Sha256 => dap_types::ChecksumAlgorithm::Sha256, + DapChecksumAlgorithm::Timestamp => dap_types::ChecksumAlgorithm::Timestamp, + DapChecksumAlgorithm::ChecksumAlgorithmUnspecified => unreachable!(), + } + } +} + +impl ProtoConversion for dap_types::Source { + type ProtoType = DapSource; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + Self::ProtoType { + name: self.name.clone(), + path: self.path.clone(), + source_reference: self.source_reference, + presentation_hint: self.presentation_hint.map(|hint| hint.to_proto().into()), + origin: self.origin.clone(), + sources: self + .sources + .clone() + .map(|src| src.to_proto()) + .unwrap_or_default(), + adapter_data: Default::default(), // TODO Debugger Collab + checksums: self + .checksums + .clone() + .map(|c| c.to_proto()) + .unwrap_or_default(), + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + Self { + name: payload.name.clone(), + path: payload.path.clone(), + source_reference: payload.source_reference, + presentation_hint: payload + .presentation_hint + .and_then(DapSourcePresentationHint::from_i32) + .map(dap_types::SourcePresentationHint::from_proto), + origin: payload.origin.clone(), + sources: Some(Vec::::from_proto(payload.sources)), + checksums: Some(Vec::::from_proto(payload.checksums)), + adapter_data: None, // TODO Debugger Collab + } + } +} + +impl ProtoConversion for dap_types::StackFrame { + type ProtoType = DapStackFrame; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + Self::ProtoType { + id: self.id, + name: self.name.clone(), + source: self.source.as_ref().map(|src| src.to_proto()), + line: self.line, + column: self.column, + end_line: self.end_line, + end_column: self.end_column, + can_restart: self.can_restart, + instruction_pointer_reference: self.instruction_pointer_reference.clone(), + module_id: None, // TODO Debugger Collab + presentation_hint: None, // TODO Debugger Collab + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + Self { + id: payload.id, + name: payload.name, + source: payload.source.map(dap_types::Source::from_proto), + line: payload.line, + column: payload.column, + end_line: payload.end_line, + end_column: payload.end_column, + can_restart: payload.can_restart, + instruction_pointer_reference: payload.instruction_pointer_reference, + module_id: None, // TODO Debugger Collab + presentation_hint: None, // TODO Debugger Collab + } + } +} + +impl ProtoConversion for dap_types::Module { + type ProtoType = DapModule; + type Output = Result; + + fn to_proto(&self) -> Self::ProtoType { + let id = match &self.id { + dap_types::ModuleId::Number(num) => proto::dap_module_id::Id::Number(*num), + dap_types::ModuleId::String(string) => proto::dap_module_id::Id::String(string.clone()), + }; + + DapModule { + id: Some(proto::DapModuleId { id: Some(id) }), + name: self.name.clone(), + path: self.path.clone(), + is_optimized: self.is_optimized, + is_user_code: self.is_user_code, + version: self.version.clone(), + symbol_status: self.symbol_status.clone(), + symbol_file_path: self.symbol_file_path.clone(), + date_time_stamp: self.date_time_stamp.clone(), + address_range: self.address_range.clone(), + } + } + + fn from_proto(payload: Self::ProtoType) -> Result { + let id = match payload + .id + .ok_or(anyhow!("All DapModule proto messages must have an id"))? + .id + .ok_or(anyhow!("All DapModuleID proto messages must have an id"))? + { + proto::dap_module_id::Id::String(string) => dap_types::ModuleId::String(string), + proto::dap_module_id::Id::Number(num) => dap_types::ModuleId::Number(num), + }; + + Ok(Self { + id, + name: payload.name, + path: payload.path, + is_optimized: payload.is_optimized, + is_user_code: payload.is_user_code, + version: payload.version, + symbol_status: payload.symbol_status, + symbol_file_path: payload.symbol_file_path, + date_time_stamp: payload.date_time_stamp, + address_range: payload.address_range, + }) + } +} + +pub fn capabilities_from_proto(payload: &SetDebugClientCapabilities) -> Capabilities { + Capabilities { + supports_loaded_sources_request: Some(payload.supports_loaded_sources_request), + supports_modules_request: Some(payload.supports_modules_request), + supports_restart_request: Some(payload.supports_restart_request), + supports_set_expression: Some(payload.supports_set_expression), + supports_single_thread_execution_requests: Some( + payload.supports_single_thread_execution_requests, + ), + supports_step_back: Some(payload.supports_step_back), + supports_stepping_granularity: Some(payload.supports_stepping_granularity), + supports_terminate_threads_request: Some(payload.supports_terminate_threads_request), + supports_restart_frame: Some(payload.supports_restart_frame_request), + supports_clipboard_context: Some(payload.supports_clipboard_context), + ..Default::default() + } +} + +pub fn capabilities_to_proto( + capabilities: &Capabilities, + project_id: u64, + session_id: u64, +) -> SetDebugClientCapabilities { + SetDebugClientCapabilities { + session_id, + project_id, + supports_loaded_sources_request: capabilities + .supports_loaded_sources_request + .unwrap_or_default(), + supports_modules_request: capabilities.supports_modules_request.unwrap_or_default(), + supports_restart_request: capabilities.supports_restart_request.unwrap_or_default(), + supports_set_expression: capabilities.supports_set_expression.unwrap_or_default(), + supports_single_thread_execution_requests: capabilities + .supports_single_thread_execution_requests + .unwrap_or_default(), + supports_step_back: capabilities.supports_step_back.unwrap_or_default(), + supports_stepping_granularity: capabilities + .supports_stepping_granularity + .unwrap_or_default(), + supports_terminate_threads_request: capabilities + .supports_terminate_threads_request + .unwrap_or_default(), + supports_restart_frame_request: capabilities.supports_restart_frame.unwrap_or_default(), + supports_clipboard_context: capabilities.supports_clipboard_context.unwrap_or_default(), + } +} + +impl ProtoConversion for dap_types::SteppingGranularity { + type ProtoType = proto::SteppingGranularity; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + match self { + dap_types::SteppingGranularity::Statement => proto::SteppingGranularity::Statement, + dap_types::SteppingGranularity::Line => proto::SteppingGranularity::Line, + dap_types::SteppingGranularity::Instruction => proto::SteppingGranularity::Instruction, + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + match payload { + proto::SteppingGranularity::Line => dap_types::SteppingGranularity::Line, + proto::SteppingGranularity::Instruction => dap_types::SteppingGranularity::Instruction, + proto::SteppingGranularity::Statement => dap_types::SteppingGranularity::Statement, + } + } +} + +impl ProtoConversion for dap_types::OutputEventCategory { + type ProtoType = proto::DapOutputCategory; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + match self { + Self::Console => proto::DapOutputCategory::ConsoleOutput, + Self::Important => proto::DapOutputCategory::Important, + Self::Stdout => proto::DapOutputCategory::Stdout, + Self::Stderr => proto::DapOutputCategory::Stderr, + _ => proto::DapOutputCategory::Unknown, + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + match payload { + proto::DapOutputCategory::ConsoleOutput => Self::Console, + proto::DapOutputCategory::Important => Self::Important, + proto::DapOutputCategory::Stdout => Self::Stdout, + proto::DapOutputCategory::Stderr => Self::Stderr, + proto::DapOutputCategory::Unknown => Self::Unknown, + } + } +} + +impl ProtoConversion for dap_types::OutputEvent { + type ProtoType = proto::DapOutputEvent; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + proto::DapOutputEvent { + category: self + .category + .as_ref() + .map(|category| category.to_proto().into()), + output: self.output.clone(), + variables_reference: self.variables_reference, + source: self.source.as_ref().map(|source| source.to_proto()), + line: self.line.map(|line| line as u32), + column: self.column.map(|column| column as u32), + group: self.group.map(|group| group.to_proto().into()), + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + dap_types::OutputEvent { + category: payload + .category + .and_then(proto::DapOutputCategory::from_i32) + .map(OutputEventCategory::from_proto), + output: payload.output.clone(), + variables_reference: payload.variables_reference, + source: payload.source.map(Source::from_proto), + line: payload.line.map(|line| line as u64), + column: payload.column.map(|column| column as u64), + group: payload + .group + .and_then(proto::DapOutputEventGroup::from_i32) + .map(OutputEventGroup::from_proto), + data: None, + } + } +} + +impl ProtoConversion for dap_types::OutputEventGroup { + type ProtoType = proto::DapOutputEventGroup; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + match self { + dap_types::OutputEventGroup::Start => proto::DapOutputEventGroup::Start, + dap_types::OutputEventGroup::StartCollapsed => { + proto::DapOutputEventGroup::StartCollapsed + } + dap_types::OutputEventGroup::End => proto::DapOutputEventGroup::End, + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + match payload { + proto::DapOutputEventGroup::Start => Self::Start, + proto::DapOutputEventGroup::StartCollapsed => Self::StartCollapsed, + proto::DapOutputEventGroup::End => Self::End, + } + } +} + +impl ProtoConversion for dap_types::CompletionItem { + type ProtoType = proto::DapCompletionItem; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + proto::DapCompletionItem { + label: self.label.clone(), + text: self.text.clone(), + detail: self.detail.clone(), + typ: self + .type_ + .as_ref() + .map(ProtoConversion::to_proto) + .map(|typ| typ.into()), + start: self.start, + length: self.length, + selection_start: self.selection_start, + selection_length: self.selection_length, + sort_text: self.sort_text.clone(), + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + let typ = payload.typ(); // todo(debugger): This might be a potential issue/bug because it defaults to a type when it's None + + Self { + label: payload.label, + detail: payload.detail, + sort_text: payload.sort_text, + text: payload.text.clone(), + type_: Some(dap_types::CompletionItemType::from_proto(typ)), + start: payload.start, + length: payload.length, + selection_start: payload.selection_start, + selection_length: payload.selection_length, + } + } +} + +impl ProtoConversion for dap_types::EvaluateArgumentsContext { + type ProtoType = DapEvaluateContext; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + match self { + dap_types::EvaluateArgumentsContext::Variables => { + proto::DapEvaluateContext::EvaluateVariables + } + dap_types::EvaluateArgumentsContext::Watch => proto::DapEvaluateContext::Watch, + dap_types::EvaluateArgumentsContext::Hover => proto::DapEvaluateContext::Hover, + dap_types::EvaluateArgumentsContext::Repl => proto::DapEvaluateContext::Repl, + dap_types::EvaluateArgumentsContext::Clipboard => proto::DapEvaluateContext::Clipboard, + dap_types::EvaluateArgumentsContext::Unknown => { + proto::DapEvaluateContext::EvaluateUnknown + } + _ => proto::DapEvaluateContext::EvaluateUnknown, + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + match payload { + proto::DapEvaluateContext::EvaluateVariables => { + dap_types::EvaluateArgumentsContext::Variables + } + proto::DapEvaluateContext::Watch => dap_types::EvaluateArgumentsContext::Watch, + proto::DapEvaluateContext::Hover => dap_types::EvaluateArgumentsContext::Hover, + proto::DapEvaluateContext::Repl => dap_types::EvaluateArgumentsContext::Repl, + proto::DapEvaluateContext::Clipboard => dap_types::EvaluateArgumentsContext::Clipboard, + proto::DapEvaluateContext::EvaluateUnknown => { + dap_types::EvaluateArgumentsContext::Unknown + } + } + } +} + +impl ProtoConversion for dap_types::CompletionItemType { + type ProtoType = proto::DapCompletionItemType; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + match self { + dap_types::CompletionItemType::Class => proto::DapCompletionItemType::Class, + dap_types::CompletionItemType::Color => proto::DapCompletionItemType::Color, + dap_types::CompletionItemType::Constructor => proto::DapCompletionItemType::Constructor, + dap_types::CompletionItemType::Customcolor => proto::DapCompletionItemType::Customcolor, + dap_types::CompletionItemType::Enum => proto::DapCompletionItemType::Enum, + dap_types::CompletionItemType::Field => proto::DapCompletionItemType::Field, + dap_types::CompletionItemType::File => proto::DapCompletionItemType::CompletionItemFile, + dap_types::CompletionItemType::Function => proto::DapCompletionItemType::Function, + dap_types::CompletionItemType::Interface => proto::DapCompletionItemType::Interface, + dap_types::CompletionItemType::Keyword => proto::DapCompletionItemType::Keyword, + dap_types::CompletionItemType::Method => proto::DapCompletionItemType::Method, + dap_types::CompletionItemType::Module => proto::DapCompletionItemType::Module, + dap_types::CompletionItemType::Property => proto::DapCompletionItemType::Property, + dap_types::CompletionItemType::Reference => proto::DapCompletionItemType::Reference, + dap_types::CompletionItemType::Snippet => proto::DapCompletionItemType::Snippet, + dap_types::CompletionItemType::Text => proto::DapCompletionItemType::Text, + dap_types::CompletionItemType::Unit => proto::DapCompletionItemType::Unit, + dap_types::CompletionItemType::Value => proto::DapCompletionItemType::Value, + dap_types::CompletionItemType::Variable => proto::DapCompletionItemType::Variable, + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + match payload { + proto::DapCompletionItemType::Class => dap_types::CompletionItemType::Class, + proto::DapCompletionItemType::Color => dap_types::CompletionItemType::Color, + proto::DapCompletionItemType::CompletionItemFile => dap_types::CompletionItemType::File, + proto::DapCompletionItemType::Constructor => dap_types::CompletionItemType::Constructor, + proto::DapCompletionItemType::Customcolor => dap_types::CompletionItemType::Customcolor, + proto::DapCompletionItemType::Enum => dap_types::CompletionItemType::Enum, + proto::DapCompletionItemType::Field => dap_types::CompletionItemType::Field, + proto::DapCompletionItemType::Function => dap_types::CompletionItemType::Function, + proto::DapCompletionItemType::Interface => dap_types::CompletionItemType::Interface, + proto::DapCompletionItemType::Keyword => dap_types::CompletionItemType::Keyword, + proto::DapCompletionItemType::Method => dap_types::CompletionItemType::Method, + proto::DapCompletionItemType::Module => dap_types::CompletionItemType::Module, + proto::DapCompletionItemType::Property => dap_types::CompletionItemType::Property, + proto::DapCompletionItemType::Reference => dap_types::CompletionItemType::Reference, + proto::DapCompletionItemType::Snippet => dap_types::CompletionItemType::Snippet, + proto::DapCompletionItemType::Text => dap_types::CompletionItemType::Text, + proto::DapCompletionItemType::Unit => dap_types::CompletionItemType::Unit, + proto::DapCompletionItemType::Value => dap_types::CompletionItemType::Value, + proto::DapCompletionItemType::Variable => dap_types::CompletionItemType::Variable, + } + } +} + +impl ProtoConversion for dap_types::Thread { + type ProtoType = proto::DapThread; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + proto::DapThread { + id: self.id, + name: self.name.clone(), + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + Self { + id: payload.id, + name: payload.name, + } + } +} diff --git a/crates/dap/src/transport.rs b/crates/dap/src/transport.rs new file mode 100644 index 00000000000000..162ce2ed462271 --- /dev/null +++ b/crates/dap/src/transport.rs @@ -0,0 +1,934 @@ +use anyhow::{anyhow, bail, Context, Result}; +use dap_types::{ + messages::{Message, Response}, + ErrorResponse, +}; +use futures::{channel::oneshot, select, AsyncRead, AsyncReadExt as _, AsyncWrite, FutureExt as _}; +use gpui::AsyncApp; +use settings::Settings as _; +use smallvec::SmallVec; +use smol::{ + channel::{unbounded, Receiver, Sender}, + io::{AsyncBufReadExt as _, AsyncWriteExt, BufReader}, + lock::Mutex, + net::{TcpListener, TcpStream}, + process::Child, +}; +use std::{ + collections::HashMap, + net::{Ipv4Addr, SocketAddrV4}, + process::Stdio, + sync::Arc, + time::Duration, +}; +use task::TCPHost; +use util::ResultExt as _; + +use crate::{adapters::DebugAdapterBinary, debugger_settings::DebuggerSettings}; + +pub type IoHandler = Box; + +#[derive(PartialEq, Eq, Clone, Copy)] +pub enum LogKind { + Adapter, + Rpc, +} + +pub enum IoKind { + StdIn, + StdOut, + StdErr, +} + +pub struct TransportPipe { + input: Box, + output: Box, + stdout: Option>, + stderr: Option>, +} + +impl TransportPipe { + pub fn new( + input: Box, + output: Box, + stdout: Option>, + stderr: Option>, + ) -> Self { + TransportPipe { + input, + output, + stdout, + stderr, + } + } +} + +type Requests = Arc>>>>; +type LogHandlers = Arc>>; + +enum Transport { + Stdio(StdioTransport), + Tcp(TcpTransport), + #[cfg(any(test, feature = "test-support"))] + Fake(FakeTransport), +} + +impl Transport { + async fn start(binary: &DebugAdapterBinary, cx: AsyncApp) -> Result<(TransportPipe, Self)> { + #[cfg(any(test, feature = "test-support"))] + if binary.is_fake { + return FakeTransport::start(cx) + .await + .map(|(transports, fake)| (transports, Self::Fake(fake))); + } + + if binary.connection.is_some() { + TcpTransport::start(binary, cx) + .await + .map(|(transports, tcp)| (transports, Self::Tcp(tcp))) + } else { + StdioTransport::start(binary, cx) + .await + .map(|(transports, stdio)| (transports, Self::Stdio(stdio))) + } + } + + fn has_adapter_logs(&self) -> bool { + match self { + Transport::Stdio(stdio_transport) => stdio_transport.has_adapter_logs(), + Transport::Tcp(tcp_transport) => tcp_transport.has_adapter_logs(), + #[cfg(any(test, feature = "test-support"))] + Transport::Fake(fake_transport) => fake_transport.has_adapter_logs(), + } + } + + async fn kill(&self) -> Result<()> { + match self { + Transport::Stdio(stdio_transport) => stdio_transport.kill().await, + Transport::Tcp(tcp_transport) => tcp_transport.kill().await, + #[cfg(any(test, feature = "test-support"))] + Transport::Fake(fake_transport) => fake_transport.kill().await, + } + } + + #[cfg(any(test, feature = "test-support"))] + fn as_fake(&self) -> &FakeTransport { + match self { + Transport::Fake(fake_transport) => fake_transport, + _ => panic!("Not a fake transport layer"), + } + } +} + +pub(crate) struct TransportDelegate { + log_handlers: LogHandlers, + current_requests: Requests, + pending_requests: Requests, + transport: Transport, + server_tx: Arc>>>, +} + +impl Transport { + #[cfg(any(test, feature = "test-support"))] + fn _fake(_args: DebugAdapterBinary) -> Self { + todo!() + } +} + +impl TransportDelegate { + #[cfg(any(test, feature = "test-support"))] + pub fn _fake(args: DebugAdapterBinary) -> Self { + Self { + transport: Transport::_fake(args), + server_tx: Default::default(), + log_handlers: Default::default(), + current_requests: Default::default(), + pending_requests: Default::default(), + } + } + + pub(crate) async fn start( + binary: &DebugAdapterBinary, + cx: AsyncApp, + ) -> Result<((Receiver, Sender), Self)> { + let (transport_pipes, transport) = Transport::start(binary, cx.clone()).await?; + let mut this = Self { + transport, + server_tx: Default::default(), + log_handlers: Default::default(), + current_requests: Default::default(), + pending_requests: Default::default(), + }; + let messages = this.start_handlers(transport_pipes, cx).await?; + Ok((messages, this)) + } + + async fn start_handlers( + &mut self, + mut params: TransportPipe, + cx: AsyncApp, + ) -> Result<(Receiver, Sender)> { + let (client_tx, server_rx) = unbounded::(); + let (server_tx, client_rx) = unbounded::(); + + let log_dap_communications = + cx.update(|cx| DebuggerSettings::get_global(cx).log_dap_communications) + .with_context(|| "Failed to get Debugger Setting log dap communications error in transport::start_handlers. Defaulting to false") + .unwrap_or(false); + + let log_handler = if log_dap_communications { + Some(self.log_handlers.clone()) + } else { + None + }; + + cx.update(|cx| { + if let Some(stdout) = params.stdout.take() { + cx.background_executor() + .spawn(Self::handle_adapter_log(stdout, log_handler.clone())) + .detach_and_log_err(cx); + } + + cx.background_executor() + .spawn(Self::handle_output( + params.output, + client_tx, + self.pending_requests.clone(), + log_handler.clone(), + )) + .detach_and_log_err(cx); + + if let Some(stderr) = params.stderr.take() { + cx.background_executor() + .spawn(Self::handle_error(stderr, self.log_handlers.clone())) + .detach_and_log_err(cx); + } + + cx.background_executor() + .spawn(Self::handle_input( + params.input, + client_rx, + self.current_requests.clone(), + self.pending_requests.clone(), + log_handler.clone(), + )) + .detach_and_log_err(cx); + })?; + + { + let mut lock = self.server_tx.lock().await; + *lock = Some(server_tx.clone()); + } + + Ok((server_rx, server_tx)) + } + + pub(crate) async fn add_pending_request( + &self, + sequence_id: u64, + request: oneshot::Sender>, + ) { + let mut pending_requests = self.pending_requests.lock().await; + pending_requests.insert(sequence_id, request); + } + + pub(crate) async fn cancel_pending_request(&self, sequence_id: &u64) { + let mut pending_requests = self.pending_requests.lock().await; + pending_requests.remove(sequence_id); + } + + pub(crate) async fn send_message(&self, message: Message) -> Result<()> { + if let Some(server_tx) = self.server_tx.lock().await.as_ref() { + server_tx + .send(message) + .await + .map_err(|e| anyhow!("Failed to send message: {}", e)) + } else { + Err(anyhow!("Server tx already dropped")) + } + } + + async fn handle_adapter_log( + stdout: Stdout, + log_handlers: Option, + ) -> Result<()> + where + Stdout: AsyncRead + Unpin + Send + 'static, + { + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + + let result = loop { + line.truncate(0); + + let bytes_read = match reader.read_line(&mut line).await { + Ok(bytes_read) => bytes_read, + Err(e) => break Err(e.into()), + }; + + if bytes_read == 0 { + break Err(anyhow!("Debugger log stream closed")); + } + + if let Some(log_handlers) = log_handlers.as_ref() { + for (kind, handler) in log_handlers.lock().iter_mut() { + if matches!(kind, LogKind::Adapter) { + handler(IoKind::StdOut, line.as_str()); + } + } + } + + smol::future::yield_now().await; + }; + + log::debug!("Handle adapter log dropped"); + + result + } + + fn build_rpc_message(message: String) -> String { + format!("Content-Length: {}\r\n\r\n{}", message.len(), message) + } + + async fn handle_input( + mut server_stdin: Stdin, + client_rx: Receiver, + current_requests: Requests, + pending_requests: Requests, + log_handlers: Option, + ) -> Result<()> + where + Stdin: AsyncWrite + Unpin + Send + 'static, + { + let result = loop { + match client_rx.recv().await { + Ok(message) => { + if let Message::Request(request) = &message { + if let Some(sender) = current_requests.lock().await.remove(&request.seq) { + pending_requests.lock().await.insert(request.seq, sender); + } + } + + let message = match serde_json::to_string(&message) { + Ok(message) => message, + Err(e) => break Err(e.into()), + }; + + if let Some(log_handlers) = log_handlers.as_ref() { + for (kind, log_handler) in log_handlers.lock().iter_mut() { + if matches!(kind, LogKind::Rpc) { + log_handler(IoKind::StdIn, &message); + } + } + } + + if let Err(e) = server_stdin + .write_all(Self::build_rpc_message(message).as_bytes()) + .await + { + break Err(e.into()); + } + + if let Err(e) = server_stdin.flush().await { + break Err(e.into()); + } + } + Err(error) => break Err(error.into()), + } + + smol::future::yield_now().await; + }; + + log::debug!("Handle adapter input dropped"); + + result + } + + async fn handle_output( + server_stdout: Stdout, + client_tx: Sender, + pending_requests: Requests, + log_handlers: Option, + ) -> Result<()> + where + Stdout: AsyncRead + Unpin + Send + 'static, + { + let mut recv_buffer = String::new(); + let mut reader = BufReader::new(server_stdout); + + let result = loop { + let message = + Self::receive_server_message(&mut reader, &mut recv_buffer, log_handlers.as_ref()) + .await; + + match message { + Ok(Message::Response(res)) => { + if let Some(tx) = pending_requests.lock().await.remove(&res.request_seq) { + if let Err(e) = tx.send(Self::process_response(res)) { + break Err(anyhow!("Failed to send response: {:?}", e)); + } + } else { + client_tx.send(Message::Response(res)).await?; + }; + } + Ok(message) => { + client_tx.send(message).await?; + } + Err(e) => break Err(e), + } + + smol::future::yield_now().await; + }; + + drop(client_tx); + + log::debug!("Handle adapter output dropped"); + + result + } + + async fn handle_error(stderr: Stderr, log_handlers: LogHandlers) -> Result<()> + where + Stderr: AsyncRead + Unpin + Send + 'static, + { + let mut buffer = String::new(); + + let mut reader = BufReader::new(stderr); + + let result = loop { + match reader.read_line(&mut buffer).await { + Ok(0) => break Err(anyhow!("debugger error stream closed")), + Ok(_) => { + for (kind, log_handler) in log_handlers.lock().iter_mut() { + if matches!(kind, LogKind::Adapter) { + log_handler(IoKind::StdErr, buffer.as_str()); + } + } + + buffer.truncate(0); + } + Err(error) => break Err(error.into()), + } + + smol::future::yield_now().await; + }; + + log::debug!("Handle adapter error dropped"); + + result + } + + fn process_response(response: Response) -> Result { + if response.success { + Ok(response) + } else { + if let Some(body) = response.body.clone() { + 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. Response: {:?}", + response.clone() + )) + } + } + + async fn receive_server_message( + reader: &mut BufReader, + buffer: &mut String, + log_handlers: Option<&LogHandlers>, + ) -> Result + where + Stdout: AsyncRead + Unpin + Send + 'static, + { + 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 message = std::str::from_utf8(&content).context("invalid utf8 from server")?; + + if let Some(log_handlers) = log_handlers { + for (kind, log_handler) in log_handlers.lock().iter_mut() { + if matches!(kind, LogKind::Rpc) { + log_handler(IoKind::StdOut, &message); + } + } + } + + Ok(serde_json::from_str::(message)?) + } + + pub async fn shutdown(&self) -> Result<()> { + log::debug!("Start shutdown client"); + + if let Some(server_tx) = self.server_tx.lock().await.take().as_ref() { + server_tx.close(); + } + + let mut current_requests = self.current_requests.lock().await; + let mut pending_requests = self.pending_requests.lock().await; + + current_requests.clear(); + pending_requests.clear(); + + let _ = self.transport.kill().await.log_err(); + + drop(current_requests); + drop(pending_requests); + + log::debug!("Shutdown client completed"); + + anyhow::Ok(()) + } + + pub fn has_adapter_logs(&self) -> bool { + self.transport.has_adapter_logs() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn transport(&self) -> Arc<&FakeTransport> { + Arc::new(self.transport.as_fake()) + } + + pub fn add_log_handler(&self, f: F, kind: LogKind) + where + F: 'static + Send + FnMut(IoKind, &str), + { + let mut log_handlers = self.log_handlers.lock(); + log_handlers.push((kind, Box::new(f))); + } +} + +pub struct TcpTransport { + port: u16, + host: Ipv4Addr, + timeout: u64, + process: Mutex, +} + +impl TcpTransport { + /// Get an open port to use with the tcp client when not supplied by debug config + pub async fn port(host: &TCPHost) -> Result { + if let Some(port) = host.port { + Ok(port) + } else { + Ok(TcpListener::bind(SocketAddrV4::new(host.host(), 0)) + .await? + .local_addr()? + .port()) + } + } + async fn port_for_host(host: Ipv4Addr) -> Result { + Ok(TcpListener::bind(SocketAddrV4::new(host, 0)) + .await? + .local_addr()? + .port()) + } + + async fn start(binary: &DebugAdapterBinary, cx: AsyncApp) -> Result<(TransportPipe, Self)> { + let Some(connection_args) = binary.connection.as_ref() else { + return Err(anyhow!("No connection arguments provided")); + }; + let host = connection_args.host; + let port = if let Some(port) = connection_args.port { + port + } else { + TcpListener::bind(SocketAddrV4::new(host, 0)) + .await + .with_context(|| { + format!( + "Failed to connect to debug adapter over tcp. host: {}", + host + ) + })? + .local_addr()? + .port() + }; + + let mut command = util::command::new_smol_command(&binary.command); + + if let Some(cwd) = &binary.cwd { + command.current_dir(cwd); + } + + if let Some(args) = &binary.arguments { + command.args(args); + } + + if let Some(envs) = &binary.envs { + command.envs(envs); + } + + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let mut process = command + .spawn() + .with_context(|| "failed to start debug adapter.")?; + + let address = SocketAddrV4::new(host, port); + + let timeout = connection_args.timeout.unwrap_or_else(|| { + cx.update(|cx| DebuggerSettings::get_global(cx).timeout) + .unwrap_or(2000u64) + }); + + let (rx, tx) = select! { + _ = cx.background_executor().timer(Duration::from_millis(timeout)).fuse() => { + return Err(anyhow!(format!("Connection to TCP DAP timeout {}:{}", host, port))) + }, + result = cx.spawn(|cx| async move { + loop { + match TcpStream::connect(address).await { + Ok(stream) => return stream.split(), + Err(_) => { + cx.background_executor().timer(Duration::from_millis(100)).await; + } + } + } + }).fuse() => result + }; + log::info!( + "Debug adapter has connected to TCP server {}:{}", + host, + port + ); + let stdout = process.stdout.take(); + let stderr = process.stderr.take(); + + let this = Self { + port, + host, + process: Mutex::new(process), + timeout, + }; + + let pipe = TransportPipe::new( + Box::new(tx), + Box::new(BufReader::new(rx)), + stdout.map(|s| Box::new(s) as Box), + stderr.map(|s| Box::new(s) as Box), + ); + + Ok((pipe, this)) + } + + fn has_adapter_logs(&self) -> bool { + true + } + + async fn kill(&self) -> Result<()> { + self.process.lock().await.kill()?; + + Ok(()) + } +} + +pub struct StdioTransport { + process: Mutex, +} + +impl StdioTransport { + async fn start(binary: &DebugAdapterBinary, _: AsyncApp) -> Result<(TransportPipe, Self)> { + let mut command = util::command::new_smol_command(&binary.command); + + if let Some(cwd) = &binary.cwd { + command.current_dir(cwd); + } + + if let Some(args) = &binary.arguments { + command.args(args); + } + + if let Some(envs) = &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 + .stdout + .take() + .map(|io_err| Box::new(io_err) as Box); + + if stderr.is_none() { + log::error!( + "Failed to connect to stderr for debug adapter command {}", + &binary.command + ); + } + + log::info!("Debug adapter has connected to stdio adapter"); + + let process = Mutex::new(process); + + Ok(( + TransportPipe::new( + Box::new(stdin), + Box::new(BufReader::new(stdout)), + None, + stderr, + ), + Self { process }, + )) + } + + fn has_adapter_logs(&self) -> bool { + false + } + + async fn reconnect(&self, _: AsyncApp) -> Result { + bail!("Cannot reconnect to adapter") + } + + async fn kill(&self) -> Result<()> { + self.process.lock().await.kill()?; + Ok(()) + } +} + +#[cfg(any(test, feature = "test-support"))] +type RequestHandler = Box< + dyn Send + + FnMut( + u64, + serde_json::Value, + Arc>, + ) -> std::pin::Pin + Send>>, +>; + +#[cfg(any(test, feature = "test-support"))] +type ResponseHandler = Box; + +#[cfg(any(test, feature = "test-support"))] +pub struct FakeTransport { + // for sending fake response back from adapter side + request_handlers: Arc>>, + // for reverse request responses + response_handlers: Arc>>, +} + +#[cfg(any(test, feature = "test-support"))] +impl FakeTransport { + pub async fn on_request(&self, mut handler: F) + where + F: 'static + Send + FnMut(u64, R::Arguments) -> Result, + { + self.request_handlers.lock().await.insert( + R::COMMAND, + Box::new( + move |seq, args, writer: Arc>| { + let response = handler(seq, serde_json::from_value(args).unwrap()); + + let message = serde_json::to_string(&Message::Response(Response { + seq: seq + 1, + request_seq: seq, + success: response.as_ref().is_ok(), + command: R::COMMAND.into(), + body: util::maybe!({ serde_json::to_value(response.ok()?).ok() }), + })) + .unwrap(); + + let writer = writer.clone(); + + Box::pin(async move { + let mut writer = writer.lock().await; + writer + .write_all(TransportDelegate::build_rpc_message(message).as_bytes()) + .await + .unwrap(); + writer.flush().await.unwrap(); + }) + }, + ), + ); + } + + pub async fn on_response(&self, handler: F) + where + F: 'static + Send + Fn(Response), + { + self.response_handlers + .lock() + .await + .insert(R::COMMAND, Box::new(handler)); + } + + async fn reconnect(&self, cx: AsyncApp) -> Result<(TransportPipe, Self)> { + FakeTransport::start(cx).await + } + + async fn start(cx: AsyncApp) -> Result<(TransportPipe, Self)> { + let this = Self { + request_handlers: Arc::new(Mutex::new(HashMap::default())), + response_handlers: Arc::new(Mutex::new(HashMap::default())), + }; + use dap_types::requests::{Request, RunInTerminal, StartDebugging}; + use serde_json::json; + + let (stdin_writer, stdin_reader) = async_pipe::pipe(); + let (stdout_writer, stdout_reader) = async_pipe::pipe(); + + let request_handlers = this.request_handlers.clone(); + let response_handlers = this.response_handlers.clone(); + let stdout_writer = Arc::new(Mutex::new(stdout_writer)); + + cx.background_executor() + .spawn(async move { + let mut reader = BufReader::new(stdin_reader); + let mut buffer = String::new(); + + loop { + let message = + TransportDelegate::receive_server_message(&mut reader, &mut buffer, None) + .await; + + match message { + Err(error) => { + break anyhow!(error); + } + Ok(message) => { + match message { + Message::Request(request) => { + // redirect reverse requests to stdout writer/reader + if request.command == RunInTerminal::COMMAND + || request.command == StartDebugging::COMMAND + { + let message = + serde_json::to_string(&Message::Request(request)) + .unwrap(); + + let mut writer = stdout_writer.lock().await; + writer + .write_all( + TransportDelegate::build_rpc_message(message) + .as_bytes(), + ) + .await + .unwrap(); + writer.flush().await.unwrap(); + } else { + if let Some(handle) = request_handlers + .lock() + .await + .get_mut(request.command.as_str()) + { + handle( + request.seq, + request.arguments.unwrap_or(json!({})), + stdout_writer.clone(), + ) + .await; + } else { + log::error!( + "No request handler for {}", + request.command + ); + } + } + } + Message::Event(event) => { + let message = + serde_json::to_string(&Message::Event(event)).unwrap(); + + let mut writer = stdout_writer.lock().await; + writer + .write_all( + TransportDelegate::build_rpc_message(message) + .as_bytes(), + ) + .await + .unwrap(); + writer.flush().await.unwrap(); + } + Message::Response(response) => { + if let Some(handle) = response_handlers + .lock() + .await + .get(response.command.as_str()) + { + handle(response); + } else { + log::error!("No response handler for {}", response.command); + } + } + } + } + } + } + }) + .detach(); + + Ok(( + TransportPipe::new(Box::new(stdin_writer), Box::new(stdout_reader), None, None), + this, + )) + } + + fn has_adapter_logs(&self) -> bool { + false + } + + async fn kill(&self) -> Result<()> { + Ok(()) + } + + #[cfg(any(test, feature = "test-support"))] + fn as_fake(&self) -> &FakeTransport { + self + } +} diff --git a/crates/dap_adapters/Cargo.toml b/crates/dap_adapters/Cargo.toml new file mode 100644 index 00000000000000..0fe055738a1e87 --- /dev/null +++ b/crates/dap_adapters/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "dap_adapters" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[features] +test-support = [ + "dap/test-support", + "gpui/test-support", + "task/test-support", + "util/test-support", +] + +[lints] +workspace = true + +[lib] +path = "src/dap_adapters.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +async-trait.workspace = true +dap.workspace = true +gpui.workspace = true +language.workspace = true +paths.workspace = true +regex.workspace = true +serde.workspace = true +serde_json.workspace = true +sysinfo.workspace = true +task.workspace = true +util.workspace = true + +[dev-dependencies] +dap = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +task = { workspace = true, features = ["test-support"] } +util = { workspace = true, features = ["test-support"] } diff --git a/crates/dap_adapters/LICENSE-GPL b/crates/dap_adapters/LICENSE-GPL new file mode 120000 index 00000000000000..89e542f750cd38 --- /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 00000000000000..b1d3ff60bb8300 --- /dev/null +++ b/crates/dap_adapters/src/custom.rs @@ -0,0 +1,80 @@ +use gpui::AsyncApp; +use serde_json::Value; +use std::{ffi::OsString, path::PathBuf}; +use task::DebugAdapterConfig; + +use crate::*; + +pub(crate) struct CustomDebugAdapter { + custom_args: CustomArgs, +} + +impl CustomDebugAdapter { + const ADAPTER_NAME: &'static str = "custom_dap"; + + pub(crate) async fn new(custom_args: CustomArgs) -> Result { + Ok(CustomDebugAdapter { custom_args }) + } +} + +#[async_trait(?Send)] +impl DebugAdapter for CustomDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn get_binary( + &self, + _: &dyn DapDelegate, + config: &DebugAdapterConfig, + _: Option, + _: &mut AsyncApp, + ) -> Result { + let connection = if let DebugConnectionType::TCP(connection) = &self.custom_args.connection + { + Some(adapters::TcpArguments { + host: connection.host(), + port: connection.port, + timeout: connection.timeout, + }) + } else { + None + }; + let ret = DebugAdapterBinary { + command: self.custom_args.command.clone(), + arguments: self + .custom_args + .args + .clone() + .map(|args| args.iter().map(OsString::from).collect()), + cwd: config.cwd.clone(), + envs: self.custom_args.envs.clone(), + #[cfg(any(test, feature = "test-support"))] + is_fake: false, + connection, + }; + Ok(ret) + } + + async fn fetch_latest_adapter_version(&self, _: &dyn DapDelegate) -> Result { + bail!("Custom debug adapters don't have latest versions") + } + + async fn install_binary(&self, _: AdapterVersion, _: &dyn DapDelegate) -> Result<()> { + bail!("Custom debug adapters cannot be installed") + } + + async fn get_installed_binary( + &self, + _: &dyn DapDelegate, + _: &DebugAdapterConfig, + _: Option, + _: &mut AsyncApp, + ) -> Result { + bail!("Custom debug adapters cannot be installed") + } + + 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 00000000000000..21d8a8cc084094 --- /dev/null +++ b/crates/dap_adapters/src/dap_adapters.rs @@ -0,0 +1,49 @@ +mod custom; +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +mod gdb; +mod go; +mod javascript; +mod lldb; +mod php; +mod python; + +use std::sync::Arc; + +use anyhow::{anyhow, bail, Result}; +use async_trait::async_trait; +use custom::CustomDebugAdapter; +use dap::adapters::{ + self, AdapterVersion, DapDelegate, DebugAdapter, DebugAdapterBinary, DebugAdapterName, + GithubRepo, +}; +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +use gdb::GdbDebugAdapter; +use go::GoDebugAdapter; +use javascript::JsDebugAdapter; +use lldb::LldbDebugAdapter; +use php::PhpDebugAdapter; +use python::PythonDebugAdapter; +use serde_json::{json, Value}; +use task::{CustomArgs, DebugAdapterConfig, DebugAdapterKind, DebugConnectionType, TCPHost}; + +pub async fn build_adapter(kind: &DebugAdapterKind) -> Result> { + match kind { + DebugAdapterKind::Custom(start_args) => { + Ok(Arc::new(CustomDebugAdapter::new(start_args.clone()).await?)) + } + DebugAdapterKind::Python(host) => Ok(Arc::new(PythonDebugAdapter::new(host).await?)), + DebugAdapterKind::Php(host) => Ok(Arc::new(PhpDebugAdapter::new(host.clone()).await?)), + DebugAdapterKind::Javascript(host) => { + Ok(Arc::new(JsDebugAdapter::new(host.clone()).await?)) + } + DebugAdapterKind::Lldb => Ok(Arc::new(LldbDebugAdapter::new())), + DebugAdapterKind::Go(host) => Ok(Arc::new(GoDebugAdapter::new(host).await?)), + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + DebugAdapterKind::Gdb => Ok(Arc::new(GdbDebugAdapter::new())), + #[cfg(any(test, feature = "test-support"))] + DebugAdapterKind::Fake => Ok(Arc::new(dap::adapters::FakeAdapter::new())), + #[cfg(not(any(test, feature = "test-support")))] + #[allow(unreachable_patterns)] + _ => unreachable!("Fake variant only exists with test-support feature"), + } +} diff --git a/crates/dap_adapters/src/gdb.rs b/crates/dap_adapters/src/gdb.rs new file mode 100644 index 00000000000000..84977cb8a777c9 --- /dev/null +++ b/crates/dap_adapters/src/gdb.rs @@ -0,0 +1,87 @@ +use std::ffi::OsStr; + +use anyhow::Result; +use async_trait::async_trait; +use dap::transport::{StdioTransport, Transport}; +use gpui::AsyncApp; +use task::DebugAdapterConfig; + +use crate::*; + +pub(crate) struct GdbDebugAdapter {} + +impl GdbDebugAdapter { + const ADAPTER_NAME: &'static str = "gdb"; + + pub(crate) fn new() -> Self { + GdbDebugAdapter {} + } +} + +#[async_trait(?Send)] +impl DebugAdapter for GdbDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + fn transport(&self) -> Arc { + Arc::new(StdioTransport::new()) + } + + async fn get_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + user_installed_path: Option, + _: &mut AsyncApp, + ) -> Result { + let user_setting_path = user_installed_path + .filter(|p| p.exists()) + .and_then(|p| p.to_str().map(|s| s.to_string())); + + /* GDB implements DAP natively so just need to */ + let gdb_path = delegate + .which(OsStr::new("gdb")) + .and_then(|p| p.to_str().map(|s| s.to_string())) + .ok_or(anyhow!("Could not find gdb in path")); + + if gdb_path.is_err() && user_setting_path.is_none() { + bail!("Could not find gdb path or it's not installed"); + } + + let gdb_path = user_setting_path.unwrap_or(gdb_path?); + + Ok(DebugAdapterBinary { + command: gdb_path, + arguments: Some(vec!["-i=dap".into()]), + envs: None, + cwd: config.cwd.clone(), + }) + } + + async fn install_binary( + &self, + _version: AdapterVersion, + _delegate: &dyn DapDelegate, + ) -> Result<()> { + unimplemented!("GDB debug adapter cannot be installed by Zed (yet)") + } + + async fn fetch_latest_adapter_version(&self, _: &dyn DapDelegate) -> Result { + unimplemented!("Fetch latest GDB version not implemented (yet)") + } + + async fn get_installed_binary( + &self, + _: &dyn DapDelegate, + _: &DebugAdapterConfig, + _: Option, + _: &mut AsyncApp, + ) -> Result { + unimplemented!("GDB cannot be installed by Zed (yet)") + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + json!({"program": config.program, "cwd": config.cwd}) + } +} diff --git a/crates/dap_adapters/src/go.rs b/crates/dap_adapters/src/go.rs new file mode 100644 index 00000000000000..d367bbeebbd23e --- /dev/null +++ b/crates/dap_adapters/src/go.rs @@ -0,0 +1,98 @@ +use dap::transport::TcpTransport; +use gpui::AsyncApp; +use std::{ffi::OsStr, net::Ipv4Addr, path::PathBuf}; + +use crate::*; + +pub(crate) struct GoDebugAdapter { + port: u16, + host: Ipv4Addr, + timeout: Option, +} + +impl GoDebugAdapter { + const ADAPTER_NAME: &'static str = "delve"; + + pub(crate) async fn new(host: &TCPHost) -> Result { + Ok(GoDebugAdapter { + port: TcpTransport::port(host).await?, + host: host.host(), + timeout: host.timeout, + }) + } +} + +#[async_trait(?Send)] +impl DebugAdapter for GoDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn get_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + user_installed_path: Option, + cx: &mut AsyncApp, + ) -> Result { + self.get_installed_binary(delegate, config, user_installed_path, cx) + .await + } + + async fn fetch_latest_adapter_version( + &self, + _delegate: &dyn DapDelegate, + ) -> Result { + unimplemented!("This adapter is used from path for now"); + } + + async fn install_binary( + &self, + version: AdapterVersion, + delegate: &dyn DapDelegate, + ) -> Result<()> { + adapters::download_adapter_from_github( + self.name(), + version, + adapters::DownloadedFileType::Zip, + delegate, + ) + .await?; + Ok(()) + } + + async fn get_installed_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + _: Option, + _: &mut AsyncApp, + ) -> Result { + let delve_path = delegate + .which(OsStr::new("dlv")) + .and_then(|p| p.to_str().map(|p| p.to_string())) + .ok_or(anyhow!("Dlv not found in path"))?; + + Ok(DebugAdapterBinary { + command: delve_path, + arguments: Some(vec![ + "dap".into(), + "--listen".into(), + format!("{}:{}", self.host, self.port).into(), + ]), + cwd: config.cwd.clone(), + envs: None, + connection: None, + #[cfg(any(test, feature = "test-support"))] + is_fake: false, + }) + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + json!({ + "program": config.program, + "cwd": config.cwd, + "subProcess": true, + }) + } +} diff --git a/crates/dap_adapters/src/javascript.rs b/crates/dap_adapters/src/javascript.rs new file mode 100644 index 00000000000000..4209a3b731f1d5 --- /dev/null +++ b/crates/dap_adapters/src/javascript.rs @@ -0,0 +1,155 @@ +use adapters::latest_github_release; +use dap::transport::TcpTransport; +use gpui::AsyncApp; +use regex::Regex; +use std::{collections::HashMap, net::Ipv4Addr, path::PathBuf}; +use sysinfo::{Pid, Process}; +use task::DebugRequestType; + +use crate::*; + +pub(crate) struct JsDebugAdapter { + port: u16, + host: Ipv4Addr, + timeout: Option, +} + +impl JsDebugAdapter { + const ADAPTER_NAME: &'static str = "vscode-js-debug"; + const ADAPTER_PATH: &'static str = "js-debug/src/dapDebugServer.js"; + + pub(crate) async fn new(host: TCPHost) -> Result { + Ok(JsDebugAdapter { + host: host.host(), + timeout: host.timeout, + port: TcpTransport::port(&host).await?, + }) + } +} + +#[async_trait(?Send)] +impl DebugAdapter for JsDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn fetch_latest_adapter_version( + &self, + delegate: &dyn DapDelegate, + ) -> Result { + let release = latest_github_release( + &format!("{}/{}", "microsoft", Self::ADAPTER_NAME), + true, + false, + delegate.http_client(), + ) + .await?; + + let asset_name = format!("js-debug-dap-{}.tar.gz", release.tag_name); + + Ok(AdapterVersion { + tag_name: release.tag_name, + url: release + .assets + .iter() + .find(|asset| asset.name == asset_name) + .ok_or_else(|| anyhow!("no asset found matching {:?}", asset_name))? + .browser_download_url + .clone(), + }) + } + + async fn get_installed_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + user_installed_path: Option, + _: &mut AsyncApp, + ) -> Result { + let adapter_path = if let Some(user_installed_path) = user_installed_path { + user_installed_path + } else { + let adapter_path = paths::debug_adapters_dir().join(self.name()); + + let file_name_prefix = format!("{}_", self.name()); + + util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| { + file_name.starts_with(&file_name_prefix) + }) + .await + .ok_or_else(|| anyhow!("Couldn't find JavaScript dap directory"))? + }; + + Ok(DebugAdapterBinary { + command: delegate + .node_runtime() + .binary_path() + .await? + .to_string_lossy() + .into_owned(), + arguments: Some(vec![ + adapter_path.join(Self::ADAPTER_PATH).into(), + self.port.to_string().into(), + self.host.to_string().into(), + ]), + cwd: config.cwd.clone(), + envs: None, + #[cfg(any(test, feature = "test-support"))] + is_fake: false, + connection: Some(adapters::TcpArguments { + host: self.host, + port: Some(self.port), + timeout: self.timeout, + }), + }) + } + + async fn install_binary( + &self, + version: AdapterVersion, + delegate: &dyn DapDelegate, + ) -> Result<()> { + adapters::download_adapter_from_github( + self.name(), + version, + adapters::DownloadedFileType::GzipTar, + delegate, + ) + .await?; + + return Ok(()); + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + let pid = if let DebugRequestType::Attach(attach_config) = &config.request { + attach_config.process_id + } else { + None + }; + + json!({ + "program": config.program, + "type": "pwa-node", + "request": match config.request { + DebugRequestType::Launch => "launch", + DebugRequestType::Attach(_) => "attach", + }, + "processId": pid, + "cwd": config.cwd, + }) + } + + fn attach_processes<'a>( + &self, + processes: &'a HashMap, + ) -> Option> { + let regex = Regex::new(r"(?i)^(?:node|bun|iojs)(?:$|\b)").unwrap(); + + Some( + processes + .iter() + .filter(|(_, process)| regex.is_match(&process.name().to_string_lossy())) + .collect::>(), + ) + } +} diff --git a/crates/dap_adapters/src/lldb.rs b/crates/dap_adapters/src/lldb.rs new file mode 100644 index 00000000000000..52a0c5adc1f983 --- /dev/null +++ b/crates/dap_adapters/src/lldb.rs @@ -0,0 +1,109 @@ +use std::{collections::HashMap, ffi::OsStr, path::PathBuf}; + +use anyhow::Result; +use async_trait::async_trait; +use gpui::AsyncApp; +use sysinfo::{Pid, Process}; +use task::{DebugAdapterConfig, DebugRequestType}; + +use crate::*; + +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 get_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + user_installed_path: Option, + _: &mut AsyncApp, + ) -> Result { + let lldb_dap_path = if let Some(user_installed_path) = user_installed_path { + user_installed_path.to_string_lossy().into() + } else if cfg!(target_os = "macos") { + util::command::new_smol_command("xcrun") + .args(&["-f", "lldb-dap"]) + .output() + .await + .ok() + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|path| path.trim().to_string()) + .ok_or(anyhow!("Failed to find lldb-dap in user's path"))? + } else { + delegate + .which(OsStr::new("lldb-dap")) + .and_then(|p| p.to_str().map(|s| s.to_string())) + .ok_or(anyhow!("Could not find lldb-dap in path"))? + }; + + Ok(DebugAdapterBinary { + command: lldb_dap_path, + arguments: None, + envs: None, + cwd: config.cwd.clone(), + connection: None, + #[cfg(any(test, feature = "test-support"))] + is_fake: false, + }) + } + + async fn install_binary( + &self, + _version: AdapterVersion, + _delegate: &dyn DapDelegate, + ) -> Result<()> { + unimplemented!("LLDB debug adapter cannot be installed by Zed (yet)") + } + + async fn fetch_latest_adapter_version(&self, _: &dyn DapDelegate) -> Result { + unimplemented!("Fetch latest adapter version not implemented for lldb (yet)") + } + + async fn get_installed_binary( + &self, + _: &dyn DapDelegate, + _: &DebugAdapterConfig, + _: Option, + _: &mut AsyncApp, + ) -> Result { + unimplemented!("LLDB debug adapter cannot be installed by Zed (yet)") + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + let pid = if let DebugRequestType::Attach(attach_config) = &config.request { + attach_config.process_id + } else { + None + }; + + json!({ + "program": config.program, + "request": match config.request { + DebugRequestType::Launch => "launch", + DebugRequestType::Attach(_) => "attach", + }, + "pid": pid, + "cwd": config.cwd, + }) + } + + fn attach_processes<'a>( + &self, + processes: &'a HashMap, + ) -> Option> { + Some(processes.iter().collect::>()) + } +} diff --git a/crates/dap_adapters/src/php.rs b/crates/dap_adapters/src/php.rs new file mode 100644 index 00000000000000..e7634d34f0537b --- /dev/null +++ b/crates/dap_adapters/src/php.rs @@ -0,0 +1,125 @@ +use adapters::latest_github_release; +use dap::{adapters::TcpArguments, transport::TcpTransport}; +use gpui::AsyncApp; +use std::{net::Ipv4Addr, path::PathBuf}; + +use crate::*; + +pub(crate) struct PhpDebugAdapter { + port: u16, + host: Ipv4Addr, + timeout: Option, +} + +impl PhpDebugAdapter { + const ADAPTER_NAME: &'static str = "vscode-php-debug"; + const ADAPTER_PATH: &'static str = "extension/out/phpDebug.js"; + + pub(crate) async fn new(host: TCPHost) -> Result { + Ok(PhpDebugAdapter { + port: TcpTransport::port(&host).await?, + host: host.host(), + timeout: host.timeout, + }) + } +} + +#[async_trait(?Send)] +impl DebugAdapter for PhpDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn fetch_latest_adapter_version( + &self, + delegate: &dyn DapDelegate, + ) -> Result { + let release = latest_github_release( + &format!("{}/{}", "xdebug", Self::ADAPTER_NAME), + true, + false, + delegate.http_client(), + ) + .await?; + + let asset_name = format!("php-debug-{}.vsix", release.tag_name.replace("v", "")); + + Ok(AdapterVersion { + tag_name: release.tag_name, + url: release + .assets + .iter() + .find(|asset| asset.name == asset_name) + .ok_or_else(|| anyhow!("no asset found matching {:?}", asset_name))? + .browser_download_url + .clone(), + }) + } + + async fn get_installed_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + user_installed_path: Option, + _: &mut AsyncApp, + ) -> Result { + let adapter_path = if let Some(user_installed_path) = user_installed_path { + user_installed_path + } else { + let adapter_path = paths::debug_adapters_dir().join(self.name()); + + let file_name_prefix = format!("{}_", self.name()); + + util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| { + file_name.starts_with(&file_name_prefix) + }) + .await + .ok_or_else(|| anyhow!("Couldn't find PHP dap directory"))? + }; + + Ok(DebugAdapterBinary { + command: delegate + .node_runtime() + .binary_path() + .await? + .to_string_lossy() + .into_owned(), + arguments: Some(vec![ + adapter_path.join(Self::ADAPTER_PATH).into(), + format!("--server={}", self.port).into(), + ]), + connection: Some(TcpArguments { + port: Some(self.port), + host: Ipv4Addr::LOCALHOST, + timeout: None, + }), + cwd: config.cwd.clone(), + envs: None, + #[cfg(any(test, feature = "test-support"))] + is_fake: false, + }) + } + + async fn install_binary( + &self, + version: AdapterVersion, + delegate: &dyn DapDelegate, + ) -> Result<()> { + adapters::download_adapter_from_github( + self.name(), + version, + adapters::DownloadedFileType::Vsix, + delegate, + ) + .await?; + + Ok(()) + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + json!({ + "program": config.program, + "cwd": config.cwd, + }) + } +} diff --git a/crates/dap_adapters/src/python.rs b/crates/dap_adapters/src/python.rs new file mode 100644 index 00000000000000..b5f4f5c9db55ef --- /dev/null +++ b/crates/dap_adapters/src/python.rs @@ -0,0 +1,143 @@ +use crate::*; +use dap::transport::TcpTransport; +use gpui::AsyncApp; +use std::{ffi::OsStr, net::Ipv4Addr, path::PathBuf}; + +pub(crate) struct PythonDebugAdapter { + port: u16, + host: Ipv4Addr, + timeout: Option, +} + +impl PythonDebugAdapter { + const ADAPTER_NAME: &'static str = "debugpy"; + const ADAPTER_PATH: &'static str = "src/debugpy/adapter"; + const LANGUAGE_NAME: &'static str = "Python"; + + pub(crate) async fn new(host: &TCPHost) -> Result { + Ok(PythonDebugAdapter { + port: TcpTransport::port(host).await?, + host: host.host(), + timeout: host.timeout, + }) + } +} + +#[async_trait(?Send)] +impl DebugAdapter for PythonDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn fetch_latest_adapter_version( + &self, + delegate: &dyn DapDelegate, + ) -> Result { + let github_repo = GithubRepo { + repo_name: Self::ADAPTER_NAME.into(), + repo_owner: "microsoft".into(), + }; + + adapters::fetch_latest_adapter_version_from_github(github_repo, delegate).await + } + + async fn install_binary( + &self, + version: AdapterVersion, + delegate: &dyn DapDelegate, + ) -> Result<()> { + let version_path = adapters::download_adapter_from_github( + self.name(), + version, + adapters::DownloadedFileType::Zip, + delegate, + ) + .await?; + + // only needed when you install the latest version for the first time + if let Some(debugpy_dir) = + util::fs::find_file_name_in_dir(version_path.as_path(), |file_name| { + file_name.starts_with("microsoft-debugpy-") + }) + .await + { + // TODO Debugger: Rename folder instead of moving all files to another folder + // We're doing uncessary IO work right now + util::fs::move_folder_files_to_folder(debugpy_dir.as_path(), version_path.as_path()) + .await?; + } + + Ok(()) + } + + async fn get_installed_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + user_installed_path: Option, + cx: &mut AsyncApp, + ) -> Result { + const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"]; + + let debugpy_dir = if let Some(user_installed_path) = user_installed_path { + user_installed_path + } else { + let adapter_path = paths::debug_adapters_dir().join(self.name()); + let file_name_prefix = format!("{}_", self.name()); + + util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| { + file_name.starts_with(&file_name_prefix) + }) + .await + .ok_or_else(|| anyhow!("Debugpy directory not found"))? + }; + + let toolchain = delegate + .toolchain_store() + .active_toolchain( + delegate.worktree_id(), + language::LanguageName::new(Self::LANGUAGE_NAME), + cx, + ) + .await; + + let python_path = if let Some(toolchain) = toolchain { + Some(toolchain.path.to_string()) + } else { + BINARY_NAMES + .iter() + .filter_map(|cmd| { + delegate + .which(OsStr::new(cmd)) + .map(|path| path.to_string_lossy().to_string()) + }) + .find(|_| true) + }; + + Ok(DebugAdapterBinary { + command: python_path.ok_or(anyhow!("failed to find binary path for python"))?, + arguments: Some(vec![ + debugpy_dir.join(Self::ADAPTER_PATH).into(), + format!("--port={}", self.port).into(), + format!("--host={}", self.host).into(), + ]), + connection: Some(adapters::TcpArguments { + host: self.host, + port: Some(self.port), + timeout: self.timeout, + }), + cwd: config.cwd.clone(), + envs: None, + #[cfg(any(test, feature = "test-support"))] + is_fake: false, + }) + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + json!({ + "program": config.program, + "subProcess": true, + "cwd": config.cwd, + }) + } +} diff --git a/crates/debugger_tools/Cargo.toml b/crates/debugger_tools/Cargo.toml new file mode 100644 index 00000000000000..a7a96587e275c0 --- /dev/null +++ b/crates/debugger_tools/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "debugger_tools" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/debugger_tools.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +dap.workspace = true +editor.workspace = true +futures.workspace = true +gpui.workspace = true +project.workspace = true +serde_json.workspace = true +settings.workspace = true +smol.workspace = true +util.workspace = true +workspace.workspace = true diff --git a/crates/debugger_tools/LICENSE-GPL b/crates/debugger_tools/LICENSE-GPL new file mode 120000 index 00000000000000..89e542f750cd38 --- /dev/null +++ b/crates/debugger_tools/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/debugger_tools/src/dap_log.rs b/crates/debugger_tools/src/dap_log.rs new file mode 100644 index 00000000000000..6ed048ca19febb --- /dev/null +++ b/crates/debugger_tools/src/dap_log.rs @@ -0,0 +1,834 @@ +use dap::{ + client::SessionId, + debugger_settings::DebuggerSettings, + transport::{IoKind, LogKind}, +}; +use editor::{Editor, EditorEvent}; +use futures::{ + channel::mpsc::{unbounded, UnboundedSender}, + StreamExt, +}; +use gpui::{ + actions, div, App, AppContext, Context, Empty, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, ParentElement, Render, SharedString, Styled, Subscription, WeakEntity, Window, +}; +use project::{debugger::session::Session, search::SearchQuery, Project}; +use settings::Settings as _; +use std::{ + borrow::Cow, + collections::{HashMap, VecDeque}, + sync::Arc, +}; +use util::maybe; +use workspace::{ + item::Item, + searchable::{SearchEvent, SearchableItem, SearchableItemHandle}, + ui::{h_flex, Button, Clickable, ContextMenu, Label, LabelCommon, PopoverMenu}, + ToolbarItemEvent, ToolbarItemView, Workspace, +}; + +struct DapLogView { + editor: Entity, + focus_handle: FocusHandle, + log_store: Entity, + editor_subscriptions: Vec, + current_view: Option<(SessionId, LogKind)>, + project: Entity, + _subscriptions: Vec, +} + +struct LogStore { + projects: HashMap, ProjectState>, + debug_clients: HashMap, + rpc_tx: UnboundedSender<(SessionId, IoKind, String)>, + adapter_log_tx: UnboundedSender<(SessionId, IoKind, String)>, +} + +struct ProjectState { + _subscriptions: [gpui::Subscription; 2], +} + +struct DebugAdapterState { + log_messages: VecDeque, + rpc_messages: RpcMessages, +} + +struct RpcMessages { + messages: VecDeque, + last_message_kind: Option, +} + +impl RpcMessages { + const MESSAGE_QUEUE_LIMIT: usize = 255; + + fn new() -> Self { + Self { + last_message_kind: None, + messages: VecDeque::with_capacity(Self::MESSAGE_QUEUE_LIMIT), + } + } +} + +const SEND: &str = "// Send"; +const RECEIVE: &str = "// Receive"; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum MessageKind { + Send, + Receive, +} + +impl MessageKind { + fn label(&self) -> &'static str { + match self { + Self::Send => SEND, + Self::Receive => RECEIVE, + } + } +} + +impl DebugAdapterState { + fn new() -> Self { + Self { + log_messages: VecDeque::new(), + rpc_messages: RpcMessages::new(), + } + } +} + +impl LogStore { + fn new(cx: &Context) -> Self { + let (rpc_tx, mut rpc_rx) = unbounded::<(SessionId, IoKind, String)>(); + cx.spawn(|this, mut cx| async move { + while let Some((client_id, io_kind, message)) = rpc_rx.next().await { + if let Some(this) = this.upgrade() { + this.update(&mut cx, |this, cx| { + this.on_rpc_log(client_id, io_kind, &message, cx); + })?; + } + + smol::future::yield_now().await; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + let (adapter_log_tx, mut adapter_log_rx) = unbounded::<(SessionId, IoKind, String)>(); + cx.spawn(|this, mut cx| async move { + while let Some((client_id, io_kind, message)) = adapter_log_rx.next().await { + if let Some(this) = this.upgrade() { + this.update(&mut cx, |this, cx| { + this.on_adapter_log(client_id, io_kind, &message, cx); + })?; + } + + smol::future::yield_now().await; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + Self { + rpc_tx, + adapter_log_tx, + projects: HashMap::new(), + debug_clients: HashMap::new(), + } + } + + fn on_rpc_log( + &mut self, + client_id: SessionId, + io_kind: IoKind, + message: &str, + cx: &mut Context, + ) { + self.add_debug_client_message(client_id, io_kind, message.to_string(), cx); + } + + fn on_adapter_log( + &mut self, + client_id: SessionId, + io_kind: IoKind, + message: &str, + cx: &mut Context, + ) { + self.add_debug_client_log(client_id, io_kind, message.to_string(), cx); + } + + pub fn add_project(&mut self, project: &Entity, cx: &mut Context) { + let weak_project = project.downgrade(); + self.projects.insert( + project.downgrade(), + ProjectState { + _subscriptions: [ + cx.observe_release(project, move |this, _, _| { + this.projects.remove(&weak_project); + }), + cx.subscribe(project, |this, project, event, cx| match event { + project::Event::DebugClientStarted(client_id) => { + let session = project + .read(cx) + .dap_store() + .read(cx) + .session_by_id(client_id); + if let Some(session) = session { + this.add_debug_client(*client_id, session, cx); + } + } + project::Event::DebugClientShutdown(client_id) => { + this.remove_debug_client(*client_id, cx); + } + + _ => {} + }), + ], + }, + ); + } + + fn get_debug_adapter_state(&mut self, id: SessionId) -> Option<&mut DebugAdapterState> { + self.debug_clients.get_mut(&id) + } + + fn add_debug_client_message( + &mut self, + id: SessionId, + io_kind: IoKind, + message: String, + cx: &mut Context, + ) { + let Some(debug_client_state) = self.get_debug_adapter_state(id) else { + return; + }; + + let kind = match io_kind { + IoKind::StdOut | IoKind::StdErr => MessageKind::Receive, + IoKind::StdIn => MessageKind::Send, + }; + + let rpc_messages = &mut debug_client_state.rpc_messages; + if rpc_messages.last_message_kind != Some(kind) { + Self::add_debug_client_entry( + &mut rpc_messages.messages, + id, + kind.label().to_string(), + LogKind::Rpc, + cx, + ); + rpc_messages.last_message_kind = Some(kind); + } + Self::add_debug_client_entry(&mut rpc_messages.messages, id, message, LogKind::Rpc, cx); + + cx.notify(); + } + + fn add_debug_client_log( + &mut self, + id: SessionId, + io_kind: IoKind, + message: String, + cx: &mut Context, + ) { + let Some(debug_client_state) = self.get_debug_adapter_state(id) else { + return; + }; + + let message = match io_kind { + IoKind::StdErr => { + let mut message = message.clone(); + message.insert_str(0, "stderr: "); + message + } + _ => message, + }; + + Self::add_debug_client_entry( + &mut debug_client_state.log_messages, + id, + message, + LogKind::Adapter, + cx, + ); + cx.notify(); + } + + fn add_debug_client_entry( + log_lines: &mut VecDeque, + id: SessionId, + message: String, + kind: LogKind, + cx: &mut Context, + ) { + while log_lines.len() >= RpcMessages::MESSAGE_QUEUE_LIMIT { + log_lines.pop_front(); + } + + let format_messages = DebuggerSettings::get_global(cx).format_dap_log_messages; + + let entry = if format_messages { + maybe!({ + serde_json::to_string_pretty::( + &serde_json::from_str(&message).ok()?, + ) + .ok() + }) + .unwrap_or(message) + } else { + message + }; + log_lines.push_back(entry.clone()); + + cx.emit(Event::NewLogEntry { id, entry, kind }); + } + + fn add_debug_client( + &mut self, + client_id: SessionId, + client: Entity, + cx: &App, + ) -> Option<&mut DebugAdapterState> { + let client_state = self + .debug_clients + .entry(client_id) + .or_insert_with(DebugAdapterState::new); + + let io_tx = self.rpc_tx.clone(); + + let client = client.read(cx).adapter_client()?; + client.add_log_handler( + move |io_kind, message| { + io_tx + .unbounded_send((client_id, io_kind, message.to_string())) + .ok(); + }, + LogKind::Rpc, + ); + + let log_io_tx = self.adapter_log_tx.clone(); + client.add_log_handler( + move |io_kind, message| { + log_io_tx + .unbounded_send((client_id, io_kind, message.to_string())) + .ok(); + }, + LogKind::Adapter, + ); + + Some(client_state) + } + + fn remove_debug_client(&mut self, client_id: SessionId, cx: &mut Context) { + self.debug_clients.remove(&client_id); + cx.notify(); + } + + fn log_messages_for_client(&mut self, client_id: SessionId) -> Option<&mut VecDeque> { + Some(&mut self.debug_clients.get_mut(&client_id)?.log_messages) + } + + fn rpc_messages_for_client(&mut self, client_id: SessionId) -> Option<&mut VecDeque> { + Some( + &mut self + .debug_clients + .get_mut(&client_id)? + .rpc_messages + .messages, + ) + } +} + +pub struct DapLogToolbarItemView { + log_view: Option>, +} + +impl DapLogToolbarItemView { + pub fn new() -> Self { + Self { log_view: None } + } +} + +impl Render for DapLogToolbarItemView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(log_view) = self.log_view.clone() else { + return Empty.into_any_element(); + }; + + let (menu_rows, current_client_id) = log_view.update(cx, |log_view, cx| { + ( + log_view.menu_items(cx).unwrap_or_default(), + log_view.current_view.map(|(client_id, _)| client_id), + ) + }); + + let current_client = current_client_id.and_then(|current_client_id| { + menu_rows + .iter() + .find(|row| row.client_id == current_client_id) + }); + + let dap_menu: PopoverMenu<_> = PopoverMenu::new("DapLogView") + .anchor(gpui::Corner::TopLeft) + .trigger(Button::new( + "debug_client_menu_header", + current_client + .map(|sub_item| { + Cow::Owned(format!( + "{} ({}) - {}", + sub_item.client_name, + sub_item.client_id.0, + match sub_item.selected_entry { + LogKind::Adapter => ADAPTER_LOGS, + LogKind::Rpc => RPC_MESSAGES, + } + )) + }) + .unwrap_or_else(|| "No adapter selected".into()), + )) + .menu(move |mut window, cx| { + let log_view = log_view.clone(); + let menu_rows = menu_rows.clone(); + ContextMenu::build(&mut window, cx, move |mut menu, window, _cx| { + for row in menu_rows.into_iter() { + menu = menu.custom_row(move |_window, _cx| { + div() + .w_full() + .pl_2() + .child( + Label::new( + format!("{}. {}", row.client_id.0, row.client_name,), + ) + .color(workspace::ui::Color::Muted), + ) + .into_any_element() + }); + + if row.has_adapter_logs { + menu = menu.custom_entry( + move |_window, _cx| { + div() + .w_full() + .pl_4() + .child(Label::new(ADAPTER_LOGS)) + .into_any_element() + }, + window.handler_for(&log_view, move |view, window, cx| { + view.show_log_messages_for_adapter(row.client_id, window, cx); + }), + ); + } + + menu = menu.custom_entry( + move |_window, _cx| { + div() + .w_full() + .pl_4() + .child(Label::new(RPC_MESSAGES)) + .into_any_element() + }, + window.handler_for(&log_view, move |view, window, cx| { + view.show_rpc_trace_for_server(row.client_id, window, cx); + }), + ); + } + + menu + }) + .into() + }); + + h_flex() + .size_full() + .child(dap_menu) + .child( + div() + .child( + Button::new("clear_log_button", "Clear").on_click(cx.listener( + |this, _, window, cx| { + if let Some(log_view) = this.log_view.as_ref() { + log_view.update(cx, |log_view, cx| { + log_view.editor.update(cx, |editor, cx| { + editor.set_read_only(false); + editor.clear(window, cx); + editor.set_read_only(true); + }); + }) + } + }, + )), + ) + .ml_2(), + ) + .into_any_element() + } +} + +impl EventEmitter for DapLogToolbarItemView {} + +impl ToolbarItemView for DapLogToolbarItemView { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn workspace::item::ItemHandle>, + _window: &mut Window, + cx: &mut Context, + ) -> workspace::ToolbarItemLocation { + if let Some(item) = active_pane_item { + if let Some(log_view) = item.downcast::() { + self.log_view = Some(log_view.clone()); + return workspace::ToolbarItemLocation::PrimaryLeft; + } + } + self.log_view = None; + + cx.notify(); + + workspace::ToolbarItemLocation::Hidden + } +} + +impl DapLogView { + pub fn new( + project: Entity, + log_store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let (editor, editor_subscriptions) = Self::editor_for_logs(String::new(), window, cx); + + let focus_handle = cx.focus_handle(); + + let events_subscriptions = cx.subscribe(&log_store, |log_view, _, event, cx| match event { + Event::NewLogEntry { id, entry, kind } => { + if log_view.current_view == Some((*id, *kind)) { + log_view.editor.update(cx, |editor, cx| { + editor.set_read_only(false); + let last_point = editor.buffer().read(cx).len(cx); + editor.edit( + vec![ + (last_point..last_point, entry.trim()), + (last_point..last_point, "\n"), + ], + cx, + ); + editor.set_read_only(true); + }); + } + } + }); + + Self { + editor, + focus_handle, + project, + log_store, + editor_subscriptions, + current_view: None, + _subscriptions: vec![events_subscriptions], + } + } + + fn editor_for_logs( + log_contents: String, + window: &mut Window, + cx: &mut Context, + ) -> (Entity, Vec) { + let editor = cx.new(|cx| { + let mut editor = Editor::multi_line(window, cx); + editor.set_text(log_contents, window, cx); + editor.move_to_end(&editor::actions::MoveToEnd, window, cx); + editor.set_read_only(true); + editor.set_show_edit_predictions(Some(false), window, cx); + editor + }); + let editor_subscription = cx.subscribe( + &editor, + |_, _, event: &EditorEvent, cx: &mut Context<'_, DapLogView>| cx.emit(event.clone()), + ); + let search_subscription = cx.subscribe( + &editor, + |_, _, event: &SearchEvent, cx: &mut Context<'_, DapLogView>| cx.emit(event.clone()), + ); + (editor, vec![editor_subscription, search_subscription]) + } + + fn menu_items(&self, cx: &App) -> Option> { + let mut menu_items = self + .project + .read(cx) + .dap_store() + .read(cx) + .sessions() + .filter_map(|client| { + let client = client.read(cx).adapter_client()?; + Some(DapMenuItem { + client_id: client.id(), + client_name: "debygpy (hard coded)".into(), // todo(debugger) Fix this hard coded + has_adapter_logs: client.has_adapter_logs(), + selected_entry: self.current_view.map_or(LogKind::Adapter, |(_, kind)| kind), + }) + }) + .collect::>(); + menu_items.sort_by_key(|item| item.client_id.0); + Some(menu_items) + } + + fn show_rpc_trace_for_server( + &mut self, + client_id: SessionId, + window: &mut Window, + cx: &mut Context, + ) { + let rpc_log = self.log_store.update(cx, |log_store, _| { + log_store + .rpc_messages_for_client(client_id) + .map(|state| log_contents(&state)) + }); + if let Some(rpc_log) = rpc_log { + self.current_view = Some((client_id, LogKind::Rpc)); + let (editor, editor_subscriptions) = Self::editor_for_logs(rpc_log, window, cx); + let language = self.project.read(cx).languages().language_for_name("JSON"); + editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .expect("log buffer should be a singleton") + .update(cx, |_, cx| { + cx.spawn({ + let buffer = cx.entity(); + |_, mut cx| async move { + let language = language.await.ok(); + buffer.update(&mut cx, |buffer, cx| { + buffer.set_language(language, cx); + }) + } + }) + .detach_and_log_err(cx); + }); + + self.editor = editor; + self.editor_subscriptions = editor_subscriptions; + cx.notify(); + } + + cx.focus_self(window); + } + + fn show_log_messages_for_adapter( + &mut self, + client_id: SessionId, + window: &mut Window, + cx: &mut Context, + ) { + let message_log = self.log_store.update(cx, |log_store, _| { + log_store + .log_messages_for_client(client_id) + .map(|state| log_contents(&state)) + }); + if let Some(message_log) = message_log { + self.current_view = Some((client_id, LogKind::Adapter)); + let (editor, editor_subscriptions) = Self::editor_for_logs(message_log, window, cx); + editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .expect("log buffer should be a singleton"); + + self.editor = editor; + self.editor_subscriptions = editor_subscriptions; + cx.notify(); + } + + cx.focus_self(window); + } +} + +fn log_contents(lines: &VecDeque) -> String { + let (a, b) = lines.as_slices(); + let a = a.iter().map(move |v| v.as_ref()); + let b = b.iter().map(move |v| v.as_ref()); + a.chain(b).fold(String::new(), |mut acc, el| { + acc.push_str(el); + acc.push('\n'); + acc + }) +} + +#[derive(Clone, PartialEq)] +pub(crate) struct DapMenuItem { + pub client_id: SessionId, + pub client_name: String, + pub has_adapter_logs: bool, + pub selected_entry: LogKind, +} + +const ADAPTER_LOGS: &str = "Adapter Logs"; +const RPC_MESSAGES: &str = "RPC Messages"; + +impl Render for DapLogView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.editor.update(cx, |editor, cx| { + editor.render(window, cx).into_any_element() + }) + } +} + +actions!(debug, [OpenDebuggerAdapterLogs]); + +pub fn init(cx: &mut App) { + let log_store = cx.new(|cx| LogStore::new(cx)); + + cx.observe_new(move |workspace: &mut Workspace, window, cx| { + let Some(_window) = window else { + return; + }; + + let project = workspace.project(); + if project.read(cx).is_local() { + log_store.update(cx, |store, cx| { + store.add_project(project, cx); + }); + } + + let log_store = log_store.clone(); + workspace.register_action(move |workspace, _: &OpenDebuggerAdapterLogs, window, cx| { + let project = workspace.project().read(cx); + if project.is_local() { + workspace.add_item_to_active_pane( + Box::new(cx.new(|cx| { + DapLogView::new(workspace.project().clone(), log_store.clone(), window, cx) + })), + None, + true, + window, + cx, + ); + } + }); + }) + .detach(); +} + +impl Item for DapLogView { + type Event = EditorEvent; + + fn to_item_events(event: &Self::Event, f: impl FnMut(workspace::item::ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option { + Some("DAP Logs".into()) + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + + fn as_searchable(&self, handle: &Entity) -> Option> { + Some(Box::new(handle.clone())) + } +} + +impl SearchableItem for DapLogView { + type Match = ::Match; + + fn clear_matches(&mut self, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |e, cx| e.clear_matches(window, cx)) + } + + fn update_matches( + &mut self, + matches: &[Self::Match], + window: &mut Window, + cx: &mut Context, + ) { + self.editor + .update(cx, |e, cx| e.update_matches(matches, window, cx)) + } + + fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String { + self.editor + .update(cx, |e, cx| e.query_suggestion(window, cx)) + } + + fn activate_match( + &mut self, + index: usize, + matches: &[Self::Match], + window: &mut Window, + cx: &mut Context, + ) { + self.editor + .update(cx, |e, cx| e.activate_match(index, matches, window, cx)) + } + + fn select_matches( + &mut self, + matches: &[Self::Match], + window: &mut Window, + cx: &mut Context, + ) { + self.editor + .update(cx, |e, cx| e.select_matches(matches, window, cx)) + } + + fn find_matches( + &mut self, + query: Arc, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Task> { + self.editor + .update(cx, |e, cx| e.find_matches(query, window, cx)) + } + + fn replace( + &mut self, + _: &Self::Match, + _: &SearchQuery, + _window: &mut Window, + _: &mut Context, + ) { + // Since DAP Log is read-only, it doesn't make sense to support replace operation. + } + + fn supported_options(&self) -> workspace::searchable::SearchOptions { + workspace::searchable::SearchOptions { + case: true, + word: true, + regex: true, + find_in_results: true, + // DAP log is read-only. + replacement: false, + selection: false, + } + } + fn active_match_index( + &mut self, + matches: &[Self::Match], + window: &mut Window, + cx: &mut Context, + ) -> Option { + self.editor + .update(cx, |e, cx| e.active_match_index(matches, window, cx)) + } +} + +impl Focusable for DapLogView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +pub enum Event { + NewLogEntry { + id: SessionId, + entry: String, + kind: LogKind, + }, +} + +impl EventEmitter for LogStore {} +impl EventEmitter for DapLogView {} +impl EventEmitter for DapLogView {} +impl EventEmitter for DapLogView {} diff --git a/crates/debugger_tools/src/debugger_tools.rs b/crates/debugger_tools/src/debugger_tools.rs new file mode 100644 index 00000000000000..da7a43b53b534e --- /dev/null +++ b/crates/debugger_tools/src/debugger_tools.rs @@ -0,0 +1,8 @@ +mod dap_log; +pub use dap_log::*; + +use gpui::App; + +pub fn init(cx: &mut App) { + dap_log::init(cx); +} diff --git a/crates/debugger_ui/Cargo.toml b/crates/debugger_ui/Cargo.toml new file mode 100644 index 00000000000000..ad4a013d7a6249 --- /dev/null +++ b/crates/debugger_ui/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "debugger_ui" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[features] +test-support = [ + "dap/test-support", + "editor/test-support", + "gpui/test-support", + "project/test-support", + "util/test-support", + "workspace/test-support", +] + +[dependencies] +anyhow.workspace = true +client.workspace = true +collections.workspace = true +command_palette_hooks.workspace = true +dap.workspace = true +editor.workspace = true +fuzzy.workspace = true +gpui.workspace = true +language.workspace = true +log.workspace = true +menu.workspace = true +picker.workspace = true +pretty_assertions.workspace = true +project.workspace = true +rpc.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +sum_tree.workspace = true +sysinfo.workspace = true +task.workspace = true +tasks_ui.workspace = true +terminal_view.workspace = true +theme.workspace = true +ui.workspace = true +util.workspace = true +workspace.workspace = true + +[dev-dependencies] +dap = { workspace = true, features = ["test-support"] } +editor = { workspace = true, features = ["test-support"] } +env_logger.workspace = true +gpui = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +unindent.workspace = true +util = { 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 00000000000000..89e542f750cd38 --- /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/attach_modal.rs b/crates/debugger_ui/src/attach_modal.rs new file mode 100644 index 00000000000000..7b3834ba72b0d4 --- /dev/null +++ b/crates/debugger_ui/src/attach_modal.rs @@ -0,0 +1,303 @@ +use dap::client::SessionId; +use fuzzy::{StringMatch, StringMatchCandidate}; +use gpui::Subscription; +use gpui::{DismissEvent, Entity, EventEmitter, Focusable, Render}; +use picker::{Picker, PickerDelegate}; +use project::debugger::dap_store::DapStore; +use std::sync::Arc; +use sysinfo::System; +use ui::{prelude::*, Context, Tooltip}; +use ui::{ListItem, ListItemSpacing}; +use workspace::ModalView; + +#[derive(Debug, Clone)] +struct Candidate { + pid: u32, + name: String, + command: Vec, +} + +pub(crate) struct AttachModalDelegate { + selected_index: usize, + matches: Vec, + session_id: SessionId, + placeholder_text: Arc, + dap_store: Entity, + client_id: SessionId, + candidates: Option>, +} + +impl AttachModalDelegate { + pub fn new(session_id: SessionId, client_id: SessionId, dap_store: Entity) -> Self { + Self { + client_id, + dap_store, + session_id, + candidates: None, + selected_index: 0, + matches: Vec::default(), + placeholder_text: Arc::from("Select the process you want to attach the debugger to"), + } + } +} + +pub(crate) struct AttachModal { + _subscription: Subscription, + pub(crate) picker: Entity>, +} + +impl AttachModal { + pub fn new( + session_id: &SessionId, + client_id: SessionId, + dap_store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let picker = cx.new(|cx| { + Picker::uniform_list( + AttachModalDelegate::new(*session_id, client_id, dap_store), + window, + cx, + ) + }); + let _subscription = cx.subscribe(&picker, |_, _, _, cx| { + cx.emit(DismissEvent); + }); + Self { + picker, + _subscription, + } + } +} + +impl Render for AttachModal { + fn render(&mut self, _window: &mut Window, _: &mut Context) -> impl ui::IntoElement { + v_flex() + .key_context("AttachModal") + .w(rems(34.)) + .child(self.picker.clone()) + } +} + +impl EventEmitter for AttachModal {} + +impl Focusable for AttachModal { + fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { + self.picker.read(cx).focus_handle(cx) + } +} + +impl ModalView for AttachModal {} + +impl PickerDelegate for AttachModalDelegate { + type ListItem = ListItem; + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _: &mut Context>, + ) { + self.selected_index = ix; + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> std::sync::Arc { + self.placeholder_text.clone() + } + + fn update_matches( + &mut self, + query: String, + _window: &mut Window, + cx: &mut Context>, + ) -> gpui::Task<()> { + cx.spawn(|this, mut cx| async move { + let Some(processes) = this + .update(&mut cx, |this, cx| { + if let Some(processes) = this.delegate.candidates.clone() { + processes + } else { + let Some(client) = this.delegate.dap_store.update(cx, |store, cx| { + store + .session_by_id(&this.delegate.client_id) + .and_then(|client| client.read(cx).adapter_client()) + }) else { + return Vec::new(); + }; + + let system = System::new_all(); + todo!("client.adapter().attach_processes(&system.processes())"); + let processes: Vec<(&sysinfo::Pid, &sysinfo::Process)> = vec![]; + + let processes = processes + .into_iter() + .map(|(pid, process)| Candidate { + pid: pid.as_u32(), + name: process.name().to_string_lossy().into_owned(), + command: process + .cmd() + .iter() + .map(|s| s.to_string_lossy().to_string()) + .collect::>(), + }) + .collect::>(); + + let _ = this.delegate.candidates.insert(processes.clone()); + + processes + } + }) + .ok() + else { + return; + }; + + let matches = fuzzy::match_strings( + &processes + .iter() + .enumerate() + .map(|(id, candidate)| { + StringMatchCandidate::new( + id, + format!( + "{} {} {}", + candidate.command.join(" "), + candidate.pid, + candidate.name + ) + .as_str(), + ) + }) + .collect::>(), + &query, + true, + 100, + &Default::default(), + cx.background_executor().clone(), + ) + .await; + + this.update(&mut cx, |this, _| { + let delegate = &mut this.delegate; + + delegate.matches = matches; + delegate.candidates = Some(processes); + + if delegate.matches.is_empty() { + delegate.selected_index = 0; + } else { + delegate.selected_index = + delegate.selected_index.min(delegate.matches.len() - 1); + } + }) + .ok(); + }) + } + + fn confirm(&mut self, _: bool, _window: &mut Window, cx: &mut Context>) { + let candidate = self + .matches + .get(self.selected_index()) + .and_then(|current_match| { + let ix = current_match.candidate_id; + self.candidates.as_ref().map(|candidates| &candidates[ix]) + }); + let Some(candidate) = candidate else { + return cx.emit(DismissEvent); + }; + + unimplemented!( + r#"self.dap_store.update(cx, |store, cx| {{ + store + .attach(self.client_id, candidate.pid, cx) + .detach_and_log_err(cx); + }})"# + ); + + // cx.emit(DismissEvent); + } + + fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { + self.selected_index = 0; + self.candidates.take(); + + self.dap_store.update(cx, |store, cx| { + store.shutdown_session(&self.session_id, cx).detach(); + }); + + cx.emit(DismissEvent); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _window: &mut Window, + _: &mut Context>, + ) -> Option { + let candidates = self.candidates.as_ref()?; + let hit = &self.matches[ix]; + let candidate = &candidates.get(hit.candidate_id)?; + + Some( + ListItem::new(SharedString::from(format!("process-entry-{ix}"))) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .child( + v_flex() + .items_start() + .child(Label::new(format!("{} {}", candidate.name, candidate.pid))) + .child( + div() + .id(SharedString::from(format!("process-entry-{ix}-command"))) + .tooltip(Tooltip::text( + candidate + .command + .clone() + .into_iter() + .collect::>() + .join(" "), + )) + .child( + Label::new(format!( + "{} {}", + candidate.name, + candidate + .command + .clone() + .into_iter() + .skip(1) + .collect::>() + .join(" ") + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ), + ), + ) + } +} + +#[allow(dead_code)] +#[cfg(any(test, feature = "test-support"))] +pub(crate) fn procss_names(modal: &AttachModal, cx: &mut Context) -> Vec { + modal.picker.update(cx, |picker, _| { + picker + .delegate + .matches + .iter() + .map(|hit| hit.string.clone()) + .collect::>() + }) +} diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs new file mode 100644 index 00000000000000..ae6ed3f45fae28 --- /dev/null +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -0,0 +1,398 @@ +use crate::session::DebugSession; +use anyhow::Result; +use command_palette_hooks::CommandPaletteFilter; +use dap::{ + client::SessionId, debugger_settings::DebuggerSettings, ContinuedEvent, LoadedSourceEvent, + ModuleEvent, OutputEvent, StoppedEvent, ThreadEvent, +}; +use gpui::{ + actions, Action, App, AsyncWindowContext, Context, Entity, EventEmitter, FocusHandle, + Focusable, Subscription, Task, WeakEntity, +}; +use project::{ + debugger::dap_store::{self, DapStore}, + Project, +}; +use rpc::proto::{self}; +use settings::Settings; +use std::any::TypeId; +use ui::prelude::*; +use workspace::{ + dock::{DockPosition, Panel, PanelEvent}, + pane, Continue, Disconnect, Pane, Pause, Restart, StepBack, StepInto, StepOut, StepOver, Stop, + ToggleIgnoreBreakpoints, Workspace, +}; + +pub enum DebugPanelEvent { + Exited(SessionId), + Terminated(SessionId), + Stopped { + client_id: SessionId, + event: StoppedEvent, + go_to_stack_frame: bool, + }, + Thread((SessionId, ThreadEvent)), + Continued((SessionId, ContinuedEvent)), + Output((SessionId, OutputEvent)), + Module((SessionId, ModuleEvent)), + LoadedSource((SessionId, LoadedSourceEvent)), + ClientShutdown(SessionId), + CapabilitiesChanged(SessionId), +} + +actions!(debug_panel, [ToggleFocus]); +pub struct DebugPanel { + size: Pixels, + pane: Entity, + project: WeakEntity, + workspace: WeakEntity, + _subscriptions: Vec, +} + +impl DebugPanel { + pub fn new( + workspace: &Workspace, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + cx.new(|cx| { + let project = workspace.project().clone(); + let dap_store = project.read(cx).dap_store(); + let weak_workspace = workspace.weak_handle(); + let pane = cx.new(|cx| { + let mut pane = Pane::new( + workspace.weak_handle(), + workspace.project().clone(), + Default::default(), + None, + gpui::NoAction.boxed_clone(), + window, + cx, + ); + pane.set_can_split(None); + pane.set_can_navigate(true, cx); + pane.display_nav_history_buttons(None); + pane.set_should_display_tab_bar(|_window, _cx| true); + pane.set_close_pane_if_empty(true, cx); + pane.set_render_tab_bar_buttons(cx, { + let project = project.clone(); + let weak_workspace = weak_workspace.clone(); + move |_, _, cx| { + let project = project.clone(); + let weak_workspace = weak_workspace.clone(); + ( + None, + Some( + h_flex() + .child( + IconButton::new("new-debug-session", IconName::Plus) + .icon_size(IconSize::Small) + .on_click(cx.listener(move |pane, _, window, cx| { + pane.add_item( + Box::new(DebugSession::inert( + project.clone(), + weak_workspace.clone(), + window, + cx, + )), + false, + false, + None, + window, + cx, + ); + })), + ) + .into_any_element(), + ), + ) + } + }); + pane.add_item( + Box::new(DebugSession::inert( + project.clone(), + weak_workspace.clone(), + window, + cx, + )), + false, + false, + None, + window, + cx, + ); + pane + }); + + let _subscriptions = vec![ + cx.observe(&pane, |_, _, cx| cx.notify()), + cx.subscribe_in(&pane, window, Self::handle_pane_event), + cx.subscribe_in(&dap_store, window, Self::handle_dap_store_event), + ]; + + let debug_panel = Self { + pane, + size: px(300.), + _subscriptions, + project: project.downgrade(), + workspace: workspace.weak_handle(), + }; + + debug_panel + }) + } + + pub fn load( + workspace: WeakEntity, + cx: AsyncWindowContext, + ) -> Task>> { + cx.spawn(|mut cx| async move { + workspace.update_in(&mut cx, |workspace, window, cx| { + let debug_panel = DebugPanel::new(workspace, window, cx); + + cx.observe(&debug_panel, |_, debug_panel, cx| { + let (has_active_session, support_step_back) = + debug_panel.update(cx, |this, cx| { + this.active_debug_panel_item(cx) + .map(|item| (true, false)) + .unwrap_or((false, false)) + }); + + let filter = CommandPaletteFilter::global_mut(cx); + let debugger_action_types = [ + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + ]; + + let step_back_action_type = [TypeId::of::()]; + + if has_active_session { + filter.show_action_types(debugger_action_types.iter()); + + if support_step_back { + filter.show_action_types(step_back_action_type.iter()); + } else { + filter.hide_action_types(&step_back_action_type); + } + } else { + // show only the `debug: start` + filter.hide_action_types(&debugger_action_types); + filter.hide_action_types(&step_back_action_type); + } + }) + .detach(); + + debug_panel + }) + }) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn message_queue(&self) -> &HashMap> { + // &self.message_queue + unimplemented!("Should chekc session for console messagse") + } + + #[cfg(any(test, feature = "test-support"))] + pub fn dap_store(&self) -> Entity { + self.dap_store.clone() + } + + pub fn active_debug_panel_item(&self, cx: &Context) -> Option> { + self.pane + .read(cx) + .active_item() + .and_then(|panel| panel.downcast::()) + } + + pub fn debug_panel_items_by_client( + &self, + client_id: &SessionId, + cx: &Context, + ) -> Vec> { + self.pane + .read(cx) + .items() + .filter_map(|item| item.downcast::()) + .filter(|item| item.read(cx).session_id(cx) == Some(*client_id)) + .map(|item| item.clone()) + .collect() + } + + pub fn debug_panel_item_by_client( + &self, + client_id: SessionId, + cx: &mut Context, + ) -> Option> { + self.pane + .read(cx) + .items() + .filter_map(|item| item.downcast::()) + .find(|item| { + let item = item.read(cx); + + item.session_id(cx) == Some(client_id) + }) + } + + fn handle_dap_store_event( + &mut self, + dap_store: &Entity, + event: &dap_store::DapStoreEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + dap_store::DapStoreEvent::DebugClientStarted(session_id) => { + let Some(session) = dap_store.read(cx).session_by_id(session_id) else { + return log::error!("Couldn't get session with id: {session_id:?} from DebugClientStarted event"); + }; + + let Some(project) = self.project.upgrade() else { + return log::error!("Debug Panel out lived it's weak reference to Project"); + }; + + let session_item = + DebugSession::running(project, self.workspace.clone(), session, window, cx); + + self.pane.update(cx, |pane, cx| { + pane.add_item(Box::new(session_item), true, true, None, window, cx); + window.focus(&pane.focus_handle(cx)); + cx.notify(); + }); + } + _ => {} + } + } + + fn handle_pane_event( + &mut self, + _: &Entity, + event: &pane::Event, + window: &mut Window, + cx: &mut Context, + ) { + match event { + 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(), window, cx) + }) + .ok(); + } + _ => {} + } + } +} + +impl EventEmitter for DebugPanel {} +impl EventEmitter for DebugPanel {} +impl EventEmitter for DebugPanel {} + +impl Focusable for DebugPanel { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.pane.focus_handle(cx) + } +} + +impl Panel for DebugPanel { + fn pane(&self) -> Option> { + Some(self.pane.clone()) + } + + fn persistent_name() -> &'static str { + "DebugPanel" + } + + fn position(&self, _window: &Window, _cx: &App) -> DockPosition { + DockPosition::Bottom + } + + fn position_is_valid(&self, position: DockPosition) -> bool { + position == DockPosition::Bottom + } + + fn set_position( + &mut self, + _position: DockPosition, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn size(&self, _window: &Window, _cx: &App) -> Pixels { + self.size + } + + fn set_size(&mut self, size: Option, _window: &mut Window, _cx: &mut Context) { + self.size = size.unwrap(); + } + + fn remote_id() -> Option { + Some(proto::PanelId::DebugPanel) + } + + fn icon(&self, _window: &Window, _cx: &App) -> Option { + Some(IconName::Debug) + } + + fn icon_tooltip(&self, _window: &Window, cx: &App) -> Option<&'static str> { + if DebuggerSettings::get_global(cx).button { + Some("Debug Panel") + } else { + None + } + } + + fn toggle_action(&self) -> Box { + Box::new(ToggleFocus) + } + + fn activation_priority(&self) -> u32 { + 9 + } + fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { + if active && self.pane.read(cx).items_len() == 0 { + let Some(project) = self.project.clone().upgrade() else { + return; + }; + // todo: We need to revisit it when we start adding stopped items to pane (as that'll cause us to add two items). + self.pane.update(cx, |this, cx| { + this.add_item( + Box::new(DebugSession::inert( + project, + self.workspace.clone(), + window, + cx, + )), + false, + false, + None, + window, + cx, + ); + }); + } + } +} + +impl Render for DebugPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("DebugPanel") + .track_focus(&self.focus_handle(cx)) + .size_full() + .child(self.pane.clone()) + .into_any() + } +} diff --git a/crates/debugger_ui/src/lib.rs b/crates/debugger_ui/src/lib.rs new file mode 100644 index 00000000000000..49c64edf502951 --- /dev/null +++ b/crates/debugger_ui/src/lib.rs @@ -0,0 +1,43 @@ +use dap::debugger_settings::DebuggerSettings; +use debugger_panel::{DebugPanel, ToggleFocus}; +use gpui::App; +use session::DebugSession; +use settings::Settings; +use workspace::{ShutdownDebugAdapters, Start, Workspace}; + +pub mod attach_modal; +pub mod debugger_panel; +pub mod session; + +#[cfg(test)] +mod tests; + +pub fn init(cx: &mut App) { + DebuggerSettings::register(cx); + workspace::FollowableViewRegistry::register::(cx); + + cx.observe_new(|workspace: &mut Workspace, window, _cx| { + let Some(_) = window else { + return; + }; + + workspace + .register_action(|workspace, _: &ToggleFocus, window, cx| { + workspace.toggle_panel_focus::(window, cx); + }) + .register_action(|workspace: &mut Workspace, _: &Start, window, cx| { + tasks_ui::toggle_modal(workspace, None, task::TaskModal::DebugModal, window, cx) + .detach(); + }) + .register_action( + |workspace: &mut Workspace, _: &ShutdownDebugAdapters, _window, cx| { + workspace.project().update(cx, |project, cx| { + project.dap_store().update(cx, |store, cx| { + store.shutdown_sessions(cx).detach(); + }) + }) + }, + ); + }) + .detach(); +} diff --git a/crates/debugger_ui/src/session.rs b/crates/debugger_ui/src/session.rs new file mode 100644 index 00000000000000..a1065463c2cb80 --- /dev/null +++ b/crates/debugger_ui/src/session.rs @@ -0,0 +1,270 @@ +mod inert; +mod running; +mod starting; + +use dap::client::SessionId; +use gpui::{ + AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity, +}; +use inert::{InertEvent, InertState}; +use project::debugger::{self, dap_store::DapStore, session::Session}; +use project::worktree_store::WorktreeStore; +use project::Project; +use rpc::proto::{self, PeerId}; +use running::RunningState; +use starting::{StartingEvent, StartingState}; +use ui::prelude::*; +use workspace::{ + item::{self, Item}, + FollowableItem, ViewId, Workspace, +}; + +enum DebugSessionState { + Inert(Entity), + Starting(Entity), + Running(Entity), +} + +pub struct DebugSession { + remote_id: Option, + mode: DebugSessionState, + dap_store: WeakEntity, + worktree_store: WeakEntity, + workspace: WeakEntity, + _subscriptions: [Subscription; 1], +} +#[derive(Debug)] +pub enum DebugPanelItemEvent { + Close, + Stopped { go_to_stack_frame: bool }, +} + +#[derive(Clone, PartialEq, Eq)] +pub enum ThreadItem { + Console, + LoadedSource, + Modules, + Variables, +} + +impl ThreadItem { + fn _to_proto(&self) -> proto::DebuggerThreadItem { + match self { + ThreadItem::Console => proto::DebuggerThreadItem::Console, + ThreadItem::LoadedSource => proto::DebuggerThreadItem::LoadedSource, + ThreadItem::Modules => proto::DebuggerThreadItem::Modules, + ThreadItem::Variables => proto::DebuggerThreadItem::Variables, + } + } + + fn from_proto(active_thread_item: proto::DebuggerThreadItem) -> Self { + match active_thread_item { + proto::DebuggerThreadItem::Console => ThreadItem::Console, + proto::DebuggerThreadItem::LoadedSource => ThreadItem::LoadedSource, + proto::DebuggerThreadItem::Modules => ThreadItem::Modules, + proto::DebuggerThreadItem::Variables => ThreadItem::Variables, + } + } +} + +impl DebugSession { + pub(super) fn inert( + project: Entity, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Entity { + let inert = cx.new(|cx| InertState::new(window, cx)); + + let project = project.read(cx); + let dap_store = project.dap_store().downgrade(); + let worktree_store = project.worktree_store().downgrade(); + cx.new(|cx| { + let _subscriptions = [cx.subscribe_in(&inert, window, Self::on_inert_event)]; + Self { + remote_id: None, + mode: DebugSessionState::Inert(inert), + dap_store, + worktree_store, + workspace, + _subscriptions, + } + }) + } + + pub(crate) fn running( + project: Entity, + workspace: WeakEntity, + session: Entity, + window: &mut Window, + cx: &mut App, + ) -> Entity { + let mode = DebugSessionState::Running( + cx.new(|cx| RunningState::new(session.clone(), workspace.clone(), window, cx)), + ); + + cx.new(|cx| Self { + remote_id: None, + mode, + dap_store: project.read(cx).dap_store().downgrade(), + worktree_store: project.read(cx).worktree_store().downgrade(), + workspace, + _subscriptions: [cx.subscribe(&project, |_, _, _, _| {})], // todo(debugger) We don't need this subscription + }) + } + + pub(crate) fn session_id(&self, cx: &App) -> Option { + match &self.mode { + DebugSessionState::Inert(_) => None, + DebugSessionState::Starting(_entity) => unimplemented!(), + DebugSessionState::Running(entity) => Some(entity.read(cx).session_id()), + } + } + fn on_inert_event( + &mut self, + _: &Entity, + event: &InertEvent, + window: &mut Window, + cx: &mut Context<'_, Self>, + ) { + let dap_store = self.dap_store.clone(); + let InertEvent::Spawned { config } = event; + let config = config.clone(); + let worktree = self + .worktree_store + .update(cx, |this, _| this.worktrees().next()) + .ok() + .flatten() + .expect("worktree-less project"); + let Ok(task) = dap_store.update(cx, |store, cx| store.new_session(config, &worktree, cx)) + else { + return; + }; + let starting = cx.new(|cx| StartingState::new(task, cx)); + + self._subscriptions = [cx.subscribe_in(&starting, window, Self::on_starting_event)]; + self.mode = DebugSessionState::Starting(starting); + } + + fn on_starting_event( + &mut self, + _: &Entity, + event: &StartingEvent, + window: &mut Window, + cx: &mut Context<'_, Self>, + ) { + let StartingEvent::Finished(Ok(session)) = event else { + return; + }; + + let mode = + cx.new(|cx| RunningState::new(session.clone(), self.workspace.clone(), window, cx)); + + self.mode = DebugSessionState::Running(mode); + } +} +impl EventEmitter for DebugSession {} + +impl Focusable for DebugSession { + fn focus_handle(&self, cx: &App) -> FocusHandle { + match &self.mode { + DebugSessionState::Inert(inert_state) => inert_state.focus_handle(cx), + DebugSessionState::Starting(starting_state) => starting_state.focus_handle(cx), + DebugSessionState::Running(running_state) => running_state.focus_handle(cx), + } + } +} + +impl Item for DebugSession { + type Event = DebugPanelItemEvent; + fn tab_content(&self, _: item::TabContentParams, _: &Window, _: &App) -> AnyElement { + let label = match &self.mode { + DebugSessionState::Inert(_) => "New Session", + DebugSessionState::Starting(_) => "Starting", + DebugSessionState::Running(_) => "Running", + }; + div().child(Label::new(label)).into_any_element() + } +} + +impl FollowableItem for DebugSession { + fn remote_id(&self) -> Option { + self.remote_id + } + + fn to_state_proto(&self, _window: &Window, _cx: &App) -> Option { + None + } + + fn from_state_proto( + _workspace: Entity, + _remote_id: ViewId, + _state: &mut Option, + _window: &mut Window, + _cx: &mut App, + ) -> Option>>> { + None + } + + fn add_event_to_update_proto( + &self, + _event: &Self::Event, + _update: &mut Option, + _window: &Window, + _cx: &App, + ) -> bool { + // update.get_or_insert_with(|| proto::update_view::Variant::DebugPanel(Default::default())); + + true + } + + fn apply_update_proto( + &mut self, + _project: &Entity, + _message: proto::update_view::Variant, + _window: &mut Window, + _cx: &mut Context, + ) -> gpui::Task> { + Task::ready(Ok(())) + } + + fn set_leader_peer_id( + &mut self, + _leader_peer_id: Option, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn to_follow_event(_event: &Self::Event) -> Option { + None + } + + fn dedup(&self, existing: &Self, _window: &Window, cx: &App) -> Option { + if existing.session_id(cx) == self.session_id(cx) { + Some(item::Dedup::KeepExisting) + } else { + None + } + } + + fn is_project_item(&self, _window: &Window, _cx: &App) -> bool { + true + } +} + +impl Render for DebugSession { + fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement { + match &self.mode { + DebugSessionState::Inert(inert_state) => { + inert_state.update(cx, |this, cx| this.render(window, cx).into_any_element()) + } + DebugSessionState::Starting(starting_state) => { + starting_state.update(cx, |this, cx| this.render(window, cx).into_any_element()) + } + DebugSessionState::Running(running_state) => { + running_state.update(cx, |this, cx| this.render(window, cx).into_any_element()) + } + } + } +} diff --git a/crates/debugger_ui/src/session/inert.rs b/crates/debugger_ui/src/session/inert.rs new file mode 100644 index 00000000000000..b14c1257e44586 --- /dev/null +++ b/crates/debugger_ui/src/session/inert.rs @@ -0,0 +1,165 @@ +use std::path::PathBuf; + +use dap::{DebugAdapterConfig, DebugAdapterKind, DebugRequestType}; +use editor::{Editor, EditorElement, EditorStyle}; +use gpui::{App, AppContext, Entity, EventEmitter, FocusHandle, Focusable, TextStyle}; +use settings::Settings as _; +use task::TCPHost; +use theme::ThemeSettings; +use ui::{ + h_flex, relative, v_flex, ActiveTheme as _, Button, ButtonCommon, ButtonStyle, Clickable, + Context, ContextMenu, DropdownMenu, InteractiveElement, IntoElement, Label, ParentElement, + Render, SharedString, Styled, Window, +}; + +pub(super) struct InertState { + focus_handle: FocusHandle, + selected_debugger: Option, + program_editor: Entity, + cwd_editor: Entity, +} + +impl InertState { + pub(super) fn new(window: &mut Window, cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + selected_debugger: None, + program_editor: cx.new(|cx| Editor::single_line(window, cx)), + cwd_editor: cx.new(|cx| Editor::single_line(window, cx)), + } + } +} +impl Focusable for InertState { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +pub(crate) enum InertEvent { + Spawned { config: DebugAdapterConfig }, +} + +impl EventEmitter for InertState {} + +static SELECT_DEBUGGER_LABEL: SharedString = SharedString::new_static("Select Debugger"); + +impl Render for InertState { + fn render( + &mut self, + window: &mut ui::Window, + cx: &mut ui::Context<'_, Self>, + ) -> impl ui::IntoElement { + let weak = cx.weak_entity(); + v_flex() + .track_focus(&self.focus_handle) + .size_full() + .gap_1() + .p_1() + .child( + h_flex().child(DropdownMenu::new( + "dap-adapter-picker", + self.selected_debugger + .as_ref() + .unwrap_or_else(|| &SELECT_DEBUGGER_LABEL) + .clone(), + ContextMenu::build(window, cx, move |this, _, _| { + let setter_for_name = |name: &'static str| { + let weak = weak.clone(); + move |_: &mut Window, cx: &mut App| { + let name = name; + (&weak) + .update(cx, move |this, _| { + this.selected_debugger = Some(name.into()); + }) + .ok(); + } + }; + this.entry("GDB", None, setter_for_name("GDB")) + .entry("Delve", None, setter_for_name("Delve")) + .entry("LLDB", None, setter_for_name("LLDB")) + .entry("PHP", None, setter_for_name("PHP")) + .entry("JavaScript", None, setter_for_name("JavaScript")) + .entry("Debugpy", None, setter_for_name("Debugpy")) + }), + )), + ) + .child( + v_flex() + .child( + h_flex() + .w_4_5() + .gap_2() + .child(Label::new("Program path")) + .child(Self::render_editor(&self.program_editor, cx)), + ) + .child( + h_flex() + .gap_2() + .child(Label::new("Working directory")) + .child(Self::render_editor(&self.cwd_editor, cx)), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("launch-dap", "Launch") + .style(ButtonStyle::Filled) + .on_click(cx.listener(|this, _, _, cx| { + let program = this.program_editor.read(cx).text(cx); + let cwd = PathBuf::from(this.cwd_editor.read(cx).text(cx)); + let kind = kind_for_label(this.selected_debugger.as_deref().unwrap_or_else(|| unimplemented!("Automatic selection of a debugger based on users project"))); + cx.emit(InertEvent::Spawned { + config: DebugAdapterConfig { + label: "hard coded".into(), + kind, + request: DebugRequestType::Launch, + program: Some(program), + cwd: Some(cwd), + initialize_args: None, + supports_attach: false, + }, + }); + })), + ) + .child(Button::new("attach-dap", "Attach").style(ButtonStyle::Filled)), + ) + } +} + +fn kind_for_label(label: &str) -> DebugAdapterKind { + match label { + "LLDB" => DebugAdapterKind::Lldb, + "Debugpy" => DebugAdapterKind::Python(TCPHost::default()), + "JavaScript" => DebugAdapterKind::Javascript(TCPHost::default()), + "PHP" => DebugAdapterKind::Php(TCPHost::default()), + "Delve" => DebugAdapterKind::Go(TCPHost::default()), + _ => { + unimplemented!() + } // Maybe we should set a toast notification here + } +} +impl InertState { + fn render_editor(editor: &Entity, cx: &Context) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: 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( + editor, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + ..Default::default() + }, + ) + } +} diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs new file mode 100644 index 00000000000000..07e7e094b7fcfd --- /dev/null +++ b/crates/debugger_ui/src/session/running.rs @@ -0,0 +1,681 @@ +mod console; +mod loaded_source_list; +mod module_list; +mod stack_frame_list; +mod variable_list; + +use super::{DebugPanelItemEvent, ThreadItem}; +use console::Console; +use dap::{client::SessionId, debugger_settings::DebuggerSettings, Capabilities}; +use gpui::{AppContext, Entity, EventEmitter, FocusHandle, Focusable, Subscription, WeakEntity}; +use loaded_source_list::LoadedSourceList; +use module_list::ModuleList; +use project::debugger::session::{Session, ThreadId, ThreadStatus}; +use rpc::proto::ViewId; +use settings::Settings; +use stack_frame_list::{StackFrameList, StackFrameListEvent}; +use ui::{ + div, h_flex, v_flex, ActiveTheme, AnyElement, App, Button, ButtonCommon, Clickable, Context, + ContextMenu, Disableable, DropdownMenu, FluentBuilder, IconButton, IconName, IconSize, + Indicator, InteractiveElement, IntoElement, ParentElement, Render, SharedString, + StatefulInteractiveElement, Styled, Tooltip, Window, +}; +use variable_list::VariableList; +use workspace::{item::ItemEvent, Item, Workspace}; + +pub struct RunningState { + session: Entity, + thread: Option<(ThreadId, String)>, + console: Entity, + focus_handle: FocusHandle, + remote_id: Option, + show_console_indicator: bool, + module_list: Entity, + active_thread_item: ThreadItem, + _workspace: WeakEntity, + session_id: SessionId, + variable_list: Entity, + _subscriptions: Vec, + stack_frame_list: Entity, + loaded_source_list: Entity, +} + +impl Render for RunningState { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let threads = self.session.update(cx, |this, cx| this.threads(cx)); + if let Some((thread, _)) = threads.first().filter(|_| self.thread.is_none()) { + self.select_thread(ThreadId(thread.id), thread.name.clone(), cx); + } + + let thread_status = self + .thread + .as_ref() + .map(|(thread_id, _)| self.session.read(cx).thread_status(*thread_id)) + .unwrap_or(ThreadStatus::Exited); + let is_terminated = self.session.read(cx).is_terminated(); + let active_thread_item = &self.active_thread_item; + + let has_no_threads = threads.is_empty(); + let capabilities = self.capabilities(cx); + let state = cx.entity(); + h_flex() + .when(is_terminated, |this| this.bg(gpui::red())) + .key_context("DebugPanelItem") + .track_focus(&self.focus_handle(cx)) + .size_full() + .items_start() + .child( + v_flex() + .size_full() + .items_start() + .child( + h_flex() + .w_full() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .justify_between() + .child( + h_flex() + .p_1() + .w_full() + .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, _, _window, cx| { + this.pause_thread(cx); + })) + .tooltip(move |window, cx| { + Tooltip::text("Pause program")(window, cx) + }), + ) + } else { + this.child( + IconButton::new( + "debug-continue", + IconName::DebugContinue, + ) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.continue_thread(cx) + })) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |window, cx| { + Tooltip::text("Continue program")(window, cx) + }), + ) + } + }) + .when( + capabilities.supports_step_back.unwrap_or(false), + |this| { + this.child( + IconButton::new( + "debug-step-back", + IconName::DebugStepBack, + ) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.step_back(cx); + })) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |window, cx| { + Tooltip::text("Step back")(window, cx) + }), + ) + }, + ) + .child( + IconButton::new("debug-step-over", IconName::DebugStepOver) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.step_over(cx); + })) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |window, cx| { + Tooltip::text("Step over")(window, cx) + }), + ) + .child( + IconButton::new("debug-step-in", IconName::DebugStepInto) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.step_in(cx); + })) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |window, cx| { + Tooltip::text("Step in")(window, cx) + }), + ) + .child( + IconButton::new("debug-step-out", IconName::DebugStepOut) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.step_out(cx); + })) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |window, cx| { + Tooltip::text("Step out")(window, cx) + }), + ) + .child( + IconButton::new("debug-restart", IconName::DebugRestart) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.restart_client(cx); + })) + .disabled( + !capabilities + .supports_restart_request + .unwrap_or_default(), + ) + .tooltip(move |window, cx| { + Tooltip::text("Restart")(window, cx) + }), + ) + .child( + IconButton::new("debug-stop", IconName::DebugStop) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.stop_thread(cx); + })) + .disabled( + thread_status != ThreadStatus::Stopped + && thread_status != ThreadStatus::Running, + ) + .tooltip(move |window, cx| { + Tooltip::text("Stop")(window, cx) + }), + ) + .child( + IconButton::new( + "debug-disconnect", + IconName::DebugDisconnect, + ) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.disconnect_client(cx); + })) + .disabled( + thread_status == ThreadStatus::Exited + || thread_status == ThreadStatus::Ended, + ) + .tooltip( + move |window, cx| { + Tooltip::text("Disconnect")(window, cx) + }, + ), + ) + .child( + IconButton::new( + "debug-ignore-breakpoints", + if self.session.read(cx).breakpoints_enabled() { + IconName::DebugBreakpoint + } else { + IconName::DebugIgnoreBreakpoints + }, + ) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.toggle_ignore_breakpoints(cx); + })) + .disabled( + thread_status == ThreadStatus::Exited + || thread_status == ThreadStatus::Ended, + ) + .tooltip( + move |window, cx| { + Tooltip::text("Ignore breakpoints")(window, cx) + }, + ), + ), + ) + //.child(h_flex()) + .child( + h_flex().p_1().mx_2().w_3_4().justify_end().child( + DropdownMenu::new( + "thread-list", + self.thread + .as_ref() + .map(|(_, name)| format!("Thread {name}")) + .unwrap_or_else(|| "Threads".into()), + ContextMenu::build(window, cx, move |mut this, _, _| { + for (thread, _) in threads { + let state = state.clone(); + let thread_id = thread.id; + let thread_name = SharedString::from(&thread.name); + this = + this.entry(thread.name, None, move |_, cx| { + state.update(cx, |state, cx| { + state.select_thread( + ThreadId(thread_id), + String::from(thread_name.as_ref()), + cx, + ); + }); + }); + } + this + }), + ) + .disabled(has_no_threads), + ), + ), + ) + .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, + )), + ) + .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::Console, |this| { + this.child(self.console.clone()) + }), + ) + } +} + +impl RunningState { + pub fn new( + session: Entity, + workspace: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + let session_id = session.read(cx).session_id(); + let stack_frame_list = + cx.new(|cx| StackFrameList::new(workspace.clone(), session.clone(), cx)); + + let variable_list = + cx.new(|cx| VariableList::new(session.clone(), stack_frame_list.clone(), window, cx)); + + let module_list = cx.new(|cx| ModuleList::new(session.clone(), cx)); + + let loaded_source_list = cx.new(|cx| LoadedSourceList::new(session.clone(), cx)); + + let console = cx.new(|cx| { + Console::new( + session.clone(), + stack_frame_list.clone(), + variable_list.clone(), + window, + cx, + ) + }); + + cx.observe(&module_list, |_, _, cx| cx.notify()).detach(); + + let _subscriptions = vec![cx.subscribe( + &stack_frame_list, + move |this: &mut Self, _, event: &StackFrameListEvent, cx| match event { + StackFrameListEvent::SelectedStackFrameChanged(_) + | StackFrameListEvent::StackFramesUpdated => this.clear_highlights(cx), + }, + )]; + + Self { + session, + console, + _workspace: workspace, + module_list, + focus_handle, + variable_list, + _subscriptions, + thread: None, + remote_id: None, + stack_frame_list, + loaded_source_list, + session_id, + show_console_indicator: false, + active_thread_item: ThreadItem::Variables, + } + } + + // pub(crate) fn update_adapter( + // &mut self, + // update: &UpdateDebugAdapter, + // window: &mut Window, + // cx: &mut Context, + // ) { + // if let Some(update_variant) = update.variant.as_ref() { + // match update_variant { + // proto::update_debug_adapter::Variant::StackFrameList(stack_frame_list) => { + // self.stack_frame_list.update(cx, |this, cx| { + // this.set_from_proto(stack_frame_list.clone(), cx); + // }) + // } + // proto::update_debug_adapter::Variant::ThreadState(thread_state) => { + // self.thread_state.update(cx, |this, _| { + // *this = ThreadState::from_proto(thread_state.clone()); + // }) + // } + // proto::update_debug_adapter::Variant::VariableList(variable_list) => self + // .variable_list + // .update(cx, |this, cx| this.set_from_proto(variable_list, cx)), + // proto::update_debug_adapter::Variant::AddToVariableList(variables_to_add) => self + // .variable_list + // .update(cx, |this, _| this.add_variables(variables_to_add.clone())), + // proto::update_debug_adapter::Variant::Modules(_) => {} + // proto::update_debug_adapter::Variant::OutputEvent(output_event) => { + // self.console.update(cx, |this, cx| { + // this.add_message(OutputEvent::from_proto(output_event.clone()), window, cx); + // }) + // } + // } + // } + // } + + pub fn session(&self) -> &Entity { + &self.session + } + + pub fn session_id(&self) -> SessionId { + self.session_id + } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_thread_item(&mut self, thread_item: ThreadItem, cx: &mut Context) { + self.active_thread_item = thread_item; + cx.notify() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn stack_frame_list(&self) -> &Entity { + &self.stack_frame_list + } + + #[cfg(any(test, feature = "test-support"))] + pub fn console(&self) -> &Entity { + &self.console + } + + #[cfg(any(test, feature = "test-support"))] + pub fn module_list(&self) -> &Entity { + &self.module_list + } + + #[cfg(any(test, feature = "test-support"))] + pub fn variable_list(&self) -> &Entity { + &self.variable_list + } + + #[cfg(any(test, feature = "test-support"))] + pub fn are_breakpoints_ignored(&self, cx: &App) -> bool { + self.session.read(cx).ignore_breakpoints() + } + + pub fn capabilities(&self, cx: &mut Context) -> Capabilities { + self.session().read(cx).capabilities().clone() + } + + fn select_thread(&mut self, thread_id: ThreadId, thread_name: String, cx: &mut Context) { + self.thread = Some((thread_id, thread_name)); + + self.stack_frame_list.update(cx, |stack_frame_list, cx| { + stack_frame_list.set_thread_id(self.thread.as_ref().map(|id| id.0), cx); + }); + + cx.notify(); + } + + fn clear_highlights(&self, _cx: &mut Context) { + // TODO(debugger): make this work again + // if let Some((_, project_path, _)) = self.dap_store.read(cx).active_debug_line() { + // self.workspace + // .update(cx, |workspace, cx| { + // let editor = workspace + // .items_of_type::(cx) + // .find(|editor| Some(project_path.clone()) == editor.project_path(cx)); + + // if let Some(editor) = editor { + // editor.update(cx, |editor, cx| { + // editor.clear_row_highlights::(); + + // cx.notify(); + // }); + // } + // }) + // .ok(); + // } + } + + pub fn go_to_current_stack_frame(&self, window: &mut Window, cx: &mut Context) { + self.stack_frame_list.update(cx, |stack_frame_list, cx| { + if let Some(stack_frame) = stack_frame_list + .stack_frames(cx) + .iter() + .find(|frame| frame.dap.id == stack_frame_list.current_stack_frame_id()) + .cloned() + { + stack_frame_list + .select_stack_frame(&stack_frame.dap, true, window, cx) + .detach_and_log_err(cx); + } + }); + } + + fn render_entry_button( + &self, + label: &SharedString, + thread_item: ThreadItem, + cx: &mut Context, + ) -> AnyElement { + let has_indicator = + matches!(thread_item, ThreadItem::Console) && self.show_console_indicator; + + 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( + h_flex() + .child(Button::new(label.clone(), label.clone())) + .when(has_indicator, |this| this.child(Indicator::dot())), + ) + .on_click(cx.listener(move |this, _, _window, cx| { + this.active_thread_item = thread_item.clone(); + + if matches!(this.active_thread_item, ThreadItem::Console) { + this.show_console_indicator = false; + } + + cx.notify(); + })) + .into_any_element() + } + + pub fn continue_thread(&mut self, cx: &mut Context) { + let Some((thread_id, _)) = self.thread else { + return; + }; + + self.session().update(cx, |state, cx| { + state.continue_thread(thread_id, cx); + }); + } + + pub fn step_over(&mut self, cx: &mut Context) { + let Some((thread_id, _)) = self.thread else { + return; + }; + + let granularity = DebuggerSettings::get_global(cx).stepping_granularity; + + self.session().update(cx, |state, cx| { + state.step_over(thread_id, granularity, cx); + }); + } + + pub fn step_in(&mut self, cx: &mut Context) { + let Some((thread_id, _)) = self.thread else { + return; + }; + + let granularity = DebuggerSettings::get_global(cx).stepping_granularity; + + self.session().update(cx, |state, cx| { + state.step_in(thread_id, granularity, cx); + }); + } + + pub fn step_out(&mut self, cx: &mut Context) { + let Some((thread_id, _)) = self.thread else { + return; + }; + + let granularity = DebuggerSettings::get_global(cx).stepping_granularity; + + self.session().update(cx, |state, cx| { + state.step_out(thread_id, granularity, cx); + }); + } + + pub fn step_back(&mut self, cx: &mut Context) { + let Some((thread_id, _)) = self.thread else { + return; + }; + + let granularity = DebuggerSettings::get_global(cx).stepping_granularity; + + self.session().update(cx, |state, cx| { + state.step_back(thread_id, granularity, cx); + }); + } + + pub fn restart_client(&self, cx: &mut Context) { + self.session().update(cx, |state, cx| { + state.restart(None, cx); + }); + } + + pub fn pause_thread(&self, cx: &mut Context) { + let Some((thread_id, _)) = self.thread else { + return; + }; + + self.session().update(cx, |state, cx| { + state.pause_thread(thread_id, cx); + }); + } + + pub fn stop_thread(&self, cx: &mut Context) { + let Some((thread_id, _)) = self.thread else { + return; + }; + + self.session().update(cx, |state, cx| { + state.terminate_threads(Some(vec![thread_id; 1]), cx); + }); + } + + pub fn disconnect_client(&self, cx: &mut Context) { + self.session().update(cx, |state, cx| { + state.disconnect_client(cx); + }); + } + + pub fn toggle_ignore_breakpoints(&mut self, cx: &mut Context) { + self.session + .update(cx, |session, cx| session.toggle_ignore_breakpoints(cx)) + .detach_and_log_err(cx); + } +} + +impl EventEmitter for RunningState {} + +impl Focusable for RunningState { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Item for RunningState { + type Event = DebugPanelItemEvent; + + fn tab_content( + &self, + params: workspace::item::TabContentParams, + _window: &Window, + cx: &App, + ) -> AnyElement { + todo!() + } + + fn tab_tooltip_text(&self, cx: &App) -> Option { + todo!() + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { + match event { + DebugPanelItemEvent::Close => f(ItemEvent::CloseItem), + DebugPanelItemEvent::Stopped { .. } => {} + } + } +} diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs new file mode 100644 index 00000000000000..c63a64ad79c08b --- /dev/null +++ b/crates/debugger_ui/src/session/running/console.rs @@ -0,0 +1,508 @@ +use super::{ + stack_frame_list::{StackFrameList, StackFrameListEvent}, + variable_list::VariableList, +}; +use dap::{OutputEvent, OutputEventGroup}; +use editor::{ + display_map::{Crease, CreaseId}, + Anchor, CompletionProvider, Editor, EditorElement, EditorStyle, FoldPlaceholder, +}; +use fuzzy::StringMatchCandidate; +use gpui::{Context, Entity, Render, Subscription, Task, TextStyle, WeakEntity}; +use language::{Buffer, CodeLabel, LanguageServerId}; +use menu::Confirm; +use project::{ + debugger::session::{CompletionsQuery, Session}, + Completion, +}; +use settings::Settings; +use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::Arc, usize}; +use theme::ThemeSettings; +use ui::{prelude::*, ButtonLike, Disclosure, ElevationIndex}; + +pub struct OutputGroup { + pub start: Anchor, + pub collapsed: bool, + pub end: Option, + pub crease_ids: Vec, + pub placeholder: SharedString, +} + +pub struct Console { + groups: Vec, + console: Entity, + query_bar: Entity, + session: Entity, + _subscriptions: Vec, + variable_list: Entity, + stack_frame_list: Entity, +} + +impl Console { + pub fn new( + session: Entity, + stack_frame_list: Entity, + variable_list: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let console = cx.new(|cx| { + let mut editor = Editor::multi_line(window, cx); + editor.move_to_end(&editor::actions::MoveToEnd, window, cx); + editor.set_read_only(true); + editor.set_show_gutter(true, cx); + editor.set_show_runnables(false, cx); + editor.set_show_code_actions(false, cx); + editor.set_show_line_numbers(false, cx); + editor.set_show_git_diff_gutter(false, cx); + editor.set_autoindent(false); + editor.set_input_enabled(false); + editor.set_use_autoclose(false); + editor.set_show_wrap_guides(false, cx); + editor.set_show_indent_guides(false, cx); + editor.set_show_edit_predictions(Some(false), window, cx); + editor + }); + + let this = cx.weak_entity(); + let query_bar = cx.new(|cx| { + let mut editor = Editor::single_line(window, 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), + cx.observe_in(&session, window, |console, session, window, cx| { + let (output, last_processed_ix) = session.update(cx, |session, cx| { + (session.output(), session.last_processed_output()) + }); + + if output.len() > last_processed_ix { + for event in &output[last_processed_ix..] { + console.add_message(event.clone(), window, cx); + } + + session.update(cx, |session, cx| { + session.set_last_processed_output(output.len()); + }); + } + }), + ]; + + Self { + session, + console, + query_bar, + variable_list, + _subscriptions, + stack_frame_list, + groups: Vec::default(), + } + } + + #[cfg(any(test, feature = "test-support"))] + pub fn editor(&self) -> &Entity { + &self.console + } + + #[cfg(any(test, feature = "test-support"))] + pub fn query_bar(&self) -> &Entity { + &self.query_bar + } + + fn is_local(&self, cx: &Context) -> bool { + self.session.read(cx).is_local() + } + + fn handle_stack_frame_list_events( + &mut self, + _: Entity, + event: &StackFrameListEvent, + cx: &mut Context, + ) { + match event { + StackFrameListEvent::SelectedStackFrameChanged(_) => cx.notify(), + StackFrameListEvent::StackFramesUpdated => {} + } + } + + pub fn add_message(&mut self, event: OutputEvent, window: &mut Window, cx: &mut Context) { + self.console.update(cx, |console, cx| { + let output = event.output.trim_end().to_string(); + + let snapshot = console.buffer().read(cx).snapshot(cx); + + let start = snapshot.anchor_before(snapshot.max_point()); + + let mut indent_size = self + .groups + .iter() + .filter(|group| group.end.is_none()) + .count(); + if Some(OutputEventGroup::End) == event.group { + indent_size = indent_size.saturating_sub(1); + } + + let indent = if indent_size > 0 { + " ".repeat(indent_size) + } else { + "".to_string() + }; + + console.set_read_only(false); + console.move_to_end(&editor::actions::MoveToEnd, window, cx); + console.insert(format!("{}{}\n", indent, output).as_str(), window, cx); + console.set_read_only(true); + + let end = snapshot.anchor_before(snapshot.max_point()); + + match event.group { + Some(OutputEventGroup::Start) => { + self.groups.push(OutputGroup { + start, + end: None, + collapsed: false, + placeholder: output.clone().into(), + crease_ids: console.insert_creases( + vec![Self::create_crease(output.into(), start, end)], + cx, + ), + }); + } + Some(OutputEventGroup::StartCollapsed) => { + self.groups.push(OutputGroup { + start, + end: None, + collapsed: true, + placeholder: output.clone().into(), + crease_ids: console.insert_creases( + vec![Self::create_crease(output.into(), start, end)], + cx, + ), + }); + } + Some(OutputEventGroup::End) => { + if let Some(index) = self.groups.iter().rposition(|group| group.end.is_none()) { + let group = self.groups.remove(index); + + console.remove_creases(group.crease_ids.clone(), cx); + + let creases = + vec![Self::create_crease(group.placeholder, group.start, end)]; + console.insert_creases(creases.clone(), cx); + + if group.collapsed { + console.fold_creases(creases, false, window, cx); + } + } + } + None => {} + } + + cx.notify(); + }); + } + + fn create_crease(placeholder: SharedString, start: Anchor, end: Anchor) -> Crease { + Crease::inline( + start..end, + FoldPlaceholder { + render: Arc::new({ + let placeholder = placeholder.clone(); + move |_id, _range, _cx| { + ButtonLike::new("output-group-placeholder") + .style(ButtonStyle::Transparent) + .layer(ElevationIndex::ElevatedSurface) + .child(Label::new(placeholder.clone()).single_line()) + .into_any_element() + } + }), + ..Default::default() + }, + move |row, is_folded, fold, _window, _cx| { + Disclosure::new(("output-group", row.0 as u64), !is_folded) + .toggle_state(is_folded) + .on_click(move |_event, window, cx| fold(!is_folded, window, cx)) + .into_any_element() + }, + move |_id, _range, _window, _cx| gpui::Empty.into_any_element(), + ) + } + + pub fn evaluate(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let expression = self.query_bar.update(cx, |editor, cx| { + let expression = editor.text(cx); + + editor.clear(window, cx); + + expression + }); + + self.session.update(cx, |state, cx| { + state.evaluate( + expression, + Some(dap::EvaluateArgumentsContext::Variables), + Some(self.stack_frame_list.read(cx).current_stack_frame_id()), + None, + cx, + ); + }); + } + + fn render_console(&self, cx: &Context) -> 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: &Context) -> 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, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("DebugConsole") + .on_action(cx.listener(Self::evaluate)) + .size_full() + .child(self.render_console(cx)) + .when(self.is_local(cx), |this| { + this.child(self.render_query_bar(cx)) + .pt(DynamicSpacing::Base04.rems(cx)) + }) + .border_2() + } +} + +struct ConsoleQueryBarCompletionProvider(WeakEntity); + +impl CompletionProvider for ConsoleQueryBarCompletionProvider { + fn completions( + &self, + buffer: &Entity, + buffer_position: language::Anchor, + _trigger: editor::CompletionContext, + _window: &mut Window, + cx: &mut Context, + ) -> gpui::Task>> { + let Some(console) = self.0.upgrade() else { + return Task::ready(Ok(Vec::new())); + }; + + let support_completions = console + .read(cx) + .session + .read(cx) + .capabilities() + .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: Entity, + _completion_indices: Vec, + _completions: Rc>>, + _cx: &mut Context, + ) -> gpui::Task> { + Task::ready(Ok(false)) + } + + fn apply_additional_edits_for_completion( + &self, + _buffer: Entity, + _completions: Rc>>, + _completion_index: usize, + _push_to_history: bool, + _cx: &mut Context, + ) -> gpui::Task>> { + Task::ready(Ok(None)) + } + + fn is_completion_trigger( + &self, + _buffer: &Entity, + _position: language::Anchor, + _text: &str, + _trigger_in_words: bool, + _cx: &mut Context, + ) -> bool { + true + } +} + +impl ConsoleQueryBarCompletionProvider { + fn variable_list_completions( + &self, + console: &Entity, + buffer: &Entity, + buffer_position: language::Anchor, + cx: &mut Context, + ) -> 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, |variable_list, cx| { + variable_list.completion_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(); + + 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: buffer_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(usize::MAX), + documentation: None, + lsp_completion: Default::default(), + confirm: None, + resolved: true, + }) + }) + .collect()) + }) + } + + fn client_completions( + &self, + console: &Entity, + buffer: &Entity, + buffer_position: language::Anchor, + cx: &mut Context, + ) -> gpui::Task>> { + let completion_task = console.update(cx, |console, cx| { + console.session.update(cx, |state, cx| { + let frame_id = Some(console.stack_frame_list.read(cx).current_stack_frame_id()); + + state.completions( + CompletionsQuery::new(buffer.read(cx), buffer_position, frame_id), + cx, + ) + }) + }); + + cx.background_executor().spawn(async move { + Ok(completion_task + .await? + .iter() + .map(|completion| project::Completion { + old_range: buffer_position..buffer_position, // TODO(debugger): change this + 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(usize::MAX), + documentation: None, + lsp_completion: Default::default(), + confirm: None, + resolved: true, + }) + .collect()) + }) + } +} diff --git a/crates/debugger_ui/src/session/running/loaded_source_list.rs b/crates/debugger_ui/src/session/running/loaded_source_list.rs new file mode 100644 index 00000000000000..fd1fbdc98199f6 --- /dev/null +++ b/crates/debugger_ui/src/session/running/loaded_source_list.rs @@ -0,0 +1,95 @@ +use gpui::{list, AnyElement, Empty, Entity, FocusHandle, Focusable, ListState, Subscription}; +use project::debugger::session::Session; +use ui::prelude::*; +use util::maybe; + +pub struct LoadedSourceList { + list: ListState, + focus_handle: FocusHandle, + _subscription: Subscription, + session: Entity, +} + +impl LoadedSourceList { + pub fn new(session: Entity, cx: &mut Context) -> Self { + let weak_entity = cx.weak_entity(); + let focus_handle = cx.focus_handle(); + + let list = ListState::new( + 0, + gpui::ListAlignment::Top, + px(1000.), + move |ix, _window, cx| { + weak_entity + .upgrade() + .map(|loaded_sources| { + loaded_sources.update(cx, |this, cx| this.render_entry(ix, cx)) + }) + .unwrap_or(div().into_any()) + }, + ); + + let _subscription = cx.observe(&session, |loaded_source_list, state, cx| { + let len = state.update(cx, |state, cx| state.loaded_sources(cx).len()); + + loaded_source_list.list.reset(len); + cx.notify(); + }); + + Self { + list, + session, + focus_handle, + _subscription, + } + } + + fn render_entry(&mut self, ix: usize, cx: &mut Context) -> AnyElement { + let Some(source) = maybe!({ + self.session + .update(cx, |state, cx| state.loaded_sources(cx).get(ix).cloned()) + }) else { + return Empty.into_any(); + }; + + 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 Focusable for LoadedSourceList { + fn focus_handle(&self, _: &gpui::App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for LoadedSourceList { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.session.update(cx, |state, cx| { + state.loaded_sources(cx); + }); + + div() + .track_focus(&self.focus_handle) + .size_full() + .p_1() + .child(list(self.list.clone()).size_full()) + } +} diff --git a/crates/debugger_ui/src/session/running/module_list.rs b/crates/debugger_ui/src/session/running/module_list.rs new file mode 100644 index 00000000000000..821b91519fda0b --- /dev/null +++ b/crates/debugger_ui/src/session/running/module_list.rs @@ -0,0 +1,101 @@ +use dap::ModuleEvent; +use gpui::{list, AnyElement, Empty, Entity, FocusHandle, Focusable, ListState, Subscription}; +use project::debugger::session::Session; +use ui::prelude::*; + +pub struct ModuleList { + list: ListState, + focus_handle: FocusHandle, + _subscription: Subscription, + session: Entity, +} + +impl ModuleList { + pub fn new(session: Entity, cx: &mut Context) -> Self { + let weak_entity = cx.weak_entity(); + let focus_handle = cx.focus_handle(); + + let list = ListState::new( + 0, + gpui::ListAlignment::Top, + px(1000.), + move |ix, _window, cx| { + weak_entity + .upgrade() + .map(|module_list| module_list.update(cx, |this, cx| this.render_entry(ix, cx))) + .unwrap_or(div().into_any()) + }, + ); + + let _subscription = cx.observe(&session, |module_list, state, cx| { + let modules_len = state.update(cx, |state, cx| state.modules(cx).len()); + + module_list.list.reset(modules_len); + cx.notify(); + }); + + Self { + list, + session, + focus_handle, + _subscription, + } + } + + fn render_entry(&mut self, ix: usize, cx: &mut Context) -> AnyElement { + let Some(module) = maybe!({ + self.session + .update(cx, |state, cx| state.modules(cx).get(ix).cloned()) + }) else { + return Empty.into_any(); + }; + + 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 Focusable for ModuleList { + fn focus_handle(&self, _: &gpui::App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ModuleList { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.session.update(cx, |state, cx| { + state.modules(cx); + }); + + div() + .track_focus(&self.focus_handle) + .size_full() + .p_1() + .child(list(self.list.clone()).size_full()) + } +} + +#[cfg(any(test, feature = "test-support"))] +use dap::Module; +use util::maybe; + +#[cfg(any(test, feature = "test-support"))] +impl ModuleList { + pub fn modules(&self, cx: &mut Context) -> Vec { + self.session.update(cx, |session, cx| { + session.modules(cx).iter().cloned().collect() + }) + } +} diff --git a/crates/debugger_ui/src/session/running/stack_frame_list.rs b/crates/debugger_ui/src/session/running/stack_frame_list.rs new file mode 100644 index 00000000000000..0b775ea740b710 --- /dev/null +++ b/crates/debugger_ui/src/session/running/stack_frame_list.rs @@ -0,0 +1,478 @@ +use std::path::Path; + +use anyhow::{anyhow, Result}; +use gpui::{ + list, AnyElement, Entity, EventEmitter, FocusHandle, Focusable, ListState, Subscription, Task, + WeakEntity, +}; + +use project::debugger::session::{Session, StackFrame, ThreadId}; +use project::ProjectPath; +use ui::{prelude::*, Tooltip}; +use workspace::Workspace; + +pub type StackFrameId = u64; + +#[derive(Debug)] +pub enum StackFrameListEvent { + SelectedStackFrameChanged(StackFrameId), + StackFramesUpdated, +} + +pub struct StackFrameList { + list: ListState, + thread_id: Option, + focus_handle: FocusHandle, + _subscription: Subscription, + session: Entity, + entries: Vec, + workspace: WeakEntity, + current_stack_frame_id: StackFrameId, + _fetch_stack_frames_task: Option>>, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum StackFrameEntry { + Normal(dap::StackFrame), + Collapsed(Vec), +} + +impl StackFrameList { + pub fn new( + workspace: WeakEntity, + session: Entity, + cx: &mut Context, + ) -> Self { + let weak_entity = cx.weak_entity(); + let focus_handle = cx.focus_handle(); + + let list = ListState::new( + 0, + gpui::ListAlignment::Top, + px(1000.), + move |ix, _window, cx| { + weak_entity + .upgrade() + .map(|stack_frame_list| { + stack_frame_list.update(cx, |this, cx| this.render_entry(ix, cx)) + }) + .unwrap_or(div().into_any()) + }, + ); + + let _subscription = cx.observe(&session, |stack_frame_list, state, cx| { + stack_frame_list.build_entries(cx); + }); + + Self { + list, + session, + workspace, + focus_handle, + _subscription, + thread_id: None, + entries: Default::default(), + _fetch_stack_frames_task: None, + current_stack_frame_id: Default::default(), + } + } + + pub(crate) fn set_thread_id(&mut self, thread_id: Option, cx: &mut Context) { + self.thread_id = thread_id; + self.build_entries(cx); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn entries(&self) -> &Vec { + &self.entries + } + + pub fn stack_frames(&self, cx: &mut App) -> Vec { + self.thread_id + .map(|thread_id| { + self.session + .update(cx, |this, cx| this.stack_frames(thread_id, cx)) + }) + .unwrap_or_default() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn dap_stack_frames(&self, cx: &mut App) -> Vec { + self.stack_frames(cx) + .into_iter() + .map(|stack_frame| stack_frame.dap.clone()) + .collect() + } + + pub fn get_main_stack_frame_id(&self, cx: &mut Context) -> u64 { + self.stack_frames(cx) + .first() + .map(|stack_frame| stack_frame.dap.id) + .unwrap_or(0) + } + + pub fn current_stack_frame_id(&self) -> u64 { + self.current_stack_frame_id + } + + pub fn current_thread_id(&self) -> Option { + self.thread_id + } + + fn build_entries(&mut self, cx: &mut Context) { + let mut entries = Vec::new(); + let mut collapsed_entries = Vec::new(); + + for stack_frame in &self.stack_frames(cx) { + match stack_frame.dap.presentation_hint { + Some(dap::StackFramePresentationHint::Deemphasize) => { + collapsed_entries.push(stack_frame.dap.clone()); + } + _ => { + let collapsed_entries = std::mem::take(&mut collapsed_entries); + if !collapsed_entries.is_empty() { + entries.push(StackFrameEntry::Collapsed(collapsed_entries.clone())); + } + + entries.push(StackFrameEntry::Normal(stack_frame.dap.clone())); + } + } + } + + let collapsed_entries = std::mem::take(&mut collapsed_entries); + if !collapsed_entries.is_empty() { + entries.push(StackFrameEntry::Collapsed(collapsed_entries.clone())); + } + + std::mem::swap(&mut self.entries, &mut entries); + self.list.reset(self.entries.len()); + cx.notify(); + } + + // fn fetch_stack_frames( + // &mut self, + // go_to_stack_frame: bool, + // window: &Window, + // cx: &mut Context, + // ) { + // // If this is a remote debug session we never need to fetch stack frames ourselves + // // because the host will fetch and send us stack frames whenever there's a stop event + // if self.dap_store.read(cx).as_remote().is_some() { + // return; + // } + + // let task = self.dap_store.update(cx, |store, cx| { + // store.stack_frames(&self.client_id, self.thread_id, cx) + // }); + + // self.fetch_stack_frames_task = Some(cx.spawn_in(window, |this, mut cx| async move { + // let mut stack_frames = task.await?; + + // let task = this.update_in(&mut cx, |this, window, cx| { + // std::mem::swap(&mut this.stack_frames, &mut stack_frames); + + // this.build_entries(); + + // cx.emit(StackFrameListEvent::StackFramesUpdated); + + // let stack_frame = this + // .stack_frames + // .first() + // .cloned() + // .ok_or_else(|| anyhow!("No stack frame found to select"))?; + + // anyhow::Ok(this.select_stack_frame(&stack_frame, go_to_stack_frame, window, cx)) + // })?; + + // task?.await?; + + // this.update(&mut cx, |this, _| { + // this.fetch_stack_frames_task.take(); + // }) + // })); + // } + + pub fn select_stack_frame( + &mut self, + stack_frame: &dap::StackFrame, + go_to_stack_frame: bool, + window: &Window, + cx: &mut Context, + ) -> Task> { + self.current_stack_frame_id = stack_frame.id; + + cx.emit(StackFrameListEvent::SelectedStackFrameChanged( + stack_frame.id, + )); + cx.notify(); + + if !go_to_stack_frame { + return Task::ready(Ok(())); + }; + + let row = (stack_frame.line.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"))); + }; + + cx.spawn_in(window, { + // let client_id = self.client_id; + move |this, mut cx| async move { + this.update_in(&mut cx, |this, window, cx| { + this.workspace.update(cx, |workspace, cx| { + workspace.open_path_preview( + project_path.clone(), + None, + false, + true, + window, + cx, + ) + }) + })?? + .await?; + + // TODO(debugger): make this work again + this.update(&mut cx, |this, cx| { + this.workspace.update(cx, |workspace, cx| { + workspace + .project() + .read(cx) + .dap_store() + .update(cx, |store, cx| { + store.set_active_debug_line( + this.session.read(cx).session_id(), + &project_path, + row, + cx, + ); + }) + }) + })? + } + }) + } + + fn project_path_from_stack_frame( + &self, + stack_frame: &dap::StackFrame, + cx: &mut Context, + ) -> 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()? + } + + pub fn restart_stack_frame(&mut self, stack_frame_id: u64, cx: &mut Context) { + self.session.update(cx, |state, cx| { + state.restart_stack_frame(stack_frame_id, cx) + }); + } + + fn render_normal_entry( + &self, + stack_frame: &dap::StackFrame, + cx: &mut Context, + ) -> AnyElement { + 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, + ); + + let supports_frame_restart = self + .session + .read(cx) + .capabilities() + .supports_restart_frame + .unwrap_or_default(); + + let origin = stack_frame + .source + .to_owned() + .and_then(|source| source.origin); + + h_flex() + .rounded_md() + .justify_between() + .w_full() + .group("") + .id(("stack-frame", stack_frame.id)) + .tooltip({ + let formatted_path = formatted_path.clone(); + move |_window, app| { + app.new(|_| { + let mut tooltip = Tooltip::new(formatted_path.clone()); + + if let Some(origin) = &origin { + tooltip = tooltip.meta(origin); + } + + tooltip + }) + .into() + } + }) + .p_1() + .when(is_selected_frame, |this| { + this.bg(cx.theme().colors().element_hover) + }) + .on_click(cx.listener({ + let stack_frame = stack_frame.clone(); + move |this, _, window, cx| { + this.select_stack_frame(&stack_frame, true, window, cx) + .detach_and_log_err(cx); + } + })) + .hover(|style| style.bg(cx.theme().colors().element_hover).cursor_pointer()) + .child( + v_flex() + .child( + h_flex() + .gap_0p5() + .text_ui_sm(cx) + .truncate() + .child(stack_frame.name.clone()) + .child(formatted_path), + ) + .child( + h_flex() + .text_ui_xs(cx) + .truncate() + .text_color(cx.theme().colors().text_muted) + .when_some(source.and_then(|s| s.path), |this, path| this.child(path)), + ), + ) + .when( + supports_frame_restart && stack_frame.can_restart.unwrap_or(true), + |this| { + this.child( + h_flex() + .id(("restart-stack-frame", stack_frame.id)) + .visible_on_hover("") + .absolute() + .right_2() + .overflow_hidden() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().element_selected) + .bg(cx.theme().colors().element_background) + .hover(|style| { + style + .bg(cx.theme().colors().ghost_element_hover) + .cursor_pointer() + }) + .child( + IconButton::new( + ("restart-stack-frame", stack_frame.id), + IconName::DebugRestart, + ) + .icon_size(IconSize::Small) + .on_click(cx.listener({ + let stack_frame_id = stack_frame.id; + move |this, _, _window, cx| { + this.restart_stack_frame(stack_frame_id, cx); + } + })) + .tooltip(move |window, cx| { + Tooltip::text("Restart Stack Frame")(window, cx) + }), + ), + ) + }, + ) + .into_any() + } + + pub fn expand_collapsed_entry( + &mut self, + ix: usize, + stack_frames: &Vec, + cx: &mut Context, + ) { + self.entries.splice( + ix..ix + 1, + stack_frames + .iter() + .map(|frame| StackFrameEntry::Normal(frame.clone())), + ); + self.list.reset(self.entries.len()); + cx.notify(); + } + + fn render_collapsed_entry( + &self, + ix: usize, + stack_frames: &Vec, + cx: &mut Context, + ) -> AnyElement { + let first_stack_frame = &stack_frames[0]; + + h_flex() + .rounded_md() + .justify_between() + .w_full() + .group("") + .id(("stack-frame", first_stack_frame.id)) + .p_1() + .on_click(cx.listener({ + let stack_frames = stack_frames.clone(); + move |this, _, _window, cx| { + this.expand_collapsed_entry(ix, &stack_frames, cx); + } + })) + .hover(|style| style.bg(cx.theme().colors().element_hover).cursor_pointer()) + .child( + v_flex() + .text_ui_sm(cx) + .truncate() + .text_color(cx.theme().colors().text_muted) + .child(format!( + "Show {} more{}", + stack_frames.len(), + first_stack_frame + .source + .as_ref() + .and_then(|source| source.origin.as_ref()) + .map_or(String::new(), |origin| format!(": {}", origin)) + )), + ) + .into_any() + } + + fn render_entry(&self, ix: usize, cx: &mut Context) -> AnyElement { + match &self.entries[ix] { + StackFrameEntry::Normal(stack_frame) => self.render_normal_entry(stack_frame, cx), + StackFrameEntry::Collapsed(stack_frames) => { + self.render_collapsed_entry(ix, stack_frames, cx) + } + } + } +} + +impl Render for StackFrameList { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .p_1() + .child(list(self.list.clone()).size_full()) + } +} + +impl Focusable for StackFrameList { + fn focus_handle(&self, _: &gpui::App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for StackFrameList {} diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs new file mode 100644 index 00000000000000..a6751443f69ea8 --- /dev/null +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -0,0 +1,1598 @@ +use super::stack_frame_list::{StackFrameId, StackFrameList, StackFrameListEvent}; +use anyhow::{anyhow, Result}; +use dap::{proto_conversions::ProtoConversion, Scope, ScopePresentationHint, Variable}; +use editor::{actions::SelectAll, Editor, EditorEvent}; +use gpui::{ + actions, anchored, deferred, list, AnyElement, ClipboardItem, Context, DismissEvent, Entity, + FocusHandle, Focusable, Hsla, ListOffset, ListState, MouseDownEvent, Point, Subscription, Task, +}; +use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev}; +use project::debugger::session::{self, Session}; +use rpc::proto::{ + self, DebuggerScopeVariableIndex, DebuggerVariableContainer, VariableListScopes, + VariableListVariables, +}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + sync::Arc, +}; +use sum_tree::{Dimension, Item, SumTree, Summary}; +use ui::{prelude::*, ContextMenu, ListItem}; +use util::{debug_panic, ResultExt}; + +actions!(variable_list, [ExpandSelectedEntry, CollapseSelectedEntry]); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VariableContainer { + pub container_reference: u64, + pub variable: Variable, + pub depth: usize, +} + +impl ProtoConversion for VariableContainer { + type ProtoType = DebuggerVariableContainer; + type Output = Result; + + fn to_proto(&self) -> Self::ProtoType { + DebuggerVariableContainer { + container_reference: self.container_reference, + depth: self.depth as u64, + variable: Some(self.variable.to_proto()), + } + } + + fn from_proto(payload: Self::ProtoType) -> Self::Output { + Ok(Self { + container_reference: payload.container_reference, + variable: payload.variable.map(Variable::from_proto).ok_or(anyhow!( + "DebuggerVariableContainer proto message didn't contain DapVariable variable field" + ))?, + depth: payload.depth as usize, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SetVariableState { + name: String, + scope: Scope, + value: String, + stack_frame_id: u64, + evaluate_name: Option, + parent_variables_reference: u64, +} + +impl SetVariableState { + fn _from_proto(payload: proto::DebuggerSetVariableState) -> Option { + let scope = payload.scope.map(|scope| { + let proto_hint = scope + .presentation_hint + .unwrap_or(proto::DapScopePresentationHint::ScopeUnknown.into()); + + let presentation_hint = match proto::DapScopePresentationHint::from_i32(proto_hint) { + Some(proto::DapScopePresentationHint::Arguments) => { + Some(ScopePresentationHint::Arguments) + } + Some(proto::DapScopePresentationHint::Locals) => { + Some(ScopePresentationHint::Locals) + } + Some(proto::DapScopePresentationHint::Registers) => { + Some(ScopePresentationHint::Registers) + } + Some(proto::DapScopePresentationHint::ReturnValue) => { + Some(ScopePresentationHint::ReturnValue) + } + _ => Some(ScopePresentationHint::Unknown), + }; + + Scope { + name: scope.name, + presentation_hint, + variables_reference: scope.variables_reference, + named_variables: scope.named_variables, + indexed_variables: scope.indexed_variables, + expensive: scope.expensive, + source: None, + line: scope.line, + column: scope.column, + end_line: scope.end_line, + end_column: scope.end_column, + } + })?; + + Some(SetVariableState { + name: payload.name, + scope, + value: payload.value, + stack_frame_id: payload.stack_frame_id, + evaluate_name: payload.evaluate_name.clone(), + parent_variables_reference: payload.parent_variables_reference, + }) + } + + fn _to_proto(&self) -> proto::DebuggerSetVariableState { + proto::DebuggerSetVariableState { + name: self.name.clone(), + scope: Some(self.scope.to_proto()), + value: self.value.clone(), + stack_frame_id: self.stack_frame_id, + evaluate_name: self.evaluate_name.clone(), + parent_variables_reference: self.parent_variables_reference, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum OpenEntry { + Scope { + name: String, + }, + Variable { + scope_name: String, + name: String, + depth: usize, + }, +} + +impl OpenEntry { + pub(crate) fn _from_proto(open_entry: &proto::VariableListOpenEntry) -> Option { + match open_entry.entry.as_ref()? { + proto::variable_list_open_entry::Entry::Scope(state) => Some(Self::Scope { + name: state.name.clone(), + }), + proto::variable_list_open_entry::Entry::Variable(state) => Some(Self::Variable { + name: state.name.clone(), + depth: state.depth as usize, + scope_name: state.scope_name.clone(), + }), + } + } + + pub(crate) fn _to_proto(&self) -> proto::VariableListOpenEntry { + let entry = match self { + OpenEntry::Scope { name } => { + proto::variable_list_open_entry::Entry::Scope(proto::DebuggerOpenEntryScope { + name: name.clone(), + }) + } + OpenEntry::Variable { + name, + depth, + scope_name, + } => { + proto::variable_list_open_entry::Entry::Variable(proto::DebuggerOpenEntryVariable { + name: name.clone(), + depth: *depth as u64, + scope_name: scope_name.clone(), + }) + } + }; + + proto::VariableListOpenEntry { entry: Some(entry) } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VariableListEntry { + Scope(Scope), + SetVariableEditor { + depth: usize, + state: SetVariableState, + }, + Variable { + depth: usize, + scope: Arc, + variable: Arc, + has_children: bool, + container_reference: u64, + }, +} + +#[derive(Debug)] +pub struct ScopeVariableIndex { + fetched_ids: HashSet, + variables: SumTree, +} + +#[derive(Clone, Debug, Default)] +pub struct ScopeVariableSummary { + count: usize, + max_depth: usize, + container_reference: u64, +} + +impl Item for VariableContainer { + type Summary = ScopeVariableSummary; + + fn summary(&self, _cx: &()) -> Self::Summary { + ScopeVariableSummary { + count: 1, + max_depth: self.depth, + container_reference: self.container_reference, + } + } +} + +impl<'a> Dimension<'a, ScopeVariableSummary> for usize { + fn zero(_cx: &()) -> Self { + 0 + } + + fn add_summary(&mut self, summary: &'a ScopeVariableSummary, _cx: &()) { + *self += summary.count; + } +} + +impl Summary for ScopeVariableSummary { + type Context = (); + + fn zero(_: &Self::Context) -> Self { + Self::default() + } + + fn add_summary(&mut self, other: &Self, _: &Self::Context) { + self.count += other.count; + self.max_depth = self.max_depth.max(other.max_depth); + self.container_reference = self.container_reference.max(other.container_reference); + } +} + +impl ScopeVariableIndex { + pub fn new() -> Self { + Self { + variables: SumTree::default(), + fetched_ids: HashSet::default(), + } + } + + pub fn fetched(&self, container_reference: &u64) -> bool { + self.fetched_ids.contains(container_reference) + } + + /// All the variables should have the same depth and the same container reference + pub fn add_variables(&mut self, container_reference: u64, variables: Vec) { + // We want to avoid adding the same variables dued to collab clients sending add variables updates + if !self.fetched_ids.insert(container_reference) { + return; + } + + let mut new_variables = SumTree::new(&()); + let mut cursor = self.variables.cursor::(&()); + let mut found_insertion_point = false; + + cursor.seek(&0, editor::Bias::Left, &()); + while let Some(variable) = cursor.item() { + if variable.variable.variables_reference == container_reference { + found_insertion_point = true; + + let start = *cursor.start(); + new_variables.push(variable.clone(), &()); + new_variables.append(cursor.slice(&start, editor::Bias::Left, &()), &()); + new_variables.extend(variables.iter().cloned(), &()); + + cursor.next(&()); + new_variables.append(cursor.suffix(&()), &()); + + break; + } + new_variables.push(variable.clone(), &()); + cursor.next(&()); + } + drop(cursor); + + if !found_insertion_point { + new_variables.extend(variables.iter().cloned(), &()); + } + + self.variables = new_variables; + } + + pub fn is_empty(&self) -> bool { + self.variables.is_empty() + } + + pub fn variables(&self) -> Vec { + self.variables.iter().cloned().collect() + } +} + +type ScopeId = u64; + +pub struct VariableList { + list: ListState, + focus_handle: FocusHandle, + open_entries: Vec, + session: Entity, + _subscriptions: Vec, + set_variable_editor: Entity, + selection: Option, + stack_frame_list: Entity, + scopes: HashMap>, + set_variable_state: Option, + fetch_variables_task: Option>>, + entries: HashMap>, + variables: BTreeMap<(StackFrameId, ScopeId), ScopeVariableIndex>, + open_context_menu: Option<(Entity, Point, Subscription)>, +} + +impl VariableList { + pub fn new( + session: Entity, + stack_frame_list: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let weak_variable_list = cx.weak_entity(); + let focus_handle = cx.focus_handle(); + + let list = ListState::new( + 0, + gpui::ListAlignment::Top, + px(1000.), + move |ix, _window, cx| { + weak_variable_list + .upgrade() + .map(|var_list| var_list.update(cx, |this, cx| this.render_entry(ix, cx))) + .unwrap_or(div().into_any()) + }, + ); + + let set_variable_editor = cx.new(|cx| Editor::single_line(window, cx)); + + 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, + session, + focus_handle, + _subscriptions, + selection: None, + stack_frame_list, + set_variable_editor, + 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(), + } + } + + fn handle_stack_frame_list_events( + &mut self, + _: Entity, + event: &StackFrameListEvent, + cx: &mut Context, + ) { + match event { + StackFrameListEvent::SelectedStackFrameChanged(stack_frame_id) => { + self.handle_selected_stack_frame_changed(*stack_frame_id, cx); + } + StackFrameListEvent::StackFramesUpdated => { + self.entries.clear(); + self.variables.clear(); + self.scopes.clear(); + } + } + } + + fn handle_selected_stack_frame_changed( + &mut self, + stack_frame_id: StackFrameId, + cx: &mut Context, + ) { + // if self.scopes.contains_key(&stack_frame_id) { + // return self.build_entries(true, cx); + // } + + // self.fetch_variables_task = Some(cx.spawn(|this, mut cx| async move { + // let task = this.update(&mut cx, |variable_list, cx| { + // variable_list.fetch_variables_for_stack_frame(stack_frame_id, cx) + // })?; + + // let (scopes, variables) = task.await?; + + // this.update(&mut cx, |variable_list, cx| { + // variable_list.scopes.insert(stack_frame_id, scopes); + + // for (scope_id, variables) in variables.into_iter() { + // let mut variable_index = ScopeVariableIndex::new(); + // variable_index.add_variables(scope_id, variables); + + // variable_list + // .variables + // .insert((stack_frame_id, scope_id), variable_index); + // } + + // variable_list.build_entries(true, cx); + + // variable_list.fetch_variables_task.take(); + // }) + // })); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn scopes(&self) -> &HashMap> { + &self.scopes + } + + #[cfg(any(test, feature = "test-support"))] + pub fn variables(&self) -> Vec { + self.variables + .iter() + .flat_map(|((_, _), scope_index)| scope_index.variables()) + .collect() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn entries(&self) -> &HashMap> { + &self.entries + } + + pub fn variables_by_scope( + &self, + stack_frame_id: StackFrameId, + scope_id: ScopeId, + ) -> Option<&ScopeVariableIndex> { + self.variables.get(&(stack_frame_id, scope_id)) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn variables_by_stack_frame_id( + &self, + stack_frame_id: StackFrameId, + ) -> Vec { + self.variables + .range((stack_frame_id, u64::MIN)..(stack_frame_id, u64::MAX)) + .flat_map(|(_, containers)| containers.variables.iter().cloned()) + .collect() + } + + pub fn completion_variables(&self, cx: &mut Context) -> Vec { + let stack_frame_id = self + .stack_frame_list + .update(cx, |this, cx| this.get_main_stack_frame_id(cx)); + + self.variables + .range((stack_frame_id, u64::MIN)..(stack_frame_id, u64::MAX)) + .flat_map(|(_, containers)| containers.variables.iter().cloned()) + .collect() + } + + fn render_entry(&mut self, ix: usize, cx: &mut Context) -> AnyElement { + let stack_frame_id = self.stack_frame_list.read(cx).current_stack_frame_id(); + let Some(thread_id) = self.stack_frame_list.read(cx).current_thread_id() else { + return div().into_any_element(); + }; + + let entries = self.session.update(cx, |session, cx| { + session.variable_list(thread_id, stack_frame_id, cx) + }); + + let Some(entry) = entries.get(ix) else { + debug_panic!("Trying to render entry in variable list that has an out of bounds index"); + return div().into_any_element(); + }; + + let entry = &entries[ix]; + match entry { + session::VariableListContainer::Scope(scope) => self.render_scope(scope, false, cx), // todo(debugger) pass a valid value for is selected + _ => div().into_any_element(), + } + } + + pub fn toggle_variable( + &mut self, + scope: &Scope, + variable: &Variable, + depth: usize, + cx: &mut Context, + ) { + let stack_frame_id = self.stack_frame_list.read(cx).current_stack_frame_id(); + let scope_id = scope.variables_reference; + + let Some(variable_index) = self.variables_by_scope(stack_frame_id, scope_id) else { + return; + }; + + let entry_id = OpenEntry::Variable { + depth, + name: variable.name.clone(), + scope_name: scope.name.clone(), + }; + + let has_children = variable.variables_reference > 0; + let disclosed = has_children.then(|| self.open_entries.binary_search(&entry_id).is_ok()); + + // 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) || variable_index.fetched(&variable.variables_reference) { + return self.toggle_entry(&entry_id, cx); + } + + // let fetch_variables_task = self.dap_store.update(cx, |store, cx| { + // let thread_id = self.stack_frame_list.read(cx).thread_id(); + // store.variables( + // &self.client_id, + // thread_id, + // stack_frame_id, + // scope_id, + // self.session.read(cx).id(), + // variable.variables_reference, + // cx, + // ) + // }); + let fetch_variables_task = Task::ready(anyhow::Result::Err(anyhow!( + "Toggling variables isn't supported yet (dued to refactor)" + ))); + + let container_reference = variable.variables_reference; + let entry_id = entry_id.clone(); + + self.fetch_variables_task = Some(cx.spawn(|this, mut cx| async move { + let new_variables: Vec = fetch_variables_task.await?; + + this.update(&mut cx, |this, cx| { + let Some(index) = this.variables.get_mut(&(stack_frame_id, scope_id)) else { + return; + }; + + index.add_variables( + container_reference, + new_variables + .into_iter() + .map(|variable| VariableContainer { + variable, + depth: depth + 1, + container_reference, + }) + .collect::>(), + ); + + this.toggle_entry(&entry_id, cx); + }) + })) + } + + pub fn toggle_entry(&mut self, entry_id: &OpenEntry, cx: &mut Context) { + 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, cx); + } + + pub fn build_entries(&mut self, open_first_scope: bool, cx: &mut Context) { + 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; + }; + + let mut entries: Vec = Vec::default(); + for scope in scopes { + let Some(index) = self + .variables + .get(&(stack_frame_id, scope.variables_reference)) + else { + continue; + }; + + if index.is_empty() { + continue; + } + + let scope_open_entry_id = OpenEntry::Scope { + name: scope.name.clone(), + }; + + if open_first_scope + && entries.is_empty() + && self + .open_entries + .binary_search(&scope_open_entry_id) + .is_err() + { + self.open_entries.push(scope_open_entry_id.clone()); + } + entries.push(VariableListEntry::Scope(scope.clone())); + + if self + .open_entries + .binary_search(&scope_open_entry_id) + .is_err() + { + continue; + } + + let mut depth_check: Option = None; + + for variable_container in index.variables().iter() { + 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(&OpenEntry::Variable { + depth, + name: variable.name.clone(), + scope_name: scope.name.clone(), + }) + .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 old_entries = self.entries.get(&stack_frame_id).cloned(); + let old_scroll_top = self.list.logical_scroll_top(); + + let len = entries.len(); + self.entries.insert(stack_frame_id, entries.clone()); + self.list.reset(len); + + if let Some(old_entries) = old_entries.as_ref() { + if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) { + let new_scroll_top = old_entries + .iter() + .position(|entry| entry == old_top_entry) + .map(|item_ix| ListOffset { + item_ix, + offset_in_item: old_scroll_top.offset_in_item, + }) + .or_else(|| { + let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?; + let item_ix = entries + .iter() + .position(|entry| entry == entry_after_old_top)?; + Some(ListOffset { + item_ix, + offset_in_item: Pixels::ZERO, + }) + }) + .or_else(|| { + let entry_before_old_top = + old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?; + let item_ix = entries + .iter() + .position(|entry| entry == entry_before_old_top)?; + Some(ListOffset { + item_ix, + offset_in_item: Pixels::ZERO, + }) + }); + + self.list + .scroll_to(new_scroll_top.unwrap_or(old_scroll_top)); + } + } + + cx.notify(); + } + + fn fetch_nested_variables( + &self, + scope: &Scope, + container_reference: u64, + depth: usize, + open_entries: &Vec, + cx: &mut Context, + ) -> Task>> { + Task::ready(Ok(vec![])) + } + + fn deploy_variable_context_menu( + &mut self, + parent_variables_reference: u64, + scope: &Scope, + variable: &Variable, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + let caps = self.session.read(cx).capabilities(); + let (support_set_variable, support_clipboard_context) = ( + caps.supports_set_variable.unwrap_or_default(), + caps.supports_clipboard_context.unwrap_or_default(), + ); + + let this = cx.entity(); + + let context_menu = ContextMenu::build(window, cx, |menu, window, _cx| { + menu.entry("Copy name", None, { + let variable_name = variable.name.clone(); + move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(variable_name.clone())) + } + }) + .entry("Copy value", None, { + let source = scope.source.clone(); + let variable_value = variable.value.clone(); + let variable_name = variable.name.clone(); + let evaluate_name = variable.evaluate_name.clone(); + + window.handler_for(&this.clone(), move |this, _window, cx| { + if support_clipboard_context { + this.session.update(cx, |state, cx| { + state.evaluate( + evaluate_name.clone().unwrap_or(variable_name.clone()), + Some(dap::EvaluateArgumentsContext::Clipboard), + Some(this.stack_frame_list.read(cx).current_stack_frame_id()), + source.clone(), + cx, + ); + }); + // TODO(debugger): make this work again: + // cx.write_to_clipboard(ClipboardItem::new_string(response.result)); + } else { + 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, + window.handler_for(&this, move |_, _window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + memory_reference.clone(), + )) + }), + ) + }, + ) + .when(support_set_variable, |menu| { + let variable = variable.clone(); + let scope = scope.clone(); + + menu.entry( + "Set value", + None, + window.handler_for(&this, move |this, window, 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(), window, cx); + editor.select_all(&SelectAll, window, cx); + window.focus(&editor.focus_handle(cx)) + }); + + this.build_entries(false, cx); + }), + ) + }) + }); + + cx.focus_view(&context_menu, window); + let subscription = cx.subscribe_in( + &context_menu, + window, + |this, _entity, _event: &DismissEvent, window, cx| { + if this.open_context_menu.as_ref().is_some_and(|context_menu| { + context_menu.0.focus_handle(cx).contains_focused(window, cx) + }) { + cx.focus_self(window); + } + 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 Context) { + if self.set_variable_state.take().is_none() { + return; + }; + + self.build_entries(false, cx); + } + + fn set_variable_value(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let new_variable_value = self.set_variable_editor.update(cx, |editor, cx| { + let new_variable_value = editor.text(cx); + + editor.clear(window, cx); + + new_variable_value + }); + + let Some(set_variable_state) = self.set_variable_state.take() else { + return; + }; + + if new_variable_value == set_variable_state.value + || set_variable_state.stack_frame_id + != self.stack_frame_list.read(cx).current_stack_frame_id() + { + return cx.notify(); + } + + self.session.update(cx, |state, cx| { + state.set_variable_value( + set_variable_state.parent_variables_reference, + set_variable_state.name, + new_variable_value, + cx, + ); + }); + } + + fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { + let stack_frame_id = self.stack_frame_list.read(cx).current_stack_frame_id(); + if let Some(entries) = self.entries.get(&stack_frame_id) { + self.selection = entries.first().cloned(); + cx.notify(); + }; + } + + fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { + let stack_frame_id = self.stack_frame_list.read(cx).current_stack_frame_id(); + if let Some(entries) = self.entries.get(&stack_frame_id) { + self.selection = entries.last().cloned(); + cx.notify(); + }; + } + + fn select_prev(&mut self, _: &SelectPrev, window: &mut Window, cx: &mut Context) { + if let Some(selection) = &self.selection { + let stack_frame_id = self.stack_frame_list.read(cx).current_stack_frame_id(); + if let Some(entries) = self.entries.get(&stack_frame_id) { + if let Some(ix) = entries.iter().position(|entry| entry == selection) { + self.selection = entries.get(ix.saturating_sub(1)).cloned(); + cx.notify(); + } + } + } else { + self.select_first(&SelectFirst, window, cx); + } + } + + fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { + if let Some(selection) = &self.selection { + let stack_frame_id = self.stack_frame_list.read(cx).current_stack_frame_id(); + if let Some(entries) = self.entries.get(&stack_frame_id) { + if let Some(ix) = entries.iter().position(|entry| entry == selection) { + self.selection = entries.get(ix + 1).cloned(); + cx.notify(); + } + } + } else { + self.select_first(&SelectFirst, window, cx); + } + } + + fn collapse_selected_entry( + &mut self, + _: &CollapseSelectedEntry, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(selection) = &self.selection { + match selection { + VariableListEntry::Scope(scope) => { + let entry_id = &OpenEntry::Scope { + name: scope.name.clone(), + }; + + if self.open_entries.binary_search(entry_id).is_err() { + self.select_prev(&SelectPrev, window, cx); + } else { + self.toggle_entry(entry_id, cx); + } + } + VariableListEntry::Variable { + depth, + variable, + scope, + .. + } => { + let entry_id = &OpenEntry::Variable { + depth: *depth, + name: variable.name.clone(), + scope_name: scope.name.clone(), + }; + + if self.open_entries.binary_search(entry_id).is_err() { + self.select_prev(&SelectPrev, window, cx); + } else { + self.toggle_variable(&scope.clone(), &variable.clone(), *depth, cx); + } + } + VariableListEntry::SetVariableEditor { .. } => {} + } + } + } + + fn expand_selected_entry( + &mut self, + _: &ExpandSelectedEntry, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(selection) = &self.selection { + match selection { + VariableListEntry::Scope(scope) => { + let entry_id = &OpenEntry::Scope { + name: scope.name.clone(), + }; + + if self.open_entries.binary_search(entry_id).is_ok() { + self.select_next(&SelectNext, window, cx); + } else { + self.toggle_entry(entry_id, cx); + } + } + VariableListEntry::Variable { + depth, + variable, + scope, + .. + } => { + let entry_id = &OpenEntry::Variable { + depth: *depth, + name: variable.name.clone(), + scope_name: scope.name.clone(), + }; + + if self.open_entries.binary_search(entry_id).is_ok() { + self.select_next(&SelectNext, window, cx); + } else { + self.toggle_variable(&scope.clone(), &variable.clone(), *depth, cx); + } + } + VariableListEntry::SetVariableEditor { .. } => {} + } + } + } + + fn render_set_variable_editor( + &self, + depth: usize, + state: &SetVariableState, + cx: &mut Context, + ) -> 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() + } + + #[track_caller] + #[cfg(any(test, feature = "test-support"))] + pub fn assert_visual_entries(&self, expected: Vec<&str>, cx: &Context) { + const INDENT: &'static str = " "; + + let stack_frame_id = self.stack_frame_list.read(cx).current_stack_frame_id(); + let entries = self.entries.get(&stack_frame_id).unwrap(); + + let mut visual_entries = Vec::with_capacity(entries.len()); + for entry in entries { + let is_selected = Some(entry) == self.selection.as_ref(); + + match entry { + VariableListEntry::Scope(scope) => { + let is_expanded = self + .open_entries + .binary_search(&OpenEntry::Scope { + name: scope.name.clone(), + }) + .is_ok(); + + visual_entries.push(format!( + "{} {}{}", + if is_expanded { "v" } else { ">" }, + scope.name, + if is_selected { " <=== selected" } else { "" } + )); + } + VariableListEntry::SetVariableEditor { depth, state } => { + visual_entries.push(format!( + "{} [EDITOR: {}]{}", + INDENT.repeat(*depth), + state.name, + if is_selected { " <=== selected" } else { "" } + )); + } + VariableListEntry::Variable { + depth, + variable, + scope, + .. + } => { + let is_expanded = self + .open_entries + .binary_search(&OpenEntry::Variable { + depth: *depth, + name: variable.name.clone(), + scope_name: scope.name.clone(), + }) + .is_ok(); + + visual_entries.push(format!( + "{}{} {}{}", + INDENT.repeat(*depth), + if is_expanded { "v" } else { ">" }, + variable.name, + if is_selected { " <=== selected" } else { "" } + )); + } + }; + } + + pretty_assertions::assert_eq!(expected, visual_entries); + } + + #[allow(clippy::too_many_arguments)] + fn render_variable( + &self, + container_reference: u64, + variable: &Arc, + scope: &Arc, + depth: usize, + has_children: bool, + is_selected: bool, + cx: &mut Context, + ) -> AnyElement { + let entry_id = OpenEntry::Variable { + depth, + name: variable.name.clone(), + scope_name: scope.name.clone(), + }; + let disclosed = has_children.then(|| self.open_entries.binary_search(&entry_id).is_ok()); + + let colors = get_entry_color(cx); + let bg_hover_color = if !is_selected { + colors.hover + } else { + colors.default + }; + let border_color = if is_selected { + colors.marked_active + } else { + colors.default + }; + + div() + .id(SharedString::from(format!( + "variable-{}-{}-{}", + scope.variables_reference, variable.name, depth + ))) + .group("variable_list_entry") + .border_1() + .border_r_2() + .border_color(border_color) + .h_4() + .size_full() + .hover(|style| style.bg(bg_hover_color)) + .on_click(cx.listener({ + let scope = scope.clone(); + let variable = variable.clone(); + move |this, _, _window, cx| { + this.selection = Some(VariableListEntry::Variable { + depth, + has_children, + container_reference, + scope: scope.clone(), + variable: variable.clone(), + }); + cx.notify(); + } + })) + .child( + ListItem::new(SharedString::from(format!( + "variable-item-{}-{}-{}", + scope.variables_reference, variable.name, depth + ))) + .selectable(false) + .indent_level(depth + 1) + .indent_step_size(px(20.)) + .always_show_disclosure_icon(true) + .toggle(disclosed) + .when(has_children, |list_item| { + list_item.on_toggle(cx.listener({ + let scope = scope.clone(); + let variable = variable.clone(); + move |this, _, _window, cx| { + this.toggle_variable(&scope, &variable, depth, cx) + } + })) + }) + .on_secondary_mouse_down(cx.listener({ + let scope = scope.clone(); + let variable = variable.clone(); + move |this, event: &MouseDownEvent, window, cx| { + this.deploy_variable_context_menu( + container_reference, + &scope, + &variable, + event.position, + window, + 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: &session::Scope, + is_selected: bool, + cx: &mut Context, + ) -> AnyElement { + let element_id = scope.dap.variables_reference; + + let entry_id = OpenEntry::Scope { + name: scope.dap.name.clone(), + }; + + let disclosed = scope.is_toggled; + + let colors = get_entry_color(cx); + let bg_hover_color = if !is_selected { + colors.hover + } else { + colors.default + }; + let border_color = if is_selected { + colors.marked_active + } else { + colors.default + }; + + div() + .id(element_id as usize) + .group("variable_list_entry") + .border_1() + .border_r_2() + .border_color(border_color) + .flex() + .w_full() + .h_full() + .hover(|style| style.bg(bg_hover_color)) + .on_click(cx.listener({ + move |this, _, _window, cx| { + cx.notify(); + } + })) + .child( + ListItem::new(SharedString::from(format!( + "scope-{}", + scope.dap.variables_reference + ))) + .selectable(false) + .indent_level(1) + .indent_step_size(px(20.)) + .always_show_disclosure_icon(true) + .toggle(disclosed) + .child(div().text_ui(cx).w_full().child(scope.dap.name.clone())), + ) + .into_any() + } +} + +impl Focusable for VariableList { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for VariableList { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + // todo(debugger): We are reconstructing the variable list list state every frame + // which is very bad!! We should only reconstruct the variable list state when necessary. + // Will fix soon + let (stack_frame_id, thread_id) = self.stack_frame_list.read_with(cx, |list, cx| { + (list.current_stack_frame_id(), list.current_thread_id()) + }); + let len = if let Some(thread_id) = thread_id { + self.session + .update(cx, |session, cx| { + session.variable_list(thread_id, stack_frame_id, cx) + }) + .len() + } else { + 0 + }; + self.list.reset(len); + + div() + .key_context("VariableList") + .id("variable-list") + .group("variable-list") + .size_full() + .track_focus(&self.focus_handle(cx)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::select_prev)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::expand_selected_entry)) + .on_action(cx.listener(Self::collapse_selected_entry)) + .on_action( + cx.listener(|this, _: &editor::actions::Cancel, _window, 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::Corner::TopLeft) + .child(menu.clone()), + ) + .with_priority(1) + })) + } +} + +struct EntryColors { + default: Hsla, + hover: Hsla, + marked_active: Hsla, +} + +fn get_entry_color(cx: &Context) -> EntryColors { + let colors = cx.theme().colors(); + + EntryColors { + default: colors.panel_background, + hover: colors.ghost_element_hover, + marked_active: colors.ghost_element_selected, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_initial_variables_to_index() { + let mut index = ScopeVariableIndex::new(); + + assert_eq!(index.variables(), vec![]); + assert_eq!(index.fetched_ids, HashSet::default()); + + let variable1 = VariableContainer { + variable: Variable { + name: "First variable".into(), + value: "First variable".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + depth: 1, + container_reference: 1, + }; + + let variable2 = VariableContainer { + variable: Variable { + name: "Second variable with child".into(), + value: "Second variable with child".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 2, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + depth: 1, + container_reference: 1, + }; + + let variable3 = VariableContainer { + variable: Variable { + name: "Third variable".into(), + value: "Third variable".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + depth: 1, + container_reference: 1, + }; + + index.add_variables( + 1, + vec![variable1.clone(), variable2.clone(), variable3.clone()], + ); + + assert_eq!( + vec![variable1.clone(), variable2.clone(), variable3.clone()], + index.variables(), + ); + assert_eq!(HashSet::from([1]), index.fetched_ids,); + } + + /// This covers when you click on a variable that has a nested variable + /// We correctly insert the variables right after the variable you clicked on + #[test] + fn test_add_sub_variables_to_index() { + let mut index = ScopeVariableIndex::new(); + + assert_eq!(index.variables(), vec![]); + + let variable1 = VariableContainer { + variable: Variable { + name: "First variable".into(), + value: "First variable".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + depth: 1, + container_reference: 1, + }; + + let variable2 = VariableContainer { + variable: Variable { + name: "Second variable with child".into(), + value: "Second variable with child".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 2, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + depth: 1, + container_reference: 1, + }; + + let variable3 = VariableContainer { + variable: Variable { + name: "Third variable".into(), + value: "Third variable".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + depth: 1, + container_reference: 1, + }; + + index.add_variables( + 1, + vec![variable1.clone(), variable2.clone(), variable3.clone()], + ); + + assert_eq!( + vec![variable1.clone(), variable2.clone(), variable3.clone()], + index.variables(), + ); + assert_eq!(HashSet::from([1]), index.fetched_ids); + + let variable4 = VariableContainer { + variable: Variable { + name: "Fourth variable".into(), + value: "Fourth variable".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + depth: 1, + container_reference: 1, + }; + + let variable5 = VariableContainer { + variable: Variable { + name: "Five variable".into(), + value: "Five variable".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + depth: 1, + container_reference: 1, + }; + + index.add_variables(2, vec![variable4.clone(), variable5.clone()]); + + assert_eq!( + vec![ + variable1.clone(), + variable2.clone(), + variable4.clone(), + variable5.clone(), + variable3.clone(), + ], + index.variables(), + ); + assert_eq!(index.fetched_ids, HashSet::from([1, 2])); + } + + #[test] + fn test_can_serialize_to_and_from_proto() { + let mut index = ScopeVariableIndex::new(); + + let variable1 = VariableContainer { + variable: Variable { + name: "First variable".into(), + value: "First variable".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 2, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + depth: 1, + container_reference: 1, + }; + + let variable2 = VariableContainer { + variable: Variable { + name: "Second variable with child".into(), + value: "Second variable with child".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + depth: 1, + container_reference: 1, + }; + + index.add_variables(1, vec![variable1.clone(), variable2.clone()]); + + let variable3 = VariableContainer { + variable: Variable { + name: "Third variable".into(), + value: "Third variable".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + depth: 1, + container_reference: 1, + }; + + let variable4 = VariableContainer { + variable: Variable { + name: "Four variable".into(), + value: "Four variable".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + depth: 1, + container_reference: 1, + }; + + index.add_variables(2, vec![variable3.clone(), variable4.clone()]); + + assert_eq!( + vec![ + variable1.clone(), + variable3.clone(), + variable4.clone(), + variable2.clone(), + ], + index.variables(), + ); + assert_eq!(HashSet::from([1, 2]), index.fetched_ids); + + let from_proto = ScopeVariableIndex::from_proto(index.to_proto()); + + assert_eq!(index.variables(), from_proto.variables()); + assert_eq!(index.fetched_ids, from_proto.fetched_ids); + } +} diff --git a/crates/debugger_ui/src/session/starting.rs b/crates/debugger_ui/src/session/starting.rs new file mode 100644 index 00000000000000..c00d920e030347 --- /dev/null +++ b/crates/debugger_ui/src/session/starting.rs @@ -0,0 +1,65 @@ +use std::time::Duration; + +use anyhow::Result; + +use gpui::{ + percentage, Animation, AnimationExt, Entity, EventEmitter, FocusHandle, Focusable, Task, + Transformation, +}; +use project::debugger::session::Session; +use ui::{v_flex, Color, Context, Icon, IconName, IntoElement, ParentElement, Render, Styled}; + +pub(super) struct StartingState { + focus_handle: FocusHandle, + _notify_parent: Task<()>, +} + +pub(crate) enum StartingEvent { + Finished(Result>), +} + +impl EventEmitter for StartingState {} + +impl StartingState { + pub(crate) fn new(task: Task>>, cx: &mut Context) -> Self { + let _notify_parent = cx.spawn(move |this, mut cx| async move { + let entity = task.await; + this.update(&mut cx, |_, cx| cx.emit(StartingEvent::Finished(entity))) + .ok(); + }); + Self { + focus_handle: cx.focus_handle(), + _notify_parent, + } + } +} + +impl Focusable for StartingState { + fn focus_handle(&self, _: &ui::App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for StartingState { + fn render( + &mut self, + window: &mut ui::Window, + cx: &mut ui::Context<'_, Self>, + ) -> impl ui::IntoElement { + v_flex() + .size_full() + .gap_1() + .items_center() + .child("Starting a debug adapter") + .child( + Icon::new(IconName::ArrowCircle) + .color(Color::Info) + .with_animation( + "arrow-circle", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), + ) + .into_any_element(), + ) + } +} diff --git a/crates/debugger_ui/src/tests.rs b/crates/debugger_ui/src/tests.rs new file mode 100644 index 00000000000000..4a0d158ea0af9f --- /dev/null +++ b/crates/debugger_ui/src/tests.rs @@ -0,0 +1,81 @@ +use gpui::{Entity, TestAppContext, WindowHandle}; +use project::{Project, Worktree}; +use settings::SettingsStore; +use terminal_view::terminal_panel::TerminalPanel; +use workspace::Workspace; + +use crate::{debugger_panel::DebugPanel, session::DebugSession}; + +mod attach_modal; +mod console; +mod debugger_panel; +mod module_list; +mod stack_frame_list; +mod variable_list; + +pub fn init_test(cx: &mut gpui::TestAppContext) { + if std::env::var("RUST_LOG").is_ok() { + env_logger::try_init().ok(); + } + + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + terminal_view::init(cx); + theme::init(theme::LoadThemes::JustBase, cx); + command_palette_hooks::init(cx); + language::init(cx); + workspace::init_settings(cx); + Project::init_settings(cx); + editor::init(cx); + }); +} + +pub async fn init_test_workspace( + project: &Entity, + cx: &mut TestAppContext, +) -> WindowHandle { + let workspace_handle = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let debugger_panel = workspace_handle + .update(cx, |_, window, cx| cx.spawn_in(window, DebugPanel::load)) + .unwrap() + .await + .expect("Failed to load debug panel"); + + let terminal_panel = workspace_handle + .update(cx, |_, window, cx| cx.spawn_in(window, TerminalPanel::load)) + .unwrap() + .await + .expect("Failed to load terminal panel"); + + workspace_handle + .update(cx, |workspace, window, cx| { + workspace.add_panel(debugger_panel, window, cx); + workspace.add_panel(terminal_panel, window, cx); + }) + .unwrap(); + workspace_handle +} + +pub fn active_debug_panel_item( + workspace: WindowHandle, + cx: &mut TestAppContext, +) -> Entity { + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap() + }) + .unwrap() +} + +pub fn worktree_from_project( + project: &Entity, + cx: &mut TestAppContext, +) -> Entity { + project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()) +} diff --git a/crates/debugger_ui/src/tests/attach_modal.rs b/crates/debugger_ui/src/tests/attach_modal.rs new file mode 100644 index 00000000000000..3ba3bbaad88f4f --- /dev/null +++ b/crates/debugger_ui/src/tests/attach_modal.rs @@ -0,0 +1,277 @@ +use crate::*; +use attach_modal::AttachModal; +use dap::requests::{Attach, Disconnect, Initialize}; +use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; +use menu::{Cancel, Confirm}; +use project::{FakeFs, Project}; +use serde_json::json; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use task::AttachConfig; +use tests::{init_test, init_test_workspace}; + +#[gpui::test] +async fn test_direct_attach_to_process(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let send_attach_request = Arc::new(AtomicBool::new(false)); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client + .on_request::({ + let send_attach_request = send_attach_request.clone(); + move |_, args| { + send_attach_request.store(true, Ordering::SeqCst); + + assert_eq!(json!({"request": "attach", "process_id": 10}), args.raw); + + Ok(()) + } + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + cx.run_until_parked(); + + assert!( + send_attach_request.load(std::sync::atomic::Ordering::SeqCst), + "Expected to send attach request, because we passed in the processId" + ); + + // assert we didn't show the attach modal + workspace + .update(cx, |workspace, _window, cx| { + assert!(workspace.active_modal::(cx).is_none()); + }) + .unwrap(); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + +#[gpui::test] +async fn test_show_attach_modal_and_select_process( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let send_attach_request = Arc::new(AtomicBool::new(false)); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client + .on_request::({ + let send_attach_request = send_attach_request.clone(); + move |_, args| { + send_attach_request.store(true, Ordering::SeqCst); + + assert_eq!( + json!({ + "request": "attach", + // note we filtered out all processes in FakeAdapter::attach_processes, + // that is not equal to the current process id + "process_id": std::process::id(), + }), + args.raw + ); + + Ok(()) + } + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + cx.run_until_parked(); + + // assert we show the attach modal + workspace + .update(cx, |workspace, _window, cx| { + let attach_modal = workspace.active_modal::(cx).unwrap(); + + let names = attach_modal.update(cx, |modal, cx| attach_modal::procss_names(&modal, cx)); + + // we filtered out all processes that are not the current process(zed itself) + assert_eq!(1, names.len()); + }) + .unwrap(); + + // select the only existing process + cx.dispatch_action(Confirm); + + cx.run_until_parked(); + + // assert attach modal was dismissed + workspace + .update(cx, |workspace, _window, cx| { + assert!(workspace.active_modal::(cx).is_none()); + }) + .unwrap(); + + assert!( + send_attach_request.load(std::sync::atomic::Ordering::SeqCst), + "Expected to send attach request, because we passed in the processId" + ); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + +#[gpui::test] +async fn test_shutdown_session_when_modal_is_dismissed( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let send_attach_request = Arc::new(AtomicBool::new(false)); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client + .on_request::({ + let send_attach_request = send_attach_request.clone(); + move |_, _| { + send_attach_request.store(true, Ordering::SeqCst); + + Ok(()) + } + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + cx.run_until_parked(); + + // assert we show the attach modal + workspace + .update(cx, |workspace, _window, cx| { + let attach_modal = workspace.active_modal::(cx).unwrap(); + + let names = attach_modal.update(cx, |modal, cx| attach_modal::procss_names(&modal, cx)); + + // we filtered out all processes that are not the current process(zed itself) + assert_eq!(1, names.len()); + }) + .unwrap(); + + // close the modal + cx.dispatch_action(Cancel); + + cx.run_until_parked(); + + // assert attach modal was dismissed + workspace + .update(cx, |workspace, _window, cx| { + assert!(workspace.active_modal::(cx).is_none()); + }) + .unwrap(); + + assert!( + !send_attach_request.load(std::sync::atomic::Ordering::SeqCst), + "Didn't expected to send attach request, because we closed the modal" + ); + + // assert debug session is shutdown + project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + assert!(dap_store.session_by_id(&session.read(cx).id()).is_none()) + }); + }); +} diff --git a/crates/debugger_ui/src/tests/console.rs b/crates/debugger_ui/src/tests/console.rs new file mode 100644 index 00000000000000..fa6aaf58742b92 --- /dev/null +++ b/crates/debugger_ui/src/tests/console.rs @@ -0,0 +1,853 @@ +use crate::*; +use dap::{ + requests::{Disconnect, Evaluate, Initialize, Launch, Scopes, StackTrace, Variables}, + Scope, StackFrame, Variable, +}; +use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; +use project::{FakeFs, Project}; +use serde_json::json; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; +use tests::{active_debug_panel_item, init_test, init_test_workspace}; +use unindent::Unindent as _; +use variable_list::{VariableContainer, VariableListEntry}; + +#[gpui::test] +async fn test_handle_output_event(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: None, + output: "First console output line before thread stopped!".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "First output line before thread stopped!".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + // assert we have output from before the thread stopped + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + assert_eq!(1, debug_panel.read(cx).message_queue().len()); + + assert_eq!( + "First console output line before thread stopped!\nFirst output line before thread stopped!\n", + active_debug_panel_item.read(cx).console().read(cx).editor().read(cx).text(cx).as_str() + ); + }) + .unwrap(); + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "Second output line after thread stopped!".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Console), + output: "Second console output line after thread stopped!".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + cx.run_until_parked(); + + // assert we have output from before and after the thread stopped + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + assert!(!debug_panel.read(cx).message_queue().is_empty()); + + assert_eq!( + "First console output line before thread stopped!\nFirst output line before thread stopped!\nSecond output line after thread stopped!\nSecond console output line after thread stopped!\n", + active_debug_panel_item.read(cx).console().read(cx).editor().read(cx).text(cx).as_str() + ); + }) + .unwrap(); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + +#[gpui::test] +async fn test_grouped_output(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: None, + output: "First line".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "First group".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: Some(dap::OutputEventGroup::Start), + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "First item in group 1".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "Second item in group 1".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "Second group".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: Some(dap::OutputEventGroup::Start), + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "First item in group 2".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "Second item in group 2".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "End group 2".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: Some(dap::OutputEventGroup::End), + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "Third group".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: Some(dap::OutputEventGroup::StartCollapsed), + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "First item in group 3".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "Second item in group 3".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "End group 3".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: Some(dap::OutputEventGroup::End), + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "Third item in group 1".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "Second item".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: Some(dap::OutputEventGroup::End), + })) + .await; + + cx.run_until_parked(); + + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item.console().update(cx, |console, cx| { + console.editor().update(cx, |editor, cx| { + pretty_assertions::assert_eq!( + " + First line + First group + First item in group 1 + Second item in group 1 + Second group + First item in group 2 + Second item in group 2 + End group 2 + ⋯ End group 3 + Third item in group 1 + Second item + " + .unindent(), + editor.display_text(cx) + ); + }) + }); + }); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + +#[gpui::test] +async fn test_evaluate_expression(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + const NEW_VALUE: &str = "{nested1: \"Nested 1 updated\", nested2: \"Nested 2 updated\"}"; + + let called_evaluate = Arc::new(AtomicBool::new(false)); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + const variable1 = { + nested1: "Nested 1", + nested2: "Nested 2", + }; + const variable2 = "Value 2"; + const variable3 = "Value 3"; + "# + .unindent(); + + fs.insert_tree( + "/project", + json!({ + "src": { + "test.js": test_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + let stack_frames = vec![StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some("/project/src/test.js".into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 3, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + let scopes = vec![ + Scope { + name: "Scope 1".into(), + presentation_hint: None, + variables_reference: 2, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }, + Scope { + name: "Scope 2".into(), + presentation_hint: None, + variables_reference: 4, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }, + ]; + + client + .on_request::({ + let scopes = Arc::new(scopes.clone()); + move |_, args| { + assert_eq!(1, args.frame_id); + + Ok(dap::ScopesResponse { + scopes: (*scopes).clone(), + }) + } + }) + .await; + + let scope1_variables = Arc::new(Mutex::new(vec![ + Variable { + name: "variable1".into(), + value: "{nested1: \"Nested 1\", nested2: \"Nested 2\"}".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 3, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + Variable { + name: "variable2".into(), + value: "Value 2".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + ])); + + let nested_variables = vec![ + Variable { + name: "nested1".into(), + value: "Nested 1".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + Variable { + name: "nested2".into(), + value: "Nested 2".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + ]; + + let scope2_variables = vec![Variable { + name: "variable3".into(), + value: "Value 3".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }]; + + client + .on_request::({ + let scope1_variables = scope1_variables.clone(); + let nested_variables = Arc::new(nested_variables.clone()); + let scope2_variables = Arc::new(scope2_variables.clone()); + move |_, args| match args.variables_reference { + 4 => Ok(dap::VariablesResponse { + variables: (*scope2_variables).clone(), + }), + 3 => Ok(dap::VariablesResponse { + variables: (*nested_variables).clone(), + }), + 2 => Ok(dap::VariablesResponse { + variables: scope1_variables.lock().unwrap().clone(), + }), + id => unreachable!("unexpected variables reference {id}"), + } + }) + .await; + + client + .on_request::({ + let called_evaluate = called_evaluate.clone(); + let scope1_variables = scope1_variables.clone(); + move |_, args| { + called_evaluate.store(true, Ordering::SeqCst); + + assert_eq!(format!("$variable1 = {}", NEW_VALUE), args.expression); + assert_eq!(Some(1), args.frame_id); + assert_eq!(Some(dap::EvaluateArgumentsContext::Variables), args.context); + + scope1_variables.lock().unwrap()[0].value = NEW_VALUE.to_string(); + + Ok(dap::EvaluateResponse { + result: NEW_VALUE.into(), + type_: None, + presentation_hint: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }) + } + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + // toggle nested variables for scope 1 + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + let scope1_variables = scope1_variables.lock().unwrap().clone(); + + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.toggle_entry( + &variable_list::OpenEntry::Variable { + scope_name: scopes[0].name.clone(), + name: scope1_variables[0].name.clone(), + depth: 1, + }, + cx, + ); + }); + }); + + cx.run_until_parked(); + + active_debug_panel_item(workspace, cx).update_in(cx, |debug_panel_item, window, cx| { + debug_panel_item.console().update(cx, |console, item_cx| { + console + .query_bar() + .update(item_cx, |query_bar, console_cx| { + query_bar.set_text(format!("$variable1 = {}", NEW_VALUE), window, console_cx); + }); + + console.evaluate(&menu::Confirm, window, item_cx); + }); + }); + + cx.run_until_parked(); + + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + assert_eq!( + "", + debug_panel_item + .console() + .read(cx) + .query_bar() + .read(cx) + .text(cx) + .as_str() + ); + + assert_eq!( + format!("{}\n", NEW_VALUE), + debug_panel_item + .console() + .read(cx) + .editor() + .read(cx) + .text(cx) + .as_str() + ); + + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + let scope1_variables = scope1_variables.lock().unwrap().clone(); + + // scope 1 + assert_eq!( + vec![ + VariableContainer { + container_reference: scopes[0].variables_reference, + variable: scope1_variables[0].clone(), + depth: 1, + }, + VariableContainer { + container_reference: scope1_variables[0].variables_reference, + variable: nested_variables[0].clone(), + depth: 2, + }, + VariableContainer { + container_reference: scope1_variables[0].variables_reference, + variable: nested_variables[1].clone(), + depth: 2, + }, + VariableContainer { + container_reference: scopes[0].variables_reference, + variable: scope1_variables[1].clone(), + depth: 1, + }, + ], + variable_list.variables_by_scope(1, 2).unwrap().variables() + ); + + // scope 2 + assert_eq!( + vec![VariableContainer { + container_reference: scopes[1].variables_reference, + variable: scope2_variables[0].clone(), + depth: 1, + }], + variable_list.variables_by_scope(1, 4).unwrap().variables() + ); + + // assert visual entries + assert_eq!( + vec![ + VariableListEntry::Scope(scopes[0].clone()), + VariableListEntry::Variable { + depth: 1, + scope: Arc::new(scopes[0].clone()), + has_children: true, + variable: Arc::new(scope1_variables[0].clone()), + container_reference: scopes[0].variables_reference, + }, + VariableListEntry::Variable { + depth: 2, + scope: Arc::new(scopes[0].clone()), + has_children: false, + variable: Arc::new(nested_variables[0].clone()), + container_reference: scope1_variables[0].variables_reference, + }, + VariableListEntry::Variable { + depth: 2, + scope: Arc::new(scopes[0].clone()), + has_children: false, + variable: Arc::new(nested_variables[1].clone()), + container_reference: scope1_variables[0].variables_reference, + }, + VariableListEntry::Variable { + depth: 1, + scope: Arc::new(scopes[0].clone()), + has_children: false, + variable: Arc::new(scope1_variables[1].clone()), + container_reference: scopes[0].variables_reference, + }, + VariableListEntry::Scope(scopes[1].clone()), + ], + variable_list.entries().get(&1).unwrap().clone() + ); + }); + }); + + assert!( + called_evaluate.load(std::sync::atomic::Ordering::SeqCst), + "Expected evaluate request to be called" + ); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} diff --git a/crates/debugger_ui/src/tests/debugger_panel.rs b/crates/debugger_ui/src/tests/debugger_panel.rs new file mode 100644 index 00000000000000..8785c9a9f1495a --- /dev/null +++ b/crates/debugger_ui/src/tests/debugger_panel.rs @@ -0,0 +1,1327 @@ +use crate::*; +use dap::{ + client::SessionId, + requests::{ + Continue, Disconnect, Initialize, Launch, Next, RunInTerminal, SetBreakpoints, StackTrace, + StartDebugging, StepBack, StepIn, StepOut, + }, + ErrorResponse, RunInTerminalRequestArguments, SourceBreakpoint, StartDebuggingRequestArguments, + StartDebuggingRequestArgumentsRequest, +}; +use debugger_panel::ThreadStatus; +use editor::{ + actions::{self}, + Editor, EditorMode, MultiBuffer, +}; +use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; +use project::{ + debugger::breakpoint_store::{Breakpoint, BreakpointEditAction, BreakpointKind}, + FakeFs, Project, +}; +use serde_json::json; +use std::{ + path::Path, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; +use terminal_view::{terminal_panel::TerminalPanel, TerminalView}; +use tests::{active_debug_panel_item, init_test, init_test_workspace, worktree_from_project}; +use workspace::{dock::Panel, Item}; + +#[gpui::test] +async fn test_basic_show_debug_panel(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + // assert we don't have a debug panel item yet + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + debug_panel.update(cx, |this, cx| { + assert!(this.active_debug_panel_item(cx).is_none()); + assert_eq!(0, this.pane().unwrap().read(cx).items_len()); + }); + }) + .unwrap(); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + // assert we added a debug panel item + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + assert_eq!( + 1, + debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + ); + assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); + assert_eq!(1, active_debug_panel_item.read(cx).thread_id().0); + }) + .unwrap(); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); + + // assert we don't have a debug panel item anymore because the client shutdown + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + debug_panel.update(cx, |this, cx| { + assert!(this.active_debug_panel_item(cx).is_none()); + assert_eq!(0, this.pane().unwrap().read(cx).items_len()); + }); + }) + .unwrap(); +} + +#[gpui::test] +async fn test_we_can_only_have_one_panel_per_debug_thread( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + // assert we don't have a debug panel item yet + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + debug_panel.update(cx, |this, cx| { + assert!(this.active_debug_panel_item(cx).is_none()); + assert_eq!(0, this.pane().unwrap().read(cx).items_len()); + }); + }) + .unwrap(); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + // assert we added a debug panel item + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + assert_eq!( + 1, + debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + ); + assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); + assert_eq!(1, active_debug_panel_item.read(cx).thread_id().0); + }) + .unwrap(); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + // assert we added a debug panel item + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + assert_eq!( + 1, + debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + ); + assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); + assert_eq!(1, active_debug_panel_item.read(cx).thread_id().0); + }) + .unwrap(); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); + + // assert we don't have a debug panel item anymore because the client shutdown + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + debug_panel.update(cx, |this, cx| { + assert!(this.active_debug_panel_item(cx).is_none()); + assert_eq!(0, this.pane().unwrap().read(cx).items_len()); + }); + }) + .unwrap(); +} + +#[gpui::test] +async fn test_client_can_open_multiple_thread_panels( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + // assert we don't have a debug panel item yet + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + debug_panel.update(cx, |this, cx| { + assert!(this.active_debug_panel_item(cx).is_none()); + assert_eq!(0, this.pane().unwrap().read(cx).items_len()); + }); + }) + .unwrap(); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + // assert we added a debug panel item + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + assert_eq!( + 1, + debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + ); + assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); + assert_eq!(1, active_debug_panel_item.read(cx).thread_id().0); + }) + .unwrap(); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(2), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + // assert we added a debug panel item and the new one is active + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + assert_eq!( + 2, + debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + ); + assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); + assert_eq!(2, active_debug_panel_item.read(cx).thread_id().0); + }) + .unwrap(); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); + + // assert we don't have a debug panel item anymore because the client shutdown + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + debug_panel.update(cx, |this, cx| { + assert!(this.active_debug_panel_item(cx).is_none()); + assert_eq!(0, this.pane().unwrap().read(cx).items_len()); + }); + }) + .unwrap(); +} + +#[gpui::test] +async fn test_handle_successful_run_in_terminal_reverse_request( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let send_response = Arc::new(AtomicBool::new(false)); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_response::({ + let send_response = send_response.clone(); + move |response| { + send_response.store(true, Ordering::SeqCst); + + assert!(response.success); + assert!(response.body.is_some()); + } + }) + .await; + + client + .fake_reverse_request::(RunInTerminalRequestArguments { + kind: None, + title: None, + cwd: std::env::temp_dir().to_string_lossy().to_string(), + args: vec![], + env: None, + args_can_be_interpreted_by_shell: None, + }) + .await; + + cx.run_until_parked(); + + assert!( + send_response.load(std::sync::atomic::Ordering::SeqCst), + "Expected to receive response from reverse request" + ); + + workspace + .update(cx, |workspace, _window, cx| { + let terminal_panel = workspace.panel::(cx).unwrap(); + + let panel = terminal_panel.read(cx).pane().unwrap().read(cx); + + assert_eq!(1, panel.items_len()); + assert!(panel + .active_item() + .unwrap() + .downcast::() + .unwrap() + .read(cx) + .terminal() + .read(cx) + .debug_terminal()); + }) + .unwrap(); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + +// covers that we always send a response back, if something when wrong, +// while spawning the terminal +#[gpui::test] +async fn test_handle_error_run_in_terminal_reverse_request( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let send_response = Arc::new(AtomicBool::new(false)); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_response::({ + let send_response = send_response.clone(); + move |response| { + send_response.store(true, Ordering::SeqCst); + + assert!(!response.success); + assert!(response.body.is_some()); + } + }) + .await; + + client + .fake_reverse_request::(RunInTerminalRequestArguments { + kind: None, + title: None, + cwd: "/non-existing/path".into(), // invalid/non-existing path will cause the terminal spawn to fail + args: vec![], + env: None, + args_can_be_interpreted_by_shell: None, + }) + .await; + + cx.run_until_parked(); + + assert!( + send_response.load(std::sync::atomic::Ordering::SeqCst), + "Expected to receive response from reverse request" + ); + + workspace + .update(cx, |workspace, _window, cx| { + let terminal_panel = workspace.panel::(cx).unwrap(); + + assert_eq!( + 0, + terminal_panel.read(cx).pane().unwrap().read(cx).items_len() + ); + }) + .unwrap(); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + +#[gpui::test] +async fn test_handle_start_debugging_reverse_request( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let send_response = Arc::new(AtomicBool::new(false)); + let send_launch = Arc::new(AtomicBool::new(false)); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_response::({ + let send_response = send_response.clone(); + move |response| { + send_response.store(true, Ordering::SeqCst); + + assert!(response.success); + assert!(response.body.is_some()); + } + }) + .await; + + cx.run_until_parked(); + + client + .fake_reverse_request::(StartDebuggingRequestArguments { + configuration: json!({}), + request: StartDebuggingRequestArgumentsRequest::Launch, + }) + .await; + + cx.run_until_parked(); + + project.update(cx, |_, cx| { + assert_eq!(2, session.read(cx).clients_len()); + }); + assert!( + send_response.load(std::sync::atomic::Ordering::SeqCst), + "Expected to receive response from reverse request" + ); + + let second_client = project.update(cx, |_, cx| { + session + .read(cx) + .client_state(SessionId(1)) + .unwrap() + .read(cx) + .adapter_client() + .unwrap() + }); + + project.update(cx, |_, cx| { + cx.emit(project::Event::DebugClientStarted(( + session.read(cx).id(), + second_client.id(), + ))); + }); + + second_client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + second_client + .on_request::({ + let send_launch = send_launch.clone(); + move |_, _| { + send_launch.store(true, Ordering::SeqCst); + + Ok(()) + } + }) + .await; + second_client + .on_request::(move |_, _| Ok(())) + .await; + + cx.run_until_parked(); + + assert!( + send_launch.load(std::sync::atomic::Ordering::SeqCst), + "Expected to send launch request on second client" + ); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + +#[gpui::test] +async fn test_debug_panel_item_thread_status_reset_on_failure( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(true), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client + .on_request::(move |_, _| { + Err(ErrorResponse { + error: Some(dap::Message { + id: 1, + format: "error".into(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + }) + }) + .await; + + client + .on_request::(move |_, _| { + Err(ErrorResponse { + error: Some(dap::Message { + id: 1, + format: "error".into(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + }) + }) + .await; + + client + .on_request::(move |_, _| { + Err(ErrorResponse { + error: Some(dap::Message { + id: 1, + format: "error".into(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + }) + }) + .await; + + client + .on_request::(move |_, _| { + Err(ErrorResponse { + error: Some(dap::Message { + id: 1, + format: "error".into(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + }) + }) + .await; + + client + .on_request::(move |_, _| { + Err(ErrorResponse { + error: Some(dap::Message { + id: 1, + format: "error".into(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + }) + }) + .await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + cx.run_until_parked(); + + for operation in &[ + "step_over", + "continue_thread", + "step_back", + "step_in", + "step_out", + ] { + active_debug_panel_item(workspace, cx).update( + cx, + |debug_panel_item, cx| match *operation { + "step_over" => debug_panel_item.step_over(cx), + "continue_thread" => debug_panel_item.continue_thread(cx), + "step_back" => debug_panel_item.step_back(cx), + "step_in" => debug_panel_item.step_in(cx), + "step_out" => debug_panel_item.step_out(cx), + _ => unreachable!(), + }, + ); + + cx.run_until_parked(); + + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + assert_eq!( + debug_panel_item.thread_state().read(cx).status, + debugger_panel::ThreadStatus::Stopped, + "Thread status not reset to Stopped after failed {}", + operation + ); + + // update state to running, so we can test it actually changes the status back to stopped + debug_panel_item + .thread_state() + .update(cx, |thread_state, cx| { + thread_state.status = ThreadStatus::Running; + cx.notify(); + }); + }); + } + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + +#[gpui::test] +async fn test_send_breakpoints_when_editor_has_been_saved( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let worktree = worktree_from_project(&project, cx); + let worktree_id = workspace + .update(cx, |_, _, cx| worktree.read(cx).id()) + .unwrap(); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, "main.rs"), cx) + }) + .await + .unwrap(); + + let (editor, cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::Full, + MultiBuffer::build_from_buffer(buffer, cx), + Some(project.clone()), + true, + window, + cx, + ) + }); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(true), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + let called_set_breakpoints = Arc::new(AtomicBool::new(false)); + client + .on_request::({ + let called_set_breakpoints = called_set_breakpoints.clone(); + move |_, args| { + assert_eq!("/project/main.rs", args.source.path.unwrap()); + assert_eq!( + vec![SourceBreakpoint { + line: 2, + column: None, + condition: None, + hit_condition: None, + log_message: None, + mode: None + }], + args.breakpoints.unwrap() + ); + assert!(!args.source_modified.unwrap()); + + called_set_breakpoints.store(true, Ordering::SeqCst); + + Ok(dap::SetBreakpointsResponse { + breakpoints: Vec::default(), + }) + } + }) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.move_down(&actions::MoveDown, window, cx); + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + }); + + cx.run_until_parked(); + + assert!( + called_set_breakpoints.load(std::sync::atomic::Ordering::SeqCst), + "SetBreakpoint request must be called" + ); + + let called_set_breakpoints = Arc::new(AtomicBool::new(false)); + client + .on_request::({ + let called_set_breakpoints = called_set_breakpoints.clone(); + move |_, args| { + assert_eq!("/project/main.rs", args.source.path.unwrap()); + assert_eq!( + vec![SourceBreakpoint { + line: 3, + column: None, + condition: None, + hit_condition: None, + log_message: None, + mode: None + }], + args.breakpoints.unwrap() + ); + assert!(args.source_modified.unwrap()); + + called_set_breakpoints.store(true, Ordering::SeqCst); + + Ok(dap::SetBreakpointsResponse { + breakpoints: Vec::default(), + }) + } + }) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.move_up(&actions::MoveUp, window, cx); + editor.insert("new text\n", window, cx); + }); + + editor + .update_in(cx, |editor, window, cx| { + editor.save(true, project.clone(), window, cx) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + assert!( + called_set_breakpoints.load(std::sync::atomic::Ordering::SeqCst), + "SetBreakpoint request must be called after editor is saved" + ); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + +#[gpui::test] +async fn test_it_send_breakpoint_request_if_breakpoint_buffer_is_unopened( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let worktree = worktree_from_project(&project, cx); + let worktree_id = workspace + .update(cx, |_, _, cx| worktree.read(cx).id()) + .unwrap(); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(true), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + let called_set_breakpoints = Arc::new(AtomicBool::new(false)); + client + .on_request::({ + let called_set_breakpoints = called_set_breakpoints.clone(); + move |_, args| { + assert_eq!("/project/main.rs", args.source.path.unwrap()); + assert_eq!( + vec![SourceBreakpoint { + line: 2, + column: None, + condition: None, + hit_condition: None, + log_message: None, + mode: None + }], + args.breakpoints.unwrap() + ); + assert!(!args.source_modified.unwrap()); + + called_set_breakpoints.store(true, Ordering::SeqCst); + + Ok(dap::SetBreakpointsResponse { + breakpoints: Vec::default(), + }) + } + }) + .await; + + // add breakpoint for file/buffer that has not been opened yet + project.update(cx, |project, cx| { + project + .breakpoint_store() + .update(cx, |breakpoint_store, cx| { + breakpoint_store.toggle_breakpoint_for_buffer( + &project::ProjectPath { + worktree_id, + path: Arc::from(Path::new(&"main.rs")), + }, + Breakpoint { + active_position: None, + cached_position: 1, + kind: BreakpointKind::Standard, + }, + BreakpointEditAction::Toggle, + cx, + ); + }); + }); + + cx.run_until_parked(); + + assert!( + called_set_breakpoints.load(std::sync::atomic::Ordering::SeqCst), + "SetBreakpoint request must be called for unopened buffers" + ); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + +#[gpui::test] +async fn test_debug_session_is_shutdown_when_attach_and_launch_request_fails( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + let session_id = cx.update(|_window, cx| session.read(cx).id()); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client + .on_request::(move |_, _| { + Err(ErrorResponse { + error: Some(dap::Message { + id: 1, + format: "error".into(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + }) + }) + .await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + cx.run_until_parked(); + + project.update(cx, |project, cx| { + assert!(project + .dap_store() + .read(cx) + .session_by_id(&session_id) + .is_none()); + }); +} diff --git a/crates/debugger_ui/src/tests/module_list.rs b/crates/debugger_ui/src/tests/module_list.rs new file mode 100644 index 00000000000000..44e887e678ed84 --- /dev/null +++ b/crates/debugger_ui/src/tests/module_list.rs @@ -0,0 +1,223 @@ +use crate::{ + session::ThreadItem, + tests::{active_debug_panel_item, init_test, init_test_workspace}, +}; +use dap::{ + requests::{Disconnect, Initialize, Launch, Modules, StackTrace}, + StoppedEvent, +}; +use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; +use project::{FakeFs, Project}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + +#[gpui::test] +async fn test_module_list(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_modules_request: Some(true), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + let called_modules = Arc::new(AtomicBool::new(false)); + let modules = vec![ + dap::Module { + id: dap::ModuleId::Number(1), + name: "First Module".into(), + address_range: None, + date_time_stamp: None, + path: None, + symbol_file_path: None, + symbol_status: None, + version: None, + is_optimized: None, + is_user_code: None, + }, + dap::Module { + id: dap::ModuleId::Number(2), + name: "Second Module".into(), + address_range: None, + date_time_stamp: None, + path: None, + symbol_file_path: None, + symbol_status: None, + version: None, + is_optimized: None, + is_user_code: None, + }, + ]; + + client + .on_request::({ + let called_modules = called_modules.clone(); + let modules = modules.clone(); + move |_, _| unsafe { + static mut REQUEST_COUNT: i32 = 1; + assert_eq!( + 1, REQUEST_COUNT, + "This request should only be called once from the host" + ); + REQUEST_COUNT += 1; + called_modules.store(true, Ordering::SeqCst); + + Ok(dap::ModulesResponse { + modules: modules.clone(), + total_modules: Some(2u64), + }) + } + }) + .await; + + client + .fake_event(dap::messages::Events::Stopped(StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + cx.run_until_parked(); + + assert!( + !called_modules.load(std::sync::atomic::Ordering::SeqCst), + "Request Modules shouldn't be called before it's needed" + ); + + active_debug_panel_item(workspace, cx).update(cx, |item, cx| { + item.set_thread_item(ThreadItem::Modules, cx); + }); + + cx.run_until_parked(); + + assert!( + called_modules.load(std::sync::atomic::Ordering::SeqCst), + "Request Modules should be called because a user clicked on the module list" + ); + + active_debug_panel_item(workspace, cx).update(cx, |item, cx| { + item.set_thread_item(ThreadItem::Modules, cx); + + let actual_modules = item.modules().update(cx, |list, cx| list.modules(cx)); + assert_eq!(modules, actual_modules); + }); + + // Test all module events now + // New Module + // Changed + // Removed + + let new_module = dap::Module { + id: dap::ModuleId::Number(3), + name: "Third Module".into(), + address_range: None, + date_time_stamp: None, + path: None, + symbol_file_path: None, + symbol_status: None, + version: None, + is_optimized: None, + is_user_code: None, + }; + + client + .fake_event(dap::messages::Events::Module(dap::ModuleEvent { + reason: dap::ModuleEventReason::New, + module: new_module.clone(), + })) + .await; + + cx.run_until_parked(); + + active_debug_panel_item(workspace, cx).update(cx, |item, cx| { + let actual_modules = item.modules().update(cx, |list, cx| list.modules(cx)); + assert_eq!(actual_modules.len(), 3); + assert!(actual_modules.contains(&new_module)); + }); + + let changed_module = dap::Module { + id: dap::ModuleId::Number(2), + name: "Modified Second Module".into(), + address_range: None, + date_time_stamp: None, + path: None, + symbol_file_path: None, + symbol_status: None, + version: None, + is_optimized: None, + is_user_code: None, + }; + + client + .fake_event(dap::messages::Events::Module(dap::ModuleEvent { + reason: dap::ModuleEventReason::Changed, + module: changed_module.clone(), + })) + .await; + + cx.run_until_parked(); + + active_debug_panel_item(workspace, cx).update(cx, |item, cx| { + let actual_modules = item.modules().update(cx, |list, cx| list.modules(cx)); + assert_eq!(actual_modules.len(), 3); + assert!(actual_modules.contains(&changed_module)); + }); + + client + .fake_event(dap::messages::Events::Module(dap::ModuleEvent { + reason: dap::ModuleEventReason::Removed, + module: changed_module.clone(), + })) + .await; + + cx.run_until_parked(); + + active_debug_panel_item(workspace, cx).update(cx, |item, cx| { + let actual_modules = item.modules().update(cx, |list, cx| list.modules(cx)); + assert_eq!(actual_modules.len(), 2); + assert!(!actual_modules.contains(&changed_module)); + }); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} diff --git a/crates/debugger_ui/src/tests/stack_frame_list.rs b/crates/debugger_ui/src/tests/stack_frame_list.rs new file mode 100644 index 00000000000000..83ba416a38ed1a --- /dev/null +++ b/crates/debugger_ui/src/tests/stack_frame_list.rs @@ -0,0 +1,645 @@ +use crate::{ + debugger_panel::DebugPanel, + stack_frame_list::StackFrameEntry, + tests::{active_debug_panel_item, init_test, init_test_workspace}, +}; +use dap::{ + requests::{Disconnect, Initialize, Launch, StackTrace}, + StackFrame, +}; +use editor::{Editor, ToPoint as _}; +use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; +use project::{FakeFs, Project}; +use serde_json::json; +use std::sync::Arc; +use unindent::Unindent as _; + +#[gpui::test] +async fn test_fetch_initial_stack_frames_and_go_to_stack_frame( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + import { SOME_VALUE } './module.js'; + + console.log(SOME_VALUE); + "# + .unindent(); + + let module_file_content = r#" + export SOME_VALUE = 'some value'; + "# + .unindent(); + + fs.insert_tree( + "/project", + json!({ + "src": { + "test.js": test_file_content, + "module.js": module_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + let stack_frames = vec![ + StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some("/project/src/test.js".into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 3, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + StackFrame { + id: 2, + name: "Stack Frame 2".into(), + source: Some(dap::Source { + name: Some("module.js".into()), + path: Some("/project/src/module.js".into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + ]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + active_debug_panel_item.update(cx, |debug_panel_item, cx| { + let (stack_frame_list, stack_frame_id) = + debug_panel_item.stack_frame_list().update(cx, |list, cx| { + ( + list.stack_frames(cx) + .into_iter() + .map(|frame| frame.dap) + .collect::>(), + list.current_stack_frame_id(), + ) + }); + + assert_eq!(1, stack_frame_id); + assert_eq!(stack_frames, stack_frame_list); + }); + }) + .unwrap(); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + +#[gpui::test] +async fn test_select_stack_frame(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + import { SOME_VALUE } './module.js'; + + console.log(SOME_VALUE); + "# + .unindent(); + + let module_file_content = r#" + export SOME_VALUE = 'some value'; + "# + .unindent(); + + fs.insert_tree( + "/project", + json!({ + "src": { + "test.js": test_file_content, + "module.js": module_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + let stack_frames = vec![ + StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some("/project/src/test.js".into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 3, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + StackFrame { + id: 2, + name: "Stack Frame 2".into(), + source: Some(dap::Source { + name: Some("module.js".into()), + path: Some("/project/src/module.js".into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + ]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + workspace + .update(cx, |workspace, window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + active_debug_panel_item.update(cx, |debug_panel_item, cx| { + let (stack_frame_list, stack_frame_id) = + debug_panel_item.stack_frame_list().update(cx, |list, cx| { + ( + list.stack_frames(cx) + .into_iter() + .map(|frame| frame.dap) + .collect::>(), + list.current_stack_frame_id(), + ) + }); + + assert_eq!(1, stack_frame_id); + assert_eq!(stack_frames, stack_frame_list); + }); + + let editors = workspace.items_of_type::(cx).collect::>(); + assert_eq!(1, editors.len()); + + let project_path = editors[0] + .update(cx, |editor, cx| editor.project_path(cx)) + .unwrap(); + assert_eq!("src/test.js", project_path.path.to_string_lossy()); + assert_eq!(test_file_content, editors[0].read(cx).text(cx)); + assert_eq!( + vec![2..3], + editors[0].update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + + editor + .highlighted_rows::() + .map(|(range, _)| { + let start = range.start.to_point(&snapshot.buffer_snapshot); + let end = range.end.to_point(&snapshot.buffer_snapshot); + start.row..end.row + }) + .collect::>() + }) + ); + }) + .unwrap(); + + let stack_frame_list = workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + active_debug_panel_item.read(cx).stack_frame_list().clone() + }) + .unwrap(); + + // select second stack frame + stack_frame_list + .update_in(cx, |stack_frame_list, window, cx| { + stack_frame_list.select_stack_frame(&stack_frames[1], true, window, cx) + }) + .await + .unwrap(); + + workspace + .update(cx, |workspace, window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_debug_panel_item(cx)) + .unwrap(); + + active_debug_panel_item.update(cx, |debug_panel_item, cx| { + let (stack_frame_list, stack_frame_id) = + debug_panel_item.stack_frame_list().update(cx, |list, cx| { + ( + list.stack_frames(cx) + .into_iter() + .map(|frame| frame.dap) + .collect::>(), + list.current_stack_frame_id(), + ) + }); + + assert_eq!(2, stack_frame_id); + assert_eq!(stack_frames, stack_frame_list); + }); + + let editors = workspace.items_of_type::(cx).collect::>(); + assert_eq!(1, editors.len()); + + let project_path = editors[0] + .update(cx, |editor, cx| editor.project_path(cx)) + .unwrap(); + assert_eq!("src/module.js", project_path.path.to_string_lossy()); + assert_eq!(module_file_content, editors[0].read(cx).text(cx)); + assert_eq!( + vec![0..1], + editors[0].update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + + editor + .highlighted_rows::() + .map(|(range, _)| { + let start = range.start.to_point(&snapshot.buffer_snapshot); + let end = range.end.to_point(&snapshot.buffer_snapshot); + start.row..end.row + }) + .collect::>() + }) + ); + }) + .unwrap(); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + +#[gpui::test] +async fn test_collapsed_entries(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + import { SOME_VALUE } './module.js'; + + console.log(SOME_VALUE); + "# + .unindent(); + + let module_file_content = r#" + export SOME_VALUE = 'some value'; + "# + .unindent(); + + fs.insert_tree( + "/project", + json!({ + "src": { + "test.js": test_file_content, + "module.js": module_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + let stack_frames = vec![ + StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some("/project/src/test.js".into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 3, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + StackFrame { + id: 2, + name: "Stack Frame 2".into(), + source: Some(dap::Source { + name: Some("module.js".into()), + path: Some("/project/src/module.js".into()), + source_reference: None, + presentation_hint: None, + origin: Some("ignored".into()), + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: Some(dap::StackFramePresentationHint::Deemphasize), + }, + StackFrame { + id: 3, + name: "Stack Frame 3".into(), + source: Some(dap::Source { + name: Some("module.js".into()), + path: Some("/project/src/module.js".into()), + source_reference: None, + presentation_hint: None, + origin: Some("ignored".into()), + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: Some(dap::StackFramePresentationHint::Deemphasize), + }, + StackFrame { + id: 4, + name: "Stack Frame 4".into(), + source: Some(dap::Source { + name: Some("module.js".into()), + path: Some("/project/src/module.js".into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + ]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .stack_frame_list() + .update(cx, |stack_frame_list, cx| { + assert_eq!( + &vec![ + StackFrameEntry::Normal(stack_frames[0].clone()), + StackFrameEntry::Collapsed(vec![ + stack_frames[1].clone(), + stack_frames[2].clone() + ]), + StackFrameEntry::Normal(stack_frames[3].clone()), + ], + stack_frame_list.entries() + ); + + stack_frame_list.expand_collapsed_entry( + 1, + &vec![stack_frames[1].clone(), stack_frames[2].clone()], + cx, + ); + + assert_eq!( + &vec![ + StackFrameEntry::Normal(stack_frames[0].clone()), + StackFrameEntry::Normal(stack_frames[1].clone()), + StackFrameEntry::Normal(stack_frames[2].clone()), + StackFrameEntry::Normal(stack_frames[3].clone()), + ], + stack_frame_list.entries() + ); + }); + }); + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} diff --git a/crates/debugger_ui/src/tests/variable_list.rs b/crates/debugger_ui/src/tests/variable_list.rs new file mode 100644 index 00000000000000..396e66ac1bfadf --- /dev/null +++ b/crates/debugger_ui/src/tests/variable_list.rs @@ -0,0 +1,1701 @@ +use std::sync::Arc; + +use crate::{ + tests::{active_debug_panel_item, init_test, init_test_workspace}, + variable_list::{CollapseSelectedEntry, ExpandSelectedEntry, VariableContainer}, +}; +use collections::HashMap; +use dap::{ + requests::{Disconnect, Initialize, Launch, Scopes, StackTrace, Variables}, + Scope, StackFrame, Variable, +}; +use gpui::{BackgroundExecutor, Focusable, TestAppContext, VisualTestContext}; +use menu::{SelectFirst, SelectNext}; +use project::{FakeFs, Project}; +use serde_json::json; +use unindent::Unindent as _; + +/// This only tests fetching one scope and 2 variables for a single stackframe +#[gpui::test] +async fn test_basic_fetch_initial_scope_and_variables( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + const variable1 = "Value 1"; + const variable2 = "Value 2"; + "# + .unindent(); + + fs.insert_tree( + "/project", + json!({ + "src": { + "test.js": test_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + let stack_frames = vec![StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some("/project/src/test.js".into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + let scopes = vec![Scope { + name: "Scope 1".into(), + presentation_hint: None, + variables_reference: 2, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }]; + + client + .on_request::({ + let scopes = Arc::new(scopes.clone()); + move |_, args| { + assert_eq!(1, args.frame_id); + + Ok(dap::ScopesResponse { + scopes: (*scopes).clone(), + }) + } + }) + .await; + + let variables = vec![ + Variable { + name: "variable1".into(), + value: "value 1".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + Variable { + name: "variable2".into(), + value: "value 2".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + ]; + + client + .on_request::({ + let variables = Arc::new(variables.clone()); + move |_, args| { + assert_eq!(2, args.variables_reference); + + Ok(dap::VariablesResponse { + variables: (*variables).clone(), + }) + } + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + let (stack_frame_list, stack_frame_id) = + debug_panel_item.stack_frame_list().update(cx, |list, cx| { + ( + list.stack_frames(cx) + .into_iter() + .map(|frame| frame.dap) + .collect::>(), + list.current_stack_frame_id(), + ) + }); + + assert_eq!(1, stack_frame_id); + assert_eq!(stack_frames, stack_frame_list); + + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + assert_eq!(1, variable_list.scopes().len()); + assert_eq!(scopes, variable_list.scopes().get(&1).unwrap().clone()); + assert_eq!( + vec![ + VariableContainer { + container_reference: scopes[0].variables_reference, + variable: variables[0].clone(), + depth: 1, + }, + VariableContainer { + container_reference: scopes[0].variables_reference, + variable: variables[1].clone(), + depth: 1, + }, + ], + variable_list.variables_by_stack_frame_id(1) + ); + + variable_list.assert_visual_entries( + vec!["v Scope 1", " > variable1", " > variable2"], + cx, + ); + }); + }); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + +/// This tests fetching multiple scopes and variables for them with a single stackframe +#[gpui::test] +async fn test_fetch_variables_for_multiple_scopes( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + const variable1 = { + nested1: "Nested 1", + nested2: "Nested 2", + }; + const variable2 = "Value 2"; + const variable3 = "Value 3"; + "# + .unindent(); + + fs.insert_tree( + "/project", + json!({ + "src": { + "test.js": test_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + let stack_frames = vec![StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some("/project/src/test.js".into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + let scopes = vec![ + Scope { + name: "Scope 1".into(), + presentation_hint: None, + variables_reference: 2, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }, + Scope { + name: "Scope 2".into(), + presentation_hint: None, + variables_reference: 3, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }, + ]; + + client + .on_request::({ + let scopes = Arc::new(scopes.clone()); + move |_, args| { + assert_eq!(1, args.frame_id); + + Ok(dap::ScopesResponse { + scopes: (*scopes).clone(), + }) + } + }) + .await; + + let mut variables = HashMap::default(); + variables.insert( + 2, + vec![ + Variable { + name: "variable1".into(), + value: "{nested1: \"Nested 1\", nested2: \"Nested 2\"}".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + Variable { + name: "variable2".into(), + value: "Value 2".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + ], + ); + variables.insert( + 3, + vec![Variable { + name: "variable3".into(), + value: "Value 3".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }], + ); + + client + .on_request::({ + let variables = Arc::new(variables.clone()); + move |_, args| { + Ok(dap::VariablesResponse { + variables: variables.get(&args.variables_reference).unwrap().clone(), + }) + } + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + let (stack_frame_list, stack_frame_id) = + debug_panel_item.stack_frame_list().update(cx, |list, cx| { + ( + list.stack_frames(cx) + .into_iter() + .map(|frame| frame.dap) + .collect::>(), + list.current_stack_frame_id(), + ) + }); + + assert_eq!(1, stack_frame_id); + assert_eq!(stack_frames, stack_frame_list); + + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + assert_eq!(1, variable_list.scopes().len()); + assert_eq!(scopes, variable_list.scopes().get(&1).unwrap().clone()); + + // scope 1 + assert_eq!( + vec![ + VariableContainer { + container_reference: scopes[0].variables_reference, + variable: variables.get(&2).unwrap()[0].clone(), + depth: 1, + }, + VariableContainer { + container_reference: scopes[0].variables_reference, + variable: variables.get(&2).unwrap()[1].clone(), + depth: 1, + }, + ], + variable_list.variables_by_scope(1, 2).unwrap().variables() + ); + + // scope 2 + assert_eq!( + vec![VariableContainer { + container_reference: scopes[1].variables_reference, + variable: variables.get(&3).unwrap()[0].clone(), + depth: 1, + }], + variable_list.variables_by_scope(1, 3).unwrap().variables() + ); + + variable_list.assert_visual_entries( + vec![ + "v Scope 1", + " > variable1", + " > variable2", + "> Scope 2", + ], + cx, + ); + }); + }); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + +// tests that toggling a variable will fetch its children and shows it +#[gpui::test] +async fn test_keyboard_navigation(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + const variable1 = { + nested1: "Nested 1", + nested2: "Nested 2", + }; + const variable2 = "Value 2"; + const variable3 = "Value 3"; + "# + .unindent(); + + fs.insert_tree( + "/project", + json!({ + "src": { + "test.js": test_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + let stack_frames = vec![StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some("/project/src/test.js".into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + let scopes = vec![ + Scope { + name: "Scope 1".into(), + presentation_hint: None, + variables_reference: 2, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }, + Scope { + name: "Scope 2".into(), + presentation_hint: None, + variables_reference: 4, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }, + ]; + + client + .on_request::({ + let scopes = Arc::new(scopes.clone()); + move |_, args| { + assert_eq!(1, args.frame_id); + + Ok(dap::ScopesResponse { + scopes: (*scopes).clone(), + }) + } + }) + .await; + + let scope1_variables = vec![ + Variable { + name: "variable1".into(), + value: "{nested1: \"Nested 1\", nested2: \"Nested 2\"}".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 3, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + Variable { + name: "variable2".into(), + value: "Value 2".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + ]; + + let nested_variables = vec![ + Variable { + name: "nested1".into(), + value: "Nested 1".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + Variable { + name: "nested2".into(), + value: "Nested 2".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + ]; + + let scope2_variables = vec![Variable { + name: "variable3".into(), + value: "Value 3".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }]; + + client + .on_request::({ + let scope1_variables = Arc::new(scope1_variables.clone()); + let nested_variables = Arc::new(nested_variables.clone()); + let scope2_variables = Arc::new(scope2_variables.clone()); + move |_, args| match args.variables_reference { + 4 => Ok(dap::VariablesResponse { + variables: (*scope2_variables).clone(), + }), + 3 => Ok(dap::VariablesResponse { + variables: (*nested_variables).clone(), + }), + 2 => Ok(dap::VariablesResponse { + variables: (*scope1_variables).clone(), + }), + id => unreachable!("unexpected variables reference {id}"), + } + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + active_debug_panel_item(workspace, cx).update_in(cx, |debug_panel_item, window, cx| { + debug_panel_item + .variable_list() + .focus_handle(cx) + .focus(window); + }); + + cx.dispatch_action(SelectFirst); + cx.run_until_parked(); + + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.assert_visual_entries( + vec![ + "v Scope 1 <=== selected", + " > variable1", + " > variable2", + "> Scope 2", + ], + cx, + ); + }); + }); + + // select the first variable of scope 1 + cx.dispatch_action(SelectNext); + cx.run_until_parked(); + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.assert_visual_entries( + vec![ + "v Scope 1", + " > variable1 <=== selected", + " > variable2", + "> Scope 2", + ], + cx, + ); + }); + }); + + // expand the nested variables of variable 1 + cx.dispatch_action(ExpandSelectedEntry); + cx.run_until_parked(); + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.assert_visual_entries( + vec![ + "v Scope 1", + " v variable1 <=== selected", + " > nested1", + " > nested2", + " > variable2", + "> Scope 2", + ], + cx, + ); + }); + }); + + // select the first nested variable of variable 1 + cx.dispatch_action(SelectNext); + cx.run_until_parked(); + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.assert_visual_entries( + vec![ + "v Scope 1", + " v variable1", + " > nested1 <=== selected", + " > nested2", + " > variable2", + "> Scope 2", + ], + cx, + ); + }); + }); + + // select the second nested variable of variable 1 + cx.dispatch_action(SelectNext); + cx.run_until_parked(); + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.assert_visual_entries( + vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2 <=== selected", + " > variable2", + "> Scope 2", + ], + cx, + ); + }); + }); + + // select variable 2 of scope 1 + cx.dispatch_action(SelectNext); + cx.run_until_parked(); + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.assert_visual_entries( + vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2", + " > variable2 <=== selected", + "> Scope 2", + ], + cx, + ); + }); + }); + + // select scope 2 + cx.dispatch_action(SelectNext); + cx.run_until_parked(); + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.assert_visual_entries( + vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2", + " > variable2", + "> Scope 2 <=== selected", + ], + cx, + ); + }); + }); + + // expand the nested variables of scope 2 + cx.dispatch_action(ExpandSelectedEntry); + cx.run_until_parked(); + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.assert_visual_entries( + vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2", + " > variable2", + "v Scope 2 <=== selected", + " > variable3", + ], + cx, + ); + }); + }); + + // select variable 3 of scope 2 + cx.dispatch_action(SelectNext); + cx.run_until_parked(); + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.assert_visual_entries( + vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2", + " > variable2", + "v Scope 2", + " > variable3 <=== selected", + ], + cx, + ); + }); + }); + + // select scope 2 + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.assert_visual_entries( + vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2", + " > variable2", + "v Scope 2 <=== selected", + " > variable3", + ], + cx, + ); + }); + }); + + // collapse variables of scope 2 + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.assert_visual_entries( + vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2", + " > variable2", + "> Scope 2 <=== selected", + ], + cx, + ); + }); + }); + + // select variable 2 of scope 1 + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.assert_visual_entries( + vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2", + " > variable2 <=== selected", + "> Scope 2", + ], + cx, + ); + }); + }); + + // select nested2 of variable 1 + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.assert_visual_entries( + vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2 <=== selected", + " > variable2", + "> Scope 2", + ], + cx, + ); + }); + }); + + // select nested1 of variable 1 + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.assert_visual_entries( + vec![ + "v Scope 1", + " v variable1", + " > nested1 <=== selected", + " > nested2", + " > variable2", + "> Scope 2", + ], + cx, + ); + }); + }); + + // select variable 1 of scope 1 + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.assert_visual_entries( + vec![ + "v Scope 1", + " v variable1 <=== selected", + " > nested1", + " > nested2", + " > variable2", + "> Scope 2", + ], + cx, + ); + }); + }); + + // collapse variables of variable 1 + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.assert_visual_entries( + vec![ + "v Scope 1", + " > variable1 <=== selected", + " > variable2", + "> Scope 2", + ], + cx, + ); + }); + }); + + // select scope 1 + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list.assert_visual_entries( + vec![ + "v Scope 1 <=== selected", + " > variable1", + " > variable2", + "> Scope 2", + ], + cx, + ); + }); + }); + + // collapse variables of scope 1 + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, cx| { + variable_list + .assert_visual_entries(vec!["> Scope 1 <=== selected", "> Scope 2"], cx); + }); + }); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + +#[gpui::test] +async fn test_it_only_fetches_scopes_and_variables_for_the_first_stack_frame( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + import { SOME_VALUE } './module.js'; + + console.log(SOME_VALUE); + "# + .unindent(); + + let module_file_content = r#" + export SOME_VALUE = 'some value'; + "# + .unindent(); + + fs.insert_tree( + "/project", + json!({ + "src": { + "test.js": test_file_content, + "module.js": module_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + let stack_frames = vec![ + StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some("/project/src/test.js".into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 3, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + StackFrame { + id: 2, + name: "Stack Frame 2".into(), + source: Some(dap::Source { + name: Some("module.js".into()), + path: Some("/project/src/module.js".into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + ]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + let frame_1_scopes = vec![Scope { + name: "Frame 1 Scope 1".into(), + presentation_hint: None, + variables_reference: 2, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }]; + + client + .on_request::({ + let frame_1_scopes = Arc::new(frame_1_scopes.clone()); + move |_, args| { + assert_eq!(1, args.frame_id); + + Ok(dap::ScopesResponse { + scopes: (*frame_1_scopes).clone(), + }) + } + }) + .await; + + let frame_1_variables = vec![ + Variable { + name: "variable1".into(), + value: "value 1".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + Variable { + name: "variable2".into(), + value: "value 2".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + ]; + + client + .on_request::({ + let frame_1_variables = Arc::new(frame_1_variables.clone()); + move |_, args| { + assert_eq!(2, args.variables_reference); + + Ok(dap::VariablesResponse { + variables: (*frame_1_variables).clone(), + }) + } + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + let (stack_frame_list, stack_frame_id) = + debug_panel_item.stack_frame_list().update(cx, |list, cx| { + ( + list.stack_frames(cx) + .into_iter() + .map(|frame| frame.dap) + .collect::>(), + list.current_stack_frame_id(), + ) + }); + + assert_eq!(1, stack_frame_id); + assert_eq!(stack_frames, stack_frame_list); + + let variable_list = debug_panel_item.variable_list().read(cx); + + assert_eq!( + frame_1_variables + .clone() + .into_iter() + .map(|variable| VariableContainer { + container_reference: 2, + variable, + depth: 1 + }) + .collect::>(), + variable_list.variables_by_stack_frame_id(1) + ); + assert!(variable_list.variables_by_stack_frame_id(2).is_empty()); + }); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + +#[gpui::test] +async fn test_it_fetches_scopes_variables_when_you_select_a_stack_frame( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + import { SOME_VALUE } './module.js'; + + console.log(SOME_VALUE); + "# + .unindent(); + + let module_file_content = r#" + export SOME_VALUE = 'some value'; + "# + .unindent(); + + fs.insert_tree( + "/project", + json!({ + "src": { + "test.js": test_file_content, + "module.js": module_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(), cx) + }); + + let (session, client) = task.await.unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + let stack_frames = vec![ + StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some("/project/src/test.js".into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 3, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + StackFrame { + id: 2, + name: "Stack Frame 2".into(), + source: Some(dap::Source { + name: Some("module.js".into()), + path: Some("/project/src/module.js".into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + ]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + let frame_1_scopes = vec![Scope { + name: "Frame 1 Scope 1".into(), + presentation_hint: None, + variables_reference: 2, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }]; + + client + .on_request::({ + let frame_1_scopes = Arc::new(frame_1_scopes.clone()); + move |_, args| { + assert_eq!(1, args.frame_id); + + Ok(dap::ScopesResponse { + scopes: (*frame_1_scopes).clone(), + }) + } + }) + .await; + + let frame_1_variables = vec![ + Variable { + name: "variable1".into(), + value: "value 1".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + Variable { + name: "variable2".into(), + value: "value 2".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + ]; + + client + .on_request::({ + let frame_1_variables = Arc::new(frame_1_variables.clone()); + move |_, args| { + assert_eq!(2, args.variables_reference); + + Ok(dap::VariablesResponse { + variables: (*frame_1_variables).clone(), + }) + } + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + let (stack_frame_list, stack_frame_id) = + debug_panel_item.stack_frame_list().update(cx, |list, cx| { + ( + list.stack_frames(cx) + .into_iter() + .map(|frame| frame.dap) + .collect::>(), + list.current_stack_frame_id(), + ) + }); + + let variable_list = debug_panel_item.variable_list().read(cx); + + assert_eq!(1, stack_frame_id); + assert_eq!(stack_frames, stack_frame_list); + + assert_eq!( + frame_1_variables + .clone() + .into_iter() + .map(|variable| VariableContainer { + container_reference: 2, + variable, + depth: 1 + }) + .collect::>(), + variable_list.variables_by_stack_frame_id(1) + ); + assert!(variable_list.variables_by_stack_frame_id(2).is_empty()); + }); + + // add handlers for fetching the second stack frame's scopes and variables + // after the user clicked the stack frame + + let frame_2_scopes = vec![Scope { + name: "Frame 2 Scope 1".into(), + presentation_hint: None, + variables_reference: 3, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }]; + + client + .on_request::({ + let frame_2_scopes = Arc::new(frame_2_scopes.clone()); + move |_, args| { + assert_eq!(2, args.frame_id); + + Ok(dap::ScopesResponse { + scopes: (*frame_2_scopes).clone(), + }) + } + }) + .await; + + let frame_2_variables = vec![ + Variable { + name: "variable3".into(), + value: "old value 1".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + Variable { + name: "variable4".into(), + value: "old value 2".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + }, + ]; + + client + .on_request::({ + let frame_2_variables = Arc::new(frame_2_variables.clone()); + move |_, args| { + assert_eq!(3, args.variables_reference); + + Ok(dap::VariablesResponse { + variables: (*frame_2_variables).clone(), + }) + } + }) + .await; + + active_debug_panel_item(workspace, cx) + .update_in(cx, |debug_panel_item, window, cx| { + debug_panel_item + .stack_frame_list() + .update(cx, |stack_frame_list, cx| { + stack_frame_list.select_stack_frame(&stack_frames[1], true, window, cx) + }) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + active_debug_panel_item(workspace, cx).update(cx, |debug_panel_item, cx| { + let (stack_frame_list, stack_frame_id) = + debug_panel_item.stack_frame_list().update(cx, |list, cx| { + ( + list.stack_frames(cx) + .into_iter() + .map(|frame| frame.dap) + .collect::>(), + list.current_stack_frame_id(), + ) + }); + + let variable_list = debug_panel_item.variable_list().read(cx); + + assert_eq!(2, stack_frame_id); + assert_eq!(stack_frames, stack_frame_list); + + assert_eq!( + frame_1_variables + .into_iter() + .map(|variable| VariableContainer { + container_reference: 2, + variable, + depth: 1 + }) + .collect::>(), + variable_list.variables_by_stack_frame_id(1) + ); + assert_eq!( + frame_2_variables + .into_iter() + .map(|variable| VariableContainer { + container_reference: 3, + variable, + depth: 1 + }) + .collect::>(), + variable_list.variables_by_stack_frame_id(2) + ); + }); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(&session.read(cx).id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 08d626c25fa7f6..46a4364d7131fe 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -55,6 +55,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 99b07f0a494aff..ca66f7bf734eaa 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -396,6 +396,8 @@ gpui::actions!( SwitchSourceHeader, Tab, TabPrev, + ToggleBreakpoint, + EditLogBreakpoint, ToggleAutoSignatureHelp, ToggleGitBlame, ToggleGitBlameInline, diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 5f86b468439f3a..3680169aea7ddc 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -811,6 +811,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, bias); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a3a4dfd6bbdb5c..cee3968e49137e 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -82,7 +82,7 @@ use git::blame::GitBlame; use gpui::{ div, impl_actions, point, prelude::*, pulsating_between, px, relative, size, Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, AvailableSpace, Background, Bounds, - ClipboardEntry, ClipboardItem, Context, DispatchPhase, Entity, EntityInputHandler, + ClickEvent, ClipboardEntry, ClipboardItem, Context, DispatchPhase, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent, Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla, KeyContext, Modifiers, MouseButton, MouseDownEvent, PaintQuad, ParentElement, Pixels, Render, SharedString, Size, Styled, StyledText, Subscription, Task, @@ -90,6 +90,7 @@ use gpui::{ WeakEntity, WeakFocusHandle, Window, }; use highlight_matching_bracket::refresh_matching_bracket_highlights; +use hover_links::{find_file, HoverLink, HoveredLinkState, InlayHighlight}; use hover_popover::{hide_hover, HoverState}; use indent_guides::ActiveIndentGuidesState; use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy}; @@ -110,13 +111,17 @@ use language::{point_to_lsp, BufferRow, CharClassifier, Runnable, RunnableRange} use linked_editing_ranges::refresh_linked_ranges; use mouse_context_menu::MouseContextMenu; use persistence::DB; +use project::{ + debugger::breakpoint_store::{BreakpointEditAction, BreakpointStore, BreakpointStoreEvent}, + ProjectPath, +}; + pub use proposed_changes_editor::{ ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar, }; use std::iter::Peekable; use task::{ResolvedTask, TaskTemplate, TaskVariables}; -use hover_links::{find_file, HoverLink, HoveredLinkState, InlayHighlight}; pub use lsp::CompletionContext; use lsp::{ CompletionItemKind, CompletionTriggerKind, DiagnosticSeverity, InsertTextFormat, @@ -133,7 +138,12 @@ use multi_buffer::{ ExcerptInfo, ExpandExcerptDirection, MultiBufferDiffHunk, MultiBufferPoint, MultiBufferRow, ToOffsetUtf16, }; +use parking_lot::Mutex; use project::{ + debugger::{ + breakpoint_store::{Breakpoint, BreakpointKind}, + dap_store::DapStore, + }, lsp_store::{CompletionDocumentation, FormatTrigger, LspFormatTarget, OpenLspBufferHandle}, project_settings::{GitGutterSetting, ProjectSettings}, CodeAction, Completion, CompletionIntent, DocumentHighlight, InlayHint, Location, LocationLink, @@ -149,6 +159,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, @@ -159,7 +170,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; @@ -231,6 +241,7 @@ impl InlayId { } } +pub enum DebugCurrentRowHighlight {} enum DocumentHighlightRead {} enum DocumentHighlightWrite {} enum InputComposition {} @@ -550,6 +561,7 @@ struct ResolvedTasks { templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>, position: Anchor, } + #[derive(Copy, Clone, Debug)] struct MultiBufferOffset(usize); #[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] @@ -717,6 +729,12 @@ pub struct Editor { expect_bounds_change: Option>, tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>, tasks_update_task: Option>, + pub dap_store: Option>, + pub breakpoint_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, in_project_search: bool, previous_search_ranges: Option]>>, breadcrumb_header: Option, @@ -1246,6 +1264,8 @@ impl Editor { } } } + } else if let project::Event::ActiveDebugLineChanged = event { + editor.go_to_active_debug_line(window, cx); } }, )); @@ -1287,6 +1307,15 @@ impl Editor { None }; + let (dap_store, breakpoint_store) = match (mode, project.as_ref()) { + (EditorMode::Full, Some(project)) => { + let dap_store = project.read(cx).dap_store(); + let breakpoint_store = project.read(cx).breakpoint_store(); + (Some(dap_store), Some(breakpoint_store)) + } + _ => (None, None), + }; + let mut code_action_providers = Vec::new(); let mut load_uncommitted_diff = None; if let Some(project) = project.clone() { @@ -1421,6 +1450,9 @@ impl Editor { blame: None, blame_subscription: None, tasks: Default::default(), + dap_store, + breakpoint_store, + gutter_breakpoint_indicator: None, _subscriptions: vec![ cx.observe(&buffer, Self::on_buffer_changed), cx.subscribe_in(&buffer, window, Self::on_buffer_event), @@ -1474,6 +1506,8 @@ impl Editor { this.start_git_blame_inline(false, window, cx); } + this.go_to_active_debug_line(window, cx); + if let Some(buffer) = buffer.read(cx).as_singleton() { if let Some(project) = this.project.as_ref() { let handle = project.update(cx, |project, cx| { @@ -5571,14 +5605,28 @@ impl Editor { _style: &EditorStyle, row: DisplayRow, is_active: bool, + breakpoint: Option<&Breakpoint>, cx: &mut Context, ) -> Option { + let color = if breakpoint.is_some() { + Color::Debugger + } else { + Color::Muted + }; + + let position = breakpoint.as_ref().and_then(|bp| bp.active_position); + let bp_kind = Arc::new( + breakpoint + .map(|bp| bp.kind.clone()) + .unwrap_or(BreakpointKind::Standard), + ); + if self.available_code_actions.is_some() { Some( IconButton::new("code_actions_indicator", ui::IconName::Bolt) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .icon_color(color) .toggle_state(is_active) .tooltip({ let focus_handle = self.focus_handle.clone(); @@ -5603,6 +5651,16 @@ impl Editor { window, cx, ); + })) + .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { + editor.set_breakpoint_context_menu( + row, + position, + bp_kind.clone(), + event.down.position, + window, + cx, + ); })), ) } else { @@ -5621,6 +5679,193 @@ 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, + window: &mut Window, + cx: &mut Context, + ) -> 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(window, cx); + + let breakpoints = &dap_store.read(cx).breakpoint_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) = breakpoints.get(&project_path) { + for breakpoint in breakpoints { + let point = breakpoint.point_for_buffer(&buffer.text_snapshot()); + + 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(breakpoint_set) = breakpoints.get(&project_path) { + for breakpoint in breakpoint_set { + 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 breakpoint_context_menu( + &self, + anchor: text::Anchor, + kind: Arc, + row: DisplayRow, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let weak_editor = cx.weak_entity(); + let focus_handle = self.focus_handle(cx); + + let second_entry_msg = if kind.log_message().is_some() { + "Edit Log Breakpoint" + } else { + "Add Log Breakpoint" + }; + + ui::ContextMenu::build(window, cx, |menu, _, _cx| { + menu.on_blur_subscription(Subscription::new(|| {})) + .context(focus_handle) + .entry("Toggle Breakpoint", None, { + let weak_editor = weak_editor.clone(); + move |_window, cx| { + weak_editor + .update(cx, |this, cx| { + this.edit_breakpoint_at_anchor( + anchor, + BreakpointKind::Standard, + BreakpointEditAction::Toggle, + cx, + ); + }) + .log_err(); + } + }) + .entry(second_entry_msg, None, move |window, cx| { + weak_editor + .update(cx, |this, cx| { + this.add_edit_breakpoint_block(row, anchor, kind.as_ref(), window, cx); + }) + .log_err(); + }) + }) + } + + fn render_breakpoint( + &self, + position: text::Anchor, + row: DisplayRow, + kind: &BreakpointKind, + cx: &mut Context, + ) -> 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, window, cx| { + window.focus(&editor.focus_handle(cx)); + editor.edit_breakpoint_at_anchor( + position, + arc_kind.as_ref().clone(), + BreakpointEditAction::Toggle, + cx, + ); + })) + .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { + editor.set_breakpoint_context_menu( + row, + Some(position), + arc_kind2.clone(), + event.down.position, + window, + cx, + ); + })) + } + fn build_tasks_context( project: &Entity, buffer: &Entity, @@ -5757,12 +6002,26 @@ impl Editor { _style: &EditorStyle, is_active: bool, row: DisplayRow, + breakpoint: Option, cx: &mut Context, ) -> IconButton { + let color = if breakpoint.is_some() { + Color::Debugger + } else { + Color::Muted + }; + + let position = breakpoint.as_ref().and_then(|bp| bp.active_position); + let bp_kind = Arc::new( + breakpoint + .map(|bp| bp.kind) + .unwrap_or(BreakpointKind::Standard), + ); + 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) .toggle_state(is_active) .on_click(cx.listener(move |editor, _e, window, cx| { window.focus(&editor.focus_handle(cx)); @@ -5774,6 +6033,16 @@ impl Editor { cx, ); })) + .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { + editor.set_breakpoint_context_menu( + row, + position, + bp_kind.clone(), + event.down.position, + window, + cx, + ); + })) } pub fn context_menu_visible(&self) -> bool { @@ -7130,6 +7399,239 @@ impl Editor { } } + fn set_breakpoint_context_menu( + &mut self, + row: DisplayRow, + position: Option, + kind: Arc, + clicked_point: gpui::Point, + window: &mut Window, + cx: &mut Context, + ) { + let source = self + .buffer + .read(cx) + .snapshot(cx) + .breakpoint_anchor(Point::new(row.0, 0u32)); + + let context_menu = self.breakpoint_context_menu( + position.unwrap_or(source.text_anchor), + kind, + row, + window, + cx, + ); + + self.mouse_context_menu = MouseContextMenu::pinned_to_editor( + self, + source, + clicked_point, + context_menu, + window, + cx, + ); + } + + fn add_edit_breakpoint_block( + &mut self, + row: DisplayRow, + anchor: text::Anchor, + kind: &BreakpointKind, + window: &mut Window, + cx: &mut Context, + ) { + let position = self + .snapshot(window, cx) + .display_point_to_anchor(DisplayPoint::new(row, 0), Bias::Right); + + let weak_editor = cx.weak_entity(); + let bp_prompt = + cx.new(|cx| BreakpointPromptEditor::new(weak_editor, anchor, kind.clone(), window, 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, + placement: BlockPlacement::Above(position), + height, + render: Arc::new(move |cx| { + *cloned_prompt.read(cx).gutter_dimensions.lock() = *cx.gutter_dimensions; + cloned_prompt.clone().into_any_element() + }), + priority: 0, + }]; + + let focus_handle = bp_prompt.focus_handle(cx); + window.focus(&focus_handle); + + let block_ids = self.insert_blocks(blocks, None, cx); + bp_prompt.update(cx, |prompt, _| { + prompt.add_block_ids(block_ids); + }); + } + + pub(crate) fn breakpoint_at_cursor_head( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option<(text::Anchor, BreakpointKind)> { + 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(window, cx) + .display_snapshot + .buffer_snapshot + .breakpoint_anchor(Point::new(cursor_position.row, 0)) + .text_anchor; + + let project = self.project.clone(); + + 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, |dap_store, cx| { + dap_store.breakpoint_store().read(cx).breakpoint_at_row( + row, + &project_path, + buffer_snapshot, + ) + })?; + + Some((bp.active_position?, bp.kind)) + } + + pub fn edit_log_breakpoint( + &mut self, + _: &EditLogBreakpoint, + window: &mut Window, + cx: &mut Context, + ) { + let (anchor, kind) = self + .breakpoint_at_cursor_head(window, cx) + .unwrap_or_else(|| { + let cursor_position: Point = self.selections.newest(cx).head(); + + let breakpoint_position = self + .snapshot(window, cx) + .display_snapshot + .buffer_snapshot + .breakpoint_anchor(Point::new(cursor_position.row, 0)) + .text_anchor; + + let kind = BreakpointKind::Standard; + + (breakpoint_position, kind) + }); + + if let Some(buffer) = self + .buffer() + .read(cx) + .as_singleton() + .map(|buffer| buffer.read(cx)) + { + let row = buffer + .summary_for_anchor::(&anchor) + .to_display_point(&self.snapshot(window, cx)) + .row(); + + self.add_edit_breakpoint_block(row, anchor, &kind, window, cx); + } + } + + pub fn toggle_breakpoint( + &mut self, + _: &ToggleBreakpoint, + window: &mut Window, + cx: &mut Context, + ) { + let edit_action = BreakpointEditAction::Toggle; + + if let Some((anchor, kind)) = self.breakpoint_at_cursor_head(window, cx) { + self.edit_breakpoint_at_anchor(anchor, kind, edit_action, cx); + } else { + let cursor_position: Point = self.selections.newest(cx).head(); + + let breakpoint_position = self + .snapshot(window, cx) + .display_snapshot + .buffer_snapshot + .breakpoint_anchor(Point::new(cursor_position.row, 0)) + .text_anchor; + + 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 Context, + ) { + let Some(breakpoint_store) = &self.breakpoint_store else { + 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).and_then(|buffer| { + NonZeroU32::new( + buffer + .read(cx) + .summary_for_anchor::(&breakpoint_position) + .row + + 1, + ) + }) + }) else { + return; + }; + + breakpoint_store.update(cx, |breakpoint_store, cx| { + breakpoint_store.toggle_breakpoint( + buffer_id, + Breakpoint { + cached_position: cache_position, + active_position: Some(breakpoint_position), + kind, + }, + edit_action, + cx, + ); + }); + + cx.notify(); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn breakpoint_store(&self) -> Option> { + self.breakpoint_store.clone() + } + pub fn prepare_restore_change( &self, revert_changes: &mut HashMap, Rope)>>, @@ -10812,6 +11314,32 @@ impl Editor { hunk } + pub fn go_to_line( + &mut self, + row: u32, + highlight_color: Option, + window: &mut Window, + cx: &mut Context, + ) { + let snapshot = self.snapshot(window, cx).display_snapshot; + let start = snapshot + .buffer_snapshot + .clip_point(Point::new(row, 0), 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, @@ -13498,6 +14026,40 @@ impl Editor { } } + pub fn project_path(&self, cx: &mut Context) -> Option { + if let Some(buffer) = self.buffer.read(cx).as_singleton() { + buffer.read_with(cx, |buffer, cx| buffer.project_path(cx)) + } else { + None + } + } + + pub fn go_to_active_debug_line(&mut self, window: &mut Window, cx: &mut Context) { + let Some(dap_store) = self.dap_store.as_ref() else { + return; + }; + + let Some(project_path) = self.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, + Some(cx.theme().colors().editor_debugger_active_line_background), + window, + cx, + ); + + return; + } + } + + self.clear_row_highlights::(); + cx.notify(); + } + pub fn copy_file_name_without_extension( &mut self, _: &CopyFileNameWithoutExtension, @@ -14565,9 +15127,23 @@ impl Editor { } multi_buffer::Event::DirtyChanged => cx.emit(EditorEvent::DirtyChanged), multi_buffer::Event::Saved => cx.emit(EditorEvent::Saved), - multi_buffer::Event::FileHandleChanged | multi_buffer::Event::Reloaded => { - cx.emit(EditorEvent::TitleChanged) + multi_buffer::Event::FileHandleChanged => { + cx.emit(EditorEvent::TitleChanged); + + if let Some(dap_store) = &self.dap_store { + if let Some(project_path) = self.project_path(cx) { + dap_store.update(cx, |dap_store, cx| { + dap_store.breakpoint_store().update(cx, |_, cx| { + cx.emit(BreakpointStoreEvent::BreakpointsChanged { + project_path, + source_changed: true, + }); + }); + }); + } + } } + multi_buffer::Event::Reloaded => cx.emit(EditorEvent::TitleChanged), // multi_buffer::Event::DiffBaseChanged => { // self.scrollbar_marker_state.dirty = true; // cx.emit(EditorEvent::DiffBaseChanged); @@ -17407,6 +17983,158 @@ impl Global for KillRing {} const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); +struct BreakpointPromptEditor { + pub(crate) prompt: Entity, + editor: WeakEntity, + breakpoint_anchor: text::Anchor, + kind: BreakpointKind, + block_ids: HashSet, + gutter_dimensions: Arc>, + _subscriptions: Vec, +} + +impl BreakpointPromptEditor { + const MAX_LINES: u8 = 4; + + fn new( + editor: WeakEntity, + breakpoint_anchor: text::Anchor, + kind: BreakpointKind, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let buffer = cx.new(|cx| { + Buffer::local( + kind.log_message() + .map(|msg| msg.to_string()) + .unwrap_or_default(), + cx, + ) + }); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + + let prompt = cx.new(|cx| { + let mut prompt = Editor::new( + EditorMode::AutoHeight { + max_lines: Self::MAX_LINES as usize, + }, + buffer, + None, + false, + window, + cx, + ); + prompt.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); + prompt.set_show_cursor_when_unfocused(false, cx); + prompt.set_placeholder_text( + "Message to log when breakpoint is hit. Expressions within {} are interpolated.", + cx, + ); + + prompt + }); + + Self { + prompt, + editor, + breakpoint_anchor, + kind, + 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, window: &mut Window, cx: &mut Context) { + 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, + self.kind.clone(), + BreakpointEditAction::EditLogMessage(log_message.into()), + cx, + ); + + editor.remove_blocks(self.block_ids.clone(), None, cx); + cx.focus_self(window); + }); + } + } + + fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { + self.editor + .update(cx, |editor, cx| { + editor.remove_blocks(self.block_ids.clone(), None, cx); + window.focus(&editor.focus_handle); + }) + .log_err(); + } + + fn render_prompt_editor(&self, cx: &mut Context) -> 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, window: &mut Window, cx: &mut Context) -> 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(window.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 Focusable for BreakpointPromptEditor { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.prompt.focus_handle(cx) + } +} + fn all_edits_insertions_or_deletions( edits: &Vec<(Range, String)>, snapshot: &MultiBufferSnapshot, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 030a07d3f20af4..b28e883f377bad 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -27,8 +27,11 @@ use language_settings::{Formatter, FormatterList, IndentGuideSettings}; use multi_buffer::IndentGuide; use parking_lot::Mutex; use pretty_assertions::{assert_eq, assert_ne}; -use project::project_settings::{LspSettings, ProjectSettings}; -use project::FakeFs; +use project::{ + debugger::breakpoint_store::BreakpointKind, + project_settings::{LspSettings, ProjectSettings}, + FakeFs, +}; use serde_json::{self, json}; use std::{cell::RefCell, future::Future, rc::Rc, time::Instant}; use std::{ @@ -11196,6 +11199,7 @@ async fn test_move_to_enclosing_bracket(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.move_to_enclosing_bracket(&MoveToEnclosingBracket, window, cx) }); + cx.run_until_parked(); cx.assert_editor_state(after); }; @@ -15829,6 +15833,348 @@ async fn assert_highlighted_edits( }); } +#[track_caller] +fn assert_breakpoint( + breakpoints: &BTreeMap>, + project_path: &ProjectPath, + expected: Vec<(u32, BreakpointKind)>, +) { + if expected.len() == 0usize { + assert!(!breakpoints.contains_key(project_path)); + } else { + let mut breakpoint = breakpoints + .get(project_path) + .unwrap() + .into_iter() + .map(|breakpoint| (breakpoint.cached_position, breakpoint.kind.clone())) + .collect::>(); + + breakpoint.sort_by_key(|(cached_position, _)| *cached_position); + + assert_eq!(expected, breakpoint); + } +} + +fn add_log_breakpoint_at_cursor( + editor: &mut Editor, + log_message: &str, + window: &mut Window, + cx: &mut Context, +) { + let (anchor, kind) = editor + .breakpoint_at_cursor_head(window, cx) + .unwrap_or_else(|| { + let cursor_position: Point = editor.selections.newest(cx).head(); + + let breakpoint_position = editor + .snapshot(window, cx) + .display_snapshot + .buffer_snapshot + .breakpoint_anchor(Point::new(cursor_position.row, 0)) + .text_anchor; + + let kind = BreakpointKind::Standard; + + (breakpoint_position, kind) + }); + + editor.edit_breakpoint_at_anchor( + anchor, + kind, + BreakpointEditAction::EditLogMessage(log_message.into()), + cx, + ); +} + +#[gpui::test] +async fn test_breakpoint_toggling(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let sample_text = "First line\nSecond line\nThird line\nFourth line".to_string(); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/a", + json!({ + "main.rs": sample_text, + }), + ) + .await; + let project = Project::test(fs, ["/a".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/a", + json!({ + "main.rs": sample_text, + }), + ) + .await; + let project = Project::test(fs, ["/a".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let worktree_id = workspace + .update(cx, |workspace, _window, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }) + .unwrap(); + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, "main.rs"), cx) + }) + .await + .unwrap(); + + let (editor, cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::Full, + MultiBuffer::build_from_buffer(buffer, cx), + Some(project), + true, + window, + cx, + ) + }); + + let project_path = editor.update(cx, |editor, cx| editor.project_path(cx).unwrap()); + + // assert we can add breakpoint on the first line + editor.update_in(cx, |editor, window, cx| { + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + editor.move_to_end(&MoveToEnd, window, cx); + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .project + .as_ref() + .unwrap() + .read(cx) + .breakpoint_store() + .read(cx) + .breakpoints() + .clone() + }); + + assert_eq!(1, breakpoints.len()); + assert_breakpoint( + &breakpoints, + &project_path, + vec![(0, BreakpointKind::Standard), (3, BreakpointKind::Standard)], + ); + + editor.update_in(cx, |editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .project + .as_ref() + .unwrap() + .read(cx) + .breakpoint_store() + .read(cx) + .breakpoints() + .clone() + }); + + assert_eq!(1, breakpoints.len()); + assert_breakpoint( + &breakpoints, + &project_path, + vec![(3, BreakpointKind::Standard)], + ); + + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end(&MoveToEnd, window, cx); + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .project + .as_ref() + .unwrap() + .read(cx) + .breakpoint_store() + .read(cx) + .breakpoints() + .clone() + }); + + assert_eq!(0, breakpoints.len()); + assert_breakpoint(&breakpoints, &project_path, vec![]); +} + +#[gpui::test] +async fn test_log_breakpoint_editing(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let sample_text = "First line\nSecond line\nThird line\nFourth line".to_string(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/a", + json!({ + "main.rs": sample_text, + }), + ) + .await; + let project = Project::test(fs, ["/a".as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, "main.rs"), cx) + }) + .await + .unwrap(); + + let (editor, cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::Full, + MultiBuffer::build_from_buffer(buffer, cx), + Some(project), + true, + window, + cx, + ) + }); + + let project_path = editor.update(cx, |editor, cx| editor.project_path(cx).unwrap()); + + editor.update_in(cx, |editor, window, cx| { + add_log_breakpoint_at_cursor(editor, "hello world", window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .project + .as_ref() + .unwrap() + .read(cx) + .breakpoint_store() + .read(cx) + .breakpoints() + .clone() + }); + + assert_breakpoint( + &breakpoints, + &project_path, + vec![(0, BreakpointKind::Log("hello world".into()))], + ); + + // Removing a log message from a log breakpoint should remove it + editor.update_in(cx, |editor, window, cx| { + add_log_breakpoint_at_cursor(editor, "", window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .project + .as_ref() + .unwrap() + .read(cx) + .breakpoint_store() + .read(cx) + .breakpoints() + .clone() + }); + + assert_breakpoint(&breakpoints, &project_path, vec![]); + + editor.update_in(cx, |editor, window, cx| { + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + editor.move_to_end(&MoveToEnd, window, cx); + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + // Not adding a log message to a standard breakpoint shouldn't remove it + add_log_breakpoint_at_cursor(editor, "", window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .project + .as_ref() + .unwrap() + .read(cx) + .breakpoint_store() + .read(cx) + .breakpoints() + .clone() + }); + + assert_breakpoint( + &breakpoints, + &project_path, + vec![(0, BreakpointKind::Standard), (3, BreakpointKind::Standard)], + ); + + editor.update_in(cx, |editor, window, cx| { + add_log_breakpoint_at_cursor(editor, "hello world", window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .project + .as_ref() + .unwrap() + .read(cx) + .breakpoint_store() + .read(cx) + .breakpoints() + .clone() + }); + + assert_breakpoint( + &breakpoints, + &project_path, + vec![ + (0, BreakpointKind::Standard), + (3, BreakpointKind::Log("hello world".into())), + ], + ); + + editor.update_in(cx, |editor, window, cx| { + add_log_breakpoint_at_cursor(editor, "hello Earth!!", window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .project + .as_ref() + .unwrap() + .read(cx) + .breakpoint_store() + .read(cx) + .breakpoints() + .clone() + }); + + assert_breakpoint( + &breakpoints, + &project_path, + vec![ + (0, BreakpointKind::Standard), + (3, BreakpointKind::Log("hello Earth !!".into())), + ], + ); +} + #[gpui::test] async fn test_rename_with_duplicate_edits(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index c8e609b5a89f20..e47016487cfb49 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -52,7 +52,10 @@ use multi_buffer::{ Anchor, ExcerptId, ExcerptInfo, ExpandExcerptDirection, MultiBufferPoint, MultiBufferRow, RowInfo, ToOffset, }; -use project::project_settings::{self, GitGutterSetting, ProjectSettings}; +use project::{ + debugger::breakpoint_store::{Breakpoint, BreakpointKind}, + project_settings::{self,GitGutterSetting, ProjectSettings}, +}; use settings::Settings; use smallvec::{smallvec, SmallVec}; use std::{ @@ -61,6 +64,7 @@ use std::{ cmp::{self, Ordering}, fmt::{self, Write}, iter, mem, + num::NonZeroU32, ops::{Deref, Range}, rc::Rc, sync::Arc, @@ -507,6 +511,8 @@ impl EditorElement { register_action(editor, window, Editor::insert_uuid_v4); register_action(editor, window, Editor::insert_uuid_v7); register_action(editor, window, Editor::open_selections_in_multibuffer); + register_action(editor, window, Editor::toggle_breakpoint); + register_action(editor, window, Editor::edit_log_breakpoint); } fn register_key_listeners(&self, window: &mut Window, _: &mut App, layout: &EditorLayout) { @@ -855,6 +861,18 @@ impl EditorElement { editor.set_gutter_hovered(gutter_hovered, cx); editor.mouse_cursor_hidden = false; + if gutter_hovered { + editor.gutter_breakpoint_indicator = Some( + position_map + .point_for_position(event.position) + .previous_valid, + ); + } 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(window) { let point_for_position = position_map.point_for_position(event.position); @@ -2033,6 +2051,66 @@ 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, + window: &mut Window, + cx: &mut App, + ) -> 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, + window, + cx, + ); + Some(button) + }) + .collect_vec() + }) + } + #[allow(clippy::too_many_arguments)] fn layout_run_indicators( &self, @@ -2043,6 +2121,7 @@ impl EditorElement { gutter_hitbox: &Hitbox, rows_with_hunk_bounds: &HashMap>, snapshot: &EditorSnapshot, + breakpoints: &mut HashMap, window: &mut Window, cx: &mut App, ) -> Vec { @@ -2101,11 +2180,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), cx, ); @@ -2135,6 +2216,7 @@ impl EditorElement { gutter_dimensions: &GutterDimensions, gutter_hitbox: &Hitbox, rows_with_hunk_bounds: &HashMap>, + breakpoint_points: &mut HashMap, window: &mut Window, cx: &mut App, ) -> Option { @@ -2149,11 +2231,16 @@ impl EditorElement { { active = deployed_from_indicator.map_or(true, |indicator_row| indicator_row == row); }; - button = editor.render_code_actions_indicator(&self.style, row, active, cx); + + let breakpoint = breakpoint_points.get(&row); + button = editor.render_code_actions_indicator(&self.style, row, active, breakpoint, cx); }); + let button = button?; + breakpoint_points.remove(&row); + let button = prepaint_gutter_button( - button?, + button, row, line_height, gutter_dimensions, @@ -2234,8 +2321,10 @@ impl EditorElement { scroll_position: gpui::Point, rows: Range, buffer_rows: &[RowInfo], + active_rows: &BTreeMap, newest_selection_head: Option, snapshot: &EditorSnapshot, + breakpoint_rows: &HashMap, window: &mut Window, cx: &mut App, ) -> Arc> { @@ -2289,7 +2378,13 @@ impl EditorElement { return None; } - let color = cx.theme().colors().editor_line_number; + 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 + }; let shaped_line = self .shape_line_number(SharedString::from(&line_number), color, window) .log_err()?; @@ -2320,7 +2415,6 @@ impl EditorElement { let line_number = LineNumberLayout { shaped_line, hitbox, - display_row, }; Some((multi_buffer_row, line_number)) }) @@ -4677,32 +4771,31 @@ impl EditorElement { for LineNumberLayout { shaped_line, hitbox, - display_row, } in layout.line_numbers.values() { let Some(hitbox) = hitbox else { continue; }; - let is_active = layout.active_rows.contains_key(&display_row); + let Some(()) = (if !is_singleton && hitbox.is_hovered(window) { + let color = cx.theme().colors().editor_hover_line_number; - let color = if is_active { - cx.theme().colors().editor_active_line_number - } else if !is_singleton && hitbox.is_hovered(window) { - cx.theme().colors().editor_hover_line_number - } else { - cx.theme().colors().editor_line_number - }; + let Some(line) = self + .shape_line_number(shaped_line.text.clone(), color, window) + .log_err() + else { + continue; + }; - let Some(line) = self - .shape_line_number(shaped_line.text.clone(), color, window) - .log_err() - else { - continue; - }; - let Some(()) = line.paint(hitbox.origin, line_height, window, cx).log_err() else { + line.paint(hitbox.origin, line_height, window, cx).log_err() + } else { + shaped_line + .paint(hitbox.origin, line_height, window, cx) + .log_err() + }) else { continue; }; + // In singleton buffers, we select corresponding lines on the line number click, so use | -like cursor. // In multi buffers, we open file at the line number clicked, so use a pointing hand cursor. if is_singleton { @@ -4727,7 +4820,7 @@ impl EditorElement { &layout.position_map.snapshot, line_height, layout.gutter_hitbox.bounds, - hunk, + &hunk, ); Some(( hunk_bounds, @@ -4872,6 +4965,9 @@ impl EditorElement { } }); + for breakpoint in layout.breakpoints.iter_mut() { + breakpoint.paint(window, cx); + } for test_indicator in layout.test_indicators.iter_mut() { test_indicator.paint(window, cx); } @@ -6044,6 +6140,7 @@ fn prepaint_gutter_button( cx: &mut App, ) -> AnyElement { let mut button = button.into_any_element(); + let available_space = size( AvailableSpace::MinContent, AvailableSpace::Definite(line_height), @@ -6910,6 +7007,10 @@ impl Element for EditorElement { window.set_view_id(self.editor.entity_id()); window.set_focus_handle(&focus_handle, cx); + let mut breakpoint_rows = self + .editor + .update(cx, |editor, cx| editor.active_breakpoint_points(window, cx)); + let rem_size = self.rem_size(cx); window.with_rem_size(rem_size, |window| { window.with_text_style(Some(text_style), |window| { @@ -7170,12 +7271,44 @@ impl Element for EditorElement { scroll_position, start_row..end_row, &row_infos, + &active_rows, newest_selection_head, &snapshot, + &breakpoint_rows, window, 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_with(|| { + let position = snapshot + .display_point_to_breakpoint_anchor(gutter_breakpoint_point); + let mut breakpoint = Breakpoint { + active_position: Some(position.text_anchor), + cached_position: NonZeroU32::new(u32::MAX).unwrap(), + kind: BreakpointKind::Standard, + }; + let buffer = snapshot + .buffer_snapshot + .buffer_for_excerpt(position.excerpt_id); + if let Some(buffer) = buffer { + breakpoint.cached_position = NonZeroU32::new( + breakpoint.point_for_buffer(buffer).row + 1, + ) + .unwrap(); + } + + breakpoint + }); + } + let mut crease_toggles = window.with_element_namespace("crease_toggles", |window| { self.layout_crease_toggles( @@ -7617,6 +7750,7 @@ impl Element for EditorElement { &gutter_dimensions, &gutter_hitbox, &rows_with_hunk_bounds, + &mut breakpoint_rows, window, cx, ); @@ -7646,6 +7780,7 @@ impl Element for EditorElement { &gutter_hitbox, &rows_with_hunk_bounds, &snapshot, + &mut breakpoint_rows, window, cx, ) @@ -7653,6 +7788,19 @@ impl Element for EditorElement { 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, + window, + cx, + ); + self.layout_signature_help( &hitbox, content_origin, @@ -7798,6 +7946,7 @@ impl Element for EditorElement { diff_hunk_controls, mouse_context_menu, test_indicators, + breakpoints, code_actions_indicator, crease_toggles, crease_trailers, @@ -7977,6 +8126,7 @@ pub struct EditorLayout { selections: Vec<(PlayerColor, Vec)>, code_actions_indicator: Option, test_indicators: Vec, + breakpoints: Vec, crease_toggles: Vec>, diff_hunk_controls: Vec, crease_trailers: Vec>, @@ -7996,7 +8146,6 @@ impl EditorLayout { struct LineNumberLayout { shaped_line: ShapedLine, hitbox: Option, - display_row: DisplayRow, } struct ColoredRange { @@ -8646,8 +8795,10 @@ mod tests { ..Default::default() }) .collect::>(), + &BTreeMap::default(), Some(DisplayPoint::new(DisplayRow(0), 0)), &snapshot, + &HashMap::default(), window, cx, ) diff --git a/crates/extension/src/extension_host_proxy.rs b/crates/extension/src/extension_host_proxy.rs index fee590d3456a16..513011d08314d4 100644 --- a/crates/extension/src/extension_host_proxy.rs +++ b/crates/extension/src/extension_host_proxy.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use anyhow::Result; use fs::Fs; use gpui::{App, Global, ReadGlobal, SharedString, Task}; -use language::{LanguageMatcher, LanguageName, LanguageServerBinaryStatus, LoadedLanguage}; +use language::{BinaryStatus, LanguageMatcher, LanguageName, LoadedLanguage}; use lsp::LanguageServerName; use parking_lot::RwLock; @@ -284,7 +284,7 @@ pub trait ExtensionLanguageServerProxy: Send + Sync + 'static { fn update_language_server_status( &self, language_server_id: LanguageServerName, - status: LanguageServerBinaryStatus, + status: BinaryStatus, ); } @@ -317,7 +317,7 @@ impl ExtensionLanguageServerProxy for ExtensionHostProxy { fn update_language_server_status( &self, language_server_id: LanguageServerName, - status: LanguageServerBinaryStatus, + status: BinaryStatus, ) { let Some(proxy) = self.language_server_proxy.read().clone() else { return; diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index c0ff0029b56ec8..c8a47386e0e0b8 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -8,9 +8,9 @@ use collections::BTreeMap; use extension::ExtensionHostProxy; use fs::{FakeFs, Fs, RealFs}; use futures::{io::BufReader, AsyncReadExt, StreamExt}; -use gpui::{AppContext as _, SemanticVersion, TestAppContext}; +use gpui::{AppContext as _, SemanticVersion, SharedString, TestAppContext}; use http_client::{FakeHttpClient, Response}; -use language::{LanguageMatcher, LanguageRegistry, LanguageServerBinaryStatus}; +use language::{BinaryStatus, LanguageMatcher, LanguageRegistry}; use lsp::LanguageServerName; use node_runtime::NodeRuntime; use parking_lot::Mutex; @@ -660,18 +660,9 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { status_updates.next().await.unwrap(), ], [ - ( - LanguageServerName("gleam".into()), - LanguageServerBinaryStatus::CheckingForUpdate - ), - ( - LanguageServerName("gleam".into()), - LanguageServerBinaryStatus::Downloading - ), - ( - LanguageServerName("gleam".into()), - LanguageServerBinaryStatus::None - ) + (SharedString::new("gleam"), BinaryStatus::CheckingForUpdate), + (SharedString::new("gleam"), BinaryStatus::Downloading), + (SharedString::new("gleam"), BinaryStatus::None) ] ); diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs b/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs index 1ebf3ee3a7bfcb..5210726a566ab0 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs @@ -3,7 +3,7 @@ use crate::wasm_host::wit::since_v0_0_4; use crate::wasm_host::WasmState; use anyhow::Result; use extension::{ExtensionLanguageServerProxy, WorktreeDelegate}; -use language::LanguageServerBinaryStatus; +use language::BinaryStatus; use semantic_version::SemanticVersion; use std::sync::{Arc, OnceLock}; use wasmtime::component::{Linker, Resource}; @@ -132,17 +132,11 @@ impl ExtensionImports for WasmState { status: LanguageServerInstallationStatus, ) -> wasmtime::Result<()> { let status = match status { - LanguageServerInstallationStatus::CheckingForUpdate => { - LanguageServerBinaryStatus::CheckingForUpdate - } - LanguageServerInstallationStatus::Downloading => { - LanguageServerBinaryStatus::Downloading - } + LanguageServerInstallationStatus::CheckingForUpdate => BinaryStatus::CheckingForUpdate, + LanguageServerInstallationStatus::Downloading => BinaryStatus::Downloading, LanguageServerInstallationStatus::Cached - | LanguageServerInstallationStatus::Downloaded => LanguageServerBinaryStatus::None, - LanguageServerInstallationStatus::Failed(error) => { - LanguageServerBinaryStatus::Failed { error } - } + | LanguageServerInstallationStatus::Downloaded => BinaryStatus::None, + LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error }, }; self.host diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs index 37db2197c9eccc..95c6ed0037bed8 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs @@ -8,7 +8,7 @@ use extension::{ExtensionLanguageServerProxy, KeyValueStoreDelegate, WorktreeDel use futures::{io::BufReader, FutureExt as _}; use futures::{lock::Mutex, AsyncReadExt}; use language::LanguageName; -use language::{language_settings::AllLanguageSettings, LanguageServerBinaryStatus}; +use language::{language_settings::AllLanguageSettings, BinaryStatus}; use project::project_settings::ProjectSettings; use semantic_version::SemanticVersion; use std::{ @@ -474,16 +474,10 @@ impl ExtensionImports for WasmState { status: LanguageServerInstallationStatus, ) -> wasmtime::Result<()> { let status = match status { - LanguageServerInstallationStatus::CheckingForUpdate => { - LanguageServerBinaryStatus::CheckingForUpdate - } - LanguageServerInstallationStatus::Downloading => { - LanguageServerBinaryStatus::Downloading - } - LanguageServerInstallationStatus::None => LanguageServerBinaryStatus::None, - LanguageServerInstallationStatus::Failed(error) => { - LanguageServerBinaryStatus::Failed { error } - } + LanguageServerInstallationStatus::CheckingForUpdate => BinaryStatus::CheckingForUpdate, + LanguageServerInstallationStatus::Downloading => BinaryStatus::Downloading, + LanguageServerInstallationStatus::None => BinaryStatus::None, + LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error }, }; self.host diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs index 15596ce65a90eb..6b346063dab3b3 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs @@ -13,7 +13,7 @@ use extension::{ }; use futures::{io::BufReader, FutureExt as _}; use futures::{lock::Mutex, AsyncReadExt}; -use language::{language_settings::AllLanguageSettings, LanguageName, LanguageServerBinaryStatus}; +use language::{language_settings::AllLanguageSettings, BinaryStatus, LanguageName}; use project::project_settings::ProjectSettings; use semantic_version::SemanticVersion; use std::{ @@ -661,16 +661,10 @@ impl ExtensionImports for WasmState { status: LanguageServerInstallationStatus, ) -> wasmtime::Result<()> { let status = match status { - LanguageServerInstallationStatus::CheckingForUpdate => { - LanguageServerBinaryStatus::CheckingForUpdate - } - LanguageServerInstallationStatus::Downloading => { - LanguageServerBinaryStatus::Downloading - } - LanguageServerInstallationStatus::None => LanguageServerBinaryStatus::None, - LanguageServerInstallationStatus::Failed(error) => { - LanguageServerBinaryStatus::Failed { error } - } + LanguageServerInstallationStatus::CheckingForUpdate => BinaryStatus::CheckingForUpdate, + LanguageServerInstallationStatus::Downloading => BinaryStatus::Downloading, + LanguageServerInstallationStatus::None => BinaryStatus::None, + LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error }, }; self.host diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 7bb545dc5a886b..09193a20d2b588 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -73,8 +73,8 @@ pub use buffer::Operation; pub use buffer::*; pub use diagnostic_set::{DiagnosticEntry, DiagnosticGroup}; pub use language_registry::{ - AvailableLanguage, LanguageNotFound, LanguageQueries, LanguageRegistry, - LanguageServerBinaryStatus, QUERY_FILENAME_PREFIXES, + AvailableLanguage, BinaryStatus, LanguageNotFound, LanguageQueries, LanguageRegistry, + QUERY_FILENAME_PREFIXES, }; pub use lsp::{LanguageServerId, LanguageServerName}; pub use outline::*; @@ -304,7 +304,7 @@ pub trait LspAdapterDelegate: Send + Sync { fn worktree_id(&self) -> WorktreeId; fn worktree_root_path(&self) -> &Path; fn exists(&self, path: &Path, is_dir: Option) -> bool; - fn update_status(&self, language: LanguageServerName, status: LanguageServerBinaryStatus); + fn update_status(&self, language: LanguageServerName, status: BinaryStatus); async fn language_server_download_dir(&self, name: &LanguageServerName) -> Option>; async fn npm_package_installed_version( @@ -382,7 +382,7 @@ pub trait LspAdapter: 'static + Send + Sync { } else { delegate.update_status( self.name(), - LanguageServerBinaryStatus::Failed { + BinaryStatus::Failed { error: format!("{error:?}"), }, ); @@ -569,7 +569,7 @@ async fn try_fetch_server_binary let name = adapter.name(); log::info!("fetching latest version of language server {:?}", name.0); - delegate.update_status(name.clone(), LanguageServerBinaryStatus::CheckingForUpdate); + delegate.update_status(name.clone(), BinaryStatus::CheckingForUpdate); let latest_version = adapter .fetch_latest_server_version(delegate.as_ref()) @@ -580,16 +580,16 @@ async fn try_fetch_server_binary .await { log::info!("language server {:?} is already installed", name.0); - delegate.update_status(name.clone(), LanguageServerBinaryStatus::None); + delegate.update_status(name.clone(), BinaryStatus::None); Ok(binary) } else { log::info!("downloading language server {:?}", name.0); - delegate.update_status(adapter.name(), LanguageServerBinaryStatus::Downloading); + delegate.update_status(adapter.name(), BinaryStatus::Downloading); let binary = adapter .fetch_server_binary(latest_version, container_dir, delegate.as_ref()) .await; - delegate.update_status(name.clone(), LanguageServerBinaryStatus::None); + delegate.update_status(name.clone(), BinaryStatus::None); binary } } diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index c789ab80cfb90f..f6b5940028a311 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -98,7 +98,8 @@ pub struct LanguageRegistry { state: RwLock, language_server_download_dir: Option>, executor: BackgroundExecutor, - lsp_binary_status_tx: LspBinaryStatusSender, + lsp_binary_status_tx: BinaryStatusSender, + dap_binary_status_tx: BinaryStatusSender, } struct LanguageRegistryState { @@ -130,7 +131,7 @@ pub struct FakeLanguageServerEntry { } #[derive(Clone, Debug, PartialEq, Eq)] -pub enum LanguageServerBinaryStatus { +pub enum BinaryStatus { None, CheckingForUpdate, Downloading, @@ -213,8 +214,8 @@ pub struct LanguageQueries { } #[derive(Clone, Default)] -struct LspBinaryStatusSender { - txs: Arc>>>, +struct BinaryStatusSender { + txs: Arc>>>, } pub struct LoadedLanguage { @@ -247,6 +248,7 @@ impl LanguageRegistry { }), language_server_download_dir: None, lsp_binary_status_tx: Default::default(), + dap_binary_status_tx: Default::default(), executor, }; this.add(PLAIN_TEXT.clone()); @@ -914,12 +916,12 @@ impl LanguageRegistry { self.state.read().all_lsp_adapters.get(name).cloned() } - pub fn update_lsp_status( - &self, - server_name: LanguageServerName, - status: LanguageServerBinaryStatus, - ) { - self.lsp_binary_status_tx.send(server_name, status); + pub fn update_lsp_status(&self, server_name: LanguageServerName, status: BinaryStatus) { + self.lsp_binary_status_tx.send(server_name.0, status); + } + + pub fn update_dap_status(&self, server_name: LanguageServerName, status: BinaryStatus) { + self.dap_binary_status_tx.send(server_name.0, status); } pub fn next_language_server_id(&self) -> LanguageServerId { @@ -974,10 +976,16 @@ impl LanguageRegistry { pub fn language_server_binary_statuses( &self, - ) -> mpsc::UnboundedReceiver<(LanguageServerName, LanguageServerBinaryStatus)> { + ) -> mpsc::UnboundedReceiver<(SharedString, BinaryStatus)> { self.lsp_binary_status_tx.subscribe() } + pub fn dap_server_binary_statuses( + &self, + ) -> mpsc::UnboundedReceiver<(SharedString, BinaryStatus)> { + self.dap_binary_status_tx.subscribe() + } + pub async fn delete_server_container(&self, name: LanguageServerName) { log::info!("deleting server container"); let Some(dir) = self.language_server_download_dir(&name) else { @@ -1088,16 +1096,14 @@ impl LanguageRegistryState { } } -impl LspBinaryStatusSender { - fn subscribe( - &self, - ) -> mpsc::UnboundedReceiver<(LanguageServerName, LanguageServerBinaryStatus)> { +impl BinaryStatusSender { + fn subscribe(&self) -> mpsc::UnboundedReceiver<(SharedString, BinaryStatus)> { let (tx, rx) = mpsc::unbounded(); self.txs.lock().push(tx); rx } - fn send(&self, name: LanguageServerName, status: LanguageServerBinaryStatus) { + fn send(&self, name: SharedString, status: BinaryStatus) { let mut txs = self.txs.lock(); txs.retain(|tx| tx.unbounded_send((name.clone(), status.clone())).is_ok()); } diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs index 26b53a11c1a930..ed8b0b1d49cd0e 100644 --- a/crates/language_extension/src/extension_lsp_adapter.rs +++ b/crates/language_extension/src/extension_lsp_adapter.rs @@ -12,8 +12,8 @@ use fs::Fs; use futures::{Future, FutureExt}; use gpui::AsyncApp; use language::{ - CodeLabel, HighlightId, Language, LanguageName, LanguageServerBinaryStatus, - LanguageToolchainStore, LspAdapter, LspAdapterDelegate, + BinaryStatus, CodeLabel, HighlightId, Language, LanguageName, LanguageToolchainStore, + LspAdapter, LspAdapterDelegate, }; use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerBinaryOptions, LanguageServerName}; use serde::Serialize; @@ -80,7 +80,7 @@ impl ExtensionLanguageServerProxy for LanguageServerRegistryProxy { fn update_language_server_status( &self, language_server_id: LanguageServerName, - status: LanguageServerBinaryStatus, + status: BinaryStatus, ) { self.language_registry .update_lsp_status(language_server_id, status); diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index 5be02583f53582..c802da3c8289f0 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -85,6 +85,7 @@ impl JsonLspAdapter { cx, ); let tasks_schema = task::TaskTemplates::generate_json_schema(); + let debug_schema = task::DebugTaskFile::generate_json_schema(); let snippets_schema = snippet_provider::format::VSSnippetsFile::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(); @@ -136,7 +137,15 @@ impl JsonLspAdapter { ) ], "schema": snippets_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 72a467c89f63c7..a8a92da9159cf1 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -4823,6 +4823,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); diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index fa6217ce690a9d..84f653bc9019dc 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -169,6 +169,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. @@ -284,6 +290,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(); @@ -328,5 +342,15 @@ 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 { + 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 { + Path::new(".vscode/launch.json") +} + /// A default editorconfig file name to use when resolving project settings. pub const EDITORCONFIG_NAME: &str = ".editorconfig"; diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index d2d571a81c1f3a..5a8929a8a4e346 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -22,16 +22,20 @@ test-support = [ "prettier/test-support", "worktree/test-support", "gpui/test-support", + "dap/test-support", + "dap_adapters/test-support", ] [dependencies] aho-corasick.workspace = true anyhow.workspace = true async-trait.workspace = true +buffer_diff.workspace = true client.workspace = true clock.workspace = true collections.workspace = true -buffer_diff.workspace = true +dap.workspace = true +dap_adapters.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true @@ -40,6 +44,7 @@ globset.workspace = true gpui.workspace = true http_client.workspace = true itertools.workspace = true +indexmap.workspace = true language.workspace = true log.workspace = true lsp.workspace = true @@ -79,17 +84,19 @@ fancy-regex.workspace = true client = { workspace = true, features = ["test-support"] } collections = { workspace = true, features = ["test-support"] } buffer_diff = { workspace = true, features = ["test-support"] } +dap = { workspace = true, features = ["test-support"] } +dap_adapters = { workspace = true, features = ["test-support"] } env_logger.workspace = true fs = { workspace = true, features = ["test-support"] } git2.workspace = true gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } -release_channel.workspace = true lsp = { workspace = true, features = ["test-support"] } prettier = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true -worktree = { workspace = true, features = ["test-support"] } +release_channel.workspace = true rpc = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } unindent.workspace = true util = { workspace = true, features = ["test-support"] } +worktree = { workspace = true, features = ["test-support"] } diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index 7bb76a06d607d3..b67d4a9cc6625a 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -330,6 +330,10 @@ enum OpenBuffer { pub enum BufferStoreEvent { BufferAdded(Entity), + BufferOpened { + buffer: Entity, + project_path: ProjectPath, + }, BufferDropped(BufferId), BufferChangedFilePath { buffer: Entity, @@ -1280,6 +1284,13 @@ impl BufferStore { } } + fn as_local(&self) -> Option<&LocalBufferStore> { + match &self.state { + BufferStoreState::Local(state) => Some(state), + _ => None, + } + } + fn as_local_mut(&mut self) -> Option<&mut LocalBufferStore> { match &mut self.state { BufferStoreState::Local(state) => Some(state), @@ -1307,6 +1318,11 @@ impl BufferStore { cx: &mut Context, ) -> Task>> { if let Some(buffer) = self.get_by_path(&project_path, cx) { + cx.emit(BufferStoreEvent::BufferOpened { + buffer: buffer.clone(), + project_path, + }); + return Task::ready(Ok(buffer)); } @@ -1330,12 +1346,18 @@ impl BufferStore { .insert( cx.spawn(move |this, mut cx| async move { let load_result = load_buffer.await; - this.update(&mut cx, |this, _cx| { + this.update(&mut cx, |this, cx| { // Record the fact that the buffer is no longer loading. this.loading_buffers.remove(&project_path); - }) - .ok(); - load_result.map_err(Arc::new) + + let buffer = load_result.map_err(Arc::new)?; + cx.emit(BufferStoreEvent::BufferOpened { + buffer: buffer.clone(), + project_path, + }); + + Ok(buffer) + })? }) .shared(), ) @@ -1771,6 +1793,11 @@ impl BufferStore { }) } + pub fn buffer_id_for_project_path(&self, project_path: &ProjectPath) -> Option<&BufferId> { + self.as_local() + .and_then(|state| state.local_buffer_ids_by_path.get(project_path)) + } + pub fn get_by_path(&self, path: &ProjectPath, cx: &App) -> Option> { self.buffers().find_map(|buffer| { let file = File::from_dyn(buffer.read(cx).file())?; diff --git a/crates/project/src/debugger.rs b/crates/project/src/debugger.rs new file mode 100644 index 00000000000000..695d5196ec7d45 --- /dev/null +++ b/crates/project/src/debugger.rs @@ -0,0 +1,17 @@ +//! Zed's debugger data layer is implemented in terms of 3 concepts: +//! - DAP store - that knows about all of the available debug sessions. +//! - Debug sessions - that bear responsibility of communicating with debug adapters and managing the state of each individual session. +//! For the most part it is agnostic over the communication layer (it'll use RPC for peers and actual DAP requests for the host). +//! - Breakpoint store - that knows about all breakpoints set for a project. +//! +//! There are few reasons for this divide: +//! - Breakpoints persist across debug sessions and they're not really specific to any particular session. Sure, we have to send protocol messages for them +//! (so they're a "thing" in the protocol), but we also want to set them before any session starts up. +//! - Debug clients are doing the heavy lifting, and this is where UI grabs all of it's data from. They also rely on breakpoint store during initialization to obtain +//! current set of breakpoints. +//! - Since DAP store knows about all of the available debug sessions, it is responsible for routing RPC requests to sessions. It also knows how to find adapters for particular kind of session. + +pub mod breakpoint_store; +pub mod dap_command; +pub mod dap_store; +pub mod session; diff --git a/crates/project/src/debugger/breakpoint_store.rs b/crates/project/src/debugger/breakpoint_store.rs new file mode 100644 index 00000000000000..188523fd3b5559 --- /dev/null +++ b/crates/project/src/debugger/breakpoint_store.rs @@ -0,0 +1,719 @@ +use crate::{ + buffer_store::{BufferStore, BufferStoreEvent}, + BufferId, ProjectItem as _, ProjectPath, WorktreeStore, +}; +use anyhow::{Context as _, Result}; +use collections::{BTreeMap, HashMap, HashSet}; +use dap::{debugger_settings::DebuggerSettings, SourceBreakpoint}; +use gpui::{App, AsyncApp, Context, Entity, EventEmitter}; +use language::{ + proto::{deserialize_anchor, serialize_anchor as serialize_text_anchor}, + Buffer, BufferSnapshot, +}; +use rpc::{proto, AnyProtoClient, TypedEnvelope}; +use settings::Settings; +use settings::WorktreeId; +use std::{ + hash::{Hash, Hasher}, + num::NonZeroU32, + path::Path, + sync::Arc, +}; +use text::Point; +use util::{maybe, ResultExt as _}; + +struct RemoteBreakpointStore { + upstream_client: Option, + upstream_project_id: u64, +} + +enum BreakpointMode { + Local, + Remote(RemoteBreakpointStore), +} + +pub struct BreakpointStore { + pub breakpoints: BTreeMap>, + buffer_store: Entity, + worktree_store: Entity, + downstream_client: Option<(AnyProtoClient, u64)>, + mode: BreakpointMode, +} + +pub enum BreakpointStoreEvent { + BreakpointsChanged { + project_path: ProjectPath, + source_changed: bool, + }, +} + +impl EventEmitter for BreakpointStore {} + +impl BreakpointStore { + pub fn init(client: &AnyProtoClient) { + client.add_entity_message_handler(Self::handle_synchronize_breakpoints); + } + + pub fn local( + buffer_store: Entity, + worktree_store: Entity, + cx: &mut Context, + ) -> Self { + cx.subscribe(&buffer_store, Self::handle_buffer_event) + .detach(); + + BreakpointStore { + breakpoints: BTreeMap::new(), + buffer_store, + worktree_store, + mode: BreakpointMode::Local, + downstream_client: None, + } + } + + pub(crate) fn remote( + upstream_project_id: u64, + upstream_client: AnyProtoClient, + buffer_store: Entity, + worktree_store: Entity, + cx: &mut Context, + ) -> Self { + cx.subscribe(&buffer_store, Self::handle_buffer_event) + .detach(); + + BreakpointStore { + breakpoints: BTreeMap::new(), + buffer_store, + worktree_store, + mode: BreakpointMode::Remote(RemoteBreakpointStore { + upstream_client: Some(upstream_client), + upstream_project_id, + }), + downstream_client: None, + } + } + + pub fn shared(&mut self, project_id: u64, downstream_client: AnyProtoClient) { + self.downstream_client = Some((downstream_client.clone(), project_id)); + + for (project_path, breakpoints) in self.breakpoints.iter() { + downstream_client + .send(proto::SynchronizeBreakpoints { + project_id, + project_path: Some(project_path.to_proto()), + breakpoints: breakpoints + .iter() + .filter_map(|breakpoint| breakpoint.to_proto()) + .collect(), + }) + .log_err(); + } + } + + pub fn unshared(&mut self, cx: &mut Context) { + self.downstream_client.take(); + + cx.notify(); + } + + pub fn upstream_client(&self) -> Option<(AnyProtoClient, u64)> { + match &self.mode { + BreakpointMode::Remote(RemoteBreakpointStore { + upstream_client: Some(upstream_client), + upstream_project_id, + .. + }) => Some((upstream_client.clone(), *upstream_project_id)), + + BreakpointMode::Remote(RemoteBreakpointStore { + upstream_client: None, + .. + }) => None, + BreakpointMode::Local => None, + } + } + + pub fn set_breakpoints_from_proto( + &mut self, + breakpoints: Vec, + cx: &mut Context, + ) { + let mut new_breakpoints = BTreeMap::new(); + for project_breakpoints in breakpoints { + let Some(project_path) = project_breakpoints.project_path else { + continue; + }; + + new_breakpoints.insert( + ProjectPath::from_proto(project_path), + project_breakpoints + .breakpoints + .into_iter() + .filter_map(Breakpoint::from_proto) + .collect::>(), + ); + } + + std::mem::swap(&mut self.breakpoints, &mut new_breakpoints); + cx.notify(); + } + + pub fn toggle_breakpoint( + &mut self, + buffer_id: BufferId, + mut breakpoint: Breakpoint, + edit_action: BreakpointEditAction, + cx: &mut Context, + ) { + let Some(project_path) = self + .buffer_store + .read(cx) + .get(buffer_id) + .and_then(|buffer| buffer.read(cx).project_path(cx)) + else { + return; + }; + + let upstream_client = self.upstream_client(); + 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(log_message) => { + if !log_message.is_empty() { + breakpoint.kind = BreakpointKind::Log(log_message.clone()); + breakpoint_set.remove(&breakpoint); + breakpoint_set.insert(breakpoint); + } else if matches!(&breakpoint.kind, BreakpointKind::Log(_)) { + breakpoint_set.remove(&breakpoint); + } + } + } + + if let Some((client, project_id)) = upstream_client.or(self.downstream_client.clone()) { + client + .send(client::proto::SynchronizeBreakpoints { + project_id, + project_path: Some(project_path.to_proto()), + breakpoints: breakpoint_set + .iter() + .filter_map(|breakpoint| breakpoint.to_proto()) + .collect(), + }) + .log_err(); + } + + if breakpoint_set.is_empty() { + self.breakpoints.remove(&project_path); + } + + cx.emit(BreakpointStoreEvent::BreakpointsChanged { + project_path: project_path.clone(), + source_changed: false, + }); + + cx.notify(); + } + + fn handle_buffer_event( + &mut self, + _buffer_store: Entity, + event: &BufferStoreEvent, + cx: &mut Context, + ) { + match event { + BufferStoreEvent::BufferOpened { + buffer, + project_path, + } => self.on_open_buffer(&project_path, &buffer, cx), + _ => {} + } + } + + pub fn on_open_buffer( + &mut self, + project_path: &ProjectPath, + buffer: &Entity, + cx: &mut Context, + ) { + 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.emit(BreakpointStoreEvent::BreakpointsChanged { + project_path: project_path.clone(), + source_changed: true, + }); + cx.notify(); + } + + pub fn on_file_rename( + &mut self, + old_project_path: ProjectPath, + new_project_path: ProjectPath, + cx: &mut Context, + ) { + if let Some(breakpoints) = self.breakpoints.remove(&old_project_path) { + self.breakpoints + .insert(new_project_path.clone(), breakpoints); + + cx.emit(BreakpointStoreEvent::BreakpointsChanged { + project_path: new_project_path, + source_changed: false, + }); + cx.notify(); + } + } + + pub fn sync_open_breakpoints_to_closed_breakpoints( + &mut self, + buffer: &Entity, + cx: &mut Context, + ) { + let Some(project_path) = buffer.read(cx).project_path(cx) else { + return; + }; + + if let Some(breakpoint_set) = self.breakpoints.remove(&project_path) { + let breakpoint_iter = breakpoint_set.into_iter().filter_map(|mut breakpoint| { + let position = NonZeroU32::new( + breakpoint.point_for_buffer(&buffer.read(cx).snapshot()).row + 1, + ); + debug_assert!(position.is_some()); + breakpoint.cached_position = position?; + breakpoint.active_position = None; + Some(breakpoint) + }); + + self.breakpoints.insert( + project_path.clone(), + breakpoint_iter.collect::>(), + ); + + cx.emit(BreakpointStoreEvent::BreakpointsChanged { + project_path, + source_changed: false, + }); + cx.notify(); + } + } + + 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(|breakpoint| breakpoint.point_for_buffer_snapshot(&buffer_snapshot).row == row) + .cloned() + } + + pub fn toggle_breakpoint_for_buffer( + &mut self, + project_path: &ProjectPath, + mut breakpoint: Breakpoint, + edit_action: BreakpointEditAction, + cx: &mut Context, + ) { + let upstream_client = self.upstream_client(); + + 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(log_message) => { + if !log_message.is_empty() { + breakpoint.kind = BreakpointKind::Log(log_message.clone()); + breakpoint_set.remove(&breakpoint); + breakpoint_set.insert(breakpoint); + } else if matches!(&breakpoint.kind, BreakpointKind::Log(_)) { + breakpoint_set.remove(&breakpoint); + } + } + } + + if let Some((client, project_id)) = upstream_client.or(self.downstream_client.clone()) { + client + .send(client::proto::SynchronizeBreakpoints { + project_id, + project_path: Some(project_path.to_proto()), + breakpoints: breakpoint_set + .iter() + .filter_map(|breakpoint| breakpoint.to_proto()) + .collect(), + }) + .log_err(); + } + + if breakpoint_set.is_empty() { + self.breakpoints.remove(project_path); + } + + cx.emit(BreakpointStoreEvent::BreakpointsChanged { + project_path: project_path.clone(), + source_changed: false, + }); + 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, + cached_position: serialize_breakpoint.position, + kind: serialize_breakpoint.kind, + }); + } + } + + async fn handle_synchronize_breakpoints( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + let project_path = ProjectPath::from_proto( + envelope + .payload + .project_path + .context("Invalid Breakpoint call")?, + ); + + this.update(&mut cx, |store, cx| { + let breakpoints = envelope + .payload + .breakpoints + .into_iter() + .filter_map(Breakpoint::from_proto) + .collect::>(); + + if breakpoints.is_empty() { + store.breakpoints.remove(&project_path); + } else { + store.breakpoints.insert(project_path.clone(), breakpoints); + } + + cx.emit(BreakpointStoreEvent::BreakpointsChanged { + project_path, + source_changed: false, + }); + cx.notify(); + }) + } + + pub(crate) fn serialize_breakpoints_for_project_path( + &self, + project_path: &ProjectPath, + cx: &App, + ) -> Option<(Arc, Vec)> { + let buffer = maybe!({ + let buffer_id = self + .buffer_store + .read(cx) + .buffer_id_for_project_path(project_path)?; + Some(self.buffer_store.read(cx).get(*buffer_id)?.read(cx)) + }); + + let worktree_path = self + .worktree_store + .read(cx) + .worktree_for_id(project_path.worktree_id, cx)? + .read(cx) + .abs_path(); + + Some(( + worktree_path, + self.breakpoints + .get(&project_path)? + .iter() + .map(|bp| bp.to_serialized(buffer, project_path.path.clone())) + .collect(), + )) + } + + pub fn serialize_breakpoints(&self, cx: &App) -> HashMap, Vec> { + let mut result: HashMap, Vec> = Default::default(); + + if !DebuggerSettings::get_global(cx).save_breakpoints { + return result; + } + + for project_path in self.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 + } + + pub fn all_breakpoints( + &self, + as_abs_path: bool, + cx: &App, + ) -> HashMap, Vec> { + let mut all_breakpoints: HashMap, Vec> = Default::default(); + + for (project_path, breakpoints) in &self.breakpoints { + let buffer = maybe!({ + let buffer_store = self.buffer_store.read(cx); + let buffer_id = buffer_store.buffer_id_for_project_path(project_path)?; + let buffer = buffer_store.get(*buffer_id)?; + Some(buffer.read(cx)) + }); + + let Some(path) = maybe!({ + if as_abs_path { + let worktree = self + .worktree_store + .read(cx) + .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.path.clone()) + } + }) else { + continue; + }; + + all_breakpoints.entry(path).or_default().extend( + breakpoints + .into_iter() + .map(|bp| bp.to_serialized(buffer, project_path.clone().path)), + ); + } + + all_breakpoints + } + + #[cfg(any(test, feature = "test-support"))] + pub fn breakpoints(&self) -> &BTreeMap> { + &self.breakpoints + } +} + +type LogMessage = Arc; + +#[derive(Clone, Debug)] +pub enum BreakpointEditAction { + Toggle, + EditLogMessage(LogMessage), +} + +#[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 cached_position: NonZeroU32, + 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.cached_position == other.cached_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.cached_position.hash(state); + } + } +} + +impl Breakpoint { + 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.cached_position.get() - 1, 0))); + } + } + + pub fn point_for_buffer(&self, buffer: &text::BufferSnapshot) -> Point { + self.active_position + .map(|position| buffer.summary_for_anchor::(&position)) + .unwrap_or(Point::new(self.cached_position.get() - 1, 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.cached_position.get() - 1, 0)) + } + + pub fn to_serialized(&self, buffer: Option<&Buffer>, path: Arc) -> SerializedBreakpoint { + match buffer { + Some(buffer) => SerializedBreakpoint { + position: self + .active_position + .and_then(|position| { + let ret = + NonZeroU32::new(buffer.summary_for_anchor::(&position).row + 1); + debug_assert!( + ret.is_some(), + "Serializing breakpoint close to u32::MAX position failed" + ); + ret + }) + .unwrap_or(self.cached_position), + path, + kind: self.kind.clone(), + }, + None => SerializedBreakpoint { + position: self.cached_position, + path, + kind: self.kind.clone(), + }, + } + } + + pub fn to_proto(&self) -> Option { + Some(client::proto::Breakpoint { + position: if let Some(position) = &self.active_position { + Some(serialize_text_anchor(position)) + } else { + None + }, + cached_position: self.cached_position.get(), + kind: match self.kind { + BreakpointKind::Standard => proto::BreakpointKind::Standard.into(), + BreakpointKind::Log(_) => proto::BreakpointKind::Log.into(), + }, + message: if let BreakpointKind::Log(message) = &self.kind { + Some(message.to_string()) + } else { + None + }, + }) + } + + pub fn from_proto(breakpoint: client::proto::Breakpoint) -> Option { + Some(Self { + active_position: if let Some(position) = breakpoint.position.clone() { + deserialize_anchor(position) + } else { + None + }, + cached_position: NonZeroU32::new(breakpoint.cached_position)?, + kind: match proto::BreakpointKind::from_i32(breakpoint.kind) { + Some(proto::BreakpointKind::Log) => { + BreakpointKind::Log(breakpoint.message.clone().unwrap_or_default().into()) + } + None | Some(proto::BreakpointKind::Standard) => BreakpointKind::Standard, + }, + }) + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct SerializedBreakpoint { + pub position: NonZeroU32, + 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.get() as u64, + condition: None, + hit_condition: None, + log_message, + column: None, + mode: None, + } + } +} diff --git a/crates/project/src/debugger/dap_command.rs b/crates/project/src/debugger/dap_command.rs new file mode 100644 index 00000000000000..fba9414c56d5dc --- /dev/null +++ b/crates/project/src/debugger/dap_command.rs @@ -0,0 +1,1687 @@ +use std::sync::Arc; + +use anyhow::{Ok, Result}; +use dap::{ + client::SessionId, + proto_conversions::ProtoConversion, + requests::{Continue, Next}, + Capabilities, ContinueArguments, InitializeRequestArguments, + InitializeRequestArgumentsPathFormat, NextArguments, SetVariableResponse, SourceBreakpoint, + StepInArguments, StepOutArguments, SteppingGranularity, ValueFormat, Variable, + VariablesArgumentsFilter, +}; +use rpc::proto; +use serde_json::Value; +use util::ResultExt; + +pub(crate) trait LocalDapCommand: 'static + Send + Sync + std::fmt::Debug { + type Response: 'static + Send + std::fmt::Debug; + type DapRequest: 'static + Send + dap::requests::Request; + const CACHEABLE: bool = false; + + fn is_supported(_capabilities: &Capabilities) -> bool { + true + } + + fn to_dap(&self) -> ::Arguments; + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result; +} + +pub(crate) trait DapCommand: LocalDapCommand { + type ProtoRequest: 'static + Send + proto::RequestMessage; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId; + + fn from_proto(request: &Self::ProtoRequest) -> Self; + + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest; + + fn response_to_proto( + debug_client_id: SessionId, + message: Self::Response, + ) -> ::Response; + + fn response_from_proto( + &self, + message: ::Response, + ) -> Result; +} + +impl LocalDapCommand for Arc { + type Response = T::Response; + type DapRequest = T::DapRequest; + + fn is_supported(capabilities: &Capabilities) -> bool { + T::is_supported(capabilities) + } + + fn to_dap(&self) -> ::Arguments { + T::to_dap(self) + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + T::response_from_dap(self, message) + } +} + +impl DapCommand for Arc { + type ProtoRequest = T::ProtoRequest; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + T::client_id_from_proto(request) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Arc::new(T::from_proto(request)) + } + + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest { + T::to_proto(self, debug_client_id, upstream_project_id) + } + + fn response_to_proto( + debug_client_id: SessionId, + message: Self::Response, + ) -> ::Response { + T::response_to_proto(debug_client_id, message) + } + + fn response_from_proto( + &self, + message: ::Response, + ) -> Result { + T::response_from_proto(self, message) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub struct StepCommand { + pub thread_id: u64, + pub granularity: Option, + pub single_thread: Option, +} + +impl StepCommand { + fn from_proto(message: proto::DapNextRequest) -> Self { + const LINE: i32 = proto::SteppingGranularity::Line as i32; + const INSTRUCTION: i32 = proto::SteppingGranularity::Instruction as i32; + + let granularity = message.granularity.map(|granularity| match granularity { + LINE => SteppingGranularity::Line, + INSTRUCTION => SteppingGranularity::Instruction, + _ => SteppingGranularity::Statement, + }); + + Self { + thread_id: message.thread_id, + granularity, + single_thread: message.single_thread, + } + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct NextCommand { + pub inner: StepCommand, +} + +impl LocalDapCommand for NextCommand { + type Response = ::Response; + type DapRequest = Next; + + fn to_dap(&self) -> ::Arguments { + NextArguments { + thread_id: self.inner.thread_id, + single_thread: self.inner.single_thread, + granularity: self.inner.granularity, + } + } + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for NextCommand { + type ProtoRequest = proto::DapNextRequest; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + inner: StepCommand::from_proto(request.clone()), + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> ::Response { + proto::Ack {} + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapNextRequest { + proto::DapNextRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_id: self.inner.thread_id, + single_thread: self.inner.single_thread, + granularity: self.inner.granularity.map(|gran| gran.to_proto() as i32), + } + } + + fn response_from_proto( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct StepInCommand { + pub inner: StepCommand, +} + +impl LocalDapCommand for StepInCommand { + type Response = ::Response; + type DapRequest = dap::requests::StepIn; + + fn to_dap(&self) -> ::Arguments { + StepInArguments { + thread_id: self.inner.thread_id, + single_thread: self.inner.single_thread, + target_id: None, + granularity: self.inner.granularity, + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for StepInCommand { + type ProtoRequest = proto::DapStepInRequest; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + inner: StepCommand::from_proto(proto::DapNextRequest { + project_id: request.project_id, + client_id: request.client_id, + thread_id: request.thread_id, + single_thread: request.single_thread, + granularity: request.granularity, + }), + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> ::Response { + proto::Ack {} + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapStepInRequest { + proto::DapStepInRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_id: self.inner.thread_id, + single_thread: self.inner.single_thread, + granularity: self.inner.granularity.map(|gran| gran.to_proto() as i32), + target_id: None, + } + } + + fn response_from_proto( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct StepOutCommand { + pub inner: StepCommand, +} + +impl LocalDapCommand for StepOutCommand { + type Response = ::Response; + type DapRequest = dap::requests::StepOut; + + fn to_dap(&self) -> ::Arguments { + StepOutArguments { + thread_id: self.inner.thread_id, + single_thread: self.inner.single_thread, + granularity: self.inner.granularity, + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for StepOutCommand { + type ProtoRequest = proto::DapStepOutRequest; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + inner: StepCommand::from_proto(proto::DapNextRequest { + project_id: request.project_id, + client_id: request.client_id, + thread_id: request.thread_id, + single_thread: request.single_thread, + granularity: request.granularity, + }), + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> ::Response { + proto::Ack {} + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapStepOutRequest { + proto::DapStepOutRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_id: self.inner.thread_id, + single_thread: self.inner.single_thread, + granularity: self.inner.granularity.map(|gran| gran.to_proto() as i32), + } + } + + fn response_from_proto( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct StepBackCommand { + pub inner: StepCommand, +} +impl LocalDapCommand for StepBackCommand { + type Response = ::Response; + type DapRequest = dap::requests::StepBack; + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities.supports_step_back.unwrap_or_default() + } + + fn to_dap(&self) -> ::Arguments { + dap::StepBackArguments { + thread_id: self.inner.thread_id, + single_thread: self.inner.single_thread, + granularity: self.inner.granularity, + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for StepBackCommand { + type ProtoRequest = proto::DapStepBackRequest; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + inner: StepCommand::from_proto(proto::DapNextRequest { + project_id: request.project_id, + client_id: request.client_id, + thread_id: request.thread_id, + single_thread: request.single_thread, + granularity: request.granularity, + }), + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> ::Response { + proto::Ack {} + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapStepBackRequest { + proto::DapStepBackRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_id: self.inner.thread_id, + single_thread: self.inner.single_thread, + granularity: self.inner.granularity.map(|gran| gran.to_proto() as i32), + } + } + + fn response_from_proto( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct ContinueCommand { + pub args: ContinueArguments, +} + +impl LocalDapCommand for ContinueCommand { + type Response = ::Response; + type DapRequest = Continue; + + fn to_dap(&self) -> ::Arguments { + self.args.clone() + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message) + } +} + +impl DapCommand for ContinueCommand { + type ProtoRequest = proto::DapContinueRequest; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapContinueRequest { + proto::DapContinueRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_id: self.args.thread_id, + single_thread: self.args.single_thread, + } + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + args: ContinueArguments { + thread_id: request.thread_id, + single_thread: request.single_thread, + }, + } + } + + fn response_from_proto( + &self, + message: ::Response, + ) -> Result { + Ok(Self::Response { + all_threads_continued: message.all_threads_continued, + }) + } + + fn response_to_proto( + debug_client_id: SessionId, + message: Self::Response, + ) -> ::Response { + proto::DapContinueResponse { + client_id: debug_client_id.to_proto(), + all_threads_continued: message.all_threads_continued, + } + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct PauseCommand { + pub thread_id: u64, +} + +impl LocalDapCommand for PauseCommand { + type Response = ::Response; + type DapRequest = dap::requests::Pause; + fn to_dap(&self) -> ::Arguments { + dap::PauseArguments { + thread_id: self.thread_id, + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for PauseCommand { + type ProtoRequest = proto::DapPauseRequest; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + thread_id: request.thread_id, + } + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapPauseRequest { + proto::DapPauseRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_id: self.thread_id, + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> ::Response { + proto::Ack {} + } + + fn response_from_proto( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct DisconnectCommand { + pub restart: Option, + pub terminate_debuggee: Option, + pub suspend_debuggee: Option, +} + +impl LocalDapCommand for DisconnectCommand { + type Response = ::Response; + type DapRequest = dap::requests::Disconnect; + + fn to_dap(&self) -> ::Arguments { + dap::DisconnectArguments { + restart: self.restart, + terminate_debuggee: self.terminate_debuggee, + suspend_debuggee: self.suspend_debuggee, + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for DisconnectCommand { + type ProtoRequest = proto::DapDisconnectRequest; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + restart: request.restart, + terminate_debuggee: request.terminate_debuggee, + suspend_debuggee: request.suspend_debuggee, + } + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapDisconnectRequest { + proto::DapDisconnectRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + restart: self.restart, + terminate_debuggee: self.terminate_debuggee, + suspend_debuggee: self.suspend_debuggee, + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> ::Response { + proto::Ack {} + } + + fn response_from_proto( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct TerminateThreadsCommand { + pub thread_ids: Option>, +} + +impl LocalDapCommand for TerminateThreadsCommand { + type Response = ::Response; + type DapRequest = dap::requests::TerminateThreads; + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities + .supports_terminate_threads_request + .unwrap_or_default() + } + + fn to_dap(&self) -> ::Arguments { + dap::TerminateThreadsArguments { + thread_ids: self.thread_ids.clone(), + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for TerminateThreadsCommand { + type ProtoRequest = proto::DapTerminateThreadsRequest; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + let thread_ids = if request.thread_ids.is_empty() { + None + } else { + Some(request.thread_ids.clone()) + }; + + Self { thread_ids } + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapTerminateThreadsRequest { + proto::DapTerminateThreadsRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_ids: self.thread_ids.clone().unwrap_or_default(), + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> ::Response { + proto::Ack {} + } + + fn response_from_proto( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct TerminateCommand { + pub restart: Option, +} + +impl LocalDapCommand for TerminateCommand { + type Response = ::Response; + type DapRequest = dap::requests::Terminate; + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities.supports_terminate_request.unwrap_or_default() + } + fn to_dap(&self) -> ::Arguments { + dap::TerminateArguments { + restart: self.restart, + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for TerminateCommand { + type ProtoRequest = proto::DapTerminateRequest; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + restart: request.restart, + } + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapTerminateRequest { + proto::DapTerminateRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + restart: self.restart, + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> ::Response { + proto::Ack {} + } + + fn response_from_proto( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct RestartCommand { + pub raw: serde_json::Value, +} + +impl LocalDapCommand for RestartCommand { + type Response = ::Response; + type DapRequest = dap::requests::Restart; + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities.supports_restart_request.unwrap_or_default() + } + + fn to_dap(&self) -> ::Arguments { + dap::RestartArguments { + raw: self.raw.clone(), + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for RestartCommand { + type ProtoRequest = proto::DapRestartRequest; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + raw: serde_json::from_slice(&request.raw_args) + .log_err() + .unwrap_or(serde_json::Value::Null), + } + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapRestartRequest { + let raw_args = serde_json::to_vec(&self.raw).log_err().unwrap_or_default(); + + proto::DapRestartRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + raw_args, + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> ::Response { + proto::Ack {} + } + + fn response_from_proto( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub struct VariablesCommand { + pub stack_frame_id: u64, + pub thread_id: u64, + pub variables_reference: u64, + pub filter: Option, + pub start: Option, + pub count: Option, + pub format: Option, +} + +impl LocalDapCommand for VariablesCommand { + type Response = Vec; + type DapRequest = dap::requests::Variables; + const CACHEABLE: bool = true; + + fn to_dap(&self) -> ::Arguments { + dap::VariablesArguments { + variables_reference: self.variables_reference, + filter: self.filter, + start: self.start, + count: self.count, + format: self.format.clone(), + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message.variables) + } +} + +impl DapCommand for VariablesCommand { + type ProtoRequest = proto::VariablesRequest; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest { + proto::VariablesRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_id: self.thread_id, + stack_frame_id: self.stack_frame_id, + variables_reference: self.variables_reference, + filter: None, + start: self.start, + count: self.count, + format: None, + } + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + thread_id: request.thread_id, + stack_frame_id: request.stack_frame_id, + variables_reference: request.variables_reference, + filter: None, + start: request.start, + count: request.count, + format: None, + } + } + + fn response_to_proto( + debug_client_id: SessionId, + message: Self::Response, + ) -> ::Response { + proto::DapVariables { + client_id: debug_client_id.to_proto(), + variables: message.to_proto(), + } + } + + fn response_from_proto( + &self, + message: ::Response, + ) -> Result { + Ok(Vec::from_proto(message.variables)) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct SetVariableValueCommand { + pub name: String, + pub value: String, + pub variables_reference: u64, +} +impl LocalDapCommand for SetVariableValueCommand { + type Response = SetVariableResponse; + type DapRequest = dap::requests::SetVariable; + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities.supports_set_variable.unwrap_or_default() + } + fn to_dap(&self) -> ::Arguments { + dap::SetVariableArguments { + format: None, + name: self.name.clone(), + value: self.value.clone(), + variables_reference: self.variables_reference, + } + } + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message) + } +} + +impl DapCommand for SetVariableValueCommand { + type ProtoRequest = proto::DapSetVariableValueRequest; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest { + proto::DapSetVariableValueRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + variables_reference: self.variables_reference, + value: self.value.clone(), + name: self.name.clone(), + } + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + variables_reference: request.variables_reference, + name: request.name.clone(), + value: request.value.clone(), + } + } + + fn response_to_proto( + debug_client_id: SessionId, + message: Self::Response, + ) -> ::Response { + proto::DapSetVariableValueResponse { + client_id: debug_client_id.to_proto(), + value: message.value, + variable_type: message.type_, + named_variables: message.named_variables, + variables_reference: message.variables_reference, + indexed_variables: message.indexed_variables, + memory_reference: message.memory_reference, + } + } + + fn response_from_proto( + &self, + message: ::Response, + ) -> Result { + Ok(SetVariableResponse { + value: message.value, + type_: message.variable_type, + variables_reference: message.variables_reference, + named_variables: message.named_variables, + indexed_variables: message.indexed_variables, + memory_reference: message.memory_reference, + }) + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) struct RestartStackFrameCommand { + pub stack_frame_id: u64, +} + +impl LocalDapCommand for RestartStackFrameCommand { + type Response = ::Response; + type DapRequest = dap::requests::RestartFrame; + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities.supports_restart_frame.unwrap_or_default() + } + + fn to_dap(&self) -> ::Arguments { + dap::RestartFrameArguments { + frame_id: self.stack_frame_id, + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for RestartStackFrameCommand { + type ProtoRequest = proto::DapRestartStackFrameRequest; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + stack_frame_id: request.stack_frame_id, + } + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapRestartStackFrameRequest { + proto::DapRestartStackFrameRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + stack_frame_id: self.stack_frame_id, + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> ::Response { + proto::Ack {} + } + + fn response_from_proto( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) struct ModulesCommand; + +impl LocalDapCommand for ModulesCommand { + type Response = Vec; + type DapRequest = dap::requests::Modules; + const CACHEABLE: bool = true; + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities.supports_modules_request.unwrap_or_default() + } + + fn to_dap(&self) -> ::Arguments { + dap::ModulesArguments { + start_module: None, + module_count: None, + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message.modules) + } +} + +impl DapCommand for ModulesCommand { + type ProtoRequest = proto::DapModulesRequest; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(_request: &Self::ProtoRequest) -> Self { + Self {} + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapModulesRequest { + proto::DapModulesRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + } + } + + fn response_to_proto( + debug_client_id: SessionId, + message: Self::Response, + ) -> ::Response { + proto::DapModulesResponse { + modules: message + .into_iter() + .map(|module| module.to_proto()) + .collect(), + client_id: debug_client_id.to_proto(), + } + } + + fn response_from_proto( + &self, + message: ::Response, + ) -> Result { + Ok(message + .modules + .into_iter() + .filter_map(|module| dap::Module::from_proto(module).ok()) + .collect()) + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) struct LoadedSourcesCommand; + +impl LocalDapCommand for LoadedSourcesCommand { + type Response = Vec; + type DapRequest = dap::requests::LoadedSources; + const CACHEABLE: bool = true; + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities + .supports_loaded_sources_request + .unwrap_or_default() + } + fn to_dap(&self) -> ::Arguments { + dap::LoadedSourcesArguments {} + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message.sources) + } +} + +impl DapCommand for LoadedSourcesCommand { + type ProtoRequest = proto::DapLoadedSourcesRequest; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(_request: &Self::ProtoRequest) -> Self { + Self {} + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapLoadedSourcesRequest { + proto::DapLoadedSourcesRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + } + } + + fn response_to_proto( + debug_client_id: SessionId, + message: Self::Response, + ) -> ::Response { + proto::DapLoadedSourcesResponse { + sources: message + .into_iter() + .map(|source| source.to_proto()) + .collect(), + client_id: debug_client_id.to_proto(), + } + } + + fn response_from_proto( + &self, + message: ::Response, + ) -> Result { + Ok(message + .sources + .into_iter() + .map(dap::Source::from_proto) + .collect()) + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) struct StackTraceCommand { + pub thread_id: u64, + pub start_frame: Option, + pub levels: Option, +} + +impl LocalDapCommand for StackTraceCommand { + type Response = Vec; + type DapRequest = dap::requests::StackTrace; + const CACHEABLE: bool = true; + + fn to_dap(&self) -> ::Arguments { + dap::StackTraceArguments { + thread_id: self.thread_id, + start_frame: self.start_frame, + levels: self.levels, + format: None, + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message.stack_frames) + } +} + +impl DapCommand for StackTraceCommand { + type ProtoRequest = proto::DapStackTraceRequest; + + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest { + proto::DapStackTraceRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_id: self.thread_id, + start_frame: self.start_frame, + stack_trace_levels: self.levels, + } + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + thread_id: request.thread_id, + start_frame: request.start_frame, + levels: request.stack_trace_levels, + } + } + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn response_from_proto( + &self, + message: ::Response, + ) -> Result { + Ok(message + .frames + .into_iter() + .map(dap::StackFrame::from_proto) + .collect()) + } + + fn response_to_proto( + _debug_client_id: SessionId, + message: Self::Response, + ) -> ::Response { + proto::DapStackTraceResponse { + frames: message.to_proto(), + } + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) struct ScopesCommand { + pub thread_id: u64, + pub stack_frame_id: u64, +} + +impl LocalDapCommand for ScopesCommand { + type Response = Vec; + type DapRequest = dap::requests::Scopes; + const CACHEABLE: bool = true; + + fn to_dap(&self) -> ::Arguments { + dap::ScopesArguments { + frame_id: self.stack_frame_id, + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message.scopes) + } +} + +impl DapCommand for ScopesCommand { + type ProtoRequest = proto::DapScopesRequest; + + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest { + proto::DapScopesRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_id: self.thread_id, + stack_frame_id: self.stack_frame_id, + } + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + thread_id: request.thread_id, + stack_frame_id: request.stack_frame_id, + } + } + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn response_from_proto( + &self, + message: ::Response, + ) -> Result { + Ok(Vec::from_proto(message.scopes)) + } + + fn response_to_proto( + _debug_client_id: SessionId, + message: Self::Response, + ) -> ::Response { + proto::DapScopesResponse { + scopes: message.to_proto(), + } + } +} + +impl LocalDapCommand for super::session::CompletionsQuery { + type Response = dap::CompletionsResponse; + type DapRequest = dap::requests::Completions; + const CACHEABLE: bool = true; + + fn to_dap(&self) -> ::Arguments { + dap::CompletionsArguments { + text: self.query.clone(), + frame_id: self.frame_id, + column: self.column, + line: None, + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message) + } + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities + .supports_completions_request + .unwrap_or_default() + } +} +impl DapCommand for super::session::CompletionsQuery { + type ProtoRequest = proto::DapCompletionRequest; + + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest { + proto::DapCompletionRequest { + client_id: debug_client_id.to_proto(), + project_id: upstream_project_id, + frame_id: self.frame_id, + query: self.query.clone(), + column: self.column, + line: self.line.map(u64::from), + } + } + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + query: request.query.clone(), + frame_id: request.frame_id, + column: request.column, + line: request.line, + } + } + + fn response_from_proto( + &self, + message: ::Response, + ) -> Result { + Ok(dap::CompletionsResponse { + targets: Vec::from_proto(message.completions), + }) + } + + fn response_to_proto( + _debug_client_id: SessionId, + message: Self::Response, + ) -> ::Response { + proto::DapCompletionResponse { + client_id: _debug_client_id.to_proto(), + completions: message.targets.to_proto(), + } + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) struct EvaluateCommand { + pub expression: String, + pub frame_id: Option, + pub context: Option, + pub source: Option, +} + +impl LocalDapCommand for EvaluateCommand { + type Response = dap::EvaluateResponse; + type DapRequest = dap::requests::Evaluate; + fn to_dap(&self) -> ::Arguments { + dap::EvaluateArguments { + expression: self.expression.clone(), + frame_id: self.frame_id, + context: self.context.clone(), + source: self.source.clone(), + line: None, + column: None, + format: None, + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message) + } +} +impl DapCommand for EvaluateCommand { + type ProtoRequest = proto::DapEvaluateRequest; + + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest { + proto::DapEvaluateRequest { + client_id: debug_client_id.to_proto(), + project_id: upstream_project_id, + expression: self.expression.clone(), + frame_id: self.frame_id, + context: self + .context + .clone() + .map(|context| context.to_proto().into()), + } + } + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + expression: request.expression.clone(), + frame_id: request.frame_id, + context: Some(dap::EvaluateArgumentsContext::from_proto(request.context())), + source: None, + } + } + + fn response_from_proto( + &self, + message: ::Response, + ) -> Result { + Ok(dap::EvaluateResponse { + result: message.result.clone(), + type_: message.evaluate_type.clone(), + presentation_hint: None, + variables_reference: message.variable_reference, + named_variables: message.named_variables, + indexed_variables: message.indexed_variables, + memory_reference: message.memory_reference.clone(), + }) + } + + fn response_to_proto( + _debug_client_id: SessionId, + message: Self::Response, + ) -> ::Response { + proto::DapEvaluateResponse { + result: message.result, + evaluate_type: message.type_, + variable_reference: message.variables_reference, + named_variables: message.named_variables, + indexed_variables: message.indexed_variables, + memory_reference: message.memory_reference, + } + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) struct ThreadsCommand; + +impl LocalDapCommand for ThreadsCommand { + type Response = Vec; + type DapRequest = dap::requests::Threads; + const CACHEABLE: bool = true; + + fn to_dap(&self) -> ::Arguments { + () + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message.threads) + } +} + +impl DapCommand for ThreadsCommand { + type ProtoRequest = proto::DapThreadsRequest; + + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest { + proto::DapThreadsRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + } + } + + fn from_proto(_request: &Self::ProtoRequest) -> Self { + Self {} + } + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn response_from_proto( + &self, + message: ::Response, + ) -> Result { + Ok(Vec::from_proto(message.threads)) + } + + fn response_to_proto( + _debug_client_id: SessionId, + message: Self::Response, + ) -> ::Response { + proto::DapThreadsResponse { + threads: message.to_proto(), + } + } +} + +#[derive(Clone, Debug, Hash, PartialEq)] +pub(super) struct Initialize { + pub(super) adapter_id: String, +} + +fn dap_client_capabilities(adapter_id: String) -> InitializeRequestArguments { + InitializeRequestArguments { + client_id: Some("zed".to_owned()), + client_name: Some("Zed".to_owned()), + 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(true), + supports_memory_references: Some(true), + supports_progress_reporting: Some(false), + supports_invalidated_event: Some(false), + lines_start_at1: Some(true), + columns_start_at1: Some(true), + supports_memory_event: Some(false), + supports_args_can_be_interpreted_by_shell: Some(false), + supports_start_debugging_request: Some(true), + } +} + +impl LocalDapCommand for Initialize { + type Response = Capabilities; + type DapRequest = dap::requests::Initialize; + + fn to_dap(&self) -> ::Arguments { + dap_client_capabilities(self.adapter_id.clone()) + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message) + } +} + +#[derive(Clone, Debug, Hash, PartialEq)] +pub(super) struct ConfigurationDone; + +impl LocalDapCommand for ConfigurationDone { + type Response = (); + type DapRequest = dap::requests::ConfigurationDone; + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities + .supports_configuration_done_request + .unwrap_or_default() + } + + fn to_dap(&self) -> ::Arguments { + dap::ConfigurationDoneArguments + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message) + } +} + +#[derive(Clone, Debug, Hash, PartialEq)] +pub(super) struct Launch { + pub(super) raw: Value, +} + +impl LocalDapCommand for Launch { + type Response = (); + type DapRequest = dap::requests::Launch; + + fn to_dap(&self) -> ::Arguments { + dap::LaunchRequestArguments { + raw: self.raw.clone(), + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message) + } +} + +#[derive(Clone, Debug, Hash, PartialEq)] +pub(super) struct SetBreakpoints { + pub(super) source: dap::Source, + pub(super) breakpoints: Vec, +} + +impl LocalDapCommand for SetBreakpoints { + type Response = Vec; + type DapRequest = dap::requests::SetBreakpoints; + + fn to_dap(&self) -> ::Arguments { + dap::SetBreakpointsArguments { + lines: None, + source_modified: None, + source: self.source.clone(), + breakpoints: Some(self.breakpoints.clone()), + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message.breakpoints) + } +} diff --git a/crates/project/src/debugger/dap_store.rs b/crates/project/src/debugger/dap_store.rs new file mode 100644 index 00000000000000..0c1062154e8276 --- /dev/null +++ b/crates/project/src/debugger/dap_store.rs @@ -0,0 +1,963 @@ +use super::{ + breakpoint_store::BreakpointStore, + // Will need to uncomment this once we implement rpc message handler again + // dap_command::{ + // ContinueCommand, DapCommand, DisconnectCommand, NextCommand, PauseCommand, RestartCommand, + // RestartStackFrameCommand, StepBackCommand, StepCommand, StepInCommand, StepOutCommand, + // TerminateCommand, TerminateThreadsCommand, VariablesCommand, + // }, + session::{self, Session}, +}; +use crate::{debugger, ProjectEnvironment, ProjectPath}; +use anyhow::{anyhow, Context as _, Result}; +use async_trait::async_trait; +use collections::HashMap; +use dap::{ + adapters::{DapStatus, DebugAdapterName}, + client::SessionId, + messages::{Message, Response}, + requests::{ + Completions, Evaluate, Request as _, RunInTerminal, SetExpression, SetVariable, + StartDebugging, + }, + Capabilities, CompletionItem, CompletionsArguments, ErrorResponse, EvaluateArguments, + EvaluateArgumentsContext, EvaluateResponse, SetExpressionArguments, SetVariableArguments, + Source, StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest, +}; +use fs::Fs; +use futures::future::Shared; +use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task}; +use http_client::HttpClient; +use language::{BinaryStatus, LanguageRegistry, LanguageToolchainStore}; +use lsp::LanguageServerName; +use node_runtime::NodeRuntime; +use rpc::{ + proto::{self, UpdateDebugAdapter, UpdateThreadStatus}, + AnyProtoClient, TypedEnvelope, +}; +use serde_json::Value; +use settings::WorktreeId; +use smol::{lock::Mutex, stream::StreamExt}; +use std::{ + borrow::Borrow, + collections::{BTreeMap, HashSet}, + ffi::OsStr, + path::PathBuf, + sync::{atomic::Ordering::SeqCst, Arc}, +}; +use std::{collections::VecDeque, sync::atomic::AtomicU32}; +use task::{AttachConfig, DebugAdapterConfig, DebugRequestType}; +use util::{merge_json_value_into, ResultExt as _}; +use worktree::Worktree; + +pub enum DapStoreEvent { + DebugClientStarted(SessionId), + DebugClientShutdown(SessionId), + DebugClientEvent { + session_id: SessionId, + message: Message, + }, + Notification(String), + ActiveDebugLineChanged, + RemoteHasInitialized, + UpdateDebugAdapter(UpdateDebugAdapter), + UpdateThreadStatus(UpdateThreadStatus), +} + +#[allow(clippy::large_enum_variant)] +pub enum DapStoreMode { + Local(LocalDapStore), // ssh host and collab host + Remote(RemoteDapStore), // collab guest +} + +pub struct LocalDapStore { + fs: Arc, + node_runtime: NodeRuntime, + next_session_id: AtomicU32, + http_client: Arc, + environment: Entity, + language_registry: Arc, + toolchain_store: Arc, + start_debugging_tx: futures::channel::mpsc::UnboundedSender<(SessionId, Message)>, + _start_debugging_task: Task<()>, +} + +impl LocalDapStore { + fn next_session_id(&self) -> SessionId { + SessionId(self.next_session_id.fetch_add(1, SeqCst)) + } + pub fn respond_to_start_debugging( + &mut self, + session: &Entity, + seq: u64, + args: Option, + cx: &mut Context, + ) -> Task> { + let config = session.read(cx).configuration(); + + let request_args = args.unwrap_or_else(|| StartDebuggingRequestArguments { + configuration: config.initialize_args.clone().unwrap_or_default(), + request: match config.request { + DebugRequestType::Launch => StartDebuggingRequestArgumentsRequest::Launch, + DebugRequestType::Attach(_) => StartDebuggingRequestArgumentsRequest::Attach, + }, + }); + + // Merge the new configuration over the existing configuration + let mut initialize_args = config.initialize_args.clone().unwrap_or_default(); + merge_json_value_into(request_args.configuration, &mut initialize_args); + + let new_config = DebugAdapterConfig { + label: config.label.clone(), + kind: config.kind.clone(), + request: match &request_args.request { + StartDebuggingRequestArgumentsRequest::Launch => DebugRequestType::Launch, + StartDebuggingRequestArgumentsRequest::Attach => DebugRequestType::Attach( + if let DebugRequestType::Attach(attach_config) = &config.request { + attach_config.clone() + } else { + AttachConfig::default() + }, + ), + }, + program: config.program.clone(), + cwd: config.cwd.clone(), + initialize_args: Some(initialize_args), + supports_attach: true, + }; + + cx.spawn(|this, mut cx| async move { + let (success, body) = { + let reconnect_task = this.update(&mut cx, |store, cx| { + if !unimplemented!("client.adapter().supports_attach()") + && matches!(new_config.request, DebugRequestType::Attach(_)) + { + Task::>::ready(Err(anyhow!( + "Debug adapter does not support `attach` request" + ))) + } else { + unimplemented!( + "store.reconnect_client(client.binary().clone(), new_config, cx)" + ); + } + }); + + match reconnect_task { + Ok(task) => match task.await { + Ok(_) => (true, None), + Err(error) => ( + false, + Some(serde_json::to_value(ErrorResponse { + error: Some(dap::Message { + id: seq, + format: error.to_string(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + })?), + ), + }, + Err(error) => ( + false, + Some(serde_json::to_value(ErrorResponse { + error: Some(dap::Message { + id: seq, + format: error.to_string(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + })?), + ), + } + }; + unimplemented!(); + Ok(()) + /*client + .send_message(Message::Response(Response { + seq, + body, + success, + request_seq: seq, + command: StartDebugging::COMMAND.to_string(), + })) + .await*/ + }) + } +} + +pub struct RemoteDapStore { + upstream_client: AnyProtoClient, + upstream_project_id: u64, + event_queue: Option>, +} + +pub struct DapStore { + mode: DapStoreMode, + downstream_client: Option<(AnyProtoClient, u64)>, + breakpoint_store: Entity, + active_debug_line: Option<(SessionId, ProjectPath, u32)>, + sessions: BTreeMap>, +} + +impl EventEmitter for DapStore {} + +impl DapStore { + pub fn init(client: &AnyProtoClient) { + client.add_entity_message_handler(Self::handle_remove_active_debug_line); + client.add_entity_message_handler(Self::handle_shutdown_debug_client); + client.add_entity_message_handler(Self::handle_set_active_debug_line); + client.add_entity_message_handler(Self::handle_set_debug_client_capabilities); + client.add_entity_message_handler(Self::handle_update_debug_adapter); + client.add_entity_message_handler(Self::handle_update_thread_status); + client.add_entity_message_handler(Self::handle_ignore_breakpoint_state); + + // todo(debugger): Reenable these after we finish handle_dap_command refactor + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + } + + pub fn new_local( + http_client: Arc, + node_runtime: NodeRuntime, + fs: Arc, + language_registry: Arc, + environment: Entity, + toolchain_store: Arc, + breakpoint_store: Entity, + cx: &mut Context, + ) -> Self { + cx.on_app_quit(Self::shutdown_sessions).detach(); + + let (start_debugging_tx, mut message_rx) = + futures::channel::mpsc::unbounded::<(SessionId, Message)>(); + + let _start_debugging_task = cx.spawn(move |this, mut cx| async move { + while let Some((session_id, message)) = message_rx.next().await { + match message { + Message::Request(request) => { + this.update(&mut cx, |this, cx| { + if request.command == StartDebugging::COMMAND { + // this.sessions.get(1).update(|session, cx| { + // session.child(session_id, cx); + // }); + + // this.new_session(config, worktree, cx) + } else if request.command == RunInTerminal::COMMAND { + // spawn terminal + } + }); + } + _ => {} + } + } + }); + Self { + mode: DapStoreMode::Local(LocalDapStore { + fs, + environment, + http_client, + node_runtime, + toolchain_store, + language_registry, + next_session_id: Default::default(), + start_debugging_tx, + _start_debugging_task, + }), + downstream_client: None, + active_debug_line: None, + breakpoint_store, + sessions: Default::default(), + } + } + + pub fn new_remote( + project_id: u64, + upstream_client: AnyProtoClient, + breakpoint_store: Entity, + ) -> Self { + Self { + mode: DapStoreMode::Remote(RemoteDapStore { + upstream_client, + upstream_project_id: project_id, + event_queue: Some(VecDeque::default()), + }), + downstream_client: None, + active_debug_line: None, + breakpoint_store, + sessions: Default::default(), + } + } + + pub fn as_remote(&self) -> Option<&RemoteDapStore> { + match &self.mode { + DapStoreMode::Remote(remote_dap_store) => Some(remote_dap_store), + _ => None, + } + } + + pub fn remote_event_queue(&mut self) -> Option> { + if let DapStoreMode::Remote(remote) = &mut self.mode { + remote.event_queue.take() + } else { + None + } + } + + pub fn as_local(&self) -> Option<&LocalDapStore> { + match &self.mode { + DapStoreMode::Local(local_dap_store) => Some(local_dap_store), + _ => None, + } + } + + pub fn as_local_mut(&mut self) -> Option<&mut LocalDapStore> { + match &mut self.mode { + DapStoreMode::Local(local_dap_store) => Some(local_dap_store), + _ => None, + } + } + + pub fn upstream_client(&self) -> Option<(AnyProtoClient, u64)> { + match &self.mode { + DapStoreMode::Remote(RemoteDapStore { + upstream_client, + upstream_project_id, + .. + }) => Some((upstream_client.clone(), *upstream_project_id)), + + DapStoreMode::Local(_) => None, + } + } + + pub fn downstream_client(&self) -> Option<&(AnyProtoClient, u64)> { + self.downstream_client.as_ref() + } + + pub fn add_remote_client( + &mut self, + session_id: SessionId, + ignore: Option, + cx: &mut Context, + ) { + if let DapStoreMode::Remote(remote) = &self.mode { + self.sessions.insert( + session_id, + cx.new(|_| { + debugger::session::Session::remote( + session_id, + remote.upstream_client.clone(), + remote.upstream_project_id, + self.breakpoint_store.clone(), + ignore.unwrap_or(false), + ) + }), + ); + } else { + debug_assert!(false); + } + } + + pub fn session_by_id( + &self, + session_id: impl Borrow, + ) -> Option> { + let session_id = session_id.borrow(); + let client = self.sessions.get(session_id).cloned(); + + client + } + pub fn sessions(&self) -> impl Iterator> { + self.sessions.values() + } + + pub fn capabilities_by_id( + &self, + session_id: impl Borrow, + cx: &App, + ) -> Option { + let session_id = session_id.borrow(); + self.sessions + .get(session_id) + .map(|client| client.read(cx).capabilities.clone()) + } + + pub fn update_capabilities_for_client( + &mut self, + session_id: SessionId, + capabilities: &Capabilities, + cx: &mut Context, + ) { + if let Some(client) = self.session_by_id(session_id) { + client.update(cx, |this, cx| { + this.capabilities = this.capabilities.merge(capabilities.clone()); + }); + } + + cx.notify(); + + if let Some((downstream_client, project_id)) = self.downstream_client.as_ref() { + downstream_client + .send(dap::proto_conversions::capabilities_to_proto( + &capabilities, + *project_id, + session_id.to_proto(), + )) + .log_err(); + } + } + + pub fn active_debug_line(&self) -> Option<(SessionId, ProjectPath, u32)> { + self.active_debug_line.clone() + } + + pub fn set_active_debug_line( + &mut self, + session_id: SessionId, + project_path: &ProjectPath, + row: u32, + cx: &mut Context, + ) { + self.active_debug_line = Some((session_id, project_path.clone(), row)); + cx.emit(DapStoreEvent::ActiveDebugLineChanged); + cx.notify(); + } + + pub fn remove_active_debug_line_for_client( + &mut self, + session_id: &SessionId, + cx: &mut Context, + ) { + if let Some(active_line) = &self.active_debug_line { + if active_line.0 == *session_id { + self.active_debug_line.take(); + cx.emit(DapStoreEvent::ActiveDebugLineChanged); + cx.notify(); + + if let Some((client, project_id)) = self.downstream_client.clone() { + client + .send(::client::proto::RemoveActiveDebugLine { project_id }) + .log_err(); + } + } + } + } + + pub fn breakpoint_store(&self) -> &Entity { + &self.breakpoint_store + } + + async fn handle_ignore_breakpoint_state( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + let session_id = SessionId::from_proto(envelope.payload.session_id); + + this.update(&mut cx, |this, cx| { + if let Some(session) = this.session_by_id(&session_id) { + session.update(cx, |session, cx| { + session.set_ignore_breakpoints(envelope.payload.ignore, cx) + }) + } else { + Task::ready(Ok(())) + } + })? + .await + } + + pub fn new_session( + &mut self, + config: DebugAdapterConfig, + worktree: &Entity, + cx: &mut Context, + ) -> Task>> { + let Some(local_store) = self.as_local() else { + unimplemented!("Starting session on remote side"); + }; + + let delegate = DapAdapterDelegate::new( + local_store.fs.clone(), + worktree.read(cx).id(), + local_store.node_runtime.clone(), + local_store.http_client.clone(), + local_store.language_registry.clone(), + local_store.toolchain_store.clone(), + local_store.environment.update(cx, |env, cx| { + let worktree = worktree.read(cx); + env.get_environment(Some(worktree.id()), Some(worktree.abs_path()), cx) + }), + ); + let session_id = local_store.next_session_id(); + + let start_client_task = Session::local( + self.breakpoint_store.clone(), + session_id, + delegate, + config, + local_store.start_debugging_tx.clone(), + cx, + ); + + cx.spawn(|this, mut cx| async move { + let session = match start_client_task.await { + Ok(session) => session, + Err(error) => { + this.update(&mut cx, |_, cx| { + cx.emit(DapStoreEvent::Notification(error.to_string())); + }) + .log_err(); + + return Err(error); + } + }; + + this.update(&mut cx, |store, cx| { + store.sessions.insert(session_id, session.clone()); + + cx.emit(DapStoreEvent::DebugClientStarted(session_id)); + cx.notify(); + + session + }) + }) + } + + pub fn respond_to_run_in_terminal( + &self, + session_id: SessionId, + success: bool, + seq: u64, + body: Option, + cx: &mut Context, + ) -> Task> { + let Some(client) = self + .session_by_id(session_id) + .and_then(|client| client.read(cx).adapter_client()) + else { + return Task::ready(Err(anyhow!( + "Could not find debug client: {:?}", + session_id + ))); + }; + + cx.background_executor().spawn(async move { + client + .send_message(Message::Response(Response { + seq, + body, + success, + request_seq: seq, + command: RunInTerminal::COMMAND.to_string(), + })) + .await + }) + } + + pub fn evaluate( + &self, + session_id: &SessionId, + stack_frame_id: u64, + expression: String, + context: EvaluateArgumentsContext, + source: Option, + cx: &mut Context, + ) -> Task> { + let Some(client) = self + .session_by_id(session_id) + .and_then(|client| client.read(cx).adapter_client()) + else { + return Task::ready(Err(anyhow!("Could not find client: {:?}", session_id))); + }; + + cx.background_executor().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, + }) + .await + }) + } + + pub fn completions( + &self, + session_id: &SessionId, + stack_frame_id: u64, + text: String, + completion_column: u64, + cx: &mut Context, + ) -> Task>> { + let Some(client) = self + .session_by_id(session_id) + .and_then(|client| client.read(cx).adapter_client()) + else { + return Task::ready(Err(anyhow!("Could not find client: {:?}", session_id))); + }; + + cx.background_executor().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, + session_id: &SessionId, + stack_frame_id: u64, + variables_reference: u64, + name: String, + value: String, + evaluate_name: Option, + cx: &mut Context, + ) -> Task> { + let Some(client) = self + .session_by_id(session_id) + .and_then(|client| client.read(cx).adapter_client()) + else { + return Task::ready(Err(anyhow!("Could not find client: {:?}", session_id))); + }; + + let supports_set_expression = self + .capabilities_by_id(session_id, cx) + .map(|caps| caps.supports_set_expression) + .flatten() + .unwrap_or_default(); + + cx.background_executor().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(()) + }) + } + + // .. get the client and what not + // let _ = client.modules(); // This can fire a request to a dap adapter or be a cheap getter. + // client.wait_for_request(request::Modules); // This ensures that the request that we've fired off runs to completions + // let returned_value = client.modules(); // this is a cheap getter. + + pub fn shutdown_sessions(&mut self, cx: &mut Context) -> Task<()> { + let mut tasks = vec![]; + for session_id in self.sessions.keys().cloned().collect::>() { + tasks.push(self.shutdown_session(&session_id, cx)); + } + + cx.background_executor().spawn(async move { + futures::future::join_all(tasks).await; + }) + } + + pub fn shutdown_session( + &mut self, + session_id: &SessionId, + cx: &mut Context, + ) -> Task> { + let Some(_) = self.as_local_mut() else { + if let Some((upstream_client, project_id)) = self.upstream_client() { + let future = upstream_client.request(proto::ShutdownDebugClient { + project_id, + session_id: session_id.to_proto(), + }); + + return cx + .background_executor() + .spawn(async move { future.await.map(|_| ()) }); + } + + return Task::ready(Err(anyhow!("Cannot shutdown session on remote side"))); + }; + let Some(client) = self.sessions.remove(session_id) else { + return Task::ready(Err(anyhow!("Could not find session: {:?}", session_id))); + }; + + client.update(cx, |this, cx| { + this.shutdown(cx); + }); + + Task::ready(Ok(())) + } + + // async fn _handle_dap_command_2( + // this: Entity, + // envelope: TypedEnvelope, + // mut cx: AsyncApp, + // ) -> Result<::Response> + // where + // ::Arguments: Send, + // ::Response: Send, + // { + // let request = T::from_proto(&envelope.payload); + // let session_id = T::session_id_from_proto(&envelope.payload); + + // let _state = this + // .update(&mut cx, |this, cx| { + // this.client_by_id(session_id)? + // .read(cx) + // ._wait_for_request(request) + // }) + // .ok() + // .flatten(); + // if let Some(_state) = _state { + // let _ = _state.await; + // } + + // todo!() + // } + + // async fn handle_dap_command( + // this: Entity, + // envelope: TypedEnvelope, + // mut cx: AsyncApp, + // ) -> Result<::Response> + // where + // ::Arguments: Send, + // ::Response: Send, + // { + // let _sender_id = envelope.original_sender_id().unwrap_or_default(); + // let session_id = T::session_id_from_proto(&envelope.payload); + + // let request = T::from_proto(&envelope.payload); + // let response = this + // .update(&mut cx, |this, cx| { + // this.request_dap::(&session_id, request, cx) + // })? + // .await?; + + // Ok(T::response_to_proto(&session_id, response)) + // } + + async fn handle_update_debug_adapter( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + this.update(&mut cx, |_, cx| { + cx.emit(DapStoreEvent::UpdateDebugAdapter(envelope.payload)); + }) + } + + async fn handle_update_thread_status( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + this.update(&mut cx, |_, cx| { + cx.emit(DapStoreEvent::UpdateThreadStatus(envelope.payload)); + }) + } + + async fn handle_set_debug_client_capabilities( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + this.update(&mut cx, |dap_store, cx| { + dap_store.update_capabilities_for_client( + SessionId::from_proto(envelope.payload.session_id), + &dap::proto_conversions::capabilities_from_proto(&envelope.payload), + cx, + ); + }) + } + + async fn handle_shutdown_debug_client( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + this.update(&mut cx, |dap_store, cx| { + let session_id = SessionId::from_proto(envelope.payload.session_id); + + dap_store.session_by_id(session_id).map(|state| { + state.update(cx, |state, cx| { + state.shutdown(cx); + }) + }); + + cx.emit(DapStoreEvent::DebugClientShutdown(session_id)); + cx.notify(); + }) + } + + async fn handle_set_active_debug_line( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + let project_path = ProjectPath::from_proto( + envelope + .payload + .project_path + .context("Invalid Breakpoint call")?, + ); + + this.update(&mut cx, |store, cx| { + store.active_debug_line = Some(( + SessionId::from_proto(envelope.payload.session_id), + project_path, + envelope.payload.row, + )); + + cx.emit(DapStoreEvent::ActiveDebugLineChanged); + cx.notify(); + }) + } + + async fn handle_remove_active_debug_line( + this: Entity, + _: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + this.update(&mut cx, |store, cx| { + store.active_debug_line.take(); + + cx.emit(DapStoreEvent::ActiveDebugLineChanged); + cx.notify(); + }) + } + + pub fn shared( + &mut self, + project_id: u64, + downstream_client: AnyProtoClient, + _: &mut Context, + ) { + self.downstream_client = Some((downstream_client.clone(), project_id)); + } + + pub fn unshared(&mut self, cx: &mut Context) { + self.downstream_client.take(); + + cx.notify(); + } +} + +#[derive(Clone)] +pub struct DapAdapterDelegate { + fs: Arc, + worktree_id: WorktreeId, + node_runtime: NodeRuntime, + http_client: Arc, + language_registry: Arc, + toolchain_store: Arc, + updated_adapters: Arc>>, + load_shell_env_task: Shared>>>, +} + +impl DapAdapterDelegate { + pub fn new( + fs: Arc, + worktree_id: WorktreeId, + node_runtime: NodeRuntime, + http_client: Arc, + language_registry: Arc, + toolchain_store: Arc, + load_shell_env_task: Shared>>>, + ) -> Self { + Self { + fs, + worktree_id, + http_client, + node_runtime, + toolchain_store, + language_registry, + load_shell_env_task, + updated_adapters: Default::default(), + } + } +} + +#[async_trait(?Send)] +impl dap::adapters::DapDelegate for DapAdapterDelegate { + fn worktree_id(&self) -> WorktreeId { + self.worktree_id + } + + fn http_client(&self) -> Arc { + self.http_client.clone() + } + + fn node_runtime(&self) -> NodeRuntime { + self.node_runtime.clone() + } + + fn fs(&self) -> Arc { + self.fs.clone() + } + + fn updated_adapters(&self) -> Arc>> { + self.updated_adapters.clone() + } + + fn update_status(&self, dap_name: DebugAdapterName, status: dap::adapters::DapStatus) { + let name = SharedString::from(dap_name.to_string()); + let status = match status { + DapStatus::None => BinaryStatus::None, + DapStatus::Downloading => BinaryStatus::Downloading, + DapStatus::Failed { error } => BinaryStatus::Failed { error }, + DapStatus::CheckingForUpdate => BinaryStatus::CheckingForUpdate, + }; + + self.language_registry + .update_dap_status(LanguageServerName(name), status); + } + + fn which(&self, command: &OsStr) -> Option { + which::which(command).ok() + } + + async fn shell_env(&self) -> HashMap { + let task = self.load_shell_env_task.clone(); + task.await.unwrap_or_default() + } + + fn toolchain_store(&self) -> Arc { + self.toolchain_store.clone() + } +} diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs new file mode 100644 index 00000000000000..6dfb94651b6682 --- /dev/null +++ b/crates/project/src/debugger/session.rs @@ -0,0 +1,1488 @@ +use crate::project_settings::ProjectSettings; + +use super::breakpoint_store::{BreakpointStore, BreakpointStoreEvent}; +use super::dap_command::{ + self, ConfigurationDone, ContinueCommand, DapCommand, DisconnectCommand, EvaluateCommand, + Initialize, Launch, LoadedSourcesCommand, LocalDapCommand, ModulesCommand, NextCommand, + PauseCommand, RestartCommand, RestartStackFrameCommand, ScopesCommand, SetVariableValueCommand, + StackTraceCommand, StepBackCommand, StepCommand, StepInCommand, StepOutCommand, + TerminateCommand, TerminateThreadsCommand, ThreadsCommand, VariablesCommand, +}; +use super::dap_store::DapAdapterDelegate; +use anyhow::{anyhow, Result}; +use collections::{HashMap, IndexMap}; +use dap::{ + adapters::{DapDelegate, DapStatus, DebugAdapterName}, + client::{DebugAdapterClient, SessionId}, + messages::{self, Events, Message}, + requests::SetBreakpoints, + Capabilities, ContinueArguments, EvaluateArgumentsContext, Module, SetBreakpointsArguments, + Source, SourceBreakpoint, SteppingGranularity, StoppedEvent, +}; +use dap_adapters::build_adapter; +use futures::channel::oneshot; +use futures::{future::join_all, future::Shared, FutureExt}; +use gpui::{App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Task, WeakEntity}; +use rpc::AnyProtoClient; +use serde_json::Value; +use settings::Settings; +use smol::stream::StreamExt; +use std::path::PathBuf; +use std::u64; +use std::{ + any::Any, + collections::hash_map::Entry, + hash::{Hash, Hasher}, + path::Path, + sync::Arc, +}; +use task::DebugAdapterConfig; +use text::{PointUtf16, ToPointUtf16}; +use util::ResultExt; + +#[derive(Debug, Copy, Clone, Hash, PartialEq, PartialOrd, Ord, Eq)] +#[repr(transparent)] +pub struct ThreadId(pub u64); + +impl ThreadId { + pub const MIN: ThreadId = ThreadId(u64::MIN); + pub const MAX: ThreadId = ThreadId(u64::MAX); +} + +pub enum VariableListContainer { + Scope(Scope), + Variable(Variable), +} + +#[derive(Clone, Debug)] +enum ToggledState { + Toggled, + UnToggled, + Leaf, +} + +#[derive(Clone, Debug)] +pub struct Variable { + dap: dap::Variable, + children: Vec, + is_toggled: ToggledState, +} + +impl From for Variable { + fn from(dap: dap::Variable) -> Self { + Self { + children: vec![], + is_toggled: if dap.variables_reference == 0 { + ToggledState::Leaf + } else { + ToggledState::UnToggled + }, + dap, + } + } +} + +#[derive(Clone, Debug)] +pub struct Scope { + pub dap: dap::Scope, + pub variables: Vec, + pub is_toggled: bool, +} + +impl From for Scope { + fn from(scope: dap::Scope) -> Self { + Self { + dap: scope, + variables: vec![], + is_toggled: true, + } + } +} + +#[derive(Clone, Debug)] +pub struct StackFrame { + pub dap: dap::StackFrame, + pub scopes: Vec, +} + +impl From for StackFrame { + fn from(stack_frame: dap::StackFrame) -> Self { + Self { + scopes: vec![], + dap: stack_frame, + } + } +} + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum ThreadStatus { + #[default] + Running, + Stopped, + Exited, + Ended, +} + +type StackFrameId = u64; + +#[derive(Debug)] +pub struct Thread { + dap: dap::Thread, + stack_frames: IndexMap, + has_stopped: bool, +} + +impl From for Thread { + fn from(dap: dap::Thread) -> Self { + Self { + dap, + stack_frames: IndexMap::new(), + has_stopped: false, + } + } +} + +type UpstreamProjectId = u64; + +struct RemoteConnection { + client: AnyProtoClient, + upstream_project_id: UpstreamProjectId, +} + +impl RemoteConnection { + fn send_proto_client_request( + &self, + request: R, + session_id: SessionId, + cx: &mut App, + ) -> Task> { + let message = request.to_proto(session_id, self.upstream_project_id); + let upstream_client = self.client.clone(); + cx.background_executor().spawn(async move { + let response = upstream_client.request(message).await?; + request.response_from_proto(response) + }) + } + fn request( + &self, + request: R, + session_id: SessionId, + cx: &mut App, + ) -> Task> + where + ::Response: 'static, + ::Arguments: 'static + Send, + { + return self.send_proto_client_request::(request, session_id, cx); + } +} + +enum Mode { + Local(LocalMode), + Remote(RemoteConnection), +} + +#[derive(Clone)] +struct LocalMode { + client: Arc, +} + +enum ReverseRequest { + RunInTerminal(), +} + +fn client_source(abs_path: &Path) -> dap::Source { + dap::Source { + name: abs_path + .file_name() + .map(|filename| filename.to_string_lossy().to_string()), + path: Some(abs_path.to_string_lossy().to_string()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + } +} + +impl LocalMode { + fn new( + session_id: SessionId, + breakpoints: Entity, + disposition: DebugAdapterConfig, + delegate: DapAdapterDelegate, + messages_tx: futures::channel::mpsc::UnboundedSender, + cx: AsyncApp, + ) -> Task> { + cx.spawn(move |mut cx| async move { + let adapter = build_adapter(&disposition.kind).await?; + + let binary = cx.update(|cx| { + let name = DebugAdapterName::from(adapter.name().as_ref()); + + ProjectSettings::get_global(cx) + .dap + .get(&name) + .and_then(|s| s.binary.as_ref().map(PathBuf::from)) + })?; + + let binary = match adapter + .get_binary(&delegate, &disposition, binary, &mut cx) + .await + { + Err(error) => { + delegate.update_status( + adapter.name(), + DapStatus::Failed { + error: error.to_string(), + }, + ); + + return Err(error); + } + Ok(mut binary) => { + delegate.update_status(adapter.name(), DapStatus::None); + + let shell_env = delegate.shell_env().await; + let mut envs = binary.envs.unwrap_or_default(); + envs.extend(shell_env); + binary.envs = Some(envs); + + binary + } + }; + + let (initialized_tx, initialized_rx) = oneshot::channel(); + let mut initialized_tx = Some(initialized_tx); + let message_handler = Box::new(move |message, _cx: &mut App| { + let Message::Event(event) = &message else { + messages_tx.unbounded_send(message).ok(); + return; + }; + if let Events::Initialized(_) = **event { + initialized_tx + .take() + .expect("To receive just one Initialized event") + .send(()) + .ok(); + } else { + messages_tx.unbounded_send(message).ok(); + } + }); + let client = Arc::new( + DebugAdapterClient::start(session_id, binary, message_handler, cx.clone()).await?, + ); + let this = Self { client }; + let capabilities = this + .request( + Initialize { + adapter_id: "zed-dap-this-value-needs-changing".to_owned(), + }, + cx.background_executor().clone(), + ) + .await?; + + let raw = adapter.request_args(&disposition); + + // Of relevance: https://github.com/microsoft/vscode/issues/4902#issuecomment-368583522 + let launch = this.request(Launch { raw }, cx.background_executor().clone()); + let that = this.clone(); + let breakpoints = + breakpoints.update(&mut cx, |this, cx| this.all_breakpoints(true, cx))?; + let caps = &capabilities; + let configuration_sequence = async move { + let _ = initialized_rx.await?; + + let mut breakpoint_tasks = Vec::new(); + + for (path, breakpoints) in breakpoints { + breakpoint_tasks.push( + that.request( + dap_command::SetBreakpoints { + source: client_source(&path), + breakpoints: breakpoints + .iter() + .map(|breakpoint| breakpoint.to_source_breakpoint()) + .collect(), + }, + cx.background_executor().clone(), + ), + ); + } + let _ = futures::future::join_all(breakpoint_tasks).await; + + if ConfigurationDone::is_supported(caps) { + that.request(ConfigurationDone, cx.background_executor().clone()) + .await?; + } + + anyhow::Result::<_, anyhow::Error>::Ok(()) + }; + let _ = futures::future::join(configuration_sequence, launch).await; + + Ok((this, capabilities)) + }) + } + + fn request( + &self, + request: R, + executor: BackgroundExecutor, + ) -> Task> + where + ::Response: 'static, + ::Arguments: 'static + Send, + { + let request = Arc::new(request); + + let request_clone = request.clone(); + let connection = self.client.clone(); + let request_task = executor.spawn(async move { + let args = request_clone.to_dap(); + connection.request::(args).await + }); + + executor.spawn(async move { + let response = request.response_from_dap(request_task.await?); + response + }) + } +} +impl From for Mode { + fn from(value: RemoteConnection) -> Self { + Self::Remote(value) + } +} + +impl Mode { + fn request_dap( + &self, + session_id: SessionId, + request: R, + cx: &mut Context, + ) -> Task> + where + ::Response: 'static, + ::Arguments: 'static + Send, + { + match self { + Mode::Local(debug_adapter_client) => { + debug_adapter_client.request(request, cx.background_executor().clone()) + } + Mode::Remote(remote_connection) => remote_connection.request(request, session_id, cx), + } + } +} + +#[derive(Default)] +struct ThreadStates { + global_state: Option, + known_thread_states: IndexMap, +} + +impl ThreadStates { + fn all_threads_stopped(&mut self) { + self.global_state = Some(ThreadStatus::Stopped); + self.known_thread_states.clear(); + } + + fn all_threads_continued(&mut self) { + self.global_state = Some(ThreadStatus::Running); + self.known_thread_states.clear(); + } + + fn thread_stopped(&mut self, thread_id: ThreadId) { + self.known_thread_states + .insert(thread_id, ThreadStatus::Stopped); + } + + fn thread_continued(&mut self, thread_id: ThreadId) { + self.known_thread_states + .insert(thread_id, ThreadStatus::Running); + } + + fn thread_status(&self, thread_id: ThreadId) -> ThreadStatus { + self.thread_state(thread_id) + .unwrap_or(ThreadStatus::Running) + } + + fn thread_state(&self, thread_id: ThreadId) -> Option { + self.known_thread_states + .get(&thread_id) + .copied() + .or(self.global_state) + } + + fn any_stopped_thread(&self) -> bool { + self.global_state + .is_some_and(|state| state == ThreadStatus::Stopped) + || self + .known_thread_states + .values() + .any(|status| *status == ThreadStatus::Stopped) + } +} + +/// Represents a current state of a single debug adapter and provides ways to mutate it. +pub struct Session { + mode: Mode, + config: DebugAdapterConfig, + pub(super) capabilities: Capabilities, + id: SessionId, + breakpoint_store: Entity, + ignore_breakpoints: bool, + modules: Vec, + loaded_sources: Vec, + last_processed_output: usize, + output: Vec, + threads: IndexMap, + requests: HashMap>>>, + thread_states: ThreadStates, + is_session_terminated: bool, + _background_tasks: Vec>, +} + +trait CacheableCommand: 'static + Send + Sync { + fn as_any(&self) -> &dyn Any; + fn dyn_eq(&self, rhs: &dyn CacheableCommand) -> bool; + fn dyn_hash(&self, hasher: &mut dyn Hasher); + fn as_any_arc(self: Arc) -> Arc; +} + +impl CacheableCommand for T +where + T: DapCommand + PartialEq + Eq + Hash, +{ + fn as_any(&self) -> &dyn Any { + self + } + + fn dyn_eq(&self, rhs: &dyn CacheableCommand) -> bool { + rhs.as_any() + .downcast_ref::() + .map_or(false, |rhs| self == rhs) + } + fn dyn_hash(&self, mut hasher: &mut dyn Hasher) { + T::hash(self, &mut hasher); + } + fn as_any_arc(self: Arc) -> Arc { + self + } +} + +pub(crate) struct RequestSlot(Arc); + +impl From for RequestSlot { + fn from(request: T) -> Self { + Self(Arc::new(request)) + } +} + +impl PartialEq for RequestSlot { + fn eq(&self, other: &Self) -> bool { + self.0.dyn_eq(other.0.as_ref()) + } +} + +impl Eq for RequestSlot {} + +impl Hash for RequestSlot { + fn hash(&self, state: &mut H) { + self.0.dyn_hash(state); + self.0.as_any().type_id().hash(state) + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct CompletionsQuery { + pub query: String, + pub column: u64, + pub line: Option, + pub frame_id: Option, +} + +impl CompletionsQuery { + pub fn new( + buffer: &language::Buffer, + cursor_position: language::Anchor, + frame_id: Option, + ) -> Self { + let PointUtf16 { row, column } = cursor_position.to_point_utf16(&buffer.snapshot()); + Self { + query: buffer.text(), + column: column as u64, + frame_id, + line: Some(row as u64), + } + } +} +// local session will send breakpoint updates to DAP for all new breakpoints +// remote side will only send breakpoint updates when it is a breakpoint created by that peer +// BreakpointStore notifies session on breakpoint changes +impl Session { + pub(crate) fn local( + breakpoints: Entity, + session_id: SessionId, + delegate: DapAdapterDelegate, + config: DebugAdapterConfig, + start_debugging_requests_tx: futures::channel::mpsc::UnboundedSender<(SessionId, Message)>, + cx: &mut App, + ) -> Task>> { + let (message_tx, mut message_rx) = futures::channel::mpsc::unbounded(); + + cx.spawn(move |mut cx| async move { + let (mode, capabilities) = LocalMode::new( + session_id, + breakpoints.clone(), + config.clone(), + delegate, + message_tx, + cx.clone(), + ) + .await?; + + cx.new(|cx| { + let _background_tasks = + vec![cx.spawn(move |this: WeakEntity, mut cx| async move { + while let Some(message) = message_rx.next().await { + if let Message::Event(event) = message { + let Ok(_) = this.update(&mut cx, |session, cx| { + session.handle_dap_event(event, cx); + }) else { + break; + }; + } else { + let Ok(_) = start_debugging_requests_tx + .unbounded_send((session_id, message)) + else { + break; + }; + } + } + })]; + cx.subscribe(&breakpoints, Self::send_changed_breakpoints) + .detach(); + + Self { + mode: Mode::Local(mode), + id: session_id, + breakpoint_store: breakpoints, + config, + capabilities, + thread_states: ThreadStates::default(), + ignore_breakpoints: false, + last_processed_output: 0, + output: Vec::default(), + requests: HashMap::default(), + modules: Vec::default(), + loaded_sources: Vec::default(), + threads: IndexMap::default(), + _background_tasks, + is_session_terminated: false, + } + }) + }) + } + + pub(crate) fn remote( + session_id: SessionId, + client: AnyProtoClient, + upstream_project_id: u64, + breakpoint_store: Entity, + ignore_breakpoints: bool, + ) -> Self { + Self { + mode: Mode::Remote(RemoteConnection { + client, + upstream_project_id, + }), + id: session_id, + capabilities: Capabilities::default(), + breakpoint_store, + ignore_breakpoints, + thread_states: ThreadStates::default(), + last_processed_output: 0, + output: Vec::default(), + requests: HashMap::default(), + modules: Vec::default(), + loaded_sources: Vec::default(), + threads: IndexMap::default(), + _background_tasks: Vec::default(), + config: todo!(), + is_session_terminated: false, + } + } + + pub fn session_id(&self) -> SessionId { + self.id + } + + pub fn capabilities(&self) -> &Capabilities { + &self.capabilities + } + + pub fn configuration(&self) -> DebugAdapterConfig { + self.config.clone() + } + + pub fn is_terminated(&self) -> bool { + self.is_session_terminated + } + + pub fn is_local(&self) -> bool { + matches!(self.mode, Mode::Local(_)) + } + + fn send_changed_breakpoints( + &mut self, + _breakpoint_store: Entity, + event: &BreakpointStoreEvent, + cx: &mut Context, + ) { + let BreakpointStoreEvent::BreakpointsChanged { + project_path, + source_changed, + } = event; + + // We still want to send an empty list of breakpoints to tell the dap server that there are no breakpoints + let Some((abs_path, breakpoints)) = self + .breakpoint_store + .read(cx) + .serialize_breakpoints_for_project_path(project_path, cx) + else { + return; + }; + + let source_breakpoints = breakpoints + .iter() + .map(|bp| bp.to_source_breakpoint()) + .collect::>(); + + self.send_breakpoints( + abs_path, + source_breakpoints, + self.ignore_breakpoints, + *source_changed, + cx, + ) + .detach_and_log_err(cx); + } + + fn send_initial_breakpoints(&self, cx: &App) -> Task<()> { + let mut tasks = Vec::new(); + + for (abs_path, serialized_breakpoints) in self + .breakpoint_store + .read_with(cx, |store, cx| store.all_breakpoints(true, cx)) + .into_iter() + .filter(|(_, bps)| !bps.is_empty()) + { + let source_breakpoints = serialized_breakpoints + .iter() + .map(|bp| bp.to_source_breakpoint()) + .collect::>(); + + tasks.push(self.send_breakpoints( + abs_path, + source_breakpoints, + self.ignore_breakpoints, + false, + cx, + )); + } + + cx.background_executor().spawn(async move { + join_all(tasks).await; + }) + } + + pub fn send_breakpoints( + &self, + absolute_file_path: Arc, + mut breakpoints: Vec, + ignore: bool, + source_changed: bool, + cx: &App, + ) -> Task> { + let Some(client) = self.adapter_client() else { + return Task::ready(Err(anyhow!( + "Could not get client in remote session to send breakpoints" + ))); + }; + + // Adjust breakpoints as our client declares that indices start at one. + breakpoints.iter_mut().for_each(|bp| bp.line += 1u64); + + cx.background_executor().spawn(async move { + client + .request::(SetBreakpointsArguments { + source: Source { + path: Some(String::from(absolute_file_path.to_string_lossy())), + name: absolute_file_path + .file_name() + .map(|name| name.to_string_lossy().to_string()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }, + breakpoints: Some(if ignore { Vec::default() } else { breakpoints }), + source_modified: Some(source_changed), + lines: None, + }) + .await?; + + Ok(()) + }) + } + + pub fn output(&self) -> Vec { + self.output.iter().cloned().collect() + } + + pub fn last_processed_output(&self) -> usize { + self.last_processed_output + } + + pub fn set_last_processed_output(&mut self, last_processed_output: usize) { + self.last_processed_output = last_processed_output; + } + + fn handle_stopped_event(&mut self, event: StoppedEvent, cx: &mut Context) { + // todo(debugger): We should query for all threads here if we don't get a thread id + // maybe in both cases too? + if event.all_threads_stopped.unwrap_or_default() { + self.thread_states.all_threads_stopped(); + } else if let Some(thread_id) = event.thread_id { + self.thread_states.thread_stopped(ThreadId(thread_id)); + } else { + // TODO(debugger): all threads should be stopped + } + + // todo(debugger): We should see if we could only invalidate the thread that stopped + // instead of everything right now. + self.invalidate(cx); + } + + pub(crate) fn handle_dap_event(&mut self, event: Box, cx: &mut Context) { + match *event { + Events::Initialized(_) => { + debug_assert!( + false, + "Initialized event should have been handled in LocalMode" + ); + } + Events::Stopped(event) => self.handle_stopped_event(event, cx), + Events::Continued(event) => { + if event.all_threads_continued.unwrap_or_default() { + self.thread_states.all_threads_continued(); + } else { + self.thread_states + .thread_continued(ThreadId(event.thread_id)); + } + self.invalidate(cx); + } + Events::Exited(_event) => {} + Events::Terminated(_) => { + self.is_session_terminated = true; + } + Events::Thread(event) => { + match event.reason { + dap::ThreadEventReason::Started => { + self.thread_states + .thread_continued(ThreadId(event.thread_id)); + } + _ => {} + } + self.invalidate_state(&ThreadsCommand.into()); + } + Events::Output(event) => { + self.output.push(event); + } + Events::Breakpoint(_) => {} + Events::Module(_) => { + self.invalidate_state(&ModulesCommand.into()); + } + Events::LoadedSource(_) => { + self.invalidate_state(&LoadedSourcesCommand.into()); + } + Events::Capabilities(event) => { + self.capabilities = self.capabilities.merge(event.capabilities); + cx.notify(); + } + Events::Memory(_) => {} + Events::Process(_) => {} + Events::ProgressEnd(_) => {} + Events::ProgressStart(_) => {} + Events::ProgressUpdate(_) => {} + Events::Invalidated(_) => {} + Events::Other(_) => {} + } + } + + fn _handle_start_debugging_request(&mut self, _request: messages::Request) {} + + fn _handle_run_in_terminal_request(&mut self, _request: messages::Request) {} + + pub(crate) fn _wait_for_request( + &self, + request: R, + ) -> Option>>> { + let request_slot = RequestSlot::from(request); + self.requests.get(&request_slot).cloned() + } + + /// Ensure that there's a request in flight for the given command, and if not, send it. Use this to run requests that are idempotent. + fn fetch( + &mut self, + request: T, + process_result: impl FnOnce(&mut Self, &T::Response, &mut Context) + 'static, + cx: &mut Context, + ) { + const { + assert!( + T::CACHEABLE, + "Only requests marked as cacheable should invoke `fetch`" + ); + } + if let Entry::Vacant(vacant) = self.requests.entry(request.into()) { + let command = vacant.key().0.clone().as_any_arc().downcast::().unwrap(); + + let task = Self::request_inner::>( + &self.capabilities, + self.id, + &self.mode, + command, + process_result, + cx, + ); + let task = cx + .background_executor() + .spawn(async move { + let _ = task.await?; + Some(()) + }) + .shared(); + + vacant.insert(task); + } + } + + fn request_inner( + capabilities: &Capabilities, + session_id: SessionId, + mode: &Mode, + request: T, + process_result: impl FnOnce(&mut Self, &T::Response, &mut Context) + 'static, + cx: &mut Context, + ) -> Task> { + if !T::is_supported(&capabilities) { + return Task::ready(None); + } + let request = mode.request_dap(session_id, request, cx); + cx.spawn(|this, mut cx| async move { + let result = request.await.log_err()?; + this.update(&mut cx, |this, cx| { + process_result(this, &result, cx); + }) + .log_err(); + Some(result) + }) + } + + fn request( + &self, + request: T, + process_result: impl FnOnce(&mut Self, &T::Response, &mut Context) + 'static, + cx: &mut Context, + ) -> Task> { + Self::request_inner( + &self.capabilities, + self.id, + &self.mode, + request, + process_result, + cx, + ) + } + + fn invalidate_state(&mut self, key: &RequestSlot) { + self.requests.remove(&key); + } + + /// This function should be called after changing state not before + fn invalidate(&mut self, cx: &mut Context) { + self.requests.clear(); + self.modules.clear(); + self.loaded_sources.clear(); + cx.notify(); + } + + pub fn thread_status(&self, thread_id: ThreadId) -> ThreadStatus { + self.thread_states.thread_status(thread_id) + } + + pub fn threads(&mut self, cx: &mut Context) -> Vec<(dap::Thread, ThreadStatus)> { + self.fetch( + dap_command::ThreadsCommand, + |this, result, cx| { + let v = this.threads.keys().copied().collect::>(); + for thread_id in v { + this.invalidate_state( + &StackTraceCommand { + thread_id: thread_id.0, + start_frame: None, + levels: None, + } + .into(), + ); + } + this.threads.extend( + result + .iter() + .map(|thread| (ThreadId(thread.id), Thread::from(thread.clone()))), + ); + cx.notify(); + }, + cx, + ); + self.threads + .values() + .map(|thread| { + ( + thread.dap.clone(), + self.thread_states.thread_status(ThreadId(thread.dap.id)), + ) + }) + .collect() + } + + pub fn modules(&mut self, cx: &mut Context) -> &[Module] { + if self.thread_states.any_stopped_thread() { + self.fetch( + dap_command::ModulesCommand, + |this, result, cx| { + this.modules = result.iter().cloned().collect(); + cx.notify(); + }, + cx, + ); + } + + &self.modules + } + + pub fn toggle_ignore_breakpoints(&mut self, cx: &App) -> Task> { + self.set_ignore_breakpoints(!self.ignore_breakpoints, cx) + } + + pub(crate) fn set_ignore_breakpoints(&mut self, ignore: bool, cx: &App) -> Task> { + if self.ignore_breakpoints == ignore { + return Task::ready(Err(anyhow!( + "Can't set ignore breakpoint to state it's already at" + ))); + } + + // todo(debugger): We need to propagate this change to downstream sessions and send a message to upstream sessions + + self.ignore_breakpoints = ignore; + let mut tasks = Vec::new(); + + for (abs_path, serialized_breakpoints) in self + .breakpoint_store + .read_with(cx, |store, cx| store.all_breakpoints(true, cx)) + .into_iter() + { + let source_breakpoints = if self.ignore_breakpoints { + serialized_breakpoints + .iter() + .map(|bp| bp.to_source_breakpoint()) + .collect::>() + } else { + vec![] + }; + + tasks.push(self.send_breakpoints( + abs_path, + source_breakpoints, + self.ignore_breakpoints, + false, + cx, + )); + } + + cx.background_executor().spawn(async move { + join_all(tasks).await; + Ok(()) + }) + } + + pub fn breakpoints_enabled(&self) -> bool { + self.ignore_breakpoints + } + + pub fn loaded_sources(&mut self, cx: &mut Context) -> &[Source] { + if self.thread_states.any_stopped_thread() { + self.fetch( + dap_command::LoadedSourcesCommand, + |this, result, cx| { + this.loaded_sources = result.iter().cloned().collect(); + cx.notify(); + }, + cx, + ); + } + + &self.loaded_sources + } + + fn empty_response(&mut self, _: &(), _cx: &mut Context) {} + + pub fn pause_thread(&mut self, thread_id: ThreadId, cx: &mut Context) { + self.request( + PauseCommand { + thread_id: thread_id.0, + }, + Self::empty_response, + cx, + ) + .detach(); + } + + pub fn restart_stack_frame(&mut self, stack_frame_id: u64, cx: &mut Context) { + self.request( + RestartStackFrameCommand { stack_frame_id }, + Self::empty_response, + cx, + ) + .detach(); + } + + pub fn restart(&mut self, args: Option, cx: &mut Context) { + if self.capabilities.supports_restart_request.unwrap_or(false) { + self.request( + RestartCommand { + raw: args.unwrap_or(Value::Null), + }, + Self::empty_response, + cx, + ) + .detach(); + } else { + self.request( + DisconnectCommand { + restart: Some(false), + terminate_debuggee: Some(true), + suspend_debuggee: Some(false), + }, + Self::empty_response, + cx, + ) + .detach(); + } + } + + pub(super) fn shutdown(&mut self, cx: &mut Context) { + if self + .capabilities + .supports_terminate_request + .unwrap_or_default() + { + self.request( + TerminateCommand { + restart: Some(false), + }, + Self::empty_response, + cx, + ) + .detach(); + } else { + self.request( + DisconnectCommand { + restart: Some(false), + terminate_debuggee: Some(true), + suspend_debuggee: Some(false), + }, + Self::empty_response, + cx, + ) + .detach(); + } + } + + pub fn completions( + &mut self, + query: CompletionsQuery, + cx: &mut Context, + ) -> Task>> { + let task = self.request(query, |_, _, _| {}, cx); + + cx.background_executor().spawn(async move { + anyhow::Ok( + task.await + .map(|response| response.targets) + .ok_or_else(|| anyhow!("failed to fetch completions"))?, + ) + }) + } + + pub fn continue_thread(&mut self, thread_id: ThreadId, cx: &mut Context) { + self.request( + ContinueCommand { + args: ContinueArguments { + thread_id: thread_id.0, + single_thread: Some(true), + }, + }, + |_, _, _| {}, // todo: what do we do about the payload here? + cx, + ) + .detach(); + } + + pub fn adapter_client(&self) -> Option> { + match self.mode { + Mode::Local(ref adapter_client) => Some(adapter_client.client.clone()), + Mode::Remote(_) => None, + } + } + + pub fn step_over( + &mut self, + thread_id: ThreadId, + granularity: SteppingGranularity, + cx: &mut Context, + ) { + let supports_single_thread_execution_requests = + self.capabilities.supports_single_thread_execution_requests; + let supports_stepping_granularity = self + .capabilities + .supports_stepping_granularity + .unwrap_or_default(); + + let command = NextCommand { + inner: StepCommand { + thread_id: thread_id.0, + granularity: supports_stepping_granularity.then(|| granularity), + single_thread: supports_single_thread_execution_requests, + }, + }; + + self.request(command, Self::empty_response, cx).detach(); + } + + pub fn step_in( + &self, + thread_id: ThreadId, + granularity: SteppingGranularity, + cx: &mut Context, + ) { + let supports_single_thread_execution_requests = + self.capabilities.supports_single_thread_execution_requests; + let supports_stepping_granularity = self + .capabilities + .supports_stepping_granularity + .unwrap_or_default(); + + let command = StepInCommand { + inner: StepCommand { + thread_id: thread_id.0, + granularity: supports_stepping_granularity.then(|| granularity), + single_thread: supports_single_thread_execution_requests, + }, + }; + + self.request(command, Self::empty_response, cx).detach(); + } + + pub fn step_out( + &self, + thread_id: ThreadId, + granularity: SteppingGranularity, + cx: &mut Context, + ) { + let supports_single_thread_execution_requests = + self.capabilities.supports_single_thread_execution_requests; + let supports_stepping_granularity = self + .capabilities + .supports_stepping_granularity + .unwrap_or_default(); + + let command = StepOutCommand { + inner: StepCommand { + thread_id: thread_id.0, + granularity: supports_stepping_granularity.then(|| granularity), + single_thread: supports_single_thread_execution_requests, + }, + }; + + self.request(command, Self::empty_response, cx).detach(); + } + + pub fn step_back( + &self, + thread_id: ThreadId, + granularity: SteppingGranularity, + cx: &mut Context, + ) { + let supports_single_thread_execution_requests = + self.capabilities.supports_single_thread_execution_requests; + let supports_stepping_granularity = self + .capabilities + .supports_stepping_granularity + .unwrap_or_default(); + + let command = StepBackCommand { + inner: StepCommand { + thread_id: thread_id.0, + granularity: supports_stepping_granularity.then(|| granularity), + single_thread: supports_single_thread_execution_requests, + }, + }; + + self.request(command, Self::empty_response, cx).detach(); + } + + pub fn handle_loaded_source_event( + &mut self, + _: &dap::LoadedSourceEvent, + cx: &mut Context, + ) { + self.invalidate_state(&LoadedSourcesCommand.into()); + cx.notify(); + } + + pub fn stack_frames(&mut self, thread_id: ThreadId, cx: &mut Context) -> Vec { + if self.thread_states.thread_status(thread_id) == ThreadStatus::Stopped { + self.fetch( + super::dap_command::StackTraceCommand { + thread_id: thread_id.0, + start_frame: None, + levels: None, + }, + move |this, stack_frames, cx| { + let entry = this.threads.entry(thread_id).and_modify(|thread| { + thread.stack_frames = stack_frames + .iter() + .cloned() + .map(|frame| (frame.id, frame.into())) + .collect(); + }); + debug_assert!( + matches!(entry, indexmap::map::Entry::Occupied(_)), + "Sent request for thread_id that doesn't exist" + ); + + cx.notify(); + }, + cx, + ); + } + + self.threads + .get(&thread_id) + .map(|thread| thread.stack_frames.values().cloned().collect()) + .unwrap_or_default() + } + + pub fn scopes( + &mut self, + thread_id: ThreadId, + stack_frame_id: u64, + cx: &mut Context, + ) -> Vec { + self.fetch( + ScopesCommand { + thread_id: thread_id.0, + stack_frame_id, + }, + move |this, scopes, cx| { + this.threads.entry(thread_id).and_modify(|thread| { + if let Some(stack_frame) = thread.stack_frames.get_mut(&stack_frame_id) { + stack_frame.scopes = scopes.iter().cloned().map(From::from).collect(); + cx.notify(); + } + }); + }, + cx, + ); + self.threads + .get(&thread_id) + .and_then(|thread| { + thread + .stack_frames + .get(&stack_frame_id) + .map(|stack_frame| stack_frame.scopes.clone()) + }) + .unwrap_or_default() + } + + fn find_scope( + &mut self, + thread_id: ThreadId, + stack_frame_id: u64, + variables_reference: u64, + ) -> Option<&mut Scope> { + self.threads.get_mut(&thread_id).and_then(|thread| { + thread + .stack_frames + .get_mut(&stack_frame_id) + .and_then(|frame| { + frame + .scopes + .iter_mut() + .find(|scope| scope.dap.variables_reference == variables_reference) + }) + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn variables( + &mut self, + thread_id: ThreadId, + stack_frame_id: u64, + variables_reference: u64, + cx: &mut Context, + ) -> Vec { + let command = VariablesCommand { + stack_frame_id, + thread_id: thread_id.0, + variables_reference, + filter: None, + start: None, + count: None, + format: None, + }; + + self.fetch( + command, + move |this, variables, cx| { + if let Some(scope) = this.find_scope(thread_id, stack_frame_id, variables_reference) + { + // This is only valid if scope.variable[x].ref_id == variables_reference + // otherwise we have to search the tree for the right index to add variables too + // todo(debugger): Fix this ^ + scope.variables = variables.iter().cloned().map(From::from).collect(); + cx.notify(); + } + }, + cx, + ); + + self.find_scope(thread_id, stack_frame_id, variables_reference) + .map(|scope| scope.variables.clone()) + .unwrap_or_default() + } + + pub fn set_variable_value( + &mut self, + variables_reference: u64, + name: String, + value: String, + cx: &mut Context, + ) { + if self.capabilities.supports_set_variable.unwrap_or_default() { + self.request( + SetVariableValueCommand { + name, + value, + variables_reference, + }, + |this, _response, cx| { + this.invalidate(cx); + }, + cx, + ) + .detach() + } + } + + pub fn evaluate( + &mut self, + expression: String, + context: Option, + frame_id: Option, + source: Option, + cx: &mut Context, + ) { + self.request( + EvaluateCommand { + expression, + context, + frame_id, + source, + }, + |this, response, cx| { + this.output.push(dap::OutputEvent { + category: None, + output: response.result.clone(), + group: None, + variables_reference: Some(response.variables_reference), + source: None, + line: None, + column: None, + data: None, + }); + + // TODO(debugger): only invalidate variables & scopes + this.invalidate(cx); + }, + cx, + ) + .detach(); + } + + pub fn disconnect_client(&mut self, cx: &mut Context) { + let command = DisconnectCommand { + restart: Some(false), + terminate_debuggee: Some(true), + suspend_debuggee: Some(false), + }; + + self.request(command, Self::empty_response, cx).detach() + } + + pub fn terminate_threads(&mut self, thread_ids: Option>, cx: &mut Context) { + if self + .capabilities + .supports_terminate_threads_request + .unwrap_or_default() + { + self.request( + TerminateThreadsCommand { + thread_ids: thread_ids.map(|ids| ids.into_iter().map(|id| id.0).collect()), + }, + Self::empty_response, + cx, + ) + .detach(); + } + } + + pub fn variable_list( + &mut self, + selected_thread_id: ThreadId, + stack_frame_id: u64, + cx: &mut Context, + ) -> Vec { + self.scopes(selected_thread_id, stack_frame_id, cx) + .iter() + .cloned() + .map(|scope| { + if scope.is_toggled { + self.variables( + selected_thread_id, + stack_frame_id, + scope.dap.variables_reference, + cx, + ); + } + + VariableListContainer::Scope(scope) + }) + .collect() + } +} diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 632b96c8b787a0..db07346df50905 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}, + debugger::dap_store::DapStore, deserialize_code_actions, environment::ProjectEnvironment, lsp_command::{self, *}, @@ -37,10 +38,10 @@ use language::{ }, point_to_lsp, proto::{deserialize_anchor, deserialize_version, serialize_anchor, serialize_version}, - range_from_lsp, range_to_lsp, Bias, Buffer, BufferSnapshot, CachedLspAdapter, CodeLabel, - Diagnostic, DiagnosticEntry, DiagnosticSet, Diff, File as _, Language, LanguageRegistry, - LanguageServerBinaryStatus, LanguageToolchainStore, LocalFile, LspAdapter, LspAdapterDelegate, - Patch, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction, Unclipped, + range_from_lsp, range_to_lsp, Bias, BinaryStatus, Buffer, BufferSnapshot, CachedLspAdapter, + CodeLabel, Diagnostic, DiagnosticEntry, DiagnosticSet, Diff, File as _, Language, + LanguageRegistry, LanguageToolchainStore, LocalFile, LspAdapter, LspAdapterDelegate, Patch, + PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction, Unclipped, }; use lsp::{ notification::DidRenameFiles, CodeActionKind, CompletionContext, DiagnosticSeverity, @@ -150,6 +151,7 @@ pub struct LocalLspStore { HashMap>>, supplementary_language_servers: HashMap)>, + dap_store: Entity, prettier_store: Entity, next_diagnostic_group_id: usize, diagnostics: HashMap< @@ -358,7 +360,7 @@ impl LocalLspStore { let log = stderr_capture.lock().take().unwrap_or_default(); delegate.update_status( adapter.name(), - LanguageServerBinaryStatus::Failed { + BinaryStatus::Failed { error: format!("{err}\n-- stderr--\n{}", log), }, ); @@ -434,7 +436,7 @@ impl LocalLspStore { ) .await; - delegate.update_status(adapter.name.clone(), LanguageServerBinaryStatus::None); + delegate.update_status(adapter.name.clone(), BinaryStatus::None); let mut binary = binary_result?; if let Some(arguments) = settings.and_then(|b| b.arguments) { @@ -2936,6 +2938,7 @@ impl LspStore { pub fn new_local( buffer_store: Entity, worktree_store: Entity, + dap_store: Entity, prettier_store: Entity, toolchain_store: Entity, environment: Entity, @@ -2980,6 +2983,7 @@ impl LspStore { buffers_being_formatted: Default::default(), buffer_snapshots: Default::default(), prettier_store, + dap_store, environment, http_client, fs, @@ -3096,6 +3100,7 @@ impl LspStore { } } BufferStoreEvent::BufferDropped(_) => {} + BufferStoreEvent::BufferOpened { .. } => {} } } @@ -3209,6 +3214,14 @@ impl LspStore { self.detect_language_for_buffer(buffer, cx); if let Some(local) = self.as_local_mut() { local.initialize_buffer(buffer, cx); + + local.dap_store.update(cx, |dap_store, cx| { + dap_store + .breakpoint_store() + .update(cx, |breakpoint_store, cx| { + breakpoint_store.sync_open_breakpoints_to_closed_breakpoints(buffer, cx); + }); + }); } Ok(()) @@ -8748,11 +8761,7 @@ impl LspAdapterDelegate for LocalLspAdapterDelegate { )) } - fn update_status( - &self, - server_name: LanguageServerName, - status: language::LanguageServerBinaryStatus, - ) { + fn update_status(&self, server_name: LanguageServerName, status: language::BinaryStatus) { self.language_registry .update_lsp_status(server_name, status); } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 7d51fbc9c69ec1..6a0e28be022629 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -2,6 +2,7 @@ pub mod buffer_store; mod color_extractor; pub mod connection_manager; pub mod debounced_delay; +pub mod debugger; pub mod git; pub mod image_store; pub mod lsp_command; @@ -29,14 +30,27 @@ pub mod search_history; mod yarn; use crate::git::GitStore; + use anyhow::{anyhow, Context as _, Result}; use buffer_store::{BufferStore, BufferStoreEvent}; use client::{ proto, Client, Collaborator, PendingEntitySubscription, ProjectId, TypedEnvelope, UserStore, }; use clock::ReplicaId; + +use dap::{ + client::{DebugAdapterClient, SessionId}, + messages::Message, + DebugAdapterConfig, +}; + use collections::{BTreeSet, HashMap, HashSet}; use debounced_delay::DebouncedDelay; +use debugger::{ + breakpoint_store::BreakpointStore, + dap_store::{DapStore, DapStoreEvent}, + session::Session, +}; pub use environment::ProjectEnvironment; use futures::{ channel::mpsc::{self, UnboundedReceiver}, @@ -52,8 +66,8 @@ use ::git::{ status::FileStatus, }; use gpui::{ - AnyEntity, App, AppContext as _, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter, - Hsla, SharedString, Task, WeakEntity, Window, + AnyEntity, App, AppContext, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter, Hsla, + SharedString, Task, WeakEntity, Window, }; use itertools::Itertools; use language::{ @@ -91,11 +105,13 @@ use std::{ sync::Arc, time::Duration, }; + use task_store::TaskStore; use terminals::Terminals; use text::{Anchor, BufferId}; use toolchain_store::EmptyToolchainStore; use util::{ + maybe, paths::{compare_paths, SanitizedPath}, ResultExt as _, }; @@ -154,6 +170,8 @@ pub struct Project { active_entry: Option, buffer_ordered_messages_tx: mpsc::UnboundedSender, languages: Arc, + dap_store: Entity, + breakpoint_store: Entity, client: Arc, join_project_response_message_id: u32, task_store: Entity, @@ -252,6 +270,14 @@ pub enum Event { notification_id: SharedString, }, LanguageServerPrompt(LanguageServerPromptRequest), + DebugClientStarted(SessionId), + DebugClientShutdown(SessionId), + ActiveDebugLineChanged, + DebugClientEvent { + session_id: SessionId, + message: Message, + }, + DebugClientLog(SessionId, String), LanguageNotFound(Entity), ActiveEntryChanged(Option), ActivateProjectPanel, @@ -290,6 +316,11 @@ pub enum Event { ExpandedAllForEntry(WorktreeId, ProjectEntryId), } +pub enum DebugAdapterClientState { + Starting(Task>>), + Running(Arc), +} + #[derive(Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord)] pub struct ProjectPath { pub worktree_id: WorktreeId, @@ -522,6 +553,7 @@ enum EntitySubscription { WorktreeStore(PendingEntitySubscription), LspStore(PendingEntitySubscription), SettingsObserver(PendingEntitySubscription), + DapStore(PendingEntitySubscription), } #[derive(Clone)] @@ -618,6 +650,8 @@ impl Project { SettingsObserver::init(&client); TaskStore::init(Some(&client)); ToolchainStore::init(&client); + BreakpointStore::init(&client); + DapStore::init(&client); } pub fn local( @@ -638,10 +672,37 @@ impl Project { cx.subscribe(&worktree_store, Self::on_worktree_store_event) .detach(); + let environment = ProjectEnvironment::new(&worktree_store, env, cx); + let toolchain_store = cx.new(|cx| { + ToolchainStore::local( + languages.clone(), + worktree_store.clone(), + environment.clone(), + cx, + ) + }); + let buffer_store = cx.new(|cx| BufferStore::local(worktree_store.clone(), cx)); cx.subscribe(&buffer_store, Self::on_buffer_store_event) .detach(); + let breakpoint_store = cx + .new(|cx| BreakpointStore::local(buffer_store.clone(), worktree_store.clone(), cx)); + + let dap_store = cx.new(|cx| { + DapStore::new_local( + client.http_client(), + node.clone(), + fs.clone(), + languages.clone(), + environment.clone(), + toolchain_store.read(cx).as_language_toolchain_store(), + breakpoint_store.clone(), + cx, + ) + }); + cx.subscribe(&dap_store, Self::on_dap_store_event).detach(); + let image_store = cx.new(|cx| ImageStore::local(worktree_store.clone(), cx)); cx.subscribe(&image_store, Self::on_image_store_event) .detach(); @@ -656,15 +717,6 @@ impl Project { ) }); - let environment = ProjectEnvironment::new(&worktree_store, env, cx); - let toolchain_store = cx.new(|cx| { - ToolchainStore::local( - languages.clone(), - worktree_store.clone(), - environment.clone(), - cx, - ) - }); let task_store = cx.new(|cx| { TaskStore::local( fs.clone(), @@ -691,6 +743,7 @@ impl Project { LspStore::new_local( buffer_store.clone(), worktree_store.clone(), + dap_store.clone(), prettier_store.clone(), toolchain_store.clone(), environment.clone(), @@ -727,6 +780,8 @@ impl Project { settings_observer, fs, ssh_client: None, + breakpoint_store, + dap_store, buffers_needing_diff: Default::default(), git_diff_debouncer: DebouncedDelay::new(), terminals: Terminals { @@ -822,6 +877,24 @@ impl Project { }); cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach(); + let breakpoint_store = cx.new(|cx| { + BreakpointStore::remote( + SSH_PROJECT_ID, + client.clone().into(), + buffer_store.clone(), + worktree_store.clone(), + cx, + ) + }); + + let dap_store = cx.new(|_| { + DapStore::new_remote( + SSH_PROJECT_ID, + client.clone().into(), + breakpoint_store.clone(), + ) + }); + let git_store = cx.new(|cx| { GitStore::new( &worktree_store, @@ -842,6 +915,8 @@ impl Project { buffer_store, image_store, lsp_store, + breakpoint_store, + dap_store, join_project_response_message_id: 0, client_state: ProjectClientState::Local, git_store, @@ -893,6 +968,7 @@ impl Project { ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.buffer_store); ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.worktree_store); ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.lsp_store); + ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.dap_store); ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.settings_observer); ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.git_store); @@ -908,6 +984,8 @@ impl Project { SettingsObserver::init(&ssh_proto); TaskStore::init(Some(&ssh_proto)); ToolchainStore::init(&ssh_proto); + BreakpointStore::init(&ssh_proto); + DapStore::init(&ssh_proto); GitStore::init(&ssh_proto); this @@ -952,6 +1030,7 @@ impl Project { EntitySubscription::SettingsObserver( client.subscribe_to_entity::(remote_id)?, ), + EntitySubscription::DapStore(client.subscribe_to_entity::(remote_id)?), ]; let response = client .request_envelope(proto::JoinProject { @@ -974,7 +1053,7 @@ impl Project { #[allow(clippy::too_many_arguments)] async fn from_join_project_response( response: TypedEnvelope, - subscriptions: [EntitySubscription; 5], + subscriptions: [EntitySubscription; 6], client: Arc, run_tasks: bool, user_store: Entity, @@ -995,6 +1074,27 @@ impl Project { ImageStore::remote(worktree_store.clone(), client.clone().into(), remote_id, cx) })?; + let environment = cx.update(|cx| ProjectEnvironment::new(&worktree_store, None, cx))?; + + let breakpoint_store = cx.new(|cx| { + let mut bp_store = { + BreakpointStore::remote( + remote_id, + client.clone().into(), + buffer_store.clone(), + worktree_store.clone(), + cx, + ) + }; + + bp_store.set_breakpoints_from_proto(response.payload.breakpoints, cx); + bp_store + })?; + + let dap_store = cx.new(|_cx| { + DapStore::new_remote(remote_id, client.clone().into(), breakpoint_store.clone()) + })?; + let lsp_store = cx.new(|cx| { let mut lsp_store = LspStore::new_remote( buffer_store.clone(), @@ -1065,6 +1165,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(), @@ -1090,6 +1192,8 @@ impl Project { remote_id, replica_id, }, + breakpoint_store, + dap_store: dap_store.clone(), git_store, buffers_needing_diff: Default::default(), git_diff_debouncer: DebouncedDelay::new(), @@ -1100,7 +1204,7 @@ impl Project { search_history: Self::new_search_history(), search_included_history: Self::new_search_history(), search_excluded_history: Self::new_search_history(), - environment: ProjectEnvironment::new(&worktree_store, None, cx), + environment, remotely_created_models: Arc::new(Mutex::new(RemotelyCreatedModels::default())), toolchain_store: None, }; @@ -1129,6 +1233,9 @@ impl Project { EntitySubscription::LspStore(subscription) => { subscription.set_entity(&lsp_store, &mut cx) } + EntitySubscription::DapStore(subscription) => { + subscription.set_entity(&dap_store, &mut cx) + } }) .collect::>(); @@ -1186,6 +1293,28 @@ impl Project { } } + pub fn start_debug_session( + &mut self, + config: DebugAdapterConfig, + cx: &mut Context, + ) -> Task>> { + let worktree = maybe!({ + if let Some(cwd) = &config.cwd { + Some(self.find_worktree(cwd.as_path(), cx)?.0) + } else { + self.worktrees(cx).next() + } + }); + + let Some(worktree) = &worktree else { + return Task::ready(Err(anyhow!("Failed to find a worktree"))); + }; + + self.dap_store.update(cx, |dap_store, cx| { + dap_store.new_session(config, worktree, cx) + }) + } + #[cfg(any(test, feature = "test-support"))] pub async fn example( root_paths: impl IntoIterator, @@ -1267,6 +1396,14 @@ impl Project { project } + pub fn dap_store(&self) -> Entity { + self.dap_store.clone() + } + + pub fn breakpoint_store(&self) -> Entity { + self.breakpoint_store.clone() + } + pub fn lsp_store(&self) -> Entity { self.lsp_store.clone() } @@ -1685,6 +1822,12 @@ impl Project { self.client .subscribe_to_entity(project_id)? .set_entity(&self.settings_observer, &mut cx.to_async()), + self.client + .subscribe_to_entity(project_id)? + .set_entity(&self.dap_store, &mut cx.to_async()), + self.client + .subscribe_to_entity(project_id)? + .set_entity(&self.breakpoint_store, &mut cx.to_async()), self.client .subscribe_to_entity(project_id)? .set_entity(&self.git_store, &mut cx.to_async()), @@ -1699,6 +1842,12 @@ impl Project { self.lsp_store.update(cx, |lsp_store, cx| { lsp_store.shared(project_id, self.client.clone().into(), cx) }); + self.breakpoint_store.update(cx, |breakpoint_store, _| { + breakpoint_store.shared(project_id, self.client.clone().into()) + }); + self.dap_store.update(cx, |dap_store, cx| { + dap_store.shared(project_id, self.client.clone().into(), cx); + }); self.task_store.update(cx, |task_store, cx| { task_store.shared(project_id, self.client.clone().into(), cx); }); @@ -1754,6 +1903,9 @@ impl Project { self.lsp_store.update(cx, |lsp_store, _| { lsp_store.set_language_server_statuses_from_proto(message.language_servers) }); + self.breakpoint_store.update(cx, |breakpoint_store, cx| { + breakpoint_store.set_breakpoints_from_proto(message.breakpoints, cx); + }); self.enqueue_buffer_ordered_message(BufferOrderedMessage::Resync) .unwrap(); cx.emit(Event::Rejoined); @@ -1786,6 +1938,12 @@ impl Project { self.task_store.update(cx, |task_store, cx| { task_store.unshared(cx); }); + self.breakpoint_store.update(cx, |breakpoint_store, cx| { + breakpoint_store.unshared(cx); + }); + self.dap_store.update(cx, |dap_store, cx| { + dap_store.unshared(cx); + }); self.settings_observer.update(cx, |settings_observer, cx| { settings_observer.unshared(cx); }); @@ -1931,7 +2089,7 @@ impl Project { cx: &mut Context, ) -> Task, AnyEntity)>> { 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)) @@ -2279,6 +2437,7 @@ impl Project { .log_err(); } } + BufferStoreEvent::BufferOpened { .. } => {} } } @@ -2298,6 +2457,41 @@ impl Project { } } + fn on_dap_store_event( + &mut self, + _: Entity, + event: &DapStoreEvent, + cx: &mut Context, + ) { + match event { + DapStoreEvent::DebugClientStarted(session_id) => { + cx.emit(Event::DebugClientStarted(*session_id)); + } + DapStoreEvent::DebugClientShutdown(session_id) => { + cx.emit(Event::DebugClientShutdown(*session_id)); + } + DapStoreEvent::DebugClientEvent { + session_id, + message, + } => { + cx.emit(Event::DebugClientEvent { + session_id: *session_id, + message: message.clone(), + }); + } + DapStoreEvent::Notification(message) => { + cx.emit(Event::Toast { + notification_id: "dap".into(), + message: message.clone(), + }); + } + DapStoreEvent::ActiveDebugLineChanged => { + cx.emit(Event::ActiveDebugLineChanged); + } + _ => {} + } + } + fn on_lsp_store_event( &mut self, _: Entity, @@ -3628,6 +3822,29 @@ impl Project { None } + pub fn project_path_for_absolute_path(&self, abs_path: &Path, cx: &App) -> 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: &App, + ) -> Option<(Entity, PathBuf)> { + let trees = self.worktrees(cx); + + for tree in trees { + if let Some(relative_path) = abs_path.strip_prefix(tree.read(cx).abs_path()).ok() { + return Some((tree.clone(), relative_path.into())); + } + } + None + } + pub fn get_workspace_root(&self, project_path: &ProjectPath, cx: &App) -> Option { Some( self.worktree_for_id(project_path.worktree_id, cx)? diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index f884ef4780c3a5..4bf72d2a40a3ad 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -1,11 +1,12 @@ use anyhow::Context as _; use collections::HashMap; +use dap::adapters::DebugAdapterName; use fs::Fs; use gpui::{App, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter}; use lsp::LanguageServerName; use paths::{ - local_settings_file_relative_path, local_tasks_file_relative_path, - local_vscode_tasks_file_relative_path, EDITORCONFIG_NAME, + local_debug_file_relative_path, local_settings_file_relative_path, + local_tasks_file_relative_path, local_vscode_tasks_file_relative_path, EDITORCONFIG_NAME, }; use rpc::{ proto::{self, FromProto, ToProto}, @@ -15,7 +16,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{ parse_json_with_comments, InvalidSettingsError, LocalSettingsKind, Settings, SettingsLocation, - SettingsSources, SettingsStore, + SettingsSources, SettingsStore, TaskKind, }; use std::{path::Path, sync::Arc, time::Duration}; use task::{TaskTemplates, VsCodeTaskFile}; @@ -40,6 +41,10 @@ pub struct ProjectSettings { #[serde(default)] pub lsp: HashMap, + /// Configuration for Debugger-related features + #[serde(default)] + pub dap: HashMap, + /// Configuration for Diagnostics-related features. #[serde(default)] pub diagnostics: DiagnosticsSettings, @@ -61,6 +66,12 @@ pub struct ProjectSettings { pub session: SessionSettings, } +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct DapSettings { + pub binary: Option, +} + #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct NodeBinarySettings { /// The path to the Node binary. @@ -469,7 +480,7 @@ impl SettingsObserver { ) .unwrap(), ); - (settings_dir, LocalSettingsKind::Tasks) + (settings_dir, LocalSettingsKind::Tasks(TaskKind::Script)) } else if path.ends_with(local_vscode_tasks_file_relative_path()) { let settings_dir = Arc::::from( path.ancestors() @@ -481,7 +492,19 @@ impl SettingsObserver { ) .unwrap(), ); - (settings_dir, LocalSettingsKind::Tasks) + (settings_dir, LocalSettingsKind::Tasks(TaskKind::Script)) + } else if path.ends_with(local_debug_file_relative_path()) { + let settings_dir = Arc::::from( + path.ancestors() + .nth( + local_debug_file_relative_path() + .components() + .count() + .saturating_sub(1), + ) + .unwrap(), + ); + (settings_dir, LocalSettingsKind::Tasks(TaskKind::Debug)) } else if path.ends_with(EDITORCONFIG_NAME) { let Some(settings_dir) = path.parent().map(Arc::from) else { continue; @@ -602,7 +625,7 @@ impl SettingsObserver { } } }), - LocalSettingsKind::Tasks => task_store.update(cx, |task_store, cx| { + LocalSettingsKind::Tasks(task_kind) => task_store.update(cx, |task_store, cx| { task_store .update_user_tasks( Some(SettingsLocation { @@ -610,6 +633,7 @@ impl SettingsObserver { path: directory.as_ref(), }), file_content.as_deref(), + task_kind, cx, ) .log_err(); @@ -634,7 +658,7 @@ impl SettingsObserver { pub fn local_settings_kind_from_proto(kind: proto::LocalSettingsKind) -> LocalSettingsKind { match kind { proto::LocalSettingsKind::Settings => LocalSettingsKind::Settings, - proto::LocalSettingsKind::Tasks => LocalSettingsKind::Tasks, + proto::LocalSettingsKind::Tasks => LocalSettingsKind::Tasks(TaskKind::Script), proto::LocalSettingsKind::Editorconfig => LocalSettingsKind::Editorconfig, } } @@ -642,7 +666,7 @@ pub fn local_settings_kind_from_proto(kind: proto::LocalSettingsKind) -> LocalSe pub fn local_settings_kind_to_proto(kind: LocalSettingsKind) -> proto::LocalSettingsKind { match kind { LocalSettingsKind::Settings => proto::LocalSettingsKind::Settings, - LocalSettingsKind::Tasks => proto::LocalSettingsKind::Tasks, + LocalSettingsKind::Tasks(_) => proto::LocalSettingsKind::Tasks, LocalSettingsKind::Editorconfig => proto::LocalSettingsKind::Editorconfig, } } diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 150fc9fa56c3da..815ad6b634259d 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -336,6 +336,7 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext) }]) .to_string(), ), + settings::TaskKind::Script, ) .unwrap(); }); diff --git a/crates/project/src/task_inventory.rs b/crates/project/src/task_inventory.rs index b727d1365b6ca8..a0c494ab35e203 100644 --- a/crates/project/src/task_inventory.rs +++ b/crates/project/src/task_inventory.rs @@ -13,9 +13,10 @@ use collections::{HashMap, HashSet, VecDeque}; use gpui::{App, AppContext as _, Entity, SharedString, Task}; use itertools::Itertools; use language::{ContextProvider, File, Language, LanguageToolchainStore, Location}; -use settings::{parse_json_with_comments, SettingsLocation}; +use settings::{parse_json_with_comments, SettingsLocation, TaskKind}; use task::{ - ResolvedTask, TaskContext, TaskId, TaskTemplate, TaskTemplates, TaskVariables, VariableName, + DebugTaskDefinition, ResolvedTask, TaskContext, TaskId, TaskTemplate, TaskTemplates, + TaskVariables, VariableName, }; use text::{Point, ToPoint}; use util::{paths::PathExt as _, post_inc, NumericPrefixWithSuffix, ResultExt as _}; @@ -33,7 +34,7 @@ pub struct Inventory { #[derive(Debug, Default)] struct ParsedTemplates { global: Vec, - worktree: HashMap, Vec>>, + worktree: HashMap, TaskKind), Vec>>, } /// Kind of a source the tasks are fetched from, used to display more source information in the UI. @@ -252,8 +253,14 @@ impl Inventory { .map(|template| { ( TaskSourceKind::AbsPath { - id_base: Cow::Borrowed("global tasks.json"), - abs_path: paths::tasks_file().clone(), + id_base: match template.task_type { + task::TaskType::Script => Cow::Borrowed("global tasks.json"), + task::TaskType::Debug(_) => Cow::Borrowed("global debug.json"), + }, + abs_path: match template.task_type { + task::TaskType::Script => paths::tasks_file().clone(), + task::TaskType::Debug(_) => paths::debug_tasks_file().clone(), + }, }, template, ) @@ -273,7 +280,7 @@ impl Inventory { .flat_map(|(directory, templates)| { templates.iter().map(move |template| (directory, template)) }) - .map(move |(directory, template)| { + .map(move |((directory, _task_kind), template)| { ( TaskSourceKind::Worktree { id: worktree, @@ -296,13 +303,19 @@ impl Inventory { &mut self, location: Option>, raw_tasks_json: Option<&str>, + task_kind: TaskKind, ) -> anyhow::Result<()> { let raw_tasks = parse_json_with_comments::>(raw_tasks_json.unwrap_or("[]")) .context("parsing tasks file content as a JSON array")?; - let new_templates = raw_tasks.into_iter().filter_map(|raw_template| { - serde_json::from_value::(raw_template).log_err() - }); + let new_templates = raw_tasks + .into_iter() + .filter_map(|raw_template| match &task_kind { + TaskKind::Script => serde_json::from_value::(raw_template).log_err(), + TaskKind::Debug => serde_json::from_value::(raw_template) + .log_err() + .and_then(|content| content.to_zed_format().log_err()), + }); let parsed_templates = &mut self.templates_from_settings; match location { @@ -312,14 +325,14 @@ impl Inventory { if let Some(worktree_tasks) = parsed_templates.worktree.get_mut(&location.worktree_id) { - worktree_tasks.remove(location.path); + worktree_tasks.remove(&(Arc::from(location.path), task_kind)); } } else { parsed_templates .worktree .entry(location.worktree_id) .or_default() - .insert(Arc::from(location.path), new_templates); + .insert((Arc::from(location.path), task_kind), new_templates); } } None => parsed_templates.global = new_templates.collect(), @@ -599,6 +612,7 @@ mod tests { Some(&mock_tasks_from_names( expected_initial_state.iter().map(|name| name.as_str()), )), + settings::TaskKind::Script, ) .unwrap(); }); @@ -654,6 +668,7 @@ mod tests { .into_iter() .chain(expected_initial_state.iter().map(|name| name.as_str())), )), + settings::TaskKind::Script, ) .unwrap(); }); @@ -778,6 +793,7 @@ mod tests { .iter() .map(|(_, name)| name.as_str()), )), + settings::TaskKind::Script, ) .unwrap(); inventory @@ -789,6 +805,7 @@ mod tests { Some(&mock_tasks_from_names( worktree_1_tasks.iter().map(|(_, name)| name.as_str()), )), + settings::TaskKind::Script, ) .unwrap(); inventory @@ -800,6 +817,7 @@ mod tests { Some(&mock_tasks_from_names( worktree_2_tasks.iter().map(|(_, name)| name.as_str()), )), + settings::TaskKind::Script, ) .unwrap(); }); diff --git a/crates/project/src/task_store.rs b/crates/project/src/task_store.rs index 925575c033cd11..0d1ccc270d16c5 100644 --- a/crates/project/src/task_store.rs +++ b/crates/project/src/task_store.rs @@ -10,7 +10,7 @@ use language::{ ContextProvider as _, LanguageToolchainStore, Location, }; use rpc::{proto, AnyProtoClient, TypedEnvelope}; -use settings::{watch_config_file, SettingsLocation}; +use settings::{watch_config_file, SettingsLocation, TaskKind}; use task::{TaskContext, TaskVariables, VariableName}; use text::{BufferId, OffsetRangeExt}; use util::ResultExt; @@ -32,7 +32,7 @@ pub struct StoreState { buffer_store: WeakEntity, worktree_store: Entity, toolchain_store: Arc, - _global_task_config_watcher: Task<()>, + _global_task_config_watchers: (Task<()>, Task<()>), } enum StoreMode { @@ -168,7 +168,20 @@ impl TaskStore { buffer_store, toolchain_store, worktree_store, - _global_task_config_watcher: Self::subscribe_to_global_task_file_changes(fs, cx), + _global_task_config_watchers: ( + Self::subscribe_to_global_task_file_changes( + fs.clone(), + TaskKind::Script, + paths::tasks_file().clone(), + cx, + ), + Self::subscribe_to_global_task_file_changes( + fs.clone(), + TaskKind::Debug, + paths::debug_tasks_file().clone(), + cx, + ), + ), }) } @@ -190,7 +203,20 @@ impl TaskStore { buffer_store, toolchain_store, worktree_store, - _global_task_config_watcher: Self::subscribe_to_global_task_file_changes(fs, cx), + _global_task_config_watchers: ( + Self::subscribe_to_global_task_file_changes( + fs.clone(), + TaskKind::Script, + paths::tasks_file().clone(), + cx, + ), + Self::subscribe_to_global_task_file_changes( + fs.clone(), + TaskKind::Debug, + paths::debug_tasks_file().clone(), + cx, + ), + ), }) } @@ -262,6 +288,7 @@ impl TaskStore { &self, location: Option>, raw_tasks_json: Option<&str>, + task_type: TaskKind, cx: &mut Context<'_, Self>, ) -> anyhow::Result<()> { let task_inventory = match self { @@ -273,22 +300,23 @@ impl TaskStore { .filter(|json| !json.is_empty()); task_inventory.update(cx, |inventory, _| { - inventory.update_file_based_tasks(location, raw_tasks_json) + inventory.update_file_based_tasks(location, raw_tasks_json, task_type) }) } fn subscribe_to_global_task_file_changes( fs: Arc, + task_kind: TaskKind, + file_path: PathBuf, cx: &mut Context<'_, Self>, ) -> Task<()> { - let mut user_tasks_file_rx = - watch_config_file(&cx.background_executor(), fs, paths::tasks_file().clone()); + let mut user_tasks_file_rx = watch_config_file(&cx.background_executor(), fs, file_path); let user_tasks_content = cx.background_executor().block(user_tasks_file_rx.next()); cx.spawn(move |task_store, mut cx| async move { if let Some(user_tasks_content) = user_tasks_content { let Ok(_) = task_store.update(&mut cx, |task_store, cx| { task_store - .update_user_tasks(None, Some(&user_tasks_content), cx) + .update_user_tasks(None, Some(&user_tasks_content), task_kind, cx) .log_err(); }) else { return; @@ -296,12 +324,17 @@ impl TaskStore { } while let Some(user_tasks_content) = user_tasks_file_rx.next().await { let Ok(()) = task_store.update(&mut cx, |task_store, cx| { - let result = task_store.update_user_tasks(None, Some(&user_tasks_content), cx); + let result = task_store.update_user_tasks( + None, + Some(&user_tasks_content), + task_kind, + cx, + ); if let Err(err) = &result { - log::error!("Failed to load user tasks: {err}"); + log::error!("Failed to load user {:?} tasks: {err}", task_kind); cx.emit(crate::Event::Toast { - notification_id: "load-user-tasks".into(), - message: format!("Invalid global tasks file\n{err}"), + notification_id: format!("load-user-{:?}-tasks", task_kind).into(), + message: format!("Invalid global {:?} tasks file\n{err}", task_kind), }); } cx.refresh_windows(); diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index cd647936039e1b..941abdf82d7d13 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -35,6 +35,14 @@ pub enum TerminalKind { Shell(Option), /// Run a task. Task(SpawnInTerminal), + /// Run a debug terminal. + Debug { + command: Option, + args: Vec, + envs: HashMap, + cwd: PathBuf, + title: Option, + }, } /// SshCommand describes how to connect to a remote server @@ -93,6 +101,7 @@ impl Project { self.active_project_directory(cx) } } + TerminalKind::Debug { cwd, .. } => Some(Arc::from(cwd.as_path())), }; let mut settings_location = None; @@ -196,6 +205,7 @@ impl Project { this.active_project_directory(cx) } } + TerminalKind::Debug { cwd, .. } => Some(Arc::from(cwd.as_path())), }; let ssh_details = this.ssh_details(cx); @@ -229,6 +239,7 @@ impl Project { }; let mut python_venv_activate_command = None; + let debug_terminal = matches!(kind, TerminalKind::Debug { .. }); let (spawn_task, shell) = match kind { TerminalKind::Shell(_) => { @@ -323,6 +334,27 @@ impl Project { } } } + TerminalKind::Debug { + command, + args, + envs, + title, + .. + } => { + env.extend(envs); + + let shell = if let Some(program) = command { + Shell::WithArguments { + program, + args, + title_override: Some(title.unwrap_or("Debug Terminal".into()).into()), + } + } else { + settings.shell.clone() + }; + + (None, shell) + } }; TerminalBuilder::new( local_path.map(|path| path.to_path_buf()), @@ -336,6 +368,7 @@ impl Project { ssh_details.is_some(), window, completion_tx, + debug_terminal, cx, ) .map(|builder| { diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 078ae81b86ce75..7d87c3613722d6 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1172,6 +1172,22 @@ impl ProjectPanel { } Ok(CreatedEntry::Included(new_entry)) => { project_panel.update(&mut cx, |project_panel, cx| { + project_panel.project.update(cx, |project, cx| { + let old_path = ProjectPath { + worktree_id, + path: entry.path, + }; + + let new_path = ProjectPath { + worktree_id, + path: new_entry.path.clone() + }; + + project.breakpoint_store().update(cx, |breakpoint_store, cx| { + breakpoint_store.on_file_rename(old_path, new_path, cx); + }); + }); + if let Some(selection) = &mut project_panel.selection { if selection.entry_id == edited_entry_id { selection.worktree_id = worktree_id; diff --git a/crates/proto/build.rs b/crates/proto/build.rs index d94d80082aefe8..b16aad1b6909b8 100644 --- a/crates/proto/build.rs +++ b/crates/proto/build.rs @@ -2,6 +2,9 @@ fn main() { let mut build = prost_build::Config::new(); build .type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]") + .type_attribute("ProjectPath", "#[derive(Hash, Eq)]") + .type_attribute("Breakpoint", "#[derive(Hash, Eq)]") + .type_attribute("Anchor", "#[derive(Hash, Eq)]") .compile_protos(&["proto/zed.proto"], &["proto"]) .unwrap(); } diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 2a708a7e31fe34..2a458b61d82eee 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -316,12 +316,53 @@ message Envelope { OpenUncommittedDiff open_uncommitted_diff = 297; OpenUncommittedDiffResponse open_uncommitted_diff_response = 298; + + SetIndexText set_index_text = 299; + GitShow git_show = 300; GitReset git_reset = 301; GitCommitDetails git_commit_details = 302; - - SetIndexText set_index_text = 299; - GitCheckoutFiles git_checkout_files = 303; // current max + GitCheckoutFiles git_checkout_files = 303; + + SynchronizeBreakpoints synchronize_breakpoints = 304; + SetActiveDebugLine set_active_debug_line = 305; + RemoveActiveDebugLine remove_active_debug_line = 306; + UpdateDebugAdapter update_debug_adapter = 307; + ShutdownDebugClient shutdown_debug_client = 308; + SetDebugClientCapabilities set_debug_client_capabilities = 309; + DapNextRequest dap_next_request = 310; + DapStepInRequest dap_step_in_request = 311; + DapStepOutRequest dap_step_out_request = 312; + DapStepBackRequest dap_step_back_request = 313; + DapContinueRequest dap_continue_request = 314; + DapContinueResponse dap_continue_response = 315; + DapPauseRequest dap_pause_request = 316; + DapDisconnectRequest dap_disconnect_request = 317; + DapTerminateThreadsRequest dap_terminate_threads_request = 318; + DapTerminateRequest dap_terminate_request = 319; + DapRestartRequest dap_restart_request = 320; + UpdateThreadStatus update_thread_status = 321; + VariablesRequest variables_request = 322; + DapVariables dap_variables = 323; + DapRestartStackFrameRequest dap_restart_stack_frame_request = 324; + IgnoreBreakpointState ignore_breakpoint_state = 325; + ToggleIgnoreBreakpoints toggle_ignore_breakpoints = 326; + DapModulesRequest dap_modules_request = 327; + DapModulesResponse dap_modules_response = 328; + DapLoadedSourcesRequest dap_loaded_sources_request = 329; + DapLoadedSourcesResponse dap_loaded_sources_response = 330; + DapStackTraceRequest dap_stack_trace_request = 331; + DapStackTraceResponse dap_stack_trace_response = 332; + DapScopesRequest dap_scopes_request = 333; + DapScopesResponse dap_scopes_response = 334; + DapSetVariableValueRequest dap_set_variable_value_request = 335; + DapSetVariableValueResponse dap_set_variable_value_response = 336; + DapEvaluateRequest dap_evaluate_request = 337; + DapEvaluateResponse dap_evaluate_response = 338; + DapCompletionRequest dap_completion_request = 339; + DapCompletionResponse dap_completion_response = 340; + DapThreadsRequest dap_threads_request = 341; + DapThreadsResponse dap_threads_response = 342;// current max } reserved 87 to 88; @@ -441,6 +482,7 @@ message RejoinedProject { repeated WorktreeMetadata worktrees = 2; repeated Collaborator collaborators = 3; repeated LanguageServer language_servers = 4; + repeated SynchronizeBreakpoints breakpoints = 5; } message LeaveRoom {} @@ -578,6 +620,7 @@ message JoinProjectResponse { repeated LanguageServer language_servers = 4; ChannelRole role = 6; reserved 7; + repeated SynchronizeBreakpoints breakpoints = 8; } message LeaveProject { @@ -1711,6 +1754,7 @@ message UpdateActiveView { enum PanelId { AssistantPanel = 0; + DebugPanel = 1; } message UpdateView { @@ -1765,6 +1809,7 @@ message View { } } + message Collaborator { PeerId peer_id = 1; uint32 replica_id = 2; @@ -2503,6 +2548,602 @@ message GetLlmTokenResponse { message RefreshLlmToken {} +// Remote debugging + +enum BreakpointKind { + Standard = 0; + Log = 1; +} + + +message ActiveDebugClientsResponse { + repeated DebugClient clients = 1; +} + +message DebugClient { + uint64 client_id = 1; + SetDebugClientCapabilities capabilities = 2; + bool ignore_breakpoints = 3; +} + +message ShutdownDebugClient { + uint64 project_id = 1; + uint64 session_id = 2; +} + +message SetDebugClientCapabilities { + uint64 session_id = 1; + uint64 project_id = 2; + bool supports_loaded_sources_request = 3; + bool supports_modules_request = 4; + bool supports_restart_request = 5; + bool supports_set_expression = 6; + bool supports_single_thread_execution_requests = 7; + bool supports_step_back = 8; + bool supports_stepping_granularity = 9; + bool supports_terminate_threads_request = 10; + bool supports_restart_frame_request = 11; + bool supports_clipboard_context = 12; +} + +message Breakpoint { + Anchor position = 1; + uint32 cached_position = 2; + BreakpointKind kind = 3; + optional string message = 4; +} + +message SynchronizeBreakpoints { + uint64 project_id = 1; + ProjectPath project_path = 2; + repeated Breakpoint breakpoints = 3; +} + +message SetActiveDebugLine { + uint64 project_id = 1; + ProjectPath project_path = 2; + uint64 session_id = 3; + uint32 row = 4; +} + +message RemoveActiveDebugLine { + uint64 project_id = 1; +} + +enum DebuggerThreadItem { + Console = 0; + LoadedSource = 1; + Modules = 2; + Variables = 3; +} + +message DebuggerSetVariableState { + string name = 1; + DapScope scope = 2; + string value = 3; + uint64 stack_frame_id = 4; + optional string evaluate_name = 5; + uint64 parent_variables_reference = 6; +} + +message VariableListOpenEntry { + oneof entry { + DebuggerOpenEntryScope scope = 1; + DebuggerOpenEntryVariable variable = 2; + } +} + +message DebuggerOpenEntryScope { + string name = 1; +} + +message DebuggerOpenEntryVariable { + string scope_name = 1; + string name = 2; + uint64 depth = 3; +} + +message VariableListEntrySetState { + uint64 depth = 1; + DebuggerSetVariableState state = 2; +} + +message VariableListEntryVariable { + uint64 depth = 1; + DapScope scope = 2; + DapVariable variable = 3; + bool has_children = 4; + uint64 container_reference = 5; +} + +message DebuggerScopeVariableIndex { + repeated uint64 fetched_ids = 1; + repeated DebuggerVariableContainer variables = 2; +} + +message DebuggerVariableContainer { + uint64 container_reference = 1; + DapVariable variable = 2; + uint64 depth = 3; +} + +message DebuggerThreadState { + DebuggerThreadStatus thread_status = 1; + bool stopped = 2; +} + +message UpdateThreadStatus { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 thread_id = 3; + DebuggerThreadStatus status = 4; +} + +enum DebuggerThreadStatus { + Running = 0; + Stopped = 1; + Exited = 2; + Ended = 3; +} + +message VariableListScopes { + uint64 stack_frame_id = 1; + repeated DapScope scopes = 2; +} + +message VariableListVariables { + uint64 stack_frame_id = 1; + uint64 scope_id = 2; + DebuggerScopeVariableIndex variables = 3; +} + +message DebuggerVariableList { + repeated VariableListScopes scopes = 1; + repeated VariableListVariables variables = 2; +} + +enum VariablesArgumentsFilter { + Indexed = 0; + Named = 1; +} + +message ValueFormat { + optional bool hex = 1; +} + +message VariablesRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 thread_id = 3; + uint64 stack_frame_id = 4; + uint64 variables_reference = 5; + optional VariablesArgumentsFilter filter = 6; + optional uint64 start = 7; + optional uint64 count = 8; + optional ValueFormat format = 9; +} + + +message DebuggerStackFrameList { + uint64 thread_id = 1; + uint64 client_id = 2; + uint64 current_stack_frame = 3; + repeated DapStackFrame stack_frames = 4; +} + +enum SteppingGranularity { + Statement = 0; + Line = 1; + Instruction = 2; +} + +enum DapEvaluateContext { + Repl = 0; + Watch = 1; + Hover = 2; + Clipboard = 3; + EvaluateVariables = 4; + EvaluateUnknown = 5; +} + +message DapEvaluateRequest { + uint64 project_id = 1; + uint64 client_id = 2; + string expression = 3; + optional uint64 frame_id = 4; + optional DapEvaluateContext context = 5; +} + +message DapEvaluateResponse { + string result = 1; + optional string evaluate_type = 2; + uint64 variable_reference = 3; + optional uint64 named_variables = 4; + optional uint64 indexed_variables = 5; + optional string memory_reference = 6; +} + + +message DapCompletionRequest { + uint64 project_id = 1; + uint64 client_id = 2; + string query = 3; + optional uint64 frame_id = 4; + optional uint64 line = 5; + uint64 column = 6; +} + +enum DapCompletionItemType { + Method = 0; + Function = 1; + Constructor = 2; + Field = 3; + Variable = 4; + Class = 5; + Interface = 6; + Module = 7; + Property = 8; + Unit = 9; + Value = 10; + Enum = 11; + Keyword = 12; + Snippet = 13; + Text = 14; + Color = 15; + CompletionItemFile = 16; + Reference = 17; + Customcolor = 19; +} + +message DapCompletionItem { + string label = 1; + optional string text = 2; + optional string sort_text = 3; + optional string detail = 4; + optional DapCompletionItemType typ = 5; + optional uint64 start = 6; + optional uint64 length = 7; + optional uint64 selection_start = 8; + optional uint64 selection_length = 9; +} + +message DapCompletionResponse { + uint64 client_id = 1; + repeated DapCompletionItem completions = 2; +} + +message DapScopesRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 thread_id = 3; + uint64 stack_frame_id = 4; +} + +message DapScopesResponse { + repeated DapScope scopes = 1; +} + +message DapSetVariableValueRequest { + uint64 project_id = 1; + uint64 client_id = 2; + string name = 3; + string value = 4; + uint64 variables_reference = 5; +} + +message DapSetVariableValueResponse { + uint64 client_id = 1; + string value = 2; + optional string variable_type = 3; + optional uint64 variables_reference = 4; + optional uint64 named_variables = 5; + optional uint64 indexed_variables = 6; + optional string memory_reference = 7; +} + +message DapPauseRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 thread_id = 3; +} + +message DapDisconnectRequest { + uint64 project_id = 1; + uint64 client_id = 2; + optional bool restart = 3; + optional bool terminate_debuggee = 4; + optional bool suspend_debuggee = 5; +} + +message DapTerminateThreadsRequest { + uint64 project_id = 1; + uint64 client_id = 2; + repeated uint64 thread_ids = 3; +} + +message DapThreadsRequest { + uint64 project_id = 1; + uint64 client_id = 2; +} + +message DapThreadsResponse { + repeated DapThread threads = 1; +} + +message DapTerminateRequest { + uint64 project_id = 1; + uint64 client_id = 2; + optional bool restart = 3; +} + +message DapRestartRequest { + uint64 project_id = 1; + uint64 client_id = 2; + bytes raw_args = 3; +} + +message DapRestartStackFrameRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 stack_frame_id = 3; +} + +message ToggleIgnoreBreakpoints { + uint64 project_id = 1; + uint32 session_id = 2; +} + +message IgnoreBreakpointState { + uint64 project_id = 1; + uint64 session_id = 2; + bool ignore = 3; +} + +message DapNextRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 thread_id = 3; + optional bool single_thread = 4; + optional SteppingGranularity granularity = 5; +} + +message DapStepInRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 thread_id = 3; + optional uint64 target_id = 4; + optional bool single_thread = 5; + optional SteppingGranularity granularity = 6; +} + +message DapStepOutRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 thread_id = 3; + optional bool single_thread = 4; + optional SteppingGranularity granularity = 5; +} + +message DapStepBackRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 thread_id = 3; + optional bool single_thread = 4; + optional SteppingGranularity granularity = 5; +} + +message DapContinueRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 thread_id = 3; + optional bool single_thread = 4; +} + +message DapContinueResponse { + uint64 client_id = 1; + optional bool all_threads_continued = 2; +} + +message DapModulesRequest { + uint64 project_id = 1; + uint64 client_id = 2; +} + +message DapModulesResponse { + uint64 client_id = 1; + repeated DapModule modules = 2; +} + +message DapLoadedSourcesRequest { + uint64 project_id = 1; + uint64 client_id = 2; +} + +message DapLoadedSourcesResponse { + uint64 client_id = 1; + repeated DapSource sources = 2; +} + +message DapStackTraceRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 thread_id = 3; + optional uint64 start_frame = 4; + optional uint64 stack_trace_levels = 5; +} + +message DapStackTraceResponse { + repeated DapStackFrame frames = 1; +} + +message DapStackFrame { + uint64 id = 1; + string name = 2; + optional DapSource source = 3; + uint64 line = 4; + uint64 column = 5; + optional uint64 end_line = 6; + optional uint64 end_column = 7; + optional bool can_restart = 8; + optional string instruction_pointer_reference = 9; + optional DapModuleId module_id = 10; + optional DapStackPresentationHint presentation_hint = 11; +} + +message DebuggerLoadedSourceList { + uint64 client_id = 1; + repeated DapSource sources = 2; +} + +message DebuggerConsole { + // repeated DapOutputEvent events = 1; +} + +message DebuggerModuleList { + repeated DapModule modules = 1; + uint64 client_id = 2; +} + +message UpdateDebugAdapter { + uint64 project_id = 1; + uint64 client_id = 2; + optional uint64 thread_id = 3; + oneof variant { + DebuggerThreadState thread_state = 5; + DebuggerStackFrameList stack_frame_list = 6; + DebuggerVariableList variable_list = 7; + DebuggerModuleList modules = 8; + DapOutputEvent output_event = 9; + } +} + +message DapVariables { + uint64 client_id = 1; + repeated DapVariable variables = 2; +} + +// Remote Debugging: Dap Types +message DapVariable { + string name = 1; + string value = 2; + optional string type = 3; + // optional DapVariablePresentationHint presentation_hint = 4; + optional string evaluate_name = 5; + uint64 variables_reference = 6; + optional uint64 named_variables = 7; + optional uint64 indexed_variables = 8; + optional string memory_reference = 9; +} + +message DapThread { + uint64 id = 1; + string name = 2; +} + +message DapScope { + string name = 1; + optional DapScopePresentationHint presentation_hint = 2; + uint64 variables_reference = 3; + optional uint64 named_variables = 4; + optional uint64 indexed_variables = 5; + bool expensive = 6; + optional DapSource source = 7; + optional uint64 line = 8; + optional uint64 column = 9; + optional uint64 end_line = 10; + optional uint64 end_column = 11; +} + +message DapSource { + optional string name = 1; + optional string path = 2; + optional uint64 source_reference = 3; + optional DapSourcePresentationHint presentation_hint = 4; + optional string origin = 5; + repeated DapSource sources = 6; + optional bytes adapter_data = 7; + repeated DapChecksum checksums = 8; +} + +enum DapOutputCategory { + ConsoleOutput = 0; + Important = 1; + Stdout = 2; + Stderr = 3; + Unknown = 4; +} + +enum DapOutputEventGroup { + Start = 0; + StartCollapsed = 1; + End = 2; +} + +message DapOutputEvent { + string output = 1; + optional DapOutputCategory category = 2; + optional uint64 variables_reference = 3; + optional DapOutputEventGroup group = 4; + optional DapSource source = 5; + optional uint32 line = 6; + optional uint32 column = 7; +} + +enum DapChecksumAlgorithm { + CHECKSUM_ALGORITHM_UNSPECIFIED = 0; + MD5 = 1; + SHA1 = 2; + SHA256 = 3; + TIMESTAMP = 4; +} + +message DapChecksum { + DapChecksumAlgorithm algorithm = 1; + string checksum = 2; +} + +enum DapScopePresentationHint { + Arguments = 0; + Locals = 1; + Registers = 2; + ReturnValue = 3; + ScopeUnknown = 4; +} + +enum DapSourcePresentationHint { + SourceNormal = 0; + Emphasize = 1; + Deemphasize = 2; + SourceUnknown = 3; +} + +enum DapStackPresentationHint { + StackNormal = 0; + Label = 1; + Subtle = 2; + StackUnknown = 3; +} + +message DapModule { + DapModuleId id = 1; + string name = 2; + optional string path = 3; + optional bool is_optimized = 4; + optional bool is_user_code = 5; + optional string version = 6; + optional string symbol_status = 7; + optional string symbol_file_path = 8; + optional string date_time_stamp = 9; + optional string address_range = 10; +} + +message DapModuleId { + oneof id { + uint32 number = 1; + string string = 2; + } +} + // Remote FS message AddWorktree { diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 88737ed50e6767..7dcc61a777316c 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -201,38 +201,69 @@ messages!( (Ack, Foreground), (AckBufferOperation, Background), (AckChannelMessage, Background), + (ActivateToolchain, Foreground), + (ActiveToolchain, Foreground), + (ActiveToolchainResponse, Foreground), (AddNotification, Foreground), (AddProjectCollaborator, Foreground), + (AddWorktree, Foreground), + (AddWorktreeResponse, Foreground), + (AdvertiseContexts, Foreground), (ApplyCodeAction, Background), (ApplyCodeActionResponse, Background), (ApplyCompletionAdditionalEdits, Background), (ApplyCompletionAdditionalEditsResponse, Background), + (BlameBuffer, Foreground), + (BlameBufferResponse, Foreground), (BufferReloaded, Foreground), (BufferSaved, Foreground), (Call, Foreground), (CallCanceled, Foreground), (CancelCall, Foreground), + (CancelLanguageServerWork, Foreground), (ChannelMessageSent, Foreground), (ChannelMessageUpdate, Foreground), + (CloseBuffer, Foreground), (Commit, Background), (ComputeEmbeddings, Background), (ComputeEmbeddingsResponse, Background), (CopyProjectEntry, Foreground), + (CountLanguageModelTokens, Background), + (CountLanguageModelTokensResponse, Background), (CreateBufferForPeer, Foreground), (CreateChannel, Foreground), (CreateChannelResponse, Foreground), + (CreateContext, Foreground), + (CreateContextResponse, Foreground), (CreateProjectEntry, Foreground), (CreateRoom, Foreground), (CreateRoomResponse, Foreground), + (DapContinueRequest, Background), + (DapContinueResponse, Background), + (DapModulesRequest, Background), + (DapModulesResponse, Background), + (DapLoadedSourcesRequest, Background), + (DapLoadedSourcesResponse, Background), + (DapDisconnectRequest, Background), + (DapNextRequest, Background), + (DapPauseRequest, Background), + (DapRestartRequest, Background), + (DapRestartStackFrameRequest, Background), + (DapStepBackRequest, Background), + (DapStepInRequest, Background), + (DapStepOutRequest, Background), + (DapTerminateThreadsRequest, Background), (DeclineCall, Foreground), (DeleteChannel, Foreground), (DeleteNotification, Foreground), - (UpdateNotification, Foreground), (DeleteProjectEntry, Foreground), (EndStream, Foreground), (Error, Foreground), (ExpandProjectEntry, Foreground), (ExpandProjectEntryResponse, Foreground), + (FindSearchCandidatesResponse, Background), + (FindSearchCandidates, Background), + (FlushBufferedMessages, Foreground), (ExpandAllForProjectEntry, Foreground), (ExpandAllForProjectEntryResponse, Foreground), (Follow, Foreground), @@ -251,16 +282,22 @@ messages!( (GetCodeActionsResponse, Background), (GetCompletions, Background), (GetCompletionsResponse, Background), - (GetDefinition, Background), - (GetDefinitionResponse, Background), (GetDeclaration, Background), (GetDeclarationResponse, Background), + (GetDefinition, Background), + (GetDefinitionResponse, Background), (GetDocumentHighlights, Background), (GetDocumentHighlightsResponse, Background), (GetHover, Background), (GetHoverResponse, Background), (GetNotifications, Foreground), (GetNotificationsResponse, Foreground), + (GetPanicFiles, Background), + (GetPanicFilesResponse, Background), + (GetPathMetadata, Background), + (GetPathMetadataResponse, Background), + (GetPermalinkToLine, Foreground), + (GetPermalinkToLineResponse, Foreground), (GetPrivateUserInfo, Foreground), (GetPrivateUserInfoResponse, Foreground), (GetProjectSymbols, Background), @@ -282,10 +319,14 @@ messages!( (OpenUncommittedDiff, Foreground), (OpenUncommittedDiffResponse, Foreground), (GetUsers, Foreground), + (GitBranches, Background), + (GitBranchesResponse, Background), (Hello, Foreground), + (HideToast, Background), (IncomingCall, Foreground), (InlayHints, Background), (InlayHintsResponse, Background), + (InstallExtension, Background), (InviteChannelMember, Foreground), (JoinChannel, Foreground), (JoinChannelBuffer, Foreground), @@ -296,12 +337,29 @@ messages!( (JoinProjectResponse, Foreground), (JoinRoom, Foreground), (JoinRoomResponse, Foreground), + (LanguageServerLog, Foreground), + (LanguageServerPromptRequest, Foreground), + (LanguageServerPromptResponse, Foreground), (LeaveChannelBuffer, Background), (LeaveChannelChat, Foreground), (LeaveProject, Foreground), (LeaveRoom, Foreground), + (LinkedEditingRange, Background), + (LinkedEditingRangeResponse, Background), + (ListRemoteDirectory, Background), + (ListRemoteDirectoryResponse, Background), + (ListToolchains, Foreground), + (ListToolchainsResponse, Foreground), + (LspExtExpandMacro, Background), + (LspExtExpandMacroResponse, Background), + (LspExtOpenDocs, Background), + (LspExtOpenDocsResponse, Background), + (LspExtSwitchSourceHeader, Background), + (LspExtSwitchSourceHeaderResponse, Background), (MarkNotificationRead, Foreground), (MoveChannel, Foreground), + (MultiLspQuery, Background), + (MultiLspQueryResponse, Background), (OnTypeFormatting, Background), (OnTypeFormattingResponse, Background), (OpenBufferById, Background), @@ -310,27 +368,33 @@ messages!( (OpenBufferForSymbolResponse, Background), (OpenBufferResponse, Background), (OpenCommitMessageBuffer, Background), + (OpenContext, Foreground), + (OpenContextResponse, Foreground), + (OpenNewBuffer, Foreground), + (OpenServerSettings, Foreground), (PerformRename, Background), (PerformRenameResponse, Background), (Ping, Foreground), (PrepareRename, Background), (PrepareRenameResponse, Background), (ProjectEntryResponse, Foreground), - (CountLanguageModelTokens, Background), - (CountLanguageModelTokensResponse, Background), - (RefreshLlmToken, Background), (RefreshInlayHints, Foreground), + (RefreshLlmToken, Background), + (RegisterBufferWithLanguageServers, Background), (RejoinChannelBuffers, Foreground), (RejoinChannelBuffersResponse, Foreground), + (RejoinRemoteProjects, Foreground), + (RejoinRemoteProjectsResponse, Foreground), (RejoinRoom, Foreground), (RejoinRoomResponse, Foreground), (ReloadBuffers, Foreground), (ReloadBuffersResponse, Foreground), + (RemoveActiveDebugLine, Background), (RemoveChannelMember, Foreground), (RemoveChannelMessage, Foreground), - (UpdateChannelMessage, Foreground), (RemoveContact, Foreground), (RemoveProjectCollaborator, Foreground), + (RemoveWorktree, Foreground), (RenameChannel, Foreground), (RenameChannelResponse, Foreground), (RenameProjectEntry, Foreground), @@ -341,23 +405,35 @@ messages!( (ResolveInlayHintResponse, Background), (RespondToChannelInvite, Foreground), (RespondToContactRequest, Foreground), + (RestartLanguageServers, Foreground), (RoomUpdated, Foreground), (SaveBuffer, Foreground), - (SetChannelMemberRole, Foreground), - (SetChannelVisibility, Foreground), (SendChannelMessage, Background), (SendChannelMessageResponse, Background), + (SetActiveDebugLine, Background), + (SetChannelMemberRole, Foreground), + (SetChannelVisibility, Foreground), + (SetDebugClientCapabilities, Background), + (SetRoomParticipantRole, Foreground), (ShareProject, Foreground), (ShareProjectResponse, Foreground), (ShowContacts, Foreground), + (ShutdownDebugClient, Background), + (ShutdownRemoteServer, Foreground), (Stage, Background), (StartLanguageServer, Foreground), (SubscribeToChannels, Foreground), + (SyncExtensions, Background), + (SyncExtensionsResponse, Background), + (SynchronizeBreakpoints, Background), (SynchronizeBuffers, Foreground), (SynchronizeBuffersResponse, Foreground), - (TaskContextForLocation, Background), + (SynchronizeContexts, Foreground), + (SynchronizeContextsResponse, Foreground), (TaskContext, Background), + (TaskContextForLocation, Background), (Test, Foreground), + (Toast, Background), (Unfollow, Foreground), (UnshareProject, Foreground), (Unstage, Background), @@ -365,86 +441,49 @@ messages!( (UpdateBufferFile, Foreground), (UpdateChannelBuffer, Foreground), (UpdateChannelBufferCollaborators, Foreground), + (UpdateChannelMessage, Foreground), (UpdateChannels, Foreground), - (UpdateUserChannels, Foreground), (UpdateContacts, Foreground), + (UpdateContext, Foreground), + (UpdateDebugAdapter, Foreground), (UpdateDiagnosticSummary, Foreground), (UpdateDiffBases, Foreground), (UpdateFollowers, Foreground), + (UpdateGitBranch, Background), (UpdateInviteInfo, Foreground), (UpdateLanguageServer, Foreground), + (UpdateNotification, Foreground), (UpdateParticipantLocation, Foreground), (UpdateProject, Foreground), (UpdateProjectCollaborator, Foreground), + (UpdateThreadStatus, Background), + (UpdateUserChannels, Foreground), (UpdateUserPlan, Foreground), (UpdateWorktree, Foreground), (UpdateWorktreeSettings, Foreground), (UsersResponse, Foreground), - (LspExtExpandMacro, Background), - (LspExtExpandMacroResponse, Background), - (LspExtOpenDocs, Background), - (LspExtOpenDocsResponse, Background), - (SetRoomParticipantRole, Foreground), - (BlameBuffer, Foreground), - (BlameBufferResponse, Foreground), - (RejoinRemoteProjects, Foreground), - (RejoinRemoteProjectsResponse, Foreground), - (MultiLspQuery, Background), - (MultiLspQueryResponse, Background), - (ListRemoteDirectory, Background), - (ListRemoteDirectoryResponse, Background), - (OpenNewBuffer, Foreground), - (RestartLanguageServers, Foreground), - (LinkedEditingRange, Background), - (LinkedEditingRangeResponse, Background), - (AdvertiseContexts, Foreground), - (OpenContext, Foreground), - (OpenContextResponse, Foreground), - (CreateContext, Foreground), - (CreateContextResponse, Foreground), - (UpdateContext, Foreground), - (SynchronizeContexts, Foreground), - (SynchronizeContextsResponse, Foreground), - (LspExtSwitchSourceHeader, Background), - (LspExtSwitchSourceHeaderResponse, Background), - (AddWorktree, Foreground), - (AddWorktreeResponse, Foreground), - (FindSearchCandidates, Background), - (FindSearchCandidatesResponse, Background), - (CloseBuffer, Foreground), - (ShutdownRemoteServer, Foreground), - (RemoveWorktree, Foreground), - (LanguageServerLog, Foreground), - (Toast, Background), - (HideToast, Background), - (OpenServerSettings, Foreground), - (GetPermalinkToLine, Foreground), - (GetPermalinkToLineResponse, Foreground), - (FlushBufferedMessages, Foreground), - (LanguageServerPromptRequest, Foreground), - (LanguageServerPromptResponse, Foreground), - (GitBranches, Background), - (GitBranchesResponse, Background), - (UpdateGitBranch, Background), - (ListToolchains, Foreground), - (ListToolchainsResponse, Foreground), - (ActivateToolchain, Foreground), - (ActiveToolchain, Foreground), - (ActiveToolchainResponse, Foreground), - (GetPathMetadata, Background), - (GetPathMetadataResponse, Background), - (GetPanicFiles, Background), - (GetPanicFilesResponse, Background), - (CancelLanguageServerWork, Foreground), - (SyncExtensions, Background), - (SyncExtensionsResponse, Background), - (InstallExtension, Background), - (RegisterBufferWithLanguageServers, Background), (GitReset, Background), (GitCheckoutFiles, Background), (GitShow, Background), (GitCommitDetails, Background), (SetIndexText, Background), + (VariablesRequest, Background), + (DapVariables, Background), + (IgnoreBreakpointState, Background), + (ToggleIgnoreBreakpoints, Background), + (DapStackTraceRequest, Background), + (DapStackTraceResponse, Background), + (DapScopesRequest, Background), + (DapScopesResponse, Background), + (DapSetVariableValueRequest, Background), + (DapSetVariableValueResponse, Background), + (DapEvaluateRequest, Background), + (DapEvaluateResponse, Background), + (DapCompletionRequest, Background), + (DapCompletionResponse, Background), + (DapThreadsRequest, Background), + (DapThreadsResponse, Background), + (DapTerminateRequest, Background) ); request_messages!( @@ -582,6 +621,27 @@ request_messages!( (GitReset, Ack), (GitCheckoutFiles, Ack), (SetIndexText, Ack), + (DapNextRequest, Ack), + (DapStepInRequest, Ack), + (DapStepOutRequest, Ack), + (DapStepBackRequest, Ack), + (DapContinueRequest, DapContinueResponse), + (DapModulesRequest, DapModulesResponse), + (DapLoadedSourcesRequest, DapLoadedSourcesResponse), + (DapPauseRequest, Ack), + (DapDisconnectRequest, Ack), + (DapTerminateThreadsRequest, Ack), + (DapRestartRequest, Ack), + (DapRestartStackFrameRequest, Ack), + (VariablesRequest, DapVariables), + (DapStackTraceRequest, DapStackTraceResponse), + (DapScopesRequest, DapScopesResponse), + (DapSetVariableValueRequest, DapSetVariableValueResponse), + (DapEvaluateRequest, DapEvaluateResponse), + (DapCompletionRequest, DapCompletionResponse), + (DapThreadsRequest, DapThreadsResponse), + (DapTerminateRequest, Ack), + (ShutdownDebugClient, Ack), ); entity_messages!( @@ -652,6 +712,7 @@ entity_messages!( UpdateProjectCollaborator, UpdateWorktree, UpdateWorktreeSettings, + UpdateDebugAdapter, LspExtExpandMacro, LspExtOpenDocs, AdvertiseContexts, @@ -678,6 +739,32 @@ entity_messages!( GitReset, GitCheckoutFiles, SetIndexText, + SynchronizeBreakpoints, + SetActiveDebugLine, + RemoveActiveDebugLine, + ShutdownDebugClient, + SetDebugClientCapabilities, + DapNextRequest, + DapStepInRequest, + DapStepOutRequest, + DapStepBackRequest, + DapContinueRequest, + DapPauseRequest, + DapDisconnectRequest, + DapTerminateThreadsRequest, + DapRestartRequest, + DapRestartStackFrameRequest, + UpdateThreadStatus, + VariablesRequest, + IgnoreBreakpointState, + ToggleIgnoreBreakpoints, + DapStackTraceRequest, + DapScopesRequest, + DapSetVariableValueRequest, + DapEvaluateRequest, + DapCompletionRequest, + DapThreadsRequest, + DapTerminateRequest ); entity_messages!( diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index fa611e958a5210..5e7725b7677727 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -44,9 +44,10 @@ workspace.workspace = true zed_actions.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/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index f06b3ae701528f..0c45690277faa5 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -590,6 +590,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}; @@ -738,6 +739,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 4f09647e17cc00..072c1161b485ce 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -9,6 +9,7 @@ use language::{proto::serialize_operation, Buffer, BufferEvent, LanguageRegistry use node_runtime::NodeRuntime; use project::{ buffer_store::{BufferStore, BufferStoreEvent}, + debugger::{breakpoint_store::BreakpointStore, dap_store::DapStore}, git::GitStore, project_settings::SettingsObserver, search::SearchQuery, @@ -81,29 +82,47 @@ impl HeadlessProject { store }); + let environment = project::ProjectEnvironment::new(&worktree_store, None, cx); + + let toolchain_store = cx.new(|cx| { + ToolchainStore::local( + languages.clone(), + worktree_store.clone(), + environment.clone(), + cx, + ) + }); + let buffer_store = cx.new(|cx| { let mut buffer_store = BufferStore::local(worktree_store.clone(), cx); buffer_store.shared(SSH_PROJECT_ID, session.clone().into(), cx); buffer_store }); - let git_store = - cx.new(|cx| GitStore::new(&worktree_store, buffer_store.clone(), None, None, cx)); - let prettier_store = cx.new(|cx| { - PrettierStore::new( + let breakpoint_store = + cx.new(|cx| BreakpointStore::local(buffer_store.clone(), worktree_store.clone(), cx)); + + let dap_store = cx.new(|cx| { + DapStore::new_local( + http_client.clone(), node_runtime.clone(), fs.clone(), languages.clone(), - worktree_store.clone(), + environment.clone(), + toolchain_store.read(cx).as_language_toolchain_store(), + breakpoint_store.clone(), cx, ) }); - let environment = project::ProjectEnvironment::new(&worktree_store, None, cx); - let toolchain_store = cx.new(|cx| { - ToolchainStore::local( + + let git_store = + cx.new(|cx| GitStore::new(&worktree_store, buffer_store.clone(), None, None, cx)); + let prettier_store = cx.new(|cx| { + PrettierStore::new( + node_runtime.clone(), + fs.clone(), languages.clone(), worktree_store.clone(), - environment.clone(), cx, ) }); @@ -135,6 +154,7 @@ impl HeadlessProject { let mut lsp_store = LspStore::new_local( buffer_store.clone(), worktree_store.clone(), + dap_store.clone(), prettier_store.clone(), toolchain_store.clone(), environment, @@ -178,6 +198,7 @@ impl HeadlessProject { session.subscribe_to_entity(SSH_PROJECT_ID, &lsp_store); session.subscribe_to_entity(SSH_PROJECT_ID, &task_store); session.subscribe_to_entity(SSH_PROJECT_ID, &toolchain_store); + session.subscribe_to_entity(SSH_PROJECT_ID, &dap_store); session.subscribe_to_entity(SSH_PROJECT_ID, &settings_observer); session.subscribe_to_entity(SSH_PROJECT_ID, &git_store); @@ -212,6 +233,8 @@ impl HeadlessProject { LspStore::init(&client); TaskStore::init(Some(&client)); ToolchainStore::init(&client); + DapStore::init(&client); + BreakpointStore::init(&client); GitStore::init(&client); HeadlessProject { diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index cfec7f2d168f9b..ea6aba84f64b4c 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -19,7 +19,7 @@ pub use keymap_file::{ pub use settings_file::*; pub use settings_store::{ parse_json_with_comments, InvalidSettingsError, LocalSettingsKind, Settings, SettingsLocation, - SettingsSources, SettingsStore, + SettingsSources, SettingsStore, TaskKind, }; #[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)] @@ -109,3 +109,7 @@ pub fn initial_keymap_content() -> Cow<'static, str> { pub fn initial_tasks_content() -> Cow<'static, str> { asset_str::("settings/initial_tasks.json") } + +pub fn initial_debug_tasks_content() -> Cow<'static, str> { + asset_str::("settings/initial_debug_tasks.json") +} diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 924b25fabe0a07..8b60fd88d7fa5c 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -210,10 +210,16 @@ impl FromStr for Editorconfig { #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub enum LocalSettingsKind { Settings, - Tasks, + Tasks(TaskKind), Editorconfig, } +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub enum TaskKind { + Debug, + Script, +} + impl Global for SettingsStore {} #[derive(Debug)] @@ -604,7 +610,7 @@ impl SettingsStore { .map(|content| content.trim()) .filter(|content| !content.is_empty()), ) { - (LocalSettingsKind::Tasks, _) => { + (LocalSettingsKind::Tasks(_), _) => { return Err(InvalidSettingsError::Tasks { message: "Attempted to submit tasks into the settings store".to_string(), }) diff --git a/crates/sqlez/src/bindable.rs b/crates/sqlez/src/bindable.rs index cddc2c486dc74c..19bd1e2fa4737b 100644 --- a/crates/sqlez/src/bindable.rs +++ b/crates/sqlez/src/bindable.rs @@ -8,12 +8,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; } diff --git a/crates/sqlez/src/statement.rs b/crates/sqlez/src/statement.rs index f1d89919ece5ed..db299e428c09a4 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 6ce7d4ca82db7b..ee4315ad5f019b 100644 --- a/crates/task/Cargo.toml +++ b/crates/task/Cargo.toml @@ -5,6 +5,12 @@ edition.workspace = true publish.workspace = true license = "GPL-3.0-or-later" +[features] +test-support = [ + "gpui/test-support", + "util/test-support" +] + [lints] workspace = true @@ -17,6 +23,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 00000000000000..f89728aa5e4ff9 --- /dev/null +++ b/crates/task/src/debug_format.rs @@ -0,0 +1,226 @@ +use schemars::{gen::SchemaSettings, JsonSchema}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::net::Ipv4Addr; +use std::path::PathBuf; +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 + /// + /// Default: We will try to find an open port + pub port: Option, + /// The host that the debug adapter is listening too + /// + /// Default: 127.0.0.1 + pub host: Option, + /// The max amount of time in milliseconds to connect to a tcp DAP before returning an error + /// + /// Default: 2000ms + pub timeout: Option, +} + +impl TCPHost { + /// Get the host or fallback to the default host + pub fn host(&self) -> Ipv4Addr { + self.host.unwrap_or_else(|| Ipv4Addr::new(127, 0, 0, 1)) + } +} + +/// Represents the attach request information of the debug adapter +#[derive(Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +pub struct AttachConfig { + /// The processId to attach to, if left empty we will show a process picker + #[serde(default)] + pub process_id: 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(AttachConfig), +} + +/// The Debug adapter to use +#[derive(Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +#[serde(rename_all = "lowercase", tag = "adapter")] +pub enum DebugAdapterKind { + /// Manually setup starting a debug adapter + /// The argument within is used to start the DAP + Custom(CustomArgs), + /// Use debugpy + Python(TCPHost), + /// Use vscode-php-debug + Php(TCPHost), + /// Use vscode-js-debug + Javascript(TCPHost), + /// Use delve + Go(TCPHost), + /// Use lldb + Lldb, + /// Use GDB's built-in DAP support + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + Gdb, + /// Used for integration tests + #[cfg(any(test, feature = "test-support"))] + Fake, +} + +impl DebugAdapterKind { + /// Returns the display name for the adapter kind + pub fn display_name(&self) -> &str { + match self { + Self::Custom(_) => "Custom", + Self::Python(_) => "Python", + Self::Php(_) => "PHP", + Self::Javascript(_) => "JavaScript", + Self::Lldb => "LLDB", + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + Self::Gdb => "GDB", + Self::Go(_) => "Go", + #[cfg(any(test, feature = "test-support"))] + Self::Fake => "Fake", + } + } +} + +/// 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 { + /// Name of the debug task + pub label: String, + /// The type of adapter you want to use + #[serde(flatten)] + pub kind: DebugAdapterKind, + /// 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: Option, + /// The current working directory of your project + pub cwd: Option, + /// Additional initialization arguments to be sent on DAP initialization + pub initialize_args: Option, + /// Whether the debug adapter supports attaching to a running process. + pub supports_attach: bool, +} + +/// 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, +} + +/// This struct represent a user created debug task +#[derive(Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub struct DebugTaskDefinition { + /// The adapter to run + #[serde(flatten)] + kind: DebugAdapterKind, + /// The type of request that should be called on the debug adapter + #[serde(default)] + request: DebugRequestType, + /// Name of the debug task + label: String, + /// Program to run the debugger on + program: Option, + /// The current working directory of your project + cwd: Option, + /// Additional initialization arguments to be sent on DAP initialization + initialize_args: Option, +} + +impl DebugTaskDefinition { + /// Translate from debug definition to a task template + pub fn to_zed_format(self) -> anyhow::Result { + let command = "".to_string(); + let cwd = self.cwd.clone().map(PathBuf::from).take_if(|p| p.exists()); + + let task_type = TaskType::Debug(DebugAdapterConfig { + label: self.label.clone(), + kind: self.kind, + request: self.request, + program: self.program, + cwd: cwd.clone(), + initialize_args: self.initialize_args, + supports_attach: true, + }); + + let args: Vec = Vec::new(); + + Ok(TaskTemplate { + label: self.label, + command, + args, + task_type, + cwd: self.cwd, + ..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 0470e1e3a2841e..6afc06ce4242c9 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::{ + AttachConfig, CustomArgs, DebugAdapterConfig, DebugAdapterKind, DebugConnectionType, + DebugRequestType, DebugTaskDefinition, DebugTaskFile, TCPHost, +}; +pub use task_template::{ + HideStrategy, RevealStrategy, TaskModal, TaskTemplate, TaskTemplates, TaskType, +}; pub use vscode_format::VsCodeTaskFile; pub use zed_actions::RevealTarget; @@ -54,6 +61,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, /// Whether to show the task summary line in the task output (sucess/failure). pub show_summary: bool, /// Whether to show the command line in the task output. @@ -86,6 +95,28 @@ 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 resolved_debug_adapter_config(&self) -> Option { + match self.original_task.task_type.clone() { + TaskType::Script => None, + TaskType::Debug(mut adapter_config) => { + if let Some(resolved) = &self.resolved { + adapter_config.label = resolved.label.clone(); + adapter_config.program = resolved.program.clone().or(adapter_config.program); + adapter_config.cwd = resolved.cwd.clone().or(adapter_config.cwd); + } + + 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/task_template.rs b/crates/task/src/task_template.rs index a25e407f1000bf..baa3e59e2f592e 100644 --- a/crates/task/src/task_template.rs +++ b/crates/task/src/task_template.rs @@ -9,8 +9,8 @@ use sha2::{Digest, Sha256}; use util::{truncate_and_remove_front, ResultExt}; use crate::{ - ResolvedTask, RevealTarget, Shell, SpawnInTerminal, TaskContext, TaskId, VariableName, - ZED_VARIABLE_NAME_PREFIX, + debug_format::DebugAdapterConfig, ResolvedTask, RevealTarget, Shell, SpawnInTerminal, + TaskContext, TaskId, VariableName, ZED_VARIABLE_NAME_PREFIX, }; /// A template definition of a Zed task to run. @@ -58,6 +58,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, skip)] + 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, @@ -72,6 +75,71 @@ pub struct TaskTemplate { pub show_command: bool, } +/// 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, TCPHost}; + + 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 { + label: "test config".into(), + kind: DebugAdapterKind::Python(TCPHost::default()), + request: crate::DebugRequestType::Launch, + program: Some("main".to_string()), + supports_attach: false, + cwd: None, + initialize_args: None, + }; + let json = json!({ + "label": "test config", + "type": "debug", + "adapter": "python", + "program": "main", + "supports_attach": false, + }); + + 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")] @@ -122,7 +190,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; } @@ -198,6 +268,22 @@ impl TaskTemplate { &mut substituted_variables, )?; + let program = match &self.task_type { + TaskType::Script => None, + TaskType::Debug(adapter_config) => { + if let Some(program) = &adapter_config.program { + Some(substitute_all_template_variables_in_str( + program, + &task_variables, + &variable_names, + &mut substituted_variables, + )?) + } else { + None + } + } + }; + let task_hash = to_hex_hash(self) .context("hashing task template") .log_err()?; @@ -253,6 +339,7 @@ impl TaskTemplate { reveal_target: self.reveal_target, hide: self.hide, shell: self.shell.clone(), + program, show_summary: self.show_summary, show_command: self.show_command, }), diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index 32e9add67c38c5..4a02028982a545 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::HighlightedMatch, Picker, PickerDelegate}; use project::{task_store::TaskStore, TaskSourceKind}; -use task::{ResolvedTask, RevealTarget, TaskContext, TaskTemplate}; +use task::{ResolvedTask, RevealTarget, TaskContext, TaskModal, TaskTemplate, TaskType}; use ui::{ div, h_flex, v_flex, ActiveTheme, Button, ButtonCommon, ButtonSize, Clickable, Color, FluentBuilder as _, Icon, IconButton, IconButtonShape, IconName, IconSize, IntoElement, @@ -32,6 +32,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, } /// Task template amendments to do before resolving the context. @@ -46,6 +48,7 @@ impl TasksModalDelegate { task_store: Entity, task_context: TaskContext, task_overrides: Option, + task_modal_type: TaskModal, workspace: WeakEntity, ) -> Self { let placeholder_text = if let Some(TaskOverrides { @@ -66,6 +69,7 @@ impl TasksModalDelegate { selected_index: 0, prompt: String::default(), task_context, + task_modal_type, task_overrides, placeholder_text, } @@ -125,12 +129,19 @@ impl TasksModal { task_context: TaskContext, task_overrides: Option, workspace: WeakEntity, + task_modal_type: TaskModal, window: &mut Window, cx: &mut Context, ) -> Self { let picker = cx.new(|cx| { Picker::uniform_list( - TasksModalDelegate::new(task_store, task_context, task_overrides, workspace), + TasksModalDelegate::new( + task_store, + task_context, + task_overrides, + task_modal_type, + workspace, + ), window, cx, ) @@ -198,11 +209,12 @@ impl PickerDelegate for TasksModalDelegate { window: &mut Window, cx: &mut Context>, ) -> Task<()> { + let task_type = self.task_modal_type.clone(); cx.spawn_in(window, 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| { @@ -236,7 +248,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 } @@ -312,7 +325,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, + ), + TaskType::Debug(_) => workspace.project().update(cx, |project, cx| { + project + .start_debug_session(task.resolved_debug_adapter_config().unwrap(), cx) + .detach_and_log_err(cx); + }), + }; }) .ok(); cx.emit(DismissEvent); @@ -466,7 +492,22 @@ 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_session(task.resolved_debug_adapter_config().unwrap(), cx) + .detach_and_log_err(cx); + }), + }; }) .ok(); cx.emit(DismissEvent); @@ -588,9 +629,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::new(index, candidate.display_label())) .collect() } diff --git a/crates/tasks_ui/src/tasks_ui.rs b/crates/tasks_ui/src/tasks_ui.rs index 5a536c09270aa2..ac5898bb8dd6d2 100644 --- a/crates/tasks_ui/src/tasks_ui.rs +++ b/crates/tasks_ui/src/tasks_ui.rs @@ -3,7 +3,7 @@ use editor::{tasks::task_context, Editor}; use gpui::{App, Context, Task as AsyncTask, Window}; use modal::{TaskOverrides, TasksModal}; use project::{Location, WorktreeId}; -use task::{RevealTarget, TaskId}; +use task::{RevealTarget, TaskId, TaskModal}; use workspace::tasks::schedule_task; use workspace::{tasks::schedule_resolved_task, Workspace}; @@ -79,7 +79,7 @@ pub fn init(cx: &mut App) { ); } } else { - toggle_modal(workspace, None, window, cx).detach(); + toggle_modal(workspace, None, TaskModal::ScriptModal, window, cx).detach(); }; }); }, @@ -103,15 +103,21 @@ fn spawn_task_or_modal( }); spawn_task_with_name(task_name.clone(), overrides, window, cx).detach_and_log_err(cx) } - Spawn::ViaModal { reveal_target } => { - toggle_modal(workspace, *reveal_target, window, cx).detach() - } + Spawn::ViaModal { reveal_target } => toggle_modal( + workspace, + *reveal_target, + TaskModal::ScriptModal, + window, + cx, + ) + .detach(), } } -fn toggle_modal( +pub fn toggle_modal( workspace: &mut Workspace, reveal_target: Option, + task_type: TaskModal, window: &mut Window, cx: &mut Context, ) -> AsyncTask<()> { @@ -134,6 +140,7 @@ fn toggle_modal( reveal_target: Some(target), }), workspace_handle, + task_type, window, cx, ) diff --git a/crates/terminal/src/pty_info.rs b/crates/terminal/src/pty_info.rs index 6478cb4ad87c96..ee47ba7cbe528c 100644 --- a/crates/terminal/src/pty_info.rs +++ b/crates/terminal/src/pty_info.rs @@ -154,4 +154,8 @@ impl PtyProcessInfo { } has_changed } + + pub fn pid(&self) -> Option { + self.pid_getter.pid() + } } diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 4658eb364371de..f61d5126d20509 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -338,6 +338,7 @@ impl TerminalBuilder { is_ssh_terminal: bool, window: AnyWindowHandle, completion_tx: Sender<()>, + debug_terminal: bool, cx: &App, ) -> Result { // If the parent environment doesn't have a locale set @@ -474,6 +475,7 @@ impl TerminalBuilder { url_regex: RegexSearch::new(URL_REGEX).unwrap(), word_regex: RegexSearch::new(WORD_REGEX).unwrap(), vi_mode_enabled: false, + debug_terminal, is_ssh_terminal, python_venv_directory, }; @@ -630,6 +632,7 @@ pub struct Terminal { word_regex: RegexSearch, task: Option, vi_mode_enabled: bool, + debug_terminal: bool, is_ssh_terminal: bool, } @@ -1732,6 +1735,10 @@ impl Terminal { self.task.as_ref() } + pub fn debug_terminal(&self) -> bool { + self.debug_terminal + } + pub fn wait_for_completed_task(&self, cx: &App) -> Task<()> { if let Some(task) = self.task() { if task.status == TaskStatus::Running { diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 9f278fb7f625f5..24a3c4b9f71331 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -661,7 +661,7 @@ impl TerminalPanel { }) } - fn add_terminal( + pub fn add_terminal( &mut self, kind: TerminalKind, reveal_strategy: RevealStrategy, diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 28a926c611b5a6..3a31ca1fec1aad 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1419,7 +1419,7 @@ impl SerializableItem for TerminalView { cx: &mut Context, ) -> Option>> { let terminal = self.terminal().read(cx); - if terminal.task().is_some() { + if terminal.task().is_some() || terminal.debug_terminal() { return None; } diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index 55d2b2b62398b2..886f2815ff5f56 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -2172,7 +2172,7 @@ impl BufferSnapshot { }) } - fn summary_for_anchor(&self, anchor: &Anchor) -> D + pub fn summary_for_anchor(&self, anchor: &Anchor) -> D where D: TextDimension, { @@ -2279,6 +2279,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 45c70cb7cf65c7..46698e86746ba7 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -68,6 +68,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(), @@ -94,6 +95,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_hover_line_number: neutral().light().step_12(), editor_active_line_number: neutral().light().step_11(), @@ -185,6 +187,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(), @@ -210,7 +213,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_hover_line_number: neutral().dark_alpha().step_12(), editor_active_line_number: neutral().dark_alpha().step_11(), diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index 861f2bc7dde686..d9dbba41c40b07 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -114,6 +114,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, @@ -128,6 +129,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_hover_line_number: hsla(216.0 / 360., 5.9 / 100., 56.7 / 100., 1.0), diff --git a/crates/theme/src/schema.rs b/crates/theme/src/schema.rs index 8ebcab2f88785f..3832c74b5a5897 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, @@ -382,6 +387,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, @@ -723,6 +732,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() @@ -833,6 +846,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 04f65ca8078076..f2d12a0ef1a9ae 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 @@ -148,6 +151,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 96d093c249ab4a..f8799fe093b8eb 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; @@ -360,6 +360,7 @@ pub struct ButtonLike { tooltip: Option AnyView>>, cursor_style: CursorStyle, on_click: Option>, + on_right_click: Option>, children: SmallVec<[AnyElement; 2]>, } @@ -380,6 +381,7 @@ impl ButtonLike { children: SmallVec::new(), cursor_style: CursorStyle::PointingHand, on_click: None, + on_right_click: None, layer: None, } } @@ -406,6 +408,14 @@ impl ButtonLike { self.rounding = rounding.into(); self } + + pub fn on_right_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_right_click = Some(Box::new(handler)); + self + } } impl Disableable for ButtonLike { @@ -529,6 +539,37 @@ 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, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + }) + .on_mouse_up( + MouseButton::Right, + move |event, window, 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, window, 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 204ea8e564c888..691af253c96ccc 100644 --- a/crates/ui/src/components/button/icon_button.rs +++ b/crates/ui/src/components/button/icon_button.rs @@ -71,6 +71,14 @@ impl IconButton { self } + pub fn on_right_click( + mut self, + handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.base = self.base.on_right_click(handler); + self + } + /// Sets the icon color used when the button is in a selected state. pub fn selected_icon_color(mut self, color: impl Into>) -> Self { self.selected_icon_color = color.into(); @@ -84,6 +92,7 @@ impl IconButton { pub fn indicator_border_color(mut self, color: Option) -> Self { self.indicator_border_color = color; + self } } diff --git a/crates/ui/src/components/dropdown_menu.rs b/crates/ui/src/components/dropdown_menu.rs index 1f2e0473af4fe1..00698912c19e47 100644 --- a/crates/ui/src/components/dropdown_menu.rs +++ b/crates/ui/src/components/dropdown_menu.rs @@ -45,7 +45,11 @@ impl RenderOnce for DropdownMenu { PopoverMenu::new(self.id) .full_width(self.full_width) .menu(move |_window, _cx| Some(self.menu.clone())) - .trigger(DropdownMenuTrigger::new(self.label).full_width(self.full_width)) + .trigger( + DropdownMenuTrigger::new(self.label) + .full_width(self.full_width) + .disabled(self.disabled), + ) .attach(Corner::BottomLeft) } } diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index ca90a16ea7029c..691e7d920a8c79 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -179,6 +179,19 @@ pub enum IconName { CountdownTimer, CursorIBeam, Dash, + DebugBreakpoint, + DebugIgnoreBreakpoints, + DebugPause, + DebugContinue, + DebugStepOver, + DebugStepInto, + DebugStepOut, + DebugStepBack, + DebugRestart, + Debug, + DebugStop, + DebugDisconnect, + DebugLogBreakpoint, DatabaseZap, Delete, Diff, diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index 7eec79cfd74c46..b674d2c071a3b3 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -39,6 +39,7 @@ pub struct ListItem { on_secondary_mouse_down: Option>, children: SmallVec<[AnyElement; 2]>, selectable: bool, + always_show_disclosure_icon: bool, outlined: bool, overflow_x: bool, focused: Option, @@ -64,6 +65,7 @@ impl ListItem { tooltip: None, children: SmallVec::new(), selectable: true, + always_show_disclosure_icon: false, outlined: false, overflow_x: false, focused: None, @@ -80,6 +82,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 Window, &mut App) + 'static, @@ -277,7 +284,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 0d234ad50d9bcd..7c632a711aba48 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/util/src/fs.rs b/crates/util/src/fs.rs index f235753e8b2af0..d6baf9364b7016 100644 --- a/crates/util/src/fs.rs +++ b/crates/util/src/fs.rs @@ -1,8 +1,8 @@ -use std::path::Path; - use crate::ResultExt; +use anyhow::{bail, Result}; use async_fs as fs; use futures_lite::StreamExt; +use std::path::{Path, PathBuf}; /// Removes all files and directories matching the given predicate pub async fn remove_matching(dir: &Path, predicate: F) @@ -26,3 +26,68 @@ where } } } + +pub async fn collect_matching(dir: &Path, predicate: F) -> Vec +where + F: Fn(&Path) -> bool, +{ + let mut matching = vec![]; + + if let Some(mut entries) = fs::read_dir(dir).await.log_err() { + while let Some(entry) = entries.next().await { + if let Some(entry) = entry.log_err() { + if predicate(entry.path().as_path()) { + matching.push(entry.path()); + } + } + } + } + + matching +} + +pub async fn find_file_name_in_dir(dir: &Path, predicate: F) -> Option +where + F: Fn(&str) -> bool, +{ + if let Some(mut entries) = fs::read_dir(dir).await.log_err() { + while let Some(entry) = entries.next().await { + if let Some(entry) = entry.log_err() { + let entry_path = entry.path(); + + if let Some(file_name) = entry_path + .file_name() + .map(|file_name| file_name.to_string_lossy()) + { + if predicate(&file_name) { + return Some(entry_path); + } + } + } + } + } + + None +} + +pub async fn move_folder_files_to_folder>( + source_path: P, + target_path: P, +) -> Result<()> { + if !target_path.as_ref().is_dir() { + bail!("Folder not found or is not a directory"); + } + + let mut entries = fs::read_dir(source_path.as_ref()).await?; + while let Some(entry) = entries.next().await { + let entry = entry?; + let old_path = entry.path(); + let new_path = target_path.as_ref().join(entry.file_name()); + + fs::rename(&old_path, &new_path).await?; + } + + fs::remove_dir(source_path).await?; + + Ok(()) +} diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 12b7211b8e6c8a..f14b331c40487c 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -1430,6 +1430,7 @@ impl ShellExec { reveal_target: RevealTarget::Dock, hide: HideStrategy::Never, shell, + program: None, show_summary: false, show_command: false, }), diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 50563169ddc40f..97260c498e3156 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -219,7 +219,7 @@ pub enum Event { idx: usize, }, RemovedItem { - item_id: EntityId, + item: Box, }, Split(SplitDirection), JoinAll, @@ -247,9 +247,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") @@ -315,6 +315,7 @@ pub struct Pane { display_nav_history_buttons: Option, double_click_dispatch_action: Box, save_modals_spawned: HashSet, + close_pane_if_empty: bool, pub new_item_context_menu_handle: PopoverMenuHandle, pub split_item_context_menu_handle: PopoverMenuHandle, pinned_tab_count: usize, @@ -519,6 +520,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, @@ -706,6 +708,11 @@ impl Pane { self.can_split_predicate = can_split_predicate; } + pub fn set_close_pane_if_empty(&mut self, close_pane_if_empty: bool, cx: &mut Context) { + self.close_pane_if_empty = close_pane_if_empty; + cx.notify(); + } + pub fn set_can_navigate(&mut self, can_navigate: bool, cx: &mut Context) { self.toolbar.update(cx, |toolbar, cx| { toolbar.set_can_navigate(can_navigate, cx); @@ -1604,6 +1611,13 @@ impl Pane { // Remove the item from the pane. pane.update_in(&mut cx, |pane, window, cx| { + pane.remove_item( + item_to_close.item_id(), + false, + pane.close_pane_if_empty, + window, + cx, + ); pane.remove_item(item_to_close.item_id(), false, true, window, cx); }) .ok(); @@ -1711,13 +1725,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(window, cx); if close_pane_if_empty { @@ -2731,7 +2741,7 @@ impl Pane { window.dispatch_action( this.double_click_dispatch_action.boxed_clone(), cx, - ) + ); } })), ), diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index a89c1a7766c4b4..52f9ac948e0805 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -1,18 +1,26 @@ pub mod model; -use std::{path::Path, str::FromStr}; +use std::{ + borrow::Cow, + num::NonZeroU32, + path::{Path, PathBuf}, + str::FromStr, + 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::debugger::breakpoint_store::{BreakpointKind, SerializedBreakpoint}; use language::{LanguageName, Toolchain}; use project::WorktreeId; use remote::ssh_session::SshProjectId; use sqlez::{ bindable::{Bind, Column, StaticColumnCount}, - statement::Statement, + statement::{SqlType, Statement}, }; use ui::px; @@ -136,6 +144,128 @@ impl Column for SerializedWindowBounds { } } +#[derive(Debug)] +pub struct Breakpoint { + pub position: NonZeroU32, + pub kind: BreakpointKind, +} + +/// Wrapper for DB type of a breakpoint +struct BreakpointKindWrapper<'a>(Cow<'a, BreakpointKind>); + +impl From for BreakpointKindWrapper<'static> { + fn from(kind: BreakpointKind) -> Self { + BreakpointKindWrapper(Cow::Owned(kind)) + } +} +impl StaticColumnCount for BreakpointKindWrapper<'_> { + fn column_count() -> usize { + 1 + } +} + +impl Bind for BreakpointKindWrapper<'_> { + fn bind(&self, statement: &Statement, start_index: i32) -> anyhow::Result { + let next_index = statement.bind(&self.0.to_int(), start_index)?; + + match self.0.as_ref() { + BreakpointKind::Standard => { + statement.bind_null(next_index)?; + Ok(next_index + 1) + } + BreakpointKind::Log(message) => statement.bind(&message.as_ref(), next_index), + } + } +} + +impl Column for BreakpointKindWrapper<'_> { + 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.into(), start_index + 2)), + 1 => { + let message = statement.column_text(start_index + 1)?.to_string(); + Ok((BreakpointKind::Log(message.into()).into(), start_index + 1)) + } + _ => Err(anyhow::anyhow!("Invalid BreakpointKind discriminant")), + } + } +} + +/// 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 + BreakpointKindWrapper::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.get(), start_index)?; + statement.bind( + &BreakpointKindWrapper(Cow::Borrowed(&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 position = + NonZeroU32::new(position).ok_or_else(|| anyhow!("Position must be non-zero"))?; + let (kind, next_index) = BreakpointKindWrapper::column(statement, start_index + 1)?; + + Ok(( + Breakpoint { + position, + kind: kind.0.into_owned(), + }, + 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 position = NonZeroU32::new(position) + .ok_or_else(|| anyhow!("Position must be non-zero"))?; + let (kind, next_index) = BreakpointKindWrapper::column(statement, index + 1)?; + + breakpoints.push(Breakpoint { + position, + kind: kind.0.into_owned(), + }); + index = next_index; + } + } + } + Ok((Breakpoints(breakpoints), index)) + } +} + #[derive(Clone, Debug, PartialEq)] struct SerializedPixels(gpui::Pixels); impl sqlez::bindable::StaticColumnCount for SerializedPixels {} @@ -205,6 +335,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!( @@ -383,6 +522,19 @@ define_connection! { sql!( ALTER TABLE toolchains ADD COLUMN raw_json TEXT DEFAULT "{}"; ), + 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 + ); + ), ]; } @@ -449,6 +601,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), @@ -470,6 +657,7 @@ impl WorkspaceDb { display, docks, session_id: None, + breakpoints: serialized_breakpoints, window_id, }) } @@ -527,6 +715,7 @@ impl WorkspaceDb { docks, session_id: None, window_id, + breakpoints: Default::default(), }) } @@ -535,12 +724,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!( @@ -720,6 +942,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 ssh_projects() -> Result> { SELECT id, host, port, paths, user @@ -1162,6 +1415,71 @@ mod tests { use db::open_test_db; use gpui::{self}; + #[gpui::test] + async fn test_breakpoints() { + env_logger::try_init().ok(); + + let db = WorkspaceDb(open_test_db("test_breakpoints").await); + let id = db.next_id().await.unwrap(); + + let path = Path::new("/tmp/test.rs"); + let worktree = Path::new("/tmp"); + + let breakpoint = Breakpoint { + position: 123, + kind: BreakpointKind::Standard, + }; + + let log_breakpoint = Breakpoint { + position: 456, + kind: BreakpointKind::Log("Test log message".into()), + }; + + let workspace = SerializedWorkspace { + id, + location: SerializedWorkspaceLocation::from_local_paths(["/tmp"]), + center_group: Default::default(), + window_bounds: Default::default(), + display: Default::default(), + docks: Default::default(), + centered_layout: false, + session_id: None, + breakpoints: { + let mut map = HashMap::default(); + map.insert( + Arc::from(worktree), + vec![ + SerializedBreakpoint { + position: breakpoint.position, + path: Arc::from(path), + kind: breakpoint.kind.clone(), + }, + SerializedBreakpoint { + position: log_breakpoint.position, + path: Arc::from(path), + kind: log_breakpoint.kind.clone(), + }, + ], + ); + map + }, + window_id: None, + }; + + db.save_workspace(workspace.clone()).await; + + let loaded = db.workspace_for_roots(&["/tmp"]).unwrap(); + let loaded_breakpoints = loaded.breakpoints.get(&Arc::from(worktree)).unwrap(); + + assert_eq!(loaded_breakpoints.len(), 2); + assert_eq!(loaded_breakpoints[0].position, breakpoint.position); + assert_eq!(loaded_breakpoints[0].kind, breakpoint.kind); + assert_eq!(loaded_breakpoints[1].position, log_breakpoint.position); + assert_eq!(loaded_breakpoints[1].kind, log_breakpoint.kind); + assert_eq!(loaded_breakpoints[0].path, Arc::from(path)); + assert_eq!(loaded_breakpoints[1].path, Arc::from(path)); + } + #[gpui::test] async fn test_next_id_stability() { env_logger::try_init().ok(); @@ -1241,6 +1559,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: None, + breakpoints: Default::default(), window_id: None, }; @@ -1253,6 +1572,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: None, + breakpoints: Default::default(), window_id: None, }; @@ -1360,6 +1680,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: None, + breakpoints: Default::default(), window_id: Some(999), }; @@ -1394,6 +1715,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: None, + breakpoints: Default::default(), window_id: Some(1), }; @@ -1406,6 +1728,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: None, + breakpoints: Default::default(), window_id: Some(2), }; @@ -1448,6 +1771,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: None, + breakpoints: Default::default(), window_id: Some(3), }; @@ -1484,6 +1808,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: Some("session-id-1".to_owned()), + breakpoints: Default::default(), window_id: Some(10), }; @@ -1496,6 +1821,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: Some("session-id-1".to_owned()), + breakpoints: Default::default(), window_id: Some(20), }; @@ -1508,6 +1834,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: Some("session-id-2".to_owned()), + breakpoints: Default::default(), window_id: Some(30), }; @@ -1520,6 +1847,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: None, + breakpoints: Default::default(), window_id: None, }; @@ -1538,6 +1866,7 @@ mod tests { centered_layout: false, session_id: Some("session-id-2".to_owned()), window_id: Some(50), + breakpoints: Default::default(), }; let workspace_6 = SerializedWorkspace { @@ -1553,6 +1882,7 @@ mod tests { centered_layout: false, session_id: Some("session-id-3".to_owned()), window_id: Some(60), + breakpoints: Default::default(), }; db.save_workspace(workspace_1.clone()).await; @@ -1605,6 +1935,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: None, + breakpoints: Default::default(), window_id: None, } } @@ -1650,6 +1981,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::>(); @@ -1742,6 +2074,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 b9d0b69ac02df0..2847bfea19ef79 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -4,12 +4,14 @@ use crate::{ }; use anyhow::{Context as _, Result}; use async_recursion::async_recursion; +use collections::HashMap; use db::sqlez::{ bindable::{Bind, Column, StaticColumnCount}, statement::Statement, }; use gpui::{AsyncWindowContext, Entity, WeakEntity}; use itertools::Itertools as _; +use project::debugger::breakpoint_store::SerializedBreakpoint; use project::Project; use remote::ssh_session::SshProjectId; use serde::{Deserialize, Serialize}; @@ -263,6 +265,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 8098450ef4405d..ace3725a72373d 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -122,6 +122,23 @@ static ZED_WINDOW_POSITION: LazyLock>> = LazyLock::new(|| { actions!(assistant, [ShowConfiguration]); +actions!( + debugger, + [ + Start, + Continue, + Disconnect, + Pause, + Restart, + StepInto, + StepOver, + StepOut, + StepBack, + Stop, + ToggleIgnoreBreakpoints + ] +); + actions!( workspace, [ @@ -150,6 +167,7 @@ actions!( ReloadActiveItem, SaveAs, SaveWithoutFormat, + ShutdownDebugAdapters, ToggleBottomDock, ToggleCenteredLayout, ToggleLeftDock, @@ -1199,6 +1217,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| { @@ -3382,10 +3401,10 @@ impl Workspace { serialize_workspace = false; } pane::Event::RemoveItem { .. } => {} - pane::Event::RemovedItem { item_id } => { + pane::Event::RemovedItem { item } => { cx.emit(Event::ActiveItemChanged); self.update_window_edited(window, 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(); } @@ -4583,6 +4602,13 @@ impl Workspace { }; if let Some(location) = location { + let breakpoint_lines = self.project.update(cx, |project, cx| { + project + .breakpoint_store() + .read(cx) + .serialize_breakpoints(cx) + }); + let center_group = build_serialized_pane_group(&self.center.root, window, cx); let docks = build_serialized_docks(self, window, cx); let window_bounds = Some(SerializedWindowBounds(window.window_bounds())); @@ -4595,6 +4621,7 @@ impl Workspace { docks, centered_layout: self.centered_layout, session_id: self.session_id.clone(), + breakpoints: breakpoint_lines, window_id: Some(window.window_handle().window_id().as_u64()), }; return window.spawn(cx, |_| persistence::DB.save_workspace(serialized_workspace)); @@ -4649,7 +4676,7 @@ impl Workspace { } pub(crate) fn load_workspace( - serialized_workspace: SerializedWorkspace, + mut serialized_workspace: SerializedWorkspace, paths_to_open: Vec>, window: &mut Window, cx: &mut Context, @@ -4660,6 +4687,31 @@ 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, |dap_store, cx| { + dap_store + .breakpoint_store() + .update(cx, |breakpoint_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) + { + breakpoint_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 529c5f8b6bad55..f1e1a91e618142 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -41,6 +41,8 @@ command_palette.workspace = true command_palette_hooks.workspace = true component_preview.workspace = true copilot.workspace = true +debugger_ui.workspace = true +debugger_tools.workspace = true db.workspace = true diagnostics.workspace = true editor.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 6ec65acb40188a..9c247446db9ad5 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -367,6 +367,8 @@ fn main() { zed::init(cx); project::Project::init(&client, cx); + debugger_ui::init(cx); + debugger_tools::init(cx); client::init(&client, cx); let telemetry = client.telemetry(); telemetry.start( diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index cfd55155799f3f..23cb501fa2ca88 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -18,6 +18,7 @@ use breadcrumbs::Breadcrumbs; use client::{zed_urls, 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, FeatureFlagViewExt, GitUiFeatureFlag}; @@ -34,7 +35,10 @@ use migrate::{MigrationBanner, MigrationEvent, MigrationNotification, MigrationT use migrator::{migrate_keymap, migrate_settings}; pub use open_listener::*; use outline_panel::OutlinePanel; -use paths::{local_settings_file_relative_path, local_tasks_file_relative_path}; +use paths::{ + local_debug_file_relative_path, local_settings_file_relative_path, + local_tasks_file_relative_path, +}; use project::{DirectoryLister, ProjectItem}; use project_panel::ProjectPanel; use prompt_library::PromptBuilder; @@ -44,9 +48,9 @@ use release_channel::{AppCommitSha, ReleaseChannel}; use rope::Rope; use search::project_search::ProjectSearchBar; use settings::{ - initial_project_settings_content, initial_tasks_content, update_settings_file, - InvalidSettingsError, KeymapFile, KeymapFileLoadResult, Settings, SettingsStore, - DEFAULT_KEYMAP_PATH, VIM_KEYMAP_PATH, + initial_debug_tasks_content, initial_project_settings_content, initial_tasks_content, + update_settings_file, InvalidSettingsError, KeymapFile, KeymapFileLoadResult, Settings, + SettingsStore, DEFAULT_KEYMAP_PATH, VIM_KEYMAP_PATH, }; use std::any::TypeId; use std::path::PathBuf; @@ -82,7 +86,9 @@ actions!( OpenDefaultSettings, OpenProjectSettings, OpenProjectTasks, + OpenProjectDebugTasks, OpenTasks, + OpenDebugTasks, ResetDatabase, ShowAll, ToggleFullScreen, @@ -405,6 +411,8 @@ fn initialize_panels( cx.clone(), ); + let debug_panel = DebugPanel::load(workspace_handle.clone(), cx.clone()); + let ( project_panel, outline_panel, @@ -412,6 +420,7 @@ fn initialize_panels( channels_panel, chat_panel, notification_panel, + debug_panel, ) = futures::try_join!( project_panel, outline_panel, @@ -419,12 +428,14 @@ fn initialize_panels( channels_panel, chat_panel, notification_panel, + debug_panel, )?; workspace_handle.update_in(&mut cx, |workspace, window, cx| { workspace.add_panel(project_panel, window, cx); workspace.add_panel(outline_panel, window, cx); workspace.add_panel(terminal_panel, window, cx); + workspace.add_panel(debug_panel, window, cx); workspace.add_panel(channels_panel, window, cx); workspace.add_panel(chat_panel, window, cx); workspace.add_panel(notification_panel, window, cx); @@ -699,10 +710,7 @@ fn register_actions( }, ) .register_action( - move |_: &mut Workspace, - _: &zed_actions::OpenKeymap, - window: &mut Window, - cx: &mut Context| { + move |_: &mut Workspace, _: &zed_actions::OpenKeymap, window, cx| { open_settings_file( paths::keymap_file(), || settings::initial_keymap_content().as_ref().into(), @@ -711,47 +719,40 @@ fn register_actions( ); }, ) + .register_action(move |_: &mut Workspace, _: &OpenSettings, window, cx| { + open_settings_file( + paths::settings_file(), + || settings::initial_user_settings_content().as_ref().into(), + window, + cx, + ); + }) .register_action( - move |_: &mut Workspace, - _: &OpenSettings, - window: &mut Window, - cx: &mut Context| { - open_settings_file( - paths::settings_file(), - || settings::initial_user_settings_content().as_ref().into(), - window, - cx, - ); - }, - ) - .register_action( - |_: &mut Workspace, - _: &OpenAccountSettings, - _: &mut Window, - cx: &mut Context| { + |_: &mut Workspace, _: &OpenAccountSettings, _: &mut Window, cx| { cx.open_url(&zed_urls::account_url(cx)); }, ) - .register_action( - move |_: &mut Workspace, - _: &OpenTasks, - window: &mut Window, - cx: &mut Context| { - open_settings_file( - paths::tasks_file(), - || settings::initial_tasks_content().as_ref().into(), - window, - cx, - ); - }, - ) + .register_action(move |_: &mut Workspace, _: &OpenTasks, window, cx| { + open_settings_file( + paths::tasks_file(), + || settings::initial_tasks_content().as_ref().into(), + window, + cx, + ); + }) + .register_action(move |_: &mut Workspace, _: &OpenDebugTasks, window, cx| { + open_settings_file( + paths::debug_tasks_file(), + || settings::initial_debug_tasks_content().as_ref().into(), + window, + cx, + ); + }) .register_action(open_project_settings_file) .register_action(open_project_tasks_file) + .register_action(open_project_debug_tasks_file) .register_action( - move |workspace: &mut Workspace, - _: &zed_actions::OpenDefaultKeymap, - window: &mut Window, - cx: &mut Context| { + move |workspace, _: &zed_actions::OpenDefaultKeymap, window, cx| { open_bundled_file( workspace, settings::default_keymap(), @@ -762,21 +763,16 @@ fn register_actions( ); }, ) - .register_action( - move |workspace: &mut Workspace, - _: &OpenDefaultSettings, - window: &mut Window, - cx: &mut Context| { - open_bundled_file( - workspace, - settings::default_settings(), - "Default Settings", - "JSON", - window, - cx, - ); - }, - ) + .register_action(move |workspace, _: &OpenDefaultSettings, window, cx| { + open_bundled_file( + workspace, + settings::default_settings(), + "Default Settings", + "JSON", + window, + cx, + ); + }) .register_action( |workspace: &mut Workspace, _: &project_panel::ToggleFocus, @@ -924,6 +920,8 @@ fn initialize_pane( toolbar.add_item(project_search_bar, window, cx); let lsp_log_item = cx.new(|_| language_tools::LspLogToolbarItemView::new()); toolbar.add_item(lsp_log_item, window, cx); + let dap_log_item = cx.new(|_| debugger_tools::DapLogToolbarItemView::new()); + toolbar.add_item(dap_log_item, window, cx); let syntax_tree_item = cx.new(|_| language_tools::SyntaxTreeToolbarItemView::new()); toolbar.add_item(syntax_tree_item, window, cx); let migration_banner = cx.new(|cx| MigrationBanner::new(workspace, cx)); @@ -1512,6 +1510,21 @@ fn open_project_tasks_file( ) } +fn open_project_debug_tasks_file( + workspace: &mut Workspace, + _: &OpenProjectDebugTasks, + window: &mut Window, + cx: &mut Context, +) { + open_local_file( + workspace, + local_debug_file_relative_path(), + initial_debug_tasks_content(), + window, + cx, + ) +} + fn open_local_file( workspace: &mut Workspace, settings_relative_path: &'static Path, @@ -4252,6 +4265,11 @@ mod tests { repl::init(app_state.fs.clone(), cx); repl::notebook::init(cx); tasks_ui::init(cx); + project::debugger::breakpoint_store::BreakpointStore::init( + &app_state.client.clone().into(), + ); + project::debugger::dap_store::DapStore::init(&app_state.client.clone().into()); + debugger_ui::init(cx); initialize_workspace(app_state.clone(), prompt_builder, cx); search::init(cx); app_state diff --git a/docs/src/debugger.md b/docs/src/debugger.md new file mode 100644 index 00000000000000..0e8809492698ff --- /dev/null +++ b/docs/src/debugger.md @@ -0,0 +1,350 @@ +# Debugger + +Zed uses the Debug Adapter Protocol (DAP) to provide debugging functionality across multiple programming languages. +DAP is a standardized protocol that defines how debuggers, editors, and IDEs communicate with each other. +It allows Zed to support various debuggers without needing to implement language-specific debugging logic. +This protocol enables features like setting breakpoints, stepping through code, inspecting variables, +and more, in a consistent manner across different programming languages and runtime environments. + +## Supported Debug Adapters + +Zed supports a variety of debug adapters for different programming languages: + +- JavaScript (node): Enables debugging of Node.js applications, including setting breakpoints, stepping through code, and inspecting variables in JavaScript. + +- Python (debugpy): Provides debugging capabilities for Python applications, supporting features like remote debugging, multi-threaded debugging, and Django/Flask application debugging. + +- LLDB: A powerful debugger for C, C++, Objective-C, and Swift, offering low-level debugging features and support for Apple platforms. + +- GDB: The GNU Debugger, which supports debugging for multiple programming languages including C, C++, Go, and Rust, across various platforms. + +- Go (dlv): Delve, a debugger for the Go programming language, offering both local and remote debugging capabilities with full support for Go's runtime and standard library. + +- PHP (xdebug): Provides debugging and profiling capabilities for PHP applications, including remote debugging and code coverage analysis. + +- Custom: Allows you to configure any debug adapter that supports the Debug Adapter Protocol, enabling debugging for additional languages or specialized environments not natively supported by Zed. + +These adapters enable Zed to provide a consistent debugging experience across multiple languages while leveraging the specific features and capabilities of each debugger. + +## How To Get Started + +To start a debug session, we added few default debug configurations for each supported language that supports generic configuration options. To see all the available debug configurations, you can use the command palette `debugger: start` action, this should list all the available debug configurations. + +### Configuration + +To create a custom debug configuration you have to create a `.zed/debug.json` file in your project root directory. This file should contain an array of debug configurations, each with a unique label and adapter the other option are optional/required based on the adapter. + +```json +[ + { + // The label for the debug configuration and used to identify the debug session inside the debug panel + "label": "Example Start debugger config" + // The debug adapter that Zed should use to debug the program + "adapter": "custom", + // Request: defaults to launch + // - launch: Zed will launch the program if specified or shows a debug terminal with the right configuration + // - attach: Zed will attach to a running program to debug it or when the process_id is not specified we will show a process picker (only supported for node currently) + "request": "launch", + // cwd: defaults to the current working directory of your project ($ZED_WORKTREE_ROOT) + // this field also supports task variables e.g. $ZED_WORKTREE_ROOT + "cwd": "$ZED_WORKTREE_ROOT", + // program: The program that you want to debug + // this fields also support task variables e.g. $ZED_FILE + // Note: this field should only contain the path to the program you want to debug + "program": "path_to_program", + // initialize_args: This field should contain all the adapter specific initialization arguments that are directly send to the debug adapter + "initialize_args": { + // "stopOnEntry": true // e.g. to stop on the first line of the program (These args are DAP specific) + } + } +] +``` + +### Using Attach [WIP] + +Only javascript and lldb supports starting a debug session using attach. + +When using the attach request with a process ID the syntax is as follows: + +```json +{ + "label": "Attach to Process", + "adapter": "javascript", + "request": { + "attach": { + "process_id": "12345" + } + } +} +``` + +Without process ID the syntax is as follows: + +```json +{ + "label": "Attach to Process", + "adapter": "javascript", + "request": { + "attach": {} + } +} +``` + +#### JavaScript Configuration + +##### Debug Active File + +This configuration allows you to debug a JavaScript file in your project. + +```json +{ + "label": "JavaScript: Debug Active File", + "adapter": "javascript", + "program": "$ZED_FILE", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" +} +``` + +##### Debug Terminal + +This configuration will spawn a debug terminal where you could start you program by typing `node test.js`, and the debug adapter will automatically attach to the process. + +```json +{ + "label": "JavaScript: Debug Terminal", + "adapter": "javascript", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT", + // "program": "$ZED_FILE", // optional if you pass this in, you will see the output inside the terminal itself + "initialize_args": { + "console": "integratedTerminal" + } +} +``` + +#### PHP Configuration + +##### Debug Active File + +This configuration allows you to debug a PHP file in your project. + +```json +{ + "label": "PHP: Debug Active File", + "adapter": "php", + "program": "$ZED_FILE", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" +} +``` + +#### Python Configuration + +##### Debug Active File + +This configuration allows you to debug a Python file in your project. + +```json +{ + "label": "Python: Debug Active File", + "adapter": "python", + "program": "$ZED_FILE", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" +} +``` + +#### GDB Configuration + +**NOTE:** This configuration is for Linux systems only & intel macbooks. + +##### Debug Program + +This configuration allows you to debug a program using GDB e.g. Zed itself. + +```json +{ + "label": "GDB: Debug program", + "adapter": "gdb", + "program": "$ZED_WORKTREE_ROOT/target/debug/zed", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" +} +``` + +#### LLDB Configuration + +##### Debug Program + +This configuration allows you to debug a program using LLDB e.g. Zed itself. + +```json +{ + "label": "LLDB: Debug program", + "adapter": "lldb", + "program": "$ZED_WORKTREE_ROOT/target/debug/zed", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" +} +``` + +## Breakpoints + +Zed currently supports these types of breakpoints + +- Log Breakpoints: Output a log message instead of stopping at the breakpoint when it's hit +- Standard Breakpoints: Stop at the breakpoint when it's hit + +Standard breakpoints can be toggled by left clicking on the editor gutter or using the Toggle Breakpoint action. Right clicking on a breakpoint, code action symbol, or code runner symbol brings up the breakpoint context menu. That has options for toggling breakpoints and editing log breakpoints. + +Log breakpoints can also be edited/added through the edit log breakpoint action + +## Settings + +- `stepping_granularity`: Determines the stepping granularity. +- `save_breakpoints`: Whether the breakpoints should be reused across Zed sessions. +- `button`: Whether to show the debug button in the status bar. +- `timeout`: Time in milliseconds until timeout error when connecting to a TCP debug adapter. +- `log_dap_communications`: Whether to log messages between active debug adapters and Zed +- `format_dap_log_messages`: Whether to format dap messages in when adding them to debug adapter logger + +### Stepping granularity + +- Description: The Step granularity that the debugger will use +- Default: line +- Setting: debugger.stepping_granularity + +**Options** + +1. Statement - The step should allow the program to run until the current statement has finished executing. + The meaning of a statement is determined by the adapter and it may be considered equivalent to a line. + For example 'for(int i = 0; i < 10; i++)' could be considered to have 3 statements 'int i = 0', 'i < 10', and 'i++'. + +```json +{ + "debugger": { + "stepping_granularity": "statement" + } +} +``` + +2. Line - The step should allow the program to run until the current source line has executed. + +```json +{ + "debugger": { + "stepping_granularity": "line" + } +} +``` + +3. Instruction - The step should allow one instruction to execute (e.g. one x86 instruction). + +```json +{ + "debugger": { + "stepping_granularity": "instruction" + } +} +``` + +### Save Breakpoints + +- Description: Whether the breakpoints should be saved across Zed sessions. +- Default: true +- Setting: debugger.save_breakpoints + +**Options** + +`boolean` values + +```json +{ + "debugger": { + "save_breakpoints": true + } +} +``` + +### Button + +- Description: Whether the button should be displayed in the debugger toolbar. +- Default: true +- Setting: debugger.show_button + +**Options** + +`boolean` values + +```json +{ + "debugger": { + "show_button": true + } +} +``` + +### Timeout + +- Description: Time in milliseconds until timeout error when connecting to a TCP debug adapter. +- Default: 2000ms +- Setting: debugger.timeout + +**Options** + +`integer` values + +```json +{ + "debugger": { + "timeout": 3000 + } +} +``` + +### Log Dap Communications + +- Description: Whether to log messages between active debug adapters and Zed. (Used for DAP development) +- Default: false +- Setting: debugger.log_dap_communications + +**Options** + +`boolean` values + +```json +{ + "debugger": { + "log_dap_communications": true + } +} +``` + +### Format Dap Log Messages + +- Description: Whether to format dap messages in when adding them to debug adapter logger. (Used for DAP development) +- Default: false +- Setting: debugger.format_dap_log_messages + +**Options** + +`boolean` values + +```json +{ + "debugger": { + "format_dap_log_messages": true + } +} +``` + +## Theme + +The Debugger supports the following theme options + + /// Color used to accent some of the debuggers elements + /// Only accent breakpoint & breakpoint related symbols right now + +**debugger.accent**: Color used to accent breakpoint & breakpoint related symbols +**editor.debugger_active_line.background**: Background color of active debug line