debugger: Add ability to only show stack frame entries from visible work trees (#37061)

Anthony Eid created

This PR adds a toggleable filter to the stack frame list that filters
out entries that don't exist within a user's project (visible work
trees). This works by keeping a vector of entry indices that exist
within a user's project and updates the list state based on these
entries when filtering the list.

I went with this approach so the stack frame list wouldn't have to
rebuild itself whenever the filter is toggled and it could persist its
state across toggles (uncollapsing a collapse list). It was also easier
to keep track of selected entries on toggle using the vector as well.

### Preview

https://github.com/user-attachments/assets/d86c7485-c885-4bbb-bebb-2f6385674925



Release Notes:

- debugger: Add option to only show stack frames from user's project in
stack frame list

Change summary

assets/icons/list_filter.svg                               |   1 
crates/dap_adapters/src/python.rs                          |   1 
crates/debugger_ui/src/debugger_ui.rs                      |  17 
crates/debugger_ui/src/persistence.rs                      |   9 
crates/debugger_ui/src/session/running.rs                  |  23 
crates/debugger_ui/src/session/running/stack_frame_list.rs | 199 +++++
crates/debugger_ui/src/tests/stack_frame_list.rs           | 285 ++++++++
crates/icons/src/icons.rs                                  |   1 
8 files changed, 522 insertions(+), 14 deletions(-)

Detailed changes

assets/icons/list_filter.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-filter-icon lucide-list-filter"><path d="M3 6h18"/><path d="M7 12h10"/><path d="M10 18h4"/></svg>

crates/dap_adapters/src/python.rs 🔗

@@ -234,6 +234,7 @@ impl PythonDebugAdapter {
                     .await
                     .map_err(|e| format!("{e:#?}"))?
                     .success();
+
                 if !did_succeed {
                     return Err("Failed to create base virtual environment".into());
                 }

crates/debugger_ui/src/debugger_ui.rs 🔗

@@ -85,6 +85,10 @@ actions!(
         Rerun,
         /// Toggles expansion of the selected item in the debugger UI.
         ToggleExpandItem,
+        /// Toggle the user frame filter in the stack frame list
+        /// When toggled on, only frames from the user's code are shown
+        /// When toggled off, all frames are shown
+        ToggleUserFrames,
     ]
 );
 
@@ -272,12 +276,25 @@ pub fn init(cx: &mut App) {
                     }
                 })
                 .on_action({
+                    let active_item = active_item.clone();
                     move |_: &ToggleIgnoreBreakpoints, _, cx| {
                         active_item
                             .update(cx, |item, cx| item.toggle_ignore_breakpoints(cx))
                             .ok();
                     }
                 })
+                .on_action(move |_: &ToggleUserFrames, _, cx| {
+                    if let Some((thread_status, stack_frame_list)) = active_item
+                        .read_with(cx, |item, cx| {
+                            (item.thread_status(cx), item.stack_frame_list().clone())
+                        })
+                        .ok()
+                    {
+                        stack_frame_list.update(cx, |stack_frame_list, cx| {
+                            stack_frame_list.toggle_frame_filter(thread_status, cx);
+                        })
+                    }
+                })
             });
     })
     .detach();

crates/debugger_ui/src/persistence.rs 🔗

