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

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>
This commit is contained in:
Matt 2025-09-18 15:05:55 -05:00 committed by GitHub
parent 6b8ed5bf28
commit fc0eb882f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 216 additions and 55 deletions

View file

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

View file

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

View file

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

View file

@ -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(),
});

View file

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