debugger: Allow users to include PickProcessId in debug tasks and resolve Pid (#42913)

Closes #33286

This PR adds support for Zed's `$ZED_PICK_PID` command in debug
configurations, which allows users to select a process to attach to at
debug time. When this variable is present in a debug configuration, Zed
automatically opens a process picker modal.

Follow up for this will be integrating this variable in the task system
instead of just the debug configuration system.

Release Notes:

- Added `$ZED_PICK_PID` variable for debug configurations, allowing
users to select which process to attach the debugger to at runtime

---------

Co-authored-by: Remco Smits <djsmits12@gmail.com>
This commit is contained in:
Anthony Eid 2025-11-20 10:12:59 -05:00 committed by GitHub
parent e033829ef2
commit 56401fc99c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 413 additions and 90 deletions

View file

@ -1,4 +1,5 @@
use dap::{DapRegistry, DebugRequest};
use futures::channel::oneshot;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Render, Task};
use gpui::{Subscription, WeakEntity};
@ -9,6 +10,7 @@ use task::ZedDebugConfig;
use util::debug_panic;
use std::sync::Arc;
use sysinfo::{ProcessRefreshKind, RefreshKind, System, UpdateKind};
use ui::{Context, Tooltip, prelude::*};
use ui::{ListItem, ListItemSpacing};
@ -23,11 +25,16 @@ pub(super) struct Candidate {
pub(super) command: Vec<String>,
}
pub(crate) enum ModalIntent {
ResolveProcessId(Option<oneshot::Sender<Option<i32>>>),
AttachToProcess(ZedDebugConfig),
}
pub(crate) struct AttachModalDelegate {
selected_index: usize,
matches: Vec<StringMatch>,
placeholder_text: Arc<str>,
pub(crate) definition: ZedDebugConfig,
pub(crate) intent: ModalIntent,
workspace: WeakEntity<Workspace>,
candidates: Arc<[Candidate]>,
}
@ -35,13 +42,13 @@ pub(crate) struct AttachModalDelegate {
impl AttachModalDelegate {
fn new(
workspace: WeakEntity<Workspace>,
definition: ZedDebugConfig,
intent: ModalIntent,
candidates: Arc<[Candidate]>,
) -> Self {
Self {
workspace,
definition,
candidates,
intent,
selected_index: 0,
matches: Vec::default(),
placeholder_text: Arc::from("Select the process you want to attach the debugger to"),
@ -55,8 +62,8 @@ pub struct AttachModal {
}
impl AttachModal {
pub fn new(
definition: ZedDebugConfig,
pub(crate) fn new(
intent: ModalIntent,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
modal: bool,
@ -65,7 +72,7 @@ impl AttachModal {
) -> Self {
let processes_task = get_processes_for_project(&project, cx);
let modal = Self::with_processes(workspace, definition, Arc::new([]), modal, window, cx);
let modal = Self::with_processes(workspace, Arc::new([]), modal, intent, window, cx);
cx.spawn_in(window, async move |this, cx| {
let processes = processes_task.await;
@ -84,15 +91,15 @@ impl AttachModal {
pub(super) fn with_processes(
workspace: WeakEntity<Workspace>,
definition: ZedDebugConfig,
processes: Arc<[Candidate]>,
modal: bool,
intent: ModalIntent,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let picker = cx.new(|cx| {
Picker::uniform_list(
AttachModalDelegate::new(workspace, definition, processes),
AttachModalDelegate::new(workspace, intent, processes),
window,
cx,
)
@ -207,7 +214,7 @@ impl PickerDelegate for AttachModalDelegate {
})
}
fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
let candidate = self
.matches
.get(self.selected_index())
@ -216,69 +223,86 @@ impl PickerDelegate for AttachModalDelegate {
self.candidates.get(ix)
});
let Some(candidate) = candidate else {
return cx.emit(DismissEvent);
};
match &mut self.definition.request {
DebugRequest::Attach(config) => {
config.process_id = Some(candidate.pid);
}
DebugRequest::Launch(_) => {
debug_panic!("Debugger attach modal used on launch debug config");
return;
}
}
let workspace = self.workspace.clone();
let Some(panel) = workspace
.update(cx, |workspace, cx| workspace.panel::<DebugPanel>(cx))
.ok()
.flatten()
else {
return;
};
if secondary {
// let Some(id) = worktree_id else { return };
// cx.spawn_in(window, async move |_, cx| {
// panel
// .update_in(cx, |debug_panel, window, cx| {
// debug_panel.save_scenario(&debug_scenario, id, window, cx)
// })?
// .await?;
// anyhow::Ok(())
// })
// .detach_and_log_err(cx);
}
let Some(adapter) = cx.read_global::<DapRegistry, _>(|registry, _| {
registry.adapter(&self.definition.adapter)
}) else {
return;
};
let definition = self.definition.clone();
cx.spawn_in(window, async move |this, cx| {
let Ok(scenario) = adapter.config_from_zed_format(definition).await else {
return;
};
panel
.update_in(cx, |panel, window, cx| {
panel.start_session(scenario, Default::default(), None, None, window, cx);
})
.ok();
this.update(cx, |_, cx| {
match &mut self.intent {
ModalIntent::ResolveProcessId(sender) => {
cx.emit(DismissEvent);
})
.ok();
})
.detach();
if let Some(sender) = sender.take() {
sender
.send(candidate.map(|candidate| candidate.pid as i32))
.ok();
}
}
ModalIntent::AttachToProcess(definition) => {
let Some(candidate) = candidate else {
return cx.emit(DismissEvent);
};
match &mut definition.request {
DebugRequest::Attach(config) => {
config.process_id = Some(candidate.pid);
}
DebugRequest::Launch(_) => {
debug_panic!("Debugger attach modal used on launch debug config");
return;
}
}
let workspace = self.workspace.clone();
let Some(panel) = workspace
.update(cx, |workspace, cx| workspace.panel::<DebugPanel>(cx))
.ok()
.flatten()
else {
return;
};
let Some(adapter) = cx.read_global::<DapRegistry, _>(|registry, _| {
registry.adapter(&definition.adapter)
}) else {
return;
};
let definition = definition.clone();
cx.spawn_in(window, async move |this, cx| {
let Ok(scenario) = adapter.config_from_zed_format(definition).await else {
return;
};
panel
.update_in(cx, |panel, window, cx| {
panel.start_session(
scenario,
Default::default(),
None,
None,
window,
cx,
);
})
.ok();
this.update(cx, |_, cx| {
cx.emit(DismissEvent);
})
.ok();
})
.detach();
}
}
}
fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
self.selected_index = 0;
match &mut self.intent {
ModalIntent::ResolveProcessId(sender) => {
if let Some(sender) = sender.take() {
sender.send(None).ok();
}
}
ModalIntent::AttachToProcess(_) => {}
}
cx.emit(DismissEvent);
}
@ -338,7 +362,7 @@ fn get_processes_for_project(project: &Entity<Project>, cx: &mut App) -> Task<Ar
if let Some(remote_client) = project.remote_client() {
let proto_client = remote_client.read(cx).proto_client();
cx.spawn(async move |_cx| {
cx.background_spawn(async move {
let response = proto_client
.request(proto::GetProcesses {
project_id: proto::REMOTE_SERVER_PROJECT_ID,
@ -389,8 +413,21 @@ fn get_processes_for_project(project: &Entity<Project>, cx: &mut App) -> Task<Ar
}
}
#[cfg(any(test, feature = "test-support"))]
pub(crate) fn _process_names(modal: &AttachModal, cx: &mut Context<AttachModal>) -> Vec<String> {
#[cfg(test)]
pub(crate) fn set_candidates(
modal: &AttachModal,
candidates: Arc<[Candidate]>,
window: &mut Window,
cx: &mut Context<AttachModal>,
) {
modal.picker.update(cx, |picker, cx| {
picker.delegate.candidates = candidates;
picker.refresh(window, cx);
});
}
#[cfg(test)]
pub(crate) fn process_names(modal: &AttachModal, cx: &mut Context<AttachModal>) -> Vec<String> {
modal.picker.read_with(cx, |picker, _| {
picker
.delegate

View file

@ -29,10 +29,13 @@ use ui::{
KeyBinding, ListItem, ListItemSpacing, ToggleButtonGroup, ToggleButtonSimple, ToggleState,
Tooltip, prelude::*,
};
use util::{ResultExt, rel_path::RelPath, shell::ShellKind};
use util::{ResultExt, debug_panic, rel_path::RelPath, shell::ShellKind};
use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr, pane};
use crate::{attach_modal::AttachModal, debugger_panel::DebugPanel};
use crate::{
attach_modal::{AttachModal, ModalIntent},
debugger_panel::DebugPanel,
};
pub(super) struct NewProcessModal {
workspace: WeakEntity<Workspace>,
@ -395,8 +398,15 @@ impl NewProcessModal {
this.attach_picker.update(cx, |this, cx| {
this.picker.update(cx, |this, cx| {
this.delegate.definition.adapter = adapter.0.clone();
this.focus(window, cx);
match &mut this.delegate.intent {
ModalIntent::AttachToProcess(definition) => {
definition.adapter = adapter.0.clone();
this.focus(window, cx);
},
ModalIntent::ResolveProcessId(_) => {
debug_panic!("Attach picker attempted to update config when in resolve Process ID mode");
}
}
})
});
}
@ -942,7 +952,14 @@ impl AttachMode {
stop_on_entry: Some(false),
};
let attach_picker = cx.new(|cx| {
let modal = AttachModal::new(definition.clone(), workspace, project, false, window, cx);
let modal = AttachModal::new(
ModalIntent::AttachToProcess(definition.clone()),
workspace,
project,
false,
window,
cx,
);
window.focus(&modal.focus_handle(cx));
modal

View file

@ -5,16 +5,23 @@ pub(crate) mod memory_view;
pub(crate) mod module_list;
pub mod stack_frame_list;
pub mod variable_list;
use std::{any::Any, ops::ControlFlow, path::PathBuf, sync::Arc, time::Duration};
use std::{
any::Any,
ops::ControlFlow,
path::PathBuf,
sync::{Arc, LazyLock},
time::Duration,
};
use crate::{
ToggleExpandItem,
attach_modal::{AttachModal, ModalIntent},
new_process_modal::resolve_path,
persistence::{self, DebuggerPaneItem, SerializedLayout},
session::running::memory_view::MemoryView,
};
use anyhow::{Context as _, Result, anyhow};
use anyhow::{Context as _, Result, anyhow, bail};
use breakpoint_list::BreakpointList;
use collections::{HashMap, IndexMap};
use console::Console;
@ -56,6 +63,9 @@ use workspace::{
Workspace, item::TabContentParams, move_item, pane::Event,
};
static PROCESS_ID_PLACEHOLDER: LazyLock<String> =
LazyLock::new(|| task::VariableName::PickProcessId.template_value());
pub struct RunningState {
session: Entity<Session>,
thread_id: Option<ThreadId>,
@ -653,6 +663,40 @@ impl RunningState {
}
}
pub(crate) fn contains_substring(config: &serde_json::Value, substring: &str) -> bool {
match config {
serde_json::Value::Object(obj) => obj
.values()
.any(|value| Self::contains_substring(value, substring)),
serde_json::Value::Array(array) => array
.iter()
.any(|value| Self::contains_substring(value, substring)),
serde_json::Value::String(s) => s.contains(substring),
_ => false,
}
}
pub(crate) fn substitute_process_id_in_config(config: &mut serde_json::Value, process_id: i32) {
match config {
serde_json::Value::Object(obj) => {
obj.values_mut().for_each(|value| {
Self::substitute_process_id_in_config(value, process_id);
});
}
serde_json::Value::Array(array) => {
array.iter_mut().for_each(|value| {
Self::substitute_process_id_in_config(value, process_id);
});
}
serde_json::Value::String(s) => {
if s.contains(PROCESS_ID_PLACEHOLDER.as_str()) {
*s = s.replace(PROCESS_ID_PLACEHOLDER.as_str(), &process_id.to_string());
}
}
_ => {}
}
}
pub(crate) fn relativize_paths(
key: Option<&str>,
config: &mut serde_json::Value,
@ -955,6 +999,31 @@ impl RunningState {
Self::relativize_paths(None, &mut config, &task_context);
Self::substitute_variables_in_config(&mut config, &task_context);
if Self::contains_substring(&config, PROCESS_ID_PLACEHOLDER.as_str()) || label.as_ref().contains(PROCESS_ID_PLACEHOLDER.as_str()) {
let (tx, rx) = futures::channel::oneshot::channel::<Option<i32>>();
let weak_workspace_clone = weak_workspace.clone();
weak_workspace.update_in(cx, |workspace, window, cx| {
let project = workspace.project().clone();
workspace.toggle_modal(window, cx, |window, cx| {
AttachModal::new(
ModalIntent::ResolveProcessId(Some(tx)),
weak_workspace_clone,
project,
true,
window,
cx,
)
});
}).ok();
let Some(process_id) = rx.await.ok().flatten() else {
bail!("No process selected with config that contains {}", PROCESS_ID_PLACEHOLDER.as_str())
};
Self::substitute_process_id_in_config(&mut config, process_id);
}
let request_type = match dap_registry
.adapter(&adapter)
.with_context(|| format!("{}: is not a valid adapter name", &adapter)) {

View file

@ -1,4 +1,8 @@
use crate::{attach_modal::Candidate, tests::start_debug_session_with, *};
use crate::{
attach_modal::{Candidate, ModalIntent},
tests::start_debug_session_with,
*,
};
use attach_modal::AttachModal;
use dap::{FakeAdapter, adapters::DebugTaskDefinition};
use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext};
@ -98,12 +102,6 @@ async fn test_show_attach_modal_and_select_process(
workspace.toggle_modal(window, cx, |window, cx| {
AttachModal::with_processes(
workspace_handle,
task::ZedDebugConfig {
adapter: FakeAdapter::ADAPTER_NAME.into(),
request: dap::DebugRequest::Attach(AttachRequest::default()),
label: "attach example".into(),
stop_on_entry: None,
},
vec![
Candidate {
pid: 0,
@ -124,6 +122,12 @@ async fn test_show_attach_modal_and_select_process(
.into_iter()
.collect(),
true,
ModalIntent::AttachToProcess(task::ZedDebugConfig {
adapter: FakeAdapter::ADAPTER_NAME.into(),
request: dap::DebugRequest::Attach(AttachRequest::default()),
label: "attach example".into(),
stop_on_entry: None,
}),
window,
cx,
)
@ -138,8 +142,7 @@ async fn test_show_attach_modal_and_select_process(
// assert we got the expected processes
workspace
.update(cx, |_, window, cx| {
let names =
attach_modal.update(cx, |modal, cx| attach_modal::_process_names(modal, cx));
let names = attach_modal.update(cx, |modal, cx| attach_modal::process_names(modal, cx));
// Initially all processes are visible.
assert_eq!(3, names.len());
attach_modal.update(cx, |this, cx| {
@ -153,8 +156,7 @@ async fn test_show_attach_modal_and_select_process(
// assert we got the expected processes
workspace
.update(cx, |_, _, cx| {
let names =
attach_modal.update(cx, |modal, cx| attach_modal::_process_names(modal, cx));
let names = attach_modal.update(cx, |modal, cx| attach_modal::process_names(modal, cx));
// Initially all processes are visible.
assert_eq!(2, names.len());
})
@ -171,3 +173,139 @@ async fn test_show_attach_modal_and_select_process(
})
.unwrap();
}
#[gpui::test]
async fn test_attach_with_pick_pid_variable(executor: BackgroundExecutor, cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(executor.clone());
fs.insert_tree(
path!("/project"),
json!({
"main.rs": "First line\nSecond line\nThird line\nFourth line",
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
let workspace = init_test_workspace(&project, cx).await;
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let _initialize_subscription =
project::debugger::test::intercept_debug_sessions(cx, |client| {
client.on_request::<dap::requests::Attach, _>(move |_, args| {
let raw = &args.raw;
assert_eq!(raw["request"], "attach");
assert_eq!(
raw["process_id"], "42",
"verify process id has been replaced"
);
Ok(())
});
});
let pick_pid_placeholder = task::VariableName::PickProcessId.template_value();
workspace
.update(cx, |workspace, window, cx| {
workspace.start_debug_session(
DebugTaskDefinition {
adapter: FakeAdapter::ADAPTER_NAME.into(),
label: "attach with picker".into(),
config: json!({
"request": "attach",
"process_id": pick_pid_placeholder,
}),
tcp_connection: None,
}
.to_scenario(),
task::TaskContext::default(),
None,
None,
window,
cx,
)
})
.unwrap();
cx.run_until_parked();
let attach_modal = workspace
.update(cx, |workspace, _window, cx| {
workspace.active_modal::<AttachModal>(cx)
})
.unwrap();
assert!(
attach_modal.is_some(),
"Attach modal should open when config contains ZED_PICK_PID"
);
let attach_modal = attach_modal.unwrap();
workspace
.update(cx, |_, window, cx| {
attach_modal.update(cx, |modal, cx| {
attach_modal::set_candidates(
modal,
vec![
Candidate {
pid: 10,
name: "process-1".into(),
command: vec![],
},
Candidate {
pid: 42,
name: "target-process".into(),
command: vec![],
},
Candidate {
pid: 99,
name: "process-3".into(),
command: vec![],
},
]
.into_iter()
.collect(),
window,
cx,
)
})
})
.unwrap();
cx.run_until_parked();
workspace
.update(cx, |_, window, cx| {
attach_modal.update(cx, |modal, cx| {
modal.picker.update(cx, |picker, cx| {
picker.set_query("target", window, cx);
})
})
})
.unwrap();
cx.run_until_parked();
workspace
.update(cx, |_, _, cx| {
let names = attach_modal.update(cx, |modal, cx| attach_modal::process_names(modal, cx));
assert_eq!(names.len(), 1);
assert_eq!(names[0], " 42 target-process");
})
.unwrap();
cx.dispatch_action(Confirm);
cx.run_until_parked();
workspace
.update(cx, |workspace, _window, cx| {
assert!(
workspace.active_modal::<AttachModal>(cx).is_none(),
"Attach modal should be dismissed after selection"
);
})
.unwrap();
}

View file

@ -173,6 +173,9 @@ pub enum VariableName {
SelectedText,
/// The symbol selected by the symbol tagging system, specifically the @run capture in a runnables.scm
RunnableSymbol,
/// Open a Picker to select a process ID to use in place
/// Can only be used to debug configurations
PickProcessId,
/// Custom variable, provided by the plugin or other external source.
/// Will be printed with `CUSTOM_` prefix to avoid potential conflicts with other variables.
Custom(Cow<'static, str>),
@ -240,6 +243,7 @@ impl std::fmt::Display for VariableName {
Self::Column => write!(f, "{ZED_VARIABLE_NAME_PREFIX}COLUMN"),
Self::SelectedText => write!(f, "{ZED_VARIABLE_NAME_PREFIX}SELECTED_TEXT"),
Self::RunnableSymbol => write!(f, "{ZED_VARIABLE_NAME_PREFIX}RUNNABLE_SYMBOL"),
Self::PickProcessId => write!(f, "{ZED_VARIABLE_NAME_PREFIX}PICK_PID"),
Self::Custom(s) => write!(
f,
"{ZED_VARIABLE_NAME_PREFIX}{ZED_CUSTOM_VARIABLE_NAME_PREFIX}{s}"
@ -346,15 +350,28 @@ pub fn shell_to_proto(shell: Shell) -> proto::Shell {
}
type VsCodeEnvVariable = String;
type VsCodeCommand = String;
type ZedEnvVariable = String;
struct EnvVariableReplacer {
variables: HashMap<VsCodeEnvVariable, ZedEnvVariable>,
commands: HashMap<VsCodeCommand, ZedEnvVariable>,
}
impl EnvVariableReplacer {
fn new(variables: HashMap<VsCodeEnvVariable, ZedEnvVariable>) -> Self {
Self { variables }
Self {
variables,
commands: HashMap::default(),
}
}
fn with_commands(
mut self,
commands: impl IntoIterator<Item = (VsCodeCommand, ZedEnvVariable)>,
) -> Self {
self.commands = commands.into_iter().collect();
self
}
fn replace_value(&self, input: serde_json::Value) -> serde_json::Value {
@ -380,7 +397,13 @@ impl EnvVariableReplacer {
if left == "env" && !right.is_empty() {
let variable_name = &right[1..];
return Some(format!("${{{variable_name}}}"));
} else if left == "command" && !right.is_empty() {
let command_name = &right[1..];
if let Some(replacement_command) = self.commands.get(command_name) {
return Some(format!("${{{replacement_command}}}"));
}
}
let (variable_name, default) = (left, right);
let append_previous_default = |ret: &mut String| {
if !default.is_empty() {

View file

@ -68,7 +68,11 @@ impl TryFrom<VsCodeDebugTaskFile> for DebugTaskFile {
VariableName::RelativeFile.to_string(),
),
("file".to_owned(), VariableName::File.to_string()),
]));
]))
.with_commands([(
"pickMyProcess".to_owned(),
VariableName::PickProcessId.to_string(),
)]);
let templates = file
.configurations
.into_iter()
@ -96,7 +100,7 @@ fn task_type_to_adapter_name(task_type: &str) -> String {
mod tests {
use serde_json::json;
use crate::{DebugScenario, DebugTaskFile};
use crate::{DebugScenario, DebugTaskFile, VariableName};
use super::VsCodeDebugTaskFile;
@ -152,4 +156,39 @@ mod tests {
}])
);
}
#[test]
fn test_command_pickmyprocess_replacement() {
let raw = r#"
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach to Process",
"request": "attach",
"type": "cppdbg",
"processId": "${command:pickMyProcess}"
}
]
}
"#;
let parsed: VsCodeDebugTaskFile =
serde_json_lenient::from_str(raw).expect("deserializing launch.json");
let zed = DebugTaskFile::try_from(parsed).expect("converting to Zed debug templates");
let expected_placeholder = format!("${{{}}}", VariableName::PickProcessId);
pretty_assertions::assert_eq!(
zed,
DebugTaskFile(vec![DebugScenario {
label: "Attach to Process".into(),
adapter: "CodeLLDB".into(),
config: json!({
"request": "attach",
"processId": expected_placeholder,
}),
tcp_connection: None,
build: None
}])
);
}
}