@@ -270,12 +270,9 @@ pub(crate) fn deserialize_pane_layout(
                 .children
                 .iter()
                 .map(|child| match child {
-                    DebuggerPaneItem::Frames => Box::new(SubView::new(
-                        stack_frame_list.focus_handle(cx),
-                        stack_frame_list.clone().into(),
-                        DebuggerPaneItem::Frames,
-                        cx,
-                    )),
+                    DebuggerPaneItem::Frames => {
+                        Box::new(SubView::stack_frame_list(stack_frame_list.clone(), cx))
+                    }
                     DebuggerPaneItem::Variables => Box::new(SubView::new(
                         variable_list.focus_handle(cx),
                         variable_list.clone().into(),

crates/debugger_ui/src/session/running.rs 🔗

@@ -158,6 +158,29 @@ impl SubView {
         })
     }
 
+    pub(crate) fn stack_frame_list(
+        stack_frame_list: Entity<StackFrameList>,
+        cx: &mut App,
+    ) -> Entity<Self> {
+        let weak_list = stack_frame_list.downgrade();
+        let this = Self::new(
+            stack_frame_list.focus_handle(cx),
+            stack_frame_list.into(),
+            DebuggerPaneItem::Frames,
+            cx,
+        );
+
+        this.update(cx, |this, _| {
+            this.with_actions(Box::new(move |_, cx| {
+                weak_list
+                    .update(cx, |this, _| this.render_control_strip())
+                    .unwrap_or_else(|_| div().into_any_element())
+            }));
+        });
+
+        this
+    }
+
     pub(crate) fn console(console: Entity<Console>, cx: &mut App) -> Entity<Self> {
         let weak_console = console.downgrade();
         let this = Self::new(

crates/debugger_ui/src/session/running/stack_frame_list.rs 🔗

@@ -4,16 +4,17 @@ use std::time::Duration;
 
 use anyhow::{Context as _, Result, anyhow};
 use dap::StackFrameId;
+use db::kvp::KEY_VALUE_STORE;
 use gpui::{
-    AnyElement, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, ListState, MouseButton,
-    Stateful, Subscription, Task, WeakEntity, list,
+    Action, AnyElement, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, ListState,
+    MouseButton, Stateful, Subscription, Task, WeakEntity, list,
 };
 use util::debug_panic;
 
-use crate::StackTraceView;
+use crate::{StackTraceView, ToggleUserFrames};
 use language::PointUtf16;
 use project::debugger::breakpoint_store::ActiveStackFrame;
-use project::debugger::session::{Session, SessionEvent, StackFrame};
+use project::debugger::session::{Session, SessionEvent, StackFrame, ThreadStatus};
 use project::{ProjectItem, ProjectPath};
 use ui::{Scrollbar, ScrollbarState, Tooltip, prelude::*};
 use workspace::{ItemHandle, Workspace};
@@ -26,6 +27,34 @@ pub enum StackFrameListEvent {
     BuiltEntries,
 }
 
+/// Represents the filter applied to the stack frame list
+#[derive(PartialEq, Eq, Copy, Clone)]
+enum StackFrameFilter {
+    /// Show all frames
+    All,
+    /// Show only frames from the user's code
+    OnlyUserFrames,
+}
+
+impl StackFrameFilter {
+    fn from_str_or_default(s: impl AsRef<str>) -> Self {
+        match s.as_ref() {
+            "user" => StackFrameFilter::OnlyUserFrames,
+            "all" => StackFrameFilter::All,
+            _ => StackFrameFilter::All,
+        }
+    }
+}
+
+impl From<StackFrameFilter> for String {
+    fn from(filter: StackFrameFilter) -> Self {
+        match filter {
+            StackFrameFilter::All => "all".to_string(),
+            StackFrameFilter::OnlyUserFrames => "user".to_string(),
+        }
+    }
+}
+
 pub struct StackFrameList {
     focus_handle: FocusHandle,
     _subscription: Subscription,
@@ -37,6 +66,8 @@ pub struct StackFrameList {
     opened_stack_frame_id: Option<StackFrameId>,
     scrollbar_state: ScrollbarState,
     list_state: ListState,
+    list_filter: StackFrameFilter,
+    filter_entries_indices: Vec<usize>,
     error: Option<SharedString>,
     _refresh_task: Task<()>,
 }
@@ -73,6 +104,16 @@ impl StackFrameList {
         let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.));
         let scrollbar_state = ScrollbarState::new(list_state.clone());
 
+        let list_filter = KEY_VALUE_STORE
+            .read_kvp(&format!(
+                "stack-frame-list-filter-{}",
+                session.read(cx).adapter().0
+            ))
+            .ok()
+            .flatten()
+            .map(StackFrameFilter::from_str_or_default)
+            .unwrap_or(StackFrameFilter::All);
+
         let mut this = Self {
             session,
             workspace,
@@ -80,9 +121,11 @@ impl StackFrameList {
             state,
             _subscription,
             entries: Default::default(),
+            filter_entries_indices: Vec::default(),
             error: None,
             selected_ix: None,
             opened_stack_frame_id: None,
+            list_filter,
             list_state,
             scrollbar_state,
             _refresh_task: Task::ready(()),
@@ -103,7 +146,15 @@ impl StackFrameList {
     ) -> Vec<dap::StackFrame> {
         self.entries
             .iter()
-            .flat_map(|frame| match frame {
+            .enumerate()
+            .filter(|(ix, _)| {
+                self.list_filter == StackFrameFilter::All
+                    || self
+                        .filter_entries_indices
+                        .binary_search_by_key(&ix, |ix| ix)
+                        .is_ok()
+            })
+            .flat_map(|(_, frame)| match frame {
                 StackFrameEntry::Normal(frame) => vec![frame.clone()],
                 StackFrameEntry::Label(frame) if show_labels => vec![frame.clone()],
                 StackFrameEntry::Collapsed(frames) if show_collapsed => frames.clone(),
@@ -126,7 +177,15 @@ impl StackFrameList {
         self.stack_frames(cx)
             .unwrap_or_default()
             .into_iter()
-            .map(|stack_frame| stack_frame.dap)
+            .enumerate()
+            .filter(|(ix, _)| {
+                self.list_filter == StackFrameFilter::All
+                    || self
+                        .filter_entries_indices
+                        .binary_search_by_key(&ix, |ix| ix)
+                        .is_ok()
+            })
+            .map(|(_, stack_frame)| stack_frame.dap)
             .collect()
     }
 
@@ -192,7 +251,32 @@ impl StackFrameList {
                 return;
             }
         };
-        for stack_frame in &stack_frames {
+
+        let worktree_prefixes: Vec<_> = self
+            .workspace
+            .read_with(cx, |workspace, cx| {
+                workspace
+                    .visible_worktrees(cx)
+                    .map(|tree| tree.read(cx).abs_path())
+                    .collect()
+            })
+            .unwrap_or_default();
+
+        let mut filter_entries_indices = Vec::default();
+        for (ix, stack_frame) in stack_frames.iter().enumerate() {
+            let frame_in_visible_worktree = stack_frame.dap.source.as_ref().is_some_and(|source| {
+                source.path.as_ref().is_some_and(|path| {
+                    worktree_prefixes
+                        .iter()
+                        .filter_map(|tree| tree.to_str())
+                        .any(|tree| path.starts_with(tree))
+                })
+            });
+
+            if frame_in_visible_worktree {
+                filter_entries_indices.push(ix);
+            }
+
             match stack_frame.dap.presentation_hint {
                 Some(dap::StackFramePresentationHint::Deemphasize)
                 | Some(dap::StackFramePresentationHint::Subtle) => {
@@ -225,8 +309,10 @@ impl StackFrameList {
         let collapsed_entries = std::mem::take(&mut collapsed_entries);
         if !collapsed_entries.is_empty() {
             entries.push(StackFrameEntry::Collapsed(collapsed_entries));
+            self.filter_entries_indices.push(entries.len() - 1);
         }
         self.entries = entries;
+        self.filter_entries_indices = filter_entries_indices;
 
         if let Some(ix) = first_stack_frame_with_path
             .or(first_stack_frame)
@@ -242,7 +328,14 @@ impl StackFrameList {
             self.selected_ix = ix;
         }
 
-        self.list_state.reset(self.entries.len());
+        match self.list_filter {
+            StackFrameFilter::All => {
+                self.list_state.reset(self.entries.len());
+            }
+            StackFrameFilter::OnlyUserFrames => {
+                self.list_state.reset(self.filter_entries_indices.len());
+            }
+        }
         cx.emit(StackFrameListEvent::BuiltEntries);
         cx.notify();
     }
@@ -572,6 +665,11 @@ impl StackFrameList {
     }
 
     fn render_entry(&self, ix: usize, cx: &mut Context<Self>) -> AnyElement {
+        let ix = match self.list_filter {
+            StackFrameFilter::All => ix,
+            StackFrameFilter::OnlyUserFrames => self.filter_entries_indices[ix],
+        };
+
         match &self.entries[ix] {
             StackFrameEntry::Label(stack_frame) => self.render_label_entry(stack_frame, cx),
             StackFrameEntry::Normal(stack_frame) => self.render_normal_entry(ix, stack_frame, cx),
@@ -702,6 +800,67 @@ impl StackFrameList {
         self.activate_selected_entry(window, cx);
     }
 
+    pub(crate) fn toggle_frame_filter(
+        &mut self,
+        thread_status: Option<ThreadStatus>,
+        cx: &mut Context<Self>,
+    ) {
+        self.list_filter = match self.list_filter {
+            StackFrameFilter::All => StackFrameFilter::OnlyUserFrames,
+            StackFrameFilter::OnlyUserFrames => StackFrameFilter::All,
+        };
+
+        if let Some(database_id) = self
+            .workspace
+            .read_with(cx, |workspace, _| workspace.database_id())
+            .ok()
+            .flatten()
+        {
+            let database_id: i64 = database_id.into();
+            let save_task = KEY_VALUE_STORE.write_kvp(
+                format!(
+                    "stack-frame-list-filter-{}-{}",
+                    self.session.read(cx).adapter().0,
+                    database_id,
+                ),
+                self.list_filter.into(),
+            );
+            cx.background_spawn(save_task).detach();
+        }
+
+        if let Some(ThreadStatus::Stopped) = thread_status {
+            match self.list_filter {
+                StackFrameFilter::All => {
+                    self.list_state.reset(self.entries.len());
+                }
+                StackFrameFilter::OnlyUserFrames => {
+                    self.list_state.reset(self.filter_entries_indices.len());
+                    if !self
+                        .selected_ix
+                        .map(|ix| self.filter_entries_indices.contains(&ix))
+                        .unwrap_or_default()
+                    {
+                        self.selected_ix = None;
+                    }
+                }
+            }
+
+            if let Some(ix) = self.selected_ix {
+                let scroll_to = match self.list_filter {
+                    StackFrameFilter::All => ix,
+                    StackFrameFilter::OnlyUserFrames => self
+                        .filter_entries_indices
+                        .binary_search_by_key(&ix, |ix| *ix)
+                        .expect("This index will always exist"),
+                };
+                self.list_state.scroll_to_reveal_item(scroll_to);
+            }
+
+            cx.emit(StackFrameListEvent::BuiltEntries);
+            cx.notify();
+        }
+    }
+
     fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         div().p_1().size_full().child(
             list(
@@ -711,6 +870,30 @@ impl StackFrameList {
             .size_full(),
         )
     }
+
+    pub(crate) fn render_control_strip(&self) -> AnyElement {
+        let tooltip_title = match self.list_filter {
+            StackFrameFilter::All => "Show stack frames from your project",
+            StackFrameFilter::OnlyUserFrames => "Show all stack frames",
+        };
+
+        h_flex()
+            .child(
+                IconButton::new(
+                    "filter-by-visible-worktree-stack-frame-list",
+                    IconName::ListFilter,
+                )
+                .tooltip(move |window, cx| {
+                    Tooltip::for_action(tooltip_title, &ToggleUserFrames, window, cx)
+                })
+                .toggle_state(self.list_filter == StackFrameFilter::OnlyUserFrames)
+                .icon_size(IconSize::Small)
+                .on_click(|_, window, cx| {
+                    window.dispatch_action(ToggleUserFrames.boxed_clone(), cx)
+                }),
+            )
+            .into_any_element()
+    }
 }
 
 impl Render for StackFrameList {

crates/debugger_ui/src/tests/stack_frame_list.rs 🔗

@@ -752,3 +752,288 @@ async fn test_collapsed_entries(executor: BackgroundExecutor, cx: &mut TestAppCo
         });
     });
 }
+
+#[gpui::test]
+async fn test_stack_frame_filter(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor.clone());
+
+    let test_file_content = r#"
+        function main() {
+            doSomething();
+        }
+
+        function doSomething() {
+            console.log('doing something');
+        }
+    "#
+    .unindent();
+
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+           "src": {
+               "test.js": test_file_content,
+           }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+    let workspace = init_test_workspace(&project, cx).await;
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+    let session = start_debug_session(&workspace, cx, |_| {}).unwrap();
+    let client = session.update(cx, |session, _| session.adapter_client().unwrap());
+
+    client.on_request::<Threads, _>(move |_, _| {
+        Ok(dap::ThreadsResponse {
+            threads: vec![dap::Thread {
+                id: 1,
+                name: "Thread 1".into(),
+            }],
+        })
+    });
+
+    client.on_request::<Scopes, _>(move |_, _| Ok(dap::ScopesResponse { scopes: vec![] }));
+
+    let stack_frames = vec![
+        StackFrame {
+            id: 1,
+            name: "main".into(),
+            source: Some(dap::Source {
+                name: Some("test.js".into()),
+                path: Some(path!("/project/src/test.js").into()),
+                source_reference: None,
+                presentation_hint: None,
+                origin: None,
+                sources: None,
+                adapter_data: None,
+                checksums: None,
+            }),
+            line: 2,
+            column: 1,
+            end_line: None,
+            end_column: None,
+            can_restart: None,
+            instruction_pointer_reference: None,
+            module_id: None,
+            presentation_hint: None,
+        },
+        StackFrame {
+            id: 2,
+            name: "node:internal/modules/cjs/loader".into(),
+            source: Some(dap::Source {
+                name: Some("loader.js".into()),
+                path: Some(path!("/usr/lib/node/internal/modules/cjs/loader.js").into()),
+                source_reference: None,
+                presentation_hint: None,
+                origin: None,
+                sources: None,
+                adapter_data: None,
+                checksums: None,
+            }),
+            line: 100,
+            column: 1,
+            end_line: None,
+            end_column: None,
+            can_restart: None,
+            instruction_pointer_reference: None,
+            module_id: None,
+            presentation_hint: Some(dap::StackFramePresentationHint::Deemphasize),
+        },
+        StackFrame {
+            id: 3,
+            name: "node:internal/modules/run_main".into(),
+            source: Some(dap::Source {
+                name: Some("run_main.js".into()),
+                path: Some(path!("/usr/lib/node/internal/modules/run_main.js").into()),
+                source_reference: None,
+                presentation_hint: None,
+                origin: None,
+                sources: None,
+                adapter_data: None,
+                checksums: None,
+            }),
+            line: 50,
+            column: 1,
+            end_line: None,
+            end_column: None,
+            can_restart: None,
+            instruction_pointer_reference: None,
+            module_id: None,
+            presentation_hint: Some(dap::StackFramePresentationHint::Deemphasize),
+        },
+        StackFrame {
+            id: 4,
+            name: "doSomething".into(),
+            source: Some(dap::Source {
+                name: Some("test.js".into()),
+                path: Some(path!("/project/src/test.js").into()),
+                source_reference: None,
+                presentation_hint: None,
+                origin: None,
+                sources: None,
+                adapter_data: None,
+                checksums: None,
+            }),
+            line: 3,
+            column: 1,
+            end_line: None,
+            end_column: None,
+            can_restart: None,
+            instruction_pointer_reference: None,
+            module_id: None,
+            presentation_hint: None,
+        },
+    ];
+
+    // Store a copy for assertions
+    let stack_frames_for_assertions = stack_frames.clone();
+
+    client.on_request::<StackTrace, _>({
+        let stack_frames = Arc::new(stack_frames.clone());
+        move |_, args| {
+            assert_eq!(1, args.thread_id);
+
+            Ok(dap::StackTraceResponse {
+                stack_frames: (*stack_frames).clone(),
+                total_frames: None,
+            })
+        }
+    });
+
+    client
+        .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
+            reason: dap::StoppedEventReason::Pause,
+            description: None,
+            thread_id: Some(1),
+            preserve_focus_hint: None,
+            text: None,
+            all_threads_stopped: None,
+            hit_breakpoint_ids: None,
+        }))
+        .await;
+
+    cx.run_until_parked();
+
+    // trigger threads to load
+    active_debug_session_panel(workspace, cx).update(cx, |session, cx| {
+        session.running_state().update(cx, |running_state, cx| {
+            running_state
+                .session()
+                .update(cx, |session, cx| session.threads(cx));
+        });
+    });
+
+    cx.run_until_parked();
+
+    // select first thread
+    active_debug_session_panel(workspace, cx).update_in(cx, |session, window, cx| {
+        session.running_state().update(cx, |running_state, cx| {
+            running_state.select_current_thread(
+                &running_state
+                    .session()
+                    .update(cx, |session, cx| session.threads(cx)),
+                window,
+                cx,
+            );
+        });
+    });
+
+    cx.run_until_parked();
+
+    // trigger stack frames to load
+    active_debug_session_panel(workspace, cx).update(cx, |debug_panel_item, cx| {
+        let stack_frame_list = debug_panel_item
+            .running_state()
+            .update(cx, |state, _| state.stack_frame_list().clone());
+
+        stack_frame_list.update(cx, |stack_frame_list, cx| {
+            stack_frame_list.dap_stack_frames(cx);
+        });
+    });
+
+    cx.run_until_parked();
+
+    active_debug_session_panel(workspace, cx).update_in(cx, |debug_panel_item, window, cx| {
+        let stack_frame_list = debug_panel_item
+            .running_state()
+            .update(cx, |state, _| state.stack_frame_list().clone());
+
+        stack_frame_list.update(cx, |stack_frame_list, cx| {
+            stack_frame_list.build_entries(true, window, cx);
+
+            // Verify we have the expected collapsed structure
+            assert_eq!(
+                stack_frame_list.entries(),
+                &vec![
+                    StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()),
+                    StackFrameEntry::Collapsed(vec![
+                        stack_frames_for_assertions[1].clone(),
+                        stack_frames_for_assertions[2].clone()
+                    ]),
+                    StackFrameEntry::Normal(stack_frames_for_assertions[3].clone()),
+                ]
+            );
+
+            // Test 1: Verify filtering works
+            let all_frames = stack_frame_list.flatten_entries(true, false);
+            assert_eq!(all_frames.len(), 4, "Should see all 4 frames initially");
+
+            // Toggle to user frames only
+            stack_frame_list
+                .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx);
+
+            let user_frames = stack_frame_list.dap_stack_frames(cx);
+            assert_eq!(user_frames.len(), 2, "Should only see 2 user frames");
+            assert_eq!(user_frames[0].name, "main");
+            assert_eq!(user_frames[1].name, "doSomething");
+
+            // Test 2: Verify filtering toggles correctly
+            // Check we can toggle back and see all frames again
+
+            // Toggle back to all frames
+            stack_frame_list
+                .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx);
+
+            let all_frames_again = stack_frame_list.flatten_entries(true, false);
+            assert_eq!(
+                all_frames_again.len(),
+                4,
+                "Should see all 4 frames after toggling back"
+            );
+
+            // Test 3: Verify collapsed entries stay expanded
+            stack_frame_list.expand_collapsed_entry(1, cx);
+            assert_eq!(
+                stack_frame_list.entries(),
+                &vec![
+                    StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()),
+                    StackFrameEntry::Normal(stack_frames_for_assertions[1].clone()),
+                    StackFrameEntry::Normal(stack_frames_for_assertions[2].clone()),
+                    StackFrameEntry::Normal(stack_frames_for_assertions[3].clone()),
+                ]
+            );
+
+            // Toggle filter twice
+            stack_frame_list
+                .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx);
+            stack_frame_list
+                .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx);
+
+            // Verify entries remain expanded
+            assert_eq!(
+                stack_frame_list.entries(),
+                &vec![
+                    StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()),
+                    StackFrameEntry::Normal(stack_frames_for_assertions[1].clone()),
+                    StackFrameEntry::Normal(stack_frames_for_assertions[2].clone()),
+                    StackFrameEntry::Normal(stack_frames_for_assertions[3].clone()),
+                ],
+                "Expanded entries should remain expanded after toggling filter"
+            );
+        });
+    });
+}

crates/icons/src/icons.rs 🔗

@@ -146,6 +146,7 @@ pub enum IconName {
     Library,
     LineHeight,
     ListCollapse,
+    ListFilter,
     ListTodo,
     ListTree,
     ListX,