debugger: Add close button and coloring to debug panel session's menu (#28310)

Anthony Eid created

This PR adds colors to debug panel's session menu that indicate the
state of each respective session. It also adds a close button to each
entry.

green - running
yellow - stopped
red - terminated/ended 


Release Notes:

- N/A

Change summary

crates/debugger_ui/src/debugger_panel.rs  | 123 ++++++++++++++++++------
crates/debugger_ui/src/session.rs         |  30 +++++
crates/markdown/src/parser.rs             |   2 
crates/project/src/debugger/dap_store.rs  |  11 ++
crates/project/src/debugger/session.rs    |   7 +
crates/ui/src/components/dropdown_menu.rs |  44 ++++++--
6 files changed, 173 insertions(+), 44 deletions(-)

Detailed changes

crates/debugger_ui/src/debugger_panel.rs 🔗

@@ -12,8 +12,8 @@ use dap::{
 };
 use futures::{SinkExt as _, channel::mpsc};
 use gpui::{
-    Action, App, AsyncWindowContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
-    Subscription, Task, WeakEntity, actions,
+    Action, App, AsyncWindowContext, Context, Entity, EntityId, EventEmitter, FocusHandle,
+    Focusable, Subscription, Task, WeakEntity, actions,
 };
 use project::{
     Project,
@@ -336,6 +336,95 @@ impl DebugPanel {
         })
     }
 
