Edit debug tasks (#32908)

Julia Ryan and Conrad Irwin created

Release Notes:

- Added the ability to edit LSP provided debug tasks

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

.zed/debug.json                                   |  10 
Cargo.lock                                        |   2 
crates/debugger_ui/Cargo.toml                     |   2 
crates/debugger_ui/src/attach_modal.rs            |  40 +
crates/debugger_ui/src/debugger_panel.rs          | 235 +++++++-
crates/debugger_ui/src/new_process_modal.rs       | 408 ++++++----------
crates/debugger_ui/src/tests/new_process_modal.rs | 232 +++++----
crates/editor/src/element.rs                      |   1 
8 files changed, 515 insertions(+), 415 deletions(-)

Detailed changes

.zed/debug.json 🔗

@@ -5,9 +5,7 @@
     "build": {
       "label": "Build Zed",
       "command": "cargo",
-      "args": [
-        "build"
-      ]
+      "args": ["build"]
     }
   },
   {
@@ -16,9 +14,7 @@
     "build": {
       "label": "Build Zed",
       "command": "cargo",
-      "args": [
-        "build"
-      ]
+      "args": ["build"]
     }
-  },
+  }
 ]

Cargo.lock 🔗

@@ -4324,6 +4324,7 @@ dependencies = [
  "futures 0.3.31",
  "fuzzy",
  "gpui",
+ "indoc",
  "itertools 0.14.0",
  "language",
  "log",
@@ -4344,6 +4345,7 @@ dependencies = [
  "tasks_ui",
  "telemetry",
  "terminal_view",
+ "text",
  "theme",
  "tree-sitter",
  "tree-sitter-go",

crates/debugger_ui/Cargo.toml 🔗

@@ -40,6 +40,7 @@ file_icons.workspace = true
 futures.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
+indoc.workspace = true
 itertools.workspace = true
 language.workspace = true
 log.workspace = true
@@ -60,6 +61,7 @@ task.workspace = true
 tasks_ui.workspace = true
 telemetry.workspace = true
 terminal_view.workspace = true
+text.workspace = true
 theme.workspace = true
 tree-sitter.workspace = true
 tree-sitter-json.workspace = true

crates/debugger_ui/src/attach_modal.rs 🔗

@@ -206,7 +206,7 @@ impl PickerDelegate for AttachModalDelegate {
         })
     }
 
-    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
         let candidate = self
             .matches
             .get(self.selected_index())
@@ -229,30 +229,44 @@ impl PickerDelegate for AttachModalDelegate {
             }
         }
 
+        let workspace = self.workspace.clone();
+        let Some(panel) = workspace
+            .update(cx, |workspace, cx| workspace.panel::<DebugPanel>(cx))
+            .ok()
+            .flatten()
+        else {
+            return;
+        };
+
+        if secondary {
+            // let Some(id) = worktree_id else { return };
+            // cx.spawn_in(window, async move |_, cx| {
+            //     panel
+            //         .update_in(cx, |debug_panel, window, cx| {
+            //             debug_panel.save_scenario(&debug_scenario, id, window, cx)
+            //         })?
+            //         .await?;
+            //     anyhow::Ok(())
+            // })
+            // .detach_and_log_err(cx);
+        }
         let Some(adapter) = cx.read_global::<DapRegistry, _>(|registry, _| {
             registry.adapter(&self.definition.adapter)
         }) else {
             return;
         };
 
