debug: Launch custom commands from start modal (#32484)

Julia Ryan and Anthony Eid created

Release Notes:

- Add custom command launching from the `debug: start` modal

---------

Co-authored-by: Anthony Eid <hello@anthonyeid.me>

Change summary

assets/settings/default.json                   |   9 
crates/debugger_ui/src/new_process_modal.rs    | 176 ++++++++++++++++++-
crates/project/src/debugger/locators/cargo.rs  |   1 
crates/project/src/debugger/locators/go.rs     |   1 
crates/project/src/debugger/locators/node.rs   |   3 
crates/project/src/debugger/locators/python.rs |   2 
6 files changed, 171 insertions(+), 21 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -1478,7 +1478,8 @@
     "Go": {
       "code_actions_on_format": {
         "source.organizeImports": true
-      }
+      },
+      "debuggers": ["Delve"]
     },
     "GraphQL": {
       "prettier": {
@@ -1543,9 +1544,15 @@
     "Plain Text": {
       "allow_rewrap": "anywhere"
     },
+    "Python": {
+      "debuggers": ["Debugpy"]
+    },
     "Ruby": {
       "language_servers": ["solargraph", "!ruby-lsp", "!rubocop", "!sorbet", "!steep", "..."]
     },
+    "Rust": {
+      "debuggers": ["CodeLLDB"]
+    },
     "SCSS": {
       "prettier": {
         "allowed": true

crates/debugger_ui/src/new_process_modal.rs 🔗

@@ -1,4 +1,4 @@
-use collections::FxHashMap;
+use collections::{FxHashMap, HashMap};
 use language::LanguageRegistry;
 use paths::local_debug_file_relative_path;
 use std::{
@@ -15,9 +15,9 @@ use dap::{
 use editor::{Editor, EditorElement, EditorStyle};
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
-    App, AppContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, HighlightStyle,
-    InteractiveText, KeyContext, PromptButton, PromptLevel, Render, StyledText, Subscription,
-    TextStyle, UnderlineStyle, WeakEntity,
+    Action, App, AppContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+    HighlightStyle, InteractiveText, KeyContext, PromptButton, PromptLevel, Render, StyledText,
+    Subscription, TextStyle, UnderlineStyle, WeakEntity,
 };
 use itertools::Itertools as _;
 use picker::{Picker, PickerDelegate, highlighted_match_with_paths::HighlightedMatch};
@@ -28,10 +28,10 @@ use theme::ThemeSettings;
 use ui::{
     ActiveTheme, Button, ButtonCommon, ButtonSize, CheckboxWithLabel, Clickable, Color, Context,
     ContextMenu, Disableable, DropdownMenu, FluentBuilder, Icon, IconName, IconSize,
-    IconWithIndicator, Indicator, InteractiveElement, IntoElement, Label, LabelCommon as _,
-    ListItem, ListItemSpacing, ParentElement, RenderOnce, SharedString, Styled, StyledExt,
-    StyledTypography, ToggleButton, ToggleState, Toggleable, Tooltip, Window, div, h_flex, px,
-    relative, rems, v_flex,
+    IconWithIndicator, Indicator, InteractiveElement, IntoElement, KeyBinding, Label,
+    LabelCommon as _, LabelSize, ListItem, ListItemSpacing, ParentElement, RenderOnce,
+    SharedString, Styled, StyledExt, StyledTypography, ToggleButton, ToggleState, Toggleable,
+    Tooltip, Window, div, h_flex, px, relative, rems, v_flex,
 };
 use util::ResultExt;
 use workspace::{ModalView, Workspace, pane};
@@ -50,7 +50,7 @@ pub(super) struct NewProcessModal {
     mode: NewProcessMode,
     debug_picker: Entity<Picker<DebugDelegate>>,
     attach_mode: Entity<AttachMode>,
-    launch_mode: Entity<ConfigureMode>,
+    configure_mode: Entity<ConfigureMode>,
     task_mode: TaskMode,
     debugger: Option<DebugAdapterName>,
     // save_scenario_state: Option<SaveScenarioState>,
@@ -253,7 +253,7 @@ impl NewProcessModal {
                     Self {
                         debug_picker,
                         attach_mode,
-                        launch_mode: configure_mode,
+                        configure_mode,
                         task_mode,
                         debugger: None,
                         mode,
@@ -283,7 +283,7 @@ impl NewProcessModal {
             NewProcessMode::Attach => self.attach_mode.update(cx, |this, cx| {
                 this.clone().render(window, cx).into_any_element()
             }),
-            NewProcessMode::Launch => self.launch_mode.update(cx, |this, cx| {
+            NewProcessMode::Launch => self.configure_mode.update(cx, |this, cx| {
                 this.clone().render(dap_menu, window, cx).into_any_element()
             }),
             NewProcessMode::Debug => v_flex()
@@ -297,7 +297,7 @@ impl NewProcessModal {
         match self.mode {
             NewProcessMode::Task => self.task_mode.task_modal.focus_handle(cx),
             NewProcessMode::Attach => self.attach_mode.read(cx).attach_picker.focus_handle(cx),
-            NewProcessMode::Launch => self.launch_mode.read(cx).program.focus_handle(cx),
+            NewProcessMode::Launch => self.configure_mode.read(cx).program.focus_handle(cx),
             NewProcessMode::Debug => self.debug_picker.focus_handle(cx),
         }
     }
@@ -305,7 +305,7 @@ impl NewProcessModal {
     fn debug_scenario(&self, debugger: &str, cx: &App) -> Option<DebugScenario> {
         let request = match self.mode {
             NewProcessMode::Launch => Some(DebugRequest::Launch(
-                self.launch_mode.read(cx).debug_request(cx),
+                self.configure_mode.read(cx).debug_request(cx),
             )),
             NewProcessMode::Attach => Some(DebugRequest::Attach(
                 self.attach_mode.read(cx).debug_request(),
@@ -315,7 +315,7 @@ impl NewProcessModal {
         let label = suggested_label(&request, debugger);
 
         let stop_on_entry = if let NewProcessMode::Launch = &self.mode {
-            Some(self.launch_mode.read(cx).stop_on_entry.selected())
+            Some(self.configure_mode.read(cx).stop_on_entry.selected())
         } else {
             None
         };
@@ -831,7 +831,7 @@ impl Render for NewProcessModal {
                                     .disabled(
                                         self.debugger.is_none()
                                             || self
-                                                .launch_mode
+                                                .configure_mode
                                                 .read(cx)
                                                 .program
                                                 .read(cx)
@@ -1202,7 +1202,7 @@ impl PickerDelegate for DebugDelegate {
     }
 
     fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> std::sync::Arc<str> {
-        "".into()
+        "Find a debug task, or debug a command.".into()
     }
 
     fn update_matches(
@@ -1265,6 +1265,96 @@ impl PickerDelegate for DebugDelegate {
         }
     }
 
+    fn confirm_input(
+        &mut self,
+        _secondary: bool,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) {
+        let text = self.prompt.clone();
+        let (task_context, worktree_id) = self
+            .task_contexts
+            .as_ref()
+            .and_then(|task_contexts| {
+                Some((
+                    task_contexts.active_context().cloned()?,
+                    task_contexts.worktree(),
+                ))
+            })
+            .unwrap_or_default();
+
+        let mut args = shlex::split(&text).into_iter().flatten().peekable();
+        let mut env = HashMap::default();
+        while args.peek().is_some_and(|arg| arg.contains('=')) {
+            let arg = args.next().unwrap();
+            let (lhs, rhs) = arg.split_once('=').unwrap();
+            env.insert(lhs.to_string(), rhs.to_string());
+        }
+
+        let program = if let Some(program) = args.next() {
+            program
+        } else {
+            env = HashMap::default();
+            text
+        };
+
+        let args = args.collect::<Vec<_>>();
+        let task = task::TaskTemplate {
+            label: "one-off".to_owned(),
+            env,
+            command: program,
+            args,
+            ..Default::default()
+        };
+
+        let Some(location) = self
+            .task_contexts
+            .as_ref()
+            .and_then(|cx| cx.location().cloned())
+        else {
+            return;
+        };
+        let file = location.buffer.read(cx).file();
+        let language = location.buffer.read(cx).language();
+        let language_name = language.as_ref().map(|l| l.name());
+        let Some(adapter): Option<DebugAdapterName> =
+            language::language_settings::language_settings(language_name, file, cx)
+                .debuggers
+                .first()
+                .map(SharedString::from)
+                .map(Into::into)
+                .or_else(|| {
+                    language.and_then(|l| {
+                        l.config()
+                            .debuggers
+                            .first()
+                            .map(SharedString::from)
+                            .map(Into::into)
+                    })
+                })
+        else {
+            return;
+        };
+        let Some(debug_scenario) = cx
+            .global::<DapRegistry>()
+            .locators()
+            .iter()
+            .find_map(|locator| locator.1.create_scenario(&task, "one-off", adapter.clone()))
+        else {
+            return;
+        };
+
+        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);
+    }
+
     fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
         let debug_scenario = self
             .matches
@@ -1300,6 +1390,60 @@ impl PickerDelegate for DebugDelegate {
         cx.emit(DismissEvent);
     }
 
+    fn render_footer(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Option<ui::AnyElement> {
+        let current_modifiers = window.modifiers();
+        let footer = h_flex()
+            .w_full()
+            .h_8()
+            .p_2()
+            .justify_between()
+            .rounded_b_sm()
+            .bg(cx.theme().colors().ghost_element_selected)
+            .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(),
+            )
+            .map(|this| {
+                if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty() {
+                    let action = picker::ConfirmInput {
+                        secondary: current_modifiers.secondary(),
+                    }
+                    .boxed_clone();
+                    this.children(KeyBinding::for_action(&*action, window, cx).map(|keybind| {
+                        Button::new("launch-custom", "Launch Custom")
+                            .label_size(LabelSize::Small)
+                            .key_binding(keybind)
+                            .on_click(move |_, window, cx| {
+                                window.dispatch_action(action.boxed_clone(), cx)
+                            })
+                    }))
+                } else {
+                    this.children(KeyBinding::for_action(&menu::Confirm, window, cx).map(
+                        |keybind| {
+                            let is_recent_selected =
+                                self.divider_index >= Some(self.selected_index);
+                            let run_entry_label =
+                                if is_recent_selected { "Rerun" } else { "Spawn" };
+
+                            Button::new("spawn", run_entry_label)
+                                .label_size(LabelSize::Small)
+                                .key_binding(keybind)
+                                .on_click(|_, window, cx| {
+                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx);
+                                })
+                        },
+                    ))
+                }
+            });
+        Some(footer.into_any_element())
+    }
+
     fn render_match(
         &self,
         ix: usize,

crates/project/src/debugger/locators/node.rs 🔗

@@ -31,8 +31,7 @@ impl DapLocator for NodeLocator {
         if cfg!(not(debug_assertions)) {
             return None;
         }
-
-        if adapter.as_ref() != "JavaScript" {
+        if adapter.0.as_ref() != "JavaScript" {
             return None;
         }
         if build_config.command != TYPESCRIPT_RUNNER_VARIABLE.template_value() {

crates/project/src/debugger/locators/python.rs 🔗

@@ -22,7 +22,7 @@ impl DapLocator for PythonLocator {
         resolved_label: &str,
         adapter: DebugAdapterName,
     ) -> Option<DebugScenario> {
-        if adapter.as_ref() != "Debugpy" {
+        if adapter.0.as_ref() != "Debugpy" {
             return None;
         }
         let valid_program = build_config.command.starts_with("$ZED_")