debugger: Make historic snapshot button a dropdown menu (#44307)

Remco Smits and Anthony Eid created

This allows users to select any snapshot in the debugger history feature
and go back to the active session snapshot.

We also change variable names to use hsitoric snapshot instead of
history and move the snapshot icon to the back of the debugger top
control strip.


https://github.com/user-attachments/assets/805de8d0-30c1-4719-8af7-2d47e1df1da4

Release Notes:

- N/A

Co-authored-by: Anthony Eid <hello@anthonyeid.me>

Change summary

crates/debugger_ui/src/debugger_panel.rs                   | 212 +++++--
crates/debugger_ui/src/session/running/stack_frame_list.rs |   1 
crates/project/src/debugger/session.rs                     |  24 
crates/ui/src/components/button/split_button.rs            |  32 
4 files changed, 190 insertions(+), 79 deletions(-)

Detailed changes

crates/debugger_ui/src/debugger_panel.rs 🔗

@@ -17,9 +17,9 @@ use dap::{client::SessionId, debugger_settings::DebuggerSettings};
 use editor::{Editor, MultiBufferOffset, ToPoint};
 use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
 use gpui::{
-    Action, App, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Entity, EntityId,
-    EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, Point, Subscription, Task,
-    WeakEntity, anchored, deferred,
+    Action, App, AsyncWindowContext, ClipboardItem, Context, Corner, DismissEvent, Entity,
+    EntityId, EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, Point,
+    Subscription, Task, WeakEntity, anchored, deferred,
 };
 
 use itertools::Itertools as _;
@@ -32,7 +32,9 @@ use settings::Settings;
 use std::sync::{Arc, LazyLock};
 use task::{DebugScenario, TaskContext};
 use tree_sitter::{Query, StreamingIterator as _};
-use ui::{ContextMenu, Divider, PopoverMenuHandle, Tab, Tooltip, prelude::*};
+use ui::{
+    ContextMenu, Divider, PopoverMenu, PopoverMenuHandle, SplitButton, Tab, Tooltip, prelude::*,
+};
 use util::rel_path::RelPath;
 use util::{ResultExt, debug_panic, maybe};
 use workspace::SplitDirection;
@@ -669,6 +671,12 @@ impl DebugPanel {
             )
         };
 
+        let thread_status = active_session
+            .as_ref()
+            .map(|session| session.read(cx).running_state())
+            .and_then(|state| state.read(cx).thread_status(cx))
+            .unwrap_or(project::debugger::session::ThreadStatus::Exited);
+
         Some(
             div.w_full()
                 .py_1()
@@ -686,10 +694,6 @@ impl DebugPanel {
                                     .as_ref()
                                     .map(|session| session.read(cx).running_state()),
                                 |this, running_state| {
-                                    let thread_status =
-                                        running_state.read(cx).thread_status(cx).unwrap_or(
-                                            project::debugger::session::ThreadStatus::Exited,
-                                        );
                                     let capabilities = running_state.read(cx).capabilities(cx);
                                     let supports_detach =
                                         running_state.read(cx).session().read(cx).is_attached();
@@ -812,34 +816,6 @@ impl DebugPanel {
                                                 }
                                             }),
                                     )
-                                    .when(cx.has_flag::<DebuggerHistoryFeatureFlag>(), |this| {
-                                        this.child(
-                                            IconButton::new(
-                                                "debug-back-in-history",
-                                                IconName::HistoryRerun,
-                                            )
-                                            .icon_size(IconSize::Small)
-                                            .on_click(
-                                                window.listener_for(
-                                                    running_state,
-                                                    |this, _, _window, cx| {
-                                                        this.session().update(cx, |session, cx| {
-                                                            let ix = session
-                                                                .active_history()
-                                                                .unwrap_or_else(|| {
-                                                                    session.history().len()
-                                                                });
-
-                                                            session.go_back_to_history(
-                                                                Some(ix.saturating_sub(1)),
-                                                                cx,
-                                                            );
-                                                        })
-                                                    },
-                                                ),
-                                            ),
-                                        )
-                                    })
                                     .child(Divider::vertical())
                                     .child(
                                         IconButton::new("debug-restart", IconName::RotateCcw)
@@ -906,36 +882,53 @@ impl DebugPanel {
                                                 }
                                             }),
                                     )
