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

Anthony Eid created

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`

Change summary

crates/debugger_ui/src/new_session_modal.rs       | 348 ++++++++++------
crates/debugger_ui/src/tests/new_session_modal.rs | 102 ++++
crates/tasks_ui/src/tasks_ui.rs                   |   6 
3 files changed, 319 insertions(+), 137 deletions(-)

Detailed changes

crates/debugger_ui/src/new_session_modal.rs 🔗

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

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

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

crates/tasks_ui/src/tasks_ui.rs 🔗

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