debugger beta: Autoscroll to recently saved debug scenario when saving a scenario (#31528)

I added a test to this too as one of my first steps of improving
`NewSessionModal`'s test coverage.


Release Notes:

- debugger beta: Select saved debug config when opening debug.json from
`NewSessionModal`
This commit is contained in:
Anthony Eid 2025-05-27 21:35:17 +03:00 committed by GitHub
parent 94c006236e
commit 86b75759d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 319 additions and 137 deletions

View file

@ -1,5 +1,5 @@
use collections::FxHashMap;
use language::LanguageRegistry;
use language::{LanguageRegistry, Point, Selection};
use std::{
borrow::Cow,
ops::Not,
@ -12,7 +12,7 @@ use std::{
use dap::{
DapRegistry, DebugRequest, TelemetrySpawnLocation, adapters::DebugAdapterName, send_telemetry,
};
use editor::{Editor, EditorElement, EditorStyle};
use editor::{Anchor, Editor, EditorElement, EditorStyle, scroll::Autoscroll};
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
Animation, AnimationExt as _, App, AppContext, DismissEvent, Entity, EventEmitter, FocusHandle,
@ -37,7 +37,7 @@ use crate::{attach_modal::AttachModal, debugger_panel::DebugPanel};
enum SaveScenarioState {
Saving,
Saved(ProjectPath),
Saved((ProjectPath, SharedString)),
Failed(SharedString),
}
@ -284,6 +284,177 @@ impl NewSessionModal {
self.launch_picker.read(cx).delegate.task_contexts.clone()
}
fn save_debug_scenario(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some((save_scenario, scenario_label)) = self
.debugger
.as_ref()
.and_then(|debugger| self.debug_scenario(&debugger, cx))
.zip(self.task_contexts(cx).and_then(|tcx| tcx.worktree()))
.and_then(|(scenario, worktree_id)| {
self.debug_panel
.update(cx, |panel, cx| {
panel.save_scenario(&scenario, worktree_id, window, cx)
})
.ok()
.zip(Some(scenario.label.clone()))
})
else {
return;
};
self.save_scenario_state = Some(SaveScenarioState::Saving);
cx.spawn(async move |this, cx| {
let res = save_scenario.await;
this.update(cx, |this, _| match res {
Ok(saved_file) => {
this.save_scenario_state =
Some(SaveScenarioState::Saved((saved_file, scenario_label)))
}
Err(error) => {
this.save_scenario_state =
Some(SaveScenarioState::Failed(error.to_string().into()))
}
})
.ok();
cx.background_executor().timer(Duration::from_secs(3)).await;
this.update(cx, |this, _| this.save_scenario_state.take())
.ok();
})
.detach();
}
fn render_save_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
let this_entity = cx.weak_entity().clone();
div().when_some(self.save_scenario_state.as_ref(), {
let this_entity = this_entity.clone();
move |this, save_state| match save_state {
SaveScenarioState::Saved((saved_path, scenario_label)) => this.child(
IconButton::new("new-session-modal-go-to-file", IconName::ArrowUpRight)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.on_click({
let this_entity = this_entity.clone();
let saved_path = saved_path.clone();
let scenario_label = scenario_label.clone();
move |_, window, cx| {
window
.spawn(cx, {
let this_entity = this_entity.clone();
let saved_path = saved_path.clone();
let scenario_label = scenario_label.clone();
async move |cx| {
let editor = this_entity
.update_in(cx, |this, window, cx| {
this.workspace.update(cx, |workspace, cx| {
workspace.open_path(
saved_path.clone(),
None,
true,
window,
cx,
)
})
})??
.await?;
cx.update(|window, cx| {
if let Some(editor) = editor.act_as::<Editor>(cx) {
editor.update(cx, |editor, cx| {
let row = editor
.text(cx)
.lines()
.enumerate()
.find_map(|(row, text)| {
if text.contains(
scenario_label.as_ref(),
) {
Some(row)
} else {
None
}
})?;
let buffer = editor.buffer().read(cx);
let excerpt_id =
*buffer.excerpt_ids().first()?;
let snapshot = buffer
.as_singleton()?
.read(cx)
.snapshot();
let anchor = snapshot.anchor_before(
Point::new(row as u32, 0),
);
let anchor = Anchor {
buffer_id: anchor.buffer_id,
excerpt_id,
text_anchor: anchor,
diff_base_anchor: None,
};
editor.change_selections(
Some(Autoscroll::center()),
window,
cx,
|selections| {
let id =
selections.new_selection_id();
selections.select_anchors(
vec![Selection {
id,
start: anchor,
end: anchor,
reversed: false,
goal: language::SelectionGoal::None
}],
);
},
);
Some(())
});
}
})?;
this_entity
.update(cx, |_, cx| cx.emit(DismissEvent))
.ok();
anyhow::Ok(())
}
})
.detach();
}
}),
),
SaveScenarioState::Saving => this.child(
Icon::new(IconName::Spinner)
.size(IconSize::Small)
.color(Color::Muted)
.with_animation(
"Spinner",
Animation::new(Duration::from_secs(3)).repeat(),
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
),
),
SaveScenarioState::Failed(error_msg) => this.child(
IconButton::new("Failed Scenario Saved", IconName::X)
.icon_size(IconSize::Small)
.icon_color(Color::Error)
.tooltip(ui::Tooltip::text(error_msg.clone())),
),
}
})
}
fn adapter_drop_down_menu(
&mut self,
window: &mut Window,
@ -355,7 +526,7 @@ impl NewSessionModal {
static SELECT_DEBUGGER_LABEL: SharedString = SharedString::new_static("Select Debugger");
#[derive(Clone)]
enum NewSessionMode {
pub(crate) enum NewSessionMode {
Custom,
Attach,
Launch,
@ -423,8 +594,6 @@ impl Render for NewSessionModal {
window: &mut ui::Window,
cx: &mut ui::Context<Self>,
) -> impl ui::IntoElement {
let this = cx.weak_entity().clone();
v_flex()
.size_full()
.w(rems(34.))
@ -534,58 +703,7 @@ impl Render for NewSessionModal {
.child(
Button::new("new-session-modal-back", "Save to .zed/debug.json...")
.on_click(cx.listener(|this, _, window, cx| {
let Some(save_scenario) = this
.debugger
.as_ref()
.and_then(|debugger| this.debug_scenario(&debugger, cx))
.zip(
this.task_contexts(cx)
.and_then(|tcx| tcx.worktree()),
)
.and_then(|(scenario, worktree_id)| {
this.debug_panel
.update(cx, |panel, cx| {
panel.save_scenario(
&scenario,
worktree_id,
window,
cx,
)
})
.ok()
})
else {
return;
};
this.save_scenario_state = Some(SaveScenarioState::Saving);
cx.spawn(async move |this, cx| {
let res = save_scenario.await;
this.update(cx, |this, _| match res {
Ok(saved_file) => {
this.save_scenario_state =
Some(SaveScenarioState::Saved(saved_file))
}
Err(error) => {
this.save_scenario_state =
Some(SaveScenarioState::Failed(
error.to_string().into(),
))
}
})
.ok();
cx.background_executor()
.timer(Duration::from_secs(2))
.await;
this.update(cx, |this, _| {
this.save_scenario_state.take()
})
.ok();
})
.detach();
this.save_debug_scenario(window, cx);
}))
.disabled(
self.debugger.is_none()
@ -598,83 +716,7 @@ impl Render for NewSessionModal {
|| self.save_scenario_state.is_some(),
),
)
.when_some(self.save_scenario_state.as_ref(), {
let this_entity = this.clone();
move |this, save_state| match save_state {
SaveScenarioState::Saved(saved_path) => this.child(
IconButton::new(
"new-session-modal-go-to-file",
IconName::ArrowUpRight,
)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.on_click({
let this_entity = this_entity.clone();
let saved_path = saved_path.clone();
move |_, window, cx| {
window
.spawn(cx, {
let this_entity = this_entity.clone();
let saved_path = saved_path.clone();
async move |cx| {
this_entity
.update_in(
cx,
|this, window, cx| {
this.workspace.update(
cx,
|workspace, cx| {
workspace.open_path(
saved_path
.clone(),
None,
true,
window,
cx,
)
},
)
},
)??
.await?;
this_entity
.update(cx, |_, cx| {
cx.emit(DismissEvent)
})
.ok();
anyhow::Ok(())
}
})
.detach();
}
}),
),
SaveScenarioState::Saving => this.child(
Icon::new(IconName::Spinner)
.size(IconSize::Small)
.color(Color::Muted)
.with_animation(
"Spinner",
Animation::new(Duration::from_secs(3)).repeat(),
|icon, delta| {
icon.transform(Transformation::rotate(
percentage(delta),
))
},
),
),
SaveScenarioState::Failed(error_msg) => this.child(
IconButton::new("Failed Scenario Saved", IconName::X)
.icon_size(IconSize::Small)
.icon_color(Color::Error)
.tooltip(ui::Tooltip::text(error_msg.clone())),
),
}
}),
.child(self.render_save_state(cx)),
})
.child(
Button::new("debugger-spawn", "Start")
@ -1162,6 +1204,42 @@ pub(crate) fn resolve_path(path: &mut String) {
};
}
#[cfg(test)]
impl NewSessionModal {
pub(crate) fn set_custom(
&mut self,
program: impl AsRef<str>,
cwd: impl AsRef<str>,
stop_on_entry: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.mode = NewSessionMode::Custom;
self.debugger = Some(dap::adapters::DebugAdapterName("fake-adapter".into()));
self.custom_mode.update(cx, |custom, cx| {
custom.program.update(cx, |editor, cx| {
editor.clear(window, cx);
editor.set_text(program.as_ref(), window, cx);
});
custom.cwd.update(cx, |editor, cx| {
editor.clear(window, cx);
editor.set_text(cwd.as_ref(), window, cx);
});
custom.stop_on_entry = match stop_on_entry {
true => ToggleState::Selected,
_ => ToggleState::Unselected,
}
})
}
pub(crate) fn save_scenario(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.save_debug_scenario(window, cx);
}
}
#[cfg(test)]
mod tests {
use paths::home_dir;

View file

@ -1,6 +1,6 @@
use dap::DapRegistry;
use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext};
use project::{FakeFs, Project};
use project::{FakeFs, Fs, Project};
use serde_json::json;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@ -151,6 +151,106 @@ async fn test_debug_session_substitutes_variables_and_relativizes_paths(
}
}
#[gpui::test]
async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(executor.clone());
fs.insert_tree(
path!("/project"),
json!({
"main.rs": "fn main() {}"
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/project").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| {
crate::new_session_modal::NewSessionModal::show(workspace, window, cx);
})
.unwrap();
cx.run_until_parked();
let modal = workspace
.update(cx, |workspace, _, cx| {
workspace.active_modal::<crate::new_session_modal::NewSessionModal>(cx)
})
.unwrap()
.expect("Modal should be active");
modal.update_in(cx, |modal, window, cx| {
modal.set_custom("/project/main", "/project", false, window, cx);
modal.save_scenario(window, cx);
});
cx.executor().run_until_parked();
let debug_json_content = fs
.load(path!("/project/.zed/debug.json").as_ref())
.await
.expect("debug.json should exist");
let expected_content = vec![
"[",
" {",
r#" "adapter": "fake-adapter","#,
r#" "label": "main (fake-adapter)","#,
r#" "request": "launch","#,
r#" "program": "/project/main","#,
r#" "cwd": "/project","#,
r#" "args": [],"#,
r#" "env": {}"#,
" }",
"]",
];
let actual_lines: Vec<&str> = debug_json_content.lines().collect();
pretty_assertions::assert_eq!(expected_content, actual_lines);
modal.update_in(cx, |modal, window, cx| {
modal.set_custom("/project/other", "/project", true, window, cx);
modal.save_scenario(window, cx);
});
cx.executor().run_until_parked();
let debug_json_content = fs
.load(path!("/project/.zed/debug.json").as_ref())
.await
.expect("debug.json should exist after second save");
let expected_content = vec![
"[",
" {",
r#" "adapter": "fake-adapter","#,
r#" "label": "main (fake-adapter)","#,
r#" "request": "launch","#,
r#" "program": "/project/main","#,
r#" "cwd": "/project","#,
r#" "args": [],"#,
r#" "env": {}"#,
" },",
" {",
r#" "adapter": "fake-adapter","#,
r#" "label": "other (fake-adapter)","#,
r#" "request": "launch","#,
r#" "program": "/project/other","#,
r#" "cwd": "/project","#,
r#" "args": [],"#,
r#" "env": {}"#,
" }",
"]",
];
let actual_lines: Vec<&str> = debug_json_content.lines().collect();
pretty_assertions::assert_eq!(expected_content, actual_lines);
}
#[gpui::test]
async fn test_dap_adapter_config_conversion_and_validation(cx: &mut TestAppContext) {
init_test(cx);

View file

@ -270,7 +270,11 @@ pub fn task_contexts(
.read(cx)
.worktree_for_id(*worktree_id, cx)
.map_or(false, |worktree| is_visible_directory(&worktree, cx))
});
})
.or(workspace
.visible_worktrees(cx)
.next()
.map(|tree| tree.read(cx).id()));
let active_editor = active_item.and_then(|item| item.act_as::<Editor>(cx));