From e47c48fd3b337fc66a843b163d5eabf38ef2b33f Mon Sep 17 00:00:00 2001 From: Julia Ryan Date: Tue, 17 Jun 2025 15:51:05 -0700 Subject: [PATCH] debugger: Add comment-preserving debug.json editing (#32896) Release Notes: - Re-added "Save to `debug.json`" for custom debug tasks --------- Co-authored-by: Cole Miller --- Cargo.lock | 3 + crates/dap/src/adapters.rs | 2 +- crates/debugger_ui/Cargo.toml | 4 +- crates/debugger_ui/src/debugger_panel.rs | 149 +++++++++++------- crates/debugger_ui/src/new_process_modal.rs | 162 +++++++++++--------- 5 files changed, 186 insertions(+), 134 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e82d4768b3..b72773ae98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4293,6 +4293,7 @@ dependencies = [ "rpc", "serde", "serde_json", + "serde_json_lenient", "settings", "shlex", "sysinfo", @@ -4300,6 +4301,8 @@ dependencies = [ "tasks_ui", "terminal_view", "theme", + "tree-sitter", + "tree-sitter-json", "ui", "unindent", "util", diff --git a/crates/dap/src/adapters.rs b/crates/dap/src/adapters.rs index e3a35d8ac8..a269c099cc 100644 --- a/crates/dap/src/adapters.rs +++ b/crates/dap/src/adapters.rs @@ -1,10 +1,10 @@ -use ::fs::Fs; use anyhow::{Context as _, Result, anyhow}; use async_compression::futures::bufread::GzipDecoder; use async_tar::Archive; use async_trait::async_trait; use collections::HashMap; pub use dap_types::{StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest}; +use fs::Fs; use futures::io::BufReader; use gpui::{AsyncApp, SharedString}; pub use http_client::{HttpClient, github::latest_github_release}; diff --git a/crates/debugger_ui/Cargo.toml b/crates/debugger_ui/Cargo.toml index e2e5b9f75a..c837d16dd1 100644 --- a/crates/debugger_ui/Cargo.toml +++ b/crates/debugger_ui/Cargo.toml @@ -50,7 +50,7 @@ project.workspace = true rpc.workspace = true serde.workspace = true serde_json.workspace = true -# serde_json_lenient.workspace = true +serde_json_lenient.workspace = true settings.workspace = true shlex.workspace = true sysinfo.workspace = true @@ -58,6 +58,8 @@ task.workspace = true tasks_ui.workspace = true terminal_view.workspace = true theme.workspace = true +tree-sitter.workspace = true +tree-sitter-json.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 05620359a9..4ab9517fe1 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -7,7 +7,7 @@ use crate::{ NewProcessModal, NewProcessMode, Pause, Restart, StepInto, StepOut, StepOver, Stop, ToggleExpandItem, ToggleSessionPicker, ToggleThreadPicker, persistence, spawn_task_or_modal, }; -use anyhow::Result; +use anyhow::{Context as _, Result, anyhow}; use dap::adapters::DebugAdapterName; use dap::debugger_settings::DebugPanelDockPosition; use dap::{ @@ -21,14 +21,16 @@ use gpui::{ WeakEntity, actions, anchored, deferred, }; +use itertools::Itertools as _; use language::Buffer; use project::debugger::session::{Session, SessionStateEvent}; -use project::{Fs, WorktreeId}; +use project::{Fs, ProjectPath, WorktreeId}; use project::{Project, debugger::session::ThreadStatus}; use rpc::proto::{self}; use settings::Settings; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use task::{DebugScenario, TaskContext}; +use tree_sitter::{Query, StreamingIterator as _}; use ui::{ContextMenu, Divider, PopoverMenuHandle, Tooltip, prelude::*}; use util::maybe; use workspace::SplitDirection; @@ -957,69 +959,98 @@ impl DebugPanel { cx.notify(); } - // TODO: restore once we have proper comment preserving file edits - // pub(crate) fn save_scenario( - // &self, - // scenario: &DebugScenario, - // worktree_id: WorktreeId, - // window: &mut Window, - // cx: &mut App, - // ) -> Task> { - // self.workspace - // .update(cx, |workspace, cx| { - // let Some(mut path) = workspace.absolute_path_of_worktree(worktree_id, cx) else { - // return Task::ready(Err(anyhow!("Couldn't get worktree path"))); - // }; + pub(crate) fn save_scenario( + &self, + scenario: &DebugScenario, + worktree_id: WorktreeId, + window: &mut Window, + cx: &mut App, + ) -> Task> { + self.workspace + .update(cx, |workspace, cx| { + let Some(mut path) = workspace.absolute_path_of_worktree(worktree_id, cx) else { + return Task::ready(Err(anyhow!("Couldn't get worktree path"))); + }; - // let serialized_scenario = serde_json::to_value(scenario); + let serialized_scenario = serde_json::to_value(scenario); - // cx.spawn_in(window, async move |workspace, cx| { - // let serialized_scenario = serialized_scenario?; - // let fs = - // workspace.read_with(cx, |workspace, _| workspace.app_state().fs.clone())?; + cx.spawn_in(window, async move |workspace, cx| { + let serialized_scenario = serialized_scenario?; + let fs = + workspace.read_with(cx, |workspace, _| workspace.app_state().fs.clone())?; - // path.push(paths::local_settings_folder_relative_path()); - // if !fs.is_dir(path.as_path()).await { - // fs.create_dir(path.as_path()).await?; - // } - // path.pop(); + path.push(paths::local_settings_folder_relative_path()); + if !fs.is_dir(path.as_path()).await { + fs.create_dir(path.as_path()).await?; + } + path.pop(); - // path.push(paths::local_debug_file_relative_path()); - // let path = path.as_path(); + path.push(paths::local_debug_file_relative_path()); + let path = path.as_path(); - // if !fs.is_file(path).await { - // fs.create_file(path, Default::default()).await?; - // fs.write( - // path, - // initial_local_debug_tasks_content().to_string().as_bytes(), - // ) - // .await?; - // } + if !fs.is_file(path).await { + fs.create_file(path, Default::default()).await?; + fs.write( + path, + settings::initial_local_debug_tasks_content() + .to_string() + .as_bytes(), + ) + .await?; + } - // let content = fs.load(path).await?; - // let mut values = - // serde_json_lenient::from_str::>(&content)?; - // values.push(serialized_scenario); - // fs.save( - // path, - // &serde_json_lenient::to_string_pretty(&values).map(Into::into)?, - // Default::default(), - // ) - // .await?; + let mut content = fs.load(path).await?; + let new_scenario = serde_json_lenient::to_string_pretty(&serialized_scenario)? + .lines() + .map(|l| format!(" {l}")) + .join("\n"); - // workspace.update(cx, |workspace, cx| { - // workspace - // .project() - // .read(cx) - // .project_path_for_absolute_path(&path, cx) - // .context( - // "Couldn't get project path for .zed/debug.json in active worktree", - // ) - // })? - // }) - // }) - // .unwrap_or_else(|err| Task::ready(Err(err))) - // } + static ARRAY_QUERY: LazyLock = LazyLock::new(|| { + Query::new( + &tree_sitter_json::LANGUAGE.into(), + "(document (array (object) @object))", // TODO: use "." anchor to only match last object + ) + .expect("Failed to create ARRAY_QUERY") + }); + + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&tree_sitter_json::LANGUAGE.into()) + .unwrap(); + let mut cursor = tree_sitter::QueryCursor::new(); + let syntax_tree = parser.parse(&content, None).unwrap(); + let mut matches = + cursor.matches(&ARRAY_QUERY, syntax_tree.root_node(), content.as_bytes()); + + // we don't have `.last()` since it's a lending iterator, so loop over + // the whole thing to find the last one + let mut last_offset = None; + while let Some(mat) = matches.next() { + if let Some(pos) = mat.captures.first().map(|m| m.node.byte_range().end) { + last_offset = Some(pos) + } + } + + if let Some(pos) = last_offset { + content.insert_str(pos, &new_scenario); + content.insert_str(pos, ",\n"); + } + + fs.write(path, content.as_bytes()).await?; + + workspace.update(cx, |workspace, cx| { + workspace + .project() + .read(cx) + .project_path_for_absolute_path(&path, cx) + .context( + "Couldn't get project path for .zed/debug.json in active worktree", + ) + })? + }) + }) + .unwrap_or_else(|err| Task::ready(Err(err))) + } pub(crate) fn toggle_thread_picker(&mut self, window: &mut Window, cx: &mut Context) { self.thread_picker_menu_handle.toggle(window, cx); diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index 5b769c07ac..ffd25e610f 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -6,6 +6,7 @@ use std::{ borrow::Cow, path::{Path, PathBuf}, sync::Arc, + time::Duration, usize, }; use tasks_ui::{TaskOverrides, TasksModal}; @@ -39,11 +40,12 @@ use workspace::{ModalView, Workspace, pane}; use crate::{attach_modal::AttachModal, debugger_panel::DebugPanel}; -// enum SaveScenarioState { -// Saving, -// Saved((ProjectPath, SharedString)), -// Failed(SharedString), -// } +#[allow(unused)] +enum SaveScenarioState { + Saving, + Saved((ProjectPath, SharedString)), + Failed(SharedString), +} pub(super) struct NewProcessModal { workspace: WeakEntity, @@ -54,7 +56,7 @@ pub(super) struct NewProcessModal { configure_mode: Entity, task_mode: TaskMode, debugger: Option, - // save_scenario_state: Option, + save_scenario_state: Option, _subscriptions: [Subscription; 3], } @@ -265,7 +267,7 @@ impl NewProcessModal { mode, debug_panel: debug_panel.downgrade(), workspace: workspace_handle, - // save_scenario_state: None, + save_scenario_state: None, _subscriptions, } }); @@ -352,12 +354,11 @@ impl NewProcessModal { return; } - // TODO: Restore once we have proper, comment preserving edits - // if let NewProcessMode::Launch = &self.mode { - // if self.launch_mode.read(cx).save_to_debug_json.selected() { - // self.save_debug_scenario(window, cx); - // } - // } + if let NewProcessMode::Launch = &self.mode { + if self.configure_mode.read(cx).save_to_debug_json.selected() { + self.save_debug_scenario(window, cx); + } + } let Some(debugger) = self.debugger.clone() else { return; @@ -418,47 +419,64 @@ impl NewProcessModal { self.debug_picker.read(cx).delegate.task_contexts.clone() } - // fn save_debug_scenario(&mut self, window: &mut Window, cx: &mut Context) { - // let Some((save_scenario, scenario_label)) = self - // .debugger - // .as_ref() - // .and_then(|debugger| self.debug_scenario(&debugger, cx)) - // .zip(self.task_contexts(cx).and_then(|tcx| tcx.worktree())) - // .and_then(|(scenario, worktree_id)| { - // self.debug_panel - // .update(cx, |panel, cx| { - // panel.save_scenario(&scenario, worktree_id, window, cx) - // }) - // .ok() - // .zip(Some(scenario.label.clone())) - // }) - // else { - // return; - // }; + fn save_debug_scenario(&mut self, window: &mut Window, cx: &mut Context) { + let task_contents = self.task_contexts(cx); + let Some(adapter) = self.debugger.as_ref() else { + return; + }; + let scenario = self.debug_scenario(&adapter, cx); - // self.save_scenario_state = Some(SaveScenarioState::Saving); + self.save_scenario_state = Some(SaveScenarioState::Saving); - // cx.spawn(async move |this, cx| { - // let res = save_scenario.await; + cx.spawn_in(window, async move |this, cx| { + let Some((scenario, worktree_id)) = scenario + .await + .zip(task_contents.and_then(|tcx| tcx.worktree())) + else { + this.update(cx, |this, _| { + this.save_scenario_state = Some(SaveScenarioState::Failed( + "Couldn't get scenario or task contents".into(), + )) + }) + .ok(); + return; + }; - // this.update(cx, |this, _| match res { - // Ok(saved_file) => { - // this.save_scenario_state = - // Some(SaveScenarioState::Saved((saved_file, scenario_label))) - // } - // Err(error) => { - // this.save_scenario_state = - // Some(SaveScenarioState::Failed(error.to_string().into())) - // } - // }) - // .ok(); + let Some(save_scenario) = this + .update_in(cx, |this, window, cx| { + this.debug_panel + .update(cx, |panel, cx| { + panel.save_scenario(&scenario, worktree_id, window, cx) + }) + .ok() + }) + .ok() + .flatten() + else { + return; + }; + let res = save_scenario.await; - // cx.background_executor().timer(Duration::from_secs(3)).await; - // this.update(cx, |this, _| this.save_scenario_state.take()) - // .ok(); - // }) - // .detach(); - // } + this.update(cx, |this, _| match res { + Ok(saved_file) => { + this.save_scenario_state = Some(SaveScenarioState::Saved(( + saved_file, + scenario.label.clone(), + ))) + } + Err(error) => { + this.save_scenario_state = + Some(SaveScenarioState::Failed(error.to_string().into())) + } + }) + .ok(); + + cx.background_executor().timer(Duration::from_secs(3)).await; + this.update(cx, |this, _| this.save_scenario_state.take()) + .ok(); + }) + .detach(); + } fn adapter_drop_down_menu( &mut self, @@ -903,7 +921,7 @@ pub(super) struct ConfigureMode { program: Entity, cwd: Entity, stop_on_entry: ToggleState, - // save_to_debug_json: ToggleState, + save_to_debug_json: ToggleState, } impl ConfigureMode { @@ -922,7 +940,7 @@ impl ConfigureMode { program, cwd, stop_on_entry: ToggleState::Unselected, - // save_to_debug_json: ToggleState::Unselected, + save_to_debug_json: ToggleState::Unselected, }) } @@ -1028,27 +1046,25 @@ impl ConfigureMode { ) .checkbox_position(ui::IconPosition::End), ) - // TODO: restore once we have proper, comment preserving - // file edits. - // .child( - // CheckboxWithLabel::new( - // "debugger-save-to-debug-json", - // Label::new("Save to debug.json") - // .size(ui::LabelSize::Small) - // .color(Color::Muted), - // self.save_to_debug_json, - // { - // let this = cx.weak_entity(); - // move |state, _, cx| { - // this.update(cx, |this, _| { - // this.save_to_debug_json = *state; - // }) - // .ok(); - // } - // }, - // ) - // .checkbox_position(ui::IconPosition::End), - // ) + .child( + CheckboxWithLabel::new( + "debugger-save-to-debug-json", + Label::new("Save to debug.json") + .size(ui::LabelSize::Small) + .color(Color::Muted), + self.save_to_debug_json, + { + let this = cx.weak_entity(); + move |state, _, cx| { + this.update(cx, |this, _| { + this.save_to_debug_json = *state; + }) + .ok(); + } + }, + ) + .checkbox_position(ui::IconPosition::End), + ) } }