debugger: Add variable watchers (#32743)

Remco Smits , Anthony Eid , and Anthony created

### This PR introduces support for adding watchers to specific
expressions (such as variable names or evaluated expressions).

This feature is useful in scenarios where many variables are in scope,
but only a few are of interest—especially when tracking variables that
change frequently. By allowing users to add watchers, it becomes easier
to monitor the values of selected expressions across stack frames
without having to sift through a large list of variables.


https://github.com/user-attachments/assets/c49b470a-d912-4182-8419-7406ba4c8f1e

------

**TODO**:
- [x] make render variable code reusable for render watch method
- [x] use SharedString for watches because of a lot of cloning
- [x] add tests
  - [x] basic test
  - [x] test step debugging

Release Notes:

- Debugger Beta: Add support for variable watchers

---------

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

Change summary

assets/keymaps/default-linux.json                       |   8 
assets/keymaps/default-macos.json                       |   8 
crates/debugger_ui/src/session/running/console.rs       | 120 +
crates/debugger_ui/src/session/running/variable_list.rs | 643 +++++++++-
crates/debugger_ui/src/tests/variable_list.rs           | 519 ++++++++
crates/project/src/debugger/session.rs                  |  64 +
crates/ui/src/components/button/button_like.rs          |   4 
7 files changed, 1,243 insertions(+), 123 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -894,7 +894,10 @@
       "right": "variable_list::ExpandSelectedEntry",
       "enter": "variable_list::EditVariable",
       "ctrl-c": "variable_list::CopyVariableValue",
-      "ctrl-alt-c": "variable_list::CopyVariableName"
+      "ctrl-alt-c": "variable_list::CopyVariableName",
+      "delete": "variable_list::RemoveWatch",
+      "backspace": "variable_list::RemoveWatch",
+      "alt-enter": "variable_list::AddWatch"
     }
   },
   {
@@ -1037,7 +1040,8 @@
     "context": "DebugConsole > Editor",
     "use_key_equivalents": true,
     "bindings": {
-      "enter": "menu::Confirm"
+      "enter": "menu::Confirm",
+      "alt-enter": "console::WatchExpression"
     }
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -864,7 +864,10 @@
       "right": "variable_list::ExpandSelectedEntry",
       "enter": "variable_list::EditVariable",
       "cmd-c": "variable_list::CopyVariableValue",
-      "cmd-alt-c": "variable_list::CopyVariableName"
+      "cmd-alt-c": "variable_list::CopyVariableName",
+      "delete": "variable_list::RemoveWatch",
+      "backspace": "variable_list::RemoveWatch",
+      "alt-enter": "variable_list::AddWatch"
     }
   },
   {
@@ -1135,7 +1138,8 @@
     "context": "DebugConsole > Editor",
     "use_key_equivalents": true,
     "bindings": {
-      "enter": "menu::Confirm"
+      "enter": "menu::Confirm",
+      "alt-enter": "console::WatchExpression"
     }
   },
   {

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

@@ -9,8 +9,8 @@ use dap::OutputEvent;
 use editor::{Bias, CompletionProvider, Editor, EditorElement, EditorStyle, ExcerptId};
 use fuzzy::StringMatchCandidate;
 use gpui::{
-    Context, Entity, FocusHandle, Focusable, HighlightStyle, Hsla, Render, Subscription, Task,
-    TextStyle, WeakEntity,
+    Action as _, AppContext, Context, Corner, Entity, FocusHandle, Focusable, HighlightStyle, Hsla,
+    Render, Subscription, Task, TextStyle, WeakEntity, actions,
 };
 use language::{Buffer, CodeLabel, ToOffset};
 use menu::Confirm;
@@ -21,7 +21,9 @@ use project::{
 use settings::Settings;
 use std::{cell::RefCell, ops::Range, rc::Rc, usize};
 use theme::{Theme, ThemeSettings};
-use ui::{Divider, prelude::*};
+use ui::{ContextMenu, Divider, PopoverMenu, SplitButton, Tooltip, prelude::*};
+
+actions!(console, [WatchExpression]);
 
 pub struct Console {
     console: Entity<Editor>,
@@ -329,6 +331,40 @@ impl Console {
         });
     }
 
+    pub fn watch_expression(
+        &mut self,
+        _: &WatchExpression,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let expression = self.query_bar.update(cx, |editor, cx| {
+            let expression = editor.text(cx);
+            cx.defer_in(window, |editor, window, cx| {
+                editor.clear(window, cx);
+            });
+
+            expression
+        });
+
+        self.session.update(cx, |session, cx| {
+            session
+                .evaluate(
+                    expression.clone(),
+                    Some(dap::EvaluateArgumentsContext::Repl),
+                    self.stack_frame_list.read(cx).opened_stack_frame_id(),
+                    None,
+                    cx,
+                )
+                .detach();
+
+            if let Some(stack_frame_id) = self.stack_frame_list.read(cx).opened_stack_frame_id() {
+                session
+                    .add_watcher(expression.into(), stack_frame_id, cx)
+                    .detach();
+            }
+        });
+    }
+
     pub fn evaluate(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
         let expression = self.query_bar.update(cx, |editor, cx| {
             let expression = editor.text(cx);
@@ -352,6 +388,43 @@ impl Console {
         });
     }
 
+    fn render_submit_menu(
+        &self,
+        id: impl Into<ElementId>,
+        keybinding_target: Option<FocusHandle>,
+        cx: &App,
+    ) -> impl IntoElement {
+        PopoverMenu::new(id.into())
+            .trigger(
+                ui::ButtonLike::new_rounded_right("console-confirm-split-button-right")
+                    .layer(ui::ElevationIndex::ModalSurface)
+                    .size(ui::ButtonSize::None)
+                    .child(
+                        div()
+                            .px_1()
+                            .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
+                    ),
+            )
+            .when(
+                self.stack_frame_list
+                    .read(cx)
+                    .opened_stack_frame_id()
+                    .is_some(),
+                |this| {
+                    this.menu(move |window, cx| {
+                        Some(ContextMenu::build(window, cx, |context_menu, _, _| {
+                            context_menu
+                                .when_some(keybinding_target.clone(), |el, keybinding_target| {
+                                    el.context(keybinding_target.clone())
+                                })
+                                .action("Watch expression", WatchExpression.boxed_clone())
+                        }))
+                    })
+                },
+            )
+            .anchor(Corner::TopRight)
+    }
+
     fn render_console(&self, cx: &Context<Self>) -> impl IntoElement {
         EditorElement::new(&self.console, Self::editor_style(&self.console, cx))
     }
@@ -408,15 +481,52 @@ impl Console {
 
 impl Render for Console {
     fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let query_focus_handle = self.query_bar.focus_handle(cx);
+
         v_flex()
             .track_focus(&self.focus_handle)
             .key_context("DebugConsole")
             .on_action(cx.listener(Self::evaluate))
+            .on_action(cx.listener(Self::watch_expression))
             .size_full()
             .child(self.render_console(cx))
             .when(self.is_running(cx), |this| {
-                this.child(Divider::horizontal())
-                    .child(self.render_query_bar(cx))
+                this.child(Divider::horizontal()).child(
+                    h_flex()
+                        .gap_1()
+                        .bg(cx.theme().colors().editor_background)
+                        .child(self.render_query_bar(cx))
+                        .child(SplitButton::new(
+                            ui::ButtonLike::new_rounded_all(ElementId::Name(
+                                "split-button-left-confirm-button".into(),
+                            ))
+                            .on_click(move |_, window, cx| {
+                                window.dispatch_action(Box::new(Confirm), cx)
+                            })
+                            .tooltip({
+                                let query_focus_handle = query_focus_handle.clone();
+
+                                move |window, cx| {
+                                    Tooltip::for_action_in(
+                                        "Evaluate",
+                                        &Confirm,
+                                        &query_focus_handle,
+                                        window,
+                                        cx,
+                                    )
+                                }
+                            })
+                            .layer(ui::ElevationIndex::ModalSurface)
+                            .size(ui::ButtonSize::Compact)
+                            .child(Label::new("Evaluate")),
+                            self.render_submit_menu(
+                                ElementId::Name("split-button-right-confirm-button".into()),
+                                Some(query_focus_handle.clone()),
+                                cx,
+                            )
+                            .into_any_element(),
+                        )),
+                )
             })
             .border_2()
     }

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

@@ -1,15 +1,18 @@
 use super::stack_frame_list::{StackFrameList, StackFrameListEvent};
-use dap::{ScopePresentationHint, StackFrameId, VariablePresentationHintKind, VariableReference};
+use dap::{
+    ScopePresentationHint, StackFrameId, VariablePresentationHint, VariablePresentationHintKind,
+    VariableReference,
+};
 use editor::Editor;
 use gpui::{
-    Action, AnyElement, ClickEvent, ClipboardItem, Context, DismissEvent, Entity, FocusHandle,
-    Focusable, Hsla, MouseButton, MouseDownEvent, Point, Stateful, Subscription,
+    Action, AnyElement, ClickEvent, ClipboardItem, Context, DismissEvent, Empty, Entity,
+    FocusHandle, Focusable, Hsla, MouseButton, MouseDownEvent, Point, Stateful, Subscription,
     TextStyleRefinement, UniformListScrollHandle, actions, anchored, deferred, uniform_list,
 };
 use menu::{SelectFirst, SelectLast, SelectNext, SelectPrevious};
-use project::debugger::session::{Session, SessionEvent};
+use project::debugger::session::{Session, SessionEvent, Watcher};
 use std::{collections::HashMap, ops::Range, sync::Arc};
-use ui::{ContextMenu, ListItem, Scrollbar, ScrollbarState, prelude::*};
+use ui::{ContextMenu, ListItem, ScrollableHandle, Scrollbar, ScrollbarState, Tooltip, prelude::*};
 use util::debug_panic;
 
 actions!(
@@ -19,7 +22,9 @@ actions!(
         CollapseSelectedEntry,
         CopyVariableName,
         CopyVariableValue,
-        EditVariable
+        EditVariable,
+        AddWatch,
+        RemoveWatch,
     ]
 );
 
@@ -38,6 +43,13 @@ pub(crate) struct EntryPath {
 }
 
 impl EntryPath {
+    fn for_watcher(expression: impl Into<SharedString>) -> Self {
+        Self {
+            leaf_name: Some(expression.into()),
+            indices: Arc::new([]),
+        }
+    }
+
     fn for_scope(scope_name: impl Into<SharedString>) -> Self {
         Self {
             leaf_name: Some(scope_name.into()),
@@ -68,11 +80,19 @@ impl EntryPath {
 
 #[derive(Debug, Clone, PartialEq)]
 enum EntryKind {
+    Watcher(Watcher),
     Variable(dap::Variable),
     Scope(dap::Scope),
 }
 
 impl EntryKind {
+    fn as_watcher(&self) -> Option<&Watcher> {
+        match self {
+            EntryKind::Watcher(watcher) => Some(watcher),
+            _ => None,
+        }
+    }
+
     fn as_variable(&self) -> Option<&dap::Variable> {
         match self {
             EntryKind::Variable(dap) => Some(dap),
@@ -87,9 +107,10 @@ impl EntryKind {
         }
     }
 
-    #[allow(dead_code)]
+    #[cfg(test)]
     fn name(&self) -> &str {
         match self {
+            EntryKind::Watcher(watcher) => &watcher.expression,
             EntryKind::Variable(dap) => &dap.name,
             EntryKind::Scope(dap) => &dap.name,
         }
@@ -103,6 +124,10 @@ struct ListEntry {
 }
 
 impl ListEntry {
+    fn as_watcher(&self) -> Option<&Watcher> {
+        self.dap_kind.as_watcher()
+    }
+
     fn as_variable(&self) -> Option<&dap::Variable> {
         self.dap_kind.as_variable()
     }
@@ -114,6 +139,7 @@ impl ListEntry {
     fn item_id(&self) -> ElementId {
         use std::fmt::Write;
         let mut id = match &self.dap_kind {
+            EntryKind::Watcher(watcher) => format!("watcher-{}", watcher.expression),
             EntryKind::Variable(dap) => format!("variable-{}", dap.name),
             EntryKind::Scope(dap) => format!("scope-{}", dap.name),
         };
@@ -126,6 +152,7 @@ impl ListEntry {
     fn item_value_id(&self) -> ElementId {
         use std::fmt::Write;
         let mut id = match &self.dap_kind {
+            EntryKind::Watcher(watcher) => format!("watcher-{}", watcher.expression),
             EntryKind::Variable(dap) => format!("variable-{}", dap.name),
             EntryKind::Scope(dap) => format!("scope-{}", dap.name),
         };
@@ -137,6 +164,11 @@ impl ListEntry {
     }
 }
 
+struct VariableColor {
+    name: Option<Hsla>,
+    value: Option<Hsla>,
+}
+
 pub struct VariableList {
     entries: Vec<ListEntry>,
     entry_states: HashMap<EntryPath, EntryState>,
@@ -169,7 +201,7 @@ impl VariableList {
                     this.edited_path.take();
                     this.selected_stack_frame_id.take();
                 }
-                SessionEvent::Variables => {
+                SessionEvent::Variables | SessionEvent::Watchers => {
                     this.build_entries(cx);
                 }
                 _ => {}
@@ -216,6 +248,7 @@ impl VariableList {
         };
 
         let mut entries = vec![];
+
         let scopes: Vec<_> = self.session.update(cx, |session, cx| {
             session.scopes(stack_frame_id, cx).iter().cloned().collect()
         });
@@ -249,11 +282,27 @@ impl VariableList {
             })
             .collect::<Vec<_>>();
 
+        let watches = self.session.read(cx).watchers().clone();
+        stack.extend(
+            watches
+                .into_values()
+                .map(|watcher| {
+                    (
+                        watcher.variables_reference,
+                        watcher.variables_reference,
+                        EntryPath::for_watcher(watcher.expression.clone()),
+                        EntryKind::Watcher(watcher.clone()),
+                    )
+                })
+                .collect::<Vec<_>>(),
+        );
+
         let scopes_count = stack.len();
 
         while let Some((container_reference, variables_reference, mut path, dap_kind)) = stack.pop()
         {
             match &dap_kind {
+                EntryKind::Watcher(watcher) => path = path.with_child(watcher.expression.clone()),
                 EntryKind::Variable(dap) => path = path.with_name(dap.name.clone().into()),
                 EntryKind::Scope(dap) => path = path.with_child(dap.name.clone().into()),
             }
@@ -312,6 +361,9 @@ impl VariableList {
         match event {
             StackFrameListEvent::SelectedStackFrameChanged(stack_frame_id) => {
                 self.selected_stack_frame_id = Some(*stack_frame_id);
+                self.session.update(cx, |session, cx| {
+                    session.refresh_watchers(*stack_frame_id, cx);
+                });
                 self.build_entries(cx);
             }
             StackFrameListEvent::BuiltEntries => {}
@@ -323,7 +375,7 @@ impl VariableList {
             .iter()
             .filter_map(|entry| match &entry.dap_kind {
                 EntryKind::Variable(dap) => Some(dap.clone()),
-                EntryKind::Scope(_) => None,
+                EntryKind::Scope(_) | EntryKind::Watcher { .. } => None,
             })
             .collect()
     }
@@ -342,6 +394,9 @@ impl VariableList {
                     .and_then(|entry| Some(entry).zip(self.entry_states.get(&entry.path)))?;
 
                 match &entry.dap_kind {
+                    EntryKind::Watcher { .. } => {
+                        Some(self.render_watcher(entry, *state, window, cx))
+                    }
                     EntryKind::Variable(_) => Some(self.render_variable(entry, *state, window, cx)),
                     EntryKind::Scope(_) => Some(self.render_scope(entry, *state, cx)),
                 }
@@ -434,14 +489,26 @@ impl VariableList {
             let Some(state) = self.entry_states.get(&var_path) else {
                 return;
             };
+
             let variables_reference = state.parent_reference;
             let Some(name) = var_path.leaf_name else {
                 return;
             };
+
+            let Some(stack_frame_id) = self.selected_stack_frame_id else {
+                return;
+            };
+
             let value = editor.read(cx).text(cx);
 
             self.session.update(cx, |session, cx| {
-                session.set_variable_value(variables_reference, name.into(), value, cx)
+                session.set_variable_value(
+                    stack_frame_id,
+                    variables_reference,
+                    name.into(),
+                    value,
+                    cx,
+                )
             });
         }
     }
@@ -488,18 +555,38 @@ impl VariableList {
         }
     }
 
-    fn deploy_variable_context_menu(
+    fn deploy_list_entry_context_menu(
         &mut self,
-        _variable: ListEntry,
+        entry: ListEntry,
         position: Point<Pixels>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        let supports_set_variable = self
+            .session
+            .read(cx)
+            .capabilities()
+            .supports_set_variable
+            .unwrap_or_default();
+
         let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
-            menu.action("Copy Name", CopyVariableName.boxed_clone())
-                .action("Copy Value", CopyVariableValue.boxed_clone())
-                .action("Edit Value", EditVariable.boxed_clone())
-                .context(self.focus_handle.clone())
+            menu.when(entry.as_variable().is_some(), |menu| {
+                menu.action("Copy Name", CopyVariableName.boxed_clone())
+                    .action("Copy Value", CopyVariableValue.boxed_clone())
+                    .when(supports_set_variable, |menu| {
+                        menu.action("Edit Value", EditVariable.boxed_clone())
+                    })
+                    .action("Watch Variable", AddWatch.boxed_clone())
+            })
+            .when(entry.as_watcher().is_some(), |menu| {
+                menu.action("Copy Name", CopyVariableName.boxed_clone())
+                    .action("Copy Value", CopyVariableValue.boxed_clone())
+                    .when(supports_set_variable, |menu| {
+                        menu.action("Edit Value", EditVariable.boxed_clone())
+                    })
+                    .action("Remove Watch", RemoveWatch.boxed_clone())
+            })
+            .context(self.focus_handle.clone())
         });
 
         cx.focus_view(&context_menu, window);
@@ -529,13 +616,18 @@ impl VariableList {
         let Some(selection) = self.selection.as_ref() else {
             return;
         };
+
         let Some(entry) = self.entries.iter().find(|entry| &entry.path == selection) else {
             return;
         };
-        let Some(variable) = entry.as_variable() else {
-            return;
+
+        let variable_name = match &entry.dap_kind {
+            EntryKind::Variable(dap) => dap.name.clone(),
+            EntryKind::Watcher(watcher) => watcher.expression.to_string(),
+            EntryKind::Scope(_) => return,
         };
-        cx.write_to_clipboard(ClipboardItem::new_string(variable.name.clone()));
+
+        cx.write_to_clipboard(ClipboardItem::new_string(variable_name));
     }
 
     fn copy_variable_value(
@@ -547,32 +639,96 @@ impl VariableList {
         let Some(selection) = self.selection.as_ref() else {
             return;
         };
+
         let Some(entry) = self.entries.iter().find(|entry| &entry.path == selection) else {
             return;
         };
-        let Some(variable) = entry.as_variable() else {
-            return;
+
+        let variable_value = match &entry.dap_kind {
+            EntryKind::Variable(dap) => dap.value.clone(),
+            EntryKind::Watcher(watcher) => watcher.value.to_string(),
+            EntryKind::Scope(_) => return,
         };
-        cx.write_to_clipboard(ClipboardItem::new_string(variable.value.clone()));
+
+        cx.write_to_clipboard(ClipboardItem::new_string(variable_value));
     }
 
     fn edit_variable(&mut self, _: &EditVariable, window: &mut Window, cx: &mut Context<Self>) {
         let Some(selection) = self.selection.as_ref() else {
             return;
         };
+
         let Some(entry) = self.entries.iter().find(|entry| &entry.path == selection) else {
             return;
         };
-        let Some(variable) = entry.as_variable() else {
-            return;
+
+        let variable_value = match &entry.dap_kind {
+            EntryKind::Watcher(watcher) => watcher.value.to_string(),
+            EntryKind::Variable(variable) => variable.value.clone(),
+            EntryKind::Scope(_) => return,
         };
 
-        let editor = Self::create_variable_editor(&variable.value, window, cx);
+        let editor = Self::create_variable_editor(&variable_value, window, cx);
         self.edited_path = Some((entry.path.clone(), editor));
 
         cx.notify();
     }
 
+    fn add_watcher(&mut self, _: &AddWatch, _: &mut Window, cx: &mut Context<Self>) {
+        let Some(selection) = self.selection.as_ref() else {
+            return;
+        };
+
+        let Some(entry) = self.entries.iter().find(|entry| &entry.path == selection) else {
+            return;
+        };
+
+        let Some(variable) = entry.as_variable() else {
+            return;
+        };
+
+        let Some(stack_frame_id) = self.selected_stack_frame_id else {
+            return;
+        };
+
+        let add_watcher_task = self.session.update(cx, |session, cx| {
+            let expression = variable
+                .evaluate_name
+                .clone()
+                .unwrap_or_else(|| variable.name.clone());
+
+            session.add_watcher(expression.into(), stack_frame_id, cx)
+        });
+
+        cx.spawn(async move |this, cx| {
+            add_watcher_task.await?;
+
+            this.update(cx, |this, cx| {
+                this.build_entries(cx);
+            })
+        })
+        .detach_and_log_err(cx);
+    }
+
+    fn remove_watcher(&mut self, _: &RemoveWatch, _: &mut Window, cx: &mut Context<Self>) {
+        let Some(selection) = self.selection.as_ref() else {
+            return;
+        };
+
+        let Some(entry) = self.entries.iter().find(|entry| &entry.path == selection) else {
+            return;
+        };
+
+        let Some(watcher) = entry.as_watcher() else {
+            return;
+        };
+
+        self.session.update(cx, |session, _| {
+            session.remove_watcher(watcher.expression.clone());
+        });
+        self.build_entries(cx);
+    }
+
     #[track_caller]
     #[cfg(test)]
     pub(crate) fn assert_visual_entries(&self, expected: Vec<&str>) {
@@ -623,6 +779,7 @@ impl VariableList {
 
         for entry in self.entries.iter() {
             match &entry.dap_kind {
+                EntryKind::Watcher { .. } => continue,
                 EntryKind::Variable(dap) => scopes[idx].1.push(dap.clone()),
                 EntryKind::Scope(scope) => {
                     if scopes.len() > 0 {
@@ -672,6 +829,288 @@ impl VariableList {
         editor
     }
 
+    fn variable_color(
+        &self,
+        presentation_hint: Option<&VariablePresentationHint>,
+        cx: &Context<Self>,
+    ) -> VariableColor {
+        let syntax_color_for = |name| cx.theme().syntax().get(name).color;
+        let name = if self.disabled {
+            Some(Color::Disabled.color(cx))
+        } else {
+            match presentation_hint
+                .as_ref()
+                .and_then(|hint| hint.kind.as_ref())
+                .unwrap_or(&VariablePresentationHintKind::Unknown)
+            {
+                VariablePresentationHintKind::Class
+                | VariablePresentationHintKind::BaseClass
+                | VariablePresentationHintKind::InnerClass
+                | VariablePresentationHintKind::MostDerivedClass => syntax_color_for("type"),
+                VariablePresentationHintKind::Data => syntax_color_for("variable"),
+                VariablePresentationHintKind::Unknown | _ => syntax_color_for("variable"),
+            }
+        };
+        let value = self
+            .disabled
+            .then(|| Color::Disabled.color(cx))
+            .or_else(|| syntax_color_for("variable.special"));
+
+        VariableColor { name, value }
+    }
+
+    fn render_variable_value(
+        &self,
+        entry: &ListEntry,
+        variable_color: &VariableColor,
+        value: String,
+        cx: &mut Context<Self>,
+    ) -> AnyElement {
+        if !value.is_empty() {
+            div()
+                .w_full()
+                .id(entry.item_value_id())
+                .map(|this| {
+                    if let Some((_, editor)) = self
+                        .edited_path
+                        .as_ref()
+                        .filter(|(path, _)| path == &entry.path)
+                    {
+                        this.child(div().size_full().px_2().child(editor.clone()))
+                    } else {
+                        this.text_color(cx.theme().colors().text_muted)
+                            .when(
+                                !self.disabled
+                                    && self
+                                        .session
+                                        .read(cx)
+                                        .capabilities()
+                                        .supports_set_variable
+                                        .unwrap_or_default(),
+                                |this| {
+                                    let path = entry.path.clone();
+                                    let variable_value = value.clone();
+                                    this.on_click(cx.listener(
+                                        move |this, click: &ClickEvent, window, cx| {
+                                            if click.down.click_count < 2 {
+                                                return;
+                                            }
+                                            let editor = Self::create_variable_editor(
+                                                &variable_value,
+                                                window,
+                                                cx,
+                                            );
+                                            this.edited_path = Some((path.clone(), editor));
+
+                                            cx.notify();
+                                        },
+                                    ))
+                                },
+                            )
+                            .child(
+                                Label::new(format!("=  {}", &value))
+                                    .single_line()
+                                    .truncate()
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted)
+                                    .when_some(variable_color.value, |this, color| {
+                                        this.color(Color::from(color))
+                                    }),
+                            )
+                    }
+                })
+                .into_any_element()
+        } else {
+            Empty.into_any_element()
+        }
+    }
+
+    fn center_truncate_string(s: &str, mut max_chars: usize) -> String {
+        const ELLIPSIS: &str = "...";
+        const MIN_LENGTH: usize = 3;
+
+        max_chars = max_chars.max(MIN_LENGTH);
+
+        let char_count = s.chars().count();
+        if char_count <= max_chars {
+            return s.to_string();
+        }
+
+        if ELLIPSIS.len() + MIN_LENGTH > max_chars {
+            return s.chars().take(MIN_LENGTH).collect();
+        }
+
+        let available_chars = max_chars - ELLIPSIS.len();
+
+        let start_chars = available_chars / 2;
+        let end_chars = available_chars - start_chars;
+        let skip_chars = char_count - end_chars;
+
+        let mut start_boundary = 0;
+        let mut end_boundary = s.len();
+
+        for (i, (byte_idx, _)) in s.char_indices().enumerate() {
+            if i == start_chars {
+                start_boundary = byte_idx.max(MIN_LENGTH);
+            }
+
+            if i == skip_chars {
+                end_boundary = byte_idx;
+            }
+        }
+
+        if start_boundary >= end_boundary {
+            return s.chars().take(MIN_LENGTH).collect();
+        }
+
+        format!("{}{}{}", &s[..start_boundary], ELLIPSIS, &s[end_boundary..])
+    }
+
+    fn render_watcher(
+        &self,
+        entry: &ListEntry,
+        state: EntryState,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> AnyElement {
+        let Some(watcher) = &entry.as_watcher() else {
+            debug_panic!("Called render watcher on non watcher variable list entry variant");
+            return div().into_any_element();
+        };
+
+        let variable_color = self.variable_color(watcher.presentation_hint.as_ref(), cx);
+
+        let is_selected = self
+            .selection
+            .as_ref()
+            .is_some_and(|selection| selection == &entry.path);
+        let var_ref = watcher.variables_reference;
+
+        let colors = get_entry_color(cx);
+        let bg_hover_color = if !is_selected {
+            colors.hover
+        } else {
+            colors.default
+        };
+        let border_color = if is_selected {
+            colors.marked_active
+        } else {
+            colors.default
+        };
+        let path = entry.path.clone();
+
+        let weak = cx.weak_entity();
+        let focus_handle = self.focus_handle.clone();
+        let watcher_len = (self.list_handle.content_size().width.0 / 12.0).floor() - 3.0;
+        let watcher_len = watcher_len as usize;
+
+        div()
+            .id(entry.item_id())
+            .group("variable_list_entry")
+            .pl_2()
+            .border_1()
+            .border_r_2()
+            .border_color(border_color)
+            .flex()
+            .w_full()
+            .h_full()
+            .hover(|style| style.bg(bg_hover_color))
+            .on_click(cx.listener({
+                let path = path.clone();
+                move |this, _, _window, cx| {
+                    this.selection = Some(path.clone());
+                    cx.notify();
+                }
+            }))
+            .child(
+                ListItem::new(SharedString::from(format!(
+                    "watcher-{}",
+                    watcher.expression
+                )))
+                .selectable(false)
+                .disabled(self.disabled)
+                .selectable(false)
+                .indent_level(state.depth)
+                .indent_step_size(px(10.))
+                .always_show_disclosure_icon(true)
+                .when(var_ref > 0, |list_item| {
+                    list_item.toggle(state.is_expanded).on_toggle(cx.listener({
+                        let var_path = entry.path.clone();
+                        move |this, _, _, cx| {
+                            this.session.update(cx, |session, cx| {
+                                session.variables(var_ref, cx);
+                            });
+
+                            this.toggle_entry(&var_path, cx);
+                        }
+                    }))
+                })
+                .on_secondary_mouse_down(cx.listener({
+                    let path = path.clone();
+                    let entry = entry.clone();
+                    move |this, event: &MouseDownEvent, window, cx| {
+                        this.selection = Some(path.clone());
+                        this.deploy_list_entry_context_menu(
+                            entry.clone(),
+                            event.position,
+                            window,
+                            cx,
+                        );
+                        cx.stop_propagation();
+                    }
+                }))
+                .child(
+                    h_flex()
+                        .gap_1()
+                        .text_ui_sm(cx)
+                        .w_full()
+                        .child(
+                            Label::new(&Self::center_truncate_string(
+                                watcher.expression.as_ref(),
+                                watcher_len,
+                            ))
+                            .when_some(variable_color.name, |this, color| {
+                                this.color(Color::from(color))
+                            }),
+                        )
+                        .child(self.render_variable_value(
+                            &entry,
+                            &variable_color,
+                            watcher.value.to_string(),
+                            cx,
+                        )),
+                )
+                .end_slot(
+                    IconButton::new(
+                        SharedString::from(format!("watcher-{}-remove-button", watcher.expression)),
+                        IconName::Close,
+                    )
+                    .on_click({
+                        let weak = weak.clone();
+                        let path = path.clone();
+                        move |_, window, cx| {
+                            weak.update(cx, |variable_list, cx| {
+                                variable_list.selection = Some(path.clone());
+                                variable_list.remove_watcher(&RemoveWatch, window, cx);
+                            })
+                            .ok();
+                        }
+                    })
+                    .tooltip(move |window, cx| {
+                        Tooltip::for_action_in(
+                            "Remove Watch",
+                            &RemoveWatch,
+                            &focus_handle,
+                            window,
+                            cx,
+                        )
+                    })
+                    .icon_size(ui::IconSize::Indicator),
+                ),
+            )
+            .into_any()
+    }
+
     fn render_scope(
         &self,
         entry: &ListEntry,
@@ -751,36 +1190,12 @@ impl VariableList {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> AnyElement {
-        let dap = match &variable.dap_kind {
-            EntryKind::Variable(dap) => dap,
-            EntryKind::Scope(_) => {
-                debug_panic!("Called render variable on variable list entry kind scope");
-                return div().into_any_element();
-            }
+        let Some(dap) = &variable.as_variable() else {
+            debug_panic!("Called render variable on non variable variable list entry variant");
+            return div().into_any_element();
         };
 
-        let syntax_color_for = |name| cx.theme().syntax().get(name).color;
-        let variable_name_color = if self.disabled {
-            Some(Color::Disabled.color(cx))
-        } else {
-            match &dap
-                .presentation_hint
-                .as_ref()
-                .and_then(|hint| hint.kind.as_ref())
-                .unwrap_or(&VariablePresentationHintKind::Unknown)
-            {
-                VariablePresentationHintKind::Class
-                | VariablePresentationHintKind::BaseClass
-                | VariablePresentationHintKind::InnerClass
-                | VariablePresentationHintKind::MostDerivedClass => syntax_color_for("type"),
-                VariablePresentationHintKind::Data => syntax_color_for("variable"),
-                VariablePresentationHintKind::Unknown | _ => syntax_color_for("variable"),
-            }
-        };
-        let variable_color = self
-            .disabled
-            .then(|| Color::Disabled.color(cx))
-            .or_else(|| syntax_color_for("variable.special"));
+        let variable_color = self.variable_color(dap.presentation_hint.as_ref(), cx);
 
         let var_ref = dap.variables_reference;
         let colors = get_entry_color(cx);
@@ -811,6 +1226,7 @@ impl VariableList {
             .size_full()
             .hover(|style| style.bg(bg_hover_color))
             .on_click(cx.listener({
+                let path = path.clone();
                 move |this, _, _window, cx| {
                     this.selection = Some(path.clone());
                     cx.notify();
@@ -839,11 +1255,12 @@ impl VariableList {
                     }))
                 })
                 .on_secondary_mouse_down(cx.listener({
-                    let variable = variable.clone();
+                    let path = path.clone();
+                    let entry = variable.clone();
                     move |this, event: &MouseDownEvent, window, cx| {
-                        this.selection = Some(variable.path.clone());
-                        this.deploy_variable_context_menu(
-                            variable.clone(),
+                        this.selection = Some(path.clone());
+                        this.deploy_list_entry_context_menu(
+                            entry.clone(),
                             event.position,
                             window,
                             cx,
@@ -857,62 +1274,16 @@ impl VariableList {
                         .text_ui_sm(cx)
                         .w_full()
                         .child(
-                            Label::new(&dap.name).when_some(variable_name_color, |this, color| {
+                            Label::new(&dap.name).when_some(variable_color.name, |this, color| {
                                 this.color(Color::from(color))
                             }),
                         )
-                        .when(!dap.value.is_empty(), |this| {
-                            this.child(div().w_full().id(variable.item_value_id()).map(|this| {
-                                if let Some((_, editor)) = self
-                                    .edited_path
-                                    .as_ref()
-                                    .filter(|(path, _)| path == &variable.path)
-                                {
-                                    this.child(div().size_full().px_2().child(editor.clone()))
-                                } else {
-                                    this.text_color(cx.theme().colors().text_muted)
-                                        .when(
-                                            !self.disabled
-                                                && self
-                                                    .session
-                                                    .read(cx)
-                                                    .capabilities()
-                                                    .supports_set_variable
-                                                    .unwrap_or_default(),
-                                            |this| {
-                                                let path = variable.path.clone();
-                                                let variable_value = dap.value.clone();
-                                                this.on_click(cx.listener(
-                                                    move |this, click: &ClickEvent, window, cx| {
-                                                        if click.down.click_count < 2 {
-                                                            return;
-                                                        }
-                                                        let editor = Self::create_variable_editor(
-                                                            &variable_value,
-                                                            window,
-                                                            cx,
-                                                        );
-                                                        this.edited_path =
-                                                            Some((path.clone(), editor));
-
-                                                        cx.notify();
-                                                    },
-                                                ))
-                                            },
-                                        )
-                                        .child(
-                                            Label::new(format!("=  {}", &dap.value))
-                                                .single_line()
-                                                .truncate()
-                                                .size(LabelSize::Small)
-                                                .color(Color::Muted)
-                                                .when_some(variable_color, |this, color| {
-                                                    this.color(Color::from(color))
-                                                }),
-                                        )
-                                }
-                            }))
-                        }),
+                        .child(self.render_variable_value(
+                            &variable,
+                            &variable_color,
+                            dap.value.clone(),
+                            cx,
+                        )),
                 ),
             )
             .into_any()
@@ -978,6 +1349,8 @@ impl Render for VariableList {
             .on_action(cx.listener(Self::copy_variable_name))
             .on_action(cx.listener(Self::copy_variable_value))
             .on_action(cx.listener(Self::edit_variable))
+            .on_action(cx.listener(Self::add_watcher))
+            .on_action(cx.listener(Self::remove_watcher))
             .child(
                 uniform_list(
                     "variable-list",
@@ -1019,3 +1392,53 @@ fn get_entry_color(cx: &Context<VariableList>) -> EntryColors {
         marked_active: colors.ghost_element_selected,
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_center_truncate_string() {
+        // Test string shorter than limit - should not be truncated
+        assert_eq!(VariableList::center_truncate_string("short", 10), "short");
+
+        // Test exact length - should not be truncated
+        assert_eq!(
+            VariableList::center_truncate_string("exactly_10", 10),
+            "exactly_10"
+        );
+
+        // Test simple truncation
+        assert_eq!(
+            VariableList::center_truncate_string("value->value2->value3->value4", 20),
+            "value->v...3->value4"
+        );
+
+        // Test with very long expression
+        assert_eq!(
+            VariableList::center_truncate_string(
+                "object->property1->property2->property3->property4->property5",
+                30
+            ),
+            "object->prope...ty4->property5"
+        );
+
+        // Test edge case with limit equal to ellipsis length
+        assert_eq!(VariableList::center_truncate_string("anything", 3), "any");
+
+        // Test edge case with limit less than ellipsis length
+        assert_eq!(VariableList::center_truncate_string("anything", 2), "any");
+
+        // Test with UTF-8 characters
+        assert_eq!(
+            VariableList::center_truncate_string("café->résumé->naïve->voilà", 15),
+            "café->...>voilà"
+        );
+
+        // Test with emoji (multi-byte UTF-8)
+        assert_eq!(
+            VariableList::center_truncate_string("😀->happy->face->😎->cool", 15),
+            "😀->hap...->cool"
+        );
+    }
+}

crates/debugger_ui/src/tests/variable_list.rs 🔗

@@ -6,18 +6,21 @@ use std::sync::{
 use crate::{
     DebugPanel,
     persistence::DebuggerPaneItem,
-    session::running::variable_list::{CollapseSelectedEntry, ExpandSelectedEntry},
+    session::running::variable_list::{
+        AddWatch, CollapseSelectedEntry, ExpandSelectedEntry, RemoveWatch,
+    },
     tests::{active_debug_session_panel, init_test, init_test_workspace, start_debug_session},
 };
 use collections::HashMap;
 use dap::{
     Scope, StackFrame, Variable,
-    requests::{Initialize, Launch, Scopes, StackTrace, Variables},
+    requests::{Evaluate, Initialize, Launch, Scopes, StackTrace, Variables},
 };
 use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext};
 use menu::{SelectFirst, SelectNext, SelectPrevious};
 use project::{FakeFs, Project};
 use serde_json::json;
+use ui::SharedString;
 use unindent::Unindent as _;
 use util::path;
 
@@ -1828,3 +1831,515 @@ async fn test_it_fetches_scopes_variables_when_you_select_a_stack_frame(
         assert_eq!(variables, frame_2_variables,);
     });
 }
+
+#[gpui::test]
+async fn test_add_and_remove_watcher(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor.clone());
+
+    let test_file_content = r#"
+        const variable1 = "Value 1";
+        const variable2 = "Value 2";
+    "#
+    .unindent();
+
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+           "src": {
+               "test.js": test_file_content,
+           }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+    let workspace = init_test_workspace(&project, cx).await;
+    workspace
+        .update(cx, |workspace, window, cx| {
+            workspace.focus_panel::<DebugPanel>(window, cx);
+        })
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+    let session = start_debug_session(&workspace, cx, |_| {}).unwrap();
+    let client = session.update(cx, |session, _| session.adapter_client().unwrap());
+
+    client.on_request::<dap::requests::Threads, _>(move |_, _| {
+        Ok(dap::ThreadsResponse {
+            threads: vec![dap::Thread {
+                id: 1,
+                name: "Thread 1".into(),
+            }],
+        })
+    });
+
+    let stack_frames = vec![StackFrame {
+        id: 1,
+        name: "Stack Frame 1".into(),
+        source: Some(dap::Source {
+            name: Some("test.js".into()),
+            path: Some(path!("/project/src/test.js").into()),
+            source_reference: None,
+            presentation_hint: None,
+            origin: None,
+            sources: None,
+            adapter_data: None,
+            checksums: None,
+        }),
+        line: 1,
+        column: 1,
+        end_line: None,
+        end_column: None,
+        can_restart: None,
+        instruction_pointer_reference: None,
+        module_id: None,
+        presentation_hint: None,
+    }];
+
+    client.on_request::<StackTrace, _>({
+        let stack_frames = Arc::new(stack_frames.clone());
+        move |_, args| {
+            assert_eq!(1, args.thread_id);
+
+            Ok(dap::StackTraceResponse {
+                stack_frames: (*stack_frames).clone(),
+                total_frames: None,
+            })
+        }
+    });
+
+    let scopes = vec![Scope {
+        name: "Scope 1".into(),
+        presentation_hint: None,
+        variables_reference: 2,
+        named_variables: None,
+        indexed_variables: None,
+        expensive: false,
+        source: None,
+        line: None,
+        column: None,
+        end_line: None,
+        end_column: None,
+    }];
+
+    client.on_request::<Scopes, _>({
+        let scopes = Arc::new(scopes.clone());
+        move |_, args| {
+            assert_eq!(1, args.frame_id);
+
+            Ok(dap::ScopesResponse {
+                scopes: (*scopes).clone(),
+            })
+        }
+    });
+
+    let variables = vec![
+        Variable {
+            name: "variable1".into(),
+            value: "value 1".into(),
+            type_: None,
+            presentation_hint: None,
+            evaluate_name: None,
+            variables_reference: 0,
+            named_variables: None,
+            indexed_variables: None,
+            memory_reference: None,
+            declaration_location_reference: None,
+            value_location_reference: None,
+        },
+        Variable {
+            name: "variable2".into(),
+            value: "value 2".into(),
+            type_: None,
+            presentation_hint: None,
+            evaluate_name: None,
+            variables_reference: 0,
+            named_variables: None,
+            indexed_variables: None,
+            memory_reference: None,
+            declaration_location_reference: None,
+            value_location_reference: None,
+        },
+    ];
+
+    client.on_request::<Variables, _>({
+        let variables = Arc::new(variables.clone());
+        move |_, args| {
+            assert_eq!(2, args.variables_reference);
+
+            Ok(dap::VariablesResponse {
+                variables: (*variables).clone(),
+            })
+        }
+    });
+
+    client.on_request::<Evaluate, _>({
+        move |_, args| {
+            assert_eq!("variable1", args.expression);
+
+            Ok(dap::EvaluateResponse {
+                result: "value1".to_owned(),
+                type_: None,
+                presentation_hint: None,
+                variables_reference: 2,
+                named_variables: None,
+                indexed_variables: None,
+                memory_reference: None,
+                value_location_reference: None,
+            })
+        }
+    });
+
+    client
+        .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
+            reason: dap::StoppedEventReason::Pause,
+            description: None,
+            thread_id: Some(1),
+            preserve_focus_hint: None,
+            text: None,
+            all_threads_stopped: None,
+            hit_breakpoint_ids: None,
+        }))
+        .await;
+
+    cx.run_until_parked();
+
+    let running_state =
+        active_debug_session_panel(workspace, cx).update_in(cx, |item, window, cx| {
+            cx.focus_self(window);
+            let running = item.running_state().clone();
+
+            let variable_list = running.update(cx, |state, cx| {
+                // have to do this because the variable list pane should be shown/active
+                // for testing the variable list
+                state.activate_item(DebuggerPaneItem::Variables, window, cx);
+
+                state.variable_list().clone()
+            });
+            variable_list.update(cx, |_, cx| cx.focus_self(window));
+            running
+        });
+    cx.run_until_parked();
+
+    // select variable 1 from first scope
+    running_state.update(cx, |running_state, cx| {
+        running_state.variable_list().update(cx, |_, cx| {
+            cx.dispatch_action(&SelectFirst);
+            cx.dispatch_action(&SelectNext);
+        });
+    });
+    cx.run_until_parked();
+
+    running_state.update(cx, |running_state, cx| {
+        running_state.variable_list().update(cx, |_, cx| {
+            cx.dispatch_action(&AddWatch);
+        });
+    });
+    cx.run_until_parked();
+
+    // assert watcher for variable1 was added
+    running_state.update(cx, |running_state, cx| {
+        running_state.variable_list().update(cx, |list, _| {
+            list.assert_visual_entries(vec![
+                "> variable1",
+                "v Scope 1",
+                "    > variable1 <=== selected",
+                "    > variable2",
+            ]);
+        });
+    });
+
+    session.update(cx, |session, _| {
+        let watcher = session
+            .watchers()
+            .get(&SharedString::from("variable1"))
+            .unwrap();
+
+        assert_eq!("value1", watcher.value.to_string());
+        assert_eq!("variable1", watcher.expression.to_string());
+        assert_eq!(2, watcher.variables_reference);
+    });
+
+    // select added watcher for variable1
+    running_state.update(cx, |running_state, cx| {
+        running_state.variable_list().update(cx, |_, cx| {
+            cx.dispatch_action(&SelectFirst);
+        });
+    });
+    cx.run_until_parked();
+
+    running_state.update(cx, |running_state, cx| {
+        running_state.variable_list().update(cx, |_, cx| {
+            cx.dispatch_action(&RemoveWatch);
+        });
+    });
+    cx.run_until_parked();
+
+    // assert watcher for variable1 was removed
+    running_state.update(cx, |running_state, cx| {
+        running_state.variable_list().update(cx, |list, _| {
+            list.assert_visual_entries(vec!["v Scope 1", "    > variable1", "    > variable2"]);
+        });
+    });
+}
+
+#[gpui::test]
+async fn test_refresh_watchers(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor.clone());
+
+    let test_file_content = r#"
+        const variable1 = "Value 1";
+        const variable2 = "Value 2";
+    "#
+    .unindent();
+
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+           "src": {
+               "test.js": test_file_content,
+           }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+    let workspace = init_test_workspace(&project, cx).await;
+    workspace
+        .update(cx, |workspace, window, cx| {
+            workspace.focus_panel::<DebugPanel>(window, cx);
+        })
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+    let session = start_debug_session(&workspace, cx, |_| {}).unwrap();
+    let client = session.update(cx, |session, _| session.adapter_client().unwrap());
+
+    client.on_request::<dap::requests::Threads, _>(move |_, _| {
+        Ok(dap::ThreadsResponse {
+            threads: vec![dap::Thread {
+                id: 1,
+                name: "Thread 1".into(),
+            }],
+        })
+    });
+
+    let stack_frames = vec![StackFrame {
+        id: 1,
+        name: "Stack Frame 1".into(),
+        source: Some(dap::Source {
+            name: Some("test.js".into()),
+            path: Some(path!("/project/src/test.js").into()),
+            source_reference: None,
+            presentation_hint: None,
+            origin: None,
+            sources: None,
+            adapter_data: None,
+            checksums: None,
+        }),
+        line: 1,
+        column: 1,
+        end_line: None,
+        end_column: None,
+        can_restart: None,
+        instruction_pointer_reference: None,
+        module_id: None,
+        presentation_hint: None,
+    }];
+
+    client.on_request::<StackTrace, _>({
+        let stack_frames = Arc::new(stack_frames.clone());
+        move |_, args| {
+            assert_eq!(1, args.thread_id);
+
+            Ok(dap::StackTraceResponse {
+                stack_frames: (*stack_frames).clone(),
+                total_frames: None,
+            })
+        }
+    });
+
+    let scopes = vec![Scope {
+        name: "Scope 1".into(),
+        presentation_hint: None,
+        variables_reference: 2,
+        named_variables: None,
+        indexed_variables: None,
+        expensive: false,
+        source: None,
+        line: None,
+        column: None,
+        end_line: None,
+        end_column: None,
+    }];
+
+    client.on_request::<Scopes, _>({
+        let scopes = Arc::new(scopes.clone());
+        move |_, args| {
+            assert_eq!(1, args.frame_id);
+
+            Ok(dap::ScopesResponse {
+                scopes: (*scopes).clone(),
+            })
+        }
+    });
+
+    let variables = vec![
+        Variable {
+            name: "variable1".into(),
+            value: "value 1".into(),
+            type_: None,
+            presentation_hint: None,
+            evaluate_name: None,
+            variables_reference: 0,
+            named_variables: None,
+            indexed_variables: None,
+            memory_reference: None,
+            declaration_location_reference: None,
+            value_location_reference: None,
+        },
+        Variable {
+            name: "variable2".into(),
+            value: "value 2".into(),
+            type_: None,
+            presentation_hint: None,
+            evaluate_name: None,
+            variables_reference: 0,
+            named_variables: None,
+            indexed_variables: None,
+            memory_reference: None,
+            declaration_location_reference: None,
+            value_location_reference: None,
+        },
+    ];
+
+    client.on_request::<Variables, _>({
+        let variables = Arc::new(variables.clone());
+        move |_, args| {
+            assert_eq!(2, args.variables_reference);
+
+            Ok(dap::VariablesResponse {
+                variables: (*variables).clone(),
+            })
+        }
+    });
+
+    client.on_request::<Evaluate, _>({
+        move |_, args| {
+            assert_eq!("variable1", args.expression);
+
+            Ok(dap::EvaluateResponse {
+                result: "value1".to_owned(),
+                type_: None,
+                presentation_hint: None,
+                variables_reference: 2,
+                named_variables: None,
+                indexed_variables: None,
+                memory_reference: None,
+                value_location_reference: None,
+            })
+        }
+    });
+
+    client
+        .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
+            reason: dap::StoppedEventReason::Pause,
+            description: None,
+            thread_id: Some(1),
+            preserve_focus_hint: None,
+            text: None,
+            all_threads_stopped: None,
+            hit_breakpoint_ids: None,
+        }))
+        .await;
+
+    cx.run_until_parked();
+
+    let running_state =
+        active_debug_session_panel(workspace, cx).update_in(cx, |item, window, cx| {
+            cx.focus_self(window);
+            let running = item.running_state().clone();
+
+            let variable_list = running.update(cx, |state, cx| {
+                // have to do this because the variable list pane should be shown/active
+                // for testing the variable list
+                state.activate_item(DebuggerPaneItem::Variables, window, cx);
+
+                state.variable_list().clone()
+            });
+            variable_list.update(cx, |_, cx| cx.focus_self(window));
+            running
+        });
+    cx.run_until_parked();
+
+    // select variable 1 from first scope
+    running_state.update(cx, |running_state, cx| {
+        running_state.variable_list().update(cx, |_, cx| {
+            cx.dispatch_action(&SelectFirst);
+            cx.dispatch_action(&SelectNext);
+        });
+    });
+    cx.run_until_parked();
+
+    running_state.update(cx, |running_state, cx| {
+        running_state.variable_list().update(cx, |_, cx| {
+            cx.dispatch_action(&AddWatch);
+        });
+    });
+    cx.run_until_parked();
+
+    session.update(cx, |session, _| {
+        let watcher = session
+            .watchers()
+            .get(&SharedString::from("variable1"))
+            .unwrap();
+
+        assert_eq!("value1", watcher.value.to_string());
+        assert_eq!("variable1", watcher.expression.to_string());
+        assert_eq!(2, watcher.variables_reference);
+    });
+
+    client.on_request::<Evaluate, _>({
+        move |_, args| {
+            assert_eq!("variable1", args.expression);
+
+            Ok(dap::EvaluateResponse {
+                result: "value updated".to_owned(),
+                type_: None,
+                presentation_hint: None,
+                variables_reference: 3,
+                named_variables: None,
+                indexed_variables: None,
+                memory_reference: None,
+                value_location_reference: None,
+            })
+        }
+    });
+
+    client
+        .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
+            reason: dap::StoppedEventReason::Pause,
+            description: None,
+            thread_id: Some(1),
+            preserve_focus_hint: None,
+            text: None,
+            all_threads_stopped: None,
+            hit_breakpoint_ids: None,
+        }))
+        .await;
+
+    cx.run_until_parked();
+
+    session.update(cx, |session, _| {
+        let watcher = session
+            .watchers()
+            .get(&SharedString::from("variable1"))
+            .unwrap();
+
+        assert_eq!("value updated", watcher.value.to_string());
+        assert_eq!("variable1", watcher.expression.to_string());
+        assert_eq!(3, watcher.variables_reference);
+    });
+}

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

@@ -26,7 +26,7 @@ use dap::{
 use dap::{
     ExceptionBreakpointsFilter, ExceptionFilterOptions, OutputEvent, OutputEventCategory,
     RunInTerminalRequestArguments, StackFramePresentationHint, StartDebuggingRequestArguments,
-    StartDebuggingRequestArgumentsRequest,
+    StartDebuggingRequestArgumentsRequest, VariablePresentationHint,
 };
 use futures::SinkExt;
 use futures::channel::mpsc::UnboundedSender;
@@ -126,6 +126,14 @@ impl From<dap::Thread> for Thread {
     }
 }
 
+#[derive(Debug, Clone, PartialEq)]
+pub struct Watcher {
+    pub expression: SharedString,
+    pub value: SharedString,
+    pub variables_reference: u64,
+    pub presentation_hint: Option<VariablePresentationHint>,
+}
+
 pub enum Mode {
     Building,
     Running(RunningMode),
@@ -630,6 +638,7 @@ pub struct Session {
     output: Box<circular_buffer::CircularBuffer<MAX_TRACKED_OUTPUT_EVENTS, dap::OutputEvent>>,
     threads: IndexMap<ThreadId, Thread>,
     thread_states: ThreadStates,
+    watchers: HashMap<SharedString, Watcher>,
     variables: HashMap<VariableReference, Vec<dap::Variable>>,
     stack_frames: IndexMap<StackFrameId, StackFrame>,
     locations: HashMap<u64, dap::LocationsResponse>,
@@ -721,6 +730,7 @@ pub enum SessionEvent {
     Stopped(Option<ThreadId>),
     StackTrace,
     Variables,
+    Watchers,
     Threads,
     InvalidateInlineValue,
     CapabilitiesLoaded,
@@ -788,6 +798,7 @@ impl Session {
                 child_session_ids: HashSet::default(),
                 parent_session,
                 capabilities: Capabilities::default(),
+                watchers: HashMap::default(),
                 variables: Default::default(),
                 stack_frames: Default::default(),
                 thread_states: ThreadStates::default(),
@@ -2155,6 +2166,53 @@ impl Session {
             .collect()
     }
 
+    pub fn watchers(&self) -> &HashMap<SharedString, Watcher> {
+        &self.watchers
+    }
+
+    pub fn add_watcher(
+        &mut self,
+        expression: SharedString,
+        frame_id: u64,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        let request = self.mode.request_dap(EvaluateCommand {
+            expression: expression.to_string(),
+            context: Some(EvaluateArgumentsContext::Watch),
+            frame_id: Some(frame_id),
+            source: None,
+        });
+
+        cx.spawn(async move |this, cx| {
+            let response = request.await?;
+
+            this.update(cx, |session, cx| {
+                session.watchers.insert(
+                    expression.clone(),
+                    Watcher {
+                        expression,
+                        value: response.result.into(),
+                        variables_reference: response.variables_reference,
+                        presentation_hint: response.presentation_hint,
+                    },
+                );
+                cx.emit(SessionEvent::Watchers);
+            })
+        })
+    }
+
+    pub fn refresh_watchers(&mut self, frame_id: u64, cx: &mut Context<Self>) {
+        let watches = self.watchers.clone();
+        for (_, watch) in watches.into_iter() {
+            self.add_watcher(watch.expression.clone(), frame_id, cx)
+                .detach();
+        }
+    }
+
+    pub fn remove_watcher(&mut self, expression: SharedString) {
+        self.watchers.remove(&expression);
+    }
+
     pub fn variables(
         &mut self,
         variables_reference: VariableReference,
@@ -2191,6 +2249,7 @@ impl Session {
 
     pub fn set_variable_value(
         &mut self,
+        stack_frame_id: u64,
         variables_reference: u64,
         name: String,
         value: String,
@@ -2206,12 +2265,13 @@ impl Session {
                 move |this, response, cx| {
                     let response = response.log_err()?;
                     this.invalidate_command_type::<VariablesCommand>();
+                    this.refresh_watchers(stack_frame_id, cx);
                     cx.emit(SessionEvent::Variables);
                     Some(response)
                 },
                 cx,
             )
-            .detach()
+            .detach();
         }
     }
 

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

@@ -396,6 +396,10 @@ impl ButtonLike {
         Self::new(id).rounding(ButtonLikeRounding::Right)
     }
 
+    pub fn new_rounded_all(id: impl Into<ElementId>) -> Self {
+        Self::new(id).rounding(ButtonLikeRounding::All)
+    }
+
     pub fn opacity(mut self, opacity: f32) -> Self {
         self.base = self.base.opacity(opacity);
         self