-        let workspace = self.workspace.clone();
         let definition = self.definition.clone();
         cx.spawn_in(window, async move |this, cx| {
             let Ok(scenario) = adapter.config_from_zed_format(definition).await else {
                 return;
             };
 
-            let panel = workspace
-                .update(cx, |workspace, cx| workspace.panel::<DebugPanel>(cx))
-                .ok()
-                .flatten();
-            if let Some(panel) = panel {
-                panel
-                    .update_in(cx, |panel, window, cx| {
-                        panel.start_session(scenario, Default::default(), None, None, window, cx);
-                    })
-                    .ok();
-            }
+            panel
+                .update_in(cx, |panel, window, cx| {
+                    panel.start_session(scenario, Default::default(), None, None, window, cx);
+                })
+                .ok();
             this.update(cx, |_, cx| {
                 cx.emit(DismissEvent);
             })

crates/debugger_ui/src/debugger_panel.rs 🔗

@@ -16,16 +16,18 @@ use dap::{
     client::SessionId, debugger_settings::DebuggerSettings,
 };
 use dap::{DapRegistry, StartDebuggingRequestArguments};
+use editor::Editor;
 use gpui::{
     Action, App, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Entity, EntityId,
     EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, Point, Subscription, Task,
     WeakEntity, anchored, deferred,
 };
+use text::ToPoint as _;
 
 use itertools::Itertools as _;
 use language::Buffer;
 use project::debugger::session::{Session, SessionStateEvent};
-use project::{DebugScenarioContext, Fs, ProjectPath, WorktreeId};
+use project::{DebugScenarioContext, Fs, ProjectPath, TaskSourceKind, WorktreeId};
 use project::{Project, debugger::session::ThreadStatus};
 use rpc::proto::{self};
 use settings::Settings;
@@ -35,8 +37,9 @@ use tree_sitter::{Query, StreamingIterator as _};
 use ui::{ContextMenu, Divider, PopoverMenuHandle, Tooltip, prelude::*};
 use util::{ResultExt, maybe};
 use workspace::SplitDirection;
+use workspace::item::SaveOptions;
 use workspace::{
-    Pane, Workspace,
+    Item, Pane, Workspace,
     dock::{DockPosition, Panel, PanelEvent},
 };
 use zed_actions::ToggleFocus;
@@ -988,13 +991,90 @@ impl DebugPanel {
         cx.notify();
     }
 
+    pub(crate) fn go_to_scenario_definition(
+        &self,
+        kind: TaskSourceKind,
+        scenario: DebugScenario,
+        worktree_id: WorktreeId,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        let Some(workspace) = self.workspace.upgrade() else {
+            return Task::ready(Ok(()));
+        };
+        let project_path = match kind {
+            TaskSourceKind::AbsPath { abs_path, .. } => {
+                let Some(project_path) = workspace
+                    .read(cx)
+                    .project()
+                    .read(cx)
+                    .project_path_for_absolute_path(&abs_path, cx)
+                else {
+                    return Task::ready(Err(anyhow!("no abs path")));
+                };
+
+                project_path
+            }
+            TaskSourceKind::Worktree {
+                id,
+                directory_in_worktree: dir,
+                ..
+            } => {
+                let relative_path = if dir.ends_with(".vscode") {
+                    dir.join("launch.json")
+                } else {
+                    dir.join("debug.json")
+                };
+                ProjectPath {
+                    worktree_id: id,
+                    path: Arc::from(relative_path),
+                }
+            }
+            _ => return self.save_scenario(scenario, worktree_id, window, cx),
+        };
+
+        let editor = workspace.update(cx, |workspace, cx| {
+            workspace.open_path(project_path, None, true, window, cx)
+        });
+        cx.spawn_in(window, async move |_, cx| {
+            let editor = editor.await?;
+            let editor = cx
+                .update(|_, cx| editor.act_as::<Editor>(cx))?
+                .context("expected editor")?;
+
+            // unfortunately debug tasks don't have an easy way to globally
+            // identify them. to jump to the one that you just created or an
+            // old one that you're choosing to edit we use a heuristic of searching for a line with `label:  <your label>` from the end rather than the start so we bias towards more renctly
+            editor.update_in(cx, |editor, window, cx| {
+                let row = editor.text(cx).lines().enumerate().find_map(|(row, text)| {
+                    if text.contains(scenario.label.as_ref()) && text.contains("\"label\": ") {
+                        Some(row)
+                    } else {
+                        None
+                    }
+                });
+                if let Some(row) = row {
+                    editor.go_to_singleton_buffer_point(
+                        text::Point::new(row as u32, 4),
+                        window,
+                        cx,
+                    );
+                }
+            })?;
+
+            Ok(())
+        })
+    }
+
     pub(crate) fn save_scenario(
         &self,
-        scenario: &DebugScenario,
+        scenario: DebugScenario,
         worktree_id: WorktreeId,
         window: &mut Window,
-        cx: &mut App,
-    ) -> Task<Result<ProjectPath>> {
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        let this = cx.weak_entity();
+        let project = self.project.clone();
         self.workspace
             .update(cx, |workspace, cx| {
                 let Some(mut path) = workspace.absolute_path_of_worktree(worktree_id, cx) else {
@@ -1027,47 +1107,7 @@ impl DebugPanel {
                         )
                         .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| {
+                    let project_path = workspace.update(cx, |workspace, cx| {
                         workspace
                             .project()
                             .read(cx)
@@ -1075,12 +1115,113 @@ impl DebugPanel {
                             .context(
                                 "Couldn't get project path for .zed/debug.json in active worktree",
                             )
-                    })?
+                    })??;
+
+                    let editor = this
+                        .update_in(cx, |this, window, cx| {
+                            this.workspace.update(cx, |workspace, cx| {
+                                workspace.open_path(project_path, None, true, window, cx)
+                            })
+                        })??
+                        .await?;
+                    let editor = cx
+                        .update(|_, cx| editor.act_as::<Editor>(cx))?
+                        .context("expected editor")?;
+
+                    let new_scenario = serde_json_lenient::to_string_pretty(&serialized_scenario)?
+                        .lines()
+                        .map(|l| format!("  {l}"))
+                        .join("\n");
+
+                    editor
+                        .update_in(cx, |editor, window, cx| {
+                            Self::insert_task_into_editor(editor, new_scenario, project, window, cx)
+                        })??
+                        .await
                 })
             })
             .unwrap_or_else(|err| Task::ready(Err(err)))
     }
 
+    pub fn insert_task_into_editor(
+        editor: &mut Editor,
+        new_scenario: String,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Editor>,
+    ) -> Result<Task<Result<()>>> {
+        static LAST_ITEM_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 LAST_ITEM_QUERY")
+        });
+        static EMPTY_ARRAY_QUERY: LazyLock<Query> = LazyLock::new(|| {
+            Query::new(
+                &tree_sitter_json::LANGUAGE.into(),
+                "(document (array) @array)",
+            )
+            .expect("Failed to create EMPTY_ARRAY_QUERY")
+        });
+
+        let content = editor.text(cx);
+        let mut parser = tree_sitter::Parser::new();
+        parser.set_language(&tree_sitter_json::LANGUAGE.into())?;
+        let mut cursor = tree_sitter::QueryCursor::new();
+        let syntax_tree = parser
+            .parse(&content, None)
+            .context("could not parse debug.json")?;
+        let mut matches = cursor.matches(
+            &LAST_ITEM_QUERY,
+            syntax_tree.root_node(),
+            content.as_bytes(),
+        );
+
+        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)
+            }
+        }
+        let mut edits = Vec::new();
+        let mut cursor_position = 0;
+
+        if let Some(pos) = last_offset {
+            edits.push((pos..pos, format!(",\n{new_scenario}")));
+            cursor_position = pos + ",\n  ".len();
+        } else {
+            let mut matches = cursor.matches(
+                &EMPTY_ARRAY_QUERY,
+                syntax_tree.root_node(),
+                content.as_bytes(),
+            );
+
+            if let Some(mat) = matches.next() {
+                if let Some(pos) = mat.captures.first().map(|m| m.node.byte_range().end - 1) {
+                    edits.push((pos..pos, format!("\n{new_scenario}\n")));
+                    cursor_position = pos + "\n  ".len();
+                }
+            } else {
+                edits.push((0..0, format!("[\n{}\n]", new_scenario)));
+                cursor_position = "[\n  ".len();
+            }
+        }
+        editor.transact(window, cx, |editor, window, cx| {
+            editor.edit(edits, cx);
+            let snapshot = editor
+                .buffer()
+                .read(cx)
+                .as_singleton()
+                .unwrap()
+                .read(cx)
+                .snapshot();
+            let point = cursor_position.to_point(&snapshot);
+            editor.go_to_singleton_buffer_point(point, window, cx);
+        });
+        Ok(editor.save(SaveOptions::default(), project, window, cx))
+    }
+
     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 🔗

