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