+                                    .when(supports_detach, |div| {
+                                        div.child(
+                                            IconButton::new(
+                                                "debug-disconnect",
+                                                IconName::DebugDetach,
+                                            )
+                                            .disabled(
+                                                thread_status != ThreadStatus::Stopped
+                                                    && thread_status != ThreadStatus::Running,
+                                            )
+                                            .icon_size(IconSize::Small)
+                                            .on_click(window.listener_for(
+                                                running_state,
+                                                |this, _, _, cx| {
+                                                    this.detach_client(cx);
+                                                },
+                                            ))
+                                            .tooltip({
+                                                let focus_handle = focus_handle.clone();
+                                                move |_window, cx| {
+                                                    Tooltip::for_action_in(
+                                                        "Detach",
+                                                        &Detach,
+                                                        &focus_handle,
+                                                        cx,
+                                                    )
+                                                }
+                                            }),
+                                        )
+                                    })
                                     .when(
-                                        supports_detach,
-                                        |div| {
-                                            div.child(
-                                                IconButton::new(
-                                                    "debug-disconnect",
-                                                    IconName::DebugDetach,
-                                                )
-                                                .disabled(
-                                                    thread_status != ThreadStatus::Stopped
-                                                        && thread_status != ThreadStatus::Running,
+                                        cx.has_flag::<DebuggerHistoryFeatureFlag>(),
+                                        |this| {
+                                            this.child(Divider::vertical()).child(
+                                                SplitButton::new(
+                                                    self.render_history_button(
+                                                        &running_state,
+                                                        thread_status,
+                                                        window,
+                                                    ),
+                                                    self.render_history_toggle_button(
+                                                        thread_status,
+                                                        &running_state,
+                                                    )
+                                                    .into_any_element(),
                                                 )
-                                                .icon_size(IconSize::Small)
-                                                .on_click(window.listener_for(
-                                                    running_state,
-                                                    |this, _, _, cx| {
-                                                        this.detach_client(cx);
-                                                    },
-                                                ))
-                                                .tooltip({
-                                                    let focus_handle = focus_handle.clone();
-                                                    move |_window, cx| {
-                                                        Tooltip::for_action_in(
-                                                            "Detach",
-                                                            &Detach,
-                                                            &focus_handle,
-                                                            cx,
-                                                        )
-                                                    }
-                                                }),
+                                                .style(ui::SplitButtonStyle::Outlined),
                                             )
                                         },
                                     )
@@ -1352,6 +1345,97 @@ impl DebugPanel {
             });
         }
     }
