debugger: First slight pass at UI (#27034)

Piotr Osiewicz created

- Collapse Launch and Attach into a single split button
- Fix code actions indicator being colored red.

Release Notes:

- N/A

Change summary

Cargo.lock                                      |   1 
crates/debugger_ui/src/session/inert.rs         | 146 +++++++++++----
crates/editor/src/editor.rs                     |   6 
crates/git_ui/Cargo.toml                        |   1 
crates/git_ui/src/git_ui.rs                     | 180 +++++++-----------
crates/ui/src/components/button.rs              |   2 
crates/ui/src/components/button/split_button.rs |  45 ++++
7 files changed, 226 insertions(+), 155 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5680,7 +5680,6 @@ dependencies = [
  "serde_derive",
  "serde_json",
  "settings",
- "smallvec",
  "strum",
  "telemetry",
  "theme",

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

@@ -7,20 +7,39 @@ use settings::Settings as _;
 use task::TCPHost;
 use theme::ThemeSettings;
 use ui::{
-    h_flex, relative, v_flex, ActiveTheme as _, Button, ButtonCommon, ButtonStyle, Clickable,
-    Context, ContextMenu, Disableable, DropdownMenu, InteractiveElement, IntoElement,
-    ParentElement, Render, SharedString, Styled, Window,
+    h_flex, relative, v_flex, ActiveTheme as _, ButtonLike, Clickable, Context, ContextMenu,
+    Disableable, Disclosure, DropdownMenu, FluentBuilder, InteractiveElement, IntoElement, Label,
+    LabelCommon, LabelSize, ParentElement, PopoverMenu, PopoverMenuHandle, Render, SharedString,
+    SplitButton, Styled, Window,
 };
 use workspace::Workspace;
 
 use crate::attach_modal::AttachModal;
 
+#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
+enum SpawnMode {
+    #[default]
+    Launch,
+    Attach,
+}
+
+impl SpawnMode {
+    fn label(&self) -> &'static str {
+        match self {
+            SpawnMode::Launch => "Launch",
+            SpawnMode::Attach => "Attach",
+        }
+    }
+}
+
 pub(crate) struct InertState {
     focus_handle: FocusHandle,
     selected_debugger: Option<SharedString>,
     program_editor: Entity<Editor>,
     cwd_editor: Entity<Editor>,
     workspace: WeakEntity<Workspace>,
+    spawn_mode: SpawnMode,
+    popover_handle: PopoverMenuHandle<ContextMenu>,
 }
 
 impl InertState {
@@ -47,6 +66,8 @@ impl InertState {
             program_editor,
             selected_debugger: None,
             focus_handle: cx.focus_handle(),
+            spawn_mode: SpawnMode::default(),
+            popover_handle: Default::default(),
         }
     }
 }
