diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 34b3a5e3d1..7feaa5b477 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 7349745587..08cfe751de 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 34f9a3bf71..e84e0d74e6 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 220276418d..c58ac865f9 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,30 +639,94 @@ 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 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); + 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 editor = Self::create_variable_editor(&variable.value, window, cx); - self.edited_path = Some((entry.path.clone(), editor)); + let Some(stack_frame_id) = self.selected_stack_frame_id else { + return; + }; - cx.notify(); + 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] @@ -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 2ae601eb90..fbbd529641 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 8047e028af..8a7d55fc59 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 b4907ac062..a0158b2fe7 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