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