@@ -1,12 +1,10 @@
-use anyhow::bail;
+use anyhow::{Context as _, bail};
 use collections::{FxHashMap, HashMap};
 use language::LanguageRegistry;
-use paths::local_debug_file_relative_path;
 use std::{
     borrow::Cow,
     path::{Path, PathBuf},
     sync::Arc,
-    time::Duration,
     usize,
 };
 use tasks_ui::{TaskOverrides, TasksModal};
@@ -18,35 +16,27 @@ use editor::{Editor, EditorElement, EditorStyle};
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
     Action, App, AppContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
-    HighlightStyle, InteractiveText, KeyContext, PromptButton, PromptLevel, Render, StyledText,
-    Subscription, Task, TextStyle, UnderlineStyle, WeakEntity,
+    KeyContext, Render, Subscription, Task, TextStyle, WeakEntity,
 };
 use itertools::Itertools as _;
 use picker::{Picker, PickerDelegate, highlighted_match_with_paths::HighlightedMatch};
-use project::{
-    DebugScenarioContext, ProjectPath, TaskContexts, TaskSourceKind, task_store::TaskStore,
-};
-use settings::{Settings, initial_local_debug_tasks_content};
+use project::{DebugScenarioContext, TaskContexts, TaskSourceKind, task_store::TaskStore};
+use settings::Settings;
 use task::{DebugScenario, RevealTarget, ZedDebugConfig};
 use theme::ThemeSettings;
 use ui::{
-    ActiveTheme, CheckboxWithLabel, Clickable, Context, ContextMenu, Disableable, DropdownMenu,
-    FluentBuilder, IconWithIndicator, Indicator, IntoElement, KeyBinding, ListItem,
-    ListItemSpacing, ParentElement, StyledExt, ToggleButton, ToggleState, Toggleable, Tooltip,
-    Window, div, prelude::*, px, relative, rems,
+    ActiveTheme, Button, ButtonCommon, ButtonSize, CheckboxWithLabel, Clickable, Color, Context,
+    ContextMenu, Disableable, DropdownMenu, FluentBuilder, Icon, IconName, IconSize,
+    IconWithIndicator, Indicator, InteractiveElement, IntoElement, KeyBinding, Label,
+    LabelCommon as _, LabelSize, ListItem, ListItemSpacing, ParentElement, RenderOnce,
+    SharedString, Styled, StyledExt, ToggleButton, ToggleState, Toggleable, Tooltip, Window, div,
+    h_flex, relative, rems, v_flex,
 };
 use util::ResultExt;
