debugger: Add Debug Panel context menu (#28847)

Anthony Eid created

This PR adds a debug panel context menu that will allow a user to select
which debug session items are visible.

The context menu will add to the pane that was right clicked on.

<img width="1275" alt="Screenshot 2025-04-16 at 2 43 36 AM"
src="https://github.com/user-attachments/assets/330322ff-69db-4731-bbaf-3544d53f2f15"
/>


Release Notes:

- N/A

Change summary

crates/debugger_ui/src/debugger_panel.rs                     |  96 +++
crates/debugger_ui/src/persistence.rs                        |  50 +
crates/debugger_ui/src/session/running.rs                    | 154 +++++
crates/debugger_ui/src/session/running/loaded_source_list.rs |   2 
crates/debugger_ui/src/session/running/variable_list.rs      |   4 
5 files changed, 287 insertions(+), 19 deletions(-)

Detailed changes

crates/debugger_ui/src/debugger_panel.rs 🔗

@@ -12,8 +12,9 @@ use dap::{
 };
 use futures::{SinkExt as _, channel::mpsc};
 use gpui::{
-    Action, App, AsyncWindowContext, Context, Entity, EntityId, EventEmitter, FocusHandle,
-    Focusable, Subscription, Task, WeakEntity, actions,
+    Action, App, AsyncWindowContext, Context, DismissEvent, Entity, EntityId, EventEmitter,
+    FocusHandle, Focusable, MouseButton, MouseDownEvent, Point, Subscription, Task, WeakEntity,
+    actions, anchored, deferred,
 };
 
 use project::{
@@ -64,6 +65,7 @@ pub struct DebugPanel {
     project: WeakEntity<Project>,
     workspace: WeakEntity<Workspace>,
     focus_handle: FocusHandle,
+    context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
     _subscriptions: Vec<Subscription>,
 }
 
@@ -126,6 +128,7 @@ impl DebugPanel {
                 focus_handle: cx.focus_handle(),
                 project: project.downgrade(),
                 workspace: workspace.weak_handle(),
+                context_menu: None,
             };
 
             debug_panel
@@ -573,6 +576,57 @@ impl DebugPanel {
         )
     }
 
+    fn deploy_context_menu(
+        &mut self,
+        position: Point<Pixels>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(running_state) = self
+            .active_session
+            .as_ref()
+            .and_then(|session| session.read(cx).mode().as_running().cloned())
+        {
+            let pane_items_status = running_state.read(cx).pane_items_status(cx);
+            let this = cx.weak_entity();
+
+            let context_menu = ContextMenu::build(window, cx, |mut menu, _window, _cx| {
+                for (item_kind, is_visible) in pane_items_status.into_iter() {
+                    menu = menu.toggleable_entry(item_kind, is_visible, IconPosition::End, None, {
+                        let this = this.clone();
+                        move |window, cx| {
+                            this.update(cx, |this, cx| {
+                                if let Some(running_state) =
+                                    this.active_session.as_ref().and_then(|session| {
+                                        session.read(cx).mode().as_running().cloned()
+                                    })
+                                {
+                                    running_state.update(cx, |state, cx| {
+                                        if is_visible {
+                                            state.remove_pane_item(item_kind, window, cx);
+                                        } else {
+                                            state.add_pane_item(item_kind, position, window, cx);
+                                        }
+                                    })
+                                }
+                            })
+                            .ok();
+                        }
+                    });
+                }
+
+                menu
+            });
+
+            window.focus(&context_menu.focus_handle(cx));
+            let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
+                this.context_menu.take();
+                cx.notify();
+            });
+            self.context_menu = Some((context_menu, position, subscription));
+        }
+    }
+
     fn top_controls_strip(&self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
         let active_session = self.active_session.clone();
 
@@ -897,11 +951,49 @@ impl Render for DebugPanel {
         let has_sessions = self.sessions.len() > 0;
         debug_assert_eq!(has_sessions, self.active_session.is_some());
 
+        if self
+            .active_session
+            .as_ref()
+            .and_then(|session| session.read(cx).mode().as_running().cloned())
+            .map(|state| state.read(cx).has_open_context_menu(cx))
+            .unwrap_or(false)
+        {
+            self.context_menu.take();
+        }
+
         v_flex()
             .size_full()
             .key_context("DebugPanel")
             .child(h_flex().children(self.top_controls_strip(window, cx)))
             .track_focus(&self.focus_handle(cx))
+            .when(self.active_session.is_some(), |this| {
+                this.on_mouse_down(
+                    MouseButton::Right,
+                    cx.listener(|this, event: &MouseDownEvent, window, cx| {
+                        if this
+                            .active_session
+                            .as_ref()
+                            .and_then(|session| {
+                                session.read(cx).mode().as_running().map(|state| {
+                                    state.read(cx).has_pane_at_position(event.position)
+                                })
+                            })
+                            .unwrap_or(false)
+                        {
+                            this.deploy_context_menu(event.position, window, cx);
+                        }
+                    }),
+                )
+                .children(self.context_menu.as_ref().map(|(menu, position, _)| {
+                    deferred(
+                        anchored()
+                            .position(*position)
+                            .anchor(gpui::Corner::TopLeft)
+                            .child(menu.clone()),
+                    )
+                    .with_priority(1)
+                }))
+            })
             .map(|this| {
                 if has_sessions {
                     this.children(self.active_session.clone())

crates/debugger_ui/src/persistence.rs 🔗

@@ -1,4 +1,5 @@
 use collections::HashMap;
+use dap::Capabilities;
 use db::kvp::KEY_VALUE_STORE;
 use gpui::{Axis, Context, Entity, EntityId, Focusable, Subscription, WeakEntity, Window};
 use project::Project;
@@ -9,19 +10,43 @@ use workspace::{Member, Pane, PaneAxis, Workspace};
 
 use crate::session::running::{
     self, RunningState, SubView, breakpoint_list::BreakpointList, console::Console,
-    module_list::ModuleList, stack_frame_list::StackFrameList, variable_list::VariableList,
+    loaded_source_list::LoadedSourceList, module_list::ModuleList,
+    stack_frame_list::StackFrameList, variable_list::VariableList,
 };
 
-#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
+#[derive(Clone, Hash, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
 pub(crate) enum DebuggerPaneItem {
     Console,
     Variables,
     BreakpointList,
     Frames,
     Modules,
+    LoadedSources,
 }
 
 impl DebuggerPaneItem {
+    pub(crate) fn all() -> &'static [DebuggerPaneItem] {
+        static VARIANTS: &[DebuggerPaneItem] = &[
+            DebuggerPaneItem::Console,
+            DebuggerPaneItem::Variables,
+            DebuggerPaneItem::BreakpointList,
+            DebuggerPaneItem::Frames,
+            DebuggerPaneItem::Modules,
+            DebuggerPaneItem::LoadedSources,
+        ];
+        VARIANTS
+    }
+
+    pub(crate) fn is_supported(&self, capabilities: &Capabilities) -> bool {
+        match self {
+            DebuggerPaneItem::Modules => capabilities.supports_modules_request.unwrap_or_default(),
+            DebuggerPaneItem::LoadedSources => capabilities
+                .supports_loaded_sources_request
+                .unwrap_or_default(),
+            _ => true,
+        }
+    }
+
     pub(crate) fn to_shared_string(self) -> SharedString {
         match self {
             DebuggerPaneItem::Console => SharedString::new_static("Console"),
@@ -29,10 +54,17 @@ impl DebuggerPaneItem {
             DebuggerPaneItem::BreakpointList => SharedString::new_static("Breakpoints"),
             DebuggerPaneItem::Frames => SharedString::new_static("Frames"),
             DebuggerPaneItem::Modules => SharedString::new_static("Modules"),
+            DebuggerPaneItem::LoadedSources => SharedString::new_static("Sources"),
         }
     }
 }
 
+impl From<DebuggerPaneItem> for SharedString {
+    fn from(item: DebuggerPaneItem) -> Self {
+        item.to_shared_string()
+    }
+}
+
 #[derive(Debug, Serialize, Deserialize)]
 pub(crate) struct SerializedAxis(pub Axis);
 
@@ -136,6 +168,7 @@ pub(crate) fn deserialize_pane_layout(
     module_list: &Entity<ModuleList>,
     console: &Entity<Console>,
     breakpoint_list: &Entity<BreakpointList>,
+    loaded_sources: &Entity<LoadedSourceList>,
     subscriptions: &mut HashMap<EntityId, Subscription>,
     window: &mut Window,
     cx: &mut Context<RunningState>,
@@ -157,6 +190,7 @@ pub(crate) fn deserialize_pane_layout(
                     module_list,
                     console,
                     breakpoint_list,
+                    loaded_sources,
                     subscriptions,
                     window,
                     cx,
@@ -191,7 +225,7 @@ pub(crate) fn deserialize_pane_layout(
                 .iter()
                 .map(|child| match child {
                     DebuggerPaneItem::Frames => Box::new(SubView::new(
-                        pane.focus_handle(cx),
+                        stack_frame_list.focus_handle(cx),
                         stack_frame_list.clone().into(),
                         DebuggerPaneItem::Frames,
                         None,
@@ -212,13 +246,19 @@ pub(crate) fn deserialize_pane_layout(
                         cx,
                     )),
                     DebuggerPaneItem::Modules => Box::new(SubView::new(
-                        pane.focus_handle(cx),
+                        module_list.focus_handle(cx),
                         module_list.clone().into(),
                         DebuggerPaneItem::Modules,
                         None,
                         cx,
                     )),
-
+                    DebuggerPaneItem::LoadedSources => Box::new(SubView::new(
+                        loaded_sources.focus_handle(cx),
+                        loaded_sources.clone().into(),
+                        DebuggerPaneItem::LoadedSources,
+                        None,
+                        cx,
+                    )),
                     DebuggerPaneItem::Console => Box::new(SubView::new(
                         pane.focus_handle(cx),
                         console.clone().into(),

crates/debugger_ui/src/session/running.rs 🔗

@@ -11,12 +11,12 @@ use crate::persistence::{self, DebuggerPaneItem, SerializedPaneLayout};
 
 use super::DebugPanelItemEvent;
 use breakpoint_list::BreakpointList;
-use collections::HashMap;
+use collections::{HashMap, IndexMap};
 use console::Console;
 use dap::{Capabilities, Thread, client::SessionId, debugger_settings::DebuggerSettings};
 use gpui::{
     Action as _, AnyView, AppContext, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
-    NoAction, Subscription, Task, WeakEntity,
+    NoAction, Pixels, Point, Subscription, Task, WeakEntity,
 };
 use loaded_source_list::LoadedSourceList;
 use module_list::ModuleList;
@@ -49,8 +49,10 @@ pub struct RunningState {
     variable_list: Entity<variable_list::VariableList>,
     _subscriptions: Vec<Subscription>,
     stack_frame_list: Entity<stack_frame_list::StackFrameList>,
-    _module_list: Entity<module_list::ModuleList>,
+    loaded_sources_list: Entity<LoadedSourceList>,
+    module_list: Entity<module_list::ModuleList>,
     _console: Entity<Console>,
+    breakpoint_list: Entity<BreakpointList>,
     panes: PaneGroup,
     pane_close_subscriptions: HashMap<EntityId, Subscription>,
     _schedule_serialize: Option<Task<()>>,
@@ -383,7 +385,6 @@ impl RunningState {
 
         let module_list = cx.new(|cx| ModuleList::new(session.clone(), workspace.clone(), cx));
 
-        #[expect(unused)]
         let loaded_source_list = cx.new(|cx| LoadedSourceList::new(session.clone(), cx));
 
         let console = cx.new(|cx| {
@@ -396,7 +397,7 @@ impl RunningState {
             )
         });
 
-        let breakpoints = BreakpointList::new(session.clone(), workspace.clone(), &project, cx);
+        let breakpoint_list = BreakpointList::new(session.clone(), workspace.clone(), &project, cx);
 
         let _subscriptions = vec![
             cx.observe(&module_list, |_, _, cx| cx.notify()),
@@ -436,7 +437,8 @@ impl RunningState {
                 &variable_list,
                 &module_list,
                 &console,
-                &breakpoints,
+                &breakpoint_list,
+                &loaded_source_list,
                 &mut pane_close_subscriptions,
                 window,
                 cx,
@@ -452,7 +454,7 @@ impl RunningState {
                 &variable_list,
                 &module_list,
                 &console,
-                breakpoints,
+                &breakpoint_list,
                 &mut pane_close_subscriptions,
                 window,
                 cx,
@@ -472,13 +474,139 @@ impl RunningState {
             stack_frame_list,
             session_id,
             panes,
-            _module_list: module_list,
+            module_list,
             _console: console,
+            breakpoint_list,
+            loaded_sources_list: loaded_source_list,
             pane_close_subscriptions,
             _schedule_serialize: None,
         }
     }
 
+    pub(crate) fn remove_pane_item(
+        &mut self,
+        item_kind: DebuggerPaneItem,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        debug_assert!(
+            item_kind.is_supported(self.session.read(cx).capabilities()),
+            "We should only allow removing supported item kinds"
+        );
+
+        if let Some((pane, item_id)) = self.panes.panes().iter().find_map(|pane| {
+            Some(pane).zip(
+                pane.read(cx)
+                    .items()
+                    .find(|item| {
+                        item.act_as::<SubView>(cx)
+                            .is_some_and(|view| view.read(cx).kind == item_kind)
+                    })
+                    .map(|item| item.item_id()),
+            )
+        }) {
+            pane.update(cx, |pane, cx| {
+                pane.remove_item(item_id, false, true, window, cx)
+            })
+        }
+    }
+
+    pub(crate) fn has_pane_at_position(&self, position: Point<Pixels>) -> bool {
+        self.panes.pane_at_pixel_position(position).is_some()
+    }
+
+    pub(crate) fn add_pane_item(
+        &mut self,
+        item_kind: DebuggerPaneItem,
+        position: Point<Pixels>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        debug_assert!(
+            item_kind.is_supported(self.session.read(cx).capabilities()),
+            "We should only allow adding supported item kinds"
+        );
+
+        if let Some(pane) = self.panes.pane_at_pixel_position(position) {
+            let sub_view = match item_kind {
+                DebuggerPaneItem::Console => {
+                    let weak_console = self._console.clone().downgrade();
+
+                    Box::new(SubView::new(
+                        pane.focus_handle(cx),
+                        self._console.clone().into(),
+                        item_kind,
+                        Some(Box::new(move |cx| {
+                            weak_console
+                                .read_with(cx, |console, cx| console.show_indicator(cx))
+                                .unwrap_or_default()
+                        })),
+                        cx,
+                    ))
+                }
+                DebuggerPaneItem::Variables => Box::new(SubView::new(
+                    self.variable_list.focus_handle(cx),
+                    self.variable_list.clone().into(),
+                    item_kind,
+                    None,
+                    cx,
+                )),
+                DebuggerPaneItem::BreakpointList => Box::new(SubView::new(
+                    self.breakpoint_list.focus_handle(cx),
+                    self.breakpoint_list.clone().into(),
+                    item_kind,
+                    None,
+                    cx,
+                )),
+                DebuggerPaneItem::Frames => Box::new(SubView::new(
+                    self.stack_frame_list.focus_handle(cx),
+                    self.stack_frame_list.clone().into(),
+                    item_kind,
+                    None,
+                    cx,
+                )),
+                DebuggerPaneItem::Modules => Box::new(SubView::new(
+                    self.module_list.focus_handle(cx),
+                    self.module_list.clone().into(),
+                    item_kind,
+                    None,
+                    cx,
+                )),
+                DebuggerPaneItem::LoadedSources => Box::new(SubView::new(
+                    self.loaded_sources_list.focus_handle(cx),
+                    self.loaded_sources_list.clone().into(),
+                    item_kind,
+                    None,
+                    cx,
+                )),
+            };
+
+            pane.update(cx, |pane, cx| {
+                pane.add_item(sub_view, false, false, None, window, cx);
+            })
+        }
+    }
+
+    pub(crate) fn pane_items_status(&self, cx: &App) -> IndexMap<DebuggerPaneItem, bool> {
+        let caps = self.session.read(cx).capabilities();
+        let mut pane_item_status = IndexMap::from_iter(
+            DebuggerPaneItem::all()
+                .iter()
+                .filter(|kind| kind.is_supported(&caps))
+                .map(|kind| (*kind, false)),
+        );
+        self.panes.panes().iter().for_each(|pane| {
+            pane.read(cx)
+                .items()
+                .filter_map(|item| item.act_as::<SubView>(cx))
+                .for_each(|view| {
+                    pane_item_status.insert(view.read(cx).kind, true);
+                });
+        });
+
+        pane_item_status
+    }
+
     pub(crate) fn serialize_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         if self._schedule_serialize.is_none() {
             self._schedule_serialize = Some(cx.spawn_in(window, async move |this, cx| {
@@ -533,6 +661,10 @@ impl RunningState {
         }
     }
 
+    pub(crate) fn has_open_context_menu(&self, cx: &App) -> bool {
+        self.variable_list.read(cx).has_open_context_menu()
+    }
+
     pub fn session(&self) -> &Entity<Session> {
         &self.session
     }
@@ -557,7 +689,7 @@ impl RunningState {
 
     #[cfg(test)]
     pub(crate) fn module_list(&self) -> &Entity<ModuleList> {
-        &self._module_list
+        &self.module_list
     }
 
     #[cfg(test)]
@@ -793,7 +925,7 @@ impl RunningState {
         variable_list: &Entity<VariableList>,
         module_list: &Entity<ModuleList>,
         console: &Entity<Console>,
-        breakpoints: Entity<BreakpointList>,
+        breakpoints: &Entity<BreakpointList>,
         subscriptions: &mut HashMap<EntityId, Subscription>,
         window: &mut Window,
         cx: &mut Context<'_, RunningState>,
@@ -817,7 +949,7 @@ impl RunningState {
             this.add_item(
                 Box::new(SubView::new(
                     breakpoints.focus_handle(cx),
-                    breakpoints.into(),
+                    breakpoints.clone().into(),
                     DebuggerPaneItem::BreakpointList,
                     None,
                     cx,

crates/debugger_ui/src/session/running/variable_list.rs 🔗

@@ -194,6 +194,10 @@ impl VariableList {
         }
     }
 
+    pub(super) fn has_open_context_menu(&self) -> bool {
+        self.open_context_menu.is_some()
+    }
+
     fn build_entries(&mut self, cx: &mut Context<Self>) {
         let Some(stack_frame_id) = self.selected_stack_frame_id else {
             return;