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

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
This commit is contained in:
Anthony Eid
2025-08-28 07:53:32 +00:00
committed by GitHub
parent 38e5c8fb66
commit 73b38c8306
8 changed files with 522 additions and 14 deletions
+1
View File
@@ -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>

After

Width:  |  Height:  |  Size: 305 B

+1
View File
@@ -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());
}
+17
View File
@@ -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();
+3 -6
View File
@@ -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(),
+23
View File
@@ -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(
@@ -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 {
@@ -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"
);
});
});
}
+1
View File
@@ -146,6 +146,7 @@ pub enum IconName {
Library,
LineHeight,
ListCollapse,
ListFilter,
ListTodo,
ListTree,
ListX,