From ad76db7244c4a98dde0d0faeecc6af112c7e4fbf Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Fri, 20 Jun 2025 22:45:55 +0200 Subject: [PATCH] debugger: Add variable watchers (#32743) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 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 Co-authored-by: Anthony --- assets/keymaps/default-linux.json | 8 +- assets/keymaps/default-macos.json | 8 +- .../src/session/running/console.rs | 120 +++- .../src/session/running/variable_list.rs | 643 +++++++++++++++--- crates/debugger_ui/src/tests/variable_list.rs | 519 +++++++++++++- crates/project/src/debugger/session.rs | 64 +- .../ui/src/components/button/button_like.rs | 4 + 7 files changed, 1243 insertions(+), 123 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 34b3a5e3d19cec0b900a1b455e6c5ba1d29d7ccc..7feaa5b477431bd77842f1b913b1f22c375a1b6f 100644 --- a/assets/keymaps/default-linux.json +++ b/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" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 734974558794959a97916a93f742f3116758c9fd..08cfe751de19518af9ededa35869d9b87e510032 100644 --- a/assets/keymaps/default-macos.json +++ b/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" } }, { diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index 34f9a3bf71d3d16e805437042482072b743cd533..e84e0d74e6c9302d7edf61f794809168c54c279e 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/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, @@ -329,6 +331,40 @@ impl Console { }); } + pub fn watch_expression( + &mut self, + _: &WatchExpression, + window: &mut Window, + cx: &mut Context, + ) { + 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) { 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, + keybinding_target: Option, + 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) -> 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) -> 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() } diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index 220276418da0f386a49f8649e5d350487a697313..c58ac865f9c5ed23e3b8129666ca7006408a34bc 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/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) -> Self { + Self { + leaf_name: Some(expression.into()), + indices: Arc::new([]), + } + } + fn for_scope(scope_name: impl Into) -> 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, + value: Option, +} + pub struct VariableList { entries: Vec, entry_states: HashMap, @@ -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::>(); + 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::>(), + ); + 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, window: &mut Window, cx: &mut Context, ) { + 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) { 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) { + 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) { + 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, + ) -> 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, + ) -> 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, + ) -> 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, ) -> 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) -> 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" + ); + } +} diff --git a/crates/debugger_ui/src/tests/variable_list.rs b/crates/debugger_ui/src/tests/variable_list.rs index 2ae601eb9059141535f7407ab828186fadf2acb2..fbbd52964105659c2cae645cec494824069f5529 100644 --- a/crates/debugger_ui/src/tests/variable_list.rs +++ b/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::(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::(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::({ + 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::({ + 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::({ + let variables = Arc::new(variables.clone()); + move |_, args| { + assert_eq!(2, args.variables_reference); + + Ok(dap::VariablesResponse { + variables: (*variables).clone(), + }) + } + }); + + client.on_request::({ + 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::(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::(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::({ + 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::({ + 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::({ + let variables = Arc::new(variables.clone()); + move |_, args| { + assert_eq!(2, args.variables_reference); + + Ok(dap::VariablesResponse { + variables: (*variables).clone(), + }) + } + }); + + client.on_request::({ + 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::({ + 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); + }); +} diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index 8047e028af5252f5eff0c5b914d4ed4a6eb8db14..8a7d55fc5946956a665d1e8e3a01f66d1bde5c2f 100644 --- a/crates/project/src/debugger/session.rs +++ b/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 for Thread { } } +#[derive(Debug, Clone, PartialEq)] +pub struct Watcher { + pub expression: SharedString, + pub value: SharedString, + pub variables_reference: u64, + pub presentation_hint: Option, +} + pub enum Mode { Building, Running(RunningMode), @@ -630,6 +638,7 @@ pub struct Session { output: Box>, threads: IndexMap, thread_states: ThreadStates, + watchers: HashMap, variables: HashMap>, stack_frames: IndexMap, locations: HashMap, @@ -721,6 +730,7 @@ pub enum SessionEvent { Stopped(Option), 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 { + &self.watchers + } + + pub fn add_watcher( + &mut self, + expression: SharedString, + frame_id: u64, + cx: &mut Context, + ) -> Task> { + 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) { + 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::(); + this.refresh_watchers(stack_frame_id, cx); cx.emit(SessionEvent::Variables); Some(response) }, cx, ) - .detach() + .detach(); } } diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index b4907ac06264a1293254a51dc696c33f7283ecf4..a0158b2fe745f383be179594c49ce1874b181176 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/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) -> Self { + Self::new(id).rounding(ButtonLikeRounding::All) + } + pub fn opacity(mut self, opacity: f32) -> Self { self.base = self.base.opacity(opacity); self