debugger: Add variable watchers (#32743)

### This PR introduces support for adding watchers to specific
expressions (such as variable names or evaluated expressions).

This feature is useful in scenarios where many variables are in scope,
but only a few are of interest—especially when tracking variables that
change frequently. By allowing users to add watchers, it becomes easier
to monitor the values of selected expressions across stack frames
without having to sift through a large list of variables.


https://github.com/user-attachments/assets/c49b470a-d912-4182-8419-7406ba4c8f1e

------

**TODO**:
- [x] make render variable code reusable for render watch method
- [x] use SharedString for watches because of a lot of cloning
- [x] add tests
  - [x] basic test
  - [x] test step debugging

Release Notes:

- Debugger Beta: Add support for variable watchers

---------

Co-authored-by: Anthony Eid <hello@anthonyeid.me>
Co-authored-by: Anthony <anthony@zed.dev>
This commit is contained in:
Remco Smits 2025-06-20 22:45:55 +02:00 committed by GitHub
parent 9f2c541ab0
commit ad76db7244
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1243 additions and 123 deletions

View file

@ -6,18 +6,21 @@ use std::sync::{
use crate::{
DebugPanel,
persistence::DebuggerPaneItem,
session::running::variable_list::{CollapseSelectedEntry, ExpandSelectedEntry},
session::running::variable_list::{
AddWatch, CollapseSelectedEntry, ExpandSelectedEntry, RemoveWatch,
},
tests::{active_debug_session_panel, init_test, init_test_workspace, start_debug_session},
};
use collections::HashMap;
use dap::{
Scope, StackFrame, Variable,
requests::{Initialize, Launch, Scopes, StackTrace, Variables},
requests::{Evaluate, Initialize, Launch, Scopes, StackTrace, Variables},
};
use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext};
use menu::{SelectFirst, SelectNext, SelectPrevious};
use project::{FakeFs, Project};
use serde_json::json;
use ui::SharedString;
use unindent::Unindent as _;
use util::path;
@ -1828,3 +1831,515 @@ async fn test_it_fetches_scopes_variables_when_you_select_a_stack_frame(
assert_eq!(variables, frame_2_variables,);
});
}
#[gpui::test]
async fn test_add_and_remove_watcher(executor: BackgroundExecutor, cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(executor.clone());
let test_file_content = r#"
const variable1 = "Value 1";
const variable2 = "Value 2";
"#
.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;
workspace
.update(cx, |workspace, window, cx| {
workspace.focus_panel::<DebugPanel>(window, cx);
})
.unwrap();
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::<dap::requests::Threads, _>(move |_, _| {
Ok(dap::ThreadsResponse {
threads: vec![dap::Thread {
id: 1,
name: "Thread 1".into(),
}],
})
});
let stack_frames = vec![StackFrame {
id: 1,
name: "Stack Frame 1".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: 1,
column: 1,
end_line: None,
end_column: None,
can_restart: None,
instruction_pointer_reference: None,
module_id: None,
presentation_hint: None,
}];
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,
})
}
});
let scopes = vec![Scope {
name: "Scope 1".into(),
presentation_hint: None,
variables_reference: 2,
named_variables: None,
indexed_variables: None,
expensive: false,
source: None,
line: None,
column: None,
end_line: None,
end_column: None,
}];
client.on_request::<Scopes, _>({
let scopes = Arc::new(scopes.clone());
move |_, args| {
assert_eq!(1, args.frame_id);
Ok(dap::ScopesResponse {
scopes: (*scopes).clone(),
})
}
});
let variables = vec![
Variable {
name: "variable1".into(),
value: "value 1".into(),
type_: None,
presentation_hint: None,
evaluate_name: None,
variables_reference: 0,
named_variables: None,
indexed_variables: None,
memory_reference: None,
declaration_location_reference: None,
value_location_reference: None,
},
Variable {
name: "variable2".into(),
value: "value 2".into(),
type_: None,
presentation_hint: None,
evaluate_name: None,
variables_reference: 0,
named_variables: None,
indexed_variables: None,
memory_reference: None,
declaration_location_reference: None,
value_location_reference: None,
},
];
client.on_request::<Variables, _>({
let variables = Arc::new(variables.clone());
move |_, args| {
assert_eq!(2, args.variables_reference);
Ok(dap::VariablesResponse {
variables: (*variables).clone(),
})
}
});
client.on_request::<Evaluate, _>({
move |_, args| {
assert_eq!("variable1", args.expression);
Ok(dap::EvaluateResponse {
result: "value1".to_owned(),
type_: None,
presentation_hint: None,
variables_reference: 2,
named_variables: None,
indexed_variables: None,
memory_reference: None,
value_location_reference: 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();
let running_state =
active_debug_session_panel(workspace, cx).update_in(cx, |item, window, cx| {
cx.focus_self(window);
let running = item.running_state().clone();
let variable_list = running.update(cx, |state, cx| {
// have to do this because the variable list pane should be shown/active
// for testing the variable list
state.activate_item(DebuggerPaneItem::Variables, window, cx);
state.variable_list().clone()
});
variable_list.update(cx, |_, cx| cx.focus_self(window));
running
});
cx.run_until_parked();
// select variable 1 from first scope
running_state.update(cx, |running_state, cx| {
running_state.variable_list().update(cx, |_, cx| {
cx.dispatch_action(&SelectFirst);
cx.dispatch_action(&SelectNext);
});
});
cx.run_until_parked();
running_state.update(cx, |running_state, cx| {
running_state.variable_list().update(cx, |_, cx| {
cx.dispatch_action(&AddWatch);
});
});
cx.run_until_parked();
// assert watcher for variable1 was added
running_state.update(cx, |running_state, cx| {
running_state.variable_list().update(cx, |list, _| {
list.assert_visual_entries(vec![
"> variable1",
"v Scope 1",
" > variable1 <=== selected",
" > variable2",
]);
});
});
session.update(cx, |session, _| {
let watcher = session
.watchers()
.get(&SharedString::from("variable1"))
.unwrap();
assert_eq!("value1", watcher.value.to_string());
assert_eq!("variable1", watcher.expression.to_string());
assert_eq!(2, watcher.variables_reference);
});
// select added watcher for variable1
running_state.update(cx, |running_state, cx| {
running_state.variable_list().update(cx, |_, cx| {
cx.dispatch_action(&SelectFirst);
});
});
cx.run_until_parked();
running_state.update(cx, |running_state, cx| {
running_state.variable_list().update(cx, |_, cx| {
cx.dispatch_action(&RemoveWatch);
});
});
cx.run_until_parked();
// assert watcher for variable1 was removed
running_state.update(cx, |running_state, cx| {
running_state.variable_list().update(cx, |list, _| {
list.assert_visual_entries(vec!["v Scope 1", " > variable1", " > variable2"]);
});
});
}
#[gpui::test]
async fn test_refresh_watchers(executor: BackgroundExecutor, cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(executor.clone());
let test_file_content = r#"
const variable1 = "Value 1";
const variable2 = "Value 2";
"#
.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;
workspace
.update(cx, |workspace, window, cx| {
workspace.focus_panel::<DebugPanel>(window, cx);
})
.unwrap();
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::<dap::requests::Threads, _>(move |_, _| {
Ok(dap::ThreadsResponse {
threads: vec![dap::Thread {
id: 1,
name: "Thread 1".into(),
}],
})
});
let stack_frames = vec![StackFrame {
id: 1,
name: "Stack Frame 1".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: 1,
column: 1,
end_line: None,
end_column: None,
can_restart: None,
instruction_pointer_reference: None,
module_id: None,
presentation_hint: None,
}];
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,
})
}
});
let scopes = vec![Scope {
name: "Scope 1".into(),
presentation_hint: None,
variables_reference: 2,
named_variables: None,
indexed_variables: None,
expensive: false,
source: None,
line: None,
column: None,
end_line: None,
end_column: None,
}];
client.on_request::<Scopes, _>({
let scopes = Arc::new(scopes.clone());
move |_, args| {
assert_eq!(1, args.frame_id);
Ok(dap::ScopesResponse {
scopes: (*scopes).clone(),
})
}
});
let variables = vec![
Variable {
name: "variable1".into(),
value: "value 1".into(),
type_: None,
presentation_hint: None,
evaluate_name: None,
variables_reference: 0,
named_variables: None,
indexed_variables: None,
memory_reference: None,
declaration_location_reference: None,
value_location_reference: None,
},
Variable {
name: "variable2".into(),
value: "value 2".into(),
type_: None,
presentation_hint: None,
evaluate_name: None,
variables_reference: 0,
named_variables: None,
indexed_variables: None,
memory_reference: None,
declaration_location_reference: None,
value_location_reference: None,
},
];
client.on_request::<Variables, _>({
let variables = Arc::new(variables.clone());
move |_, args| {
assert_eq!(2, args.variables_reference);
Ok(dap::VariablesResponse {
variables: (*variables).clone(),
})
}
});
client.on_request::<Evaluate, _>({
move |_, args| {
assert_eq!("variable1", args.expression);
Ok(dap::EvaluateResponse {
result: "value1".to_owned(),
type_: None,
presentation_hint: None,
variables_reference: 2,
named_variables: None,
indexed_variables: None,
memory_reference: None,
value_location_reference: 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();
let running_state =
active_debug_session_panel(workspace, cx).update_in(cx, |item, window, cx| {
cx.focus_self(window);
let running = item.running_state().clone();
let variable_list = running.update(cx, |state, cx| {
// have to do this because the variable list pane should be shown/active
// for testing the variable list
state.activate_item(DebuggerPaneItem::Variables, window, cx);
state.variable_list().clone()
});
variable_list.update(cx, |_, cx| cx.focus_self(window));
running
});
cx.run_until_parked();
// select variable 1 from first scope
running_state.update(cx, |running_state, cx| {
running_state.variable_list().update(cx, |_, cx| {
cx.dispatch_action(&SelectFirst);
cx.dispatch_action(&SelectNext);
});
});
cx.run_until_parked();
running_state.update(cx, |running_state, cx| {
running_state.variable_list().update(cx, |_, cx| {
cx.dispatch_action(&AddWatch);
});
});
cx.run_until_parked();
session.update(cx, |session, _| {
let watcher = session
.watchers()
.get(&SharedString::from("variable1"))
.unwrap();
assert_eq!("value1", watcher.value.to_string());
assert_eq!("variable1", watcher.expression.to_string());
assert_eq!(2, watcher.variables_reference);
});
client.on_request::<Evaluate, _>({
move |_, args| {
assert_eq!("variable1", args.expression);
Ok(dap::EvaluateResponse {
result: "value updated".to_owned(),
type_: None,
presentation_hint: None,
variables_reference: 3,
named_variables: None,
indexed_variables: None,
memory_reference: None,
value_location_reference: 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();
session.update(cx, |session, _| {
let watcher = session
.watchers()
.get(&SharedString::from("variable1"))
.unwrap();
assert_eq!("value updated", watcher.value.to_string());
assert_eq!("variable1", watcher.expression.to_string());
assert_eq!(3, watcher.variables_reference);
});
}