@@ -72,19 +93,46 @@ impl Render for InertState {
     ) -> impl ui::IntoElement {
         let weak = cx.weak_entity();
         let disable_buttons = self.selected_debugger.is_none();
+        let spawn_button = ButtonLike::new_rounded_left("spawn-debug-session")
+            .child(Label::new(self.spawn_mode.label()).size(LabelSize::Small))
+            .on_click(cx.listener(|this, _, window, cx| {
+                if this.spawn_mode == SpawnMode::Launch {
+                    let program = this.program_editor.read(cx).text(cx);
+                    let cwd = PathBuf::from(this.cwd_editor.read(cx).text(cx));
+                    let kind =
+                        kind_for_label(this.selected_debugger.as_deref().unwrap_or_else(|| {
+                            unimplemented!(
+                                "Automatic selection of a debugger based on users project"
+                            )
+                        }));
+                    cx.emit(InertEvent::Spawned {
+                        config: DebugAdapterConfig {
+                            label: "hard coded".into(),
+                            kind,
+                            request: DebugRequestType::Launch,
+                            program: Some(program),
+                            cwd: Some(cwd),
+                            initialize_args: None,
+                            supports_attach: false,
+                        },
+                    });
+                } else {
+                    this.attach(window, cx)
+                }
+            }))
+            .disabled(disable_buttons);
         v_flex()
             .track_focus(&self.focus_handle)
             .size_full()
             .gap_1()
             .p_2()
-
             .child(
-                v_flex().gap_1()
+                v_flex()
+                    .gap_1()
                     .child(
                         h_flex()
                             .w_full()
                             .gap_2()
-
                             .child(Self::render_editor(&self.program_editor, cx))
                             .child(
                                 h_flex().child(DropdownMenu::new(
@@ -109,45 +157,63 @@ impl Render for InertState {
                                             .entry("Delve", None, setter_for_name("Delve"))
                                             .entry("LLDB", None, setter_for_name("LLDB"))
                                             .entry("PHP", None, setter_for_name("PHP"))
-                                            .entry("JavaScript", None, setter_for_name("JavaScript"))
+                                            .entry(
+                                                "JavaScript",
+                                                None,
+                                                setter_for_name("JavaScript"),
+                                            )
                                             .entry("Debugpy", None, setter_for_name("Debugpy"))
                                     }),
                                 )),
-                            )
+                            ),
                     )
                     .child(
-                        h_flex().gap_2().child(
-                            Self::render_editor(&self.cwd_editor, cx),
-                        ).child(h_flex()
-                            .gap_4()
-                            .pl_2()
-                            .child(
-                                Button::new("launch-dap", "Launch")
-                                    .style(ButtonStyle::Filled)
-                                    .disabled(disable_buttons)
-                                    .on_click(cx.listener(|this, _, _, cx| {
-                                        let program = this.program_editor.read(cx).text(cx);
-                                        let cwd = PathBuf::from(this.cwd_editor.read(cx).text(cx));
-                                        let kind = kind_for_label(this.selected_debugger.as_deref().unwrap_or_else(|| unimplemented!("Automatic selection of a debugger based on users project")));
-                                        cx.emit(InertEvent::Spawned {
-                                            config: DebugAdapterConfig {
-                                                label: "hard coded".into(),
-                                                kind,
-                                                request: DebugRequestType::Launch,
-                                                program: Some(program),
-                                                cwd: Some(cwd),
-                                                initialize_args: None,
-                                                supports_attach: false,
-                                            },
-                                        });
-                                    })),
-                            )
-                            .child(Button::new("attach-dap", "Attach")
-                                .style(ButtonStyle::Filled)
-                                .disabled(disable_buttons)
-                                .on_click(cx.listener(|this, _, window, cx| this.attach(window, cx)))
-                            ))
-                    )
+                        h_flex()
+                            .gap_2()
+                            .child(Self::render_editor(&self.cwd_editor, cx))
+                            .map(|this| {
+                                let entity = cx.weak_entity();
+                                this.child(SplitButton {
+                                    left: spawn_button,
+                                    right: PopoverMenu::new("debugger-select-spawn-mode")
+                                        .trigger(Disclosure::new(
+                                            "debugger-spawn-button-disclosure",
+                                            self.popover_handle.is_deployed(),
+                                        ))
+                                        .menu(move |window, cx| {
+                                            Some(ContextMenu::build(window, cx, {
+                                                let entity = entity.clone();
+                                                move |this, _, _| {
+                                                    this.entry("Launch", None, {
+                                                        let entity = entity.clone();
+                                                        move |_, cx| {
+                                                            let _ =
+                                                                entity.update(cx, |this, cx| {
+                                                                    this.spawn_mode =
+                                                                        SpawnMode::Launch;
+                                                                    cx.notify();
+                                                                });
+                                                        }
+                                                    })
+                                                    .entry("Attach", None, {
+                                                        let entity = entity.clone();
+                                                        move |_, cx| {
+                                                            let _ =
+                                                                entity.update(cx, |this, cx| {
+                                                                    this.spawn_mode =
+                                                                        SpawnMode::Attach;
+                                                                    cx.notify();
+                                                                });
+                                                        }
+                                                    })
+                                                }
+                                            }))
+                                        })
+                                        .with_handle(self.popover_handle.clone())
+                                        .into_any_element(),
+                                })
+                            }),
+                    ),
             )
     }
 }

crates/editor/src/editor.rs 🔗

