diff --git a/Cargo.lock b/Cargo.lock index 97b1e33211..da46d191ef 100644 --- a/Cargo.lock +++ b/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", diff --git a/Cargo.toml b/Cargo.toml index dedf570052..e270dd1891 100644 --- a/Cargo.toml +++ b/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" } diff --git a/assets/icons/location_edit.svg b/assets/icons/location_edit.svg new file mode 100644 index 0000000000..de82e8db4e --- /dev/null +++ b/assets/icons/location_edit.svg @@ -0,0 +1 @@ + diff --git a/crates/debugger_ui/Cargo.toml b/crates/debugger_ui/Cargo.toml index fe9640b7b9..ebb135c1d9 100644 --- a/crates/debugger_ui/Cargo.toml +++ b/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 diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 37064d5d5d..bf5f313918 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/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) diff --git a/crates/debugger_ui/src/persistence.rs b/crates/debugger_ui/src/persistence.rs index d15244c349..3a0ad7a40e 100644 --- a/crates/debugger_ui/src/persistence.rs +++ b/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, loaded_sources: &Entity, terminal: &Entity, + memory_view: &Entity, subscriptions: &mut HashMap, window: &mut Window, cx: &mut Context, @@ -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(); diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index 264d46370f..2651a94520 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/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>, pub(crate) scenario: Option, pub(crate) scenario_context: Option, + memory_view: Entity, } 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.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)] diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs new file mode 100644 index 0000000000..e9dcb0839d --- /dev/null +++ b/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, + scroll_handle: UniformListScrollHandle, + scroll_state: ScrollbarState, + show_scrollbar: bool, + stack_frame_list: WeakEntity, + hide_scrollbar_task: Option>, + focus_handle: FocusHandle, + view_state: ViewState, + query_editor: Entity, + session: Entity, + width_picker_handle: PopoverMenuHandle, + is_writing_memory: bool, + open_context_menu: Option<(Entity, Point, 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 { + 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, +} + +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, + workspace: WeakEntity, + stack_frame_list: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> 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) { + 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) -> Option> { + 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) -> 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) -> 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, + cx: &mut Context, + ) { + use parse_int::parse; + let Ok(as_address) = parse::(&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, cx: &Context) -> 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) -> 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.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.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, + ) { + 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) { + 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) { + use parse_int::parse; + let text = self.query_editor.read(cx).text(cx); + + let Ok(as_address) = parse::(&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.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) { + 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::(&reference) else { + return; + }; + this.jump_to_address(address, cx); + }); + } + }) + .detach(); + } + + fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { + 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, + ) { + let Some(SelectedMemoryRange::DragComplete(drag)) = self.view_state.selection.clone() + else { + return; + }; + let range = drag.memory_range(); + let Some(memory): Option> = 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, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + 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::() 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, + 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, + ) -> 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)), + ) + } +} diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index bdb095bde3..c7df449ee6 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/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)>, disabled: bool, + memory_view: Entity, + weak_running: WeakEntity, _subscriptions: Vec, } impl VariableList { - pub fn new( + pub(crate) fn new( session: Entity, stack_frame_list: Entity, + memory_view: Entity, + weak_running: WeakEntity, window: &mut Window, cx: &mut Context, ) -> 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::>(); @@ -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::>(), @@ -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) -> Vec { 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, + ) { + _ = 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 { 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 { 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", diff --git a/crates/debugger_ui/src/tests/module_list.rs b/crates/debugger_ui/src/tests/module_list.rs index 49cfd6fcf8..09c90cbc4a 100644 --- a/crates/debugger_ui/src/tests/module_list.rs +++ b/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(); }); diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 6e05b384e1..cb53276bc2 100644 --- a/crates/gpui/src/elements/div.rs +++ b/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( 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::(), diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 4151e6b2ea..29f7a8f50d 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -163,6 +163,7 @@ pub enum IconName { ListTree, ListX, LoadCircle, + LocationEdit, LockOutlined, LspDebug, LspRestart, diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 729d61aab5..57d6d6ca28 100644 --- a/crates/project/Cargo.toml +++ b/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 diff --git a/crates/project/src/debugger.rs b/crates/project/src/debugger.rs index d078988a51..6c22468040 100644 --- a/crates/project/src/debugger.rs +++ b/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; diff --git a/crates/project/src/debugger/dap_command.rs b/crates/project/src/debugger/dap_command.rs index 411bacd3ba..1a3587024e 100644 --- a/crates/project/src/debugger/dap_command.rs +++ b/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, + pub(crate) count: u64, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ReadMemoryResponse { + pub(super) address: Arc, + pub(super) unreadable_bytes: Option, + 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) -> ::Arguments { + dap::ReadMemoryArguments { + memory_reference: self.memory_reference.clone(), + offset: self.offset, + count: self.count, + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + 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) -> ::Arguments { + self.clone() + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + 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) -> ::Arguments { + self.clone() + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message) + } +} diff --git a/crates/project/src/debugger/memory.rs b/crates/project/src/debugger/memory.rs new file mode 100644 index 0000000000..fec3c344c5 --- /dev/null +++ b/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), +} + +impl PageContents { + #[cfg(test)] + fn mapped(contents: Vec) -> 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, + ) -> impl Iterator { + 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, +} + +/// 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); + +impl Memory { + pub(super) fn new() -> Self { + Self { + pages: Default::default(), + } + } + + pub(super) fn memory_range_to_page_range( + range: RangeInclusive, + ) -> RangeInclusive { + 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 { + 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) -> MemoryIterator { + let pages = Self::memory_range_to_page_range(range.clone()); + let pages = self + .pages + .range(pages) + .map(|(address, page)| (*address, page.clone())) + .collect::>(); + 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 { + 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) -> Box> { + 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> + } + 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>)>, + pages: std::vec::IntoIter<(PageAddress, PageContents)>, +} + +impl MemoryIterator { + fn new( + range: RangeInclusive, + 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 { + 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::>(); + 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::>(); + 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::>(); + 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::>(); + let expected = (20..=30) + .map(|val| MemoryCell(Some(val))) + .collect::>(); + assert_eq!(actual.len(), expected.len()); + assert_eq!(actual, expected); + } +} diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index f526d5a3fe..53c13e13c3 100644 --- a/crates/project/src/debugger/session.rs +++ b/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>, restart_task: Option>, 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::(); self.invalidate_command_type::(); self.invalidate_command_type::(); + self.invalidate_command_type::(); + 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, + evaluate_name: &str, + cx: &mut Context, + ) -> Task> { + 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, + expression: String, + cx: &mut Context, + ) -> Task> { + 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) { + 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::(); + this.invalidate_command_type::(); + cx.emit(SessionEvent::Variables); + response.ok() + }, + cx, + ) + .detach(); + } + pub fn read_memory( + &mut self, + range: RangeInclusive, + cx: &mut Context, + ) -> 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) { + _ = 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, + ) { + 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::(); + this.invalidate_command_type::(); + 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::(); match response { Ok(response) => { let event = dap::OutputEvent { diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index b5bdfdd8bb..3ba73f6dff 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/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) { + pub fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { if let Some(ix) = self.selected_index { let next_index = ix + 1; if self.items.len() <= next_index {