Detailed changes
@@ -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>
@@ -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());
}
@@ -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();
@@ -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(),
@@ -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"
+ );
+ });
+ });
+}
@@ -146,6 +146,7 @@ pub enum IconName {
Library,
LineHeight,
ListCollapse,
+ ListFilter,
ListTodo,
ListTree,
ListX,