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

Release Notes:

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

---------

Co-authored-by: Anthony Eid <hello@anthonyeid.me>
This commit is contained in:
Julia Ryan 2025-06-10 13:29:11 -07:00 committed by GitHub
parent e4f8c4fb4c
commit dd17fd3d5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 171 additions and 21 deletions

View file

@ -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

View file

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

View file

@ -75,6 +75,7 @@ impl DapLocator for CargoLocator {
}
_ => {}
}
Some(DebugScenario {
adapter: adapter.0,
label: resolved_label.to_string().into(),

View file

@ -98,7 +98,6 @@ impl DapLocator for GoLocator {
if build_config.command != "go" {
return None;
}
let go_action = build_config.args.first()?;
match go_action.as_str() {

View file

@ -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() {

View file

@ -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_")