-use workspace::{ModalView, Workspace, pane};
+use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr, pane};
 
 use crate::{attach_modal::AttachModal, debugger_panel::DebugPanel};
 
-#[allow(unused)]
-enum SaveScenarioState {
-    Saving,
-    Saved((ProjectPath, SharedString)),
-    Failed(SharedString),
-}
-
 pub(super) struct NewProcessModal {
     workspace: WeakEntity<Workspace>,
     debug_panel: WeakEntity<DebugPanel>,
@@ -56,7 +46,6 @@ pub(super) struct NewProcessModal {
     configure_mode: Entity<ConfigureMode>,
     task_mode: TaskMode,
     debugger: Option<DebugAdapterName>,
-    save_scenario_state: Option<SaveScenarioState>,
     _subscriptions: [Subscription; 3],
 }
 
@@ -268,7 +257,6 @@ impl NewProcessModal {
                         mode,
                         debug_panel: debug_panel.downgrade(),
                         workspace: workspace_handle,
-                        save_scenario_state: None,
                         _subscriptions,
                     }
                 });
@@ -420,63 +408,29 @@ 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 task_contents = self.task_contexts(cx);
+    pub fn save_debug_scenario(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let task_contexts = 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()
+            let scenario = scenario.await.context("no scenario to save")?;
+            let worktree_id = task_contexts
+                .context("no task contexts")?
+                .worktree()
+                .context("no active worktree")?;
+            this.update_in(cx, |this, window, cx| {
+                this.debug_panel.update(cx, |panel, cx| {
+                    panel.save_scenario(scenario, worktree_id, window, cx)
                 })
-                .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()))
-                }
+            })??
+            .await?;
+            this.update_in(cx, |_, _, cx| {
+                cx.emit(DismissEvent);
             })
-            .ok();
-
-            cx.background_executor().timer(Duration::from_secs(3)).await;
-            this.update(cx, |this, _| this.save_scenario_state.take())
-                .ok();
         })
-        .detach();
+        .detach_and_prompt_err("Failed to edit debug.json", window, cx, |_, _, _| None);
     }
 
     fn adapter_drop_down_menu(
@@ -544,70 +498,6 @@ impl NewProcessModal {
             }),
         )
     }
-
-    fn open_debug_json(&self, window: &mut Window, cx: &mut Context<NewProcessModal>) {
-        let this = cx.entity();
-        window
-            .spawn(cx, async move |cx| {
-                let worktree_id = this.update(cx, |this, cx| {
-                    let tcx = this.task_contexts(cx);
-                    tcx?.worktree()
-                })?;
-
-                let Some(worktree_id) = worktree_id else {
-                    let _ = cx.prompt(
-                        PromptLevel::Critical,
-                        "Cannot open debug.json",
-                        Some("You must have at least one project open"),
-                        &[PromptButton::ok("Ok")],
-                    );
-                    return Ok(());
-                };
-
-                let editor = this
-                    .update_in(cx, |this, window, cx| {
-                        this.workspace.update(cx, |workspace, cx| {
-                            workspace.open_path(
-                                ProjectPath {
-                                    worktree_id,
-                                    path: local_debug_file_relative_path().into(),
-                                },
-                                None,
-                                true,
-                                window,
-                                cx,
-                            )
-                        })
-                    })??
-                    .await?;
-
-                cx.update(|_window, cx| {
-                    if let Some(editor) = editor.act_as::<Editor>(cx) {
-                        editor.update(cx, |editor, cx| {
-                            editor.buffer().update(cx, |buffer, cx| {
-                                if let Some(singleton) = buffer.as_singleton() {
-                                    singleton.update(cx, |buffer, cx| {
-                                        if buffer.is_empty() {
-                                            buffer.edit(
-                                                [(0..0, initial_local_debug_tasks_content())],
-                                                None,
-                                                cx,
-                                            );
-                                        }
-                                    })
-                                }
-                            })
-                        });
-                    }
-                })
-                .ok();
-
-                this.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
-
-                anyhow::Ok(())
-            })
-            .detach();
-    }
 }
 
 static SELECT_DEBUGGER_LABEL: SharedString = SharedString::new_static("Select Debugger");
