Debugger implementation (#13433)

###  DISCLAIMER

> As of 6th March 2025, debugger is still in development. We plan to
merge it behind a staff-only feature flag for staff use only, followed
by non-public release and then finally a public one (akin to how Git
panel release was handled). This is done to ensure the best experience
when it gets released.

### END OF DISCLAIMER 

**The current state of the debugger implementation:**


https://github.com/user-attachments/assets/c4deff07-80dd-4dc6-ad2e-0c252a478fe9


https://github.com/user-attachments/assets/e1ed2345-b750-4bb6-9c97-50961b76904f

----

All the todo's are in the following channel, so it's easier to work on
this together:
https://zed.dev/channel/zed-debugger-11370

If you are on Linux, you can use the following command to join the
channel:
```cli
zed https://zed.dev/channel/zed-debugger-11370 
```

## Current Features

- Collab
  - Breakpoints
    - Sync when you (re)join a project
    - Sync when you add/remove a breakpoint
  - Sync active debug line
  - Stack frames
    - Click on stack frame
      - View variables that belong to the stack frame
      - Visit the source file
    - Restart stack frame (if adapter supports this)
  - Variables
  - Loaded sources
  - Modules
  - Controls
    - Continue
    - Step back
      - Stepping granularity (configurable)
    - Step into
      - Stepping granularity (configurable)
    - Step over
      - Stepping granularity (configurable)
    - Step out
      - Stepping granularity (configurable)
  - Debug console
- Breakpoints
  - Log breakpoints
  - line breakpoints
  - Persistent between zed sessions (configurable)
  - Multi buffer support
  - Toggle disable/enable all breakpoints
- Stack frames
  - Click on stack frame
    - View variables that belong to the stack frame
    - Visit the source file
    - Show collapsed stack frames
  - Restart stack frame (if adapter supports this)
- Loaded sources
  - View all used loaded sources if supported by adapter.
- Modules
  - View all used modules (if adapter supports this)
- Variables
  - Copy value
  - Copy name
  - Copy memory reference
  - Set value (if adapter supports this)
  - keyboard navigation
- Debug Console
  - See logs
  - View output that was sent from debug adapter
    - Output grouping
  - Evaluate code
    - Updates the variable list
    - Auto completion
- If not supported by adapter, we will show auto-completion for existing
variables
- Debug Terminal
- Run custom commands and change env values right inside your Zed
terminal
- Attach to process (if adapter supports this)
  - Process picker
- Controls
  - Continue
  - Step back
    - Stepping granularity (configurable)
  - Step into
    - Stepping granularity (configurable)
  - Step over
    - Stepping granularity (configurable)
  - Step out
    - Stepping granularity (configurable)
  - Disconnect
  - Restart
  - Stop
- Warning when a debug session exited without hitting any breakpoint
- Debug view to see Adapter/RPC log messages
- Testing
  - Fake debug adapter
    - Fake requests & events

---

Release Notes:

- N/A

---------

Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
Co-authored-by: Anthony <anthony@zed.dev>
Co-authored-by: Piotr Osiewicz <peterosiewicz@gmail.com>
Co-authored-by: Piotr <piotr@zed.dev>
This commit is contained in:
Remco Smits 2025-03-18 17:55:25 +01:00 committed by GitHub
parent ed4e654fdf
commit 41a60ffecf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
156 changed files with 25840 additions and 451 deletions

View file

@ -219,7 +219,7 @@ pub enum Event {
idx: usize,
},
RemovedItem {
item_id: EntityId,
item: Box<dyn ItemHandle>,
},
Split(SplitDirection),
JoinAll,
@ -247,9 +247,9 @@ impl fmt::Debug for Event {
.finish(),
Event::Remove { .. } => f.write_str("Remove"),
Event::RemoveItem { idx } => f.debug_struct("RemoveItem").field("idx", idx).finish(),
Event::RemovedItem { item_id } => f
Event::RemovedItem { item } => f
.debug_struct("RemovedItem")
.field("item_id", item_id)
.field("item", &item.item_id())
.finish(),
Event::Split(direction) => f
.debug_struct("Split")
@ -315,6 +315,7 @@ pub struct Pane {
display_nav_history_buttons: Option<bool>,
double_click_dispatch_action: Box<dyn Action>,
save_modals_spawned: HashSet<EntityId>,
close_pane_if_empty: bool,
pub new_item_context_menu_handle: PopoverMenuHandle<ContextMenu>,
pub split_item_context_menu_handle: PopoverMenuHandle<ContextMenu>,
pinned_tab_count: usize,
@ -519,6 +520,7 @@ impl Pane {
_subscriptions: subscriptions,
double_click_dispatch_action,
save_modals_spawned: HashSet::default(),
close_pane_if_empty: true,
split_item_context_menu_handle: Default::default(),
new_item_context_menu_handle: Default::default(),
pinned_tab_count: 0,
@ -706,6 +708,11 @@ impl Pane {
self.can_split_predicate = can_split_predicate;
}
pub fn set_close_pane_if_empty(&mut self, close_pane_if_empty: bool, cx: &mut Context<Self>) {
self.close_pane_if_empty = close_pane_if_empty;
cx.notify();
}
pub fn set_can_navigate(&mut self, can_navigate: bool, cx: &mut Context<Self>) {
self.toolbar.update(cx, |toolbar, cx| {
toolbar.set_can_navigate(can_navigate, cx);
@ -1632,6 +1639,13 @@ impl Pane {
// Remove the item from the pane.
pane.update_in(&mut cx, |pane, window, cx| {
pane.remove_item(
item_to_close.item_id(),
false,
pane.close_pane_if_empty,
window,
cx,
);
pane.remove_item(item_to_close.item_id(), false, true, window, cx);
})
.ok();
@ -1739,13 +1753,9 @@ impl Pane {
}
}
cx.emit(Event::RemoveItem { idx: item_index });
let item = self.items.remove(item_index);
cx.emit(Event::RemovedItem {
item_id: item.item_id(),
});
cx.emit(Event::RemovedItem { item: item.clone() });
if self.items.is_empty() {
item.deactivated(window, cx);
if close_pane_if_empty {
@ -2779,7 +2789,7 @@ impl Pane {
window.dispatch_action(
this.double_click_dispatch_action.boxed_clone(),
cx,
)
);
}
})),
),

View file

@ -1,18 +1,25 @@
pub mod model;
use std::{path::Path, str::FromStr};
use std::{
borrow::Cow,
collections::BTreeMap,
path::{Path, PathBuf},
str::FromStr,
sync::Arc,
};
use anyhow::{anyhow, bail, Context, Result};
use client::DevServerProjectId;
use db::{define_connection, query, sqlez::connection::Connection, sqlez_macros::sql};
use gpui::{point, size, Axis, Bounds, WindowBounds, WindowId};
use project::debugger::breakpoint_store::{BreakpointKind, SerializedBreakpoint};
use language::{LanguageName, Toolchain};
use project::WorktreeId;
use remote::ssh_session::SshProjectId;
use sqlez::{
bindable::{Bind, Column, StaticColumnCount},
statement::Statement,
statement::{SqlType, Statement},
};
use ui::px;
@ -136,6 +143,125 @@ impl Column for SerializedWindowBounds {
}
}
#[derive(Debug)]
pub struct Breakpoint {
pub position: u32,
pub kind: BreakpointKind,
}
/// Wrapper for DB type of a breakpoint
struct BreakpointKindWrapper<'a>(Cow<'a, BreakpointKind>);
impl From<BreakpointKind> for BreakpointKindWrapper<'static> {
fn from(kind: BreakpointKind) -> Self {
BreakpointKindWrapper(Cow::Owned(kind))
}
}
impl StaticColumnCount for BreakpointKindWrapper<'_> {
fn column_count() -> usize {
1
}
}
impl Bind for BreakpointKindWrapper<'_> {
fn bind(&self, statement: &Statement, start_index: i32) -> anyhow::Result<i32> {
let next_index = statement.bind(&self.0.to_int(), start_index)?;
match self.0.as_ref() {
BreakpointKind::Standard => {
statement.bind_null(next_index)?;
Ok(next_index + 1)
}
BreakpointKind::Log(message) => statement.bind(&message.as_ref(), next_index),
}
}
}
impl Column for BreakpointKindWrapper<'_> {
fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
let kind = statement.column_int(start_index)?;
match kind {
0 => Ok((BreakpointKind::Standard.into(), start_index + 2)),
1 => {
let message = statement.column_text(start_index)?.to_string();
Ok((BreakpointKind::Log(message.into()).into(), start_index + 1))
}
_ => Err(anyhow::anyhow!("Invalid BreakpointKind discriminant")),
}
}
}
/// This struct is used to implement traits on Vec<breakpoint>
#[derive(Debug)]
#[allow(dead_code)]
struct Breakpoints(Vec<Breakpoint>);
impl sqlez::bindable::StaticColumnCount for Breakpoint {
fn column_count() -> usize {
1 + BreakpointKindWrapper::column_count()
}
}
impl sqlez::bindable::Bind for Breakpoint {
fn bind(
&self,
statement: &sqlez::statement::Statement,
start_index: i32,
) -> anyhow::Result<i32> {
let next_index = statement.bind(&self.position, start_index)?;
statement.bind(
&BreakpointKindWrapper(Cow::Borrowed(&self.kind)),
next_index,
)
}
}
impl Column for Breakpoint {
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
let position = statement
.column_int(start_index)
.with_context(|| format!("Failed to read BreakPoint at index {start_index}"))?
as u32;
let (kind, next_index) = BreakpointKindWrapper::column(statement, start_index + 1)?;
Ok((
Breakpoint {
position,
kind: kind.0.into_owned(),
},
next_index,
))
}
}
impl Column for Breakpoints {
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
let mut breakpoints = Vec::new();
let mut index = start_index;
loop {
match statement.column_type(index) {
Ok(SqlType::Null) => break,
_ => {
let position = statement
.column_int(index)
.with_context(|| format!("Failed to read BreakPoint at index {index}"))?
as u32;
let (kind, next_index) = BreakpointKindWrapper::column(statement, index + 1)?;
breakpoints.push(Breakpoint {
position,
kind: kind.0.into_owned(),
});
index = next_index;
}
}
}
Ok((Breakpoints(breakpoints), index))
}
}
#[derive(Clone, Debug, PartialEq)]
struct SerializedPixels(gpui::Pixels);
impl sqlez::bindable::StaticColumnCount for SerializedPixels {}
@ -205,6 +331,14 @@ define_connection! {
// active: bool, // Indicates if this item is the active one in the pane
// preview: bool // Indicates if this item is a preview item
// )
//
// CREATE TABLE breakpoints(
// workspace_id: usize Foreign Key, // References workspace table
// path: PathBuf, // The absolute path of the file that this breakpoint belongs to
// breakpoint_location: Vec<u32>, // A list of the locations of breakpoints
// kind: int, // The kind of breakpoint (standard, log)
// log_message: String, // log message for log breakpoints, otherwise it's Null
// )
pub static ref DB: WorkspaceDb<()> =
&[
sql!(
@ -383,6 +517,18 @@ define_connection! {
sql!(
ALTER TABLE toolchains ADD COLUMN raw_json TEXT DEFAULT "{}";
),
sql!(
CREATE TABLE breakpoints (
workspace_id INTEGER NOT NULL,
path TEXT NOT NULL,
breakpoint_location INTEGER NOT NULL,
kind INTEGER NOT NULL,
log_message TEXT,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
ON UPDATE CASCADE
);
),
];
}
@ -470,6 +616,7 @@ impl WorkspaceDb {
display,
docks,
session_id: None,
breakpoints: self.breakpoints(workspace_id),
window_id,
})
}
@ -523,6 +670,7 @@ impl WorkspaceDb {
.log_err()?,
window_bounds,
centered_layout: centered_layout.unwrap_or(false),
breakpoints: self.breakpoints(workspace_id),
display,
docks,
session_id: None,
@ -530,6 +678,46 @@ impl WorkspaceDb {
})
}
fn breakpoints(
&self,
workspace_id: WorkspaceId,
) -> BTreeMap<Arc<Path>, Vec<SerializedBreakpoint>> {
let breakpoints: Result<Vec<(PathBuf, Breakpoint)>> = self
.select_bound(sql! {
SELECT path, breakpoint_location, kind
FROM breakpoints
WHERE workspace_id = ?
})
.and_then(|mut prepared_statement| (prepared_statement)(workspace_id));
match breakpoints {
Ok(bp) => {
if bp.is_empty() {
log::error!("Breakpoints are empty after querying database for them");
}
let mut map: BTreeMap<Arc<Path>, Vec<SerializedBreakpoint>> = Default::default();
for (path, breakpoint) in bp {
let path: Arc<Path> = path.into();
map.entry(path.clone())
.or_default()
.push(SerializedBreakpoint {
position: breakpoint.position,
path,
kind: breakpoint.kind,
});
}
map
}
Err(msg) => {
log::error!("Breakpoints query failed with msg: {msg}");
Default::default()
}
}
}
/// Saves a workspace using the worktree roots. Will garbage collect any workspaces
/// that used this workspace previously
pub(crate) async fn save_workspace(&self, workspace: SerializedWorkspace) {
@ -540,6 +728,31 @@ impl WorkspaceDb {
DELETE FROM pane_groups WHERE workspace_id = ?1;
DELETE FROM panes WHERE workspace_id = ?1;))?(workspace.id)
.context("Clearing old panes")?;
for (path, breakpoints) in workspace.breakpoints {
conn.exec_bound(sql!(DELETE FROM breakpoints WHERE workspace_id = ?1 AND path = ?2))?((workspace.id, path.as_ref()))
.context("Clearing old breakpoints")?;
for bp in breakpoints {
let kind = BreakpointKindWrapper::from(bp.kind);
match conn.exec_bound(sql!(
INSERT INTO breakpoints (workspace_id, path, breakpoint_location, kind, log_message)
VALUES (?1, ?2, ?3, ?4, ?5);))?
((
workspace.id,
path.as_ref(),
bp.position,
kind,
)) {
Ok(_) => {}
Err(err) => {
log::error!("{err}");
continue;
}
}
}
}
match workspace.location {
SerializedWorkspaceLocation::Local(local_paths, local_paths_order) => {
@ -720,6 +933,21 @@ impl WorkspaceDb {
}
}
query! {
pub fn breakpoints_for_file(workspace_id: WorkspaceId, file_path: &Path) -> Result<Vec<Breakpoint>> {
SELECT breakpoint_location
FROM breakpoints
WHERE workspace_id= ?1 AND path = ?2
}
}
query! {
pub fn clear_breakpoints(file_path: &Path) -> Result<()> {
DELETE FROM breakpoints
WHERE file_path = ?2
}
}
query! {
fn ssh_projects() -> Result<Vec<SerializedSshProject>> {
SELECT id, host, port, paths, user
@ -1165,6 +1393,70 @@ mod tests {
use db::open_test_db;
use gpui;
#[gpui::test]
async fn test_breakpoints() {
env_logger::try_init().ok();
let db = WorkspaceDb(open_test_db("test_breakpoints").await);
let id = db.next_id().await.unwrap();
let path = Path::new("/tmp/test.rs");
let breakpoint = Breakpoint {
position: 123,
kind: BreakpointKind::Standard,
};
let log_breakpoint = Breakpoint {
position: 456,
kind: BreakpointKind::Log("Test log message".into()),
};
let workspace = SerializedWorkspace {
id,
location: SerializedWorkspaceLocation::from_local_paths(["/tmp"]),
center_group: Default::default(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: {
let mut map = collections::BTreeMap::default();
map.insert(
Arc::from(path),
vec![
SerializedBreakpoint {
position: breakpoint.position,
path: Arc::from(path),
kind: breakpoint.kind.clone(),
},
SerializedBreakpoint {
position: log_breakpoint.position,
path: Arc::from(path),
kind: log_breakpoint.kind.clone(),
},
],
);
map
},
session_id: None,
window_id: None,
};
db.save_workspace(workspace.clone()).await;
let loaded = db.workspace_for_roots(&["/tmp"]).unwrap();
let loaded_breakpoints = loaded.breakpoints.get(&Arc::from(path)).unwrap();
assert_eq!(loaded_breakpoints.len(), 2);
assert_eq!(loaded_breakpoints[0].position, breakpoint.position);
assert_eq!(loaded_breakpoints[0].kind, breakpoint.kind);
assert_eq!(loaded_breakpoints[1].position, log_breakpoint.position);
assert_eq!(loaded_breakpoints[1].kind, log_breakpoint.kind);
assert_eq!(loaded_breakpoints[0].path, Arc::from(path));
assert_eq!(loaded_breakpoints[1].path, Arc::from(path));
}
#[gpui::test]
async fn test_next_id_stability() {
env_logger::try_init().ok();
@ -1243,6 +1535,7 @@ mod tests {
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: Default::default(),
session_id: None,
window_id: None,
};
@ -1255,6 +1548,7 @@ mod tests {
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: Default::default(),
session_id: None,
window_id: None,
};
@ -1359,6 +1653,7 @@ mod tests {
),
center_group,
window_bounds: Default::default(),
breakpoints: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
@ -1393,6 +1688,7 @@ mod tests {
),
center_group: Default::default(),
window_bounds: Default::default(),
breakpoints: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
@ -1408,6 +1704,7 @@ mod tests {
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: Default::default(),
session_id: None,
window_id: Some(2),
};
@ -1447,6 +1744,7 @@ mod tests {
),
center_group: Default::default(),
window_bounds: Default::default(),
breakpoints: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
@ -1486,6 +1784,7 @@ mod tests {
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: Default::default(),
session_id: Some("session-id-1".to_owned()),
window_id: Some(10),
};
@ -1498,6 +1797,7 @@ mod tests {
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: Default::default(),
session_id: Some("session-id-1".to_owned()),
window_id: Some(20),
};
@ -1510,6 +1810,7 @@ mod tests {
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: Default::default(),
session_id: Some("session-id-2".to_owned()),
window_id: Some(30),
};
@ -1522,6 +1823,7 @@ mod tests {
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: Default::default(),
session_id: None,
window_id: None,
};
@ -1539,6 +1841,7 @@ mod tests {
display: Default::default(),
docks: Default::default(),
centered_layout: false,
breakpoints: Default::default(),
session_id: Some("session-id-2".to_owned()),
window_id: Some(50),
};
@ -1551,6 +1854,7 @@ mod tests {
),
center_group: Default::default(),
window_bounds: Default::default(),
breakpoints: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
@ -1608,6 +1912,7 @@ mod tests {
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
breakpoints: Default::default(),
centered_layout: false,
session_id: None,
window_id: None,
@ -1655,6 +1960,7 @@ mod tests {
docks: Default::default(),
centered_layout: false,
session_id: Some("one-session".to_owned()),
breakpoints: Default::default(),
window_id: Some(window_id),
})
.collect::<Vec<_>>();
@ -1746,6 +2052,7 @@ mod tests {
docks: Default::default(),
centered_layout: false,
session_id: Some("one-session".to_owned()),
breakpoints: Default::default(),
window_id: Some(window_id),
})
.collect::<Vec<_>>();

View file

@ -10,10 +10,11 @@ use db::sqlez::{
};
use gpui::{AsyncWindowContext, Entity, WeakEntity};
use itertools::Itertools as _;
use project::Project;
use project::{debugger::breakpoint_store::SerializedBreakpoint, Project};
use remote::ssh_session::SshProjectId;
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
sync::Arc,
};
@ -263,6 +264,7 @@ pub(crate) struct SerializedWorkspace {
pub(crate) display: Option<Uuid>,
pub(crate) docks: DockStructure,
pub(crate) session_id: Option<String>,
pub(crate) breakpoints: BTreeMap<Arc<Path>, Vec<SerializedBreakpoint>>,
pub(crate) window_id: Option<u64>,
}

View file

@ -127,6 +127,23 @@ static ZED_WINDOW_POSITION: LazyLock<Option<Point<Pixels>>> = LazyLock::new(|| {
actions!(assistant, [ShowConfiguration]);
actions!(
debugger,
[
Start,
Continue,
Disconnect,
Pause,
Restart,
StepInto,
StepOver,
StepOut,
StepBack,
Stop,
ToggleIgnoreBreakpoints
]
);
actions!(
workspace,
[
@ -155,6 +172,7 @@ actions!(
ReloadActiveItem,
SaveAs,
SaveWithoutFormat,
ShutdownDebugAdapters,
ToggleBottomDock,
ToggleCenteredLayout,
ToggleLeftDock,
@ -1211,6 +1229,7 @@ impl Workspace {
// Get project paths for all of the abs_paths
let mut project_paths: Vec<(PathBuf, Option<ProjectPath>)> =
Vec::with_capacity(paths_to_open.len());
for path in paths_to_open.into_iter() {
if let Some((_, project_entry)) = cx
.update(|cx| {
@ -3409,9 +3428,10 @@ impl Workspace {
serialize_workspace = false;
}
pane::Event::RemoveItem { .. } => {}
pane::Event::RemovedItem { item_id } => {
pane::Event::RemovedItem { item } => {
cx.emit(Event::ActiveItemChanged);
if let hash_map::Entry::Occupied(entry) = self.panes_by_item.entry(*item_id) {
self.update_window_edited(window, cx);
if let hash_map::Entry::Occupied(entry) = self.panes_by_item.entry(item.item_id()) {
if entry.get().entity_id() == pane.entity_id() {
entry.remove();
}
@ -4644,6 +4664,10 @@ impl Workspace {
};
if let Some(location) = location {
let breakpoints = self.project.update(cx, |project, cx| {
project.breakpoint_store().read(cx).all_breakpoints(cx)
});
let center_group = build_serialized_pane_group(&self.center.root, window, cx);
let docks = build_serialized_docks(self, window, cx);
let window_bounds = Some(SerializedWindowBounds(window.window_bounds()));
@ -4656,6 +4680,7 @@ impl Workspace {
docks,
centered_layout: self.centered_layout,
session_id: self.session_id.clone(),
breakpoints,
window_id: Some(window.window_handle().window_id().as_u64()),
};
return window.spawn(cx, |_| persistence::DB.save_workspace(serialized_workspace));
@ -4796,6 +4821,17 @@ impl Workspace {
cx.notify();
})?;
let _ = project
.update(&mut cx, |project, cx| {
project
.breakpoint_store()
.update(cx, |breakpoint_store, cx| {
breakpoint_store
.with_serialized_breakpoints(serialized_workspace.breakpoints, cx)
})
})?
.await;
// Clean up all the items that have _not_ been loaded. Our ItemIds aren't stable. That means
// after loading the items, we might have different items and in order to avoid
// the database filling up, we delete items that haven't been loaded now.