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));