+
+    fn render_history_button(
+        &self,
+        running_state: &Entity<RunningState>,
+        thread_status: ThreadStatus,
+        window: &mut Window,
+    ) -> IconButton {
+        IconButton::new("debug-back-in-history", IconName::HistoryRerun)
+            .icon_size(IconSize::Small)
+            .on_click(window.listener_for(running_state, |this, _, _window, cx| {
+                this.session().update(cx, |session, cx| {
+                    let ix = session
+                        .active_snapshot_index()
+                        .unwrap_or_else(|| session.historic_snapshots().len());
+
+                    session.select_historic_snapshot(Some(ix.saturating_sub(1)), cx);
+                })
+            }))
+            .disabled(
+                thread_status == ThreadStatus::Running || thread_status == ThreadStatus::Stepping,
+            )
+    }
+
+    fn render_history_toggle_button(
+        &self,
+        thread_status: ThreadStatus,
+        running_state: &Entity<RunningState>,
+    ) -> impl IntoElement {
+        PopoverMenu::new("debug-back-in-history-menu")
+            .trigger(
+                ui::ButtonLike::new_rounded_right("debug-back-in-history-menu-trigger")
+                    .layer(ui::ElevationIndex::ModalSurface)
+                    .size(ui::ButtonSize::None)
+                    .child(
+                        div()
+                            .px_1()
+                            .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
+                    )
+                    .disabled(
+                        thread_status == ThreadStatus::Running
+                            || thread_status == ThreadStatus::Stepping,
+                    ),
+            )
+            .menu({
+                let running_state = running_state.clone();
+                move |window, cx| {
+                    let handler =
+                        |ix: Option<usize>, running_state: Entity<RunningState>, cx: &mut App| {
+                            running_state.update(cx, |state, cx| {
+                                state.session().update(cx, |session, cx| {
+                                    session.select_historic_snapshot(ix, cx);
+                                })
+                            })
+                        };
+
+                    let running_state = running_state.clone();
+                    Some(ContextMenu::build(
+                        window,
+                        cx,
+                        move |mut context_menu, _window, cx| {
+                            let history = running_state
+                                .read(cx)
+                                .session()
+                                .read(cx)
+                                .historic_snapshots();
+
+                            context_menu = context_menu.entry("Current State", None, {
+                                let running_state = running_state.clone();
+                                move |_window, cx| {
+                                    handler(None, running_state.clone(), cx);
+                                }
+                            });
+                            context_menu = context_menu.separator();
+
+                            for (ix, _) in history.iter().enumerate().rev() {
+                                context_menu =
+                                    context_menu.entry(format!("history-{}", ix + 1), None, {
+                                        let running_state = running_state.clone();
+                                        move |_window, cx| {
+                                            handler(Some(ix), running_state.clone(), cx);
+                                        }
+                                    });
+                            }
+
+                            context_menu
+                        },
+                    ))
+                }
+            })
+            .anchor(Corner::TopRight)
+    }
 }
 
 async fn register_session_inner(

crates/project/src/debugger/session.rs 🔗

@@ -1436,15 +1436,23 @@ impl Session {
             .push_back(std::mem::take(&mut self.active_snapshot));
     }
 
-    pub fn history(&self) -> &VecDeque<SessionSnapshot> {
+    pub fn historic_snapshots(&self) -> &VecDeque<SessionSnapshot> {
         &self.snapshots
     }
 
-    pub fn go_back_to_history(&mut self, ix: Option<usize>, cx: &mut Context<'_, Session>) {
+    pub fn select_historic_snapshot(&mut self, ix: Option<usize>, cx: &mut Context<Session>) {
         if self.selected_snapshot_index == ix {
             return;
         }
 
+        if self
+            .selected_snapshot_index
+            .is_some_and(|ix| self.snapshots.len() <= ix)
+        {
+            debug_panic!("Attempted to select a debug session with an out of bounds index");
+            return;
+        }
+
         self.selected_snapshot_index = ix;
 
         if ix.is_some() {
@@ -1454,7 +1462,7 @@ impl Session {
         cx.notify();
     }
 
-    pub fn active_history(&self) -> Option<usize> {
+    pub fn active_snapshot_index(&self) -> Option<usize> {
         self.selected_snapshot_index
     }
 
@@ -2272,7 +2280,7 @@ impl Session {
     }
 
     pub fn continue_thread(&mut self, thread_id: ThreadId, cx: &mut Context<Self>) {
-        self.go_back_to_history(None, cx);
+        self.select_historic_snapshot(None, cx);
 
         let supports_single_thread_execution_requests =
             self.capabilities.supports_single_thread_execution_requests;
@@ -2309,7 +2317,7 @@ impl Session {
         granularity: SteppingGranularity,
         cx: &mut Context<Self>,
     ) {
-        self.go_back_to_history(None, cx);
+        self.select_historic_snapshot(None, cx);
 
         let supports_single_thread_execution_requests =
             self.capabilities.supports_single_thread_execution_requests;
@@ -2341,7 +2349,7 @@ impl Session {
         granularity: SteppingGranularity,
         cx: &mut Context<Self>,
     ) {
-        self.go_back_to_history(None, cx);
+        self.select_historic_snapshot(None, cx);
 
         let supports_single_thread_execution_requests =
             self.capabilities.supports_single_thread_execution_requests;
@@ -2373,7 +2381,7 @@ impl Session {
         granularity: SteppingGranularity,
         cx: &mut Context<Self>,
     ) {
-        self.go_back_to_history(None, cx);
+        self.select_historic_snapshot(None, cx);
 
         let supports_single_thread_execution_requests =
             self.capabilities.supports_single_thread_execution_requests;
@@ -2405,7 +2413,7 @@ impl Session {
         granularity: SteppingGranularity,
         cx: &mut Context<Self>,
     ) {
-        self.go_back_to_history(None, cx);
+        self.select_historic_snapshot(None, cx);
 
         let supports_single_thread_execution_requests =
             self.capabilities.supports_single_thread_execution_requests;

crates/ui/src/components/button/split_button.rs 🔗

@@ -4,7 +4,7 @@ use gpui::{
 };
 use theme::ActiveTheme;
 
-use crate::{ElevationIndex, h_flex};
+use crate::{ElevationIndex, IconButton, h_flex};
 
 use super::ButtonLike;
 
@@ -15,6 +15,23 @@ pub enum SplitButtonStyle {
     Transparent,
 }
 
+pub enum SplitButtonKind {
+    ButtonLike(ButtonLike),
+    IconButton(IconButton),
+}
+
+impl From<IconButton> for SplitButtonKind {
+    fn from(icon_button: IconButton) -> Self {
+        Self::IconButton(icon_button)
+    }
+}
+
+impl From<ButtonLike> for SplitButtonKind {
+    fn from(button_like: ButtonLike) -> Self {
+        Self::ButtonLike(button_like)
+    }
+}
+
 /// /// A button with two parts: a primary action on the left and a secondary action on the right.
 ///
 /// The left side is a [`ButtonLike`] with the main action, while the right side can contain
@@ -23,15 +40,15 @@ pub enum SplitButtonStyle {
 /// The two sections are visually separated by a divider, but presented as a unified control.
 #[derive(IntoElement)]
 pub struct SplitButton {
-    pub left: ButtonLike,
-    pub right: AnyElement,
+    left: SplitButtonKind,
+    right: AnyElement,
     style: SplitButtonStyle,
 }
 
 impl SplitButton {
-    pub fn new(left: ButtonLike, right: AnyElement) -> Self {
+    pub fn new(left: impl Into<SplitButtonKind>, right: AnyElement) -> Self {
         Self {
-            left,
+            left: left.into(),
             right,
             style: SplitButtonStyle::Filled,
         }
@@ -56,7 +73,10 @@ impl RenderOnce for SplitButton {
                 this.border_1()
                     .border_color(cx.theme().colors().border.opacity(0.8))
             })
-            .child(div().flex_grow().child(self.left))
+            .child(div().flex_grow().child(match self.left {
+                SplitButtonKind::ButtonLike(button) => button.into_any_element(),
+                SplitButtonKind::IconButton(icon) => icon.into_any_element(),
+            }))
             .child(
                 div()
                     .h_full()