debugger: Add UI for tweaking breakpoint properties directly from breakpoint list (#33097)

Piotr Osiewicz created

Release Notes:

- debugger: Breakpoint properties (log/hit condition/condition) can now
be set directly from breakpoint list.

Change summary

Cargo.lock                                                |   1 
assets/icons/arrow_down10.svg                             |   1 
assets/icons/scroll_text.svg                              |   1 
assets/icons/split_alt.svg                                |   1 
assets/keymaps/default-linux.json                         |   4 
assets/keymaps/default-macos.json                         |   4 
crates/debugger_ui/Cargo.toml                             |   1 
crates/debugger_ui/src/debugger_panel.rs                  |   8 
crates/debugger_ui/src/session/running.rs                 |   9 
crates/debugger_ui/src/session/running/breakpoint_list.rs | 683 +++++++-
crates/icons/src/icons.rs                                 |   3 
11 files changed, 634 insertions(+), 82 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -4310,6 +4310,7 @@ version = "0.1.0"
 dependencies = [
  "alacritty_terminal",
  "anyhow",
+ "bitflags 2.9.0",
  "client",
  "collections",
  "command_palette_hooks",

assets/icons/arrow_down10.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down10-icon lucide-arrow-down-1-0"><path d="m3 16 4 4 4-4"/><path d="M7 20V4"/><path d="M17 10V4h-2"/><path d="M15 10h4"/><rect x="15" y="14" width="4" height="6" ry="2"/></svg>

assets/icons/scroll_text.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-scroll-text-icon lucide-scroll-text"><path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/></svg>

assets/icons/split_alt.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-split-icon lucide-split"><path d="M16 3h5v5"/><path d="M8 3H3v5"/><path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3"/><path d="m15 9 6-6"/></svg>

assets/keymaps/default-linux.json 🔗

@@ -919,7 +919,9 @@
     "context": "BreakpointList",
     "bindings": {
       "space": "debugger::ToggleEnableBreakpoint",
-      "backspace": "debugger::UnsetBreakpoint"
+      "backspace": "debugger::UnsetBreakpoint",
+      "left": "debugger::PreviousBreakpointProperty",
+      "right": "debugger::NextBreakpointProperty"
     }
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -980,7 +980,9 @@
     "context": "BreakpointList",
     "bindings": {
       "space": "debugger::ToggleEnableBreakpoint",
-      "backspace": "debugger::UnsetBreakpoint"
+      "backspace": "debugger::UnsetBreakpoint",
+      "left": "debugger::PreviousBreakpointProperty",
+      "right": "debugger::NextBreakpointProperty"
     }
   },
   {

crates/debugger_ui/Cargo.toml 🔗

@@ -28,6 +28,7 @@ test-support = [
 [dependencies]
 alacritty_terminal.workspace = true
 anyhow.workspace = true
+bitflags.workspace = true
 client.workspace = true
 collections.workspace = true
 command_palette_hooks.workspace = true

crates/debugger_ui/src/debugger_panel.rs 🔗

@@ -100,7 +100,13 @@ impl DebugPanel {
                 sessions: vec![],
                 active_session: None,
                 focus_handle,
-                breakpoint_list: BreakpointList::new(None, workspace.weak_handle(), &project, cx),
+                breakpoint_list: BreakpointList::new(
+                    None,
+                    workspace.weak_handle(),
+                    &project,
+                    window,
+                    cx,
+                ),
                 project,
                 workspace: workspace.weak_handle(),
                 context_menu: None,

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

@@ -697,8 +697,13 @@ impl RunningState {
             )
         });
 
-        let breakpoint_list =
-            BreakpointList::new(Some(session.clone()), workspace.clone(), &project, cx);
+        let breakpoint_list = BreakpointList::new(
+            Some(session.clone()),
+            workspace.clone(),
+            &project,
+            window,
+            cx,
+        );
 
         let _subscriptions = vec![
             cx.on_app_quit(move |this, cx| {

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

@@ -5,11 +5,11 @@ use std::{
     time::Duration,
 };
 
-use dap::ExceptionBreakpointsFilter;
+use dap::{Capabilities, ExceptionBreakpointsFilter};
 use editor::Editor;
 use gpui::{
-    Action, AppContext, Entity, FocusHandle, Focusable, MouseButton, ScrollStrategy, Stateful,
-    Task, UniformListScrollHandle, WeakEntity, uniform_list,
+    Action, AppContext, ClickEvent, Entity, FocusHandle, Focusable, MouseButton, ScrollStrategy,
+    Stateful, Task, UniformListScrollHandle, WeakEntity, actions, uniform_list,
 };
 use language::Point;
 use project::{
@@ -21,16 +21,20 @@ use project::{
     worktree_store::WorktreeStore,
 };
 use ui::{
-    AnyElement, App, ButtonCommon, Clickable, Color, Context, Disableable, Div, FluentBuilder as _,
-    Icon, IconButton, IconName, IconSize, Indicator, InteractiveElement, IntoElement, Label,
-    LabelCommon, LabelSize, ListItem, ParentElement, Render, Scrollbar, ScrollbarState,
-    SharedString, StatefulInteractiveElement, Styled, Toggleable, Tooltip, Window, div, h_flex, px,
-    v_flex,
+    ActiveTheme, AnyElement, App, ButtonCommon, Clickable, Color, Context, Disableable, Div,
+    Divider, FluentBuilder as _, Icon, IconButton, IconName, IconSize, Indicator,
+    InteractiveElement, IntoElement, Label, LabelCommon, LabelSize, ListItem, ParentElement,
+    Render, RenderOnce, Scrollbar, ScrollbarState, SharedString, StatefulInteractiveElement,
+    Styled, Toggleable, Tooltip, Window, div, h_flex, px, v_flex,
 };
 use util::ResultExt;
 use workspace::Workspace;
 use zed_actions::{ToggleEnableBreakpoint, UnsetBreakpoint};
 
+actions!(
+    debugger,
+    [PreviousBreakpointProperty, NextBreakpointProperty]
+);
 #[derive(Clone, Copy, PartialEq)]
 pub(crate) enum SelectedBreakpointKind {
     Source,
@@ -48,6 +52,8 @@ pub(crate) struct BreakpointList {
     focus_handle: FocusHandle,
     scroll_handle: UniformListScrollHandle,
     selected_ix: Option<usize>,
+    input: Entity<Editor>,
+    strip_mode: Option<ActiveBreakpointStripMode>,
 }
 
 impl Focusable for BreakpointList {
@@ -56,11 +62,19 @@ impl Focusable for BreakpointList {
     }
 }
 
+#[derive(Clone, Copy, PartialEq)]
+enum ActiveBreakpointStripMode {
+    Log,
+    Condition,
+    HitCondition,
+}
+
 impl BreakpointList {
     pub(crate) fn new(
         session: Option<Entity<Session>>,
         workspace: WeakEntity<Workspace>,
         project: &Entity<Project>,
+        window: &mut Window,
         cx: &mut App,
     ) -> Entity<Self> {
         let project = project.read(cx);
@@ -70,7 +84,7 @@ impl BreakpointList {
         let scroll_handle = UniformListScrollHandle::new();
         let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
 
-        cx.new(|_| Self {
+        cx.new(|cx| Self {
             breakpoint_store,
             worktree_store,
             scrollbar_state,
@@ -82,17 +96,28 @@ impl BreakpointList {
             focus_handle,
             scroll_handle,
             selected_ix: None,
+            input: cx.new(|cx| Editor::single_line(window, cx)),
+            strip_mode: None,
         })
     }
 
     fn edit_line_breakpoint(
-        &mut self,
+        &self,
         path: Arc<Path>,
         row: u32,
         action: BreakpointEditAction,
-        cx: &mut Context<Self>,
+        cx: &mut App,
+    ) {
+        Self::edit_line_breakpoint_inner(&self.breakpoint_store, path, row, action, cx);
+    }
+    fn edit_line_breakpoint_inner(
+        breakpoint_store: &Entity<BreakpointStore>,
+        path: Arc<Path>,
+        row: u32,
+        action: BreakpointEditAction,
+        cx: &mut App,
     ) {
-        self.breakpoint_store.update(cx, |breakpoint_store, cx| {
+        breakpoint_store.update(cx, |breakpoint_store, cx| {
             if let Some((buffer, breakpoint)) = breakpoint_store.breakpoint_at_row(&path, row, cx) {
                 breakpoint_store.toggle_breakpoint(buffer, breakpoint, action, cx);
             } else {
@@ -148,16 +173,63 @@ impl BreakpointList {
         })
     }
 
-    fn select_ix(&mut self, ix: Option<usize>, cx: &mut Context<Self>) {
+    fn set_active_breakpoint_property(
+        &mut self,
+        prop: ActiveBreakpointStripMode,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        self.strip_mode = Some(prop);
+        let placeholder = match prop {
+            ActiveBreakpointStripMode::Log => "Set Log Message",
+            ActiveBreakpointStripMode::Condition => "Set Condition",
+            ActiveBreakpointStripMode::HitCondition => "Set Hit Condition",
+        };
+        let mut is_exception_breakpoint = true;
+        let active_value = self.selected_ix.and_then(|ix| {
+            self.breakpoints.get(ix).and_then(|bp| {
+                if let BreakpointEntryKind::LineBreakpoint(bp) = &bp.kind {
+                    is_exception_breakpoint = false;
+                    match prop {
+                        ActiveBreakpointStripMode::Log => bp.breakpoint.message.clone(),
+                        ActiveBreakpointStripMode::Condition => bp.breakpoint.condition.clone(),
+                        ActiveBreakpointStripMode::HitCondition => {
+                            bp.breakpoint.hit_condition.clone()
+                        }
+                    }
+                } else {
+                    None
+                }
+            })
+        });
+
+        self.input.update(cx, |this, cx| {
+            this.set_placeholder_text(placeholder, cx);
+            this.set_read_only(is_exception_breakpoint);
+            this.set_text(active_value.as_deref().unwrap_or(""), window, cx);
+        });
+    }
+
+    fn select_ix(&mut self, ix: Option<usize>, window: &mut Window, cx: &mut Context<Self>) {
         self.selected_ix = ix;
         if let Some(ix) = ix {
             self.scroll_handle
                 .scroll_to_item(ix, ScrollStrategy::Center);
         }
+        if let Some(mode) = self.strip_mode {
+            self.set_active_breakpoint_property(mode, window, cx);
+        }
+
         cx.notify();
     }
 
-    fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
+    fn select_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
+        if self.strip_mode.is_some() {
+            if self.input.focus_handle(cx).contains_focused(window, cx) {
+                cx.propagate();
+                return;
+            }
+        }
         let ix = match self.selected_ix {
             _ if self.breakpoints.len() == 0 => None,
             None => Some(0),
@@ -169,15 +241,21 @@ impl BreakpointList {
                 }
             }
         };
-        self.select_ix(ix, cx);
+        self.select_ix(ix, window, cx);
     }
 
     fn select_previous(
         &mut self,
         _: &menu::SelectPrevious,
-        _window: &mut Window,
+        window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        if self.strip_mode.is_some() {
+            if self.input.focus_handle(cx).contains_focused(window, cx) {
+                cx.propagate();
+                return;
+            }
+        }
         let ix = match self.selected_ix {
             _ if self.breakpoints.len() == 0 => None,
             None => Some(self.breakpoints.len() - 1),
@@ -189,37 +267,105 @@ impl BreakpointList {
                 }
             }
         };
-        self.select_ix(ix, cx);
+        self.select_ix(ix, window, cx);
     }
 
-    fn select_first(
-        &mut self,
-        _: &menu::SelectFirst,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
+    fn select_first(&mut self, _: &menu::SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
+        if self.strip_mode.is_some() {
+            if self.input.focus_handle(cx).contains_focused(window, cx) {
+                cx.propagate();
+                return;
+            }
+        }
         let ix = if self.breakpoints.len() > 0 {
             Some(0)
         } else {
             None
         };
-        self.select_ix(ix, cx);
+        self.select_ix(ix, window, cx);
     }
 
-    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+    fn select_last(&mut self, _: &menu::SelectLast, window: &mut Window, cx: &mut Context<Self>) {
+        if self.strip_mode.is_some() {
+            if self.input.focus_handle(cx).contains_focused(window, cx) {
+                cx.propagate();
+                return;
+            }
+        }
         let ix = if self.breakpoints.len() > 0 {
             Some(self.breakpoints.len() - 1)
         } else {
             None
         };
-        self.select_ix(ix, cx);
+        self.select_ix(ix, window, cx);
     }
 
+    fn dismiss(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
+        if self.input.focus_handle(cx).contains_focused(window, cx) {
+            self.focus_handle.focus(window);
+        } else if self.strip_mode.is_some() {
+            self.strip_mode.take();
+            cx.notify();
+        } else {
+            cx.propagate();
+        }
+    }
     fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
         let Some(entry) = self.selected_ix.and_then(|ix| self.breakpoints.get_mut(ix)) else {
             return;
         };
 
+        if let Some(mode) = self.strip_mode {
+            let handle = self.input.focus_handle(cx);
+            if handle.is_focused(window) {
+                // Go back to the main strip. Save the result as well.
+                let text = self.input.read(cx).text(cx);
+
+                match mode {
+                    ActiveBreakpointStripMode::Log => match &entry.kind {
+                        BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
+                            Self::edit_line_breakpoint_inner(
+                                &self.breakpoint_store,
+                                line_breakpoint.breakpoint.path.clone(),
+                                line_breakpoint.breakpoint.row,
+                                BreakpointEditAction::EditLogMessage(Arc::from(text)),
+                                cx,
+                            );
+                        }
+                        _ => {}
+                    },
+                    ActiveBreakpointStripMode::Condition => match &entry.kind {
+                        BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
+                            Self::edit_line_breakpoint_inner(
+                                &self.breakpoint_store,
+                                line_breakpoint.breakpoint.path.clone(),
+                                line_breakpoint.breakpoint.row,
+                                BreakpointEditAction::EditCondition(Arc::from(text)),
+                                cx,
+                            );
+                        }
+                        _ => {}
+                    },
+                    ActiveBreakpointStripMode::HitCondition => match &entry.kind {
+                        BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
+                            Self::edit_line_breakpoint_inner(
+                                &self.breakpoint_store,
+                                line_breakpoint.breakpoint.path.clone(),
+                                line_breakpoint.breakpoint.row,
+                                BreakpointEditAction::EditHitCondition(Arc::from(text)),
+                                cx,
+                            );
+                        }
+                        _ => {}
+                    },
+                }
+                self.focus_handle.focus(window);
+            } else {
+                handle.focus(window);
+            }
+
+            return;
+        }
         match &mut entry.kind {
             BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
                 let path = line_breakpoint.breakpoint.path.clone();
@@ -233,12 +379,18 @@ impl BreakpointList {
     fn toggle_enable_breakpoint(
         &mut self,
         _: &ToggleEnableBreakpoint,
-        _window: &mut Window,
+        window: &mut Window,
         cx: &mut Context<Self>,
     ) {
         let Some(entry) = self.selected_ix.and_then(|ix| self.breakpoints.get_mut(ix)) else {
             return;
         };
+        if self.strip_mode.is_some() {
+            if self.input.focus_handle(cx).contains_focused(window, cx) {
+                cx.propagate();
+                return;
+            }
+        }
 
         match &mut entry.kind {
             BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
@@ -279,6 +431,50 @@ impl BreakpointList {
         cx.notify();
     }
 
+    fn previous_breakpoint_property(
+        &mut self,
+        _: &PreviousBreakpointProperty,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let next_mode = match self.strip_mode {
+            Some(ActiveBreakpointStripMode::Log) => None,
+            Some(ActiveBreakpointStripMode::Condition) => Some(ActiveBreakpointStripMode::Log),
+            Some(ActiveBreakpointStripMode::HitCondition) => {
+                Some(ActiveBreakpointStripMode::Condition)
+            }
+            None => Some(ActiveBreakpointStripMode::HitCondition),
+        };
+        if let Some(mode) = next_mode {
+            self.set_active_breakpoint_property(mode, window, cx);
+        } else {
+            self.strip_mode.take();
+        }
+
+        cx.notify();
+    }
+    fn next_breakpoint_property(
+        &mut self,
+        _: &NextBreakpointProperty,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let next_mode = match self.strip_mode {
+            Some(ActiveBreakpointStripMode::Log) => Some(ActiveBreakpointStripMode::Condition),
+            Some(ActiveBreakpointStripMode::Condition) => {
+                Some(ActiveBreakpointStripMode::HitCondition)
+            }
+            Some(ActiveBreakpointStripMode::HitCondition) => None,
+            None => Some(ActiveBreakpointStripMode::Log),
+        };
+        if let Some(mode) = next_mode {
+            self.set_active_breakpoint_property(mode, window, cx);
+        } else {
+            self.strip_mode.take();
+        }
+        cx.notify();
+    }
+
     fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
         self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| {
@@ -294,20 +490,31 @@ impl BreakpointList {
         }))
     }
 
-    fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+    fn render_list(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
         let selected_ix = self.selected_ix;
         let focus_handle = self.focus_handle.clone();
+        let supported_breakpoint_properties = self
+            .session
+            .as_ref()
+            .map(|session| SupportedBreakpointProperties::from(session.read(cx).capabilities()))
+            .unwrap_or_else(SupportedBreakpointProperties::empty);
+        let strip_mode = self.strip_mode;
         uniform_list(
             "breakpoint-list",
             self.breakpoints.len(),
-            cx.processor(move |this, range: Range<usize>, window, cx| {
+            cx.processor(move |this, range: Range<usize>, _, _| {
                 range
                     .clone()
                     .zip(&mut this.breakpoints[range])
                     .map(|(ix, breakpoint)| {
                         breakpoint
-                            .render(ix, focus_handle.clone(), window, cx)
-                            .toggle_state(Some(ix) == selected_ix)
+                            .render(
+                                strip_mode,
+                                supported_breakpoint_properties,
+                                ix,
+                                Some(ix) == selected_ix,
+                                focus_handle.clone(),
+                            )
                             .into_any_element()
                     })
                     .collect()
@@ -443,7 +650,6 @@ impl BreakpointList {
 
 impl Render for BreakpointList {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
-        // let old_len = self.breakpoints.len();
         let breakpoints = self.breakpoint_store.read(cx).all_source_breakpoints(cx);
         self.breakpoints.clear();
         let weak = cx.weak_entity();
@@ -523,15 +729,46 @@ impl Render for BreakpointList {
             .on_action(cx.listener(Self::select_previous))
             .on_action(cx.listener(Self::select_first))
             .on_action(cx.listener(Self::select_last))
+            .on_action(cx.listener(Self::dismiss))
             .on_action(cx.listener(Self::confirm))
             .on_action(cx.listener(Self::toggle_enable_breakpoint))
             .on_action(cx.listener(Self::unset_breakpoint))
+            .on_action(cx.listener(Self::next_breakpoint_property))
+            .on_action(cx.listener(Self::previous_breakpoint_property))
             .size_full()
             .m_0p5()
-            .child(self.render_list(window, cx))
-            .children(self.render_vertical_scrollbar(cx))
+            .child(
+                v_flex()
+                    .size_full()
+                    .child(self.render_list(cx))
+                    .children(self.render_vertical_scrollbar(cx)),
+            )
+            .when_some(self.strip_mode, |this, _| {
+                this.child(Divider::horizontal()).child(
+                    h_flex()
+                        // .w_full()
+                        .m_0p5()
+                        .p_0p5()
+                        .border_1()
+                        .rounded_sm()
+                        .when(
+                            self.input.focus_handle(cx).contains_focused(window, cx),
+                            |this| {
+                                let colors = cx.theme().colors();
+                                let border = if self.input.read(cx).read_only(cx) {
+                                    colors.border_disabled
+                                } else {
+                                    colors.border_focused
+                                };
+                                this.border_color(border)
+                            },
+                        )
+                        .child(self.input.clone()),
+                )
+            })
     }
 }
+
 #[derive(Clone, Debug)]
 struct LineBreakpoint {
     name: SharedString,
@@ -543,7 +780,10 @@ struct LineBreakpoint {
 impl LineBreakpoint {
     fn render(
         &mut self,
+        props: SupportedBreakpointProperties,
+        strip_mode: Option<ActiveBreakpointStripMode>,
         ix: usize,
+        is_selected: bool,
         focus_handle: FocusHandle,
         weak: WeakEntity<BreakpointList>,
     ) -> ListItem {
@@ -594,15 +834,16 @@ impl LineBreakpoint {
             })
             .child(Indicator::icon(Icon::new(icon_name)).color(Color::Debugger))
             .on_mouse_down(MouseButton::Left, move |_, _, _| {});
+
         ListItem::new(SharedString::from(format!(
             "breakpoint-ui-item-{:?}/{}:{}",
             self.dir, self.name, self.line
         )))
         .on_click({
             let weak = weak.clone();
-            move |_, _, cx| {
+            move |_, window, cx| {
                 weak.update(cx, |breakpoint_list, cx| {
-                    breakpoint_list.select_ix(Some(ix), cx);
+                    breakpoint_list.select_ix(Some(ix), window, cx);
                 })
                 .ok();
             }
@@ -613,21 +854,26 @@ impl LineBreakpoint {
             cx.stop_propagation();
         })
         .child(
-            v_flex()
-                .py_1()
+            h_flex()
+                .w_full()
+                .mr_4()
+                .py_0p5()
                 .gap_1()
                 .min_h(px(26.))
-                .justify_center()
+                .justify_between()
                 .id(SharedString::from(format!(
                     "breakpoint-ui-on-click-go-to-line-{:?}/{}:{}",
                     self.dir, self.name, self.line
                 )))
-                .on_click(move |_, window, cx| {
-                    weak.update(cx, |breakpoint_list, cx| {
-                        breakpoint_list.select_ix(Some(ix), cx);
-                        breakpoint_list.go_to_line_breakpoint(path.clone(), row, window, cx);
-                    })
-                    .ok();
+                .on_click({
+                    let weak = weak.clone();
+                    move |_, window, cx| {
+                        weak.update(cx, |breakpoint_list, cx| {
+                            breakpoint_list.select_ix(Some(ix), window, cx);
+                            breakpoint_list.go_to_line_breakpoint(path.clone(), row, window, cx);
+                        })
+                        .ok();
+                    }
                 })
                 .cursor_pointer()
                 .child(
@@ -644,8 +890,20 @@ impl LineBreakpoint {
                                 .size(LabelSize::Small)
                                 .line_height_style(ui::LineHeightStyle::UiLabel)
                         })),
-                ),
+                )
+                .child(BreakpointOptionsStrip {
+                    props,
+                    breakpoint: BreakpointEntry {
+                        kind: BreakpointEntryKind::LineBreakpoint(self.clone()),
+                        weak: weak,
+                    },
+                    is_selected,
+                    focus_handle,
+                    strip_mode,
+                    index: ix,
+                }),
         )
+        .toggle_state(is_selected)
     }
 }
 #[derive(Clone, Debug)]
@@ -658,7 +916,10 @@ struct ExceptionBreakpoint {
 impl ExceptionBreakpoint {
     fn render(
         &mut self,
+        props: SupportedBreakpointProperties,
+        strip_mode: Option<ActiveBreakpointStripMode>,
         ix: usize,
+        is_selected: bool,
         focus_handle: FocusHandle,
         list: WeakEntity<BreakpointList>,
     ) -> ListItem {
@@ -669,15 +930,15 @@ impl ExceptionBreakpoint {
         };
         let id = SharedString::from(&self.id);
         let is_enabled = self.is_enabled;
-
+        let weak = list.clone();
         ListItem::new(SharedString::from(format!(
             "exception-breakpoint-ui-item-{}",
             self.id
         )))
         .on_click({
             let list = list.clone();
-            move |_, _, cx| {
-                list.update(cx, |list, cx| list.select_ix(Some(ix), cx))
+            move |_, window, cx| {
+                list.update(cx, |list, cx| list.select_ix(Some(ix), window, cx))
                     .ok();
             }
         })
@@ -691,18 +952,21 @@ impl ExceptionBreakpoint {
                     "exception-breakpoint-ui-item-{}-click-handler",
                     self.id
                 )))
-                .tooltip(move |window, cx| {
-                    Tooltip::for_action_in(
-                        if is_enabled {
-                            "Disable Exception Breakpoint"
-                        } else {
-                            "Enable Exception Breakpoint"
-                        },
-                        &ToggleEnableBreakpoint,
-                        &focus_handle,
-                        window,
-                        cx,
-                    )
+                .tooltip({
+                    let focus_handle = focus_handle.clone();
+                    move |window, cx| {
+                        Tooltip::for_action_in(
+                            if is_enabled {
+                                "Disable Exception Breakpoint"
+                            } else {
+                                "Enable Exception Breakpoint"
+                            },
+                            &ToggleEnableBreakpoint,
+                            &focus_handle,
+                            window,
+                            cx,
+                        )
+                    }
                 })
                 .on_click({
                     let list = list.clone();
@@ -722,21 +986,40 @@ impl ExceptionBreakpoint {
                 .child(Indicator::icon(Icon::new(IconName::Flame)).color(color)),
         )
         .child(
-            v_flex()
-                .py_1()
-                .gap_1()
-                .min_h(px(26.))
-                .justify_center()
-                .id(("exception-breakpoint-label", ix))
+            h_flex()
+                .w_full()
+                .mr_4()
+                .py_0p5()
+                .justify_between()
                 .child(
-                    Label::new(self.data.label.clone())
-                        .size(LabelSize::Small)
-                        .line_height_style(ui::LineHeightStyle::UiLabel),
+                    v_flex()
+                        .py_1()
+                        .gap_1()
+                        .min_h(px(26.))
+                        .justify_center()
+                        .id(("exception-breakpoint-label", ix))
+                        .child(
+                            Label::new(self.data.label.clone())
+                                .size(LabelSize::Small)
+                                .line_height_style(ui::LineHeightStyle::UiLabel),
+                        )
+                        .when_some(self.data.description.clone(), |el, description| {
+                            el.tooltip(Tooltip::text(description))
+                        }),
                 )
-                .when_some(self.data.description.clone(), |el, description| {
-                    el.tooltip(Tooltip::text(description))
+                .child(BreakpointOptionsStrip {
+                    props,
+                    breakpoint: BreakpointEntry {
+                        kind: BreakpointEntryKind::ExceptionBreakpoint(self.clone()),
+                        weak: weak,
+                    },
+                    is_selected,
+                    focus_handle,
+                    strip_mode,
+                    index: ix,
                 }),
         )
+        .toggle_state(is_selected)
     }
 }
 #[derive(Clone, Debug)]
@@ -754,18 +1037,264 @@ struct BreakpointEntry {
 impl BreakpointEntry {
     fn render(
         &mut self,
+        strip_mode: Option<ActiveBreakpointStripMode>,
+        props: SupportedBreakpointProperties,
         ix: usize,
+        is_selected: bool,
         focus_handle: FocusHandle,
-        _: &mut Window,
-        _: &mut App,
     ) -> ListItem {
         match &mut self.kind {
+            BreakpointEntryKind::LineBreakpoint(line_breakpoint) => line_breakpoint.render(
+                props,
+                strip_mode,
+                ix,
+                is_selected,
+                focus_handle,
+                self.weak.clone(),
+            ),
+            BreakpointEntryKind::ExceptionBreakpoint(exception_breakpoint) => exception_breakpoint
+                .render(
+                    props.for_exception_breakpoints(),
+                    strip_mode,
+                    ix,
+                    is_selected,
+                    focus_handle,
+                    self.weak.clone(),
+                ),
+        }
+    }
+
+    fn id(&self) -> SharedString {
+        match &self.kind {
+            BreakpointEntryKind::LineBreakpoint(line_breakpoint) => format!(
+                "source-breakpoint-control-strip-{:?}:{}",
+                line_breakpoint.breakpoint.path, line_breakpoint.breakpoint.row
+            )
+            .into(),
+            BreakpointEntryKind::ExceptionBreakpoint(exception_breakpoint) => format!(
+                "exception-breakpoint-control-strip--{}",
+                exception_breakpoint.id
+            )
+            .into(),
+        }
+    }
+
+    fn has_log(&self) -> bool {
+        match &self.kind {
             BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
-                line_breakpoint.render(ix, focus_handle, self.weak.clone())
+                line_breakpoint.breakpoint.message.is_some()
             }
-            BreakpointEntryKind::ExceptionBreakpoint(exception_breakpoint) => {
-                exception_breakpoint.render(ix, focus_handle, self.weak.clone())
+            _ => false,
+        }
+    }
+
+    fn has_condition(&self) -> bool {
+        match &self.kind {
+            BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
+                line_breakpoint.breakpoint.condition.is_some()
+            }
+            // We don't support conditions on exception breakpoints
+            BreakpointEntryKind::ExceptionBreakpoint(_) => false,
+        }
+    }
+
+    fn has_hit_condition(&self) -> bool {
+        match &self.kind {
+            BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
+                line_breakpoint.breakpoint.hit_condition.is_some()
+            }
+            _ => false,
+        }
+    }
+}
+bitflags::bitflags! {
+    #[derive(Clone, Copy)]
+    pub struct SupportedBreakpointProperties: u32 {
+        const LOG = 1 << 0;
+        const CONDITION = 1 << 1;
+        const HIT_CONDITION = 1 << 2;
+        // Conditions for exceptions can be set only when exception filters are supported.
+        const EXCEPTION_FILTER_OPTIONS = 1 << 3;
+    }
+}
+
+impl From<&Capabilities> for SupportedBreakpointProperties {
+    fn from(caps: &Capabilities) -> Self {
+        let mut this = Self::empty();
+        for (prop, offset) in [
+            (caps.supports_log_points, Self::LOG),
+            (caps.supports_conditional_breakpoints, Self::CONDITION),
+            (
+                caps.supports_hit_conditional_breakpoints,
+                Self::HIT_CONDITION,
+            ),
+            (
+                caps.supports_exception_options,
+                Self::EXCEPTION_FILTER_OPTIONS,
+            ),
+        ] {
+            if prop.unwrap_or_default() {
+                this.insert(offset);
             }
         }
+        this
+    }
+}
+
+impl SupportedBreakpointProperties {
+    fn for_exception_breakpoints(self) -> Self {
+        // TODO: we don't yet support conditions for exception breakpoints at the data layer, hence all props are disabled here.
+        Self::empty()
+    }
+}
+#[derive(IntoElement)]
+struct BreakpointOptionsStrip {
+    props: SupportedBreakpointProperties,
+    breakpoint: BreakpointEntry,
+    is_selected: bool,
+    focus_handle: FocusHandle,
+    strip_mode: Option<ActiveBreakpointStripMode>,
+    index: usize,
+}
+
+impl BreakpointOptionsStrip {
+    fn is_toggled(&self, expected_mode: ActiveBreakpointStripMode) -> bool {
+        self.is_selected && self.strip_mode == Some(expected_mode)
+    }
+    fn on_click_callback(
+        &self,
+        mode: ActiveBreakpointStripMode,
+    ) -> impl for<'a> Fn(&ClickEvent, &mut Window, &'a mut App) + use<> {
+        let list = self.breakpoint.weak.clone();
+        let ix = self.index;
+        move |_, window, cx| {
+            list.update(cx, |this, cx| {
+                if this.strip_mode != Some(mode) {
+                    this.set_active_breakpoint_property(mode, window, cx);
+                } else if this.selected_ix == Some(ix) {
+                    this.strip_mode.take();
+                } else {
+                    cx.propagate();
+                }
+            })
+            .ok();
+        }
+    }
+    fn add_border(
+        &self,
+        kind: ActiveBreakpointStripMode,
+        available: bool,
+        window: &Window,
+        cx: &App,
+    ) -> impl Fn(Div) -> Div {
+        move |this: Div| {
+            // Avoid layout shifts in case there's no colored border
+            let this = this.border_2().rounded_sm();
+            if self.is_selected && self.strip_mode == Some(kind) {
+                let theme = cx.theme().colors();
+                if self.focus_handle.is_focused(window) {
+                    this.border_color(theme.border_selected)
+                } else {
+                    this.border_color(theme.border_disabled)
+                }
+            } else if !available {
+                this.border_color(cx.theme().colors().border_disabled)
+            } else {
+                this
+            }
+        }
+    }
+}
+impl RenderOnce for BreakpointOptionsStrip {
+    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let id = self.breakpoint.id();
+        let supports_logs = self.props.contains(SupportedBreakpointProperties::LOG);
+        let supports_condition = self
+            .props
+            .contains(SupportedBreakpointProperties::CONDITION);
+        let supports_hit_condition = self
+            .props
+            .contains(SupportedBreakpointProperties::HIT_CONDITION);
+        let has_logs = self.breakpoint.has_log();
+        let has_condition = self.breakpoint.has_condition();
+        let has_hit_condition = self.breakpoint.has_hit_condition();
+        let style_for_toggle = |mode, is_enabled| {
+            if is_enabled && self.strip_mode == Some(mode) && self.is_selected {
+                ui::ButtonStyle::Filled
+            } else {
+                ui::ButtonStyle::Subtle
+            }
+        };
+        let color_for_toggle = |is_enabled| {
+            if is_enabled {
+                ui::Color::Default
+            } else {
+                ui::Color::Muted
+            }
+        };
+
+        h_flex()
+            .gap_2()
+            .child(
+                div() .map(self.add_border(ActiveBreakpointStripMode::Log, supports_logs, window, cx))
+                    .child(
+                        IconButton::new(
+                            SharedString::from(format!("{id}-log-toggle")),
+                            IconName::ScrollText,
+                        )
+                        .style(style_for_toggle(ActiveBreakpointStripMode::Log, has_logs))
+                        .icon_color(color_for_toggle(has_logs))
+                        .disabled(!supports_logs)
+                        .toggle_state(self.is_toggled(ActiveBreakpointStripMode::Log))
+                        .on_click(self.on_click_callback(ActiveBreakpointStripMode::Log)).tooltip(|window, cx| Tooltip::with_meta("Set Log Message", None, "Set log message to display (instead of stopping) when a breakpoint is hit", window, cx))
+                    )
+                    .when(!has_logs && !self.is_selected, |this| this.invisible()),
+            )
+            .child(
+                div().map(self.add_border(
+                    ActiveBreakpointStripMode::Condition,
+                    supports_condition,
+                    window, cx
+                ))
+                    .child(
+                        IconButton::new(
+                            SharedString::from(format!("{id}-condition-toggle")),
+                            IconName::SplitAlt,
+                        )
+                        .style(style_for_toggle(
+                            ActiveBreakpointStripMode::Condition,
+                            has_condition
+                        ))
+                        .icon_color(color_for_toggle(has_condition))
+                        .disabled(!supports_condition)
+                        .toggle_state(self.is_toggled(ActiveBreakpointStripMode::Condition))
+                        .on_click(self.on_click_callback(ActiveBreakpointStripMode::Condition))
+                        .tooltip(|window, cx| Tooltip::with_meta("Set Condition", None, "Set condition to evaluate when a breakpoint is hit. Program execution will stop only when the condition is met", window, cx))
+                    )
+                    .when(!has_condition && !self.is_selected, |this| this.invisible()),
+            )
+            .child(
+                div()                  .map(self.add_border(
+                    ActiveBreakpointStripMode::HitCondition,
+                    supports_hit_condition,window, cx
+                ))
+                    .child(
+                        IconButton::new(
+                            SharedString::from(format!("{id}-hit-condition-toggle")),
+                            IconName::ArrowDown10,
+                        )
+                        .style(style_for_toggle(
+                            ActiveBreakpointStripMode::HitCondition,
+                            has_hit_condition,
+                        ))
+                        .icon_color(color_for_toggle(has_hit_condition))
+                        .disabled(!supports_hit_condition)
+                        .toggle_state(self.is_toggled(ActiveBreakpointStripMode::HitCondition))
+                        .on_click(self.on_click_callback(ActiveBreakpointStripMode::HitCondition)).tooltip(|window, cx| Tooltip::with_meta("Set Hit Condition", None, "Set expression that controls how many hits of the breakpoint are ignored.", window, cx))
+                    )
+                    .when(!has_hit_condition && !self.is_selected, |this| {
+                        this.invisible()
+                    }),
+            )
     }
 }

crates/icons/src/icons.rs 🔗

@@ -23,6 +23,7 @@ pub enum IconName {
     AiZed,
     ArrowCircle,
     ArrowDown,
+    ArrowDown10,
     ArrowDownFromLine,
     ArrowDownRight,
     ArrowLeft,
@@ -212,6 +213,7 @@ pub enum IconName {
     Save,
     Scissors,
     Screen,
+    ScrollText,
     SearchCode,
     SearchSelection,
     SelectAll,
@@ -231,6 +233,7 @@ pub enum IconName {
     SparkleFilled,
     Spinner,
     Split,
+    SplitAlt,
     SquareDot,
     SquareMinus,
     SquarePlus,