@@ -812,39 +702,21 @@ impl Render for NewProcessModal {
                     NewProcessMode::Launch => el.child(
                         container
                             .child(
-                                h_flex()
-                                    .text_ui_sm(cx)
-                                    .text_color(Color::Muted.color(cx))
-                                    .child(
-                                        InteractiveText::new(
-                                            "open-debug-json",
-                                            StyledText::new(
-                                                "Open .zed/debug.json for advanced configuration.",
-                                            )
-                                            .with_highlights([(
-                                                5..20,
-                                                HighlightStyle {
-                                                    underline: Some(UnderlineStyle {
-                                                        thickness: px(1.0),
-                                                        color: None,
-                                                        wavy: false,
-                                                    }),
-                                                    ..Default::default()
-                                                },
-                                            )]),
-                                        )
-                                        .on_click(
-                                            vec![5..20],
-                                            {
-                                                let this = cx.entity();
-                                                move |_, window, cx| {
-                                                    this.update(cx, |this, cx| {
-                                                        this.open_debug_json(window, cx);
-                                                    })
-                                                }
-                                            },
+                                h_flex().child(
+                                    Button::new("edit-custom-debug", "Edit in debug.json")
+                                        .on_click(cx.listener(|this, _, window, cx| {
+                                            this.save_debug_scenario(window, cx);
+                                        }))
+                                        .disabled(
+                                            self.debugger.is_none()
+                                                || self
+                                                    .configure_mode
+                                                    .read(cx)
+                                                    .program
+                                                    .read(cx)
+                                                    .is_empty(cx),
                                         ),
-                                    ),
+                                ),
                             )
                             .child(
                                 Button::new("debugger-spawn", "Start")
@@ -862,29 +734,48 @@ impl Render for NewProcessModal {
                                     ),
                             ),
                     ),
-                    NewProcessMode::Attach => el.child(
+                    NewProcessMode::Attach => el.child({
+                        let disabled = self.debugger.is_none()
+                            || self
+                                .attach_mode
+                                .read(cx)
+                                .attach_picker
+                                .read(cx)
+                                .picker
+                                .read(cx)
+                                .delegate
+                                .match_count()
+                                == 0;
+                        let secondary_action = menu::SecondaryConfirm.boxed_clone();
                         container
-                            .child(div().child(self.adapter_drop_down_menu(window, cx)))
+                            .child(div().children(
+                                KeyBinding::for_action(&*secondary_action, window, cx).map(
+                                    |keybind| {
+                                        Button::new("edit-attach-task", "Edit in debug.json")
+                                            .label_size(LabelSize::Small)
+                                            .key_binding(keybind)
+                                            .on_click(move |_, window, cx| {
+                                                window.dispatch_action(
+                                                    secondary_action.boxed_clone(),
+                                                    cx,
+                                                )
+                                            })
+                                            .disabled(disabled)
+                                    },
+                                ),
+                            ))
                             .child(
-                                Button::new("debugger-spawn", "Start")
-                                    .on_click(cx.listener(|this, _, window, cx| {
-                                        this.start_new_session(window, cx)
-                                    }))
-                                    .disabled(
-                                        self.debugger.is_none()
-                                            || self
-                                                .attach_mode
-                                                .read(cx)
-                                                .attach_picker
-                                                .read(cx)
-                                                .picker
-                                                .read(cx)
-                                                .delegate
-                                                .match_count()
-                                                == 0,
+                                h_flex()
+                                    .child(div().child(self.adapter_drop_down_menu(window, cx)))
+                                    .child(
+                                        Button::new("debugger-spawn", "Start")
+                                            .on_click(cx.listener(|this, _, window, cx| {
+                                                this.start_new_session(window, cx)
+                                            }))
+                                            .disabled(disabled),
                                     ),
-                            ),
-                    ),
+                            )
+                    }),
                     NewProcessMode::Debug => el,
                     NewProcessMode::Task => el,
                 }
@@ -1048,25 +939,6 @@ impl ConfigureMode {
                 )
                 .checkbox_position(ui::IconPosition::End),
             )
-            .child(
-                CheckboxWithLabel::new(
-                    "debugger-save-to-debug-json",
-                    Label::new("Save to debug.json")
-                        .size(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),
-            )
     }
 }
 