@@ -5918,11 +5918,7 @@ impl Editor {
         breakpoint: Option<&(Anchor, Breakpoint)>,
         cx: &mut Context<Self>,
     ) -> Option<IconButton> {
-        let color = if breakpoint.is_some() {
-            Color::Debugger
-        } else {
-            Color::Muted
-        };
+        let color = Color::Muted;
 
         let position = breakpoint.as_ref().map(|(anchor, _)| *anchor);
         let bp_kind = Arc::new(

crates/git_ui/Cargo.toml 🔗

@@ -49,7 +49,6 @@ serde.workspace = true
 serde_derive.workspace = true
 serde_json.workspace = true
 settings.workspace = true
-smallvec.workspace = true
 strum.workspace = true
 telemetry.workspace = true
 theme.workspace = true

crates/git_ui/src/git_ui.rs 🔗

@@ -163,19 +163,18 @@ fn render_remote_button(
 }
 
 mod remote_button {
-    use gpui::{hsla, point, Action, AnyView, BoxShadow, ClickEvent, Corner, FocusHandle};
+    use gpui::{Action, AnyView, ClickEvent, Corner, FocusHandle};
     use ui::{
-        div, h_flex, px, rems, ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, Clickable,
-        ContextMenu, ElementId, ElevationIndex, FluentBuilder, Icon, IconName, IconSize,
-        IntoElement, Label, LabelCommon, LabelSize, LineHeightStyle, ParentElement, PopoverMenu,
-        RenderOnce, SharedString, Styled, Tooltip, Window,
+        div, h_flex, rems, App, ButtonCommon, Clickable, ContextMenu, ElementId, FluentBuilder,
+        Icon, IconName, IconSize, IntoElement, Label, LabelCommon, LabelSize, LineHeightStyle,
+        ParentElement, PopoverMenu, SharedString, SplitButton, Styled, Tooltip, Window,
     };
 
     pub fn render_fetch_button(
         keybinding_target: Option<FocusHandle>,
         id: SharedString,
     ) -> SplitButton {
-        SplitButton::new(
+        split_button(
             id,
             "Fetch",
             0,
@@ -203,7 +202,7 @@ mod remote_button {
         id: SharedString,
         ahead: u32,
     ) -> SplitButton {
-        SplitButton::new(
+        split_button(
             id,
             "Push",
             ahead as usize,
@@ -232,7 +231,7 @@ mod remote_button {
         ahead: u32,
         behind: u32,
     ) -> SplitButton {
-        SplitButton::new(
+        split_button(
             id,
             "Pull",
             ahead as usize,
@@ -259,7 +258,7 @@ mod remote_button {
         keybinding_target: Option<FocusHandle>,
         id: SharedString,
     ) -> SplitButton {
-        SplitButton::new(
+        split_button(
             id,
             "Publish",
             0,
@@ -286,7 +285,7 @@ mod remote_button {
         keybinding_target: Option<FocusHandle>,
         id: SharedString,
     ) -> SplitButton {
-        SplitButton::new(
+        split_button(
             id,
             "Republish",
             0,
@@ -364,111 +363,76 @@ mod remote_button {
             })
             .anchor(Corner::TopRight)
     }
+    #[allow(clippy::too_many_arguments)]
+    fn split_button(
+        id: SharedString,
+        left_label: impl Into<SharedString>,
+        ahead_count: usize,
+        behind_count: usize,
+        left_icon: Option<IconName>,
+        keybinding_target: Option<FocusHandle>,
+        left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+        tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
+    ) -> SplitButton {
+        fn count(count: usize) -> impl IntoElement {
+            h_flex()
+                .ml_neg_px()
+                .h(rems(0.875))
+                .items_center()
+                .overflow_hidden()
+                .px_0p5()
+                .child(
+                    Label::new(count.to_string())
+                        .size(LabelSize::XSmall)
+                        .line_height_style(LineHeightStyle::UiLabel),
+                )
+        }
 
-    #[derive(IntoElement)]
-    pub struct SplitButton {
-        pub left: ButtonLike,
-        pub right: AnyElement,
-    }
-
-    impl SplitButton {
-        #[allow(clippy::too_many_arguments)]
-        fn new(
-            id: impl Into<SharedString>,
-            left_label: impl Into<SharedString>,
-            ahead_count: usize,
-            behind_count: usize,
-            left_icon: Option<IconName>,
-            keybinding_target: Option<FocusHandle>,
-            left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
-            tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
-        ) -> Self {
-            let id = id.into();
+        let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
 
-            fn count(count: usize) -> impl IntoElement {
+        let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
+            format!("split-button-left-{}", id).into(),
+        ))
+        .layer(ui::ElevationIndex::ModalSurface)
+        .size(ui::ButtonSize::Compact)
+        .when(should_render_counts, |this| {
+            this.child(
                 h_flex()
-                    .ml_neg_px()
-                    .h(rems(0.875))
-                    .items_center()
-                    .overflow_hidden()
-                    .px_0p5()
-                    .child(
-                        Label::new(count.to_string())
-                            .size(LabelSize::XSmall)
-                            .line_height_style(LineHeightStyle::UiLabel),
-                    )
-            }
-
-            let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
-
-            let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
-                format!("split-button-left-{}", id).into(),
-            ))
-            .layer(ui::ElevationIndex::ModalSurface)
-            .size(ui::ButtonSize::Compact)
-            .when(should_render_counts, |this| {
-                this.child(
-                    h_flex()
-                        .ml_neg_0p5()
-                        .mr_1()
-                        .when(behind_count > 0, |this| {
-                            this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
-                                .child(count(behind_count))
-                        })
-                        .when(ahead_count > 0, |this| {
-                            this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
-                                .child(count(ahead_count))
-                        }),
-                )
-            })
-            .when_some(left_icon, |this, left_icon| {
-                this.child(
-                    h_flex()
-                        .ml_neg_0p5()
-                        .mr_1()
-                        .child(Icon::new(left_icon).size(IconSize::XSmall)),
-                )
-            })
-            .child(
-                div()
-                    .child(Label::new(left_label).size(LabelSize::Small))
-                    .mr_0p5(),
+                    .ml_neg_0p5()
+                    .mr_1()
+                    .when(behind_count > 0, |this| {
+                        this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
+                            .child(count(behind_count))
+                    })
+                    .when(ahead_count > 0, |this| {
+                        this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
+                            .child(count(ahead_count))
+                    }),
             )
-            .on_click(left_on_click)
-            .tooltip(tooltip);
-
-            let right = render_git_action_menu(
-                ElementId::Name(format!("split-button-right-{}", id).into()),
-                keybinding_target,
+        })
+        .when_some(left_icon, |this, left_icon| {
+            this.child(
+                h_flex()
+                    .ml_neg_0p5()
+                    .mr_1()
+                    .child(Icon::new(left_icon).size(IconSize::XSmall)),
             )
-            .into_any_element();
+        })
+        .child(
+            div()
+                .child(Label::new(left_label).size(LabelSize::Small))
+                .mr_0p5(),
+        )
+        .on_click(left_on_click)
+        .tooltip(tooltip);
 
-            Self { left, right }
-        }
-    }
+        let right = render_git_action_menu(
+            ElementId::Name(format!("split-button-right-{}", id).into()),
+            keybinding_target,
+        )
+        .into_any_element();
 
-    impl RenderOnce for SplitButton {
-        fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
-            h_flex()
-                .rounded_sm()
-                .border_1()
-                .border_color(cx.theme().colors().text_muted.alpha(0.12))
-                .child(div().flex_grow().child(self.left))
-                .child(
-                    div()
-                        .h_full()
-                        .w_px()
-                        .bg(cx.theme().colors().text_muted.alpha(0.16)),
-                )
-                .child(self.right)
-                .bg(ElevationIndex::Surface.on_elevation_bg(cx))
-                .shadow(smallvec::smallvec![BoxShadow {
-                    color: hsla(0.0, 0.0, 0.0, 0.16),
-                    offset: point(px(0.), px(1.)),
-                    blur_radius: px(0.),
-                    spread_radius: px(0.),
-                }])
-        }
+        SplitButton { left, right }
     }
 }
 

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

@@ -2,9 +2,11 @@ mod button;
 mod button_icon;
 mod button_like;
 mod icon_button;
+mod split_button;
 mod toggle_button;
 
 pub use button::*;
 pub use button_like::*;
 pub use icon_button::*;
+pub use split_button::*;
 pub use toggle_button::*;

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

@@ -0,0 +1,45 @@
+use gpui::{
+    div, hsla, point, px, AnyElement, App, BoxShadow, IntoElement, ParentElement, RenderOnce,
+    Styled, Window,
+};
+use theme::ActiveTheme;
+
+use crate::{h_flex, ElevationIndex};
+
+use super::ButtonLike;
+
+/// /// 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
+/// any element (typically a dropdown trigger or similar).
+///
+/// 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,
+}
+
+impl RenderOnce for SplitButton {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        h_flex()
+            .rounded_sm()
+            .border_1()
+            .border_color(cx.theme().colors().text_muted.alpha(0.12))
+            .child(div().flex_grow().child(self.left))
+            .child(
+                div()
+                    .h_full()
+                    .w_px()
+                    .bg(cx.theme().colors().text_muted.alpha(0.16)),
+            )
+            .child(self.right)
+            .bg(ElevationIndex::Surface.on_elevation_bg(cx))
+            .shadow(smallvec::smallvec![BoxShadow {
+                color: hsla(0.0, 0.0, 0.0, 0.16),
+                offset: point(px(0.), px(1.)),
+                blur_radius: px(0.),
+                spread_radius: px(0.),
+            }])
+    }
+}