debugger: Add debug task picker to new session modal (#29702)

Anthony Eid created

## Preview 

![image](https://github.com/user-attachments/assets/203a577f-3b38-4017-9571-de1234415162)


### TODO
- [x] Add scenario picker to new session modal
- [x] Make debugger start action open new session modal instead of task
modal
- [x] Fix `esc` not clearing the cancelling the new session modal while
it's in scenario or attach mode
- [x] Resolve debug scenario's correctly

Release Notes:

- N/A

Change summary

crates/debugger_ui/src/debugger_panel.rs    |  60 ++
crates/debugger_ui/src/debugger_ui.rs       |  25 
crates/debugger_ui/src/new_session_modal.rs | 620 ++++++++++++++++++----
crates/picker/src/picker.rs                 |   4 
crates/project/src/task_inventory.rs        |  18 
crates/task/src/lib.rs                      |   7 
crates/task/src/task_template.rs            |  11 
crates/tasks_ui/src/modal.rs                |  23 
crates/tasks_ui/src/tasks_ui.rs             |  19 
9 files changed, 607 insertions(+), 180 deletions(-)

Detailed changes

crates/debugger_ui/src/debugger_panel.rs 🔗

@@ -7,6 +7,7 @@ use crate::{
 };
 use crate::{new_session_modal::NewSessionModal, session::DebugSession};
 use anyhow::{Result, anyhow};
+use collections::{HashMap, HashSet};
 use command_palette_hooks::CommandPaletteFilter;
 use dap::DebugRequest;
 use dap::{
@@ -26,6 +27,7 @@ use project::{Project, debugger::session::ThreadStatus};
 use rpc::proto::{self};
 use settings::Settings;
 use std::any::TypeId;
+use std::path::PathBuf;
 use task::{DebugScenario, TaskContext};
 use ui::{ContextMenu, Divider, DropdownMenu, Tooltip, prelude::*};
 use workspace::SplitDirection;
@@ -403,7 +405,6 @@ impl DebugPanel {
     pub fn resolve_scenario(
         &self,
         scenario: DebugScenario,
-
         task_context: TaskContext,
         buffer: Option<Entity<Buffer>>,
         window: &Window,
@@ -424,8 +425,60 @@ impl DebugPanel {
                 stop_on_entry,
             } = scenario;
             let request = if let Some(mut request) = request {
-                // Resolve task variables within the request.
-                if let DebugRequest::Launch(_) = &mut request {}
+                if let DebugRequest::Launch(launch_config) = &mut request {
+                    let mut variable_names = HashMap::default();
+                    let mut substituted_variables = HashSet::default();
+                    let task_variables = task_context
+                        .task_variables
+                        .iter()
+                        .map(|(key, value)| {
+                            let key_string = key.to_string();
+                            if !variable_names.contains_key(&key_string) {
+                                variable_names.insert(key_string.clone(), key.clone());
+                            }
+                            (key_string, value.as_str())
+                        })
+                        .collect::<HashMap<_, _>>();
+
+                    let cwd = launch_config
+                        .cwd
+                        .as_ref()
+                        .and_then(|cwd| cwd.to_str())
+                        .and_then(|cwd| {
+                            task::substitute_all_template_variables_in_str(
+                                cwd,
+                                &task_variables,
+                                &variable_names,
+                                &mut substituted_variables,
+                            )
+                        });
+
+                    if let Some(cwd) = cwd {
+                        launch_config.cwd = Some(PathBuf::from(cwd))
+                    }
+
+                    if let Some(program) = task::substitute_all_template_variables_in_str(
+                        &launch_config.program,
+                        &task_variables,
+                        &variable_names,
+                        &mut substituted_variables,
+                    ) {
+                        launch_config.program = program;
+                    }
+
+                    for arg in launch_config.args.iter_mut() {
+                        if let Some(substituted_arg) =
+                            task::substitute_all_template_variables_in_str(
+                                &arg,
+                                &task_variables,
+                                &variable_names,
+                                &mut substituted_variables,
+                            )
+                        {
+                            *arg = substituted_arg;
+                        }
+                    }
+                }
 
                 request
             } else if let Some(build) = build {
@@ -944,6 +997,7 @@ impl DebugPanel {
                                                     past_debug_definition,
                                                     weak_panel,
                                                     workspace,
+                                                    None,
                                                     window,
                                                     cx,
                                                 )

crates/debugger_ui/src/debugger_ui.rs 🔗

@@ -158,6 +158,7 @@ pub fn init(cx: &mut App) {
                                     debug_panel.read(cx).past_debug_definition.clone(),
                                     weak_panel,
                                     weak_workspace,
+                                    None,
                                     window,
                                     cx,
                                 )
@@ -166,14 +167,22 @@ pub fn init(cx: &mut App) {
                     },
                 )
                 .register_action(|workspace: &mut Workspace, _: &Start, window, cx| {
-                    tasks_ui::toggle_modal(
-                        workspace,
-                        None,
-                        task::TaskModal::DebugModal,
-                        window,
-                        cx,
-                    )
-                    .detach();
+                    if let Some(debug_panel) = workspace.panel::<DebugPanel>(cx) {
+                        let weak_panel = debug_panel.downgrade();
+                        let weak_workspace = cx.weak_entity();
+                        let task_store = workspace.project().read(cx).task_store().clone();
+
+                        workspace.toggle_modal(window, cx, |window, cx| {
+                            NewSessionModal::new(
+                                debug_panel.read(cx).past_debug_definition.clone(),
+                                weak_panel,
+                                weak_workspace,
+                                Some(task_store),
+                                window,
+                                cx,
+                            )
+                        });
+                    }
                 });
         })
     })

crates/debugger_ui/src/new_session_modal.rs 🔗

@@ -6,19 +6,25 @@ use std::{
 
 use dap::{DapRegistry, DebugRequest, adapters::DebugTaskDefinition};
 use editor::{Editor, EditorElement, EditorStyle};
+use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
-    App, AppContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, TextStyle,
-    WeakEntity,
+    App, AppContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render,
+    Subscription, TextStyle, WeakEntity,
 };
+use picker::{Picker, PickerDelegate, highlighted_match_with_paths::HighlightedMatch};
+use project::{TaskSourceKind, task_store::TaskStore};
+use session_modes::{AttachMode, DebugScenarioDelegate, LaunchMode};
 use settings::Settings;
-use task::{DebugScenario, LaunchRequest, TaskContext};
+use task::{DebugScenario, LaunchRequest};
 use theme::ThemeSettings;
 use ui::{
     ActiveTheme, Button, ButtonCommon, ButtonSize, CheckboxWithLabel, Clickable, Color, Context,
-    ContextMenu, Disableable, DropdownMenu, FluentBuilder, InteractiveElement, IntoElement, Label,
-    LabelCommon as _, ParentElement, RenderOnce, SharedString, Styled, StyledExt, ToggleButton,
-    ToggleState, Toggleable, Window, div, h_flex, relative, rems, v_flex,
+    ContextMenu, Disableable, DropdownMenu, FluentBuilder, Icon, IconName, InteractiveElement,
+    IntoElement, Label, LabelCommon as _, ListItem, ListItemSpacing, ParentElement, RenderOnce,
+    SharedString, Styled, StyledExt, ToggleButton, ToggleState, Toggleable, Window, div, h_flex,
+    relative, rems, v_flex,
 };
+use util::ResultExt;
 use workspace::{ModalView, Workspace};
 
 use crate::{attach_modal::AttachModal, debugger_panel::DebugPanel};
@@ -57,6 +63,7 @@ impl NewSessionModal {
         past_debug_definition: Option<DebugTaskDefinition>,
         debug_panel: WeakEntity<DebugPanel>,
         workspace: WeakEntity<Workspace>,
+        task_store: Option<Entity<TaskStore>>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
@@ -73,6 +80,18 @@ impl NewSessionModal {
             _ => None,
         };
 
+        if let Some(task_store) = task_store {
+            cx.defer_in(window, |this, window, cx| {
+                this.mode = NewSessionMode::scenario(
+                    this.debug_panel.clone(),
+                    this.workspace.clone(),
+                    task_store,
+                    window,
+                    cx,
+                );
+            });
+        };
+
         Self {
             workspace: workspace.clone(),
             debugger,
@@ -86,10 +105,10 @@ impl NewSessionModal {
         }
     }
 
-    fn debug_config(&self, cx: &App, debugger: &str) -> DebugScenario {
-        let request = self.mode.debug_task(cx);
+    fn debug_config(&self, cx: &App, debugger: &str) -> Option<DebugScenario> {
+        let request = self.mode.debug_task(cx)?;
         let label = suggested_label(&request, debugger);
-        DebugScenario {
+        Some(DebugScenario {
             adapter: debugger.to_owned().into(),
             label,
             request: Some(request),
@@ -100,21 +119,42 @@ impl NewSessionModal {
                 _ => None,
             },
             build: None,
-        }
+        })
     }
 
     fn start_new_session(&self, window: &mut Window, cx: &mut Context<Self>) {
         let Some(debugger) = self.debugger.as_ref() else {
-            // todo: show in UI.
+            // todo(debugger): show in UI.
             log::error!("No debugger selected");
             return;
         };
-        let config = self.debug_config(cx, debugger);
+
+        if let NewSessionMode::Scenario(picker) = &self.mode {
+            picker.update(cx, |picker, cx| {
+                picker.delegate.confirm(false, window, cx);
+            });
+            return;
+        }
+
+        let Some(config) = self.debug_config(cx, debugger) else {
+            log::error!("debug config not found in mode: {}", self.mode);
+            return;
+        };
+
         let debug_panel = self.debug_panel.clone();
+        let workspace = self.workspace.clone();
 
         cx.spawn_in(window, async move |this, cx| {
+            let task_contexts = workspace
+                .update_in(cx, |workspace, window, cx| {
+                    tasks_ui::task_contexts(workspace, window, cx)
+                })?
+                .await;
+
+            let task_context = task_contexts.active_context().cloned().unwrap_or_default();
+
             debug_panel.update_in(cx, |debug_panel, window, cx| {
-                debug_panel.start_session(config, TaskContext::default(), None, window, cx)
+                debug_panel.start_session(config, task_context, None, window, cx)
             })?;
             this.update(cx, |_, cx| {
                 cx.emit(DismissEvent);
@@ -256,9 +296,14 @@ impl NewSessionModal {
                             .iter()
                             .flat_map(|task_inventory| {
                                 task_inventory.read(cx).list_debug_scenarios(
-                                    worktree.as_ref().map(|worktree| worktree.read(cx).id()),
+                                    worktree
+                                        .as_ref()
+                                        .map(|worktree| worktree.read(cx).id())
+                                        .iter()
+                                        .copied(),
                                 )
                             })
+                            .map(|(_source_kind, scenario)| scenario)
                             .collect()
                     })
                     .ok()
@@ -277,102 +322,22 @@ impl NewSessionModal {
     }
 }
 
-#[derive(Clone)]
-struct LaunchMode {
-    program: Entity<Editor>,
-    cwd: Entity<Editor>,
-}
-
-impl LaunchMode {
-    fn new(
-        past_launch_config: Option<LaunchRequest>,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> Entity<Self> {
-        let (past_program, past_cwd) = past_launch_config
-            .map(|config| (Some(config.program), config.cwd))
-            .unwrap_or_else(|| (None, None));
-
-        let program = cx.new(|cx| Editor::single_line(window, cx));
-        program.update(cx, |this, cx| {
-            this.set_placeholder_text("Program path", cx);
-
-            if let Some(past_program) = past_program {
-                this.set_text(past_program, window, cx);
-            };
-        });
-        let cwd = cx.new(|cx| Editor::single_line(window, cx));
-        cwd.update(cx, |this, cx| {
-            this.set_placeholder_text("Working Directory", cx);
-            if let Some(past_cwd) = past_cwd {
-                this.set_text(past_cwd.to_string_lossy(), window, cx);
-            };
-        });
-        cx.new(|_| Self { program, cwd })
-    }
-
-    fn debug_task(&self, cx: &App) -> task::LaunchRequest {
-        let path = self.cwd.read(cx).text(cx);
-        task::LaunchRequest {
-            program: self.program.read(cx).text(cx),
-            cwd: path.is_empty().not().then(|| PathBuf::from(path)),
-            args: Default::default(),
-            env: Default::default(),
-        }
-    }
-}
-
-#[derive(Clone)]
-struct AttachMode {
-    definition: DebugTaskDefinition,
-    attach_picker: Entity<AttachModal>,
-}
-
-impl AttachMode {
-    fn new(
-        debugger: Option<SharedString>,
-        workspace: Entity<Workspace>,
-        window: &mut Window,
-        cx: &mut Context<NewSessionModal>,
-    ) -> Entity<Self> {
-        let definition = DebugTaskDefinition {
-            adapter: debugger.clone().unwrap_or_default(),
-            label: "Attach New Session Setup".into(),
-            request: dap::DebugRequest::Attach(task::AttachRequest { process_id: None }),
-            initialize_args: None,
-            tcp_connection: None,
-            stop_on_entry: Some(false),
-        };
-        let attach_picker = cx.new(|cx| {
-            let modal = AttachModal::new(definition.clone(), workspace, false, window, cx);
-            window.focus(&modal.focus_handle(cx));
-
-            modal
-        });
-        cx.new(|_| Self {
-            definition,
-            attach_picker,
-        })
-    }
-    fn debug_task(&self) -> task::AttachRequest {
-        task::AttachRequest { process_id: None }
-    }
-}
-
 static SELECT_DEBUGGER_LABEL: SharedString = SharedString::new_static("Select Debugger");
 static SELECT_SCENARIO_LABEL: SharedString = SharedString::new_static("Select Profile");
 
 #[derive(Clone)]
 enum NewSessionMode {
     Launch(Entity<LaunchMode>),
+    Scenario(Entity<Picker<DebugScenarioDelegate>>),
     Attach(Entity<AttachMode>),
 }
 
 impl NewSessionMode {
-    fn debug_task(&self, cx: &App) -> DebugRequest {
+    fn debug_task(&self, cx: &App) -> Option<DebugRequest> {
         match self {
-            NewSessionMode::Launch(entity) => entity.read(cx).debug_task(cx).into(),
-            NewSessionMode::Attach(entity) => entity.read(cx).debug_task().into(),
+            NewSessionMode::Launch(entity) => Some(entity.read(cx).debug_task(cx).into()),
+            NewSessionMode::Attach(entity) => Some(entity.read(cx).debug_task().into()),
+            NewSessionMode::Scenario(_) => None,
         }
     }
     fn as_attach(&self) -> Option<&Entity<AttachMode>> {
@@ -382,6 +347,78 @@ impl NewSessionMode {
             None
         }
     }
+
+    fn scenario(
+        debug_panel: WeakEntity<DebugPanel>,
+        workspace: WeakEntity<Workspace>,
+        task_store: Entity<TaskStore>,
+        window: &mut Window,
+        cx: &mut Context<NewSessionModal>,
+    ) -> NewSessionMode {
+        let picker = cx.new(|cx| {
+            Picker::uniform_list(
+                DebugScenarioDelegate::new(debug_panel, workspace, task_store),
+                window,
+                cx,
+            )
+            .modal(false)
+        });
+
+        cx.subscribe(&picker, |_, _, _, cx| {
+            cx.emit(DismissEvent);
+        })
+        .detach();
+
+        picker.focus_handle(cx).focus(window);
+        NewSessionMode::Scenario(picker)
+    }
+
+    fn attach(
+        debugger: Option<SharedString>,
+        workspace: Entity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<NewSessionModal>,
+    ) -> Self {
+        Self::Attach(AttachMode::new(debugger, workspace, window, cx))
+    }
+
+    fn launch(
+        past_launch_config: Option<LaunchRequest>,
+        window: &mut Window,
+        cx: &mut Context<NewSessionModal>,
+    ) -> Self {
+        Self::Launch(LaunchMode::new(past_launch_config, window, cx))
+    }
+
+    fn has_match(&self, cx: &App) -> bool {
+        match self {
+            NewSessionMode::Scenario(picker) => picker.read(cx).delegate.match_count() > 0,
+            NewSessionMode::Attach(picker) => {
+                picker
+                    .read(cx)
+                    .attach_picker
+                    .read(cx)
+                    .picker
+                    .read(cx)
+                    .delegate
+                    .match_count()
+                    > 0
+            }
+            _ => false,
+        }
+    }
+}
+
+impl std::fmt::Display for NewSessionMode {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let mode = match self {
+            NewSessionMode::Launch(_) => "launch".to_owned(),
+            NewSessionMode::Attach(_) => "attach".to_owned(),
+            NewSessionMode::Scenario(_) => "scenario picker".to_owned(),
+        };
+
+        write!(f, "{}", mode)
+    }
 }
 
 impl Focusable for NewSessionMode {
@@ -389,6 +426,7 @@ impl Focusable for NewSessionMode {
         match &self {
             NewSessionMode::Launch(entity) => entity.read(cx).program.focus_handle(cx),
             NewSessionMode::Attach(entity) => entity.read(cx).attach_picker.focus_handle(cx),
+            NewSessionMode::Scenario(entity) => entity.read(cx).focus_handle(cx),
         }
     }
 }
@@ -437,27 +475,14 @@ impl RenderOnce for NewSessionMode {
             NewSessionMode::Attach(entity) => entity.update(cx, |this, cx| {
                 this.clone().render(window, cx).into_any_element()
             }),
+            NewSessionMode::Scenario(entity) => v_flex()
+                .w(rems(34.))
+                .child(entity.clone())
+                .into_any_element(),
         }
     }
 }
 
-impl NewSessionMode {
-    fn attach(
-        debugger: Option<SharedString>,
-        workspace: Entity<Workspace>,
-        window: &mut Window,
-        cx: &mut Context<NewSessionModal>,
-    ) -> Self {
-        Self::Attach(AttachMode::new(debugger, workspace, window, cx))
-    }
-    fn launch(
-        past_launch_config: Option<LaunchRequest>,
-        window: &mut Window,
-        cx: &mut Context<NewSessionModal>,
-    ) -> Self {
-        Self::Launch(LaunchMode::new(past_launch_config, window, cx))
-    }
-}
 fn render_editor(editor: &Entity<Editor>, window: &mut Window, cx: &App) -> impl IntoElement {
     let settings = ThemeSettings::get_global(cx);
     let theme = cx.theme();
@@ -519,6 +544,34 @@ impl Render for NewSessionModal {
                         h_flex()
                             .justify_start()
                             .w_full()
+                            .child(
+                                ToggleButton::new("debugger-session-ui-picker-button", "Scenarios")
+                                    .size(ButtonSize::Default)
+                                    .style(ui::ButtonStyle::Subtle)
+                                    .toggle_state(matches!(self.mode, NewSessionMode::Scenario(_)))
+                                    .on_click(cx.listener(|this, _, window, cx| {
+                                        let Some(task_store) = this
+                                            .workspace
+                                            .update(cx, |workspace, cx| {
+                                                workspace.project().read(cx).task_store().clone()
+                                            })
+                                            .ok()
+                                        else {
+                                            return;
+                                        };
+
+                                        this.mode = NewSessionMode::scenario(
+                                            this.debug_panel.clone(),
+                                            this.workspace.clone(),
+                                            task_store,
+                                            window,
+                                            cx,
+                                        );
+
+                                        cx.notify();
+                                    }))
+                                    .first(),
+                            )
                             .child(
                                 ToggleButton::new(
                                     "debugger-session-ui-launch-button",
@@ -532,7 +585,7 @@ impl Render for NewSessionModal {
                                     this.mode.focus_handle(cx).focus(window);
                                     cx.notify();
                                 }))
-                                .first(),
+                                .middle(),
                             )
                             .child(
                                 ToggleButton::new(
@@ -601,10 +654,21 @@ impl Render for NewSessionModal {
                             })
                             .child(
                                 Button::new("debugger-spawn", "Start")
-                                    .on_click(cx.listener(|this, _, window, cx| {
-                                        this.start_new_session(window, cx);
+                                    .on_click(cx.listener(|this, _, window, cx| match &this.mode {
+                                        NewSessionMode::Scenario(picker) => {
+                                            picker.update(cx, |picker, cx| {
+                                                picker.delegate.confirm(true, window, cx)
+                                            })
+                                        }
+                                        _ => this.start_new_session(window, cx),
                                     }))
-                                    .disabled(self.debugger.is_none()),
+                                    .disabled(match self.mode {
+                                        NewSessionMode::Scenario(_) => !self.mode.has_match(cx),
+                                        NewSessionMode::Attach(_) => {
+                                            self.debugger.is_none() || !self.mode.has_match(cx)
+                                        }
+                                        NewSessionMode::Launch(_) => self.debugger.is_none(),
+                                    }),
                             ),
                     ),
             )
@@ -619,3 +683,319 @@ impl Focusable for NewSessionModal {
 }
 
 impl ModalView for NewSessionModal {}
+
+// This module makes sure that the modes setup the correct subscriptions whenever they're created
+mod session_modes {
+    use std::rc::Rc;
+
+    use super::*;
+
+    #[derive(Clone)]
+    #[non_exhaustive]
+    pub(super) struct LaunchMode {
+        pub(super) program: Entity<Editor>,
+        pub(super) cwd: Entity<Editor>,
+    }
+
+    impl LaunchMode {
+        pub(super) fn new(
+            past_launch_config: Option<LaunchRequest>,
+            window: &mut Window,
+            cx: &mut App,
+        ) -> Entity<Self> {
+            let (past_program, past_cwd) = past_launch_config
+                .map(|config| (Some(config.program), config.cwd))
+                .unwrap_or_else(|| (None, None));
+
+            let program = cx.new(|cx| Editor::single_line(window, cx));
+            program.update(cx, |this, cx| {
+                this.set_placeholder_text("Program path", cx);
+
+                if let Some(past_program) = past_program {
+                    this.set_text(past_program, window, cx);
+                };
+            });
+            let cwd = cx.new(|cx| Editor::single_line(window, cx));
+            cwd.update(cx, |this, cx| {
+                this.set_placeholder_text("Working Directory", cx);
+                if let Some(past_cwd) = past_cwd {
+                    this.set_text(past_cwd.to_string_lossy(), window, cx);
+                };
+            });
+            cx.new(|_| Self { program, cwd })
+        }
+
+        pub(super) fn debug_task(&self, cx: &App) -> task::LaunchRequest {
+            let path = self.cwd.read(cx).text(cx);
+            task::LaunchRequest {
+                program: self.program.read(cx).text(cx),
+                cwd: path.is_empty().not().then(|| PathBuf::from(path)),
+                args: Default::default(),
+                env: Default::default(),
+            }
+        }
+    }
+
+    #[derive(Clone)]
+    pub(super) struct AttachMode {
+        pub(super) definition: DebugTaskDefinition,
+        pub(super) attach_picker: Entity<AttachModal>,
+        _subscription: Rc<Subscription>,
+    }
+
+    impl AttachMode {
+        pub(super) fn new(
+            debugger: Option<SharedString>,
+            workspace: Entity<Workspace>,
+            window: &mut Window,
+            cx: &mut Context<NewSessionModal>,
+        ) -> Entity<Self> {
+            let definition = DebugTaskDefinition {
+                adapter: debugger.clone().unwrap_or_default(),
+                label: "Attach New Session Setup".into(),
+                request: dap::DebugRequest::Attach(task::AttachRequest { process_id: None }),
+                initialize_args: None,
+                tcp_connection: None,
+                stop_on_entry: Some(false),
+            };
+            let attach_picker = cx.new(|cx| {
+                let modal = AttachModal::new(definition.clone(), workspace, false, window, cx);
+                window.focus(&modal.focus_handle(cx));
+
+                modal
+            });
+
+            let subscription = cx.subscribe(&attach_picker, |_, _, _, cx| {
+                cx.emit(DismissEvent);
+            });
+
+            cx.new(|_| Self {
+                definition,
+                attach_picker,
+                _subscription: Rc::new(subscription),
+            })
+        }
+        pub(super) fn debug_task(&self) -> task::AttachRequest {
+            task::AttachRequest { process_id: None }
+        }
+    }
+
+    pub(super) struct DebugScenarioDelegate {
+        task_store: Entity<TaskStore>,
+        candidates: Option<Vec<(TaskSourceKind, DebugScenario)>>,
+        selected_index: usize,
+        matches: Vec<StringMatch>,
+        prompt: String,
+        debug_panel: WeakEntity<DebugPanel>,
+        workspace: WeakEntity<Workspace>,
+    }
+
+    impl DebugScenarioDelegate {
+        pub(super) fn new(
+            debug_panel: WeakEntity<DebugPanel>,
+            workspace: WeakEntity<Workspace>,
+            task_store: Entity<TaskStore>,
+        ) -> Self {
+            Self {
+                task_store,
+                candidates: None,
+                selected_index: 0,
+                matches: Vec::new(),
+                prompt: String::new(),
+                debug_panel,
+                workspace,
+            }
+        }
+    }
+
+    impl PickerDelegate for DebugScenarioDelegate {
+        type ListItem = ui::ListItem;
+
+        fn match_count(&self) -> usize {
+            self.matches.len()
+        }
+
+        fn selected_index(&self) -> usize {
+            self.selected_index
+        }
+
+        fn set_selected_index(
+            &mut self,
+            ix: usize,
+            _window: &mut Window,
+            _cx: &mut Context<picker::Picker<Self>>,
+        ) {
+            self.selected_index = ix;
+        }
+
+        fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> std::sync::Arc<str> {
+            "".into()
+        }
+
+        fn update_matches(
+            &mut self,
+            query: String,
+            window: &mut Window,
+            cx: &mut Context<picker::Picker<Self>>,
+        ) -> gpui::Task<()> {
+            let candidates: Vec<_> = match &self.candidates {
+                Some(candidates) => candidates
+                    .into_iter()
+                    .enumerate()
+                    .map(|(index, (_, candidate))| {
+                        StringMatchCandidate::new(index, candidate.label.as_ref())
+                    })
+                    .collect(),
+                None => {
+                    let worktree_ids: Vec<_> = self
+                        .workspace
+                        .update(cx, |this, cx| {
+                            this.visible_worktrees(cx)
+                                .map(|tree| tree.read(cx).id())
+                                .collect()
+                        })
+                        .ok()
+                        .unwrap_or_default();
+
+                    let scenarios: Vec<_> = self
+                        .task_store
+                        .read(cx)
+                        .task_inventory()
+                        .map(|item| item.read(cx).list_debug_scenarios(worktree_ids.into_iter()))
+                        .unwrap_or_default();
+
+                    self.candidates = Some(scenarios.clone());
+
+                    scenarios
+                        .into_iter()
+                        .enumerate()
+                        .map(|(index, (_, candidate))| {
+                            StringMatchCandidate::new(index, candidate.label.as_ref())
+                        })
+                        .collect()
+                }
+            };
+
+            cx.spawn_in(window, async move |picker, cx| {
+                let matches = fuzzy::match_strings(
+                    &candidates,
+                    &query,
+                    true,
+                    1000,
+                    &Default::default(),
+                    cx.background_executor().clone(),
+                )
+                .await;
+
+                picker
+                    .update(cx, |picker, _| {
+                        let delegate = &mut picker.delegate;
+
+                        delegate.matches = matches;
+                        delegate.prompt = query;
+
+                        if delegate.matches.is_empty() {
+                            delegate.selected_index = 0;
+                        } else {
+                            delegate.selected_index =
+                                delegate.selected_index.min(delegate.matches.len() - 1);
+                        }
+                    })
+                    .log_err();
+            })
+        }
+
+        fn confirm(
+            &mut self,
+            _: 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
+                            .as_ref()
+                            .map(|candidates| candidates[match_candidate.candidate_id].clone())
+                    });
+
+            let Some((task_source_kind, debug_scenario)) = debug_scenario else {
+                return;
+            };
+
+            let task_context = if let TaskSourceKind::Worktree {
+                id: worktree_id,
+                directory_in_worktree: _,
+                id_base: _,
+            } = task_source_kind
+            {
+                let workspace = self.workspace.clone();
+
+                cx.spawn_in(window, async move |_, cx| {
+                    workspace
+                        .update_in(cx, |workspace, window, cx| {
+                            tasks_ui::task_contexts(workspace, window, cx)
+                        })
+                        .ok()?
+                        .await
+                        .task_context_for_worktree_id(worktree_id)
+                        .cloned()
+                })
+            } else {
+                gpui::Task::ready(None)
+            };
+
+            cx.spawn_in(window, async move |this, cx| {
+                let task_context = task_context.await.unwrap_or_default();
+
+                this.update_in(cx, |this, window, cx| {
+                    this.delegate
+                        .debug_panel
+                        .update(cx, |panel, cx| {
+                            panel.start_session(debug_scenario, task_context, None, window, cx);
+                        })
+                        .ok();
+
+                    cx.emit(DismissEvent);
+                })
+                .ok();
+            })
+            .detach();
+        }
+
+        fn dismissed(&mut self, _: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
+            cx.emit(DismissEvent);
+        }
+
+        fn render_match(
+            &self,
+            ix: usize,
+            selected: bool,
+            window: &mut Window,
+            cx: &mut Context<picker::Picker<Self>>,
+        ) -> Option<Self::ListItem> {
+            let hit = &self.matches[ix];
+
+            let highlighted_location = HighlightedMatch {
+                text: hit.string.clone(),
+                highlight_positions: hit.positions.clone(),
+                char_count: hit.string.chars().count(),
+                color: Color::Default,
+            };
+
+            let icon = Icon::new(IconName::FileTree)
+                .color(Color::Muted)
+                .size(ui::IconSize::Small);
+
+            Some(
+                ListItem::new(SharedString::from(format!("debug-scenario-selection-{ix}")))
+                    .inset(true)
+                    .start_slot::<Icon>(icon)
+                    .spacing(ListItemSpacing::Sparse)
+                    .toggle_state(selected)
+                    .child(highlighted_location.render(window, cx)),
+            )
+        }
+    }
+}

crates/picker/src/picker.rs 🔗

@@ -588,7 +588,9 @@ impl<D: PickerDelegate> Picker<D> {
                 self.update_matches(query, window, cx);
             }
             editor::EditorEvent::Blurred => {
-                self.cancel(&menu::Cancel, window, cx);
+                if self.is_modal {
+                    self.cancel(&menu::Cancel, window, cx);
+                }
             }
             _ => {}
         }

crates/project/src/task_inventory.rs 🔗

@@ -179,6 +179,14 @@ impl TaskContexts {
             })
             .copied()
     }
+
+    pub fn task_context_for_worktree_id(&self, worktree_id: WorktreeId) -> Option<&TaskContext> {
+        self.active_worktree_context
+            .iter()
+            .chain(self.other_worktree_contexts.iter())
+            .find(|(id, _)| *id == worktree_id)
+            .map(|(_, context)| context)
+    }
 }
 
 impl TaskSourceKind {
@@ -206,13 +214,15 @@ impl Inventory {
         cx.new(|_| Self::default())
     }
 
-    pub fn list_debug_scenarios(&self, worktree: Option<WorktreeId>) -> Vec<DebugScenario> {
+    pub fn list_debug_scenarios(
+        &self,
+        worktrees: impl Iterator<Item = WorktreeId>,
+    ) -> Vec<(TaskSourceKind, DebugScenario)> {
         let global_scenarios = self.global_debug_scenarios_from_settings();
-        let worktree_scenarios = self.worktree_scenarios_from_settings(worktree);
 
-        worktree_scenarios
+        worktrees
+            .flat_map(|tree_id| self.worktree_scenarios_from_settings(Some(tree_id)))
             .chain(global_scenarios)
-            .map(|(_, scenario)| scenario)
             .collect()
     }
 

crates/task/src/lib.rs 🔗

@@ -19,7 +19,8 @@ pub use debug_format::{
     AttachRequest, DebugRequest, DebugScenario, DebugTaskFile, LaunchRequest, TcpArgumentsTemplate,
 };
 pub use task_template::{
-    DebugArgsRequest, HideStrategy, RevealStrategy, TaskModal, TaskTemplate, TaskTemplates,
+    DebugArgsRequest, HideStrategy, RevealStrategy, TaskTemplate, TaskTemplates,
+    substitute_all_template_variables_in_str,
 };
 pub use vscode_debug_format::VsCodeDebugTaskFile;
 pub use vscode_format::VsCodeTaskFile;
@@ -266,6 +267,10 @@ impl TaskVariables {
             }
         })
     }
+
+    pub fn iter(&self) -> impl Iterator<Item = (&VariableName, &String)> {
+        self.0.iter()
+    }
 }
 
 impl FromIterator<(VariableName, String)> for TaskVariables {

crates/task/src/task_template.rs 🔗

@@ -83,15 +83,6 @@ pub enum DebugArgsRequest {
     Attach(AttachRequest),
 }
 
-#[derive(Clone, Debug, PartialEq, Eq)]
-/// The type of task modal to spawn
-pub enum TaskModal {
-    /// Show regular tasks
-    ScriptModal,
-    /// Show debug tasks
-    DebugModal,
-}
-
 /// What to do with the terminal pane and tab, after the command was started.
 #[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
 #[serde(rename_all = "snake_case")]
@@ -302,7 +293,7 @@ fn to_hex_hash(object: impl Serialize) -> anyhow::Result<String> {
     Ok(hex::encode(hasher.finalize()))
 }
 
-fn substitute_all_template_variables_in_str<A: AsRef<str>>(
+pub fn substitute_all_template_variables_in_str<A: AsRef<str>>(
     template_str: &str,
     task_variables: &HashMap<String, A>,
     variable_names: &HashMap<String, VariableName>,

crates/tasks_ui/src/modal.rs 🔗

@@ -10,7 +10,7 @@ use gpui::{
 use itertools::Itertools;
 use picker::{Picker, PickerDelegate, highlighted_match_with_paths::HighlightedMatch};
 use project::{TaskSourceKind, task_store::TaskStore};
-use task::{DebugScenario, ResolvedTask, RevealTarget, TaskContext, TaskModal, TaskTemplate};
+use task::{DebugScenario, ResolvedTask, RevealTarget, TaskContext, TaskTemplate};
 use ui::{
     ActiveTheme, Button, ButtonCommon, ButtonSize, Clickable, Color, FluentBuilder as _, Icon,
     IconButton, IconButtonShape, IconName, IconSize, IntoElement, KeyBinding, Label, LabelSize,
@@ -34,8 +34,6 @@ pub(crate) struct TasksModalDelegate {
     prompt: String,
     task_contexts: TaskContexts,
     placeholder_text: Arc<str>,
-    /// If this delegate is responsible for running a scripting task or a debugger
-    task_modal_type: TaskModal,
 }
 
 /// Task template amendments to do before resolving the context.
@@ -50,7 +48,6 @@ impl TasksModalDelegate {
         task_store: Entity<TaskStore>,
         task_contexts: TaskContexts,
         task_overrides: Option<TaskOverrides>,
-        task_modal_type: TaskModal,
         workspace: WeakEntity<Workspace>,
     ) -> Self {
         let placeholder_text = if let Some(TaskOverrides {
@@ -71,7 +68,6 @@ impl TasksModalDelegate {
             selected_index: 0,
             prompt: String::default(),
             task_contexts,
-            task_modal_type,
             task_overrides,
             placeholder_text,
         }
@@ -136,19 +132,12 @@ impl TasksModal {
         task_contexts: TaskContexts,
         task_overrides: Option<TaskOverrides>,
         workspace: WeakEntity<Workspace>,
-        task_modal_type: TaskModal,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
         let picker = cx.new(|cx| {
             Picker::uniform_list(
-                TasksModalDelegate::new(
-                    task_store,
-                    task_contexts,
-                    task_overrides,
-                    task_modal_type,
-                    workspace,
-                ),
+                TasksModalDelegate::new(task_store, task_contexts, task_overrides, workspace),
                 window,
                 cx,
             )
@@ -231,9 +220,8 @@ impl PickerDelegate for TasksModalDelegate {
         window: &mut Window,
         cx: &mut Context<picker::Picker<Self>>,
     ) -> Task<()> {
-        let task_type = self.task_modal_type.clone();
         let candidates = match &self.candidates {
-            Some(candidates) => Task::ready(string_match_candidates(candidates, task_type)),
+            Some(candidates) => Task::ready(string_match_candidates(candidates)),
             None => {
                 if let Some(task_inventory) = self.task_store.read(cx).task_inventory().cloned() {
                     let (used, current) = task_inventory
@@ -276,8 +264,7 @@ impl PickerDelegate for TasksModalDelegate {
                                     },
                                 ));
                                 new_candidates.extend(current);
-                                let match_candidates =
-                                    string_match_candidates(&new_candidates, task_type);
+                                let match_candidates = string_match_candidates(&new_candidates);
                                 let _ = picker.delegate.candidates.insert(new_candidates);
                                 match_candidates
                             })
@@ -669,12 +656,10 @@ impl PickerDelegate for TasksModalDelegate {
 
 fn string_match_candidates<'a>(
     candidates: impl IntoIterator<Item = &'a (TaskSourceKind, ResolvedTask)> + 'a,
-    task_modal_type: TaskModal,
 ) -> Vec<StringMatchCandidate> {
     candidates
         .into_iter()
         .enumerate()
-        .filter(|(_, (_, _))| task_modal_type == TaskModal::ScriptModal)
         .map(|(index, (_, candidate))| StringMatchCandidate::new(index, candidate.display_label()))
         .collect()
 }

crates/tasks_ui/src/tasks_ui.rs 🔗

@@ -5,9 +5,7 @@ use editor::Editor;
 use gpui::{App, AppContext as _, Context, Entity, Task, Window};
 use modal::TaskOverrides;
 use project::{Location, TaskContexts, TaskSourceKind, Worktree};
-use task::{
-    RevealTarget, TaskContext, TaskId, TaskModal, TaskTemplate, TaskVariables, VariableName,
-};
+use task::{RevealTarget, TaskContext, TaskId, TaskTemplate, TaskVariables, VariableName};
 use workspace::Workspace;
 
 mod modal;
@@ -83,7 +81,7 @@ pub fn init(cx: &mut App) {
                             );
                         }
                     } else {
-                        toggle_modal(workspace, None, TaskModal::ScriptModal, window, cx).detach();
+                        toggle_modal(workspace, None, window, cx).detach();
                     };
                 });
         },
@@ -125,21 +123,15 @@ fn spawn_task_or_modal(
             )
             .detach_and_log_err(cx)
         }
-        Spawn::ViaModal { reveal_target } => toggle_modal(
-            workspace,
-            *reveal_target,
-            TaskModal::ScriptModal,
-            window,
-            cx,
-        )
-        .detach(),
+        Spawn::ViaModal { reveal_target } => {
+            toggle_modal(workspace, *reveal_target, window, cx).detach()
+        }
     }
 }
 
 pub fn toggle_modal(
     workspace: &mut Workspace,
     reveal_target: Option<RevealTarget>,
-    task_type: TaskModal,
     window: &mut Window,
     cx: &mut Context<Workspace>,
 ) -> Task<()> {
@@ -162,7 +154,6 @@ pub fn toggle_modal(
                                 reveal_target: Some(target),
                             }),
                             workspace_handle,
-                            task_type,
                             window,
                             cx,
                         )