@@ -1329,12 +1201,7 @@ impl PickerDelegate for DebugDelegate {
         }
     }
 
-    fn confirm_input(
-        &mut self,
-        _secondary: bool,
-        window: &mut Window,
-        cx: &mut Context<Picker<Self>>,
-    ) {
+    fn confirm_input(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
         let text = self.prompt.clone();
         let (task_context, worktree_id) = self
             .task_contexts
@@ -1364,7 +1231,7 @@ impl PickerDelegate for DebugDelegate {
 
         let args = args.collect::<Vec<_>>();
         let task = task::TaskTemplate {
-            label: "one-off".to_owned(),
+            label: "one-off".to_owned(), // TODO: rename using command as label
             env,
             command: program,
             args,
@@ -1405,7 +1272,11 @@ impl PickerDelegate for DebugDelegate {
                 .background_spawn(async move {
                     for locator in locators {
                         if let Some(scenario) =
-                            locator.1.create_scenario(&task, "one-off", &adapter).await
+                            // TODO: use a more informative label than "one-off"
+                            locator
+                                .1
+                                .create_scenario(&task, &task.label, &adapter)
+                                .await
                         {
                             return Some(scenario);
                         }
@@ -1439,13 +1310,18 @@ impl PickerDelegate for DebugDelegate {
         .detach();
     }
 
-    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
+    fn confirm(
+        &mut self,
+        secondary: bool,
+        window: &mut Window,
+        cx: &mut Context<picker::Picker<Self>>,
+    ) {
         let debug_scenario = self
             .matches
             .get(self.selected_index())
             .and_then(|match_candidate| self.candidates.get(match_candidate.candidate_id).cloned());
 
-        let Some((_, debug_scenario, context)) = debug_scenario else {
+        let Some((kind, debug_scenario, context)) = debug_scenario else {
             return;
         };
 
@@ -1463,24 +1339,38 @@ impl PickerDelegate for DebugDelegate {
         });
         let DebugScenarioContext {
             task_context,
-            active_buffer,
+            active_buffer: _,
             worktree_id,
         } = context;
-        let active_buffer = active_buffer.and_then(|buffer| buffer.upgrade());
-
-        send_telemetry(&debug_scenario, TelemetrySpawnLocation::ScenarioList, cx);
-        self.debug_panel
-            .update(cx, |panel, cx| {
-                panel.start_session(
-                    debug_scenario,
-                    task_context,
-                    active_buffer,
-                    worktree_id,
-                    window,
-                    cx,
-                );
+
+        if secondary {
+            let Some(kind) = kind else { return };
+            let Some(id) = worktree_id else { return };
+            let debug_panel = self.debug_panel.clone();
+            cx.spawn_in(window, async move |_, cx| {
+                debug_panel
+                    .update_in(cx, |debug_panel, window, cx| {
+                        debug_panel.go_to_scenario_definition(kind, debug_scenario, id, window, cx)
+                    })?
+                    .await?;
+                anyhow::Ok(())
             })
-            .ok();
+            .detach();
+        } else {
+            send_telemetry(&debug_scenario, TelemetrySpawnLocation::ScenarioList, cx);
+            self.debug_panel
+                .update(cx, |panel, cx| {
+                    panel.start_session(
+                        debug_scenario,
+                        task_context,
+                        None,
+                        worktree_id,
+                        window,
+                        cx,
+                    );
+                })
+                .ok();
+        }
 
         cx.emit(DismissEvent);
     }
@@ -1498,19 +1388,23 @@ impl PickerDelegate for DebugDelegate {
         let footer = h_flex()
             .w_full()
             .p_1p5()
-            .justify_end()
+            .justify_between()
             .border_t_1()
             .border_color(cx.theme().colors().border_variant)
-            // .child(
-            //     // TODO: add button to open selected task in debug.json
-            //     h_flex().into_any_element(),
-            // )
+            .children({
+                let action = menu::SecondaryConfirm.boxed_clone();
+                KeyBinding::for_action(&*action, window, cx).map(|keybind| {
+                    Button::new("edit-debug-task", "Edit in debug.json")
+                        .label_size(LabelSize::Small)
+                        .key_binding(keybind)
+                        .on_click(move |_, window, cx| {
+                            window.dispatch_action(action.boxed_clone(), cx)
+                        })
+                })
+            })
             .map(|this| {
                 if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty() {
-                    let action = picker::ConfirmInput {
-                        secondary: current_modifiers.secondary(),
-                    }
-                    .boxed_clone();
+                    let action = picker::ConfirmInput { secondary: false }.boxed_clone();
                     this.children(KeyBinding::for_action(&*action, window, cx).map(|keybind| {
                         Button::new("launch-custom", "Launch Custom")
                             .key_binding(keybind)
@@ -1607,3 +1501,35 @@ pub(crate) fn resolve_path(path: &mut String) {
         );
     };
 }
+
+#[cfg(test)]
+impl NewProcessModal {
+    pub(crate) fn set_configure(
+        &mut self,
+        program: impl AsRef<str>,
+        cwd: impl AsRef<str>,
+        stop_on_entry: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.mode = NewProcessMode::Launch;
+        self.debugger = Some(dap::adapters::DebugAdapterName("fake-adapter".into()));
+
+        self.configure_mode.update(cx, |configure, cx| {
+            configure.program.update(cx, |editor, cx| {
+                editor.clear(window, cx);
+                editor.set_text(program.as_ref(), window, cx);
+            });
+
+            configure.cwd.update(cx, |editor, cx| {
+                editor.clear(window, cx);
+                editor.set_text(cwd.as_ref(), window, cx);
+            });
+
+            configure.stop_on_entry = match stop_on_entry {
+                true => ToggleState::Selected,
+                _ => ToggleState::Unselected,
+            }
+        })
+    }
+}

crates/debugger_ui/src/tests/new_process_modal.rs 🔗

@@ -1,13 +1,15 @@
 use dap::DapRegistry;
+use editor::Editor;
 use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext};
-use project::{FakeFs, Project};
+use project::{FakeFs, Fs as _, Project};
 use serde_json::json;
 use std::sync::Arc;
 use std::sync::atomic::{AtomicBool, Ordering};
 use task::{DebugRequest, DebugScenario, LaunchRequest, TaskContext, VariableName, ZedDebugConfig};
+use text::Point;
 use util::path;
 
-// use crate::new_process_modal::NewProcessMode;
+use crate::NewProcessMode;
 use crate::tests::{init_test, init_test_workspace};
 
 #[gpui::test]
@@ -159,111 +161,127 @@ async fn test_debug_session_substitutes_variables_and_relativizes_paths(
     }
 }
 
-// #[gpui::test]
-// async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut TestAppContext) {
-//     init_test(cx);
-
-//     let fs = FakeFs::new(executor.clone());
-//     fs.insert_tree(
-//         path!("/project"),
-//         json!({
-//             "main.rs": "fn main() {}"
-//         }),
-//     )
-//     .await;
-
-//     let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
-//     let workspace = init_test_workspace(&project, cx).await;
-//     let cx = &mut VisualTestContext::from_window(*workspace, cx);
-
-//     workspace
-//         .update(cx, |workspace, window, cx| {
-//             crate::new_process_modal::NewProcessModal::show(
-//                 workspace,
-//                 window,
-//                 NewProcessMode::Debug,
-//                 None,
-//                 cx,
-//             );
-//         })
-//         .unwrap();
-
-//     cx.run_until_parked();
-
-//     let modal = workspace
-//         .update(cx, |workspace, _, cx| {
-//             workspace.active_modal::<crate::new_process_modal::NewProcessModal>(cx)
-//         })
-//         .unwrap()
-//         .expect("Modal should be active");
-
-//     modal.update_in(cx, |modal, window, cx| {
-//         modal.set_configure("/project/main", "/project", false, window, cx);
-//         modal.save_scenario(window, cx);
-//     });
-
-//     cx.executor().run_until_parked();
-
-//     let debug_json_content = fs
-//         .load(path!("/project/.zed/debug.json").as_ref())
-//         .await
-//         .expect("debug.json should exist");
-
-//     let expected_content = vec![
-//         "[",
-//         "  {",
-//         r#"    "adapter": "fake-adapter","#,
-//         r#"    "label": "main (fake-adapter)","#,
-//         r#"    "request": "launch","#,
-//         r#"    "program": "/project/main","#,
-//         r#"    "cwd": "/project","#,
-//         r#"    "args": [],"#,
-//         r#"    "env": {}"#,
-//         "  }",
-//         "]",
-//     ];
-
-//     let actual_lines: Vec<&str> = debug_json_content.lines().collect();
-//     pretty_assertions::assert_eq!(expected_content, actual_lines);
-
-//     modal.update_in(cx, |modal, window, cx| {
-//         modal.set_configure("/project/other", "/project", true, window, cx);
-//         modal.save_scenario(window, cx);
-//     });
-
-//     cx.executor().run_until_parked();
-
-//     let debug_json_content = fs
-//         .load(path!("/project/.zed/debug.json").as_ref())
-//         .await
-//         .expect("debug.json should exist after second save");
-
-//     let expected_content = vec![
-//         "[",
-//         "  {",
-//         r#"    "adapter": "fake-adapter","#,
-//         r#"    "label": "main (fake-adapter)","#,
-//         r#"    "request": "launch","#,
-//         r#"    "program": "/project/main","#,
-//         r#"    "cwd": "/project","#,
-//         r#"    "args": [],"#,
-//         r#"    "env": {}"#,
-//         "  },",
-//         "  {",
-//         r#"    "adapter": "fake-adapter","#,
-//         r#"    "label": "other (fake-adapter)","#,
-//         r#"    "request": "launch","#,
-//         r#"    "program": "/project/other","#,
-//         r#"    "cwd": "/project","#,
-//         r#"    "args": [],"#,
-//         r#"    "env": {}"#,
-//         "  }",
-//         "]",
-//     ];
-
-//     let actual_lines: Vec<&str> = debug_json_content.lines().collect();
-//     pretty_assertions::assert_eq!(expected_content, actual_lines);
-// }
+#[gpui::test]
+async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor.clone());
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            "main.rs": "fn main() {}"
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+    let workspace = init_test_workspace(&project, cx).await;
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+    workspace
+        .update(cx, |workspace, window, cx| {
+            crate::new_process_modal::NewProcessModal::show(
+                workspace,
+                window,
+                NewProcessMode::Debug,
+                None,
+                cx,
+            );
+        })
+        .unwrap();
+
+    cx.run_until_parked();
+
+    let modal = workspace
+        .update(cx, |workspace, _, cx| {
+            workspace.active_modal::<crate::new_process_modal::NewProcessModal>(cx)
+        })
+        .unwrap()
+        .expect("Modal should be active");
+
+    modal.update_in(cx, |modal, window, cx| {
+        modal.set_configure("/project/main", "/project", false, window, cx);
+        modal.save_debug_scenario(window, cx);
+    });
+
+    cx.executor().run_until_parked();
+
+    let editor = workspace
+        .update(cx, |workspace, _window, cx| {
+            workspace.active_item_as::<Editor>(cx).unwrap()
+        })
+        .unwrap();
+
+    let debug_json_content = fs
+        .load(path!("/project/.zed/debug.json").as_ref())
+        .await
+        .expect("debug.json should exist")
+        .lines()
+        .filter(|line| !line.starts_with("//"))
+        .collect::<Vec<_>>()
+        .join("\n");
+
+    let expected_content = indoc::indoc! {r#"
+        [
+          {
+            "adapter": "fake-adapter",
+            "label": "main (fake-adapter)",
+            "request": "launch",
+            "program": "/project/main",
+            "cwd": "/project",
+            "args": [],
+            "env": {}
+          }
+        ]"#};
+
+    pretty_assertions::assert_eq!(expected_content, debug_json_content);
+
+    editor.update(cx, |editor, cx| {
+        assert_eq!(
+            editor.selections.newest::<Point>(cx).head(),
+            Point::new(5, 2)
+        )
+    });
+
+    modal.update_in(cx, |modal, window, cx| {
+        modal.set_configure("/project/other", "/project", true, window, cx);
+        modal.save_debug_scenario(window, cx);
+    });
+
+    cx.executor().run_until_parked();
+
+    let expected_content = indoc::indoc! {r#"
+        [
+          {
+            "adapter": "fake-adapter",
+            "label": "main (fake-adapter)",
+            "request": "launch",
+            "program": "/project/main",
+            "cwd": "/project",
+            "args": [],
+            "env": {}
+          },
+          {
+            "adapter": "fake-adapter",
+            "label": "other (fake-adapter)",
+            "request": "launch",
+            "program": "/project/other",
+            "cwd": "/project",
+            "args": [],
+            "env": {}
+          }
+        ]"#};
+
+    let debug_json_content = fs
+        .load(path!("/project/.zed/debug.json").as_ref())
+        .await
+        .expect("debug.json should exist")
+        .lines()
+        .filter(|line| !line.starts_with("//"))
+        .collect::<Vec<_>>()
+        .join("\n");
+    pretty_assertions::assert_eq!(expected_content, debug_json_content);
+}
 
 #[gpui::test]
 async fn test_dap_adapter_config_conversion_and_validation(cx: &mut TestAppContext) {

crates/editor/src/element.rs 🔗

@@ -2829,6 +2829,7 @@ impl EditorElement {
     ) -> Vec<AnyElement> {
         self.editor.update(cx, |editor, cx| {
             let active_task_indicator_row =
+                // TODO: add edit button on the right side of each row in the context menu
                 if let Some(crate::CodeContextMenu::CodeActions(CodeActionsMenu {
                     deployed_from,
                     actions,