debugger_ui: Update new process modal to include more context about its source (#36650)

Matt , Anthony , and Anthony created

Closes #36280

Release Notes:
  - Added additional context to debug task selection

Adding additional context when selecting a debug task to help with
projects that have multiple config files with similar names for tasks.

I think there is room for improvement, especially adding context for a
LanguageTask type. I started but it looked like it would need to add a
path value to that and wanted to make sure this was a good idea before
working on that.

Also any thoughts on the wording if you do like this format? 

---

<img width="1246" height="696" alt="image"
src="https://github.com/user-attachments/assets/b42e3f45-cfdb-4cb1-8a7a-3c37f33f5ee2"
/>

---------

Co-authored-by: Anthony <anthony@zed.dev>
Co-authored-by: Anthony <hello@anthonyeid.me>

Change summary

crates/debugger_ui/src/debugger_panel.rs          |   4 
crates/debugger_ui/src/new_process_modal.rs       | 188 ++++++++++++----
crates/debugger_ui/src/tests/new_process_modal.rs |  78 ++++++
crates/project/src/task_inventory.rs              |   1 
crates/tasks_ui/src/modal.rs                      |   2 
5 files changed, 217 insertions(+), 56 deletions(-)

Detailed changes

crates/debugger_ui/src/debugger_panel.rs 🔗

@@ -134,6 +134,10 @@ impl DebugPanel {
             .map(|session| session.read(cx).running_state().clone())
     }
 
+    pub fn project(&self) -> &Entity<Project> {
+        &self.project
+    }
+
     pub fn load(
         workspace: WeakEntity<Workspace>,
         cx: &mut AsyncWindowContext,

crates/debugger_ui/src/new_process_modal.rs 🔗

@@ -1,6 +1,6 @@
 use anyhow::{Context as _, bail};
 use collections::{FxHashMap, HashMap, HashSet};
-use language::LanguageRegistry;
+use language::{LanguageName, LanguageRegistry};
 use std::{
     borrow::Cow,
     path::{Path, PathBuf},
@@ -22,7 +22,7 @@ use itertools::Itertools as _;
 use picker::{Picker, PickerDelegate, highlighted_match_with_paths::HighlightedMatch};
 use project::{DebugScenarioContext, Project, TaskContexts, TaskSourceKind, task_store::TaskStore};
 use settings::Settings;
-use task::{DebugScenario, RevealTarget, ZedDebugConfig};
+use task::{DebugScenario, RevealTarget, VariableName, ZedDebugConfig};
 use theme::ThemeSettings;
 use ui::{
     ActiveTheme, Button, ButtonCommon, ButtonSize, CheckboxWithLabel, Clickable, Color, Context,
@@ -978,6 +978,7 @@ pub(super) struct DebugDelegate {
     task_store: Entity<TaskStore>,
     candidates: Vec<(
         Option<TaskSourceKind>,
+        Option<LanguageName>,
         DebugScenario,
         Option<DebugScenarioContext>,
     )>,
@@ -1005,28 +1006,89 @@ impl DebugDelegate {
         }
     }
 
-    fn get_scenario_kind(
+    fn get_task_subtitle(
+        &self,
+        task_kind: &Option<TaskSourceKind>,
+        context: &Option<DebugScenarioContext>,
+        cx: &mut App,
+    ) -> Option<String> {
+        match task_kind {
+            Some(TaskSourceKind::Worktree {
+                id: worktree_id,
+                directory_in_worktree,
+                ..
+            }) => self
+                .debug_panel
+                .update(cx, |debug_panel, cx| {
+                    let project = debug_panel.project().read(cx);
+                    let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
+
+                    let mut path = if worktrees.len() > 1
+                        && let Some(worktree) = project.worktree_for_id(*worktree_id, cx)
+                    {
+                        let worktree_path = worktree.read(cx).abs_path();
+                        let full_path = worktree_path.join(directory_in_worktree);
+                        full_path
+                    } else {
+                        directory_in_worktree.clone()
+                    };
+
+                    match path
+                        .components()
+                        .next_back()
+                        .and_then(|component| component.as_os_str().to_str())
+                    {
+                        Some(".zed") => {
+                            path.push("debug.json");
+                        }
+                        Some(".vscode") => {
+                            path.push("launch.json");
+                        }
+                        _ => {}
+                    }
+                    Some(path.display().to_string())
+                })
+                .unwrap_or_else(|_| Some(directory_in_worktree.display().to_string())),
+            Some(TaskSourceKind::AbsPath { abs_path, .. }) => {
+                Some(abs_path.to_string_lossy().into_owned())
+            }
+            Some(TaskSourceKind::Lsp { language_name, .. }) => {
+                Some(format!("LSP: {language_name}"))
+            }
+            Some(TaskSourceKind::Language { .. }) => None,
+            _ => context.clone().and_then(|ctx| {
+                ctx.task_context
+                    .task_variables
+                    .get(&VariableName::RelativeFile)
+                    .map(|f| format!("in {f}"))
+                    .or_else(|| {
+                        ctx.task_context
+                            .task_variables
+                            .get(&VariableName::Dirname)
+                            .map(|d| format!("in {d}/"))
+                    })
+            }),
+        }
+    }
+
+    fn get_scenario_language(
         languages: &Arc<LanguageRegistry>,
         dap_registry: &DapRegistry,
         scenario: DebugScenario,
-    ) -> (Option<TaskSourceKind>, DebugScenario) {
+    ) -> (Option<LanguageName>, DebugScenario) {
         let language_names = languages.language_names();
-        let language = dap_registry
-            .adapter_language(&scenario.adapter)
-            .map(|language| TaskSourceKind::Language { name: language.0 });
+        let language_name = dap_registry.adapter_language(&scenario.adapter);
 
-        let language = language.or_else(|| {
+        let language_name = language_name.or_else(|| {
             scenario.label.split_whitespace().find_map(|word| {
                 language_names
                     .iter()
                     .find(|name| name.as_ref().eq_ignore_ascii_case(word))
-                    .map(|name| TaskSourceKind::Language {
-                        name: name.to_owned().into(),
-                    })
+                    .cloned()
             })
         });
 
-        (language, scenario)
+        (language_name, scenario)
     }
 
     pub fn tasks_loaded(
@@ -1080,9 +1142,9 @@ impl DebugDelegate {
                 this.delegate.candidates = recent
                     .into_iter()
                     .map(|(scenario, context)| {
-                        let (kind, scenario) =
-                            Self::get_scenario_kind(&languages, dap_registry, scenario);
-                        (kind, scenario, Some(context))
+                        let (language_name, scenario) =
+                            Self::get_scenario_language(&languages, dap_registry, scenario);
+                        (None, language_name, scenario, Some(context))
                     })
                     .chain(
                         scenarios
@@ -1097,9 +1159,9 @@ impl DebugDelegate {
                             })
                             .filter(|(_, scenario)| valid_adapters.contains(&scenario.adapter))
                             .map(|(kind, scenario)| {
-                                let (language, scenario) =
-                                    Self::get_scenario_kind(&languages, dap_registry, scenario);
-                                (language.or(Some(kind)), scenario, None)
+                                let (language_name, scenario) =
+                                    Self::get_scenario_language(&languages, dap_registry, scenario);
+                                (Some(kind), language_name, scenario, None)
                             }),
                     )
                     .collect();
@@ -1145,7 +1207,7 @@ impl PickerDelegate for DebugDelegate {
             let candidates: Vec<_> = candidates
                 .into_iter()
                 .enumerate()
-                .map(|(index, (_, candidate, _))| {
+                .map(|(index, (_, _, candidate, _))| {
                     StringMatchCandidate::new(index, candidate.label.as_ref())
                 })
                 .collect();
@@ -1314,7 +1376,7 @@ impl PickerDelegate for DebugDelegate {
             .get(self.selected_index())
             .and_then(|match_candidate| self.candidates.get(match_candidate.candidate_id).cloned());
 
-        let Some((kind, debug_scenario, context)) = debug_scenario else {
+        let Some((kind, _, debug_scenario, context)) = debug_scenario else {
             return;
         };
 
@@ -1447,6 +1509,7 @@ impl PickerDelegate for DebugDelegate {
         cx: &mut Context<picker::Picker<Self>>,
     ) -> Option<Self::ListItem> {
         let hit = &self.matches.get(ix)?;
+        let (task_kind, language_name, _scenario, context) = &self.candidates[hit.candidate_id];
 
         let highlighted_location = HighlightedMatch {
             text: hit.string.clone(),
@@ -1454,33 +1517,40 @@ impl PickerDelegate for DebugDelegate {
             char_count: hit.string.chars().count(),
             color: Color::Default,
         };
-        let task_kind = &self.candidates[hit.candidate_id].0;
-
-        let icon = match task_kind {
-            Some(TaskSourceKind::UserInput) => Some(Icon::new(IconName::Terminal)),
-            Some(TaskSourceKind::AbsPath { .. }) => Some(Icon::new(IconName::Settings)),
-            Some(TaskSourceKind::Worktree { .. }) => Some(Icon::new(IconName::FileTree)),
-            Some(TaskSourceKind::Lsp {
-                language_name: name,
-                ..
-            })
-            | Some(TaskSourceKind::Language { name }) => file_icons::FileIcons::get(cx)
-                .get_icon_for_type(&name.to_lowercase(), cx)
-                .map(Icon::from_path),
-            None => Some(Icon::new(IconName::HistoryRerun)),
-        }
-        .map(|icon| icon.color(Color::Muted).size(IconSize::Small));
-        let indicator = if matches!(task_kind, Some(TaskSourceKind::Lsp { .. })) {
-            Some(Indicator::icon(
-                Icon::new(IconName::BoltFilled)
-                    .color(Color::Muted)
-                    .size(IconSize::Small),
-            ))
-        } else {
-            None
+
+        let subtitle = self.get_task_subtitle(task_kind, context, cx);
+
+        let language_icon = language_name.as_ref().and_then(|lang| {
+            file_icons::FileIcons::get(cx)
+                .get_icon_for_type(&lang.0.to_lowercase(), cx)
+                .map(Icon::from_path)
+        });
+
+        let (icon, indicator) = match task_kind {
+            Some(TaskSourceKind::UserInput) => (Some(Icon::new(IconName::Terminal)), None),
+            Some(TaskSourceKind::AbsPath { .. }) => (Some(Icon::new(IconName::Settings)), None),
+            Some(TaskSourceKind::Worktree { .. }) => (Some(Icon::new(IconName::FileTree)), None),
+            Some(TaskSourceKind::Lsp { language_name, .. }) => (
+                file_icons::FileIcons::get(cx)
+                    .get_icon_for_type(&language_name.to_lowercase(), cx)
+                    .map(Icon::from_path),
+                Some(Indicator::icon(
+                    Icon::new(IconName::BoltFilled)
+                        .color(Color::Muted)
+                        .size(IconSize::Small),
+                )),
+            ),
+            Some(TaskSourceKind::Language { name }) => (
+                file_icons::FileIcons::get(cx)
+                    .get_icon_for_type(&name.to_lowercase(), cx)
+                    .map(Icon::from_path),
+                None,
+            ),
+            None => (Some(Icon::new(IconName::HistoryRerun)), None),
         };
-        let icon = icon.map(|icon| {
-            IconWithIndicator::new(icon, indicator)
+
+        let icon = language_icon.or(icon).map(|icon| {
+            IconWithIndicator::new(icon.color(Color::Muted).size(IconSize::Small), indicator)
                 .indicator_border_color(Some(cx.theme().colors().border_transparent))
         });
 
@@ -1490,7 +1560,18 @@ impl PickerDelegate for DebugDelegate {
                 .start_slot::<IconWithIndicator>(icon)
                 .spacing(ListItemSpacing::Sparse)
                 .toggle_state(selected)
-                .child(highlighted_location.render(window, cx)),
+                .child(
+                    v_flex()
+                        .items_start()
+                        .child(highlighted_location.render(window, cx))
+                        .when_some(subtitle, |this, subtitle_text| {
+                            this.child(
+                                Label::new(subtitle_text)
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                            )
+                        }),
+                ),
         )
     }
 }
@@ -1539,4 +1620,17 @@ impl NewProcessModal {
             }
         })
     }
+
+    pub(crate) fn debug_picker_candidate_subtitles(&self, cx: &mut App) -> Vec<String> {
+        self.debug_picker.update(cx, |picker, cx| {
+            picker
+                .delegate
+                .candidates
+                .iter()
+                .filter_map(|(task_kind, _, _, context)| {
+                    picker.delegate.get_task_subtitle(task_kind, context, cx)
+                })
+                .collect()
+        })
+    }
 }

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

@@ -10,6 +10,7 @@ use text::Point;
 use util::path;
 
 use crate::NewProcessMode;
+use crate::new_process_modal::NewProcessModal;
 use crate::tests::{init_test, init_test_workspace};
 
 #[gpui::test]
@@ -178,13 +179,7 @@ async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut
 
     workspace
         .update(cx, |workspace, window, cx| {
-            crate::new_process_modal::NewProcessModal::show(
-                workspace,
-                window,
-                NewProcessMode::Debug,
-                None,
-                cx,
-            );
+            NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx);
         })
         .unwrap();
 
@@ -192,7 +187,7 @@ async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut
 
     let modal = workspace
         .update(cx, |workspace, _, cx| {
-            workspace.active_modal::<crate::new_process_modal::NewProcessModal>(cx)
+            workspace.active_modal::<NewProcessModal>(cx)
         })
         .unwrap()
         .expect("Modal should be active");
@@ -281,6 +276,73 @@ async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut
     pretty_assertions::assert_eq!(expected_content, debug_json_content);
 }
 
+#[gpui::test]
+async fn test_debug_modal_subtitles_with_multiple_worktrees(
+    executor: BackgroundExecutor,
+    cx: &mut TestAppContext,
+) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor.clone());
+
+    fs.insert_tree(
+        path!("/workspace1"),
+        json!({
+            ".zed": {
+                "debug.json": r#"[
+                    {
+                        "adapter": "fake-adapter",
+                        "label": "Debug App 1",
+                        "request": "launch",
+                        "program": "./app1",
+                        "cwd": "."
+                    },
+                    {
+                        "adapter": "fake-adapter",
+                        "label": "Debug Tests 1",
+                        "request": "launch",
+                        "program": "./test1",
+                        "cwd": "."
+                    }
+                ]"#
+            },
+            "main.rs": "fn main() {}"
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/workspace1").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| {
+            NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx);
+        })
+        .unwrap();
+
+    cx.run_until_parked();
+
+    let modal = workspace
+        .update(cx, |workspace, _, cx| {
+            workspace.active_modal::<NewProcessModal>(cx)
+        })
+        .unwrap()
+        .expect("Modal should be active");
+
+    cx.executor().run_until_parked();
+
+    let subtitles = modal.update_in(cx, |modal, _, cx| {
+        modal.debug_picker_candidate_subtitles(cx)
+    });
+
+    assert_eq!(
+        subtitles.as_slice(),
+        [path!(".zed/debug.json"), path!(".zed/debug.json")]
+    );
+}
+
 #[gpui::test]
 async fn test_dap_adapter_config_conversion_and_validation(cx: &mut TestAppContext) {
     init_test(cx);

crates/project/src/task_inventory.rs 🔗

@@ -388,6 +388,7 @@ impl Inventory {
             .into_iter()
             .flat_map(|worktree| self.worktree_templates_from_settings(worktree))
             .collect::<Vec<_>>();
+
         let task_source_kind = language.as_ref().map(|language| TaskSourceKind::Language {
             name: language.name().into(),
         });

crates/tasks_ui/src/modal.rs 🔗

@@ -493,7 +493,7 @@ impl PickerDelegate for TasksModalDelegate {
                 language_name: name,
                 ..
             }
-            | TaskSourceKind::Language { name } => file_icons::FileIcons::get(cx)
+            | TaskSourceKind::Language { name, .. } => file_icons::FileIcons::get(cx)
                 .get_icon_for_type(&name.to_lowercase(), cx)
                 .map(Icon::from_path),
         }