+    fn close_session(&mut self, entity_id: EntityId, cx: &mut Context<Self>) {
+        let Some(session) = self
+            .sessions
+            .iter()
+            .find(|other| entity_id == other.entity_id())
+        else {
+            return;
+        };
+
+        session.update(cx, |session, cx| session.shutdown(cx));
+
+        self.sessions.retain(|other| entity_id != other.entity_id());
+
+        if let Some(active_session_id) = self
+            .active_session
+            .as_ref()
+            .map(|session| session.entity_id())
+        {
+            if active_session_id == entity_id {
+                self.active_session = self.sessions.first().cloned();
+            }
+        }
+    }
+
+    fn sessions_drop_down_menu(
+        &self,
+        active_session: &Entity<DebugSession>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> DropdownMenu {
+        let sessions = self.sessions.clone();
+        let weak = cx.weak_entity();
+        let label = active_session.read(cx).label_element(cx);
+
+        DropdownMenu::new_with_element(
+            "debugger-session-list",
+            label,
+            ContextMenu::build(window, cx, move |mut this, _, _| {
+                for session in sessions.into_iter() {
+                    let weak_session = session.downgrade();
+                    let weak_id = weak_session.entity_id();
+
+                    this = this.custom_entry(
+                        {
+                            let weak = weak.clone();
+                            move |_, cx| {
+                                weak_session
+                                    .read_with(cx, |session, cx| {
+                                        h_flex()
+                                            .w_full()
+                                            .justify_between()
+                                            .child(session.label_element(cx))
+                                            .child(
+                                                IconButton::new(
+                                                    "close-debug-session",
+                                                    IconName::Close,
+                                                )
+                                                .icon_size(IconSize::Small)
+                                                .on_click({
+                                                    let weak = weak.clone();
+                                                    move |_, _, cx| {
+                                                        weak.update(cx, |panel, cx| {
+                                                            panel.close_session(weak_id, cx);
+                                                        })
+                                                        .ok();
+                                                    }
+                                                }),
+                                            )
+                                            .into_any_element()
+                                    })
+                                    .unwrap_or_else(|_| div().into_any_element())
+                            }
+                        },
+                        {
+                            let weak = weak.clone();
+                            move |window, cx| {
+                                weak.update(cx, |panel, cx| {
+                                    panel.activate_session(session.clone(), window, cx);
+                                })
+                                .ok();
+                            }
+                        },
+                    );
+                }
+                this
+            }),
+        )
+    }
+
     fn top_controls_strip(&self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
         let active_session = self.active_session.clone();
 
@@ -529,34 +618,8 @@ impl DebugPanel {
                             },
                         )
                         .when_some(active_session.as_ref(), |this, session| {
-                            let sessions = self.sessions.clone();
-                            let weak = cx.weak_entity();
-                            let label = session.read(cx).label(cx);
-                            this.child(DropdownMenu::new(
-                                "debugger-session-list",
-                                label,
-                                ContextMenu::build(window, cx, move |mut this, _, cx| {
-                                    for item in sessions {
-                                        let weak = weak.clone();
-                                        this = this.entry(
-                                            session.read(cx).label(cx),
-                                            None,
-                                            move |window, cx| {
-                                                weak.update(cx, |panel, cx| {
-                                                    panel.activate_session(
-                                                        item.clone(),
-                                                        window,
-                                                        cx,
-                                                    );
-                                                })
-                                                .ok();
-                                            },
-                                        );
-                                    }
-                                    this
-                                }),
-                            ))
-                            .child(Divider::vertical())
+                            let context_menu = self.sessions_drop_down_menu(session, window, cx);
+                            this.child(context_menu).child(Divider::vertical())
                         })
                         .child(
                             IconButton::new("debug-new-session", IconName::Plus)

crates/debugger_ui/src/session.rs 🔗

@@ -7,7 +7,7 @@ use project::debugger::{dap_store::DapStore, session::Session};
 use project::worktree_store::WorktreeStore;
 use rpc::proto::{self, PeerId};
 use running::RunningState;
-use ui::prelude::*;
+use ui::{Indicator, prelude::*};
 use workspace::{
     FollowableItem, ViewId, Workspace,
     item::{self, Item},
@@ -81,7 +81,6 @@ impl DebugSession {
         }
     }
 
-    #[expect(unused)]
     pub(crate) fn shutdown(&mut self, cx: &mut Context<Self>) {
         match &self.mode {
             DebugSessionState::Running(state) => state.update(cx, |state, cx| state.shutdown(cx)),
@@ -108,6 +107,33 @@ impl DebugSession {
             .expect("Remote Debug Sessions are not implemented yet")
             .label()
     }
+
+    pub(crate) fn label_element(&self, cx: &App) -> AnyElement {
+        let label = self.label(cx);
+
+        let (icon, color) = match &self.mode {
+            DebugSessionState::Running(state) => {
+                if state.read(cx).session().read(cx).is_terminated() {
+                    (Some(Indicator::dot().color(Color::Error)), Color::Error)
+                } else {
+                    match state.read(cx).thread_status(cx).unwrap_or_default() {
+                        project::debugger::session::ThreadStatus::Stopped => (
+                            Some(Indicator::dot().color(Color::Conflict)),
+                            Color::Conflict,
+                        ),
+                        _ => (Some(Indicator::dot().color(Color::Success)), Color::Success),
+                    }
+                }
+            }
+        };
+
+        h_flex()
+            .gap_2()
+            .when_some(icon, |this, indicator| this.child(indicator))
+            .justify_between()
+            .child(Label::new(label).color(color))
+            .into_any_element()
+    }
 }
 
 impl EventEmitter<DebugPanelItemEvent> for DebugSession {}

crates/markdown/src/parser.rs 🔗

@@ -219,7 +219,7 @@ pub enum MarkdownEvent {
     Start(MarkdownTag),
     /// End of a tagged element.
     End(MarkdownTagEnd),
-    /// Text that uses the associated range from the mardown source.
+    /// Text that uses the associated range from the markdown source.
     Text,
     /// Text that differs from the markdown source - typically due to substitution of HTML entities
     /// and smart punctuation.

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

@@ -1,7 +1,7 @@
 use super::{
     breakpoint_store::BreakpointStore,
     locator_store::LocatorStore,
-    session::{self, Session},
+    session::{self, Session, SessionStateEvent},
 };
 use crate::{ProjectEnvironment, debugger, worktree_store::WorktreeStore};
 use anyhow::{Result, anyhow};
@@ -869,6 +869,15 @@ fn create_new_session(
         }
 
         this.update(cx, |_, cx| {
+            cx.subscribe(
+                &session,
+                move |this: &mut DapStore, _, event: &SessionStateEvent, cx| match event {
+                    SessionStateEvent::Shutdown => {
+                        this.shutdown_session(session_id, cx).detach_and_log_err(cx);
+                    }
+                },
+            )
+            .detach();
             cx.emit(DapStoreEvent::DebugSessionInitialized(session_id));
         })?;
 

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

@@ -832,7 +832,12 @@ pub enum SessionEvent {
     Threads,
 }
 
+pub(crate) enum SessionStateEvent {
+    Shutdown,
+}
+
 impl EventEmitter<SessionEvent> for Session {}
+impl EventEmitter<SessionStateEvent> for Session {}
 
 // local session will send breakpoint updates to DAP for all new breakpoints
 // remote side will only send breakpoint updates when it is a breakpoint created by that peer
@@ -1553,6 +1558,8 @@ impl Session {
             )
         };
 
+        cx.emit(SessionStateEvent::Shutdown);
+
         cx.background_spawn(async move {
             let _ = task.await;
         })

crates/ui/src/components/dropdown_menu.rs 🔗

@@ -2,10 +2,15 @@ use gpui::{ClickEvent, Corner, CursorStyle, Entity, MouseButton};
 
 use crate::{ContextMenu, PopoverMenu, prelude::*};
 
+enum LabelKind {
+    Text(SharedString),
+    Element(AnyElement),
+}
+
 #[derive(IntoElement)]
 pub struct DropdownMenu {
     id: ElementId,
-    label: SharedString,
+    label: LabelKind,
     menu: Entity<ContextMenu>,
     full_width: bool,
     disabled: bool,
@@ -19,7 +24,21 @@ impl DropdownMenu {
     ) -> Self {
         Self {
             id: id.into(),
-            label: label.into(),
+            label: LabelKind::Text(label.into()),
+            menu,
+            full_width: false,
+            disabled: false,
+        }
+    }
+
+    pub fn new_with_element(
+        id: impl Into<ElementId>,
+        label: AnyElement,
+        menu: Entity<ContextMenu>,
+    ) -> Self {
+        Self {
+            id: id.into(),
+            label: LabelKind::Element(label),
             menu,
             full_width: false,
             disabled: false,
@@ -55,7 +74,7 @@ impl RenderOnce for DropdownMenu {
 
 #[derive(IntoElement)]
 struct DropdownMenuTrigger {
-    label: SharedString,
+    label: LabelKind,
     full_width: bool,
     selected: bool,
     disabled: bool,
@@ -64,9 +83,9 @@ struct DropdownMenuTrigger {
 }
 
 impl DropdownMenuTrigger {
-    pub fn new(label: impl Into<SharedString>) -> Self {
+    pub fn new(label: LabelKind) -> Self {
         Self {
-            label: label.into(),
+            label,
             full_width: false,
             selected: false,
             disabled: false,
@@ -135,11 +154,16 @@ impl RenderOnce for DropdownMenuTrigger {
                     el.cursor_pointer()
                 }
             })
-            .child(Label::new(self.label).color(if disabled {
-                Color::Disabled
-            } else {
-                Color::Default
-            }))
+            .child(match self.label {
+                LabelKind::Text(text) => Label::new(text)
+                    .color(if disabled {
+                        Color::Disabled
+                    } else {
+                        Color::Default
+                    })
+                    .into_any_element(),
+                LabelKind::Element(element) => element,
+            })
             .child(
                 Icon::new(IconName::ChevronUpDown)
                     .size(IconSize::XSmall)