debugger: Add memory view (#33955)

Piotr Osiewicz , Anthony Eid , Mikayla Maki , and Mikayla Maki created

This is mostly setting up the UI for now; I expect it to be the biggest
chunk of work.

Release Notes:

- debugger: Added memory view

---------

Co-authored-by: Anthony Eid <hello@anthonyeid.me>
Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
Co-authored-by: Mikayla Maki <mikayla@zed.dev>

Change summary

Cargo.lock                                              |  14 
Cargo.toml                                              |   1 
assets/icons/location_edit.svg                          |   1 
crates/debugger_ui/Cargo.toml                           |   3 
crates/debugger_ui/src/debugger_panel.rs                |   2 
crates/debugger_ui/src/persistence.rs                   |  17 
crates/debugger_ui/src/session/running.rs               |  54 
crates/debugger_ui/src/session/running/memory_view.rs   | 902 +++++++++++
crates/debugger_ui/src/session/running/variable_list.rs | 174 +
crates/debugger_ui/src/tests/module_list.rs             |   1 
crates/gpui/src/elements/div.rs                         |   2 
crates/icons/src/icons.rs                               |   1 
crates/project/Cargo.toml                               |   2 
crates/project/src/debugger.rs                          |   2 
crates/project/src/debugger/dap_command.rs              |  93 +
crates/project/src/debugger/memory.rs                   | 384 ++++
crates/project/src/debugger/session.rs                  | 148 +
crates/ui/src/components/context_menu.rs                |   2 
18 files changed, 1,732 insertions(+), 71 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -4393,12 +4393,15 @@ dependencies = [
  "futures 0.3.31",
  "fuzzy",
  "gpui",
+ "hex",
  "indoc",
  "itertools 0.14.0",
  "language",
  "log",
  "menu",
+ "notifications",
  "parking_lot",
+ "parse_int",
  "paths",
  "picker",
  "pretty_assertions",
@@ -11276,6 +11279,15 @@ dependencies = [
  "windows-targets 0.52.6",
 ]
 
+[[package]]
+name = "parse_int"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c464266693329dd5a8715098c7f86e6c5fd5d985018b8318f53d9c6c2b21a31"
+dependencies = [
+ "num-traits",
+]
+
 [[package]]
 name = "partial-json-fixer"
 version = "0.5.3"
@@ -12319,6 +12331,7 @@ dependencies = [
  "anyhow",
  "askpass",
  "async-trait",
+ "base64 0.22.1",
  "buffer_diff",
  "circular-buffer",
  "client",
@@ -12364,6 +12377,7 @@ dependencies = [
  "sha2",
  "shellexpand 2.1.2",
  "shlex",
+ "smallvec",
  "smol",
  "snippet",
  "snippet_provider",

Cargo.toml 🔗

@@ -507,6 +507,7 @@ ordered-float = "2.1.1"
 palette = { version = "0.7.5", default-features = false, features = ["std"] }
 parking_lot = "0.12.1"
 partial-json-fixer = "0.5.3"
+parse_int = "0.9"
 pathdiff = "0.2"
 pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" }
 pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" }

assets/icons/location_edit.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-location-edit-icon lucide-location-edit"><path d="M17.97 9.304A8 8 0 0 0 2 10c0 4.69 4.887 9.562 7.022 11.468"/><path d="M21.378 16.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z"/><circle cx="10" cy="10" r="3"/></svg>

crates/debugger_ui/Cargo.toml 🔗

@@ -40,12 +40,15 @@ file_icons.workspace = true
 futures.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
+hex.workspace = true
 indoc.workspace = true
 itertools.workspace = true
 language.workspace = true
 log.workspace = true
 menu.workspace = true
+notifications.workspace = true
 parking_lot.workspace = true
+parse_int.workspace = true
 paths.workspace = true
 picker.workspace = true
 pretty_assertions.workspace = true

crates/debugger_ui/src/debugger_panel.rs 🔗

@@ -2,6 +2,7 @@ use crate::persistence::DebuggerPaneItem;
 use crate::session::DebugSession;
 use crate::session::running::RunningState;
 use crate::session::running::breakpoint_list::BreakpointList;
+
 use crate::{
     ClearAllBreakpoints, Continue, CopyDebugAdapterArguments, Detach, FocusBreakpointList,
     FocusConsole, FocusFrames, FocusLoadedSources, FocusModules, FocusTerminal, FocusVariables,
@@ -1804,6 +1805,7 @@ impl Render for DebugPanel {
                                                 .child(breakpoint_list)
                                                 .child(Divider::vertical())
                                                 .child(welcome_experience)
+                                                .child(Divider::vertical())
                                         } else {
                                             this.items_end()
                                                 .child(welcome_experience)

crates/debugger_ui/src/persistence.rs 🔗

@@ -11,7 +11,7 @@ use workspace::{Member, Pane, PaneAxis, Workspace};
 
 use crate::session::running::{
     self, DebugTerminal, RunningState, SubView, breakpoint_list::BreakpointList, console::Console,
-    loaded_source_list::LoadedSourceList, module_list::ModuleList,
+    loaded_source_list::LoadedSourceList, memory_view::MemoryView, module_list::ModuleList,
     stack_frame_list::StackFrameList, variable_list::VariableList,
 };
 
@@ -24,6 +24,7 @@ pub(crate) enum DebuggerPaneItem {
     Modules,
     LoadedSources,
     Terminal,
+    MemoryView,
 }
 
 impl DebuggerPaneItem {
@@ -36,6 +37,7 @@ impl DebuggerPaneItem {
             DebuggerPaneItem::Modules,
             DebuggerPaneItem::LoadedSources,
             DebuggerPaneItem::Terminal,
+            DebuggerPaneItem::MemoryView,
         ];
         VARIANTS
     }
@@ -43,6 +45,9 @@ impl DebuggerPaneItem {
     pub(crate) fn is_supported(&self, capabilities: &Capabilities) -> bool {
         match self {
             DebuggerPaneItem::Modules => capabilities.supports_modules_request.unwrap_or_default(),
+            DebuggerPaneItem::MemoryView => capabilities
+                .supports_read_memory_request
+                .unwrap_or_default(),
             DebuggerPaneItem::LoadedSources => capabilities
                 .supports_loaded_sources_request
                 .unwrap_or_default(),
@@ -59,6 +64,7 @@ impl DebuggerPaneItem {
             DebuggerPaneItem::Modules => SharedString::new_static("Modules"),
             DebuggerPaneItem::LoadedSources => SharedString::new_static("Sources"),
             DebuggerPaneItem::Terminal => SharedString::new_static("Terminal"),
+            DebuggerPaneItem::MemoryView => SharedString::new_static("Memory View"),
         }
     }
     pub(crate) fn tab_tooltip(self) -> SharedString {
@@ -80,6 +86,7 @@ impl DebuggerPaneItem {
             DebuggerPaneItem::Terminal => {
                 "Provides an interactive terminal session within the debugging environment."
             }
+            DebuggerPaneItem::MemoryView => "Allows inspection of memory contents.",
         };
         SharedString::new_static(tooltip)
     }
@@ -204,6 +211,7 @@ pub(crate) fn deserialize_pane_layout(
     breakpoint_list: &Entity<BreakpointList>,
     loaded_sources: &Entity<LoadedSourceList>,
     terminal: &Entity<DebugTerminal>,
+    memory_view: &Entity<MemoryView>,
     subscriptions: &mut HashMap<EntityId, Subscription>,
     window: &mut Window,
     cx: &mut Context<RunningState>,
@@ -228,6 +236,7 @@ pub(crate) fn deserialize_pane_layout(
                     breakpoint_list,
                     loaded_sources,
                     terminal,
+                    memory_view,
                     subscriptions,
                     window,
                     cx,
@@ -298,6 +307,12 @@ pub(crate) fn deserialize_pane_layout(
                         DebuggerPaneItem::Terminal,
                         cx,
                     )),
+                    DebuggerPaneItem::MemoryView => Box::new(SubView::new(
+                        memory_view.focus_handle(cx),
+                        memory_view.clone().into(),
+                        DebuggerPaneItem::MemoryView,
+                        cx,
+                    )),
                 })
                 .collect();
 

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

@@ -1,16 +1,17 @@
 pub(crate) mod breakpoint_list;
 pub(crate) mod console;
 pub(crate) mod loaded_source_list;
+pub(crate) mod memory_view;
 pub(crate) mod module_list;
 pub mod stack_frame_list;
 pub mod variable_list;
-
 use std::{any::Any, ops::ControlFlow, path::PathBuf, sync::Arc, time::Duration};
 
 use crate::{
     ToggleExpandItem,
     new_process_modal::resolve_path,
     persistence::{self, DebuggerPaneItem, SerializedLayout},
+    session::running::memory_view::MemoryView,
 };
 
 use super::DebugPanelItemEvent;
@@ -81,6 +82,7 @@ pub struct RunningState {
     _schedule_serialize: Option<Task<()>>,
     pub(crate) scenario: Option<DebugScenario>,
     pub(crate) scenario_context: Option<DebugScenarioContext>,
+    memory_view: Entity<MemoryView>,
 }
 
 impl RunningState {
@@ -676,14 +678,36 @@ impl RunningState {
         let session_id = session.read(cx).session_id();
         let weak_state = cx.weak_entity();
         let stack_frame_list = cx.new(|cx| {
-            StackFrameList::new(workspace.clone(), session.clone(), weak_state, window, cx)
+            StackFrameList::new(
+                workspace.clone(),
+                session.clone(),
+                weak_state.clone(),
+                window,
+                cx,
+            )
         });
 
         let debug_terminal =
             parent_terminal.unwrap_or_else(|| cx.new(|cx| DebugTerminal::empty(window, cx)));
-
-        let variable_list =
-            cx.new(|cx| VariableList::new(session.clone(), stack_frame_list.clone(), window, cx));
+        let memory_view = cx.new(|cx| {
+            MemoryView::new(
+                session.clone(),
+                workspace.clone(),
+                stack_frame_list.downgrade(),
+                window,
+                cx,
+            )
+        });
+        let variable_list = cx.new(|cx| {
+            VariableList::new(
+                session.clone(),
+                stack_frame_list.clone(),
+                memory_view.clone(),
+                weak_state.clone(),
+                window,
+                cx,
+            )
+        });
 
         let module_list = cx.new(|cx| ModuleList::new(session.clone(), workspace.clone(), cx));
 
@@ -795,6 +819,7 @@ impl RunningState {
                 &breakpoint_list,
                 &loaded_source_list,
                 &debug_terminal,
+                &memory_view,
                 &mut pane_close_subscriptions,
                 window,
                 cx,
@@ -823,6 +848,7 @@ impl RunningState {
         let active_pane = panes.first_pane();
 
         Self {
+            memory_view,
             session,
             workspace,
             focus_handle,
@@ -1234,6 +1260,12 @@ impl RunningState {
                 item_kind,
                 cx,
             )),
+            DebuggerPaneItem::MemoryView => Box::new(SubView::new(
+                self.memory_view.focus_handle(cx),
+                self.memory_view.clone().into(),
+                item_kind,
+                cx,
+            )),
         }
     }
 
@@ -1418,7 +1450,14 @@ impl RunningState {
         &self.module_list
     }
 
-    pub(crate) fn activate_item(&self, item: DebuggerPaneItem, window: &mut Window, cx: &mut App) {
+    pub(crate) fn activate_item(
+        &mut self,
+        item: DebuggerPaneItem,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.ensure_pane_item(item, window, cx);
+
         let (variable_list_position, pane) = self
             .panes
             .panes()
@@ -1430,9 +1469,10 @@ impl RunningState {
                     .map(|view| (view, pane))
             })
             .unwrap();
+
         pane.update(cx, |this, cx| {
             this.activate_item(variable_list_position, true, true, window, cx);
-        })
+        });
     }
 
     #[cfg(test)]

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

@@ -0,0 +1,902 @@
+use std::{fmt::Write, ops::RangeInclusive, sync::LazyLock, time::Duration};
+
+use editor::{Editor, EditorElement, EditorStyle};
+use gpui::{
+    Action, AppContext, DismissEvent, Empty, Entity, FocusHandle, Focusable, MouseButton,
+    MouseMoveEvent, Point, ScrollStrategy, ScrollWheelEvent, Stateful, Subscription, Task,
+    TextStyle, UniformList, UniformListScrollHandle, WeakEntity, actions, anchored, bounds,
+    deferred, point, size, uniform_list,
+};
+use notifications::status_toast::{StatusToast, ToastIcon};
+use project::debugger::{MemoryCell, session::Session};
+use settings::Settings;
+use theme::ThemeSettings;
+use ui::{
+    ActiveTheme, AnyElement, App, Color, Context, ContextMenu, Div, Divider, DropdownMenu, Element,
+    FluentBuilder, Icon, IconName, InteractiveElement, IntoElement, Label, LabelCommon,
+    ParentElement, Pixels, PopoverMenuHandle, Render, Scrollbar, ScrollbarState, SharedString,
+    StatefulInteractiveElement, Styled, TextSize, Tooltip, Window, div, h_flex, px, v_flex,
+};
+use util::ResultExt;
+use workspace::Workspace;
+
+use crate::session::running::stack_frame_list::StackFrameList;
+
+actions!(debugger, [GoToSelectedAddress]);
+
+pub(crate) struct MemoryView {
+    workspace: WeakEntity<Workspace>,
+    scroll_handle: UniformListScrollHandle,
+    scroll_state: ScrollbarState,
+    show_scrollbar: bool,
+    stack_frame_list: WeakEntity<StackFrameList>,
+    hide_scrollbar_task: Option<Task<()>>,
+    focus_handle: FocusHandle,
+    view_state: ViewState,
+    query_editor: Entity<Editor>,
+    session: Entity<Session>,
+    width_picker_handle: PopoverMenuHandle<ContextMenu>,
+    is_writing_memory: bool,
+    open_context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
+}
+
+impl Focusable for MemoryView {
+    fn focus_handle(&self, _: &ui::App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+#[derive(Clone, Debug)]
+struct Drag {
+    start_address: u64,
+    end_address: u64,
+}
+
+impl Drag {
+    fn contains(&self, address: u64) -> bool {
+        let range = self.memory_range();
+        range.contains(&address)
+    }
+
+    fn memory_range(&self) -> RangeInclusive<u64> {
+        if self.start_address < self.end_address {
+            self.start_address..=self.end_address
+        } else {
+            self.end_address..=self.start_address
+        }
+    }
+}
+#[derive(Clone, Debug)]
+enum SelectedMemoryRange {
+    DragUnderway(Drag),
+    DragComplete(Drag),
+}
+
+impl SelectedMemoryRange {
+    fn contains(&self, address: u64) -> bool {
+        match self {
+            SelectedMemoryRange::DragUnderway(drag) => drag.contains(address),
+            SelectedMemoryRange::DragComplete(drag) => drag.contains(address),
+        }
+    }
+    fn is_dragging(&self) -> bool {
+        matches!(self, SelectedMemoryRange::DragUnderway(_))
+    }
+    fn drag(&self) -> &Drag {
+        match self {
+            SelectedMemoryRange::DragUnderway(drag) => drag,
+            SelectedMemoryRange::DragComplete(drag) => drag,
+        }
+    }
+}
+
+#[derive(Clone)]
+struct ViewState {
+    /// Uppermost row index
+    base_row: u64,
+    /// How many cells per row do we have?
+    line_width: ViewWidth,
+    selection: Option<SelectedMemoryRange>,
+}
+
+impl ViewState {
+    fn new(base_row: u64, line_width: ViewWidth) -> Self {
+        Self {
+            base_row,
+            line_width,
+            selection: None,
+        }
+    }
+    fn row_count(&self) -> u64 {
+        // This was picked fully arbitrarily. There's no incentive for us to care about page sizes other than the fact that it seems to be a good
+        // middle ground for data size.
+        const PAGE_SIZE: u64 = 4096;
+        PAGE_SIZE / self.line_width.width as u64
+    }
+    fn schedule_scroll_down(&mut self) {
+        self.base_row = self.base_row.saturating_add(1)
+    }
+    fn schedule_scroll_up(&mut self) {
+        self.base_row = self.base_row.saturating_sub(1);
+    }
+}
+
+static HEX_BYTES_MEMOIZED: LazyLock<[SharedString; 256]> =
+    LazyLock::new(|| std::array::from_fn(|byte| SharedString::from(format!("{byte:02X}"))));
+static UNKNOWN_BYTE: SharedString = SharedString::new_static("??");
+impl MemoryView {
+    pub(crate) fn new(
+        session: Entity<Session>,
+        workspace: WeakEntity<Workspace>,
+        stack_frame_list: WeakEntity<StackFrameList>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let view_state = ViewState::new(0, WIDTHS[4].clone());
+        let scroll_handle = UniformListScrollHandle::default();
+
+        let query_editor = cx.new(|cx| Editor::single_line(window, cx));
+
+        let scroll_state = ScrollbarState::new(scroll_handle.clone());
+        let mut this = Self {
+            workspace,
+            scroll_state,
+            scroll_handle,
+            stack_frame_list,
+            show_scrollbar: false,
+            hide_scrollbar_task: None,
+            focus_handle: cx.focus_handle(),
+            view_state,
+            query_editor,
+            session,
+            width_picker_handle: Default::default(),
+            is_writing_memory: true,
+            open_context_menu: None,
+        };
+        this.change_query_bar_mode(false, window, cx);
+        this
+    }
+    fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
+        self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| {
+            cx.background_executor()
+                .timer(SCROLLBAR_SHOW_INTERVAL)
+                .await;
+            panel
+                .update(cx, |panel, cx| {
+                    panel.show_scrollbar = false;
+                    cx.notify();
+                })
+                .log_err();
+        }))
+    }
+
+    fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
+        if !(self.show_scrollbar || self.scroll_state.is_dragging()) {
+            return None;
+        }
+        Some(
+            div()
+                .occlude()
+                .id("memory-view-vertical-scrollbar")
+                .on_mouse_move(cx.listener(|this, evt, _, cx| {
+                    this.handle_drag(evt);
+                    cx.notify();
+                    cx.stop_propagation()
+                }))
+                .on_hover(|_, _, cx| {
+                    cx.stop_propagation();
+                })
+                .on_any_mouse_down(|_, _, cx| {
+                    cx.stop_propagation();
+                })
+                .on_mouse_up(
+                    MouseButton::Left,
+                    cx.listener(|_, _, _, cx| {
+                        cx.stop_propagation();
+                    }),
+                )
+                .on_scroll_wheel(cx.listener(|_, _, _, cx| {
+                    cx.notify();
+                }))
+                .h_full()
+                .absolute()
+                .right_1()
+                .top_1()
+                .bottom_0()
+                .w(px(12.))
+                .cursor_default()
+                .children(Scrollbar::vertical(self.scroll_state.clone())),
+        )
+    }
+
+    fn render_memory(&self, cx: &mut Context<Self>) -> UniformList {
+        let weak = cx.weak_entity();
+        let session = self.session.clone();
+        let view_state = self.view_state.clone();
+        uniform_list(
+            "debugger-memory-view",
+            self.view_state.row_count() as usize,
+            move |range, _, cx| {
+                let mut line_buffer = Vec::with_capacity(view_state.line_width.width as usize);
+                let memory_start =
+                    (view_state.base_row + range.start as u64) * view_state.line_width.width as u64;
+                let memory_end = (view_state.base_row + range.end as u64)
+                    * view_state.line_width.width as u64
+                    - 1;
+                let mut memory = session.update(cx, |this, cx| {
+                    this.read_memory(memory_start..=memory_end, cx)
+                });
+                let mut rows = Vec::with_capacity(range.end - range.start);
+                for ix in range {
+                    line_buffer.extend((&mut memory).take(view_state.line_width.width as usize));
+                    rows.push(render_single_memory_view_line(
+                        &line_buffer,
+                        ix as u64,
+                        weak.clone(),
+                        cx,
+                    ));
+                    line_buffer.clear();
+                }
+                rows
+            },
+        )
+        .track_scroll(self.scroll_handle.clone())
+        .on_scroll_wheel(cx.listener(|this, evt: &ScrollWheelEvent, window, _| {
+            let delta = evt.delta.pixel_delta(window.line_height());
+            let scroll_handle = this.scroll_state.scroll_handle();
+            let size = scroll_handle.content_size();
+            let viewport = scroll_handle.viewport();
+            let current_offset = scroll_handle.offset();
+            let first_entry_offset_boundary = size.height / this.view_state.row_count() as f32;
+            let last_entry_offset_boundary = size.height - first_entry_offset_boundary;
+            if first_entry_offset_boundary + viewport.size.height > current_offset.y.abs() {
+                // The topmost entry is visible, hence if we're scrolling up, we need to load extra lines.
+                this.view_state.schedule_scroll_up();
+            } else if last_entry_offset_boundary < current_offset.y.abs() + viewport.size.height {
+                this.view_state.schedule_scroll_down();
+            }
+            scroll_handle.set_offset(current_offset + point(px(0.), delta.y));
+        }))
+    }
+    fn render_query_bar(&self, cx: &Context<Self>) -> impl IntoElement {
+        EditorElement::new(
+            &self.query_editor,
+            Self::editor_style(&self.query_editor, cx),
+        )
+    }
+    pub(super) fn go_to_memory_reference(
+        &mut self,
+        memory_reference: &str,
+        evaluate_name: Option<&str>,
+        stack_frame_id: Option<u64>,
+        cx: &mut Context<Self>,
+    ) {
+        use parse_int::parse;
+        let Ok(as_address) = parse::<u64>(&memory_reference) else {
+            return;
+        };
+        let access_size = evaluate_name
+            .map(|typ| {
+                self.session.update(cx, |this, cx| {
+                    this.data_access_size(stack_frame_id, typ, cx)
+                })
+            })
+            .unwrap_or_else(|| Task::ready(None));
+        cx.spawn(async move |this, cx| {
+            let access_size = access_size.await.unwrap_or(1);
+            this.update(cx, |this, cx| {
+                this.view_state.selection = Some(SelectedMemoryRange::DragComplete(Drag {
+                    start_address: as_address,
+                    end_address: as_address + access_size - 1,
+                }));
+                this.jump_to_address(as_address, cx);
+            })
+            .ok();
+        })
+        .detach();
+    }
+
+    fn handle_drag(&mut self, evt: &MouseMoveEvent) {
+        if !evt.dragging() {
+            return;
+        }
+        if !self.scroll_state.is_dragging()
+            && !self
+                .view_state
+                .selection
+                .as_ref()
+                .is_some_and(|selection| selection.is_dragging())
+        {
+            return;
+        }
+        let row_count = self.view_state.row_count();
+        debug_assert!(row_count > 1);
+        let scroll_handle = self.scroll_state.scroll_handle();
+        let viewport = scroll_handle.viewport();
+        let (top_area, bottom_area) = {
+            let size = size(viewport.size.width, viewport.size.height / 10.);
+            (
+                bounds(viewport.origin, size),
+                bounds(
+                    point(viewport.origin.x, viewport.origin.y + size.height * 2.),
+                    size,
+                ),
+            )
+        };
+
+        if bottom_area.contains(&evt.position) {
+            //ix == row_count - 1 {
+            self.view_state.schedule_scroll_down();
+        } else if top_area.contains(&evt.position) {
+            self.view_state.schedule_scroll_up();
+        }
+    }
+
+    fn editor_style(editor: &Entity<Editor>, cx: &Context<Self>) -> EditorStyle {
+        let is_read_only = editor.read(cx).read_only(cx);
+        let settings = ThemeSettings::get_global(cx);
+        let theme = cx.theme();
+        let text_style = TextStyle {
+            color: if is_read_only {
+                theme.colors().text_muted
+            } else {
+                theme.colors().text
+            },
+            font_family: settings.buffer_font.family.clone(),
+            font_features: settings.buffer_font.features.clone(),
+            font_size: TextSize::Small.rems(cx).into(),
+            font_weight: settings.buffer_font.weight,
+
+            ..Default::default()
+        };
+        EditorStyle {
+            background: theme.colors().editor_background,
+            local_player: theme.players().local(),
+            text: text_style,
+            ..Default::default()
+        }
+    }
+
+    fn render_width_picker(&self, window: &mut Window, cx: &mut Context<Self>) -> DropdownMenu {
+        let weak = cx.weak_entity();
+        let selected_width = self.view_state.line_width.clone();
+        DropdownMenu::new(
+            "memory-view-width-picker",
+            selected_width.label.clone(),
+            ContextMenu::build(window, cx, |mut this, window, cx| {
+                for width in &WIDTHS {
+                    let weak = weak.clone();
+                    let width = width.clone();
+                    this = this.entry(width.label.clone(), None, move |_, cx| {
+                        _ = weak.update(cx, |this, _| {
+                            // Convert base ix between 2 line widths to keep the shown memory address roughly the same.
+                            // All widths are powers of 2, so the conversion should be lossless.
+                            match this.view_state.line_width.width.cmp(&width.width) {
+                                std::cmp::Ordering::Less => {
+                                    // We're converting up.
+                                    let shift = width.width.trailing_zeros()
+                                        - this.view_state.line_width.width.trailing_zeros();
+                                    this.view_state.base_row >>= shift;
+                                }
+                                std::cmp::Ordering::Greater => {
+                                    // We're converting down.
+                                    let shift = this.view_state.line_width.width.trailing_zeros()
+                                        - width.width.trailing_zeros();
+                                    this.view_state.base_row <<= shift;
+                                }
+                                _ => {}
+                            }
+                            this.view_state.line_width = width.clone();
+                        });
+                    });
+                }
+                if let Some(ix) = WIDTHS
+                    .iter()
+                    .position(|width| width.width == selected_width.width)
+                {
+                    for _ in 0..=ix {
+                        this.select_next(&Default::default(), window, cx);
+                    }
+                }
+                this
+            }),
+        )
+        .handle(self.width_picker_handle.clone())
+    }
+
+    fn page_down(&mut self, _: &menu::SelectLast, _: &mut Window, cx: &mut Context<Self>) {
+        self.view_state.base_row = self
+            .view_state
+            .base_row
+            .overflowing_add(self.view_state.row_count())
+            .0;
+        cx.notify();
+    }
+    fn page_up(&mut self, _: &menu::SelectFirst, _: &mut Window, cx: &mut Context<Self>) {
+        self.view_state.base_row = self
+            .view_state
+            .base_row
+            .overflowing_sub(self.view_state.row_count())
+            .0;
+        cx.notify();
+    }
+
+    fn change_query_bar_mode(
+        &mut self,
+        is_writing_memory: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if is_writing_memory == self.is_writing_memory {
+            return;
+        }
+        if !self.is_writing_memory {
+            self.query_editor.update(cx, |this, cx| {
+                this.clear(window, cx);
+                this.set_placeholder_text("Write to Selected Memory Range", cx);
+            });
+            self.is_writing_memory = true;
+            self.query_editor.focus_handle(cx).focus(window);
+        } else {
+            self.query_editor.update(cx, |this, cx| {
+                this.clear(window, cx);
+                this.set_placeholder_text("Go to Memory Address / Expression", cx);
+            });
+            self.is_writing_memory = false;
+        }
+    }
+
+    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
+        if let Some(SelectedMemoryRange::DragComplete(drag)) = &self.view_state.selection {
+            // Go into memory writing mode.
+            if !self.is_writing_memory {
+                let should_return = self.session.update(cx, |session, cx| {
+                    if !session
+                        .capabilities()
+                        .supports_write_memory_request
+                        .unwrap_or_default()
+                    {
+                        let adapter_name = session.adapter();
+                        // We cannot write memory with this adapter.
+                        _ = self.workspace.update(cx, |this, cx| {
+                            this.toggle_status_toast(
+                                StatusToast::new(format!(
+                                    "Debug Adapter `{adapter_name}` does not support writing to memory"
+                                ), cx, |this, cx| {
+                                    cx.spawn(async move |this, cx| {
+                                        cx.background_executor().timer(Duration::from_secs(2)).await;
+                                        _ = this.update(cx, |_, cx| {
+                                            cx.emit(DismissEvent)
+                                        });
+                                    }).detach();
+                                    this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
+                                }),
+                                cx,
+                            );
+                        });
+                        true
+                    } else {
+                        false
+                    }
+                });
+                if should_return {
+                    return;
+                }
+
+                self.change_query_bar_mode(true, window, cx);
+            } else if self.query_editor.focus_handle(cx).is_focused(window) {
+                let mut text = self.query_editor.read(cx).text(cx);
+                if text.chars().any(|c| !c.is_ascii_hexdigit()) {
+                    // Interpret this text as a string and oh-so-conveniently convert it.
+                    text = text.bytes().map(|byte| format!("{:02x}", byte)).collect();
+                }
+                self.session.update(cx, |this, cx| {
+                    let range = drag.memory_range();
+
+                    if let Ok(as_hex) = hex::decode(text) {
+                        this.write_memory(*range.start(), &as_hex, cx);
+                    }
+                });
+                self.change_query_bar_mode(false, window, cx);
+            }
+
+            cx.notify();
+            return;
+        }
+        // Just change the currently viewed address.
+        if !self.query_editor.focus_handle(cx).is_focused(window) {
+            return;
+        }
+        self.jump_to_query_bar_address(cx);
+    }
+
+    fn jump_to_query_bar_address(&mut self, cx: &mut Context<Self>) {
+        use parse_int::parse;
+        let text = self.query_editor.read(cx).text(cx);
+
+        let Ok(as_address) = parse::<u64>(&text) else {
+            return self.jump_to_expression(text, cx);
+        };
+        self.jump_to_address(as_address, cx);
+    }
+
+    fn jump_to_address(&mut self, address: u64, cx: &mut Context<Self>) {
+        self.view_state.base_row = (address & !0xfff) / self.view_state.line_width.width as u64;
+        let line_ix = (address & 0xfff) / self.view_state.line_width.width as u64;
+        self.scroll_handle
+            .scroll_to_item(line_ix as usize, ScrollStrategy::Center);
+        cx.notify();
+    }
+
+    fn jump_to_expression(&mut self, expr: String, cx: &mut Context<Self>) {
+        let Ok(selected_frame) = self
+            .stack_frame_list
+            .update(cx, |this, _| this.opened_stack_frame_id())
+        else {
+            return;
+        };
+        let reference = self.session.update(cx, |this, cx| {
+            this.memory_reference_of_expr(selected_frame, expr, cx)
+        });
+        cx.spawn(async move |this, cx| {
+            if let Some(reference) = reference.await {
+                _ = this.update(cx, |this, cx| {
+                    let Ok(address) = parse_int::parse::<u64>(&reference) else {
+                        return;
+                    };
+                    this.jump_to_address(address, cx);
+                });
+            }
+        })
+        .detach();
+    }
+
+    fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
+        self.view_state.selection = None;
+        cx.notify();
+    }
+
+    /// Jump to memory pointed to by selected memory range.
+    fn go_to_address(
+        &mut self,
+        _: &GoToSelectedAddress,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(SelectedMemoryRange::DragComplete(drag)) = self.view_state.selection.clone()
+        else {
+            return;
+        };
+        let range = drag.memory_range();
+        let Some(memory): Option<Vec<u8>> = self.session.update(cx, |this, cx| {
+            this.read_memory(range, cx).map(|cell| cell.0).collect()
+        }) else {
+            return;
+        };
+        if memory.len() > 8 {
+            return;
+        }
+        let zeros_to_write = 8 - memory.len();
+        let mut acc = String::from("0x");
+        acc.extend(std::iter::repeat("00").take(zeros_to_write));
+        let as_query = memory.into_iter().rev().fold(acc, |mut acc, byte| {
+            _ = write!(&mut acc, "{:02x}", byte);
+            acc
+        });
+        self.query_editor.update(cx, |this, cx| {
+            this.set_text(as_query, window, cx);
+        });
+        self.jump_to_query_bar_address(cx);
+    }
+
+    fn deploy_memory_context_menu(
+        &mut self,
+        range: RangeInclusive<u64>,
+        position: Point<Pixels>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let session = self.session.clone();
+        let context_menu = ContextMenu::build(window, cx, |menu, _, cx| {
+            let range_too_large = range.end() - range.start() > std::mem::size_of::<u64>() as u64;
+            let memory_unreadable = |cx| {
+                session.update(cx, |this, cx| {
+                    this.read_memory(range.clone(), cx)
+                        .any(|cell| cell.0.is_none())
+                })
+            };
+            menu.action_disabled_when(
+                range_too_large || memory_unreadable(cx),
+                "Go To Selected Address",
+                GoToSelectedAddress.boxed_clone(),
+            )
+            .context(self.focus_handle.clone())
+        });
+
+        cx.focus_view(&context_menu, window);
+        let subscription = cx.subscribe_in(
+            &context_menu,
+            window,
+            |this, _, _: &DismissEvent, window, cx| {
+                if this.open_context_menu.as_ref().is_some_and(|context_menu| {
+                    context_menu.0.focus_handle(cx).contains_focused(window, cx)
+                }) {
+                    cx.focus_self(window);
+                }
+                this.open_context_menu.take();
+                cx.notify();
+            },
+        );
+
+        self.open_context_menu = Some((context_menu, position, subscription));
+    }
+}
+
+#[derive(Clone)]
+struct ViewWidth {
+    width: u8,
+    label: SharedString,
+}
+
+impl ViewWidth {
+    const fn new(width: u8, label: &'static str) -> Self {
+        Self {
+            width,
+            label: SharedString::new_static(label),
+        }
+    }
+}
+
+static WIDTHS: [ViewWidth; 7] = [
+    ViewWidth::new(1, "1 byte"),
+    ViewWidth::new(2, "2 bytes"),
+    ViewWidth::new(4, "4 bytes"),
+    ViewWidth::new(8, "8 bytes"),
+    ViewWidth::new(16, "16 bytes"),
+    ViewWidth::new(32, "32 bytes"),
+    ViewWidth::new(64, "64 bytes"),
+];
+
+fn render_single_memory_view_line(
+    memory: &[MemoryCell],
+    ix: u64,
+    weak: gpui::WeakEntity<MemoryView>,
+    cx: &mut App,
+) -> AnyElement {
+    let Ok(view_state) = weak.update(cx, |this, _| this.view_state.clone()) else {
+        return div().into_any();
+    };
+    let base_address = (view_state.base_row + ix) * view_state.line_width.width as u64;
+
+    h_flex()
+        .id((
+            "memory-view-row-full",
+            ix * view_state.line_width.width as u64,
+        ))
+        .size_full()
+        .gap_x_2()
+        .child(
+            div()
+                .child(
+                    Label::new(format!("{:016X}", base_address))
+                        .buffer_font(cx)
+                        .size(ui::LabelSize::Small)
+                        .color(Color::Muted),
+                )
+                .px_1()
+                .border_r_1()
+                .border_color(Color::Muted.color(cx)),
+        )
+        .child(
+            h_flex()
+                .id((
+                    "memory-view-row-raw-memory",
+                    ix * view_state.line_width.width as u64,
+                ))
+                .px_1()
+                .children(memory.iter().enumerate().map(|(cell_ix, cell)| {
+                    let weak = weak.clone();
+                    div()
+                        .id(("memory-view-row-raw-memory-cell", cell_ix as u64))
+                        .px_0p5()
+                        .when_some(view_state.selection.as_ref(), |this, selection| {
+                            this.when(selection.contains(base_address + cell_ix as u64), |this| {
+                                let weak = weak.clone();
+
+                                this.bg(Color::Accent.color(cx)).when(
+                                    !selection.is_dragging(),
+                                    |this| {
+                                        let selection = selection.drag().memory_range();
+                                        this.on_mouse_down(
+                                            MouseButton::Right,
+                                            move |click, window, cx| {
+                                                _ = weak.update(cx, |this, cx| {
+                                                    this.deploy_memory_context_menu(
+                                                        selection.clone(),
+                                                        click.position,
+                                                        window,
+                                                        cx,
+                                                    )
+                                                });
+                                                cx.stop_propagation();
+                                            },
+                                        )
+                                    },
+                                )
+                            })
+                        })
+                        .child(
+                            Label::new(
+                                cell.0
+                                    .map(|val| HEX_BYTES_MEMOIZED[val as usize].clone())
+                                    .unwrap_or_else(|| UNKNOWN_BYTE.clone()),
+                            )
+                            .buffer_font(cx)
+                            .when(cell.0.is_none(), |this| this.color(Color::Muted))
+                            .size(ui::LabelSize::Small),
+                        )
+                        .on_drag(
+                            Drag {
+                                start_address: base_address + cell_ix as u64,
+                                end_address: base_address + cell_ix as u64,
+                            },
+                            {
+                                let weak = weak.clone();
+                                move |drag, _, _, cx| {
+                                    _ = weak.update(cx, |this, _| {
+                                        this.view_state.selection =
+                                            Some(SelectedMemoryRange::DragUnderway(drag.clone()));
+                                    });
+
+                                    cx.new(|_| Empty)
+                                }
+                            },
+                        )
+                        .on_drop({
+                            let weak = weak.clone();
+                            move |drag: &Drag, _, cx| {
+                                _ = weak.update(cx, |this, _| {
+                                    this.view_state.selection =
+                                        Some(SelectedMemoryRange::DragComplete(Drag {
+                                            start_address: drag.start_address,
+                                            end_address: base_address + cell_ix as u64,
+                                        }));
+                                });
+                            }
+                        })
+                        .drag_over(move |style, drag: &Drag, _, cx| {
+                            _ = weak.update(cx, |this, _| {
+                                this.view_state.selection =
+                                    Some(SelectedMemoryRange::DragUnderway(Drag {
+                                        start_address: drag.start_address,
+                                        end_address: base_address + cell_ix as u64,
+                                    }));
+                            });
+
+                            style
+                        })
+                })),
+        )
+        .child(
+            h_flex()
+                .id((
+                    "memory-view-row-ascii-memory",
+                    ix * view_state.line_width.width as u64,
+                ))
+                .h_full()
+                .px_1()
+                .mr_4()
+                // .gap_x_1p5()
+                .border_x_1()
+                .border_color(Color::Muted.color(cx))
+                .children(memory.iter().enumerate().map(|(ix, cell)| {
+                    let as_character = char::from(cell.0.unwrap_or(0));
+                    let as_visible = if as_character.is_ascii_graphic() {
+                        as_character
+                    } else {
+                        '·'
+                    };
+                    div()
+                        .px_0p5()
+                        .when_some(view_state.selection.as_ref(), |this, selection| {
+                            this.when(selection.contains(base_address + ix as u64), |this| {
+                                this.bg(Color::Accent.color(cx))
+                            })
+                        })
+                        .child(
+                            Label::new(format!("{as_visible}"))
+                                .buffer_font(cx)
+                                .when(cell.0.is_none(), |this| this.color(Color::Muted))
+                                .size(ui::LabelSize::Small),
+                        )
+                })),
+        )
+        .into_any()
+}
+
+impl Render for MemoryView {
+    fn render(
+        &mut self,
+        window: &mut ui::Window,
+        cx: &mut ui::Context<Self>,
+    ) -> impl ui::IntoElement {
+        let (icon, tooltip_text) = if self.is_writing_memory {
+            (IconName::Pencil, "Edit memory at a selected address")
+        } else {
+            (
+                IconName::LocationEdit,
+                "Change address of currently viewed memory",
+            )
+        };
+        v_flex()
+            .id("Memory-view")
+            .on_action(cx.listener(Self::cancel))
+            .on_action(cx.listener(Self::go_to_address))
+            .p_1()
+            .on_action(cx.listener(Self::confirm))
+            .on_action(cx.listener(Self::page_down))
+            .on_action(cx.listener(Self::page_up))
+            .size_full()
+            .track_focus(&self.focus_handle)
+            .on_hover(cx.listener(|this, hovered, window, cx| {
+                if *hovered {
+                    this.show_scrollbar = true;
+                    this.hide_scrollbar_task.take();
+                    cx.notify();
+                } else if !this.focus_handle.contains_focused(window, cx) {
+                    this.hide_scrollbar(window, cx);
+                }
+            }))
+            .child(
+                h_flex()
+                    .w_full()
+                    .mb_0p5()
+                    .gap_1()
+                    .child(
+                        h_flex()
+                            .w_full()
+                            .rounded_md()
+                            .border_1()
+                            .gap_x_2()
+                            .px_2()
+                            .py_0p5()
+                            .mb_0p5()
+                            .bg(cx.theme().colors().editor_background)
+                            .when_else(
+                                self.query_editor
+                                    .focus_handle(cx)
+                                    .contains_focused(window, cx),
+                                |this| this.border_color(cx.theme().colors().border_focused),
+                                |this| this.border_color(cx.theme().colors().border_transparent),
+                            )
+                            .child(
+                                div()
+                                    .id("memory-view-editor-icon")
+                                    .child(Icon::new(icon).size(ui::IconSize::XSmall))
+                                    .tooltip(Tooltip::text(tooltip_text)),
+                            )
+                            .child(self.render_query_bar(cx)),
+                    )
+                    .child(self.render_width_picker(window, cx)),
+            )
+            .child(Divider::horizontal())
+            .child(
+                v_flex()
+                    .size_full()
+                    .on_mouse_move(cx.listener(|this, evt: &MouseMoveEvent, _, _| {
+                        this.handle_drag(evt);
+                    }))
+                    .child(self.render_memory(cx).size_full())
+                    .children(self.open_context_menu.as_ref().map(|(menu, position, _)| {
+                        deferred(
+                            anchored()
+                                .position(*position)
+                                .anchor(gpui::Corner::TopLeft)
+                                .child(menu.clone()),
+                        )
+                        .with_priority(1)
+                    }))
+                    .children(self.render_vertical_scrollbar(cx)),
+            )
+    }
+}

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

@@ -1,3 +1,5 @@
+use crate::session::running::{RunningState, memory_view::MemoryView};
+
 use super::stack_frame_list::{StackFrameList, StackFrameListEvent};
 use dap::{
     ScopePresentationHint, StackFrameId, VariablePresentationHint, VariablePresentationHintKind,
@@ -7,13 +9,14 @@ use editor::Editor;
 use gpui::{
     Action, AnyElement, ClickEvent, ClipboardItem, Context, DismissEvent, Empty, Entity,
     FocusHandle, Focusable, Hsla, MouseButton, MouseDownEvent, Point, Stateful, Subscription,
-    TextStyleRefinement, UniformListScrollHandle, actions, anchored, deferred, uniform_list,
+    TextStyleRefinement, UniformListScrollHandle, WeakEntity, actions, anchored, deferred,
+    uniform_list,
 };
 use menu::{SelectFirst, SelectLast, SelectNext, SelectPrevious};
 use project::debugger::session::{Session, SessionEvent, Watcher};
 use std::{collections::HashMap, ops::Range, sync::Arc};
 use ui::{ContextMenu, ListItem, ScrollableHandle, Scrollbar, ScrollbarState, Tooltip, prelude::*};
-use util::debug_panic;
+use util::{debug_panic, maybe};
 
 actions!(
     variable_list,
@@ -32,6 +35,8 @@ actions!(
         AddWatch,
         /// Removes the selected variable from the watch list.
         RemoveWatch,
+        /// Jump to variable's memory location.
+        GoToMemory,
     ]
 );
 
@@ -86,30 +91,30 @@ impl EntryPath {
 }
 
 #[derive(Debug, Clone, PartialEq)]
-enum EntryKind {
+enum DapEntry {
     Watcher(Watcher),
     Variable(dap::Variable),
     Scope(dap::Scope),
 }
 
-impl EntryKind {
+impl DapEntry {
     fn as_watcher(&self) -> Option<&Watcher> {
         match self {
-            EntryKind::Watcher(watcher) => Some(watcher),
+            DapEntry::Watcher(watcher) => Some(watcher),
             _ => None,
         }
     }
 
     fn as_variable(&self) -> Option<&dap::Variable> {
         match self {
-            EntryKind::Variable(dap) => Some(dap),
+            DapEntry::Variable(dap) => Some(dap),
             _ => None,
         }
     }
 
     fn as_scope(&self) -> Option<&dap::Scope> {
         match self {
-            EntryKind::Scope(dap) => Some(dap),
+            DapEntry::Scope(dap) => Some(dap),
             _ => None,
         }
     }
@@ -117,38 +122,38 @@ impl EntryKind {
     #[cfg(test)]
     fn name(&self) -> &str {
         match self {
-            EntryKind::Watcher(watcher) => &watcher.expression,
-            EntryKind::Variable(dap) => &dap.name,
-            EntryKind::Scope(dap) => &dap.name,
+            DapEntry::Watcher(watcher) => &watcher.expression,
+            DapEntry::Variable(dap) => &dap.name,
+            DapEntry::Scope(dap) => &dap.name,
         }
     }
 }
 
 #[derive(Debug, Clone, PartialEq)]
 struct ListEntry {
-    dap_kind: EntryKind,
+    entry: DapEntry,
     path: EntryPath,
 }
 
 impl ListEntry {
     fn as_watcher(&self) -> Option<&Watcher> {
-        self.dap_kind.as_watcher()
+        self.entry.as_watcher()
     }
 
     fn as_variable(&self) -> Option<&dap::Variable> {
-        self.dap_kind.as_variable()
+        self.entry.as_variable()
     }
 
     fn as_scope(&self) -> Option<&dap::Scope> {
-        self.dap_kind.as_scope()
+        self.entry.as_scope()
     }
 
     fn item_id(&self) -> ElementId {
         use std::fmt::Write;
-        let mut id = match &self.dap_kind {
-            EntryKind::Watcher(watcher) => format!("watcher-{}", watcher.expression),
-            EntryKind::Variable(dap) => format!("variable-{}", dap.name),
-            EntryKind::Scope(dap) => format!("scope-{}", dap.name),
+        let mut id = match &self.entry {
+            DapEntry::Watcher(watcher) => format!("watcher-{}", watcher.expression),
+            DapEntry::Variable(dap) => format!("variable-{}", dap.name),
+            DapEntry::Scope(dap) => format!("scope-{}", dap.name),
         };
         for name in self.path.indices.iter() {
             _ = write!(id, "-{}", name);
@@ -158,10 +163,10 @@ impl ListEntry {
 
     fn item_value_id(&self) -> ElementId {
         use std::fmt::Write;
-        let mut id = match &self.dap_kind {
-            EntryKind::Watcher(watcher) => format!("watcher-{}", watcher.expression),
-            EntryKind::Variable(dap) => format!("variable-{}", dap.name),
-            EntryKind::Scope(dap) => format!("scope-{}", dap.name),
+        let mut id = match &self.entry {
+            DapEntry::Watcher(watcher) => format!("watcher-{}", watcher.expression),
+            DapEntry::Variable(dap) => format!("variable-{}", dap.name),
+            DapEntry::Scope(dap) => format!("scope-{}", dap.name),
         };
         for name in self.path.indices.iter() {
             _ = write!(id, "-{}", name);
@@ -188,13 +193,17 @@ pub struct VariableList {
     focus_handle: FocusHandle,
     edited_path: Option<(EntryPath, Entity<Editor>)>,
     disabled: bool,
+    memory_view: Entity<MemoryView>,
+    weak_running: WeakEntity<RunningState>,
     _subscriptions: Vec<Subscription>,
 }
 
 impl VariableList {
-    pub fn new(
+    pub(crate) fn new(
         session: Entity<Session>,
         stack_frame_list: Entity<StackFrameList>,
+        memory_view: Entity<MemoryView>,
+        weak_running: WeakEntity<RunningState>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
@@ -234,6 +243,8 @@ impl VariableList {
             edited_path: None,
             entries: Default::default(),
             entry_states: Default::default(),
+            weak_running,
+            memory_view,
         }
     }
 
@@ -284,7 +295,7 @@ impl VariableList {
                     scope.variables_reference,
                     scope.variables_reference,
                     EntryPath::for_scope(&scope.name),
-                    EntryKind::Scope(scope),
+                    DapEntry::Scope(scope),
                 )
             })
             .collect::<Vec<_>>();
@@ -298,7 +309,7 @@ impl VariableList {
                         watcher.variables_reference,
                         watcher.variables_reference,
                         EntryPath::for_watcher(watcher.expression.clone()),
-                        EntryKind::Watcher(watcher.clone()),
+                        DapEntry::Watcher(watcher.clone()),
                     )
                 })
                 .collect::<Vec<_>>(),
@@ -309,9 +320,9 @@ impl VariableList {
         while let Some((container_reference, variables_reference, mut path, dap_kind)) = stack.pop()
         {
             match &dap_kind {
-                EntryKind::Watcher(watcher) => path = path.with_child(watcher.expression.clone()),
-                EntryKind::Variable(dap) => path = path.with_name(dap.name.clone().into()),
-                EntryKind::Scope(dap) => path = path.with_child(dap.name.clone().into()),
+                DapEntry::Watcher(watcher) => path = path.with_child(watcher.expression.clone()),
+                DapEntry::Variable(dap) => path = path.with_name(dap.name.clone().into()),
+                DapEntry::Scope(dap) => path = path.with_child(dap.name.clone().into()),
             }
 
             let var_state = self
@@ -336,7 +347,7 @@ impl VariableList {
                 });
 
             entries.push(ListEntry {
-                dap_kind,
+                entry: dap_kind,
                 path: path.clone(),
             });
 
@@ -349,7 +360,7 @@ impl VariableList {
                         variables_reference,
                         child.variables_reference,
                         path.with_child(child.name.clone().into()),
-                        EntryKind::Variable(child),
+                        DapEntry::Variable(child),
                     )
                 }));
             }
@@ -380,9 +391,9 @@ impl VariableList {
     pub fn completion_variables(&self, _cx: &mut Context<Self>) -> Vec<dap::Variable> {
         self.entries
             .iter()
-            .filter_map(|entry| match &entry.dap_kind {
-                EntryKind::Variable(dap) => Some(dap.clone()),
-                EntryKind::Scope(_) | EntryKind::Watcher { .. } => None,
+            .filter_map(|entry| match &entry.entry {
+                DapEntry::Variable(dap) => Some(dap.clone()),
+                DapEntry::Scope(_) | DapEntry::Watcher { .. } => None,
             })
             .collect()
     }
@@ -400,12 +411,12 @@ impl VariableList {
                     .get(ix)
                     .and_then(|entry| Some(entry).zip(self.entry_states.get(&entry.path)))?;
 
-                match &entry.dap_kind {
-                    EntryKind::Watcher { .. } => {
+                match &entry.entry {
+                    DapEntry::Watcher { .. } => {
                         Some(self.render_watcher(entry, *state, window, cx))
                     }
-                    EntryKind::Variable(_) => Some(self.render_variable(entry, *state, window, cx)),
-                    EntryKind::Scope(_) => Some(self.render_scope(entry, *state, cx)),
+                    DapEntry::Variable(_) => Some(self.render_variable(entry, *state, window, cx)),
+                    DapEntry::Scope(_) => Some(self.render_scope(entry, *state, cx)),
                 }
             })
             .collect()
@@ -562,6 +573,51 @@ impl VariableList {
         }
     }
 
+    fn jump_to_variable_memory(
+        &mut self,
+        _: &GoToMemory,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        _ = maybe!({
+            let selection = self.selection.as_ref()?;
+            let entry = self.entries.iter().find(|entry| &entry.path == selection)?;
+            let var = entry.entry.as_variable()?;
+            let memory_reference = var.memory_reference.as_deref()?;
+
+            let sizeof_expr = if var.type_.as_ref().is_some_and(|t| {
+                t.chars()
+                    .all(|c| c.is_whitespace() || c.is_alphabetic() || c == '*')
+            }) {
+                var.type_.as_deref()
+            } else {
+                var.evaluate_name
+                    .as_deref()
+                    .map(|name| name.strip_prefix("/nat ").unwrap_or_else(|| name))
+            };
+            self.memory_view.update(cx, |this, cx| {
+                this.go_to_memory_reference(
+                    memory_reference,
+                    sizeof_expr,
+                    self.selected_stack_frame_id,
+                    cx,
+                );
+            });
+            let weak_panel = self.weak_running.clone();
+
+            window.defer(cx, move |window, cx| {
+                _ = weak_panel.update(cx, |this, cx| {
+                    this.activate_item(
+                        crate::persistence::DebuggerPaneItem::MemoryView,
+                        window,
+                        cx,
+                    );
+                });
+            });
+            Some(())
+        });
+    }
+
     fn deploy_list_entry_context_menu(
         &mut self,
         entry: ListEntry,
@@ -584,6 +640,7 @@ impl VariableList {
                         menu.action("Edit Value", EditVariable.boxed_clone())
                     })
                     .action("Watch Variable", AddWatch.boxed_clone())
+                    .action("Go To Memory", GoToMemory.boxed_clone())
             })
             .when(entry.as_watcher().is_some(), |menu| {
                 menu.action("Copy Name", CopyVariableName.boxed_clone())
@@ -628,10 +685,10 @@ impl VariableList {
             return;
         };
 
-        let variable_name = match &entry.dap_kind {
-            EntryKind::Variable(dap) => dap.name.clone(),
-            EntryKind::Watcher(watcher) => watcher.expression.to_string(),
-            EntryKind::Scope(_) => return,
+        let variable_name = match &entry.entry {
+            DapEntry::Variable(dap) => dap.name.clone(),
+            DapEntry::Watcher(watcher) => watcher.expression.to_string(),
+            DapEntry::Scope(_) => return,
         };
 
         cx.write_to_clipboard(ClipboardItem::new_string(variable_name));
@@ -651,10 +708,10 @@ impl VariableList {
             return;
         };
 
-        let variable_value = match &entry.dap_kind {
-            EntryKind::Variable(dap) => dap.value.clone(),
-            EntryKind::Watcher(watcher) => watcher.value.to_string(),
-            EntryKind::Scope(_) => return,
+        let variable_value = match &entry.entry {
+            DapEntry::Variable(dap) => dap.value.clone(),
+            DapEntry::Watcher(watcher) => watcher.value.to_string(),
+            DapEntry::Scope(_) => return,
         };
 
         cx.write_to_clipboard(ClipboardItem::new_string(variable_value));
@@ -669,10 +726,10 @@ impl VariableList {
             return;
         };
 
-        let variable_value = match &entry.dap_kind {
-            EntryKind::Watcher(watcher) => watcher.value.to_string(),
-            EntryKind::Variable(variable) => variable.value.clone(),
-            EntryKind::Scope(_) => return,
+        let variable_value = match &entry.entry {
+            DapEntry::Watcher(watcher) => watcher.value.to_string(),
+            DapEntry::Variable(variable) => variable.value.clone(),
+            DapEntry::Scope(_) => return,
         };
 
         let editor = Self::create_variable_editor(&variable_value, window, cx);
@@ -753,7 +810,7 @@ impl VariableList {
                 "{}{} {}{}",
                 INDENT.repeat(state.depth - 1),
                 if state.is_expanded { "v" } else { ">" },
-                entry.dap_kind.name(),
+                entry.entry.name(),
                 if self.selection.as_ref() == Some(&entry.path) {
                     " <=== selected"
                 } else {
@@ -770,8 +827,8 @@ impl VariableList {
     pub(crate) fn scopes(&self) -> Vec<dap::Scope> {
         self.entries
             .iter()
-            .filter_map(|entry| match &entry.dap_kind {
-                EntryKind::Scope(scope) => Some(scope),
+            .filter_map(|entry| match &entry.entry {
+                DapEntry::Scope(scope) => Some(scope),
                 _ => None,
             })
             .cloned()
@@ -785,10 +842,10 @@ impl VariableList {
         let mut idx = 0;
 
         for entry in self.entries.iter() {
-            match &entry.dap_kind {
-                EntryKind::Watcher { .. } => continue,
-                EntryKind::Variable(dap) => scopes[idx].1.push(dap.clone()),
-                EntryKind::Scope(scope) => {
+            match &entry.entry {
+                DapEntry::Watcher { .. } => continue,
+                DapEntry::Variable(dap) => scopes[idx].1.push(dap.clone()),
+                DapEntry::Scope(scope) => {
                     if scopes.len() > 0 {
                         idx += 1;
                     }
@@ -806,8 +863,8 @@ impl VariableList {
     pub(crate) fn variables(&self) -> Vec<dap::Variable> {
         self.entries
             .iter()
-            .filter_map(|entry| match &entry.dap_kind {
-                EntryKind::Variable(variable) => Some(variable),
+            .filter_map(|entry| match &entry.entry {
+                DapEntry::Variable(variable) => Some(variable),
                 _ => None,
             })
             .cloned()
@@ -1358,6 +1415,7 @@ impl Render for VariableList {
             .on_action(cx.listener(Self::edit_variable))
             .on_action(cx.listener(Self::add_watcher))
             .on_action(cx.listener(Self::remove_watcher))
+            .on_action(cx.listener(Self::jump_to_variable_memory))
             .child(
                 uniform_list(
                     "variable-list",

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

@@ -111,7 +111,6 @@ async fn test_module_list(executor: BackgroundExecutor, cx: &mut TestAppContext)
         });
 
     running_state.update_in(cx, |this, window, cx| {
-        this.ensure_pane_item(DebuggerPaneItem::Modules, window, cx);
         this.activate_item(DebuggerPaneItem::Modules, window, cx);
         cx.refresh_windows();
     });

crates/gpui/src/elements/div.rs 🔗

@@ -903,7 +903,7 @@ pub trait InteractiveElement: Sized {
     /// Apply the given style when the given data type is dragged over this element
     fn drag_over<S: 'static>(
         mut self,
-        f: impl 'static + Fn(StyleRefinement, &S, &Window, &App) -> StyleRefinement,
+        f: impl 'static + Fn(StyleRefinement, &S, &mut Window, &mut App) -> StyleRefinement,
     ) -> Self {
         self.interactivity().drag_over_styles.push((
             TypeId::of::<S>(),

crates/icons/src/icons.rs 🔗

@@ -163,6 +163,7 @@ pub enum IconName {
     ListTree,
     ListX,
     LoadCircle,
+    LocationEdit,
     LockOutlined,
     LspDebug,
     LspRestart,

crates/project/Cargo.toml 🔗

@@ -31,6 +31,7 @@ aho-corasick.workspace = true
 anyhow.workspace = true
 askpass.workspace = true
 async-trait.workspace = true
+base64.workspace = true
 buffer_diff.workspace = true
 circular-buffer.workspace = true
 client.workspace = true
@@ -72,6 +73,7 @@ settings.workspace = true
 sha2.workspace = true
 shellexpand.workspace = true
 shlex.workspace = true
+smallvec.workspace = true
 smol.workspace = true
 snippet.workspace = true
 snippet_provider.workspace = true

crates/project/src/debugger.rs 🔗

@@ -15,7 +15,9 @@ pub mod breakpoint_store;
 pub mod dap_command;
 pub mod dap_store;
 pub mod locators;
+mod memory;
 pub mod session;
 
 #[cfg(any(feature = "test-support", test))]
 pub mod test;
+pub use memory::MemoryCell;

crates/project/src/debugger/dap_command.rs 🔗

@@ -1,6 +1,7 @@
 use std::sync::Arc;
 
 use anyhow::{Context as _, Ok, Result};
+use base64::Engine;
 use dap::{
     Capabilities, ContinueArguments, ExceptionFilterOptions, InitializeRequestArguments,
     InitializeRequestArgumentsPathFormat, NextArguments, SetVariableResponse, SourceBreakpoint,
@@ -1774,3 +1775,95 @@ impl DapCommand for LocationsCommand {
         })
     }
 }
+
+#[derive(Debug, Hash, PartialEq, Eq)]
+pub(crate) struct ReadMemory {
+    pub(crate) memory_reference: String,
+    pub(crate) offset: Option<u64>,
+    pub(crate) count: u64,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct ReadMemoryResponse {
+    pub(super) address: Arc<str>,
+    pub(super) unreadable_bytes: Option<u64>,
+    pub(super) content: Arc<[u8]>,
+}
+
+impl LocalDapCommand for ReadMemory {
+    type Response = ReadMemoryResponse;
+    type DapRequest = dap::requests::ReadMemory;
+    const CACHEABLE: bool = true;
+
+    fn is_supported(capabilities: &Capabilities) -> bool {
+        capabilities
+            .supports_read_memory_request
+            .unwrap_or_default()
+    }
+    fn to_dap(&self) -> <Self::DapRequest as dap::requests::Request>::Arguments {
+        dap::ReadMemoryArguments {
+            memory_reference: self.memory_reference.clone(),
+            offset: self.offset,
+            count: self.count,
+        }
+    }
+
+    fn response_from_dap(
+        &self,
+        message: <Self::DapRequest as dap::requests::Request>::Response,
+    ) -> Result<Self::Response> {
+        let data = if let Some(data) = message.data {
+            base64::engine::general_purpose::STANDARD
+                .decode(data)
+                .log_err()
+                .context("parsing base64 data from DAP's ReadMemory response")?
+        } else {
+            vec![]
+        };
+
+        Ok(ReadMemoryResponse {
+            address: message.address.into(),
+            content: data.into(),
+            unreadable_bytes: message.unreadable_bytes,
+        })
+    }
+}
+
+impl LocalDapCommand for dap::DataBreakpointInfoArguments {
+    type Response = dap::DataBreakpointInfoResponse;
+    type DapRequest = dap::requests::DataBreakpointInfo;
+    const CACHEABLE: bool = true;
+    fn is_supported(capabilities: &Capabilities) -> bool {
+        capabilities.supports_data_breakpoints.unwrap_or_default()
+    }
+    fn to_dap(&self) -> <Self::DapRequest as dap::requests::Request>::Arguments {
+        self.clone()
+    }
+
+    fn response_from_dap(
+        &self,
+        message: <Self::DapRequest as dap::requests::Request>::Response,
+    ) -> Result<Self::Response> {
+        Ok(message)
+    }
+}
+
+impl LocalDapCommand for dap::WriteMemoryArguments {
+    type Response = dap::WriteMemoryResponse;
+    type DapRequest = dap::requests::WriteMemory;
+    fn is_supported(capabilities: &Capabilities) -> bool {
+        capabilities
+            .supports_write_memory_request
+            .unwrap_or_default()
+    }
+    fn to_dap(&self) -> <Self::DapRequest as dap::requests::Request>::Arguments {
+        self.clone()
+    }
+
+    fn response_from_dap(
+        &self,
+        message: <Self::DapRequest as dap::requests::Request>::Response,
+    ) -> Result<Self::Response> {
+        Ok(message)
+    }
+}

crates/project/src/debugger/memory.rs 🔗

@@ -0,0 +1,384 @@
+//! This module defines the format in which memory of debuggee is represented.
+//!
+//! Each byte in memory can either be mapped or unmapped. We try to mimic that twofold:
+//! - We assume that the memory is divided into pages of a fixed size.
+//! - We assume that each page can be either mapped or unmapped.
+//! These two assumptions drive the shape of the memory representation.
+//! In particular, we want the unmapped pages to be represented without allocating any memory, as *most*
+//! of the memory in a program space is usually unmapped.
+//! Note that per DAP we don't know what the address space layout is, so we can't optimize off of it.
+//! Note that while we optimize for a paged layout, we also want to be able to represent memory that is not paged.
+//! This use case is relevant to embedded folks. Furthermore, we cater to default 4k page size.
+//! It is picked arbitrarily as a ubiquous default - other than that, the underlying format of Zed's memory storage should not be relevant
+//! to the users of this module.
+
+use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc};
+
+use gpui::BackgroundExecutor;
+use smallvec::SmallVec;
+
+const PAGE_SIZE: u64 = 4096;
+
+/// Represents the contents of a single page. We special-case unmapped pages to be allocation-free,
+/// since they're going to make up the majority of the memory in a program space (even though the user might not even get to see them - ever).
+#[derive(Clone, Debug)]
+pub(super) enum PageContents {
+    /// Whole page is unreadable.
+    Unmapped,
+    Mapped(Arc<MappedPageContents>),
+}
+
+impl PageContents {
+    #[cfg(test)]
+    fn mapped(contents: Vec<u8>) -> Self {
+        PageContents::Mapped(Arc::new(MappedPageContents(
+            vec![PageChunk::Mapped(contents.into())].into(),
+        )))
+    }
+}
+
+#[derive(Clone, Debug)]
+enum PageChunk {
+    Mapped(Arc<[u8]>),
+    Unmapped(u64),
+}
+
+impl PageChunk {
+    fn len(&self) -> u64 {
+        match self {
+            PageChunk::Mapped(contents) => contents.len() as u64,
+            PageChunk::Unmapped(size) => *size,
+        }
+    }
+}
+
+impl MappedPageContents {
+    fn len(&self) -> u64 {
+        self.0.iter().map(|chunk| chunk.len()).sum()
+    }
+}
+/// We hope for the whole page to be mapped in a single chunk, but we do leave the possibility open
+/// of having interleaved read permissions in a single page; debuggee's execution environment might either
+/// have a different page size OR it might not have paged memory layout altogether
+/// (which might be relevant to embedded systems).
+///
+/// As stated previously, the concept of a page in this module has to do more
+/// with optimizing fetching of the memory and not with the underlying bits and pieces
+/// of the memory of a debuggee.
+
+#[derive(Default, Debug)]
+pub(super) struct MappedPageContents(
+    /// Most of the time there should be only one chunk (either mapped or unmapped),
+    /// but we do leave the possibility open of having multiple regions of memory in a single page.
+    SmallVec<[PageChunk; 1]>,
+);
+
+type MemoryAddress = u64;
+#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Ord, Eq)]
+#[repr(transparent)]
+pub(super) struct PageAddress(u64);
+
+impl PageAddress {
+    pub(super) fn iter_range(
+        range: RangeInclusive<PageAddress>,
+    ) -> impl Iterator<Item = PageAddress> {
+        let mut current = range.start().0;
+        let end = range.end().0;
+
+        std::iter::from_fn(move || {
+            if current > end {
+                None
+            } else {
+                let addr = PageAddress(current);
+                current += PAGE_SIZE;
+                Some(addr)
+            }
+        })
+    }
+}
+
+pub(super) struct Memory {
+    pages: BTreeMap<PageAddress, PageContents>,
+}
+
+/// Represents a single memory cell (or None if a given cell is unmapped/unknown).
+#[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Ord, Eq)]
+#[repr(transparent)]
+pub struct MemoryCell(pub Option<u8>);
+
+impl Memory {
+    pub(super) fn new() -> Self {
+        Self {
+            pages: Default::default(),
+        }
+    }
+
+    pub(super) fn memory_range_to_page_range(
+        range: RangeInclusive<MemoryAddress>,
+    ) -> RangeInclusive<PageAddress> {
+        let start_page = (range.start() / PAGE_SIZE) * PAGE_SIZE;
+        let end_page = (range.end() / PAGE_SIZE) * PAGE_SIZE;
+        PageAddress(start_page)..=PageAddress(end_page)
+    }
+
+    pub(super) fn build_page(&self, page_address: PageAddress) -> Option<MemoryPageBuilder> {
+        if self.pages.contains_key(&page_address) {
+            // We already know the state of this page.
+            None
+        } else {
+            Some(MemoryPageBuilder::new(page_address))
+        }
+    }
+
+    pub(super) fn insert_page(&mut self, address: PageAddress, page: PageContents) {
+        self.pages.insert(address, page);
+    }
+
+    pub(super) fn memory_range(&self, range: RangeInclusive<MemoryAddress>) -> MemoryIterator {
+        let pages = Self::memory_range_to_page_range(range.clone());
+        let pages = self
+            .pages
+            .range(pages)
+            .map(|(address, page)| (*address, page.clone()))
+            .collect::<Vec<_>>();
+        MemoryIterator::new(range, pages.into_iter())
+    }
+
+    pub(crate) fn clear(&mut self, background_executor: &BackgroundExecutor) {
+        let memory = std::mem::take(&mut self.pages);
+        background_executor
+            .spawn(async move {
+                drop(memory);
+            })
+            .detach();
+    }
+}
+
+/// Builder for memory pages.
+///
+/// Memory reads in DAP are sequential (or at least we make them so).
+/// ReadMemory response includes `unreadableBytes` property indicating the number of bytes
+/// that could not be read after the last successfully read byte.
+///
+/// We use it as follows:
+/// - We start off with a "large" 1-page ReadMemory request.
+/// - If it succeeds/fails wholesale, cool; we have no unknown memory regions in this page.
+/// - If it succeeds partially, we know # of mapped bytes.
+///   We might also know the # of unmapped bytes.
+/// However, we're still unsure about what's *after* the unreadable region.
+///
+/// This is where this builder comes in. It lets us track the state of figuring out contents of a single page.
+pub(super) struct MemoryPageBuilder {
+    chunks: MappedPageContents,
+    base_address: PageAddress,
+    left_to_read: u64,
+}
+
+/// Represents a chunk of memory of which we don't know if it's mapped or unmapped; thus we need
+/// to issue a request to figure out it's state.
+pub(super) struct UnknownMemory {
+    pub(super) address: MemoryAddress,
+    pub(super) size: u64,
+}
+
+impl MemoryPageBuilder {
+    fn new(base_address: PageAddress) -> Self {
+        Self {
+            chunks: Default::default(),
+            base_address,
+            left_to_read: PAGE_SIZE,
+        }
+    }
+
+    pub(super) fn build(self) -> (PageAddress, PageContents) {
+        debug_assert_eq!(self.left_to_read, 0);
+        debug_assert_eq!(
+            self.chunks.len(),
+            PAGE_SIZE,
+            "Expected `build` to be called on a fully-fetched page"
+        );
+        let contents = if let Some(first) = self.chunks.0.first()
+            && self.chunks.len() == 1
+            && matches!(first, PageChunk::Unmapped(PAGE_SIZE))
+        {
+            PageContents::Unmapped
+        } else {
+            PageContents::Mapped(Arc::new(MappedPageContents(self.chunks.0)))
+        };
+        (self.base_address, contents)
+    }
+    /// Drives the fetching of memory, in an iterator-esque style.
+    pub(super) fn next_request(&self) -> Option<UnknownMemory> {
+        if self.left_to_read == 0 {
+            None
+        } else {
+            let offset_in_current_page = PAGE_SIZE - self.left_to_read;
+            Some(UnknownMemory {
+                address: self.base_address.0 + offset_in_current_page,
+                size: self.left_to_read,
+            })
+        }
+    }
+    pub(super) fn unknown(&mut self, bytes: u64) {
+        if bytes == 0 {
+            return;
+        }
+        self.left_to_read -= bytes;
+        self.chunks.0.push(PageChunk::Unmapped(bytes));
+    }
+    pub(super) fn known(&mut self, data: Arc<[u8]>) {
+        if data.is_empty() {
+            return;
+        }
+        self.left_to_read -= data.len() as u64;
+        self.chunks.0.push(PageChunk::Mapped(data));
+    }
+}
+
+fn page_contents_into_iter(data: Arc<MappedPageContents>) -> Box<dyn Iterator<Item = MemoryCell>> {
+    let mut data_range = 0..data.0.len();
+    let iter = std::iter::from_fn(move || {
+        let data = &data;
+        let data_ref = data.clone();
+        data_range.next().map(move |index| {
+            let contents = &data_ref.0[index];
+            match contents {
+                PageChunk::Mapped(items) => {
+                    let chunk_range = 0..items.len();
+                    let items = items.clone();
+                    Box::new(
+                        chunk_range
+                            .into_iter()
+                            .map(move |ix| MemoryCell(Some(items[ix]))),
+                    ) as Box<dyn Iterator<Item = MemoryCell>>
+                }
+                PageChunk::Unmapped(len) => {
+                    Box::new(std::iter::repeat_n(MemoryCell(None), *len as usize))
+                }
+            }
+        })
+    })
+    .flatten();
+
+    Box::new(iter)
+}
+/// Defines an iteration over a range of memory. Some of this memory might be unmapped or straight up missing.
+/// Thus, this iterator alternates between synthesizing values and yielding known memory.
+pub struct MemoryIterator {
+    start: MemoryAddress,
+    end: MemoryAddress,
+    current_known_page: Option<(PageAddress, Box<dyn Iterator<Item = MemoryCell>>)>,
+    pages: std::vec::IntoIter<(PageAddress, PageContents)>,
+}
+
+impl MemoryIterator {
+    fn new(
+        range: RangeInclusive<MemoryAddress>,
+        pages: std::vec::IntoIter<(PageAddress, PageContents)>,
+    ) -> Self {
+        Self {
+            start: *range.start(),
+            end: *range.end(),
+            current_known_page: None,
+            pages,
+        }
+    }
+    fn fetch_next_page(&mut self) -> bool {
+        if let Some((mut address, chunk)) = self.pages.next() {
+            let mut contents = match chunk {
+                PageContents::Unmapped => None,
+                PageContents::Mapped(mapped_page_contents) => {
+                    Some(page_contents_into_iter(mapped_page_contents))
+                }
+            };
+
+            if address.0 < self.start {
+                // Skip ahead till our iterator is at the start of the range
+
+                //address: 20, start: 25
+                //
+                let to_skip = self.start - address.0;
+                address.0 += to_skip;
+                if let Some(contents) = &mut contents {
+                    contents.nth(to_skip as usize - 1);
+                }
+            }
+            self.current_known_page = contents.map(|contents| (address, contents));
+            true
+        } else {
+            false
+        }
+    }
+}
+impl Iterator for MemoryIterator {
+    type Item = MemoryCell;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        if self.start > self.end {
+            return None;
+        }
+        if let Some((current_page_address, current_memory_chunk)) = self.current_known_page.as_mut()
+        {
+            if current_page_address.0 <= self.start {
+                if let Some(next_cell) = current_memory_chunk.next() {
+                    self.start += 1;
+                    return Some(next_cell);
+                } else {
+                    self.current_known_page.take();
+                }
+            }
+        }
+        if !self.fetch_next_page() {
+            self.start += 1;
+            return Some(MemoryCell(None));
+        } else {
+            self.next()
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::debugger::{
+        MemoryCell,
+        memory::{MemoryIterator, PageAddress, PageContents},
+    };
+
+    #[test]
+    fn iterate_over_unmapped_memory() {
+        let empty_iterator = MemoryIterator::new(0..=127, Default::default());
+        let actual = empty_iterator.collect::<Vec<_>>();
+        let expected = vec![MemoryCell(None); 128];
+        assert_eq!(actual.len(), expected.len());
+        assert_eq!(actual, expected);
+    }
+
+    #[test]
+    fn iterate_over_partially_mapped_memory() {
+        let it = MemoryIterator::new(
+            0..=127,
+            vec![(PageAddress(5), PageContents::mapped(vec![1]))].into_iter(),
+        );
+        let actual = it.collect::<Vec<_>>();
+        let expected = std::iter::repeat_n(MemoryCell(None), 5)
+            .chain(std::iter::once(MemoryCell(Some(1))))
+            .chain(std::iter::repeat_n(MemoryCell(None), 122))
+            .collect::<Vec<_>>();
+        assert_eq!(actual.len(), expected.len());
+        assert_eq!(actual, expected);
+    }
+
+    #[test]
+    fn reads_from_the_middle_of_a_page() {
+        let partial_iter = MemoryIterator::new(
+            20..=30,
+            vec![(PageAddress(0), PageContents::mapped((0..255).collect()))].into_iter(),
+        );
+        let actual = partial_iter.collect::<Vec<_>>();
+        let expected = (20..=30)
+            .map(|val| MemoryCell(Some(val)))
+            .collect::<Vec<_>>();
+        assert_eq!(actual.len(), expected.len());
+        assert_eq!(actual, expected);
+    }
+}

crates/project/src/debugger/session.rs 🔗

@@ -1,4 +1,6 @@
 use crate::debugger::breakpoint_store::BreakpointSessionState;
+use crate::debugger::dap_command::ReadMemory;
+use crate::debugger::memory::{self, Memory, MemoryIterator, MemoryPageBuilder, PageAddress};
 
 use super::breakpoint_store::{
     BreakpointStore, BreakpointStoreEvent, BreakpointUpdatedReason, SourceBreakpoint,
@@ -13,6 +15,7 @@ use super::dap_command::{
 };
 use super::dap_store::DapStore;
 use anyhow::{Context as _, Result, anyhow};
+use base64::Engine;
 use collections::{HashMap, HashSet, IndexMap};
 use dap::adapters::{DebugAdapterBinary, DebugAdapterName};
 use dap::messages::Response;
@@ -26,7 +29,7 @@ use dap::{
 use dap::{
     ExceptionBreakpointsFilter, ExceptionFilterOptions, OutputEvent, OutputEventCategory,
     RunInTerminalRequestArguments, StackFramePresentationHint, StartDebuggingRequestArguments,
-    StartDebuggingRequestArgumentsRequest, VariablePresentationHint,
+    StartDebuggingRequestArgumentsRequest, VariablePresentationHint, WriteMemoryArguments,
 };
 use futures::SinkExt;
 use futures::channel::mpsc::UnboundedSender;
@@ -42,6 +45,7 @@ use serde_json::Value;
 use smol::stream::StreamExt;
 use std::any::TypeId;
 use std::collections::BTreeMap;
+use std::ops::RangeInclusive;
 use std::u64;
 use std::{
     any::Any,
@@ -52,7 +56,7 @@ use std::{
 };
 use task::TaskContext;
 use text::{PointUtf16, ToPointUtf16};
-use util::ResultExt;
+use util::{ResultExt, maybe};
 use worktree::Worktree;
 
 #[derive(Debug, Copy, Clone, Hash, PartialEq, PartialOrd, Ord, Eq)]
@@ -685,6 +689,7 @@ pub struct Session {
     background_tasks: Vec<Task<()>>,
     restart_task: Option<Task<()>>,
     task_context: TaskContext,
+    memory: memory::Memory,
     quirks: SessionQuirks,
 }
 
@@ -855,6 +860,7 @@ impl Session {
                 label,
                 adapter,
                 task_context,
+                memory: memory::Memory::new(),
                 quirks,
             };
 
@@ -1664,6 +1670,11 @@ impl Session {
         self.invalidate_command_type::<ModulesCommand>();
         self.invalidate_command_type::<LoadedSourcesCommand>();
         self.invalidate_command_type::<ThreadsCommand>();
+        self.invalidate_command_type::<ReadMemory>();
+        let executor = self.as_running().map(|running| running.executor.clone());
+        if let Some(executor) = executor {
+            self.memory.clear(&executor);
+        }
     }
 
     fn invalidate_state(&mut self, key: &RequestSlot) {
@@ -1736,6 +1747,135 @@ impl Session {
         &self.modules
     }
 
+    // CodeLLDB returns the size of a pointed-to-memory, which we can use to make the experience of go-to-memory better.
+    pub fn data_access_size(
+        &mut self,
+        frame_id: Option<u64>,
+        evaluate_name: &str,
+        cx: &mut Context<Self>,
+    ) -> Task<Option<u64>> {
+        let request = self.request(
+            EvaluateCommand {
+                expression: format!("?${{sizeof({evaluate_name})}}"),
+                frame_id,
+
+                context: Some(EvaluateArgumentsContext::Repl),
+                source: None,
+            },
+            |_, response, _| response.ok(),
+            cx,
+        );
+        cx.background_spawn(async move {
+            let result = request.await?;
+            result.result.parse().ok()
+        })
+    }
+
+    pub fn memory_reference_of_expr(
+        &mut self,
+        frame_id: Option<u64>,
+        expression: String,
+        cx: &mut Context<Self>,
+    ) -> Task<Option<String>> {
+        let request = self.request(
+            EvaluateCommand {
+                expression,
+                frame_id,
+
+                context: Some(EvaluateArgumentsContext::Repl),
+                source: None,
+            },
+            |_, response, _| response.ok(),
+            cx,
+        );
+        cx.background_spawn(async move {
+            let result = request.await?;
+            result.memory_reference
+        })
+    }
+
+    pub fn write_memory(&mut self, address: u64, data: &[u8], cx: &mut Context<Self>) {
+        let data = base64::engine::general_purpose::STANDARD.encode(data);
+        self.request(
+            WriteMemoryArguments {
+                memory_reference: address.to_string(),
+                data,
+                allow_partial: None,
+                offset: None,
+            },
+            |this, response, cx| {
+                this.memory.clear(cx.background_executor());
+                this.invalidate_command_type::<ReadMemory>();
+                this.invalidate_command_type::<VariablesCommand>();
+                cx.emit(SessionEvent::Variables);
+                response.ok()
+            },
+            cx,
+        )
+        .detach();
+    }
+    pub fn read_memory(
+        &mut self,
+        range: RangeInclusive<u64>,
+        cx: &mut Context<Self>,
+    ) -> MemoryIterator {
+        // This function is a bit more involved when it comes to fetching data.
+        // Since we attempt to read memory in pages, we need to account for some parts
+        // of memory being unreadable. Therefore, we start off by fetching a page per request.
+        // In case that fails, we try to re-fetch smaller regions until we have the full range.
+        let page_range = Memory::memory_range_to_page_range(range.clone());
+        for page_address in PageAddress::iter_range(page_range) {
+            self.read_single_page_memory(page_address, cx);
+        }
+        self.memory.memory_range(range)
+    }
+
+    fn read_single_page_memory(&mut self, page_start: PageAddress, cx: &mut Context<Self>) {
+        _ = maybe!({
+            let builder = self.memory.build_page(page_start)?;
+
+            self.memory_read_fetch_page_recursive(builder, cx);
+            Some(())
+        });
+    }
+    fn memory_read_fetch_page_recursive(
+        &mut self,
+        mut builder: MemoryPageBuilder,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(next_request) = builder.next_request() else {
+            // We're done fetching. Let's grab the page and insert it into our memory store.
+            let (address, contents) = builder.build();
+            self.memory.insert_page(address, contents);
+
+            return;
+        };
+        let size = next_request.size;
+        self.fetch(
+            ReadMemory {
+                memory_reference: format!("0x{:X}", next_request.address),
+                offset: Some(0),
+                count: next_request.size,
+            },
+            move |this, memory, cx| {
+                if let Ok(memory) = memory {
+                    builder.known(memory.content);
+                    if let Some(unknown) = memory.unreadable_bytes {
+                        builder.unknown(unknown);
+                    }
+                    // This is the recursive bit: if we're not yet done with
+                    // the whole page, we'll kick off a new request with smaller range.
+                    // Note that this function is recursive only conceptually;
+                    // since it kicks off a new request with callback, we don't need to worry about stack overflow.
+                    this.memory_read_fetch_page_recursive(builder, cx);
+                } else {
+                    builder.unknown(size);
+                }
+            },
+            cx,
+        );
+    }
+
     pub fn ignore_breakpoints(&self) -> bool {
         self.ignore_breakpoints
     }
@@ -2378,6 +2518,8 @@ impl Session {
                 move |this, response, cx| {
                     let response = response.log_err()?;
                     this.invalidate_command_type::<VariablesCommand>();
+                    this.invalidate_command_type::<ReadMemory>();
+                    this.memory.clear(cx.background_executor());
                     this.refresh_watchers(stack_frame_id, cx);
                     cx.emit(SessionEvent::Variables);
                     Some(response)
@@ -2417,6 +2559,8 @@ impl Session {
         cx.spawn(async move |this, cx| {
             let response = request.await;
             this.update(cx, |this, cx| {
+                this.memory.clear(cx.background_executor());
+                this.invalidate_command_type::<ReadMemory>();
                 match response {
                     Ok(response) => {
                         let event = dap::OutputEvent {

crates/ui/src/components/context_menu.rs 🔗

@@ -668,7 +668,7 @@ impl ContextMenu {
         }
     }
 
-    fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
+    pub fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
         if let Some(ix) = self.selected_index {
             let next_index = ix + 1;
             if self.items.len() <= next_index {