From 73126dcb81d6a780ae1e6264b0d0ab2ad8adc365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yara=20=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7=EF=B8=8F?= Date: Fri, 17 Apr 2026 13:54:43 +0200 Subject: [PATCH] editor: Introduce Bookmarks (#54174) Adds basic bookmark functionality to the editor, allowing users to mark lines and later navigate between them. This is an MVP and will later be expanded with a picker, vim marks integration and syntax tree based bookmark positions. In this MVP bookmarks shift under external edits. # UI ## Adding/Removing bookmarks To add a bookmark: - run the toggle bookmark action - hold secondary and click in the gutter - open the context menu by right clicking in the gutter and select add bookmark To remove a bookmark: - run the toggle bookmark action - click on the bookmarks icon in the gutter - open the context menu by right clicking in the gutter and select remove bookmark remove all bookmarks with `workspace: clear bookmarks` # Implementation This mirrors the implementation of breakpoints. The rendering of the gutter was refactored to make place for bookmark icons and buttons: - Code was extracted to a `Gutter` struct - Runnables, breakpoints and bookmarks are now collected ahead of layouting. Just before layouting we remove the items that collide and do not have priority. - The `phantom_breakpoint` is replaced by a `gutter_hover_button` ## In depth phantom breakpoint discussion: This was phantom_breakpoint. It worked as follows: - A fake breakpoint was added to the list of breakpoints. - While rendering the breakpoints it a breakpoint turned out to be fake it would get a different description and look. - The breakpoint list was edited run_indicators ("play buttons") rendering to removes the fake breakpoint if it collided. This would not scale to more functionality. Now we only render breakpoints, bookmarks and run indicators. Then we render a button if there is not breakpoint, bookmark or run indicator already present. We can do so since the rendering of such "gutter indicators" has been refactored into two phases: - collect the items. - render them if no higher priority item collides. This is far easier and more readable which enabled me to easily take the phantom_breakpoint system and use it for placing bookmarks as well :) Note: this was previously merged but it needed a better squashed commit message. For the actual PR see: 51404. This reverts commit 7e523a2d2b23b464e49d5db7c1bb452af3b19b1d. Release Notes: - Added Bookmarks Co-authored-by: Austin Cummings --- assets/icons/bookmark.svg | 4 + assets/settings/default.json | 2 + codebook.toml | 1 + crates/agent_ui/src/entry_view_state.rs | 1 + crates/collab_ui/src/channel_view.rs | 3 + crates/debugger_tools/src/dap_log.rs | 1 + .../src/session/running/console.rs | 1 + .../src/rate_prediction_modal.rs | 1 + crates/editor/src/actions.rs | 8 + crates/editor/src/bookmarks.rs | 243 +++++ crates/editor/src/editor.rs | 475 ++++++++-- crates/editor/src/editor_settings.rs | 2 + crates/editor/src/editor_tests.rs | 616 +++++++++++-- crates/editor/src/element.rs | 853 +++++++++--------- crates/editor/src/runnables.rs | 11 +- crates/git_ui/src/commit_view.rs | 1 + crates/gpui/src/window.rs | 11 + crates/icons/src/icons.rs | 1 + crates/inspector_ui/src/div_inspector.rs | 1 + crates/language_tools/src/lsp_log_view.rs | 1 + crates/project/src/bookmark_store.rs | 444 +++++++++ crates/project/src/project.rs | 21 + .../tests/integration/bookmark_store.rs | 685 ++++++++++++++ .../tests/integration/project_tests.rs | 1 + crates/settings/src/vscode_import.rs | 1 + crates/settings_content/src/editor.rs | 4 + crates/settings_ui/src/page_data.rs | 25 +- crates/workspace/src/persistence.rs | 119 +++ crates/workspace/src/persistence/model.rs | 7 +- crates/workspace/src/workspace.rs | 29 + crates/zed/src/visual_test_runner.rs | 4 +- 31 files changed, 2955 insertions(+), 622 deletions(-) create mode 100644 assets/icons/bookmark.svg create mode 100644 codebook.toml create mode 100644 crates/editor/src/bookmarks.rs create mode 100644 crates/project/src/bookmark_store.rs create mode 100644 crates/project/tests/integration/bookmark_store.rs diff --git a/assets/icons/bookmark.svg b/assets/icons/bookmark.svg new file mode 100644 index 0000000000000000000000000000000000000000..999c9b72a1ead51fe9e899d6a6946e7840bfe742 --- /dev/null +++ b/assets/icons/bookmark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/settings/default.json b/assets/settings/default.json index 584c4c4d49d573be8ca600edde638c428bace3e6..335dc7543ccd171d0cc91deee364f945186d3868 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -614,6 +614,8 @@ "line_numbers": true, // Whether to show runnables buttons in the gutter. "runnables": true, + // Whether to show bookmarks in the gutter. + "bookmarks": true, // Whether to show breakpoints in the gutter. "breakpoints": true, // Whether to show fold buttons in the gutter. diff --git a/codebook.toml b/codebook.toml new file mode 100644 index 0000000000000000000000000000000000000000..57cdd2569c350bdf35a27195aa6ab86a1b08ab83 --- /dev/null +++ b/codebook.toml @@ -0,0 +1 @@ +words = ["breakpoint"] diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index 415cd1f3db19df29895d7dd984e7ac4fb4a7b47b..8543b3c96199e7b303971b476ffaade9484384a7 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -458,6 +458,7 @@ fn create_editor_diff( editor.set_show_indent_guides(false, cx); editor.set_read_only(true); editor.set_delegate_open_excerpts(true); + editor.set_show_bookmarks(false, cx); editor.set_show_breakpoints(false, cx); editor.set_show_code_actions(false, cx); editor.set_show_git_diff_gutter(false, cx); diff --git a/crates/collab_ui/src/channel_view.rs b/crates/collab_ui/src/channel_view.rs index 48dbd43dd449911a0c40f9e943ad917843941c02..f4aadb7433f143ba0bcd18e494e4b838da1f5894 100644 --- a/crates/collab_ui/src/channel_view.rs +++ b/crates/collab_ui/src/channel_view.rs @@ -216,6 +216,9 @@ impl ChannelView { }) })) }); + editor.set_show_bookmarks(false, cx); + editor.set_show_breakpoints(false, cx); + editor.set_show_runnables(false, cx); editor }); let _editor_event_subscription = diff --git a/crates/debugger_tools/src/dap_log.rs b/crates/debugger_tools/src/dap_log.rs index 2c653217716b0218cff0b60eb2bce4ac1ce02e5d..c364cdd244752afdda203911653e8a60e54b7871 100644 --- a/crates/debugger_tools/src/dap_log.rs +++ b/crates/debugger_tools/src/dap_log.rs @@ -761,6 +761,7 @@ impl DapLogView { editor.set_text(log_contents, window, cx); editor.move_to_end(&editor::actions::MoveToEnd, window, cx); editor.set_show_code_actions(false, cx); + editor.set_show_bookmarks(false, cx); editor.set_show_breakpoints(false, cx); editor.set_show_git_diff_gutter(false, cx); editor.set_show_runnables(false, cx); diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index c541257b6d219b56a611f8a3711da287109ef48d..d1c53203329d738bfd96b2fc0ac89446d7cdcc54 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -73,6 +73,7 @@ impl Console { editor.disable_scrollbars_and_minimap(window, cx); editor.set_show_gutter(false, cx); editor.set_show_runnables(false, cx); + editor.set_show_bookmarks(false, cx); editor.set_show_breakpoints(false, cx); editor.set_show_code_actions(false, cx); editor.set_show_line_numbers(false, cx); diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs index eb071bf955cede173e74993c93ab5cd294338474..6ff7c0e2e46efe1142414e9e5717e1607323636c 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -439,6 +439,7 @@ impl RatePredictionsModal { editor.set_show_git_diff_gutter(false, cx); editor.set_show_code_actions(false, cx); editor.set_show_runnables(false, cx); + editor.set_show_bookmarks(false, cx); editor.set_show_breakpoints(false, cx); editor.set_show_wrap_guides(false, cx); editor.set_show_indent_guides(false, cx); diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 4c0623006953c0aae9c718b2e894ba85b26074e0..7524c5b01bf090be3661d1af03f918aa3a7449fb 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -576,10 +576,14 @@ actions!( GoToImplementation, /// Goes to implementation in a split pane. GoToImplementationSplit, + /// Goes to the next bookmark in the file. + GoToNextBookmark, /// Goes to the next change in the file. GoToNextChange, /// Goes to the parent module of the current file. GoToParentModule, + /// Goes to the previous bookmark in the file. + GoToPreviousBookmark, /// Goes to the previous change in the file. GoToPreviousChange, /// Goes to the next symbol. @@ -670,6 +674,8 @@ actions!( NextScreen, /// Goes to the next snippet tabstop if one exists. NextSnippetTabstop, + /// Opens a view of all bookmarks in the project. + ViewBookmarks, /// Opens the context menu at cursor position. OpenContextMenu, /// Opens excerpts from the current file. @@ -819,6 +825,8 @@ actions!( Tab, /// Removes a tab character or outdents. Backtab, + /// Toggles a bookmark at the current line. + ToggleBookmark, /// Toggles a breakpoint at the current line. ToggleBreakpoint, /// Toggles the case of selected text. diff --git a/crates/editor/src/bookmarks.rs b/crates/editor/src/bookmarks.rs new file mode 100644 index 0000000000000000000000000000000000000000..fe047e7fa18c22f4dc90e665f1a24c1ee53f1fda --- /dev/null +++ b/crates/editor/src/bookmarks.rs @@ -0,0 +1,243 @@ +use std::ops::Range; + +use gpui::Entity; +use multi_buffer::{Anchor, MultiBufferOffset, MultiBufferSnapshot, ToOffset as _}; +use project::{Project, bookmark_store::BookmarkStore}; +use rope::Point; +use text::Bias; +use ui::{Context, Window}; +use util::ResultExt as _; +use workspace::{Workspace, searchable::Direction}; + +use crate::display_map::DisplayRow; +use crate::{ + Editor, GoToNextBookmark, GoToPreviousBookmark, MultibufferSelectionMode, SelectionEffects, + ToggleBookmark, ViewBookmarks, scroll::Autoscroll, +}; + +impl Editor { + pub fn set_show_bookmarks(&mut self, show_bookmarks: bool, cx: &mut Context) { + self.show_bookmarks = Some(show_bookmarks); + cx.notify(); + } + + pub fn toggle_bookmark( + &mut self, + _: &ToggleBookmark, + window: &mut Window, + cx: &mut Context, + ) { + let Some(bookmark_store) = self.bookmark_store.clone() else { + return; + }; + let Some(project) = self.project() else { + return; + }; + + let snapshot = self.snapshot(window, cx); + let multi_buffer_snapshot = snapshot.buffer_snapshot(); + + let mut selections = self.selections.all::(&snapshot.display_snapshot); + selections.sort_by_key(|s| s.head()); + selections.dedup_by_key(|s| s.head().row); + + for selection in &selections { + let head = selection.head(); + let multibuffer_anchor = multi_buffer_snapshot.anchor_before(Point::new(head.row, 0)); + + if let Some((buffer_anchor, _)) = + multi_buffer_snapshot.anchor_to_buffer_anchor(multibuffer_anchor) + { + let buffer_id = buffer_anchor.buffer_id; + if let Some(buffer) = project.read(cx).buffer_for_id(buffer_id, cx) { + bookmark_store.update(cx, |store, cx| { + store.toggle_bookmark(buffer, buffer_anchor, cx); + }); + } + } + } + + cx.notify(); + } + + pub fn toggle_bookmark_at_row(&mut self, row: DisplayRow, cx: &mut Context) { + let Some(bookmark_store) = &self.bookmark_store else { + return; + }; + let display_snapshot = self.display_snapshot(cx); + let point = display_snapshot.display_point_to_point(row.as_display_point(), Bias::Left); + let buffer_snapshot = self.buffer.read(cx).snapshot(cx); + let anchor = buffer_snapshot.anchor_before(point); + + let Some((position, _)) = buffer_snapshot.anchor_to_buffer_anchor(anchor) else { + return; + }; + let Some(buffer) = self.buffer.read(cx).buffer(position.buffer_id) else { + return; + }; + + bookmark_store.update(cx, |bookmark_store, cx| { + bookmark_store.toggle_bookmark(buffer, position, cx); + }); + + cx.notify(); + } + + pub fn toggle_bookmark_at_anchor(&mut self, anchor: Anchor, cx: &mut Context) { + let Some(bookmark_store) = &self.bookmark_store else { + return; + }; + let buffer_snapshot = self.buffer.read(cx).snapshot(cx); + let Some((position, _)) = buffer_snapshot.anchor_to_buffer_anchor(anchor) else { + return; + }; + let Some(buffer) = self.buffer.read(cx).buffer(position.buffer_id) else { + return; + }; + + bookmark_store.update(cx, |bookmark_store, cx| { + bookmark_store.toggle_bookmark(buffer, position, cx); + }); + + cx.notify(); + } + + pub fn go_to_next_bookmark( + &mut self, + _: &GoToNextBookmark, + window: &mut Window, + cx: &mut Context, + ) { + self.go_to_bookmark_impl(Direction::Next, window, cx); + } + + pub fn go_to_previous_bookmark( + &mut self, + _: &GoToPreviousBookmark, + window: &mut Window, + cx: &mut Context, + ) { + self.go_to_bookmark_impl(Direction::Prev, window, cx); + } + + fn go_to_bookmark_impl( + &mut self, + direction: Direction, + window: &mut Window, + cx: &mut Context, + ) { + let Some(project) = &self.project else { + return; + }; + let Some(bookmark_store) = &self.bookmark_store else { + return; + }; + + let selection = self + .selections + .newest::(&self.display_snapshot(cx)); + let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); + + let mut all_bookmarks = Self::bookmarks_in_range( + MultiBufferOffset(0)..multi_buffer_snapshot.len(), + &multi_buffer_snapshot, + project, + bookmark_store, + cx, + ); + all_bookmarks.sort_by_key(|a| a.to_offset(&multi_buffer_snapshot)); + + let anchor = match direction { + Direction::Next => all_bookmarks + .iter() + .find(|anchor| anchor.to_offset(&multi_buffer_snapshot) > selection.head()) + .or_else(|| all_bookmarks.first()), + Direction::Prev => all_bookmarks + .iter() + .rfind(|anchor| anchor.to_offset(&multi_buffer_snapshot) < selection.head()) + .or_else(|| all_bookmarks.last()), + } + .cloned(); + + if let Some(anchor) = anchor { + self.unfold_ranges(&[anchor..anchor], true, false, cx); + self.change_selections( + SelectionEffects::scroll(Autoscroll::center()), + window, + cx, + |s| { + s.select_anchor_ranges([anchor..anchor]); + }, + ); + } + } + + pub fn view_bookmarks( + workspace: &mut Workspace, + _: &ViewBookmarks, + window: &mut Window, + cx: &mut Context, + ) { + let bookmark_store = workspace.project().read(cx).bookmark_store(); + cx.spawn_in(window, async move |workspace, cx| { + let Some(locations) = BookmarkStore::all_bookmark_locations(bookmark_store, cx) + .await + .log_err() + else { + return; + }; + + workspace + .update_in(cx, |workspace, window, cx| { + Editor::open_locations_in_multibuffer( + workspace, + locations, + "Bookmarks".into(), + false, + false, + MultibufferSelectionMode::First, + window, + cx, + ); + }) + .log_err(); + }) + .detach(); + } + + fn bookmarks_in_range( + range: Range, + multi_buffer_snapshot: &MultiBufferSnapshot, + project: &Entity, + bookmark_store: &Entity, + cx: &mut Context, + ) -> Vec { + multi_buffer_snapshot + .range_to_buffer_ranges(range) + .into_iter() + .flat_map(|(buffer_snapshot, buffer_range, _excerpt_range)| { + let Some(buffer) = project + .read(cx) + .buffer_for_id(buffer_snapshot.remote_id(), cx) + else { + return Vec::new(); + }; + bookmark_store + .update(cx, |store, cx| { + store.bookmarks_for_buffer( + buffer, + buffer_snapshot.anchor_before(buffer_range.start) + ..buffer_snapshot.anchor_after(buffer_range.end), + &buffer_snapshot, + cx, + ) + }) + .into_iter() + .filter_map(|bookmark| { + multi_buffer_snapshot.anchor_in_buffer(bookmark.anchor()) + }) + .collect::>() + }) + .collect() + } +} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index f3dfef45783240042675667af4207797ae65b743..784c05371630d8201396cab3b797cfab037258f0 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -43,6 +43,7 @@ pub mod semantic_tokens; mod split; pub mod split_editor_view; +mod bookmarks; #[cfg(test)] mod code_completion_tests; #[cfg(test)] @@ -162,6 +163,7 @@ use project::{ CompletionResponse, CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint, InlayId, InvalidationStrategy, Location, LocationLink, LspAction, PrepareRenameResponse, Project, ProjectItem, ProjectPath, ProjectTransaction, + bookmark_store::BookmarkStore, debugger::{ breakpoint_store::{ Breakpoint, BreakpointEditAction, BreakpointSessionState, BreakpointState, @@ -351,6 +353,7 @@ pub fn init(cx: &mut App) { workspace.register_action(Editor::new_file_horizontal); workspace.register_action(Editor::cancel_language_server_work); workspace.register_action(Editor::toggle_focus); + workspace.register_action(Editor::view_bookmarks); }, ) .detach(); @@ -1026,15 +1029,14 @@ enum ColumnarSelectionState { }, } -/// Represents a breakpoint indicator that shows up when hovering over lines in the gutter that don't have -/// a breakpoint on them. +/// Represents a button that shows up when hovering over lines in the gutter that don't have +/// any button on them already (like a bookmark, breakpoint or run indicator). #[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct PhantomBreakpointIndicator { +struct GutterHoverButton { display_row: DisplayRow, /// There's a small debounce between hovering over the line and showing the indicator. /// We don't want to show the indicator when moving the mouse from editor to e.g. project panel. is_active: bool, - collides_with_existing_breakpoint: bool, } /// Represents a diff review button indicator that shows up when hovering over lines in the gutter @@ -1190,6 +1192,7 @@ pub struct Editor { show_git_diff_gutter: Option, show_code_actions: Option, show_runnables: Option, + show_bookmarks: Option, show_breakpoints: Option, show_diff_review_button: bool, show_wrap_guides: Option, @@ -1297,8 +1300,9 @@ pub struct Editor { last_position_map: Option>, expect_bounds_change: Option>, runnables: RunnableData, + bookmark_store: Option>, breakpoint_store: Option>, - gutter_breakpoint_indicator: (Option, Option>), + gutter_hover_button: (Option, Option>), pub(crate) gutter_diff_review_indicator: (Option, Option>), pub(crate) diff_review_drag_state: Option, /// Active diff review overlays. Multiple overlays can be open simultaneously @@ -1404,6 +1408,7 @@ pub struct EditorSnapshot { show_code_actions: Option, show_runnables: Option, show_breakpoints: Option, + show_bookmarks: Option, git_blame_gutter_max_author_length: Option, pub display_snapshot: DisplaySnapshot, pub placeholder_display_snapshot: Option, @@ -2365,6 +2370,11 @@ impl Editor { None }; + let bookmark_store = match (&mode, project.as_ref()) { + (EditorMode::Full { .. }, Some(project)) => Some(project.read(cx).bookmark_store()), + _ => None, + }; + let breakpoint_store = match (&mode, project.as_ref()) { (EditorMode::Full { .. }, Some(project)) => Some(project.read(cx).breakpoint_store()), _ => None, @@ -2442,6 +2452,7 @@ impl Editor { show_git_diff_gutter: None, show_code_actions: None, show_runnables: None, + show_bookmarks: None, show_breakpoints: None, show_diff_review_button: false, show_wrap_guides: None, @@ -2544,8 +2555,9 @@ impl Editor { blame: None, blame_subscription: None, + bookmark_store, breakpoint_store, - gutter_breakpoint_indicator: (None, None), + gutter_hover_button: (None, None), gutter_diff_review_indicator: (None, None), diff_review_drag_state: None, diff_review_overlays: Vec::new(), @@ -3323,6 +3335,7 @@ impl Editor { semantic_tokens_enabled: self.semantic_token_state.enabled(), show_code_actions: self.show_code_actions, show_runnables: self.show_runnables, + show_bookmarks: self.show_bookmarks, show_breakpoints: self.show_breakpoints, git_blame_gutter_max_author_length, scroll_anchor: self.scroll_manager.shared_scroll_anchor(cx), @@ -8623,6 +8636,9 @@ impl Editor { let mouse_position = window.mouse_position(); if !position_map.text_hitbox.is_hovered(window) { + if self.gutter_hover_button.0.is_some() { + cx.notify(); + } return; } @@ -9005,6 +9021,138 @@ impl Editor { Some(self.edit_prediction_provider.as_ref()?.provider.clone()) } + fn active_run_indicators( + &mut self, + range: Range, + window: &mut Window, + cx: &mut Context, + ) -> HashSet { + let snapshot = self.snapshot(window, cx); + + let offset_range_start = + snapshot.display_point_to_point(DisplayPoint::new(range.start, 0), Bias::Left); + + let offset_range_end = + snapshot.display_point_to_point(DisplayPoint::new(range.end, 0), Bias::Right); + + self.runnables + .all_runnables() + .filter_map(|tasks| { + let multibuffer_point = tasks.offset.to_point(&snapshot.buffer_snapshot()); + if multibuffer_point < offset_range_start || multibuffer_point > offset_range_end { + return None; + } + let multibuffer_row = MultiBufferRow(multibuffer_point.row); + let buffer_folded = snapshot + .buffer_snapshot() + .buffer_line_for_row(multibuffer_row) + .map(|(buffer_snapshot, _)| buffer_snapshot.remote_id()) + .map(|buffer_id| self.is_buffer_folded(buffer_id, cx)) + .unwrap_or(false); + if buffer_folded { + return None; + } + + if snapshot.is_line_folded(multibuffer_row) { + // Skip folded indicators, unless it's the starting line of a fold. + if multibuffer_row + .0 + .checked_sub(1) + .is_some_and(|previous_row| { + snapshot.is_line_folded(MultiBufferRow(previous_row)) + }) + { + return None; + } + } + + let display_row = multibuffer_point.to_display_point(&snapshot).row(); + Some(display_row) + }) + .collect() + } + + fn active_bookmarks( + &self, + range: Range, + window: &mut Window, + cx: &mut Context, + ) -> HashSet { + let mut bookmark_display_points = HashSet::default(); + + let Some(bookmark_store) = self.bookmark_store.clone() else { + return bookmark_display_points; + }; + + let snapshot = self.snapshot(window, cx); + + let multi_buffer_snapshot = snapshot.buffer_snapshot(); + let Some(project) = self.project() else { + return bookmark_display_points; + }; + + let range = snapshot.display_point_to_point(DisplayPoint::new(range.start, 0), Bias::Left) + ..snapshot.display_point_to_point(DisplayPoint::new(range.end, 0), Bias::Right); + + for (buffer_snapshot, range, _excerpt_range) in + multi_buffer_snapshot.range_to_buffer_ranges(range.start..range.end) + { + let Some(buffer) = project + .read(cx) + .buffer_for_id(buffer_snapshot.remote_id(), cx) + else { + continue; + }; + let bookmarks = bookmark_store.update(cx, |store, cx| { + store.bookmarks_for_buffer( + buffer, + buffer_snapshot.anchor_before(range.start) + ..buffer_snapshot.anchor_after(range.end), + &buffer_snapshot, + cx, + ) + }); + for bookmark in bookmarks { + let Some(multi_buffer_anchor) = + multi_buffer_snapshot.anchor_in_buffer(bookmark.anchor()) + else { + continue; + }; + let position = multi_buffer_anchor + .to_point(&multi_buffer_snapshot) + .to_display_point(&snapshot); + + bookmark_display_points.insert(position.row()); + } + } + + bookmark_display_points + } + + fn render_bookmark(&self, row: DisplayRow, cx: &mut Context) -> IconButton { + let focus_handle = self.focus_handle.clone(); + IconButton::new(("bookmark indicator", row.0 as usize), IconName::Bookmark) + .icon_size(IconSize::XSmall) + .size(ui::ButtonSize::None) + .icon_color(Color::Info) + .style(ButtonStyle::Transparent) + .on_click(cx.listener(move |editor, _, _, cx| { + editor.toggle_bookmark_at_row(row, cx); + })) + .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { + editor.set_gutter_context_menu(row, None, event.position(), window, cx); + })) + .tooltip(move |_window, cx| { + Tooltip::with_meta_in( + "Remove bookmark", + Some(&ToggleBookmark), + SharedString::from("Right-click for more options."), + &focus_handle, + cx, + ) + }) + } + /// Get all display points of breakpoints that will be rendered within editor /// /// This function is used to handle overlaps between breakpoints and Code action/runner symbol. @@ -9064,7 +9212,7 @@ impl Editor { breakpoint_display_points } - fn breakpoint_context_menu( + fn gutter_context_menu( &self, anchor: Anchor, window: &mut Window, @@ -9114,6 +9262,14 @@ impl Editor { "Set Breakpoint" }; + let bookmark = self.bookmark_at_row(row, window, cx); + + let set_bookmark_msg = if bookmark.as_ref().is_some() { + "Remove Bookmark" + } else { + "Add Bookmark" + }; + let run_to_cursor = window.is_action_available(&RunToCursor, cx); let toggle_state_msg = breakpoint.as_ref().map_or(None, |bp| match bp.1.state { @@ -9213,16 +9369,28 @@ impl Editor { .log_err(); } }) - .entry(hit_condition_breakpoint_msg, None, move |window, cx| { + .entry(hit_condition_breakpoint_msg, None, { + let breakpoint = breakpoint.clone(); + let weak_editor = weak_editor.clone(); + move |window, cx| { + weak_editor + .update(cx, |this, cx| { + this.add_edit_breakpoint_block( + anchor, + breakpoint.as_ref(), + BreakpointPromptEditAction::HitCondition, + window, + cx, + ); + }) + .log_err(); + } + }) + .separator() + .entry(set_bookmark_msg, None, move |_window, cx| { weak_editor .update(cx, |this, cx| { - this.add_edit_breakpoint_block( - anchor, - breakpoint.as_ref(), - BreakpointPromptEditAction::HitCondition, - window, - cx, - ); + this.toggle_bookmark_at_anchor(anchor, cx); }) .log_err(); }) @@ -9238,20 +9406,6 @@ impl Editor { cx: &mut Context, ) -> IconButton { let is_rejected = state.is_some_and(|s| !s.verified); - // Is it a breakpoint that shows up when hovering over gutter? - let (is_phantom, collides_with_existing) = self.gutter_breakpoint_indicator.0.map_or( - (false, false), - |PhantomBreakpointIndicator { - is_active, - display_row, - collides_with_existing_breakpoint, - }| { - ( - is_active && display_row == row, - collides_with_existing_breakpoint, - ) - }, - ); let (color, icon) = { let icon = match (&breakpoint.message.is_some(), breakpoint.is_disabled()) { @@ -9261,19 +9415,7 @@ impl Editor { (true, true) => ui::IconName::DebugDisabledLogBreakpoint, }; - let theme_colors = cx.theme().colors(); - - let color = if is_phantom { - if collides_with_existing { - Color::Custom( - theme_colors - .debugger_accent - .blend(theme_colors.text.opacity(0.6)), - ) - } else { - Color::Hint - } - } else if is_rejected { + let color = if is_rejected { Color::Disabled } else { Color::Debugger @@ -9288,20 +9430,14 @@ impl Editor { modifiers: Modifiers::secondary_key(), ..Default::default() }; - let primary_action_text = if breakpoint.is_disabled() { - "Enable breakpoint" - } else if is_phantom && !collides_with_existing { - "Set breakpoint" - } else { - "Unset breakpoint" - }; + let primary_action_text = "Unset breakpoint"; let focus_handle = self.focus_handle.clone(); let meta = if is_rejected { SharedString::from("No executable code is associated with this line.") - } else if collides_with_existing && !breakpoint.is_disabled() { + } else if !breakpoint.is_disabled() { SharedString::from(format!( - "{alt_as_text}-click to disable,\nright-click for more options." + "{alt_as_text}click to disable,\nright-click for more options." )) } else { SharedString::from("Right-click for more options.") @@ -9323,7 +9459,6 @@ impl Editor { }; window.focus(&editor.focus_handle(cx), cx); - editor.update_breakpoint_collision_on_toggle(row, &edit_action); editor.edit_breakpoint_at_anchor( position, breakpoint.as_ref().clone(), @@ -9333,13 +9468,7 @@ impl Editor { } })) .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { - editor.set_breakpoint_context_menu( - row, - Some(position), - event.position(), - window, - cx, - ); + editor.set_gutter_context_menu(row, Some(position), event.position(), window, cx); })) .tooltip(move |_window, cx| { Tooltip::with_meta_in( @@ -9352,6 +9481,117 @@ impl Editor { }) } + fn render_gutter_hover_button( + &self, + position: Anchor, + row: DisplayRow, + window: &mut Window, + cx: &mut Context, + ) -> IconButton { + #[derive(Clone, Copy)] + enum Intent { + SetBookmark, + SetBreakpoint, + } + + impl Intent { + fn as_str(&self) -> &'static str { + match self { + Intent::SetBookmark => "Set bookmark", + Intent::SetBreakpoint => "Set breakpoint", + } + } + + fn icon(&self) -> ui::IconName { + match self { + Intent::SetBookmark => ui::IconName::Bookmark, + Intent::SetBreakpoint => ui::IconName::DebugBreakpoint, + } + } + + fn color(&self) -> Color { + match self { + Intent::SetBookmark => Color::Info, + Intent::SetBreakpoint => Color::Hint, + } + } + + fn secondary_and_options(&self) -> String { + let alt_as_text = gpui::Keystroke { + modifiers: Modifiers::secondary_key(), + ..Default::default() + }; + match self { + Intent::SetBookmark => format!( + "{alt_as_text}click to add a breakpoint,\nright-click for more options." + ), + Intent::SetBreakpoint => format!( + "{alt_as_text}click to add a bookmark,\nright-click for more options." + ), + } + } + } + + let gutter_settings = EditorSettings::get_global(cx).gutter; + let show_bookmarks = self.show_bookmarks.unwrap_or(gutter_settings.bookmarks); + let show_breakpoints = self.show_breakpoints.unwrap_or(gutter_settings.breakpoints); + + let [primary, secondary] = match [show_breakpoints, show_bookmarks] { + [true, true] => [Intent::SetBreakpoint, Intent::SetBookmark], + [true, false] => [Intent::SetBreakpoint; 2], + [false, true] => [Intent::SetBookmark; 2], + [false, false] => { + log::error!("Trying to place gutter_hover without anything enabled!!"); + [Intent::SetBookmark; 2] + } + }; + + let intent = if window.modifiers().secondary() { + secondary + } else { + primary + }; + + let focus_handle = self.focus_handle.clone(); + IconButton::new(("add_breakpoint_button", row.0 as usize), intent.icon()) + .icon_size(IconSize::XSmall) + .size(ui::ButtonSize::None) + .icon_color(intent.color()) + .style(ButtonStyle::Transparent) + .on_click(cx.listener({ + move |editor, _: &ClickEvent, window, cx| { + window.focus(&editor.focus_handle(cx), cx); + let intent = if window.modifiers().secondary() { + secondary + } else { + primary + }; + + match intent { + Intent::SetBookmark => editor.toggle_bookmark_at_row(row, cx), + Intent::SetBreakpoint => editor.edit_breakpoint_at_anchor( + position, + Breakpoint::new_standard(), + BreakpointEditAction::Toggle, + cx, + ), + } + } + })) + .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { + editor.set_gutter_context_menu(row, Some(position), event.position(), window, cx); + })) + .tooltip(move |_window, cx| { + Tooltip::with_meta_in( + intent.as_str(), + Some(&ToggleBreakpoint), + intent.secondary_and_options(), + &focus_handle, + cx, + ) + }) + } + fn build_tasks_context( project: &Entity, buffer: &Entity, @@ -11924,7 +12164,7 @@ impl Editor { } } - fn set_breakpoint_context_menu( + fn set_gutter_context_menu( &mut self, display_row: DisplayRow, position: Option, @@ -11938,7 +12178,7 @@ impl Editor { .snapshot(cx) .anchor_before(Point::new(display_row.0, 0u32)); - let context_menu = self.breakpoint_context_menu(position.unwrap_or(source), window, cx); + let context_menu = self.gutter_context_menu(position.unwrap_or(source), window, cx); self.mouse_context_menu = MouseContextMenu::pinned_to_editor( self, @@ -12055,6 +12295,65 @@ impl Editor { }) } + pub(crate) fn bookmark_at_row( + &self, + row: u32, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let snapshot = self.snapshot(window, cx); + let bookmark_position = snapshot.buffer_snapshot().anchor_before(Point::new(row, 0)); + + self.bookmark_at_anchor(bookmark_position, &snapshot, cx) + } + + pub(crate) fn bookmark_at_anchor( + &self, + bookmark_position: Anchor, + snapshot: &EditorSnapshot, + cx: &mut Context, + ) -> Option { + let (bookmark_position, _) = snapshot + .buffer_snapshot() + .anchor_to_buffer_anchor(bookmark_position)?; + let buffer = self.buffer.read(cx).buffer(bookmark_position.buffer_id)?; + + let buffer_snapshot = buffer.read(cx).snapshot(); + + let row = buffer_snapshot + .summary_for_anchor::(&bookmark_position) + .row; + + let line_len = buffer_snapshot.line_len(row); + let anchor_end = buffer_snapshot.anchor_after(Point::new(row, line_len)); + + self.bookmark_store + .as_ref()? + .update(cx, |bookmark_store, cx| { + bookmark_store + .bookmarks_for_buffer( + buffer, + bookmark_position..anchor_end, + &buffer_snapshot, + cx, + ) + .first() + .and_then(|bookmark| { + let bookmark_row = buffer_snapshot + .summary_for_anchor::(&bookmark.anchor()) + .row; + + if bookmark_row == row { + snapshot + .buffer_snapshot() + .anchor_in_excerpt(bookmark.anchor()) + } else { + None + } + }) + }) + } + pub fn edit_log_breakpoint( &mut self, _: &EditLogBreakpoint, @@ -12266,19 +12565,7 @@ impl Editor { return; } - let snapshot = self.snapshot(window, cx); for (anchor, breakpoint) in self.breakpoints_at_cursors(window, cx) { - if self.gutter_breakpoint_indicator.0.is_some() { - let display_row = anchor - .to_point(snapshot.buffer_snapshot()) - .to_display_point(&snapshot.display_snapshot) - .row(); - self.update_breakpoint_collision_on_toggle( - display_row, - &BreakpointEditAction::Toggle, - ); - } - if let Some(breakpoint) = breakpoint { self.edit_breakpoint_at_anchor( anchor, @@ -12297,21 +12584,6 @@ impl Editor { } } - fn update_breakpoint_collision_on_toggle( - &mut self, - display_row: DisplayRow, - edit_action: &BreakpointEditAction, - ) { - if let Some(ref mut breakpoint_indicator) = self.gutter_breakpoint_indicator.0 { - if breakpoint_indicator.display_row == display_row - && matches!(edit_action, BreakpointEditAction::Toggle) - { - breakpoint_indicator.collides_with_existing_breakpoint = - !breakpoint_indicator.collides_with_existing_breakpoint; - } - } - } - pub fn edit_breakpoint_at_anchor( &mut self, breakpoint_position: Anchor, @@ -28188,6 +28460,7 @@ impl EditorSnapshot { let show_runnables = self.show_runnables.unwrap_or(gutter_settings.runnables); let show_breakpoints = self.show_breakpoints.unwrap_or(gutter_settings.breakpoints); + let show_bookmarks = self.show_bookmarks.unwrap_or(gutter_settings.bookmarks); let git_blame_entries_width = self.git_blame_gutter_max_author_length @@ -28208,18 +28481,20 @@ impl EditorSnapshot { let is_singleton = self.buffer_snapshot().is_singleton(); - let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO); - left_padding += if !is_singleton { - ch_width * 4.0 - } else if show_runnables || show_breakpoints { - ch_width * 3.0 - } else if show_git_gutter && show_line_numbers { - ch_width * 2.0 - } else if show_git_gutter || show_line_numbers { - ch_width - } else { - px(0.) - }; + let left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO) + + if !is_singleton { + ch_width * 4.0 + // runnables, breakpoints and bookmarks are shown in the same place + // if all three are there only the runnable is shown + } else if show_runnables || show_breakpoints || show_bookmarks { + ch_width * 3.0 + } else if show_git_gutter && show_line_numbers { + ch_width * 2.0 + } else if show_git_gutter || show_line_numbers { + ch_width + } else { + px(0.) + }; let shows_folds = is_singleton && gutter_settings.folds; diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index 548053da7d794de83d99afdfddb098e4cfb2b18e..e70dd137ba382049b59691d4252e76ae75cb66d0 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -133,6 +133,7 @@ pub struct Gutter { pub line_numbers: bool, pub runnables: bool, pub breakpoints: bool, + pub bookmarks: bool, pub folds: bool, } @@ -248,6 +249,7 @@ impl Settings for EditorSettings { min_line_number_digits: gutter.min_line_number_digits.unwrap(), line_numbers: gutter.line_numbers.unwrap(), runnables: gutter.runnables.unwrap(), + bookmarks: gutter.bookmarks.unwrap(), breakpoints: gutter.breakpoints.unwrap(), folds: gutter.folds.unwrap(), }, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 38f4259b4d4c87f3243e49dec8f35991bd82f246..20eb17a2da9ba848030f1fbf26d22bf72498d68b 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -41,6 +41,7 @@ use parking_lot::Mutex; use pretty_assertions::{assert_eq, assert_ne}; use project::{ FakeFs, Project, + bookmark_store::SerializedBookmark, debugger::breakpoint_store::{BreakpointState, SourceBreakpoint}, project_settings::LspSettings, trusted_worktrees::{PathTrust, TrustedWorktrees}, @@ -27570,109 +27571,556 @@ async fn test_breakpoint_enabling_and_disabling(cx: &mut TestAppContext) { ); } -#[gpui::test] -async fn test_breakpoint_phantom_indicator_collision_on_toggle(cx: &mut TestAppContext) { - init_test(cx, |_| {}); +struct BookmarkTestContext { + project: Entity, + editor: Entity, + cx: VisualTestContext, +} - let sample_text = "First line\nSecond line\nThird line\nFourth line".to_string(); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/a"), - json!({ - "main.rs": sample_text, - }), - ) - .await; - let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = window - .read_with(cx, |mw, _| mw.workspace().clone()) - .unwrap(); - let cx = &mut VisualTestContext::from_window(*window, cx); - let worktree_id = workspace.update_in(cx, |workspace, _window, cx| { - workspace.project().update(cx, |project, cx| { - project.worktrees(cx).next().unwrap().read(cx).id() +impl BookmarkTestContext { + async fn new(sample_text: &str, cx: &mut TestAppContext) -> BookmarkTestContext { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": sample_text, + }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let mut visual_cx = VisualTestContext::from_window(*window, cx); + let worktree_id = workspace.update_in(&mut visual_cx, |workspace, _window, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + let buffer = project + .update(&mut visual_cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + + let (editor, editor_cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::full(), + MultiBuffer::build_from_buffer(buffer, cx), + Some(project.clone()), + window, + cx, + ) + }); + let cx = editor_cx.clone(); + + BookmarkTestContext { + project, + editor, + cx, + } + } + + fn abs_path(&self) -> Arc { + let project_path = self + .editor + .read_with(&self.cx, |editor, cx| editor.project_path(cx).unwrap()); + self.project.read_with(&self.cx, |project, cx| { + project + .absolute_path(&project_path, cx) + .map(Arc::from) + .unwrap() }) - }); + } - let buffer = project - .update(cx, |project, cx| { - project.open_buffer((worktree_id, rel_path("main.rs")), cx) + fn all_bookmarks(&self) -> BTreeMap, Vec> { + self.project.read_with(&self.cx, |project, cx| { + project + .bookmark_store() + .read(cx) + .all_serialized_bookmarks(cx) }) - .await - .unwrap(); + } - let (editor, cx) = cx.add_window_view(|window, cx| { - Editor::new( - EditorMode::full(), - MultiBuffer::build_from_buffer(buffer, cx), - Some(project.clone()), - window, - cx, - ) - }); + fn assert_bookmark_rows(&self, expected_rows: Vec) { + let abs_path = self.abs_path(); + let bookmarks = self.all_bookmarks(); + if expected_rows.is_empty() { + assert!( + !bookmarks.contains_key(&abs_path), + "Expected no bookmarks for {}", + abs_path.display() + ); + } else { + let mut rows: Vec = bookmarks + .get(&abs_path) + .unwrap() + .iter() + .map(|b| b.0) + .collect(); + rows.sort(); + assert_eq!(expected_rows, rows); + } + } - // Simulate hovering over row 0 with no existing breakpoint. - editor.update(cx, |editor, _cx| { - editor.gutter_breakpoint_indicator.0 = Some(PhantomBreakpointIndicator { - display_row: DisplayRow(0), - is_active: true, - collides_with_existing_breakpoint: false, + fn cursor_row(&mut self) -> u32 { + self.editor.update(&mut self.cx, |editor, cx| { + let snapshot = editor.display_snapshot(cx); + editor.selections.newest::(&snapshot).head().row + }) + } + + fn cursor_point(&mut self) -> Point { + self.editor.update(&mut self.cx, |editor, cx| { + let snapshot = editor.display_snapshot(cx); + editor.selections.newest::(&snapshot).head() + }) + } + + fn move_to_row(&mut self, row: u32) { + self.editor + .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + for _ in 0..row { + editor.move_down(&MoveDown, window, cx); + } + }); + } + + fn toggle_bookmark(&mut self) { + self.editor + .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { + editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); + }); + } + + fn toggle_bookmarks_at_rows(&mut self, rows: &[u32]) { + for &row in rows { + self.move_to_row(row); + self.toggle_bookmark(); + } + } + + fn go_to_next_bookmark(&mut self) { + self.editor + .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { + editor.go_to_next_bookmark(&actions::GoToNextBookmark, window, cx); + }); + } + + fn go_to_previous_bookmark(&mut self) { + self.editor + .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { + editor.go_to_previous_bookmark(&actions::GoToPreviousBookmark, window, cx); + }); + } +} + +#[gpui::test] +async fn test_bookmark_toggling(cx: &mut TestAppContext) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); + editor.move_to_end(&MoveToEnd, window, cx); + editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); }); - }); - // Toggle breakpoint on the same row (row 0) — collision should flip to true. - editor.update_in(cx, |editor, window, cx| { - editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); - }); - editor.update(cx, |editor, _cx| { - let indicator = editor.gutter_breakpoint_indicator.0.unwrap(); - assert!( - indicator.collides_with_existing_breakpoint, - "Adding a breakpoint on the hovered row should set collision to true" - ); - }); + assert_eq!(1, ctx.all_bookmarks().len()); + ctx.assert_bookmark_rows(vec![0, 3]); - // Toggle again on the same row — breakpoint is removed, collision should flip back to false. - editor.update_in(cx, |editor, window, cx| { - editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); - }); - editor.update(cx, |editor, _cx| { - let indicator = editor.gutter_breakpoint_indicator.0.unwrap(); - assert!( - !indicator.collides_with_existing_breakpoint, - "Removing a breakpoint on the hovered row should set collision to false" - ); - }); + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); + }); - // Now move cursor to row 2 while phantom indicator stays on row 0. - editor.update_in(cx, |editor, window, cx| { - editor.move_down(&MoveDown, window, cx); - editor.move_down(&MoveDown, window, cx); - }); + assert_eq!(1, ctx.all_bookmarks().len()); + ctx.assert_bookmark_rows(vec![3]); - // Ensure phantom indicator is still on row 0, not colliding. - editor.update(cx, |editor, _cx| { - editor.gutter_breakpoint_indicator.0 = Some(PhantomBreakpointIndicator { - display_row: DisplayRow(0), - is_active: true, - collides_with_existing_breakpoint: false, + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_end(&MoveToEnd, window, cx); + editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); }); - }); - // Toggle breakpoint on row 2 (cursor row) — phantom on row 0 should NOT be affected. - editor.update_in(cx, |editor, window, cx| { - editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); - }); + assert_eq!(0, ctx.all_bookmarks().len()); + ctx.assert_bookmark_rows(vec![]); +} + +#[gpui::test] +async fn test_bookmark_toggling_with_multiple_selections(cx: &mut TestAppContext) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + editor.add_selection_below(&Default::default(), window, cx); + editor.add_selection_below(&Default::default(), window, cx); + editor.add_selection_below(&Default::default(), window, cx); + }); + + ctx.toggle_bookmark(); + + assert_eq!(1, ctx.all_bookmarks().len()); + ctx.assert_bookmark_rows(vec![0, 1, 2, 3]); + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + editor.add_selection_below(&Default::default(), window, cx); + editor.add_selection_below(&Default::default(), window, cx); + editor.add_selection_below(&Default::default(), window, cx); + editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); + }); + + assert_eq!(0, ctx.all_bookmarks().len()); + ctx.assert_bookmark_rows(vec![]); +} + +#[gpui::test] +async fn test_bookmark_toggle_deduplicates_by_row(cx: &mut TestAppContext) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); + }); + + ctx.assert_bookmark_rows(vec![0]); + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_end_of_line( + &MoveToEndOfLine { + stop_at_soft_wraps: true, + }, + window, + cx, + ); + editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); + }); + + ctx.assert_bookmark_rows(vec![]); +} + +#[gpui::test] +async fn test_bookmark_survives_edits(cx: &mut TestAppContext) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + ctx.move_to_row(2); + ctx.toggle_bookmark(); + ctx.assert_bookmark_rows(vec![2]); + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + editor.newline(&Newline, window, cx); + }); + + ctx.assert_bookmark_rows(vec![3]); + + ctx.move_to_row(3); + ctx.toggle_bookmark(); + ctx.assert_bookmark_rows(vec![]); +} + +#[gpui::test] +async fn test_active_bookmarks(cx: &mut TestAppContext) { + let mut ctx = BookmarkTestContext::new( + "Line 0\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9", + cx, + ) + .await; + + ctx.toggle_bookmarks_at_rows(&[1, 3, 5, 8]); + + let active = ctx + .editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.active_bookmarks(DisplayRow(0)..DisplayRow(10), window, cx) + }); + assert!(active.contains(&DisplayRow(1))); + assert!(active.contains(&DisplayRow(3))); + assert!(active.contains(&DisplayRow(5))); + assert!(active.contains(&DisplayRow(8))); + assert!(!active.contains(&DisplayRow(0))); + assert!(!active.contains(&DisplayRow(2))); + assert!(!active.contains(&DisplayRow(9))); + + let active = ctx + .editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.active_bookmarks(DisplayRow(2)..DisplayRow(6), window, cx) + }); + assert!(active.contains(&DisplayRow(3))); + assert!(active.contains(&DisplayRow(5))); + assert!(!active.contains(&DisplayRow(1))); + assert!(!active.contains(&DisplayRow(8))); +} + +#[gpui::test] +async fn test_bookmark_not_available_in_single_line_editor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let (editor, _cx) = cx.add_window_view(|window, cx| Editor::single_line(window, cx)); + editor.update(cx, |editor, _cx| { - let indicator = editor.gutter_breakpoint_indicator.0.unwrap(); assert!( - !indicator.collides_with_existing_breakpoint, - "Toggling a breakpoint on a different row should not affect the phantom indicator" + editor.bookmark_store.is_none(), + "Single-line editors should not have a bookmark store" ); }); } +#[gpui::test] +async fn test_bookmark_navigation_lands_at_column_zero(cx: &mut TestAppContext) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + editor.move_down(&MoveDown, window, cx); + editor.move_to_end_of_line( + &MoveToEndOfLine { + stop_at_soft_wraps: true, + }, + window, + cx, + ); + }); + + let column_before_toggle = ctx.cursor_point().column; + assert_eq!( + column_before_toggle, 11, + "Cursor should be at the 11th column before toggling bookmark, got column {column_before_toggle}" + ); + + ctx.toggle_bookmark(); + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + }); + + ctx.go_to_next_bookmark(); + + let cursor = ctx.cursor_point(); + assert_eq!(cursor.row, 1, "Should navigate to the bookmarked row"); + assert_eq!( + cursor.column, 0, + "Bookmark navigation should always land at column 0" + ); +} + +#[gpui::test] +async fn test_bookmark_set_from_nonzero_column_toggles_off_from_column_zero( + cx: &mut TestAppContext, +) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + editor.move_down(&MoveDown, window, cx); + editor.move_to_end_of_line( + &MoveToEndOfLine { + stop_at_soft_wraps: true, + }, + window, + cx, + ); + editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); + }); + + ctx.assert_bookmark_rows(vec![1]); + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_beginning_of_line( + &MoveToBeginningOfLine { + stop_at_soft_wraps: true, + stop_at_indent: false, + }, + window, + cx, + ); + editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); + }); + + ctx.assert_bookmark_rows(vec![]); +} + +#[gpui::test] +async fn test_go_to_next_bookmark(cx: &mut TestAppContext) { + let mut ctx = BookmarkTestContext::new( + "Line 0\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9", + cx, + ) + .await; + + ctx.toggle_bookmarks_at_rows(&[2, 5, 8]); + + ctx.move_to_row(0); + + ctx.go_to_next_bookmark(); + assert_eq!( + ctx.cursor_row(), + 2, + "First next-bookmark should go to row 2" + ); + + ctx.go_to_next_bookmark(); + assert_eq!( + ctx.cursor_row(), + 5, + "Second next-bookmark should go to row 5" + ); + + ctx.go_to_next_bookmark(); + assert_eq!( + ctx.cursor_row(), + 8, + "Third next-bookmark should go to row 8" + ); + + ctx.go_to_next_bookmark(); + assert_eq!( + ctx.cursor_row(), + 2, + "Next-bookmark should wrap around to row 2" + ); +} + +#[gpui::test] +async fn test_go_to_previous_bookmark(cx: &mut TestAppContext) { + let mut ctx = BookmarkTestContext::new( + "Line 0\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9", + cx, + ) + .await; + + ctx.toggle_bookmarks_at_rows(&[2, 5, 8]); + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.move_to_end(&MoveToEnd, window, cx); + }); + + ctx.go_to_previous_bookmark(); + assert_eq!( + ctx.cursor_row(), + 8, + "First prev-bookmark should go to row 8" + ); + + ctx.go_to_previous_bookmark(); + assert_eq!( + ctx.cursor_row(), + 5, + "Second prev-bookmark should go to row 5" + ); + + ctx.go_to_previous_bookmark(); + assert_eq!( + ctx.cursor_row(), + 2, + "Third prev-bookmark should go to row 2" + ); + + ctx.go_to_previous_bookmark(); + assert_eq!( + ctx.cursor_row(), + 8, + "Prev-bookmark should wrap around to row 8" + ); +} + +#[gpui::test] +async fn test_go_to_bookmark_when_cursor_on_bookmarked_line(cx: &mut TestAppContext) { + let mut ctx = BookmarkTestContext::new( + "Line 0\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9", + cx, + ) + .await; + + ctx.toggle_bookmarks_at_rows(&[3, 7]); + + ctx.move_to_row(3); + + ctx.go_to_next_bookmark(); + assert_eq!( + ctx.cursor_row(), + 7, + "Next from bookmarked row 3 should go to row 7" + ); + + ctx.go_to_previous_bookmark(); + assert_eq!( + ctx.cursor_row(), + 3, + "Previous from bookmarked row 7 should go to row 3" + ); + + ctx.go_to_next_bookmark(); + assert_eq!(ctx.cursor_row(), 7, "Next from row 3 should go to row 7"); + + ctx.go_to_next_bookmark(); + assert_eq!(ctx.cursor_row(), 3, "Next from row 7 should wrap to row 3"); +} + +#[gpui::test] +async fn test_go_to_bookmark_with_out_of_order_bookmarks(cx: &mut TestAppContext) { + let mut ctx = BookmarkTestContext::new( + "Line 0\nLine 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9", + cx, + ) + .await; + + ctx.toggle_bookmarks_at_rows(&[8, 1, 5]); + + ctx.move_to_row(0); + + ctx.go_to_next_bookmark(); + assert_eq!(ctx.cursor_row(), 1, "First next should go to row 1"); + + ctx.go_to_next_bookmark(); + assert_eq!(ctx.cursor_row(), 5, "Second next should go to row 5"); + + ctx.go_to_next_bookmark(); + assert_eq!(ctx.cursor_row(), 8, "Third next should go to row 8"); + + ctx.go_to_next_bookmark(); + assert_eq!(ctx.cursor_row(), 1, "Fourth next should wrap to row 1"); + + ctx.go_to_previous_bookmark(); + assert_eq!( + ctx.cursor_row(), + 8, + "Prev from row 1 should wrap around to row 8" + ); + + ctx.go_to_previous_bookmark(); + assert_eq!(ctx.cursor_row(), 5, "Prev from row 8 should go to row 5"); + + ctx.go_to_previous_bookmark(); + assert_eq!(ctx.cursor_row(), 1, "Prev from row 5 should go to row 1"); +} + #[gpui::test] async fn test_rename_with_duplicate_edits(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 392a67bb0d495c3d49063bd4aa6ec87ea6bfd610..5d246b7f6ba241d437cbca25b913f300359a2786 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -4,12 +4,12 @@ use crate::{ ConflictsOuter, ConflictsTheirs, ConflictsTheirsMarker, ContextMenuPlacement, CursorShape, CustomBlockId, DisplayDiffHunk, DisplayPoint, DisplayRow, EditDisplayMode, EditPrediction, Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle, FILE_HEADER_HEIGHT, - FocusedBlock, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor, - InlayHintRefreshReason, JumpData, LineDown, LineHighlight, LineUp, MAX_LINE_LEN, + FocusedBlock, GutterDimensions, GutterHoverButton, HalfPageDown, HalfPageUp, HandleInput, + HoveredCursor, InlayHintRefreshReason, JumpData, LineDown, LineHighlight, LineUp, MAX_LINE_LEN, MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, PageDown, PageUp, - PhantomBreakpointIndicator, PhantomDiffReviewIndicator, Point, RowExt, RowRangeExt, - SelectPhase, Selection, SelectionDragState, SelectionEffects, SizingBehavior, SoftWrap, - StickyHeaderExcerpt, ToPoint, ToggleFold, ToggleFoldAll, + PhantomDiffReviewIndicator, Point, RowExt, RowRangeExt, SelectPhase, Selection, + SelectionDragState, SelectionEffects, SizingBehavior, SoftWrap, StickyHeaderExcerpt, ToPoint, + ToggleFold, ToggleFoldAll, code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP}, column_pixels, display_map::{ @@ -34,7 +34,7 @@ use crate::{ }, }; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; -use collections::{BTreeMap, HashMap}; +use collections::{BTreeMap, HashMap, HashSet}; use feature_flags::{DiffReviewFeatureFlag, FeatureFlagAppExt as _}; use file_icons::FileIcons; use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus}; @@ -62,7 +62,7 @@ use multi_buffer::{ }; use project::{ - DisableAiSettings, Entry, ProjectPath, + DisableAiSettings, Entry, debugger::breakpoint_store::{Breakpoint, BreakpointSessionState}, project_settings::ProjectSettings, }; @@ -652,6 +652,9 @@ impl EditorElement { register_action(editor, window, Editor::insert_uuid_v4); register_action(editor, window, Editor::insert_uuid_v7); register_action(editor, window, Editor::open_selections_in_multibuffer); + register_action(editor, window, Editor::toggle_bookmark); + register_action(editor, window, Editor::go_to_next_bookmark); + register_action(editor, window, Editor::go_to_previous_bookmark); register_action(editor, window, Editor::toggle_breakpoint); register_action(editor, window, Editor::edit_log_breakpoint); register_action(editor, window, Editor::enable_breakpoint); @@ -890,9 +893,11 @@ impl EditorElement { let gutter_right_padding = editor.gutter_dimensions.right_padding; let hitbox = &position_map.gutter_hitbox; - if event.position.x <= hitbox.bounds.right() - gutter_right_padding { + if event.position.x <= hitbox.bounds.right() - gutter_right_padding + && editor.collaboration_hub.is_none() + { let point_for_position = position_map.point_for_position(event.position); - editor.set_breakpoint_context_menu( + editor.set_gutter_context_menu( point_for_position.previous_valid.row(), None, event.position, @@ -1396,49 +1401,26 @@ impl EditorElement { .snapshot .display_point_to_anchor(valid_point, Bias::Left); - if let Some((buffer_anchor, buffer_snapshot)) = position_map + if position_map .snapshot .buffer_snapshot() .anchor_to_buffer_anchor(buffer_anchor) - && let Some(file) = buffer_snapshot.file() + .is_some() { - let as_point = text::ToPoint::to_point(&buffer_anchor, buffer_snapshot); - let is_visible = editor - .gutter_breakpoint_indicator + .gutter_hover_button .0 .is_some_and(|indicator| indicator.is_active); - let has_existing_breakpoint = - editor.breakpoint_store.as_ref().is_some_and(|store| { - let Some(project) = &editor.project else { - return false; - }; - let Some(abs_path) = project.read(cx).absolute_path( - &ProjectPath { - path: file.path().clone(), - worktree_id: file.worktree_id(cx), - }, - cx, - ) else { - return false; - }; - store - .read(cx) - .breakpoint_at_row(&abs_path, as_point.row, cx) - .is_some() - }); - if !is_visible { - editor.gutter_breakpoint_indicator.1.get_or_insert_with(|| { + editor.gutter_hover_button.1.get_or_insert_with(|| { cx.spawn(async move |this, cx| { cx.background_executor() .timer(Duration::from_millis(200)) .await; this.update(cx, |this, cx| { - if let Some(indicator) = this.gutter_breakpoint_indicator.0.as_mut() - { + if let Some(indicator) = this.gutter_hover_button.0.as_mut() { indicator.is_active = true; cx.notify(); } @@ -1448,22 +1430,21 @@ impl EditorElement { }); } - Some(PhantomBreakpointIndicator { + Some(GutterHoverButton { display_row: valid_point.row(), is_active: is_visible, - collides_with_existing_breakpoint: has_existing_breakpoint, }) } else { - editor.gutter_breakpoint_indicator.1 = None; + editor.gutter_hover_button.1 = None; None } } else { - editor.gutter_breakpoint_indicator.1 = None; + editor.gutter_hover_button.1 = None; None }; - if &breakpoint_indicator != &editor.gutter_breakpoint_indicator.0 { - editor.gutter_breakpoint_indicator.0 = breakpoint_indicator; + if &breakpoint_indicator != &editor.gutter_hover_button.0 { + editor.gutter_hover_button.0 = breakpoint_indicator; cx.notify(); } @@ -3133,16 +3114,10 @@ impl EditorElement { (offset_y, length, row_range) } - fn layout_breakpoints( + fn layout_bookmarks( &self, - line_height: Pixels, - range: Range, - scroll_position: gpui::Point, - gutter_dimensions: &GutterDimensions, - gutter_hitbox: &Hitbox, - snapshot: &EditorSnapshot, - breakpoints: HashMap)>, - row_infos: &[RowInfo], + gutter: &Gutter<'_>, + bookmarks: &HashSet, window: &mut Window, cx: &mut App, ) -> Vec { @@ -3151,44 +3126,71 @@ impl EditorElement { } self.editor.update(cx, |editor, cx| { - breakpoints - .into_iter() - .filter_map(|(display_row, (text_anchor, bp, state))| { - if row_infos - .get((display_row.0.saturating_sub(range.start.0)) as usize) - .is_some_and(|row_info| { - row_info.expand_info.is_some() - || row_info - .diff_status - .is_some_and(|status| status.is_deleted()) - }) - { - return None; - } + bookmarks + .iter() + .filter_map(|row| { + gutter.layout_item_skipping_folds( + *row, + |cx, _| editor.render_bookmark(*row, cx).into_any_element(), + window, + cx, + ) + }) + .collect_vec() + }) + } - if range.start > display_row || range.end < display_row { - return None; - } + fn layout_gutter_hover_button( + &self, + gutter: &Gutter, + position: Anchor, + row: DisplayRow, + window: &mut Window, + cx: &mut App, + ) -> Option { + if self.split_side == Some(SplitSide::Left) { + return None; + } - let row = - MultiBufferRow(DisplayPoint::new(display_row, 0).to_point(snapshot).row); - if snapshot.is_line_folded(row) { - return None; - } + self.editor.update(cx, |editor, cx| { + gutter.layout_item_skipping_folds( + row, + |cx, window| { + editor + .render_gutter_hover_button(position, row, window, cx) + .into_any_element() + }, + window, + cx, + ) + }) + } - let button = editor.render_breakpoint(text_anchor, display_row, &bp, state, cx); + fn layout_breakpoints( + &self, + gutter: &Gutter, + breakpoints: &HashMap)>, + window: &mut Window, + cx: &mut App, + ) -> Vec { + if self.split_side == Some(SplitSide::Left) { + return Vec::new(); + } - let button = prepaint_gutter_button( - button.into_any_element(), - display_row, - line_height, - gutter_dimensions, - scroll_position, - gutter_hitbox, + self.editor.update(cx, |editor, cx| { + breakpoints + .iter() + .filter_map(|(row, (text_anchor, bp, state))| { + gutter.layout_item_skipping_folds( + *row, + |cx, _| { + editor + .render_breakpoint(*text_anchor, *row, &bp, *state, cx) + .into_any_element() + }, window, cx, - ); - Some(button) + ) }) .collect_vec() }) @@ -3240,17 +3242,11 @@ impl EditorElement { Some((display_row, buffer_row)) } - #[allow(clippy::too_many_arguments)] fn layout_run_indicators( &self, - line_height: Pixels, - range: Range, - row_infos: &[RowInfo], - scroll_position: gpui::Point, - gutter_dimensions: &GutterDimensions, - gutter_hitbox: &Hitbox, - snapshot: &EditorSnapshot, - breakpoints: &mut HashMap)>, + gutter: &Gutter, + run_indicators: &HashSet, + breakpoints: &HashMap)>, window: &mut Window, cx: &mut App, ) -> Vec { @@ -3269,7 +3265,7 @@ impl EditorElement { { actions .tasks() - .map(|tasks| tasks.position.to_display_point(snapshot).row()) + .map(|tasks| tasks.position.to_display_point(gutter.snapshot).row()) .or_else(|| match deployed_from { Some(CodeActionSource::Indicator(row)) => Some(*row), _ => None, @@ -3278,77 +3274,25 @@ impl EditorElement { None }; - let offset_range_start = - snapshot.display_point_to_point(DisplayPoint::new(range.start, 0), Bias::Left); - - let offset_range_end = - snapshot.display_point_to_point(DisplayPoint::new(range.end, 0), Bias::Right); - - editor - .runnables - .all_runnables() - .filter_map(|tasks| { - let multibuffer_point = tasks.offset.to_point(&snapshot.buffer_snapshot()); - if multibuffer_point < offset_range_start - || multibuffer_point > offset_range_end - { - return None; - } - let multibuffer_row = MultiBufferRow(multibuffer_point.row); - let buffer_folded = snapshot - .buffer_snapshot() - .buffer_line_for_row(multibuffer_row) - .map(|(buffer_snapshot, _)| buffer_snapshot.remote_id()) - .map(|buffer_id| editor.is_buffer_folded(buffer_id, cx)) - .unwrap_or(false); - if buffer_folded { - return None; - } - - if snapshot.is_line_folded(multibuffer_row) { - // Skip folded indicators, unless it's the starting line of a fold. - if multibuffer_row - .0 - .checked_sub(1) - .is_some_and(|previous_row| { - snapshot.is_line_folded(MultiBufferRow(previous_row)) - }) - { - return None; - } - } - - let display_row = multibuffer_point.to_display_point(snapshot).row(); - if !range.contains(&display_row) { - return None; - } - if row_infos - .get((display_row - range.start).0 as usize) - .is_some_and(|row_info| row_info.expand_info.is_some()) - { - return None; - } - - let removed_breakpoint = breakpoints.remove(&display_row); - let button = editor.render_run_indicator( - &self.style, - Some(display_row) == active_task_indicator_row, - display_row, - removed_breakpoint, - cx, - ); - - let button = prepaint_gutter_button( - button.into_any_element(), - display_row, - line_height, - gutter_dimensions, - scroll_position, - gutter_hitbox, + run_indicators + .iter() + .filter_map(|display_row| { + gutter.layout_item( + *display_row, + |cx, _| { + editor + .render_run_indicator( + &self.style, + Some(*display_row) == active_task_indicator_row, + breakpoints.get(&display_row).map(|(anchor, _, _)| *anchor), + *display_row, + cx, + ) + .into_any_element() + }, window, cx, - ); - Some(button) + ) }) .collect_vec() }) @@ -3448,19 +3392,14 @@ impl EditorElement { fn layout_line_numbers( &self, - gutter_hitbox: Option<&Hitbox>, - gutter_dimensions: GutterDimensions, - line_height: Pixels, - scroll_position: gpui::Point, - rows: Range, - buffer_rows: &[RowInfo], + gutter: &Gutter<'_>, active_rows: &BTreeMap, current_selection_head: Option, - snapshot: &EditorSnapshot, window: &mut Window, cx: &mut App, ) -> Arc> { - let include_line_numbers = snapshot + let include_line_numbers = gutter + .snapshot .show_line_numbers .unwrap_or_else(|| EditorSettings::get_global(cx).gutter.line_numbers); if !include_line_numbers { @@ -3473,8 +3412,8 @@ impl EditorElement { let relative_rows = if relative_line_numbers_enabled && let Some(current_selection_head) = current_selection_head { - snapshot.calculate_relative_line_numbers( - &rows, + gutter.snapshot.calculate_relative_line_numbers( + &gutter.range, current_selection_head, relative.wrapped(), ) @@ -3483,72 +3422,79 @@ impl EditorElement { }; let mut line_number = String::new(); - let segments = buffer_rows.iter().enumerate().flat_map(|(ix, row_info)| { - let display_row = DisplayRow(rows.start.0 + ix as u32); - line_number.clear(); - let non_relative_number = if relative.wrapped() { - row_info.buffer_row.or(row_info.wrapped_buffer_row)? + 1 - } else { - row_info.buffer_row? + 1 - }; - let relative_number = relative_rows.get(&display_row); - if !(relative_line_numbers_enabled && relative_number.is_some()) - && !snapshot.number_deleted_lines - && row_info - .diff_status - .is_some_and(|status| status.is_deleted()) - { - return None; - } + let segments = gutter + .row_infos + .iter() + .enumerate() + .flat_map(|(ix, row_info)| { + let display_row = DisplayRow(gutter.range.start.0 + ix as u32); + line_number.clear(); + let non_relative_number = if relative.wrapped() { + row_info.buffer_row.or(row_info.wrapped_buffer_row)? + 1 + } else { + row_info.buffer_row? + 1 + }; + let relative_number = relative_rows.get(&display_row); + if !(relative_line_numbers_enabled && relative_number.is_some()) + && !gutter.snapshot.number_deleted_lines + && row_info + .diff_status + .is_some_and(|status| status.is_deleted()) + { + return None; + } - let number = relative_number.unwrap_or(&non_relative_number); - write!(&mut line_number, "{number}").unwrap(); + let number = relative_number.unwrap_or(&non_relative_number); + write!(&mut line_number, "{number}").unwrap(); - let color = active_rows - .get(&display_row) - .map(|spec| { - if spec.breakpoint { - cx.theme().colors().debugger_accent - } else { - cx.theme().colors().editor_active_line_number - } - }) - .unwrap_or_else(|| cx.theme().colors().editor_line_number); - let shaped_line = - self.shape_line_number(SharedString::from(&line_number), color, window); - let scroll_top = scroll_position.y * ScrollPixelOffset::from(line_height); - let line_origin = gutter_hitbox.map(|hitbox| { - hitbox.origin + let color = active_rows + .get(&display_row) + .map(|spec| { + if spec.breakpoint { + cx.theme().colors().debugger_accent + } else { + cx.theme().colors().editor_active_line_number + } + }) + .unwrap_or_else(|| cx.theme().colors().editor_line_number); + let shaped_line = + self.shape_line_number(SharedString::from(&line_number), color, window); + let scroll_top = + gutter.scroll_position.y * ScrollPixelOffset::from(gutter.line_height); + let line_origin = gutter.hitbox.origin + point( - hitbox.size.width - shaped_line.width - gutter_dimensions.right_padding, - ix as f32 * line_height - - Pixels::from(scroll_top % ScrollPixelOffset::from(line_height)), - ) - }); + gutter.hitbox.size.width + - shaped_line.width + - gutter.dimensions.right_padding, + ix as f32 * gutter.line_height + - Pixels::from( + scroll_top % ScrollPixelOffset::from(gutter.line_height), + ), + ); - #[cfg(not(test))] - let hitbox = line_origin.map(|line_origin| { - window.insert_hitbox( - Bounds::new(line_origin, size(shaped_line.width, line_height)), + #[cfg(not(test))] + let hitbox = Some(window.insert_hitbox( + Bounds::new(line_origin, size(shaped_line.width, gutter.line_height)), HitboxBehavior::Normal, - ) - }); - #[cfg(test)] - let hitbox = { - let _ = line_origin; - None - }; + )); + #[cfg(test)] + let hitbox = { + let _ = line_origin; + None + }; - let segment = LineNumberSegment { - shaped_line, - hitbox, - }; + let segment = LineNumberSegment { + shaped_line, + hitbox, + }; - let buffer_row = DisplayPoint::new(display_row, 0).to_point(snapshot).row; - let multi_buffer_row = MultiBufferRow(buffer_row); + let buffer_row = DisplayPoint::new(display_row, 0) + .to_point(gutter.snapshot) + .row; + let multi_buffer_row = MultiBufferRow(buffer_row); - Some((multi_buffer_row, segment)) - }); + Some((multi_buffer_row, segment)) + }); let mut line_numbers: HashMap = HashMap::default(); for (buffer_row, segment) in segments { @@ -6427,6 +6373,10 @@ impl EditorElement { } }); + for bookmark in layout.bookmarks.iter_mut() { + bookmark.paint(window, cx); + } + for breakpoint in layout.breakpoints.iter_mut() { breakpoint.paint(window, cx); } @@ -7964,6 +7914,96 @@ impl EditorElement { } } +struct Gutter<'a> { + line_height: Pixels, + range: Range, + scroll_position: gpui::Point, + dimensions: &'a GutterDimensions, + hitbox: &'a Hitbox, + snapshot: &'a EditorSnapshot, + row_infos: &'a [RowInfo], +} + +impl Gutter<'_> { + fn layout_item_skipping_folds( + &self, + display_row: DisplayRow, + render_item: impl Fn(&mut Context<'_, Editor>, &mut Window) -> AnyElement, + window: &mut Window, + cx: &mut Context<'_, Editor>, + ) -> Option { + let row = MultiBufferRow( + DisplayPoint::new(display_row, 0) + .to_point(self.snapshot) + .row, + ); + if self.snapshot.is_line_folded(row) { + return None; + } + + self.layout_item(display_row, render_item, window, cx) + } + + fn layout_item( + &self, + display_row: DisplayRow, + render_item: impl Fn(&mut Context<'_, Editor>, &mut Window) -> AnyElement, + window: &mut Window, + cx: &mut Context<'_, Editor>, + ) -> Option { + if !self.range.contains(&display_row) { + return None; + } + + if self + .row_infos + .get((display_row.0.saturating_sub(self.range.start.0)) as usize) + .is_some_and(|row_info| { + row_info.expand_info.is_some() + || row_info + .diff_status + .is_some_and(|status| status.is_deleted()) + }) + { + return None; + } + + let button = self.prepaint_button(render_item(cx, window), display_row, window, cx); + Some(button) + } + + fn prepaint_button( + &self, + mut button: AnyElement, + row: DisplayRow, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + let available_space = size( + AvailableSpace::MinContent, + AvailableSpace::Definite(self.line_height), + ); + let indicator_size = button.layout_as_root(available_space, window, cx); + let git_gutter_width = EditorElement::gutter_strip_width(self.line_height) + + self.dimensions.git_blame_entries_width.unwrap_or_default(); + + let x = git_gutter_width + px(2.); + + let mut y = Pixels::from( + (row.as_f64() - self.scroll_position.y) * ScrollPixelOffset::from(self.line_height), + ); + y += (self.line_height - indicator_size.height) / 2.; + + button.prepaint_as_root( + self.hitbox.origin + point(x, y), + available_space, + window, + cx, + ); + button + } +} + pub fn render_breadcrumb_text( mut segments: Vec, breadcrumb_font: Option, @@ -8641,41 +8681,6 @@ pub(crate) fn render_buffer_header( }) } -fn prepaint_gutter_button( - mut button: AnyElement, - row: DisplayRow, - line_height: Pixels, - gutter_dimensions: &GutterDimensions, - scroll_position: gpui::Point, - gutter_hitbox: &Hitbox, - window: &mut Window, - cx: &mut App, -) -> AnyElement { - let available_space = size( - AvailableSpace::MinContent, - AvailableSpace::Definite(line_height), - ); - let indicator_size = button.layout_as_root(available_space, window, cx); - let git_gutter_width = EditorElement::gutter_strip_width(line_height) - + gutter_dimensions - .git_blame_entries_width - .unwrap_or_default(); - - let x = git_gutter_width + px(2.); - - let mut y = - Pixels::from((row.as_f64() - scroll_position.y) * ScrollPixelOffset::from(line_height)); - y += (line_height - indicator_size.height) / 2.; - - button.prepaint_as_root( - gutter_hitbox.origin + point(x, y), - available_space, - window, - cx, - ); - button -} - fn render_inline_blame_entry( blame_entry: BlameEntry, style: &EditorStyle, @@ -10122,54 +10127,38 @@ impl Element for EditorElement { }) }); + let run_indicator_rows = self.editor.update(cx, |editor, cx| { + editor.active_run_indicators(start_row..end_row, window, cx) + }); + let mut breakpoint_rows = self.editor.update(cx, |editor, cx| { editor.active_breakpoints(start_row..end_row, window, cx) }); + for (display_row, (_, bp, state)) in &breakpoint_rows { if bp.is_enabled() && state.is_none_or(|s| s.verified) { active_rows.entry(*display_row).or_default().breakpoint = true; } } - let line_numbers = self.layout_line_numbers( - Some(&gutter_hitbox), - gutter_dimensions, + let gutter = Gutter { line_height, + range: start_row..end_row, scroll_position, - start_row..end_row, - &row_infos, + dimensions: &gutter_dimensions, + hitbox: &gutter_hitbox, + snapshot: &snapshot, + row_infos: &row_infos, + }; + + let line_numbers = self.layout_line_numbers( + &gutter, &active_rows, current_selection_head, - &snapshot, window, cx, ); - // We add the gutter breakpoint indicator to breakpoint_rows after painting - // line numbers so we don't paint a line number debug accent color if a user - // has their mouse over that line when a breakpoint isn't there - self.editor.update(cx, |editor, _| { - if let Some(phantom_breakpoint) = &mut editor - .gutter_breakpoint_indicator - .0 - .filter(|phantom_breakpoint| phantom_breakpoint.is_active) - { - // Is there a non-phantom breakpoint on this line? - phantom_breakpoint.collides_with_existing_breakpoint = true; - breakpoint_rows - .entry(phantom_breakpoint.display_row) - .or_insert_with(|| { - let position = snapshot.display_point_to_anchor( - DisplayPoint::new(phantom_breakpoint.display_row, 0), - Bias::Right, - ); - let breakpoint = Breakpoint::new_standard(); - phantom_breakpoint.collides_with_existing_breakpoint = false; - (position, breakpoint, None) - }); - } - }); - let mut expand_toggles = window.with_element_namespace("expand_toggles", |window| { self.layout_expand_toggles( @@ -10752,14 +10741,9 @@ impl Element for EditorElement { let test_indicators = if gutter_settings.runnables { self.layout_run_indicators( - line_height, - start_row..end_row, - &row_infos, - scroll_position, - &gutter_dimensions, - &gutter_hitbox, - &snapshot, - &mut breakpoint_rows, + &gutter, + &run_indicator_rows, + &breakpoint_rows, window, cx, ) @@ -10767,26 +10751,54 @@ impl Element for EditorElement { Vec::new() }; + let show_bookmarks = + snapshot.show_bookmarks.unwrap_or(gutter_settings.bookmarks); + + let bookmark_rows = self.editor.update(cx, |editor, cx| { + let mut rows = editor.active_bookmarks(start_row..end_row, window, cx); + rows.retain(|k| !run_indicator_rows.contains(k)); + rows.retain(|k| !breakpoint_rows.contains_key(k)); + rows + }); + + let bookmarks = if show_bookmarks { + self.layout_bookmarks(&gutter, &bookmark_rows, window, cx) + } else { + Vec::new() + }; + let show_breakpoints = snapshot .show_breakpoints .unwrap_or(gutter_settings.breakpoints); - let breakpoints = if show_breakpoints { - self.layout_breakpoints( - line_height, - start_row..end_row, - scroll_position, - &gutter_dimensions, - &gutter_hitbox, - &snapshot, - breakpoint_rows, - &row_infos, - window, - cx, - ) + + breakpoint_rows.retain(|k, _| !run_indicator_rows.contains(k)); + let mut breakpoints = if show_breakpoints { + self.layout_breakpoints(&gutter, &breakpoint_rows, window, cx) } else { Vec::new() }; + let gutter_hover_button = self + .editor + .read(cx) + .gutter_hover_button + .0 + .filter(|phantom| phantom.is_active) + .map(|phantom| phantom.display_row); + + if let Some(row) = gutter_hover_button + && !breakpoint_rows.contains_key(&row) + && !run_indicator_rows.contains(&row) + && !bookmark_rows.contains(&row) + && (show_bookmarks || show_breakpoints) + { + let position = snapshot + .display_point_to_anchor(DisplayPoint::new(row, 0), Bias::Right); + breakpoints.extend( + self.layout_gutter_hover_button(&gutter, position, row, window, cx), + ); + } + let git_gutter_width = Self::gutter_strip_width(line_height) + gutter_dimensions .git_blame_entries_width @@ -10830,16 +10842,7 @@ impl Element for EditorElement { .render_diff_review_button(display_row, button_width, cx) .into_any_element() }); - prepaint_gutter_button( - button, - display_row, - line_height, - &gutter_dimensions, - scroll_position, - &gutter_hitbox, - window, - cx, - ) + gutter.prepaint_button(button, display_row, window, cx) }); self.layout_signature_help( @@ -11075,6 +11078,7 @@ impl Element for EditorElement { diff_hunk_controls, mouse_context_menu, test_indicators, + bookmarks, breakpoints, diff_review_button, crease_toggles, @@ -11263,6 +11267,7 @@ pub struct EditorLayout { visible_cursors: Vec, selections: Vec<(PlayerColor, Vec)>, test_indicators: Vec, + bookmarks: Vec, breakpoints: Vec, diff_review_button: Option, crease_toggles: Vec>, @@ -12472,6 +12477,71 @@ mod tests { use std::num::NonZeroU32; use util::test::sample_text; + const fn placeholder_hitbox() -> Hitbox { + use gpui::HitboxId; + let zero_bounds = Bounds { + origin: point(Pixels::ZERO, Pixels::ZERO), + size: Size { + width: Pixels::ZERO, + height: Pixels::ZERO, + }, + }; + + Hitbox { + id: HitboxId::placeholder(), + bounds: zero_bounds, + content_mask: ContentMask { + bounds: zero_bounds, + }, + behavior: HitboxBehavior::Normal, + } + } + + fn test_gutter(line_height: Pixels, snapshot: &EditorSnapshot) -> Gutter<'_> { + const DIMENSIONS: GutterDimensions = GutterDimensions { + left_padding: Pixels::ZERO, + right_padding: Pixels::ZERO, + width: px(30.0), + margin: Pixels::ZERO, + git_blame_entries_width: None, + }; + const EMPTY_ROW_INFO: RowInfo = RowInfo { + buffer_id: None, + buffer_row: None, + multibuffer_row: None, + diff_status: None, + expand_info: None, + wrapped_buffer_row: None, + }; + + const fn row_info(row: u32) -> RowInfo { + RowInfo { + buffer_row: Some(row), + ..EMPTY_ROW_INFO + } + } + + const ROW_INFOS: [RowInfo; 6] = [ + row_info(0), + row_info(1), + row_info(2), + row_info(3), + row_info(4), + row_info(5), + ]; + + const HITBOX: Hitbox = placeholder_hitbox(); + Gutter { + line_height, + range: DisplayRow(0)..DisplayRow(6), + scroll_position: gpui::Point::default(), + dimensions: &DIMENSIONS, + hitbox: &HITBOX, + snapshot: snapshot, + row_infos: &ROW_INFOS, + } + } + #[gpui::test] async fn test_soft_wrap_editor_width_auto_height_editor(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -12558,26 +12628,9 @@ mod tests { let layouts = cx .update_window(*window, |_, window, cx| { element.layout_line_numbers( - None, - GutterDimensions { - left_padding: Pixels::ZERO, - right_padding: Pixels::ZERO, - width: px(30.0), - margin: Pixels::ZERO, - git_blame_entries_width: None, - }, - line_height, - gpui::Point::default(), - DisplayRow(0)..DisplayRow(6), - &(0..6) - .map(|row| RowInfo { - buffer_row: Some(row), - ..Default::default() - }) - .collect::>(), + &test_gutter(line_height, &snapshot), &BTreeMap::default(), Some(DisplayRow(0)), - &snapshot, window, cx, ) @@ -12635,35 +12688,28 @@ mod tests { assert_eq!(relative_rows[&DisplayRow(1)], 4); assert_eq!(relative_rows[&DisplayRow(2)], 3); + let gutter = Gutter { + row_infos: &(0..6) + .map(|row| RowInfo { + buffer_row: Some(row), + diff_status: (row == DELETED_LINE).then(|| { + DiffHunkStatus::deleted( + buffer_diff::DiffHunkSecondaryStatus::NoSecondaryHunk, + ) + }), + ..Default::default() + }) + .collect::>(), + ..test_gutter(line_height, &snapshot) + }; + const DELETED_LINE: u32 = 3; let layouts = cx .update_window(*window, |_, window, cx| { element.layout_line_numbers( - None, - GutterDimensions { - left_padding: Pixels::ZERO, - right_padding: Pixels::ZERO, - width: px(30.0), - margin: Pixels::ZERO, - git_blame_entries_width: None, - }, - line_height, - gpui::Point::default(), - DisplayRow(0)..DisplayRow(6), - &(0..6) - .map(|row| RowInfo { - buffer_row: Some(row), - diff_status: (row == DELETED_LINE).then(|| { - DiffHunkStatus::deleted( - buffer_diff::DiffHunkSecondaryStatus::NoSecondaryHunk, - ) - }), - ..Default::default() - }) - .collect::>(), + &gutter, &BTreeMap::default(), Some(DisplayRow(0)), - &snapshot, window, cx, ) @@ -12722,26 +12768,9 @@ mod tests { let layouts = cx .update_window(*window, |_, window, cx| { element.layout_line_numbers( - None, - GutterDimensions { - left_padding: Pixels::ZERO, - right_padding: Pixels::ZERO, - width: px(30.0), - margin: Pixels::ZERO, - git_blame_entries_width: None, - }, - line_height, - gpui::Point::default(), - DisplayRow(0)..DisplayRow(6), - &(0..6) - .map(|row| RowInfo { - buffer_row: Some(row), - ..Default::default() - }) - .collect::>(), + &test_gutter(line_height, &snapshot), &BTreeMap::default(), Some(DisplayRow(3)), - &snapshot, window, cx, ) @@ -12796,26 +12825,9 @@ mod tests { let layouts = cx .update_window(*window, |_, window, cx| { element.layout_line_numbers( - None, - GutterDimensions { - left_padding: Pixels::ZERO, - right_padding: Pixels::ZERO, - width: px(30.0), - margin: Pixels::ZERO, - git_blame_entries_width: None, - }, - line_height, - gpui::Point::default(), - DisplayRow(0)..DisplayRow(6), - &(0..6) - .map(|row| RowInfo { - buffer_row: Some(row), - ..Default::default() - }) - .collect::>(), + &test_gutter(line_height, &snapshot), &BTreeMap::default(), Some(DisplayRow(0)), - &snapshot, window, cx, ) @@ -12845,29 +12857,20 @@ mod tests { let layouts = cx .update_window(*window, |_, window, cx| { element.layout_line_numbers( - None, - GutterDimensions { - left_padding: Pixels::ZERO, - right_padding: Pixels::ZERO, - width: px(30.0), - margin: Pixels::ZERO, - git_blame_entries_width: None, + &Gutter { + row_infos: &(0..6) + .map(|row| RowInfo { + buffer_row: Some(row), + diff_status: Some(DiffHunkStatus::deleted( + buffer_diff::DiffHunkSecondaryStatus::NoSecondaryHunk, + )), + ..Default::default() + }) + .collect::>(), + ..test_gutter(line_height, &snapshot) }, - line_height, - gpui::Point::default(), - DisplayRow(0)..DisplayRow(6), - &(0..6) - .map(|row| RowInfo { - buffer_row: Some(row), - diff_status: Some(DiffHunkStatus::deleted( - buffer_diff::DiffHunkSecondaryStatus::NoSecondaryHunk, - )), - ..Default::default() - }) - .collect::>(), &BTreeMap::from_iter([(DisplayRow(0), LineHighlightSpec::default())]), Some(DisplayRow(0)), - &snapshot, window, cx, ) diff --git a/crates/editor/src/runnables.rs b/crates/editor/src/runnables.rs index f451eb7d61d6a2513e1ebf6ec96062b600cbecb6..dc97a3ea310b5b519c13887996c3bb3c0d6274a8 100644 --- a/crates/editor/src/runnables.rs +++ b/crates/editor/src/runnables.rs @@ -9,11 +9,7 @@ use gpui::{ use language::{Buffer, BufferRow, Runnable}; use lsp::LanguageServerName; use multi_buffer::{Anchor, BufferOffset, MultiBufferRow, MultiBufferSnapshot, ToPoint as _}; -use project::{ - Location, Project, TaskSourceKind, - debugger::breakpoint_store::{Breakpoint, BreakpointSessionState}, - project_settings::ProjectSettings, -}; +use project::{Location, Project, TaskSourceKind, project_settings::ProjectSettings}; use settings::Settings as _; use smallvec::SmallVec; use task::{ResolvedTask, RunnableTag, TaskContext, TaskTemplate, TaskVariables, VariableName}; @@ -519,12 +515,11 @@ impl Editor { &self, _style: &EditorStyle, is_active: bool, + active_breakpoint: Option, row: DisplayRow, - breakpoint: Option<(Anchor, Breakpoint, Option)>, cx: &mut Context, ) -> IconButton { let color = Color::Muted; - let position = breakpoint.as_ref().map(|(anchor, _, _)| *anchor); IconButton::new( ("run_indicator", row.0 as usize), @@ -551,7 +546,7 @@ impl Editor { ); })) .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { - editor.set_breakpoint_context_menu(row, position, event.position(), window, cx); + editor.set_gutter_context_menu(row, active_breakpoint, event.position(), window, cx); })) } diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index aac44c7f9c6eaf6f18c72bea390c0a0b7ad1a4bd..b3783fe2b70862f04f3eda970e772243a32f5504 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -203,6 +203,7 @@ impl CommitView { Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx); editor.disable_inline_diagnostics(); + editor.set_show_bookmarks(false, cx); editor.set_show_breakpoints(false, cx); editor.set_show_diff_review_button(true, cx); editor.set_expand_all_diff_hunks(cx); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index dadc5a6247dc69d1b12fab08b253e1cb1564bd84..f73a59358c3b06b187fd7357b5351f557d2fd68c 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -572,6 +572,17 @@ pub enum WindowControlArea { #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] pub struct HitboxId(u64); +#[cfg(feature = "test-support")] +impl HitboxId { + /// A placeholder HitboxId exclusively for integration testing API's that + /// need a hitbox but where the value of the hitbox does not matter. The + /// alternative is to make the Hitbox optional but that complicates the + /// implementation. + pub const fn placeholder() -> Self { + Self(0) + } +} + impl HitboxId { /// Checks if the hitbox with this ID is currently hovered. Returns `false` during keyboard /// input modality so that keyboard navigation suppresses hover highlights. Except when handling diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 20d7b609d8de07c4de4c489eac90b312fbf9c210..568244912460ca750c7e904bca6b903488b0305c 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -48,6 +48,7 @@ pub enum IconName { BellRing, Binary, Blocks, + Bookmark, BoltFilled, BoltOutlined, Book, diff --git a/crates/inspector_ui/src/div_inspector.rs b/crates/inspector_ui/src/div_inspector.rs index 135c8f22116498fbc0db43c88928a365e5607ce5..e36a72f8ffd15a1edf006db8390e39025eb07ea0 100644 --- a/crates/inspector_ui/src/div_inspector.rs +++ b/crates/inspector_ui/src/div_inspector.rs @@ -495,6 +495,7 @@ impl DivInspector { editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); editor.set_show_line_numbers(false, cx); editor.set_show_code_actions(false, cx); + editor.set_show_bookmarks(false, cx); editor.set_show_breakpoints(false, cx); editor.set_show_git_diff_gutter(false, cx); editor.set_show_runnables(false, cx); diff --git a/crates/language_tools/src/lsp_log_view.rs b/crates/language_tools/src/lsp_log_view.rs index 97f0676d250cac2cee54b307e7c07d894d3d3128..59571040eac6281fa2b2032a655dafabfa345f0a 100644 --- a/crates/language_tools/src/lsp_log_view.rs +++ b/crates/language_tools/src/lsp_log_view.rs @@ -1294,6 +1294,7 @@ fn initialize_new_editor( editor.set_text(content, window, cx); editor.set_show_git_diff_gutter(false, cx); editor.set_show_runnables(false, cx); + editor.set_show_bookmarks(false, cx); editor.set_show_breakpoints(false, cx); editor.set_read_only(true); editor.set_show_edit_predictions(Some(false), window, cx); diff --git a/crates/project/src/bookmark_store.rs b/crates/project/src/bookmark_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..841fb04af2440944ff6bcec4dcf479fa4fc82b6f --- /dev/null +++ b/crates/project/src/bookmark_store.rs @@ -0,0 +1,444 @@ +use std::{collections::BTreeMap, ops::Range, path::Path, sync::Arc}; + +use anyhow::Result; +use futures::{StreamExt, TryFutureExt, TryStreamExt, stream::FuturesUnordered}; +use gpui::{App, AppContext, Context, Entity, Subscription, Task}; +use itertools::Itertools; +use language::{Buffer, BufferEvent}; +use std::collections::HashMap; +use text::{BufferSnapshot, Point}; + +use crate::{ProjectPath, buffer_store::BufferStore, worktree_store::WorktreeStore}; + +#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)] +pub struct BookmarkAnchor(text::Anchor); + +impl BookmarkAnchor { + pub fn anchor(&self) -> text::Anchor { + self.0 + } +} + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub struct SerializedBookmark(pub u32); + +#[derive(Debug)] +pub struct BufferBookmarks { + buffer: Entity, + bookmarks: Vec, + _subscription: Subscription, +} + +impl BufferBookmarks { + pub fn new(buffer: Entity, cx: &mut Context) -> Self { + let subscription = cx.subscribe( + &buffer, + |bookmark_store, buffer, event: &BufferEvent, cx| match event { + BufferEvent::FileHandleChanged => { + bookmark_store.handle_file_changed(buffer, cx); + } + _ => {} + }, + ); + + Self { + buffer, + bookmarks: Vec::new(), + _subscription: subscription, + } + } + + pub fn buffer(&self) -> &Entity { + &self.buffer + } + + pub fn bookmarks(&self) -> &[BookmarkAnchor] { + &self.bookmarks + } +} + +#[derive(Debug)] +pub enum BookmarkEntry { + Loaded(BufferBookmarks), + Unloaded(Vec), +} + +impl BookmarkEntry { + pub fn is_empty(&self) -> bool { + match self { + BookmarkEntry::Loaded(buffer_bookmarks) => buffer_bookmarks.bookmarks.is_empty(), + BookmarkEntry::Unloaded(rows) => rows.is_empty(), + } + } + + fn loaded(&self) -> Option<&BufferBookmarks> { + match self { + BookmarkEntry::Loaded(buffer_bookmarks) => Some(buffer_bookmarks), + BookmarkEntry::Unloaded(_) => None, + } + } +} + +pub struct BookmarkStore { + buffer_store: Entity, + worktree_store: Entity, + bookmarks: BTreeMap, BookmarkEntry>, +} + +impl BookmarkStore { + pub fn new(worktree_store: Entity, buffer_store: Entity) -> Self { + Self { + buffer_store, + worktree_store, + bookmarks: BTreeMap::new(), + } + } + + pub fn load_serialized_bookmarks( + &mut self, + bookmark_rows: BTreeMap, Vec>, + cx: &mut Context, + ) -> Task> { + self.bookmarks.clear(); + + for (path, rows) in bookmark_rows { + if rows.is_empty() { + continue; + } + + let count = rows.len(); + log::debug!("Stored {count} unloaded bookmark(s) at {}", path.display()); + + self.bookmarks.insert(path, BookmarkEntry::Unloaded(rows)); + } + + cx.notify(); + Task::ready(Ok(())) + } + + fn resolve_anchors_if_needed( + &mut self, + abs_path: &Arc, + buffer: &Entity, + cx: &mut Context, + ) { + let Some(BookmarkEntry::Unloaded(rows)) = self.bookmarks.get(abs_path) else { + return; + }; + + let snapshot = buffer.read(cx).snapshot(); + let max_point = snapshot.max_point(); + + let anchors: Vec = rows + .iter() + .filter_map(|bookmark_row| { + let point = Point::new(bookmark_row.0, 0); + + if point > max_point { + log::warn!( + "Skipping out-of-range bookmark: {} row {} (file has {} rows)", + abs_path.display(), + bookmark_row.0, + max_point.row + ); + return None; + } + + let anchor = snapshot.anchor_after(point); + Some(BookmarkAnchor(anchor)) + }) + .collect(); + + if anchors.is_empty() { + self.bookmarks.remove(abs_path); + } else { + let mut buffer_bookmarks = BufferBookmarks::new(buffer.clone(), cx); + buffer_bookmarks.bookmarks = anchors; + self.bookmarks + .insert(abs_path.clone(), BookmarkEntry::Loaded(buffer_bookmarks)); + } + } + + pub fn abs_path_from_buffer(buffer: &Entity, cx: &App) -> Option> { + worktree::File::from_dyn(buffer.read(cx).file()) + .map(|file| file.worktree.read(cx).absolutize(&file.path)) + .map(Arc::::from) + } + + /// Toggle a bookmark at the given anchor in the buffer. + /// If a bookmark already exists on the same row, it will be removed. + /// Otherwise, a new bookmark will be added. + pub fn toggle_bookmark( + &mut self, + buffer: Entity, + anchor: text::Anchor, + cx: &mut Context, + ) { + let Some(abs_path) = Self::abs_path_from_buffer(&buffer, cx) else { + return; + }; + + self.resolve_anchors_if_needed(&abs_path, &buffer, cx); + + let entry = self + .bookmarks + .entry(abs_path.clone()) + .or_insert_with(|| BookmarkEntry::Loaded(BufferBookmarks::new(buffer.clone(), cx))); + + let BookmarkEntry::Loaded(buffer_bookmarks) = entry else { + unreachable!("resolve_if_needed should have converted to Loaded"); + }; + + let snapshot = buffer.read(cx).text_snapshot(); + + let existing_index = buffer_bookmarks.bookmarks.iter().position(|existing| { + existing.0.summary::(&snapshot).row == anchor.summary::(&snapshot).row + }); + + if let Some(index) = existing_index { + buffer_bookmarks.bookmarks.remove(index); + if buffer_bookmarks.bookmarks.is_empty() { + self.bookmarks.remove(&abs_path); + } + } else { + buffer_bookmarks.bookmarks.push(BookmarkAnchor(anchor)); + } + + cx.notify(); + } + + /// Returns the bookmarks for a given buffer within an optional range. + /// Only returns bookmarks that have been resolved to anchors (loaded). + /// Unloaded bookmarks for the given buffer will be resolved first. + pub fn bookmarks_for_buffer( + &mut self, + buffer: Entity, + range: Range, + buffer_snapshot: &BufferSnapshot, + cx: &mut Context, + ) -> Vec { + let Some(abs_path) = Self::abs_path_from_buffer(&buffer, cx) else { + return Vec::new(); + }; + + self.resolve_anchors_if_needed(&abs_path, &buffer, cx); + + let Some(BookmarkEntry::Loaded(file_bookmarks)) = self.bookmarks.get(&abs_path) else { + return Vec::new(); + }; + + file_bookmarks + .bookmarks + .iter() + .filter_map({ + move |bookmark| { + if !buffer_snapshot.can_resolve(&bookmark.anchor()) { + return None; + } + + if bookmark.anchor().cmp(&range.start, buffer_snapshot).is_lt() + || bookmark.anchor().cmp(&range.end, buffer_snapshot).is_gt() + { + return None; + } + + Some(*bookmark) + } + }) + .collect() + } + + fn handle_file_changed(&mut self, buffer: Entity, cx: &mut Context) { + let entity_id = buffer.entity_id(); + + if buffer + .read(cx) + .file() + .is_none_or(|f| f.disk_state().is_deleted()) + { + self.bookmarks.retain(|_, entry| match entry { + BookmarkEntry::Loaded(buffer_bookmarks) => { + buffer_bookmarks.buffer.entity_id() != entity_id + } + BookmarkEntry::Unloaded(_) => true, + }); + cx.notify(); + return; + } + + if let Some(new_abs_path) = Self::abs_path_from_buffer(&buffer, cx) { + if self.bookmarks.contains_key(&new_abs_path) { + return; + } + + if let Some(old_path) = self + .bookmarks + .iter() + .find(|(_, entry)| match entry { + BookmarkEntry::Loaded(buffer_bookmarks) => { + buffer_bookmarks.buffer.entity_id() == entity_id + } + BookmarkEntry::Unloaded(_) => false, + }) + .map(|(path, _)| path) + .cloned() + { + let Some(entry) = self.bookmarks.remove(&old_path) else { + log::error!( + "Couldn't get bookmarks from old path during buffer rename handling" + ); + return; + }; + self.bookmarks.insert(new_abs_path, entry); + cx.notify(); + } + } + } + + pub fn all_serialized_bookmarks( + &self, + cx: &App, + ) -> BTreeMap, Vec> { + self.bookmarks + .iter() + .filter_map(|(path, entry)| { + let mut rows = match entry { + BookmarkEntry::Unloaded(rows) => rows.clone(), + BookmarkEntry::Loaded(buffer_bookmarks) => { + let snapshot = buffer_bookmarks.buffer.read(cx).snapshot(); + buffer_bookmarks + .bookmarks + .iter() + .filter_map(|bookmark| { + if !snapshot.can_resolve(&bookmark.anchor()) { + return None; + } + let row = + snapshot.summary_for_anchor::(&bookmark.anchor()).row; + Some(SerializedBookmark(row)) + }) + .collect() + } + }; + + rows.sort(); + rows.dedup(); + + if rows.is_empty() { + None + } else { + Some((path.clone(), rows)) + } + }) + .collect() + } + + pub async fn all_bookmark_locations( + this: Entity, + cx: &mut (impl AppContext + Clone), + ) -> Result, Vec>>> { + Self::resolve_all(&this, cx).await?; + + cx.read_entity(&this, |this, cx| { + let mut locations: HashMap<_, Vec<_>> = HashMap::new(); + for bookmarks in this.bookmarks.values().filter_map(BookmarkEntry::loaded) { + let snapshot = cx.read_entity(bookmarks.buffer(), |b, _| b.snapshot()); + let ranges: Vec> = bookmarks + .bookmarks() + .iter() + .map(|anchor| { + let row = snapshot.summary_for_anchor::(&anchor.anchor()).row; + Point::row_range(row..row) + }) + .collect(); + + locations + .entry(bookmarks.buffer().clone()) + .or_default() + .extend(ranges); + } + + Ok(locations) + }) + } + + /// Opens buffers for all unloaded bookmark entries and resolves them to anchors. This is used to show all bookmarks in a large multi-buffer. + async fn resolve_all(this: &Entity, cx: &mut (impl AppContext + Clone)) -> Result<()> { + let unloaded_paths: Vec> = cx.read_entity(&this, |this, _| { + this.bookmarks + .iter() + .filter_map(|(path, entry)| match entry { + BookmarkEntry::Unloaded(_) => Some(path.clone()), + BookmarkEntry::Loaded(_) => None, + }) + .collect_vec() + }); + + if unloaded_paths.is_empty() { + return Ok(()); + } + + let worktree_store = cx.read_entity(&this, |this, _| this.worktree_store.clone()); + let buffer_store = cx.read_entity(&this, |this, _| this.buffer_store.clone()); + + let open_tasks: FuturesUnordered<_> = unloaded_paths + .iter() + .map(|path| { + open_path(path, &worktree_store, &buffer_store, cx.clone()) + .map_err(move |e| (path, e)) + .map_ok(move |b| (path, b)) + }) + .collect(); + + let opened: Vec<_> = open_tasks + .inspect_err(|(path, error)| { + log::warn!( + "Could not open buffer for bookmarked path {}: {error}", + path.display() + ) + }) + .filter_map(|res| async move { res.ok() }) + .collect() + .await; + + cx.update_entity(&this, |this, cx| { + for (path, buffer) in opened { + this.resolve_anchors_if_needed(&path, &buffer, cx); + } + cx.notify(); + }); + + Ok(()) + } + + pub fn clear_bookmarks(&mut self, cx: &mut Context) { + self.bookmarks.clear(); + cx.notify(); + } +} + +async fn open_path( + path: &Path, + worktree_store: &Entity, + buffer_store: &Entity, + mut cx: impl AppContext, +) -> Result> { + let (worktree, worktree_path) = cx + .update_entity(&worktree_store, |worktree_store, cx| { + worktree_store.find_or_create_worktree(path, false, cx) + }) + .await?; + + let project_path = ProjectPath { + worktree_id: cx.read_entity(&worktree, |worktree, _| worktree.id()), + path: worktree_path, + }; + + let buffer = cx + .update_entity(&buffer_store, |buffer_store, cx| { + buffer_store.open_buffer(project_path, cx) + }) + .await?; + + Ok(buffer) +} diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index bb128388ae3bd7f29d3bd39260a56eaf38adbebc..a8bd461d3d94839d5222164ef88d536abc1bcaf4 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1,5 +1,6 @@ pub mod agent_registry_store; pub mod agent_server_store; +pub mod bookmark_store; pub mod buffer_store; pub mod color_extractor; pub mod connection_manager; @@ -36,6 +37,7 @@ use dap::inline_value::{InlineValueLocation, VariableLookupKind, VariableScope}; use itertools::{Either, Itertools}; use crate::{ + bookmark_store::BookmarkStore, git_store::GitStore, lsp_store::{SymbolLocation, log_store::LogKind}, project_search::SearchResultsHandle, @@ -215,6 +217,7 @@ pub struct Project { dap_store: Entity, agent_server_store: Entity, + bookmark_store: Entity, breakpoint_store: Entity, collab_client: Arc, join_project_response_message_id: u32, @@ -1205,6 +1208,9 @@ impl Project { cx.subscribe(&buffer_store, Self::on_buffer_store_event) .detach(); + let bookmark_store = + cx.new(|_| BookmarkStore::new(worktree_store.clone(), buffer_store.clone())); + let breakpoint_store = cx.new(|_| BreakpointStore::local(worktree_store.clone(), buffer_store.clone())); @@ -1323,6 +1329,7 @@ impl Project { settings_observer, fs, remote_client: None, + bookmark_store, breakpoint_store, dap_store, agent_server_store, @@ -1456,6 +1463,9 @@ impl Project { }); cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach(); + let bookmark_store = + cx.new(|_| BookmarkStore::new(worktree_store.clone(), buffer_store.clone())); + let breakpoint_store = cx.new(|_| { BreakpointStore::remote( REMOTE_SERVER_PROJECT_ID, @@ -1531,6 +1541,7 @@ impl Project { image_store, lsp_store, context_server_store, + bookmark_store, breakpoint_store, dap_store, join_project_response_message_id: 0, @@ -1713,6 +1724,10 @@ impl Project { let environment = cx.new(|cx| ProjectEnvironment::new(None, worktree_store.downgrade(), None, true, cx)); + + let bookmark_store = + cx.new(|_| BookmarkStore::new(worktree_store.clone(), buffer_store.clone())); + let breakpoint_store = cx.new(|_| { BreakpointStore::remote( remote_id, @@ -1846,6 +1861,7 @@ impl Project { remote_id, replica_id, }, + bookmark_store: bookmark_store.clone(), breakpoint_store: breakpoint_store.clone(), dap_store: dap_store.clone(), git_store: git_store.clone(), @@ -2126,6 +2142,11 @@ impl Project { self.dap_store.clone() } + #[inline] + pub fn bookmark_store(&self) -> Entity { + self.bookmark_store.clone() + } + #[inline] pub fn breakpoint_store(&self) -> Entity { self.breakpoint_store.clone() diff --git a/crates/project/tests/integration/bookmark_store.rs b/crates/project/tests/integration/bookmark_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..3b84a0c76b65931e42b93ac8f29df4242d55f006 --- /dev/null +++ b/crates/project/tests/integration/bookmark_store.rs @@ -0,0 +1,685 @@ +use std::{path::Path, sync::Arc}; + +use collections::BTreeMap; +use gpui::{Entity, TestAppContext}; +use language::Buffer; +use project::{Project, bookmark_store::SerializedBookmark}; +use serde_json::json; +use util::path; + +mod integration { + use super::*; + use fs::Fs as _; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + } + + fn project_path(path: &str) -> Arc { + Arc::from(Path::new(path)) + } + + async fn open_buffer( + project: &Entity, + path: &str, + cx: &mut TestAppContext, + ) -> Entity { + project + .update(cx, |project, cx| { + project.open_local_buffer(Path::new(path), cx) + }) + .await + .unwrap() + } + + fn add_bookmarks( + project: &Entity, + buffer: &Entity, + rows: &[u32], + cx: &mut TestAppContext, + ) { + let buffer = buffer.clone(); + project.update(cx, |project, cx| { + let bookmark_store = project.bookmark_store(); + let snapshot = buffer.read(cx).snapshot(); + for &row in rows { + let anchor = snapshot.anchor_after(text::Point::new(row, 0)); + bookmark_store.update(cx, |store, cx| { + store.toggle_bookmark(buffer.clone(), anchor, cx); + }); + } + }); + } + + fn get_all_bookmarks( + project: &Entity, + cx: &mut TestAppContext, + ) -> BTreeMap, Vec> { + project.read_with(cx, |project, cx| { + project + .bookmark_store() + .read(cx) + .all_serialized_bookmarks(cx) + }) + } + + fn build_serialized( + entries: &[(&str, &[u32])], + ) -> BTreeMap, Vec> { + let mut map = BTreeMap::new(); + for &(path_str, rows) in entries { + let path = project_path(path_str); + map.insert( + path.clone(), + rows.iter().map(|&row| SerializedBookmark(row)).collect(), + ); + } + map + } + + async fn restore_bookmarks( + project: &Entity, + serialized: BTreeMap, Vec>, + cx: &mut TestAppContext, + ) { + project + .update(cx, |project, cx| { + project.bookmark_store().update(cx, |store, cx| { + store.load_serialized_bookmarks(serialized, cx) + }) + }) + .await + .expect("with_serialized_bookmarks should succeed"); + } + + fn clear_bookmarks(project: &Entity, cx: &mut TestAppContext) { + project.update(cx, |project, cx| { + project.bookmark_store().update(cx, |store, cx| { + store.clear_bookmarks(cx); + }); + }); + } + + fn assert_bookmark_rows( + bookmarks: &BTreeMap, Vec>, + path: &str, + expected_rows: &[u32], + ) { + let path = project_path(path); + let file_bookmarks = bookmarks + .get(&path) + .unwrap_or_else(|| panic!("Expected bookmarks for {}", path.display())); + let rows: Vec = file_bookmarks.iter().map(|b| b.0).collect(); + assert_eq!(rows, expected_rows, "Bookmark rows for {}", path.display()); + } + + #[gpui::test] + async fn test_all_serialized_bookmarks_empty(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), json!({"file1.rs": "line1\nline2\n"})) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + assert!(get_all_bookmarks(&project, cx).is_empty()); + } + + #[gpui::test] + async fn test_all_serialized_bookmarks_single_file(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({"file1.rs": "line1\nline2\nline3\nline4\nline5\n"}), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let buffer = open_buffer(&project, path!("/project/file1.rs"), cx).await; + + add_bookmarks(&project, &buffer, &[0, 2], cx); + + let bookmarks = get_all_bookmarks(&project, cx); + assert_eq!(bookmarks.len(), 1); + assert_bookmark_rows(&bookmarks, path!("/project/file1.rs"), &[0, 2]); + } + + #[gpui::test] + async fn test_all_serialized_bookmarks_multiple_files(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "file1.rs": "line1\nline2\nline3\n", + "file2.rs": "lineA\nlineB\nlineC\nlineD\n", + "file3.rs": "single line" + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let buffer1 = open_buffer(&project, path!("/project/file1.rs"), cx).await; + let buffer2 = open_buffer(&project, path!("/project/file2.rs"), cx).await; + let _buffer3 = open_buffer(&project, path!("/project/file3.rs"), cx).await; + + add_bookmarks(&project, &buffer1, &[1], cx); + add_bookmarks(&project, &buffer2, &[0, 3], cx); + + let bookmarks = get_all_bookmarks(&project, cx); + assert_eq!(bookmarks.len(), 2); + assert_bookmark_rows(&bookmarks, path!("/project/file1.rs"), &[1]); + assert_bookmark_rows(&bookmarks, path!("/project/file2.rs"), &[0, 3]); + assert!( + !bookmarks.contains_key(&project_path(path!("/project/file3.rs"))), + "file3.rs should have no bookmarks" + ); + } + + #[gpui::test] + async fn test_all_serialized_bookmarks_after_toggle_off(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({"file1.rs": "line1\nline2\nline3\n"}), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let buffer = open_buffer(&project, path!("/project/file1.rs"), cx).await; + + add_bookmarks(&project, &buffer, &[1], cx); + assert_eq!(get_all_bookmarks(&project, cx).len(), 1); + + // Toggle same row again to remove it + add_bookmarks(&project, &buffer, &[1], cx); + assert!(get_all_bookmarks(&project, cx).is_empty()); + } + + #[gpui::test] + async fn test_all_serialized_bookmarks_with_clear(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "file1.rs": "line1\nline2\nline3\n", + "file2.rs": "lineA\nlineB\n" + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let buffer1 = open_buffer(&project, path!("/project/file1.rs"), cx).await; + let buffer2 = open_buffer(&project, path!("/project/file2.rs"), cx).await; + + add_bookmarks(&project, &buffer1, &[0], cx); + add_bookmarks(&project, &buffer2, &[1], cx); + assert_eq!(get_all_bookmarks(&project, cx).len(), 2); + + clear_bookmarks(&project, cx); + assert!(get_all_bookmarks(&project, cx).is_empty()); + } + + #[gpui::test] + async fn test_all_serialized_bookmarks_returns_sorted_by_path(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({"b.rs": "line1\n", "a.rs": "line1\n", "c.rs": "line1\n"}), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let buffer_b = open_buffer(&project, path!("/project/b.rs"), cx).await; + let buffer_a = open_buffer(&project, path!("/project/a.rs"), cx).await; + let buffer_c = open_buffer(&project, path!("/project/c.rs"), cx).await; + + add_bookmarks(&project, &buffer_b, &[0], cx); + add_bookmarks(&project, &buffer_a, &[0], cx); + add_bookmarks(&project, &buffer_c, &[0], cx); + + let paths: Vec<_> = get_all_bookmarks(&project, cx).keys().cloned().collect(); + assert_eq!( + paths, + [ + project_path(path!("/project/a.rs")), + project_path(path!("/project/b.rs")), + project_path(path!("/project/c.rs")), + ] + ); + } + + #[gpui::test] + async fn test_all_serialized_bookmarks_deduplicates_same_row(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({"file1.rs": "line1\nline2\nline3\nline4\n"}), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let buffer = open_buffer(&project, path!("/project/file1.rs"), cx).await; + + add_bookmarks(&project, &buffer, &[1, 2], cx); + + let bookmarks = get_all_bookmarks(&project, cx); + assert_bookmark_rows(&bookmarks, path!("/project/file1.rs"), &[1, 2]); + + // Verify no duplicates + let rows: Vec = bookmarks + .get(&project_path(path!("/project/file1.rs"))) + .unwrap() + .iter() + .map(|b| b.0) + .collect(); + let mut deduped = rows.clone(); + deduped.dedup(); + assert_eq!(rows, deduped); + } + + #[gpui::test] + async fn test_with_serialized_bookmarks_restores_bookmarks(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "file1.rs": "line1\nline2\nline3\nline4\nline5\n", + "file2.rs": "aaa\nbbb\nccc\n" + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + + let serialized = build_serialized(&[ + (path!("/project/file1.rs"), &[0, 3]), + (path!("/project/file2.rs"), &[1]), + ]); + + restore_bookmarks(&project, serialized, cx).await; + + let restored = get_all_bookmarks(&project, cx); + assert_eq!(restored.len(), 2); + assert_bookmark_rows(&restored, path!("/project/file1.rs"), &[0, 3]); + assert_bookmark_rows(&restored, path!("/project/file2.rs"), &[1]); + } + + #[gpui::test] + async fn test_with_serialized_bookmarks_skips_out_of_range_rows(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + // 3 lines: rows 0, 1, 2 + fs.insert_tree( + path!("/project"), + json!({"file1.rs": "line1\nline2\nline3"}), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + + let serialized = build_serialized(&[(path!("/project/file1.rs"), &[1, 100, 2])]); + restore_bookmarks(&project, serialized, cx).await; + + // Before resolution, unloaded bookmarks are stored as-is + let unresolved = get_all_bookmarks(&project, cx); + assert_bookmark_rows(&unresolved, path!("/project/file1.rs"), &[1, 2, 100]); + + // Open the buffer to trigger lazy resolution + let buffer = open_buffer(&project, path!("/project/file1.rs"), cx).await; + project.update(cx, |project, cx| { + let buffer_snapshot = buffer.read(cx).snapshot(); + project.bookmark_store().update(cx, |store, cx| { + store.bookmarks_for_buffer( + buffer.clone(), + buffer_snapshot.anchor_before(0) + ..buffer_snapshot.anchor_after(buffer_snapshot.len()), + &buffer_snapshot, + cx, + ); + }); + }); + + // After resolution, out-of-range rows are filtered + let restored = get_all_bookmarks(&project, cx); + assert_bookmark_rows(&restored, path!("/project/file1.rs"), &[1, 2]); + } + + #[gpui::test] + async fn test_with_serialized_bookmarks_skips_empty_entries(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({"file1.rs": "line1\nline2\n", "file2.rs": "aaa\nbbb\n"}), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + + let mut serialized = build_serialized(&[(path!("/project/file1.rs"), &[0])]); + serialized.insert(project_path(path!("/project/file2.rs")), vec![]); + + restore_bookmarks(&project, serialized, cx).await; + + let restored = get_all_bookmarks(&project, cx); + assert_eq!(restored.len(), 1); + assert!(restored.contains_key(&project_path(path!("/project/file1.rs")))); + assert!(!restored.contains_key(&project_path(path!("/project/file2.rs")))); + } + + #[gpui::test] + async fn test_with_serialized_bookmarks_all_out_of_range_produces_no_entry( + cx: &mut TestAppContext, + ) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), json!({"tiny.rs": "x"})) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + + let serialized = build_serialized(&[(path!("/project/tiny.rs"), &[5, 10])]); + restore_bookmarks(&project, serialized, cx).await; + + // Before resolution, unloaded bookmarks are stored as-is + let unresolved = get_all_bookmarks(&project, cx); + assert_eq!(unresolved.len(), 1); + + // Open the buffer to trigger lazy resolution + let buffer = open_buffer(&project, path!("/project/tiny.rs"), cx).await; + project.update(cx, |project, cx| { + let buffer_snapshot = buffer.read(cx).snapshot(); + project.bookmark_store().update(cx, |store, cx| { + store.bookmarks_for_buffer( + buffer.clone(), + buffer_snapshot.anchor_before(0) + ..buffer_snapshot.anchor_after(buffer_snapshot.len()), + &buffer_snapshot, + cx, + ); + }); + }); + + // After resolution, all out-of-range rows are filtered away + assert!(get_all_bookmarks(&project, cx).is_empty()); + } + + #[gpui::test] + async fn test_with_serialized_bookmarks_replaces_existing(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({"file1.rs": "aaa\nbbb\nccc\nddd\n"}), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let buffer = open_buffer(&project, path!("/project/file1.rs"), cx).await; + + add_bookmarks(&project, &buffer, &[0], cx); + assert_bookmark_rows( + &get_all_bookmarks(&project, cx), + path!("/project/file1.rs"), + &[0], + ); + + // Restoring different bookmarks should replace, not merge + let serialized = build_serialized(&[(path!("/project/file1.rs"), &[2, 3])]); + restore_bookmarks(&project, serialized, cx).await; + + let after = get_all_bookmarks(&project, cx); + assert_eq!(after.len(), 1); + assert_bookmark_rows(&after, path!("/project/file1.rs"), &[2, 3]); + } + + #[gpui::test] + async fn test_serialize_deserialize_round_trip(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "alpha.rs": "fn main() {\n println!(\"hello\");\n return;\n}\n", + "beta.rs": "use std::io;\nfn read() {}\nfn write() {}\n" + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let buffer_alpha = open_buffer(&project, path!("/project/alpha.rs"), cx).await; + let buffer_beta = open_buffer(&project, path!("/project/beta.rs"), cx).await; + + add_bookmarks(&project, &buffer_alpha, &[0, 2, 3], cx); + add_bookmarks(&project, &buffer_beta, &[1], cx); + + // Serialize + let serialized = get_all_bookmarks(&project, cx); + assert_eq!(serialized.len(), 2); + assert_bookmark_rows(&serialized, path!("/project/alpha.rs"), &[0, 2, 3]); + assert_bookmark_rows(&serialized, path!("/project/beta.rs"), &[1]); + + // Clear and restore + clear_bookmarks(&project, cx); + assert!(get_all_bookmarks(&project, cx).is_empty()); + + restore_bookmarks(&project, serialized, cx).await; + + let restored = get_all_bookmarks(&project, cx); + assert_eq!(restored.len(), 2); + assert_bookmark_rows(&restored, path!("/project/alpha.rs"), &[0, 2, 3]); + assert_bookmark_rows(&restored, path!("/project/beta.rs"), &[1]); + } + + #[gpui::test] + async fn test_round_trip_preserves_bookmarks_after_file_edit(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({"file.rs": "aaa\nbbb\nccc\nddd\neee\n"}), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let buffer = open_buffer(&project, path!("/project/file.rs"), cx).await; + + add_bookmarks(&project, &buffer, &[1, 3], cx); + + // Insert a line at the beginning, shifting bookmarks down by 1 + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "new_first_line\n")], None, cx); + }); + + let serialized = get_all_bookmarks(&project, cx); + assert_bookmark_rows(&serialized, path!("/project/file.rs"), &[2, 4]); + + // Clear and restore + clear_bookmarks(&project, cx); + restore_bookmarks(&project, serialized, cx).await; + + let restored = get_all_bookmarks(&project, cx); + assert_bookmark_rows(&restored, path!("/project/file.rs"), &[2, 4]); + } + + #[gpui::test] + async fn test_file_deletion_removes_bookmarks(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "file1.rs": "aaa\nbbb\nccc\n", + "file2.rs": "ddd\neee\nfff\n" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let buffer1 = open_buffer(&project, path!("/project/file1.rs"), cx).await; + let buffer2 = open_buffer(&project, path!("/project/file2.rs"), cx).await; + + add_bookmarks(&project, &buffer1, &[0, 2], cx); + add_bookmarks(&project, &buffer2, &[1], cx); + assert_eq!(get_all_bookmarks(&project, cx).len(), 2); + + // Delete file1.rs + fs.remove_file(path!("/project/file1.rs").as_ref(), Default::default()) + .await + .unwrap(); + cx.executor().run_until_parked(); + + // file1.rs bookmarks should be gone, file2.rs bookmarks preserved + let bookmarks = get_all_bookmarks(&project, cx); + assert_eq!(bookmarks.len(), 1); + assert!(!bookmarks.contains_key(&project_path(path!("/project/file1.rs")))); + assert_bookmark_rows(&bookmarks, path!("/project/file2.rs"), &[1]); + } + + #[gpui::test] + async fn test_deleting_all_bookmarked_files_clears_store(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "file1.rs": "aaa\nbbb\n", + "file2.rs": "ccc\nddd\n" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let buffer1 = open_buffer(&project, path!("/project/file1.rs"), cx).await; + let buffer2 = open_buffer(&project, path!("/project/file2.rs"), cx).await; + + add_bookmarks(&project, &buffer1, &[0], cx); + add_bookmarks(&project, &buffer2, &[1], cx); + assert_eq!(get_all_bookmarks(&project, cx).len(), 2); + + // Delete both files + fs.remove_file(path!("/project/file1.rs").as_ref(), Default::default()) + .await + .unwrap(); + fs.remove_file(path!("/project/file2.rs").as_ref(), Default::default()) + .await + .unwrap(); + cx.executor().run_until_parked(); + + assert!(get_all_bookmarks(&project, cx).is_empty()); + } + + #[gpui::test] + async fn test_file_rename_re_keys_bookmarks(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), json!({"old_name.rs": "aaa\nbbb\nccc\n"})) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let buffer = open_buffer(&project, path!("/project/old_name.rs"), cx).await; + + add_bookmarks(&project, &buffer, &[0, 2], cx); + assert_bookmark_rows( + &get_all_bookmarks(&project, cx), + path!("/project/old_name.rs"), + &[0, 2], + ); + + // Rename the file + fs.rename( + path!("/project/old_name.rs").as_ref(), + path!("/project/new_name.rs").as_ref(), + Default::default(), + ) + .await + .unwrap(); + cx.executor().run_until_parked(); + + let bookmarks = get_all_bookmarks(&project, cx); + assert_eq!(bookmarks.len(), 1); + assert!(!bookmarks.contains_key(&project_path(path!("/project/old_name.rs")))); + assert_bookmark_rows(&bookmarks, path!("/project/new_name.rs"), &[0, 2]); + } + + #[gpui::test] + async fn test_file_rename_preserves_other_bookmarks(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "rename_me.rs": "aaa\nbbb\n", + "untouched.rs": "ccc\nddd\neee\n" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let buffer_rename = open_buffer(&project, path!("/project/rename_me.rs"), cx).await; + let buffer_other = open_buffer(&project, path!("/project/untouched.rs"), cx).await; + + add_bookmarks(&project, &buffer_rename, &[1], cx); + add_bookmarks(&project, &buffer_other, &[0, 2], cx); + + fs.rename( + path!("/project/rename_me.rs").as_ref(), + path!("/project/renamed.rs").as_ref(), + Default::default(), + ) + .await + .unwrap(); + cx.executor().run_until_parked(); + + let bookmarks = get_all_bookmarks(&project, cx); + assert_eq!(bookmarks.len(), 2); + assert_bookmark_rows(&bookmarks, path!("/project/renamed.rs"), &[1]); + assert_bookmark_rows(&bookmarks, path!("/project/untouched.rs"), &[0, 2]); + } +} diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 575c021c3db5c0a8ce1984376e0f00a355977468..fa7454e5e16f17e4bbdebc87d90fa780c996724e 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -1,5 +1,6 @@ #![allow(clippy::format_collect)] +mod bookmark_store; mod color_extractor; mod context_server_store; mod debugger; diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index c83e56577373aa9834f76b3c32488a069844d249..74084421455a9aa4f6032a03810289744a3dd928 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -331,6 +331,7 @@ impl VsCodeSettings { min_line_number_digits: None, runnables: None, breakpoints: None, + bookmarks: None, folds: self.read_enum("editor.showFoldingControls", |s| match s { "always" | "mouseover" => Some(true), "never" => Some(false), diff --git a/crates/settings_content/src/editor.rs b/crates/settings_content/src/editor.rs index 60c2686c084ba428992dfc82a9c18b6c24860a66..d6cdf751fdfd413cf234b59bb7e4c32566e3a125 100644 --- a/crates/settings_content/src/editor.rs +++ b/crates/settings_content/src/editor.rs @@ -451,6 +451,10 @@ pub struct GutterContent { /// /// Default: true pub breakpoints: Option, + /// Whether to show bookmarks in the gutter. + /// + /// Default: true + pub bookmarks: Option, /// Whether to show fold buttons in the gutter. /// /// Default: true diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 0f5679b85f80c418ecc677349689878e7322597a..80f2714960a17497bd7556c1b8df6ce90ddc4fa9 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1893,7 +1893,7 @@ fn editor_page() -> SettingsPage { ] } - fn gutter_section() -> [SettingsPageItem; 8] { + fn gutter_section() -> [SettingsPageItem; 9] { [ SettingsPageItem::SectionHeader("Gutter"), SettingsPageItem::SettingItem(SettingItem { @@ -1978,6 +1978,29 @@ fn editor_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Show Bookmarks", + description: "Show bookmarks in the gutter.", + field: Box::new(SettingField { + json_path: Some("gutter.bookmarks"), + pick: |settings_content| { + settings_content + .editor + .gutter + .as_ref() + .and_then(|gutter| gutter.bookmarks.as_ref()) + }, + write: |settings_content, value| { + settings_content + .editor + .gutter + .get_or_insert_default() + .bookmarks = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Show Folds", description: "Show code folding controls in the gutter.", diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 4dde067c1f74e8eb7570435c587bfba90bea146c..b1617fbc623314a5e27c4c1070b099ceff143600 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -21,6 +21,7 @@ use db::{ }; use gpui::{Axis, Bounds, Task, WindowBounds, WindowId, point, size}; use project::{ + bookmark_store::SerializedBookmark, debugger::breakpoint_store::{BreakpointState, SourceBreakpoint}, trusted_worktrees::{DbTrustedPaths, RemoteHostLocation}, }; @@ -374,6 +375,39 @@ pub async fn write_default_dock_state( Ok(()) } +#[derive(Debug)] +pub struct Bookmark { + pub row: u32, +} + +impl sqlez::bindable::StaticColumnCount for Bookmark { + fn column_count() -> usize { + // row + 1 + } +} + +impl sqlez::bindable::Bind for Bookmark { + fn bind( + &self, + statement: &sqlez::statement::Statement, + start_index: i32, + ) -> anyhow::Result { + statement.bind(&self.row, start_index) + } +} + +impl Column for Bookmark { + fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { + let row = statement + .column_int(start_index) + .with_context(|| format!("Failed to read bookmark at index {start_index}"))? + as u32; + + Ok((Bookmark { row }, start_index + 1)) + } +} + #[derive(Debug)] pub struct Breakpoint { pub position: u32, @@ -980,6 +1014,16 @@ impl Domain for WorkspaceDb { sql!( ALTER TABLE remote_connections ADD COLUMN remote_env TEXT; ), + sql!( + CREATE TABLE bookmarks ( + workspace_id INTEGER NOT NULL, + path TEXT NOT NULL, + row INTEGER NOT NULL, + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ON UPDATE CASCADE + ); + ), ]; // Allow recovering from bad migration that was initially shipped to nightly @@ -1114,6 +1158,7 @@ impl WorkspaceDb { display, docks, session_id: None, + bookmarks: self.bookmarks(workspace_id), breakpoints: self.breakpoints(workspace_id), window_id, user_toolchains: self.user_toolchains(workspace_id, remote_connection_id), @@ -1204,12 +1249,47 @@ impl WorkspaceDb { display, docks, session_id: None, + bookmarks: self.bookmarks(workspace_id), breakpoints: self.breakpoints(workspace_id), window_id, user_toolchains: self.user_toolchains(workspace_id, remote_connection_id), }) } + fn bookmarks(&self, workspace_id: WorkspaceId) -> BTreeMap, Vec> { + let bookmarks: Result> = self + .select_bound(sql! { + SELECT path, row + FROM bookmarks + WHERE workspace_id = ? + ORDER BY path, row + }) + .and_then(|mut prepared_statement| (prepared_statement)(workspace_id)); + + match bookmarks { + Ok(bookmarks) => { + if bookmarks.is_empty() { + log::debug!("Bookmarks are empty after querying database for them"); + } + + let mut map: BTreeMap<_, Vec<_>> = BTreeMap::default(); + + for (path, bookmark) in bookmarks { + let path: Arc = path.into(); + map.entry(path.clone()) + .or_default() + .push(SerializedBookmark(bookmark.row)) + } + + map + } + Err(e) => { + log::error!("Failed to load bookmarks: {}", e); + BTreeMap::default() + } + } + } + fn breakpoints(&self, workspace_id: WorkspaceId) -> BTreeMap, Vec> { let breakpoints: Result> = self .select_bound(sql! { @@ -1351,6 +1431,21 @@ impl WorkspaceDb { DELETE FROM panes WHERE workspace_id = ?1;))?(workspace.id) .context("Clearing old panes")?; + conn.exec_bound( + sql!( + DELETE FROM bookmarks WHERE workspace_id = ?1; + ) + )?(workspace.id).context("Clearing old bookmarks")?; + + for (path, bookmarks) in workspace.bookmarks { + for bookmark in bookmarks { + conn.exec_bound(sql!( + INSERT INTO bookmarks (workspace_id, path, row) + VALUES (?1, ?2, ?3); + ))?((workspace.id, path.as_ref(), bookmark.0)).context("Inserting bookmark")?; + } + } + conn.exec_bound( sql!( DELETE FROM breakpoints WHERE workspace_id = ?1; @@ -2665,6 +2760,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + bookmarks: Default::default(), breakpoints: { let mut map = collections::BTreeMap::default(); map.insert( @@ -2820,6 +2916,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + bookmarks: Default::default(), breakpoints: { let mut map = collections::BTreeMap::default(); map.insert( @@ -2868,6 +2965,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + bookmarks: Default::default(), breakpoints: collections::BTreeMap::default(), session_id: None, window_id: None, @@ -2966,6 +3064,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + bookmarks: Default::default(), breakpoints: Default::default(), session_id: None, window_id: None, @@ -2981,6 +3080,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + bookmarks: Default::default(), breakpoints: Default::default(), session_id: None, window_id: None, @@ -3085,6 +3185,7 @@ mod tests { location: SerializedWorkspaceLocation::Local, center_group, window_bounds: Default::default(), + bookmarks: Default::default(), breakpoints: Default::default(), display: Default::default(), docks: Default::default(), @@ -3119,6 +3220,7 @@ mod tests { location: SerializedWorkspaceLocation::Local, center_group: Default::default(), window_bounds: Default::default(), + bookmarks: Default::default(), breakpoints: Default::default(), display: Default::default(), docks: Default::default(), @@ -3137,6 +3239,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + bookmarks: Default::default(), breakpoints: Default::default(), session_id: None, window_id: Some(2), @@ -3176,6 +3279,7 @@ mod tests { location: SerializedWorkspaceLocation::Local, center_group: Default::default(), window_bounds: Default::default(), + bookmarks: Default::default(), breakpoints: Default::default(), display: Default::default(), docks: Default::default(), @@ -3217,6 +3321,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + bookmarks: Default::default(), breakpoints: Default::default(), session_id: Some("session-id-1".to_owned()), window_id: Some(10), @@ -3232,6 +3337,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + bookmarks: Default::default(), breakpoints: Default::default(), session_id: Some("session-id-1".to_owned()), window_id: Some(20), @@ -3247,6 +3353,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + bookmarks: Default::default(), breakpoints: Default::default(), session_id: Some("session-id-2".to_owned()), window_id: Some(30), @@ -3262,6 +3369,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + bookmarks: Default::default(), breakpoints: Default::default(), session_id: None, window_id: None, @@ -3288,6 +3396,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + bookmarks: Default::default(), breakpoints: Default::default(), session_id: Some("session-id-2".to_owned()), window_id: Some(50), @@ -3300,6 +3409,7 @@ mod tests { location: SerializedWorkspaceLocation::Local, center_group: Default::default(), window_bounds: Default::default(), + bookmarks: Default::default(), breakpoints: Default::default(), display: Default::default(), docks: Default::default(), @@ -3359,6 +3469,7 @@ mod tests { window_bounds: Default::default(), display: Default::default(), docks: Default::default(), + bookmarks: Default::default(), breakpoints: Default::default(), centered_layout: false, session_id: None, @@ -3402,6 +3513,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: Some("one-session".to_owned()), + bookmarks: Default::default(), breakpoints: Default::default(), window_id: Some(window_id), user_toolchains: Default::default(), @@ -3514,6 +3626,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: Some("one-session".to_owned()), + bookmarks: Default::default(), breakpoints: Default::default(), window_id: Some(window_id), user_toolchains: Default::default(), @@ -3873,6 +3986,7 @@ mod tests { window_bounds: None, display: None, docks: Default::default(), + bookmarks: Default::default(), breakpoints: Default::default(), centered_layout: false, session_id: None, @@ -3951,6 +4065,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: Some("test-session".to_owned()), + bookmarks: Default::default(), breakpoints: Default::default(), window_id: Some(*window_id), user_toolchains: Default::default(), @@ -4236,6 +4351,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: Some(session_id.clone()), + bookmarks: Default::default(), breakpoints: Default::default(), window_id: Some(99), user_toolchains: Default::default(), @@ -4331,6 +4447,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: Some(session_id.to_owned()), + bookmarks: Default::default(), breakpoints: Default::default(), window_id: Some(window_id_val), user_toolchains: Default::default(), @@ -4347,6 +4464,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: Some(session_id.to_owned()), + bookmarks: Default::default(), breakpoints: Default::default(), window_id: Some(window_id_val), user_toolchains: Default::default(), @@ -4425,6 +4543,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: Some(session_id.clone()), + bookmarks: Default::default(), breakpoints: Default::default(), window_id: Some(88), user_toolchains: Default::default(), diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index c1bfcefc17a4c2735acaebf1fbae3a6b5852ce90..f0f14cdb591053ef9a048d4fbe4274a5212a9595 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -12,9 +12,11 @@ use db::sqlez::{ }; use gpui::{AsyncWindowContext, Entity, WeakEntity, WindowId}; -use crate::ProjectGroupKey; use language::{Toolchain, ToolchainScope}; -use project::{Project, debugger::breakpoint_store::SourceBreakpoint}; +use project::{ + Project, ProjectGroupKey, bookmark_store::SerializedBookmark, + debugger::breakpoint_store::SourceBreakpoint, +}; use remote::RemoteConnectionOptions; use serde::{Deserialize, Serialize}; use std::{ @@ -134,6 +136,7 @@ pub(crate) struct SerializedWorkspace { pub(crate) display: Option, pub(crate) docks: DockStructure, pub(crate) session_id: Option, + pub(crate) bookmarks: BTreeMap, Vec>, pub(crate) breakpoints: BTreeMap, Vec>, pub(crate) user_toolchains: BTreeMap>, pub(crate) window_id: Option, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index d5544dc1aa6ad6bb2fbbd0777eecc393d0fae22c..79c7734c379b4c433062b0bb351a6b6c17d8f9ae 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -262,6 +262,8 @@ actions!( ActivatePreviousWindow, /// Adds a folder to the current project. AddFolderToProject, + /// Clears all bookmarks in the project. + ClearBookmarks, /// Clears all notifications. ClearAllNotifications, /// Clears all navigation history, including forward/backward navigation, recently opened files, and recently closed tabs. **This action is irreversible**. @@ -6601,6 +6603,13 @@ impl Workspace { match self.workspace_location(cx) { WorkspaceLocation::Location(location, paths) => { + let bookmarks = self.project.update(cx, |project, cx| { + project + .bookmark_store() + .read(cx) + .all_serialized_bookmarks(cx) + }); + let breakpoints = self.project.update(cx, |project, cx| { project .breakpoint_store() @@ -6627,6 +6636,7 @@ impl Workspace { docks, centered_layout: self.centered_layout, session_id: self.session_id.clone(), + bookmarks, breakpoints, window_id: Some(window.window_handle().window_id().as_u64()), user_toolchains, @@ -6836,6 +6846,15 @@ impl Workspace { cx.notify(); })?; + project + .update(cx, |project, cx| { + project.bookmark_store().update(cx, |bookmark_store, cx| { + bookmark_store.load_serialized_bookmarks(serialized_workspace.bookmarks, cx) + }) + }) + .await + .log_err(); + let _ = project .update(cx, |project, cx| { project @@ -7308,6 +7327,7 @@ impl Workspace { .on_action(cx.listener(|workspace, _: &FocusCenterPane, window, cx| { workspace.focus_center_pane(window, cx); })) + .on_action(cx.listener(Workspace::clear_bookmarks)) .on_action(cx.listener(Workspace::cancel)) } @@ -7435,6 +7455,15 @@ impl Workspace { cx.notify(); } + pub fn clear_bookmarks(&mut self, _: &ClearBookmarks, _: &mut Window, cx: &mut Context) { + self.project() + .read(cx) + .bookmark_store() + .update(cx, |bookmark_store, cx| { + bookmark_store.clear_bookmarks(cx); + }); + } + fn adjust_padding(padding: Option) -> f32 { padding .unwrap_or(CenteredPaddingSettings::default().0) diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index d4c0d29ade5c4bd6496509675f9ccb3fc188eb8f..3e07140adcc97d783ce7f04f21b0986f92c6ecc4 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -1148,11 +1148,11 @@ fn run_breakpoint_hover_visual_tests( // // The breakpoint hover requires multiple steps: // 1. Draw to register mouse listeners - // 2. Mouse move to trigger gutter_hovered and create PhantomBreakpointIndicator + // 2. Mouse move to trigger gutter_hovered and create GutterHoverButton // 3. Wait 200ms for is_active to become true // 4. Draw again to render the indicator // - // The gutter_position should be in the gutter area to trigger the phantom breakpoint. + // The gutter_position should be in the gutter area to trigger the gutter hover button. // The button_position should be directly over the breakpoint icon button for tooltip hover. // Based on debug output: button is at origin=(3.12, 66.5) with size=(14, 16) let gutter_position = point(px(30.0), px(85.0));