1use crate::session::running::{RunningState, memory_view::MemoryView};
2
3use super::stack_frame_list::{StackFrameList, StackFrameListEvent};
4use dap::{
5 ScopePresentationHint, StackFrameId, VariablePresentationHint, VariablePresentationHintKind,
6 VariableReference,
7};
8use editor::Editor;
9use gpui::{
10 Action, AnyElement, ClickEvent, ClipboardItem, Context, DismissEvent, Empty, Entity,
11 FocusHandle, Focusable, Hsla, MouseDownEvent, Point, Subscription, TextStyleRefinement,
12 UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list,
13};
14use menu::{SelectFirst, SelectLast, SelectNext, SelectPrevious};
15use project::debugger::{
16 dap_command::DataBreakpointContext,
17 session::{Session, SessionEvent, Watcher},
18};
19use std::{collections::HashMap, ops::Range, sync::Arc};
20use ui::{ContextMenu, ListItem, ScrollableHandle, Tooltip, WithScrollbar, prelude::*};
21use util::{debug_panic, maybe};
22
23actions!(
24 variable_list,
25 [
26 /// Expands the selected variable entry to show its children.
27 ExpandSelectedEntry,
28 /// Collapses the selected variable entry to hide its children.
29 CollapseSelectedEntry,
30 /// Copies the variable name to the clipboard.
31 CopyVariableName,
32 /// Copies the variable value to the clipboard.
33 CopyVariableValue,
34 /// Edits the value of the selected variable.
35 EditVariable,
36 /// Adds the selected variable to the watch list.
37 AddWatch,
38 /// Removes the selected variable from the watch list.
39 RemoveWatch,
40 /// Jump to variable's memory location.
41 GoToMemory,
42 ]
43);
44
45#[derive(Debug, Copy, Clone, PartialEq, Eq)]
46pub(crate) struct EntryState {
47 depth: usize,
48 is_expanded: bool,
49 has_children: bool,
50 parent_reference: VariableReference,
51}
52
53#[derive(Debug, PartialEq, Eq, Hash, Clone)]
54pub(crate) struct EntryPath {
55 pub leaf_name: Option<SharedString>,
56 pub indices: Arc<[SharedString]>,
57}
58
59impl EntryPath {
60 fn for_watcher(expression: impl Into<SharedString>) -> Self {
61 Self {
62 leaf_name: Some(expression.into()),
63 indices: Arc::new([]),
64 }
65 }
66
67 fn for_scope(scope_name: impl Into<SharedString>) -> Self {
68 Self {
69 leaf_name: Some(scope_name.into()),
70 indices: Arc::new([]),
71 }
72 }
73
74 fn with_name(&self, name: SharedString) -> Self {
75 Self {
76 leaf_name: Some(name),
77 indices: self.indices.clone(),
78 }
79 }
80
81 /// Create a new child of this variable path
82 fn with_child(&self, name: SharedString) -> Self {
83 Self {
84 leaf_name: None,
85 indices: self
86 .indices
87 .iter()
88 .cloned()
89 .chain(std::iter::once(name))
90 .collect(),
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq)]
96enum DapEntry {
97 Watcher(Watcher),
98 Variable(dap::Variable),
99 Scope(dap::Scope),
100}
101
102impl DapEntry {
103 fn as_watcher(&self) -> Option<&Watcher> {
104 match self {
105 DapEntry::Watcher(watcher) => Some(watcher),
106 _ => None,
107 }
108 }
109
110 fn as_variable(&self) -> Option<&dap::Variable> {
111 match self {
112 DapEntry::Variable(dap) => Some(dap),
113 _ => None,
114 }
115 }
116
117 fn as_scope(&self) -> Option<&dap::Scope> {
118 match self {
119 DapEntry::Scope(dap) => Some(dap),
120 _ => None,
121 }
122 }
123
124 #[cfg(test)]
125 fn name(&self) -> &str {
126 match self {
127 DapEntry::Watcher(watcher) => &watcher.expression,
128 DapEntry::Variable(dap) => &dap.name,
129 DapEntry::Scope(dap) => &dap.name,
130 }
131 }
132}
133
134#[derive(Debug, Clone, PartialEq)]
135struct ListEntry {
136 entry: DapEntry,
137 path: EntryPath,
138}
139
140impl ListEntry {
141 fn as_watcher(&self) -> Option<&Watcher> {
142 self.entry.as_watcher()
143 }
144
145 fn as_variable(&self) -> Option<&dap::Variable> {
146 self.entry.as_variable()
147 }
148
149 fn as_scope(&self) -> Option<&dap::Scope> {
150 self.entry.as_scope()
151 }
152
153 fn item_id(&self) -> ElementId {
154 use std::fmt::Write;
155 let mut id = match &self.entry {
156 DapEntry::Watcher(watcher) => format!("watcher-{}", watcher.expression),
157 DapEntry::Variable(dap) => format!("variable-{}", dap.name),
158 DapEntry::Scope(dap) => format!("scope-{}", dap.name),
159 };
160 for name in self.path.indices.iter() {
161 _ = write!(id, "-{}", name);
162 }
163 SharedString::from(id).into()
164 }
165
166 fn item_value_id(&self) -> ElementId {
167 use std::fmt::Write;
168 let mut id = match &self.entry {
169 DapEntry::Watcher(watcher) => format!("watcher-{}", watcher.expression),
170 DapEntry::Variable(dap) => format!("variable-{}", dap.name),
171 DapEntry::Scope(dap) => format!("scope-{}", dap.name),
172 };
173 for name in self.path.indices.iter() {
174 _ = write!(id, "-{}", name);
175 }
176 _ = write!(id, "-value");
177 SharedString::from(id).into()
178 }
179}
180
181struct VariableColor {
182 name: Option<Hsla>,
183 value: Option<Hsla>,
184}
185
186pub struct VariableList {
187 entries: Vec<ListEntry>,
188 entry_states: HashMap<EntryPath, EntryState>,
189 selected_stack_frame_id: Option<StackFrameId>,
190 list_handle: UniformListScrollHandle,
191 session: Entity<Session>,
192 selection: Option<EntryPath>,
193 open_context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
194 focus_handle: FocusHandle,
195 edited_path: Option<(EntryPath, Entity<Editor>)>,
196 disabled: bool,
197 memory_view: Entity<MemoryView>,
198 weak_running: WeakEntity<RunningState>,
199 _subscriptions: Vec<Subscription>,
200}
201
202impl VariableList {
203 pub(crate) fn new(
204 session: Entity<Session>,
205 stack_frame_list: Entity<StackFrameList>,
206 memory_view: Entity<MemoryView>,
207 weak_running: WeakEntity<RunningState>,
208 window: &mut Window,
209 cx: &mut Context<Self>,
210 ) -> Self {
211 let focus_handle = cx.focus_handle();
212
213 let _subscriptions = vec![
214 cx.subscribe(&stack_frame_list, Self::handle_stack_frame_list_events),
215 cx.subscribe(&session, |this, _, event, cx| match event {
216 SessionEvent::Stopped(_) => {
217 this.selection.take();
218 this.edited_path.take();
219 this.selected_stack_frame_id.take();
220 }
221 SessionEvent::Variables | SessionEvent::Watchers => {
222 this.build_entries(cx);
223 }
224
225 _ => {}
226 }),
227 cx.on_focus_out(&focus_handle, window, |this, _, _, cx| {
228 this.edited_path.take();
229 cx.notify();
230 }),
231 ];
232
233 let list_state = UniformListScrollHandle::default();
234
235 Self {
236 list_handle: list_state,
237 session,
238 focus_handle,
239 _subscriptions,
240 selected_stack_frame_id: None,
241 selection: None,
242 open_context_menu: None,
243 disabled: false,
244 edited_path: None,
245 entries: Default::default(),
246 entry_states: Default::default(),
247 weak_running,
248 memory_view,
249 }
250 }
251
252 pub(super) fn disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
253 let old_disabled = std::mem::take(&mut self.disabled);
254 self.disabled = disabled;
255 if old_disabled != disabled {
256 cx.notify();
257 }
258 }
259
260 pub(super) fn has_open_context_menu(&self) -> bool {
261 self.open_context_menu.is_some()
262 }
263
264 fn build_entries(&mut self, cx: &mut Context<Self>) {
265 let Some(stack_frame_id) = self.selected_stack_frame_id else {
266 return;
267 };
268
269 let mut entries = vec![];
270
271 let scopes: Vec<_> = self.session.update(cx, |session, cx| {
272 session.scopes(stack_frame_id, cx).to_vec()
273 });
274
275 let mut contains_local_scope = false;
276
277 let mut stack = scopes
278 .into_iter()
279 .rev()
280 .filter(|scope| {
281 if scope
282 .presentation_hint
283 .as_ref()
284 .map(|hint| *hint == ScopePresentationHint::Locals)
285 .unwrap_or(scope.name.to_lowercase().starts_with("local"))
286 {
287 contains_local_scope = true;
288 }
289
290 self.session.update(cx, |session, cx| {
291 !session.variables(scope.variables_reference, cx).is_empty()
292 })
293 })
294 .map(|scope| {
295 (
296 scope.variables_reference,
297 scope.variables_reference,
298 EntryPath::for_scope(&scope.name),
299 DapEntry::Scope(scope),
300 )
301 })
302 .collect::<Vec<_>>();
303
304 let watches = self.session.read(cx).watchers().clone();
305 stack.extend(
306 watches
307 .into_values()
308 .map(|watcher| {
309 (
310 watcher.variables_reference,
311 watcher.variables_reference,
312 EntryPath::for_watcher(watcher.expression.clone()),
313 DapEntry::Watcher(watcher),
314 )
315 })
316 .collect::<Vec<_>>(),
317 );
318
319 let scopes_count = stack.len();
320
321 while let Some((container_reference, variables_reference, mut path, dap_kind)) = stack.pop()
322 {
323 match &dap_kind {
324 DapEntry::Watcher(watcher) => path = path.with_child(watcher.expression.clone()),
325 DapEntry::Variable(dap) => path = path.with_name(dap.name.clone().into()),
326 DapEntry::Scope(dap) => path = path.with_child(dap.name.clone().into()),
327 }
328
329 let var_state = self
330 .entry_states
331 .entry(path.clone())
332 .and_modify(|state| {
333 state.parent_reference = container_reference;
334 state.has_children = variables_reference != 0;
335 })
336 .or_insert(EntryState {
337 depth: path.indices.len(),
338 is_expanded: dap_kind.as_scope().is_some_and(|scope| {
339 (scopes_count == 1 && !contains_local_scope)
340 || scope
341 .presentation_hint
342 .as_ref()
343 .map(|hint| *hint == ScopePresentationHint::Locals)
344 .unwrap_or(scope.name.to_lowercase().starts_with("local"))
345 }),
346 parent_reference: container_reference,
347 has_children: variables_reference != 0,
348 });
349
350 entries.push(ListEntry {
351 entry: dap_kind,
352 path: path.clone(),
353 });
354
355 if var_state.is_expanded {
356 let children = self
357 .session
358 .update(cx, |session, cx| session.variables(variables_reference, cx));
359 stack.extend(children.into_iter().rev().map(|child| {
360 (
361 variables_reference,
362 child.variables_reference,
363 path.with_child(child.name.clone().into()),
364 DapEntry::Variable(child),
365 )
366 }));
367 }
368 }
369
370 self.entries = entries;
371 cx.notify();
372 }
373
374 fn handle_stack_frame_list_events(
375 &mut self,
376 _: Entity<StackFrameList>,
377 event: &StackFrameListEvent,
378 cx: &mut Context<Self>,
379 ) {
380 match event {
381 StackFrameListEvent::SelectedStackFrameChanged(stack_frame_id) => {
382 self.selected_stack_frame_id = Some(*stack_frame_id);
383 self.session.update(cx, |session, cx| {
384 session.refresh_watchers(*stack_frame_id, cx);
385 });
386 self.build_entries(cx);
387 }
388 StackFrameListEvent::BuiltEntries => {}
389 }
390 }
391
392 pub fn completion_variables(&self, _cx: &mut Context<Self>) -> Vec<dap::Variable> {
393 self.entries
394 .iter()
395 .filter_map(|entry| match &entry.entry {
396 DapEntry::Variable(dap) => Some(dap.clone()),
397 DapEntry::Scope(_) | DapEntry::Watcher { .. } => None,
398 })
399 .collect()
400 }
401
402 fn render_entries(
403 &mut self,
404 ix: Range<usize>,
405 window: &mut Window,
406 cx: &mut Context<Self>,
407 ) -> Vec<AnyElement> {
408 ix.into_iter()
409 .filter_map(|ix| {
410 let (entry, state) = self
411 .entries
412 .get(ix)
413 .and_then(|entry| Some(entry).zip(self.entry_states.get(&entry.path)))?;
414
415 match &entry.entry {
416 DapEntry::Watcher { .. } => {
417 Some(self.render_watcher(entry, *state, window, cx))
418 }
419 DapEntry::Variable(_) => Some(self.render_variable(entry, *state, window, cx)),
420 DapEntry::Scope(_) => Some(self.render_scope(entry, *state, cx)),
421 }
422 })
423 .collect()
424 }
425
426 pub(crate) fn toggle_entry(&mut self, var_path: &EntryPath, cx: &mut Context<Self>) {
427 let Some(entry) = self.entry_states.get_mut(var_path) else {
428 log::error!("Could not find variable list entry state to toggle");
429 return;
430 };
431
432 entry.is_expanded = !entry.is_expanded;
433 self.build_entries(cx);
434 }
435
436 fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
437 self.cancel(&Default::default(), window, cx);
438 if let Some(variable) = self.entries.first() {
439 self.selection = Some(variable.path.clone());
440 self.build_entries(cx);
441 }
442 }
443
444 fn select_last(&mut self, _: &SelectLast, window: &mut Window, cx: &mut Context<Self>) {
445 self.cancel(&Default::default(), window, cx);
446 if let Some(variable) = self.entries.last() {
447 self.selection = Some(variable.path.clone());
448 self.build_entries(cx);
449 }
450 }
451
452 fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
453 self.cancel(&Default::default(), window, cx);
454 if let Some(selection) = &self.selection {
455 let index = self.entries.iter().enumerate().find_map(|(ix, var)| {
456 if &var.path == selection && ix > 0 {
457 Some(ix.saturating_sub(1))
458 } else {
459 None
460 }
461 });
462
463 if let Some(new_selection) =
464 index.and_then(|ix| self.entries.get(ix).map(|var| var.path.clone()))
465 {
466 self.selection = Some(new_selection);
467 self.build_entries(cx);
468 } else {
469 self.select_last(&SelectLast, window, cx);
470 }
471 } else {
472 self.select_last(&SelectLast, window, cx);
473 }
474 }
475
476 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
477 self.cancel(&Default::default(), window, cx);
478 if let Some(selection) = &self.selection {
479 let index = self.entries.iter().enumerate().find_map(|(ix, var)| {
480 if &var.path == selection {
481 Some(ix.saturating_add(1))
482 } else {
483 None
484 }
485 });
486
487 if let Some(new_selection) =
488 index.and_then(|ix| self.entries.get(ix).map(|var| var.path.clone()))
489 {
490 self.selection = Some(new_selection);
491 self.build_entries(cx);
492 } else {
493 self.select_first(&SelectFirst, window, cx);
494 }
495 } else {
496 self.select_first(&SelectFirst, window, cx);
497 }
498 }
499
500 fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
501 self.edited_path.take();
502 self.focus_handle.focus(window);
503 cx.notify();
504 }
505
506 fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
507 if let Some((var_path, editor)) = self.edited_path.take() {
508 let Some(state) = self.entry_states.get(&var_path) else {
509 return;
510 };
511
512 let variables_reference = state.parent_reference;
513 let Some(name) = var_path.leaf_name else {
514 return;
515 };
516
517 let Some(stack_frame_id) = self.selected_stack_frame_id else {
518 return;
519 };
520
521 let value = editor.read(cx).text(cx);
522
523 self.session.update(cx, |session, cx| {
524 session.set_variable_value(
525 stack_frame_id,
526 variables_reference,
527 name.into(),
528 value,
529 cx,
530 )
531 });
532 }
533 }
534
535 fn collapse_selected_entry(
536 &mut self,
537 _: &CollapseSelectedEntry,
538 window: &mut Window,
539 cx: &mut Context<Self>,
540 ) {
541 if let Some(ref selected_entry) = self.selection {
542 let Some(entry_state) = self.entry_states.get_mut(selected_entry) else {
543 debug_panic!("Trying to toggle variable in variable list that has an no state");
544 return;
545 };
546
547 if !entry_state.is_expanded || !entry_state.has_children {
548 self.select_prev(&SelectPrevious, window, cx);
549 } else {
550 entry_state.is_expanded = false;
551 self.build_entries(cx);
552 }
553 }
554 }
555
556 fn expand_selected_entry(
557 &mut self,
558 _: &ExpandSelectedEntry,
559 window: &mut Window,
560 cx: &mut Context<Self>,
561 ) {
562 if let Some(selected_entry) = &self.selection {
563 let Some(entry_state) = self.entry_states.get_mut(selected_entry) else {
564 debug_panic!("Trying to toggle variable in variable list that has an no state");
565 return;
566 };
567
568 if entry_state.is_expanded || !entry_state.has_children {
569 self.select_next(&SelectNext, window, cx);
570 } else {
571 entry_state.is_expanded = true;
572 self.build_entries(cx);
573 }
574 }
575 }
576
577 fn jump_to_variable_memory(
578 &mut self,
579 _: &GoToMemory,
580 window: &mut Window,
581 cx: &mut Context<Self>,
582 ) {
583 _ = maybe!({
584 let selection = self.selection.as_ref()?;
585 let entry = self.entries.iter().find(|entry| &entry.path == selection)?;
586 let var = entry.entry.as_variable()?;
587 let memory_reference = var.memory_reference.as_deref()?;
588
589 let sizeof_expr = if var.type_.as_ref().is_some_and(|t| {
590 t.chars()
591 .all(|c| c.is_whitespace() || c.is_alphabetic() || c == '*')
592 }) {
593 var.type_.as_deref()
594 } else {
595 var.evaluate_name
596 .as_deref()
597 .map(|name| name.strip_prefix("/nat ").unwrap_or_else(|| name))
598 };
599 self.memory_view.update(cx, |this, cx| {
600 this.go_to_memory_reference(
601 memory_reference,
602 sizeof_expr,
603 self.selected_stack_frame_id,
604 cx,
605 );
606 });
607 let weak_panel = self.weak_running.clone();
608
609 window.defer(cx, move |window, cx| {
610 _ = weak_panel.update(cx, |this, cx| {
611 this.activate_item(
612 crate::persistence::DebuggerPaneItem::MemoryView,
613 window,
614 cx,
615 );
616 });
617 });
618 Some(())
619 });
620 }
621
622 fn deploy_list_entry_context_menu(
623 &mut self,
624 entry: ListEntry,
625 position: Point<Pixels>,
626 window: &mut Window,
627 cx: &mut Context<Self>,
628 ) {
629 let (supports_set_variable, supports_data_breakpoints, supports_go_to_memory) =
630 self.session.read_with(cx, |session, _| {
631 (
632 session
633 .capabilities()
634 .supports_set_variable
635 .unwrap_or_default(),
636 session
637 .capabilities()
638 .supports_data_breakpoints
639 .unwrap_or_default(),
640 session
641 .capabilities()
642 .supports_read_memory_request
643 .unwrap_or_default(),
644 )
645 });
646 let can_toggle_data_breakpoint = entry
647 .as_variable()
648 .filter(|_| supports_data_breakpoints)
649 .and_then(|variable| {
650 let variables_reference = self
651 .entry_states
652 .get(&entry.path)
653 .map(|state| state.parent_reference)?;
654 Some(self.session.update(cx, |session, cx| {
655 session.data_breakpoint_info(
656 Arc::new(DataBreakpointContext::Variable {
657 variables_reference,
658 name: variable.name.clone(),
659 bytes: None,
660 }),
661 None,
662 cx,
663 )
664 }))
665 });
666
667 let focus_handle = self.focus_handle.clone();
668 cx.spawn_in(window, async move |this, cx| {
669 let can_toggle_data_breakpoint = if let Some(task) = can_toggle_data_breakpoint {
670 task.await
671 } else {
672 None
673 };
674 cx.update(|window, cx| {
675 let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
676 menu.when_some(entry.as_variable(), |menu, _| {
677 menu.action("Copy Name", CopyVariableName.boxed_clone())
678 .action("Copy Value", CopyVariableValue.boxed_clone())
679 .when(supports_set_variable, |menu| {
680 menu.action("Edit Value", EditVariable.boxed_clone())
681 })
682 .when(supports_go_to_memory, |menu| {
683 menu.action("Go To Memory", GoToMemory.boxed_clone())
684 })
685 .action("Watch Variable", AddWatch.boxed_clone())
686 .when_some(can_toggle_data_breakpoint, |mut menu, data_info| {
687 menu = menu.separator();
688 if let Some(access_types) = data_info.access_types {
689 for access in access_types {
690 menu = menu.action(
691 format!(
692 "Toggle {} Data Breakpoint",
693 match access {
694 dap::DataBreakpointAccessType::Read => "Read",
695 dap::DataBreakpointAccessType::Write => "Write",
696 dap::DataBreakpointAccessType::ReadWrite =>
697 "Read/Write",
698 }
699 ),
700 crate::ToggleDataBreakpoint {
701 access_type: Some(access),
702 }
703 .boxed_clone(),
704 );
705 }
706
707 menu
708 } else {
709 menu.action(
710 "Toggle Data Breakpoint",
711 crate::ToggleDataBreakpoint { access_type: None }
712 .boxed_clone(),
713 )
714 }
715 })
716 })
717 .when(entry.as_watcher().is_some(), |menu| {
718 menu.action("Copy Name", CopyVariableName.boxed_clone())
719 .action("Copy Value", CopyVariableValue.boxed_clone())
720 .when(supports_set_variable, |menu| {
721 menu.action("Edit Value", EditVariable.boxed_clone())
722 })
723 .action("Remove Watch", RemoveWatch.boxed_clone())
724 })
725 .context(focus_handle.clone())
726 });
727
728 _ = this.update(cx, |this, cx| {
729 cx.focus_view(&context_menu, window);
730 let subscription = cx.subscribe_in(
731 &context_menu,
732 window,
733 |this, _, _: &DismissEvent, window, cx| {
734 if this.open_context_menu.as_ref().is_some_and(|context_menu| {
735 context_menu.0.focus_handle(cx).contains_focused(window, cx)
736 }) {
737 cx.focus_self(window);
738 }
739 this.open_context_menu.take();
740 cx.notify();
741 },
742 );
743
744 this.open_context_menu = Some((context_menu, position, subscription));
745 });
746 })
747 })
748 .detach();
749 }
750
751 fn toggle_data_breakpoint(
752 &mut self,
753 data_info: &crate::ToggleDataBreakpoint,
754 _window: &mut Window,
755 cx: &mut Context<Self>,
756 ) {
757 let Some(entry) = self
758 .selection
759 .as_ref()
760 .and_then(|selection| self.entries.iter().find(|entry| &entry.path == selection))
761 else {
762 return;
763 };
764
765 let Some((name, var_ref)) = entry.as_variable().map(|var| &var.name).zip(
766 self.entry_states
767 .get(&entry.path)
768 .map(|state| state.parent_reference),
769 ) else {
770 return;
771 };
772
773 let context = Arc::new(DataBreakpointContext::Variable {
774 variables_reference: var_ref,
775 name: name.clone(),
776 bytes: None,
777 });
778 let data_breakpoint = self.session.update(cx, |session, cx| {
779 session.data_breakpoint_info(context.clone(), None, cx)
780 });
781
782 let session = self.session.downgrade();
783 let access_type = data_info.access_type;
784 cx.spawn(async move |_, cx| {
785 let Some((data_id, access_types)) = data_breakpoint
786 .await
787 .and_then(|info| Some((info.data_id?, info.access_types)))
788 else {
789 return;
790 };
791
792 // Because user's can manually add this action to the keymap
793 // we check if access type is supported
794 let access_type = match access_types {
795 None => None,
796 Some(access_types) => {
797 if access_type.is_some_and(|access_type| access_types.contains(&access_type)) {
798 access_type
799 } else {
800 None
801 }
802 }
803 };
804 _ = session.update(cx, |session, cx| {
805 session.create_data_breakpoint(
806 context,
807 data_id.clone(),
808 dap::DataBreakpoint {
809 data_id,
810 access_type,
811 condition: None,
812 hit_condition: None,
813 },
814 cx,
815 );
816 cx.notify();
817 });
818 })
819 .detach();
820 }
821
822 fn copy_variable_name(
823 &mut self,
824 _: &CopyVariableName,
825 _window: &mut Window,
826 cx: &mut Context<Self>,
827 ) {
828 let Some(selection) = self.selection.as_ref() else {
829 return;
830 };
831
832 let Some(entry) = self.entries.iter().find(|entry| &entry.path == selection) else {
833 return;
834 };
835
836 let variable_name = match &entry.entry {
837 DapEntry::Variable(dap) => dap.name.clone(),
838 DapEntry::Watcher(watcher) => watcher.expression.to_string(),
839 DapEntry::Scope(_) => return,
840 };
841
842 cx.write_to_clipboard(ClipboardItem::new_string(variable_name));
843 }
844
845 fn copy_variable_value(
846 &mut self,
847 _: &CopyVariableValue,
848 _window: &mut Window,
849 cx: &mut Context<Self>,
850 ) {
851 let Some(selection) = self.selection.as_ref() else {
852 return;
853 };
854
855 let Some(entry) = self.entries.iter().find(|entry| &entry.path == selection) else {
856 return;
857 };
858
859 let variable_value = match &entry.entry {
860 DapEntry::Variable(dap) => dap.value.clone(),
861 DapEntry::Watcher(watcher) => watcher.value.to_string(),
862 DapEntry::Scope(_) => return,
863 };
864
865 cx.write_to_clipboard(ClipboardItem::new_string(variable_value));
866 }
867
868 fn edit_variable(&mut self, _: &EditVariable, window: &mut Window, cx: &mut Context<Self>) {
869 let Some(selection) = self.selection.as_ref() else {
870 return;
871 };
872
873 let Some(entry) = self.entries.iter().find(|entry| &entry.path == selection) else {
874 return;
875 };
876
877 let variable_value = match &entry.entry {
878 DapEntry::Watcher(watcher) => watcher.value.to_string(),
879 DapEntry::Variable(variable) => variable.value.clone(),
880 DapEntry::Scope(_) => return,
881 };
882
883 let editor = Self::create_variable_editor(&variable_value, window, cx);
884 self.edited_path = Some((entry.path.clone(), editor));
885
886 cx.notify();
887 }
888
889 fn add_watcher(&mut self, _: &AddWatch, _: &mut Window, cx: &mut Context<Self>) {
890 let Some(selection) = self.selection.as_ref() else {
891 return;
892 };
893
894 let Some(entry) = self.entries.iter().find(|entry| &entry.path == selection) else {
895 return;
896 };
897
898 let Some(variable) = entry.as_variable() else {
899 return;
900 };
901
902 let Some(stack_frame_id) = self.selected_stack_frame_id else {
903 return;
904 };
905
906 let add_watcher_task = self.session.update(cx, |session, cx| {
907 let expression = variable
908 .evaluate_name
909 .clone()
910 .unwrap_or_else(|| variable.name.clone());
911
912 session.add_watcher(expression.into(), stack_frame_id, cx)
913 });
914
915 cx.spawn(async move |this, cx| {
916 add_watcher_task.await?;
917
918 this.update(cx, |this, cx| {
919 this.build_entries(cx);
920 })
921 })
922 .detach_and_log_err(cx);
923 }
924
925 fn remove_watcher(&mut self, _: &RemoveWatch, _: &mut Window, cx: &mut Context<Self>) {
926 let Some(selection) = self.selection.as_ref() else {
927 return;
928 };
929
930 let Some(entry) = self.entries.iter().find(|entry| &entry.path == selection) else {
931 return;
932 };
933
934 let Some(watcher) = entry.as_watcher() else {
935 return;
936 };
937
938 self.session.update(cx, |session, _| {
939 session.remove_watcher(watcher.expression.clone());
940 });
941 self.build_entries(cx);
942 }
943
944 #[track_caller]
945 #[cfg(test)]
946 pub(crate) fn assert_visual_entries(&self, expected: Vec<&str>) {
947 const INDENT: &str = " ";
948
949 let entries = &self.entries;
950 let mut visual_entries = Vec::with_capacity(entries.len());
951 for entry in entries {
952 let state = self
953 .entry_states
954 .get(&entry.path)
955 .expect("If there's a variable entry there has to be a state that goes with it");
956
957 visual_entries.push(format!(
958 "{}{} {}{}",
959 INDENT.repeat(state.depth - 1),
960 if state.is_expanded { "v" } else { ">" },
961 entry.entry.name(),
962 if self.selection.as_ref() == Some(&entry.path) {
963 " <=== selected"
964 } else {
965 ""
966 }
967 ));
968 }
969
970 pretty_assertions::assert_eq!(expected, visual_entries);
971 }
972
973 #[track_caller]
974 #[cfg(test)]
975 pub(crate) fn scopes(&self) -> Vec<dap::Scope> {
976 self.entries
977 .iter()
978 .filter_map(|entry| match &entry.entry {
979 DapEntry::Scope(scope) => Some(scope),
980 _ => None,
981 })
982 .cloned()
983 .collect()
984 }
985
986 #[track_caller]
987 #[cfg(test)]
988 pub(crate) fn variables_per_scope(&self) -> Vec<(dap::Scope, Vec<dap::Variable>)> {
989 let mut scopes: Vec<(dap::Scope, Vec<_>)> = Vec::new();
990 let mut idx = 0;
991
992 for entry in self.entries.iter() {
993 match &entry.entry {
994 DapEntry::Watcher { .. } => continue,
995 DapEntry::Variable(dap) => scopes[idx].1.push(dap.clone()),
996 DapEntry::Scope(scope) => {
997 if !scopes.is_empty() {
998 idx += 1;
999 }
1000
1001 scopes.push((scope.clone(), Vec::new()));
1002 }
1003 }
1004 }
1005
1006 scopes
1007 }
1008
1009 #[track_caller]
1010 #[cfg(test)]
1011 pub(crate) fn variables(&self) -> Vec<dap::Variable> {
1012 self.entries
1013 .iter()
1014 .filter_map(|entry| match &entry.entry {
1015 DapEntry::Variable(variable) => Some(variable),
1016 _ => None,
1017 })
1018 .cloned()
1019 .collect()
1020 }
1021
1022 fn create_variable_editor(default: &str, window: &mut Window, cx: &mut App) -> Entity<Editor> {
1023 let editor = cx.new(|cx| {
1024 let mut editor = Editor::single_line(window, cx);
1025
1026 let refinement = TextStyleRefinement {
1027 font_size: Some(
1028 TextSize::XSmall
1029 .rems(cx)
1030 .to_pixels(window.rem_size())
1031 .into(),
1032 ),
1033 ..Default::default()
1034 };
1035 editor.set_text_style_refinement(refinement);
1036 editor.set_text(default, window, cx);
1037 editor.select_all(&editor::actions::SelectAll, window, cx);
1038 editor
1039 });
1040 editor.focus_handle(cx).focus(window);
1041 editor
1042 }
1043
1044 fn variable_color(
1045 &self,
1046 presentation_hint: Option<&VariablePresentationHint>,
1047 cx: &Context<Self>,
1048 ) -> VariableColor {
1049 let syntax_color_for = |name| cx.theme().syntax().get(name).color;
1050 let name = if self.disabled {
1051 Some(Color::Disabled.color(cx))
1052 } else {
1053 match presentation_hint
1054 .as_ref()
1055 .and_then(|hint| hint.kind.as_ref())
1056 .unwrap_or(&VariablePresentationHintKind::Unknown)
1057 {
1058 VariablePresentationHintKind::Class
1059 | VariablePresentationHintKind::BaseClass
1060 | VariablePresentationHintKind::InnerClass
1061 | VariablePresentationHintKind::MostDerivedClass => syntax_color_for("type"),
1062 VariablePresentationHintKind::Data => syntax_color_for("variable"),
1063 VariablePresentationHintKind::Unknown | _ => syntax_color_for("variable"),
1064 }
1065 };
1066 let value = self
1067 .disabled
1068 .then(|| Color::Disabled.color(cx))
1069 .or_else(|| syntax_color_for("variable.special"));
1070
1071 VariableColor { name, value }
1072 }
1073
1074 fn render_variable_value(
1075 &self,
1076 entry: &ListEntry,
1077 variable_color: &VariableColor,
1078 value: String,
1079 cx: &mut Context<Self>,
1080 ) -> AnyElement {
1081 if !value.is_empty() {
1082 div()
1083 .w_full()
1084 .id(entry.item_value_id())
1085 .map(|this| {
1086 if let Some((_, editor)) = self
1087 .edited_path
1088 .as_ref()
1089 .filter(|(path, _)| path == &entry.path)
1090 {
1091 this.child(div().size_full().px_2().child(editor.clone()))
1092 } else {
1093 this.text_color(cx.theme().colors().text_muted)
1094 .when(
1095 !self.disabled
1096 && self
1097 .session
1098 .read(cx)
1099 .capabilities()
1100 .supports_set_variable
1101 .unwrap_or_default(),
1102 |this| {
1103 let path = entry.path.clone();
1104 let variable_value = value.clone();
1105 this.on_click(cx.listener(
1106 move |this, click: &ClickEvent, window, cx| {
1107 if click.click_count() < 2 {
1108 return;
1109 }
1110 let editor = Self::create_variable_editor(
1111 &variable_value,
1112 window,
1113 cx,
1114 );
1115 this.edited_path = Some((path.clone(), editor));
1116
1117 cx.notify();
1118 },
1119 ))
1120 },
1121 )
1122 .child(
1123 Label::new(format!("= {}", &value))
1124 .single_line()
1125 .truncate()
1126 .size(LabelSize::Small)
1127 .color(Color::Muted)
1128 .when_some(variable_color.value, |this, color| {
1129 this.color(Color::from(color))
1130 }),
1131 )
1132 .tooltip(Tooltip::text(value))
1133 }
1134 })
1135 .into_any_element()
1136 } else {
1137 Empty.into_any_element()
1138 }
1139 }
1140
1141 fn center_truncate_string(s: &str, mut max_chars: usize) -> String {
1142 const ELLIPSIS: &str = "...";
1143 const MIN_LENGTH: usize = 3;
1144
1145 max_chars = max_chars.max(MIN_LENGTH);
1146
1147 let char_count = s.chars().count();
1148 if char_count <= max_chars {
1149 return s.to_string();
1150 }
1151
1152 if ELLIPSIS.len() + MIN_LENGTH > max_chars {
1153 return s.chars().take(MIN_LENGTH).collect();
1154 }
1155
1156 let available_chars = max_chars - ELLIPSIS.len();
1157
1158 let start_chars = available_chars / 2;
1159 let end_chars = available_chars - start_chars;
1160 let skip_chars = char_count - end_chars;
1161
1162 let mut start_boundary = 0;
1163 let mut end_boundary = s.len();
1164
1165 for (i, (byte_idx, _)) in s.char_indices().enumerate() {
1166 if i == start_chars {
1167 start_boundary = byte_idx.max(MIN_LENGTH);
1168 }
1169
1170 if i == skip_chars {
1171 end_boundary = byte_idx;
1172 }
1173 }
1174
1175 if start_boundary >= end_boundary {
1176 return s.chars().take(MIN_LENGTH).collect();
1177 }
1178
1179 format!("{}{}{}", &s[..start_boundary], ELLIPSIS, &s[end_boundary..])
1180 }
1181
1182 fn render_watcher(
1183 &self,
1184 entry: &ListEntry,
1185 state: EntryState,
1186 _window: &mut Window,
1187 cx: &mut Context<Self>,
1188 ) -> AnyElement {
1189 let Some(watcher) = &entry.as_watcher() else {
1190 debug_panic!("Called render watcher on non watcher variable list entry variant");
1191 return div().into_any_element();
1192 };
1193
1194 let variable_color = self.variable_color(watcher.presentation_hint.as_ref(), cx);
1195
1196 let is_selected = self
1197 .selection
1198 .as_ref()
1199 .is_some_and(|selection| selection == &entry.path);
1200 let var_ref = watcher.variables_reference;
1201
1202 let colors = get_entry_color(cx);
1203 let bg_hover_color = if !is_selected {
1204 colors.hover
1205 } else {
1206 colors.default
1207 };
1208 let border_color = if is_selected {
1209 colors.marked_active
1210 } else {
1211 colors.default
1212 };
1213 let path = entry.path.clone();
1214
1215 let weak = cx.weak_entity();
1216 let focus_handle = self.focus_handle.clone();
1217 let watcher_len = (f32::from(self.list_handle.content_size().width / 12.0).floor()) - 3.0;
1218 let watcher_len = watcher_len as usize;
1219
1220 div()
1221 .id(entry.item_id())
1222 .group("variable_list_entry")
1223 .pl_2()
1224 .border_1()
1225 .border_r_2()
1226 .border_color(border_color)
1227 .flex()
1228 .w_full()
1229 .h_full()
1230 .hover(|style| style.bg(bg_hover_color))
1231 .on_click(cx.listener({
1232 let path = path.clone();
1233 move |this, _, _window, cx| {
1234 this.selection = Some(path.clone());
1235 cx.notify();
1236 }
1237 }))
1238 .child(
1239 ListItem::new(SharedString::from(format!(
1240 "watcher-{}",
1241 watcher.expression
1242 )))
1243 .selectable(false)
1244 .disabled(self.disabled)
1245 .selectable(false)
1246 .indent_level(state.depth)
1247 .indent_step_size(px(10.))
1248 .always_show_disclosure_icon(true)
1249 .when(var_ref > 0, |list_item| {
1250 list_item.toggle(state.is_expanded).on_toggle(cx.listener({
1251 let var_path = entry.path.clone();
1252 move |this, _, _, cx| {
1253 this.session.update(cx, |session, cx| {
1254 session.variables(var_ref, cx);
1255 });
1256
1257 this.toggle_entry(&var_path, cx);
1258 }
1259 }))
1260 })
1261 .on_secondary_mouse_down(cx.listener({
1262 let path = path.clone();
1263 let entry = entry.clone();
1264 move |this, event: &MouseDownEvent, window, cx| {
1265 this.selection = Some(path.clone());
1266 this.deploy_list_entry_context_menu(
1267 entry.clone(),
1268 event.position,
1269 window,
1270 cx,
1271 );
1272 cx.stop_propagation();
1273 }
1274 }))
1275 .child(
1276 h_flex()
1277 .gap_1()
1278 .text_ui_sm(cx)
1279 .w_full()
1280 .child(
1281 Label::new(&Self::center_truncate_string(
1282 watcher.expression.as_ref(),
1283 watcher_len,
1284 ))
1285 .when_some(variable_color.name, |this, color| {
1286 this.color(Color::from(color))
1287 }),
1288 )
1289 .child(self.render_variable_value(
1290 entry,
1291 &variable_color,
1292 watcher.value.to_string(),
1293 cx,
1294 )),
1295 )
1296 .end_slot(
1297 IconButton::new(
1298 SharedString::from(format!("watcher-{}-remove-button", watcher.expression)),
1299 IconName::Close,
1300 )
1301 .on_click({
1302 move |_, window, cx| {
1303 weak.update(cx, |variable_list, cx| {
1304 variable_list.selection = Some(path.clone());
1305 variable_list.remove_watcher(&RemoveWatch, window, cx);
1306 })
1307 .ok();
1308 }
1309 })
1310 .tooltip(move |_window, cx| {
1311 Tooltip::for_action_in("Remove Watch", &RemoveWatch, &focus_handle, cx)
1312 })
1313 .icon_size(ui::IconSize::Indicator),
1314 ),
1315 )
1316 .into_any()
1317 }
1318
1319 fn render_scope(
1320 &self,
1321 entry: &ListEntry,
1322 state: EntryState,
1323 cx: &mut Context<Self>,
1324 ) -> AnyElement {
1325 let Some(scope) = entry.as_scope() else {
1326 debug_panic!("Called render scope on non scope variable list entry variant");
1327 return div().into_any_element();
1328 };
1329
1330 let var_ref = scope.variables_reference;
1331 let is_selected = self
1332 .selection
1333 .as_ref()
1334 .is_some_and(|selection| selection == &entry.path);
1335
1336 let colors = get_entry_color(cx);
1337 let bg_hover_color = if !is_selected {
1338 colors.hover
1339 } else {
1340 colors.default
1341 };
1342 let border_color = if is_selected {
1343 colors.marked_active
1344 } else {
1345 colors.default
1346 };
1347 let path = entry.path.clone();
1348
1349 div()
1350 .id(var_ref as usize)
1351 .group("variable_list_entry")
1352 .pl_2()
1353 .border_1()
1354 .border_r_2()
1355 .border_color(border_color)
1356 .flex()
1357 .w_full()
1358 .h_full()
1359 .hover(|style| style.bg(bg_hover_color))
1360 .on_click(cx.listener({
1361 move |this, _, _window, cx| {
1362 this.selection = Some(path.clone());
1363 cx.notify();
1364 }
1365 }))
1366 .child(
1367 ListItem::new(SharedString::from(format!("scope-{}", var_ref)))
1368 .selectable(false)
1369 .disabled(self.disabled)
1370 .indent_level(state.depth)
1371 .indent_step_size(px(10.))
1372 .always_show_disclosure_icon(true)
1373 .toggle(state.is_expanded)
1374 .on_toggle({
1375 let var_path = entry.path.clone();
1376 cx.listener(move |this, _, _, cx| this.toggle_entry(&var_path, cx))
1377 })
1378 .child(
1379 div()
1380 .text_ui(cx)
1381 .w_full()
1382 .when(self.disabled, |this| {
1383 this.text_color(Color::Disabled.color(cx))
1384 })
1385 .child(scope.name.clone()),
1386 ),
1387 )
1388 .into_any()
1389 }
1390
1391 fn render_variable(
1392 &self,
1393 variable: &ListEntry,
1394 state: EntryState,
1395 window: &mut Window,
1396 cx: &mut Context<Self>,
1397 ) -> AnyElement {
1398 let Some(dap) = &variable.as_variable() else {
1399 debug_panic!("Called render variable on non variable variable list entry variant");
1400 return div().into_any_element();
1401 };
1402
1403 let variable_color = self.variable_color(dap.presentation_hint.as_ref(), cx);
1404
1405 let var_ref = dap.variables_reference;
1406 let colors = get_entry_color(cx);
1407 let is_selected = self
1408 .selection
1409 .as_ref()
1410 .is_some_and(|selected_path| *selected_path == variable.path);
1411
1412 let bg_hover_color = if !is_selected {
1413 colors.hover
1414 } else {
1415 colors.default
1416 };
1417 let border_color = if is_selected && self.focus_handle.contains_focused(window, cx) {
1418 colors.marked_active
1419 } else {
1420 colors.default
1421 };
1422 let path = variable.path.clone();
1423 div()
1424 .id(variable.item_id())
1425 .group("variable_list_entry")
1426 .pl_2()
1427 .border_1()
1428 .border_r_2()
1429 .border_color(border_color)
1430 .h_4()
1431 .size_full()
1432 .hover(|style| style.bg(bg_hover_color))
1433 .on_click(cx.listener({
1434 let path = path.clone();
1435 move |this, _, _window, cx| {
1436 this.selection = Some(path.clone());
1437 cx.notify();
1438 }
1439 }))
1440 .child(
1441 ListItem::new(SharedString::from(format!(
1442 "variable-item-{}-{}",
1443 dap.name, state.depth
1444 )))
1445 .disabled(self.disabled)
1446 .selectable(false)
1447 .indent_level(state.depth)
1448 .indent_step_size(px(10.))
1449 .always_show_disclosure_icon(true)
1450 .when(var_ref > 0, |list_item| {
1451 list_item.toggle(state.is_expanded).on_toggle(cx.listener({
1452 let var_path = variable.path.clone();
1453 move |this, _, _, cx| {
1454 this.session.update(cx, |session, cx| {
1455 session.variables(var_ref, cx);
1456 });
1457
1458 this.toggle_entry(&var_path, cx);
1459 }
1460 }))
1461 })
1462 .on_secondary_mouse_down(cx.listener({
1463 let entry = variable.clone();
1464 move |this, event: &MouseDownEvent, window, cx| {
1465 this.selection = Some(path.clone());
1466 this.deploy_list_entry_context_menu(
1467 entry.clone(),
1468 event.position,
1469 window,
1470 cx,
1471 );
1472 cx.stop_propagation();
1473 }
1474 }))
1475 .child(
1476 h_flex()
1477 .gap_1()
1478 .text_ui_sm(cx)
1479 .w_full()
1480 .child(
1481 Label::new(&dap.name).when_some(variable_color.name, |this, color| {
1482 this.color(Color::from(color))
1483 }),
1484 )
1485 .child(self.render_variable_value(
1486 variable,
1487 &variable_color,
1488 dap.value.clone(),
1489 cx,
1490 )),
1491 ),
1492 )
1493 .into_any()
1494 }
1495}
1496
1497impl Focusable for VariableList {
1498 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
1499 self.focus_handle.clone()
1500 }
1501}
1502
1503impl Render for VariableList {
1504 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1505 v_flex()
1506 .track_focus(&self.focus_handle)
1507 .key_context("VariableList")
1508 .id("variable-list")
1509 .group("variable-list")
1510 .overflow_y_scroll()
1511 .size_full()
1512 .on_action(cx.listener(Self::select_first))
1513 .on_action(cx.listener(Self::select_last))
1514 .on_action(cx.listener(Self::select_prev))
1515 .on_action(cx.listener(Self::select_next))
1516 .on_action(cx.listener(Self::cancel))
1517 .on_action(cx.listener(Self::confirm))
1518 .on_action(cx.listener(Self::expand_selected_entry))
1519 .on_action(cx.listener(Self::collapse_selected_entry))
1520 .on_action(cx.listener(Self::copy_variable_name))
1521 .on_action(cx.listener(Self::copy_variable_value))
1522 .on_action(cx.listener(Self::edit_variable))
1523 .on_action(cx.listener(Self::add_watcher))
1524 .on_action(cx.listener(Self::remove_watcher))
1525 .on_action(cx.listener(Self::toggle_data_breakpoint))
1526 .on_action(cx.listener(Self::jump_to_variable_memory))
1527 .child(
1528 uniform_list(
1529 "variable-list",
1530 self.entries.len(),
1531 cx.processor(move |this, range: Range<usize>, window, cx| {
1532 this.render_entries(range, window, cx)
1533 }),
1534 )
1535 .track_scroll(self.list_handle.clone())
1536 .gap_1_5()
1537 .size_full()
1538 .flex_grow(),
1539 )
1540 .children(self.open_context_menu.as_ref().map(|(menu, position, _)| {
1541 deferred(
1542 anchored()
1543 .position(*position)
1544 .anchor(gpui::Corner::TopLeft)
1545 .child(menu.clone()),
1546 )
1547 .with_priority(1)
1548 }))
1549 .vertical_scrollbar_for(self.list_handle.clone(), window, cx)
1550 }
1551}
1552
1553struct EntryColors {
1554 default: Hsla,
1555 hover: Hsla,
1556 marked_active: Hsla,
1557}
1558
1559fn get_entry_color(cx: &Context<VariableList>) -> EntryColors {
1560 let colors = cx.theme().colors();
1561
1562 EntryColors {
1563 default: colors.panel_background,
1564 hover: colors.ghost_element_hover,
1565 marked_active: colors.ghost_element_selected,
1566 }
1567}
1568
1569#[cfg(test)]
1570mod tests {
1571 use super::*;
1572
1573 #[test]
1574 fn test_center_truncate_string() {
1575 // Test string shorter than limit - should not be truncated
1576 assert_eq!(VariableList::center_truncate_string("short", 10), "short");
1577
1578 // Test exact length - should not be truncated
1579 assert_eq!(
1580 VariableList::center_truncate_string("exactly_10", 10),
1581 "exactly_10"
1582 );
1583
1584 // Test simple truncation
1585 assert_eq!(
1586 VariableList::center_truncate_string("value->value2->value3->value4", 20),
1587 "value->v...3->value4"
1588 );
1589
1590 // Test with very long expression
1591 assert_eq!(
1592 VariableList::center_truncate_string(
1593 "object->property1->property2->property3->property4->property5",
1594 30
1595 ),
1596 "object->prope...ty4->property5"
1597 );
1598
1599 // Test edge case with limit equal to ellipsis length
1600 assert_eq!(VariableList::center_truncate_string("anything", 3), "any");
1601
1602 // Test edge case with limit less than ellipsis length
1603 assert_eq!(VariableList::center_truncate_string("anything", 2), "any");
1604
1605 // Test with UTF-8 characters
1606 assert_eq!(
1607 VariableList::center_truncate_string("café->résumé->naïve->voilà", 15),
1608 "café->...>voilà"
1609 );
1610
1611 // Test with emoji (multi-byte UTF-8)
1612 assert_eq!(
1613 VariableList::center_truncate_string("😀->happy->face->😎->cool", 15),
1614 "😀->hap...->cool"
1615 );
1616 }
1617}