variable_list.rs

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