debugger: Add comment-preserving debug.json editing (#32896)

Julia Ryan and Cole Miller created

Release Notes:

- Re-added "Save to `debug.json`" for custom debug tasks

---------

Co-authored-by: Cole Miller <cole@zed.dev>

Change summary

Cargo.lock                                  |   3 
crates/dap/src/adapters.rs                  |   2 
crates/debugger_ui/Cargo.toml               |   4 
crates/debugger_ui/src/debugger_panel.rs    | 163 +++++++++++++--------
crates/debugger_ui/src/new_process_modal.rs | 170 ++++++++++++----------
5 files changed, 197 insertions(+), 145 deletions(-)

Detailed changes

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",

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};

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

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<Result<ProjectPath>> {
-    //     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);
-
-    //             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_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?;
-    //                 }
-
-    //                 let content = fs.load(path).await?;
-    //                 let mut values =
-    //                     serde_json_lenient::from_str::<Vec<serde_json::Value>>(&content)?;
-    //                 values.push(serialized_scenario);
-    //                 fs.save(
-    //                     path,
-    //                     &serde_json_lenient::to_string_pretty(&values).map(Into::into)?,
-    //                     Default::default(),
-    //                 )
-    //                 .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 save_scenario(
+        &self,
+        scenario: &DebugScenario,
+        worktree_id: WorktreeId,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Task<Result<ProjectPath>> {
+        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);
+
+                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_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,
+                            settings::initial_local_debug_tasks_content()
+                                .to_string()
+                                .as_bytes(),
+                        )
+                        .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");
+
+                    static ARRAY_QUERY: LazyLock<Query> = 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>) {
         self.thread_picker_menu_handle.toggle(window, cx);

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<Workspace>,
@@ -54,7 +56,7 @@ pub(super) struct NewProcessModal {
     configure_mode: Entity<ConfigureMode>,
     task_mode: TaskMode,
     debugger: Option<DebugAdapterName>,
-    // save_scenario_state: Option<SaveScenarioState>,
+    save_scenario_state: Option<SaveScenarioState>,
     _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<Self>) {
-    //     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;
-    //     };
-
-    //     self.save_scenario_state = Some(SaveScenarioState::Saving);
-
-    //     cx.spawn(async move |this, cx| {
-    //         let res = save_scenario.await;
-
-    //         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();
-
-    //         cx.background_executor().timer(Duration::from_secs(3)).await;
-    //         this.update(cx, |this, _| this.save_scenario_state.take())
-    //             .ok();
-    //     })
-    //     .detach();
-    // }
+    fn save_debug_scenario(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        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);
+
+        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;
+            };
+
+            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;
+
+            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<Editor>,
     cwd: Entity<Editor>,
     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),
+            )
     }
 }