Files
zed/crates/editor/src/bookmarks.rs
T
guopenghuiandYara 🏳️‍⚧️ 9079f2fa95 Add named bookmark support (#57491)
Adds support for named bookmarks while preserving the existing unnamed
bookmark workflow.

Main changes:
- Keeps `ToggleBookmark` as the quick unnamed bookmark action.
- Adds `ToggleNamedBookmark`, which prompts for a bookmark name when
adding a new bookmark and removes an existing bookmark on the same line,
including unnamed ones.
- Adds `EditBookmark` support for renaming existing bookmarks.
- Persists bookmark names in workspace storage.
- Adds a project bookmarks picker that supports filtering by bookmark
name and relative path, with highlighted matches.
- Updates bookmark storage and tests to carry bookmark names through
serialization, restoration, navigation, and project-level listing.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #ISSUE

Video
toggle:


https://github.com/user-attachments/assets/a725b9cf-ac37-4ebc-85cc-ee820452c043

rename:


https://github.com/user-attachments/assets/a6d0d0c1-511b-4fb7-ae5d-22c55d1a075f


Release Notes:

- Add named bookmark support

---------

Co-authored-by: Yara 🏳️‍⚧️ <git@yara.blue>
2026-06-22 20:47:44 +00:00

403 lines
13 KiB
Rust

use std::ops::Range;
use gpui::Entity;
use language::Buffer;
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::{
EditBookmark, Editor, GoToNextBookmark, GoToPreviousBookmark, MultibufferSelectionMode,
SelectionEffects, ToggleBookmark, ViewBookmarks, scroll::Autoscroll,
};
#[derive(Clone, Debug)]
struct BookmarkTarget {
buffer: Entity<Buffer>,
anchor: Anchor,
buffer_anchor: text::Anchor,
}
impl Editor {
fn bookmark_exists_for_target(
bookmark_store: &Entity<BookmarkStore>,
target: &BookmarkTarget,
cx: &mut Context<Self>,
) -> bool {
bookmark_store.update(cx, |bookmark_store, cx| {
bookmark_store
.find_bookmark(&target.buffer, target.buffer_anchor, cx)
.is_some()
})
}
pub fn set_show_bookmarks(&mut self, show_bookmarks: bool, cx: &mut Context<Self>) {
self.show_bookmarks = Some(show_bookmarks);
cx.notify();
}
pub fn toggle_bookmark(
&mut self,
_: &ToggleBookmark,
window: &mut Window,
cx: &mut Context<Self>,
) {
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::<Point>(&snapshot.display_snapshot);
selections.sort_by_key(|s| s.head());
selections.dedup_by_key(|s| s.head().row);
let mut exist_targets: Vec<BookmarkTarget> = vec![];
let mut absent_targets: Vec<BookmarkTarget> = vec![];
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) {
let target = BookmarkTarget {
buffer,
anchor: multibuffer_anchor,
buffer_anchor,
};
if Self::bookmark_exists_for_target(&bookmark_store, &target, cx) {
exist_targets.push(target);
} else {
absent_targets.push(target);
}
}
}
}
if absent_targets.is_empty() {
// All cursors are on existing bookmarks, remove all bookmarks.
self.toggle_bookmarks(exist_targets, String::new(), cx);
} else {
// Only add new ones and leave existing ones unchanged.
self.add_toggle_bookmark_blocks(absent_targets, bookmark_store, window, cx);
}
cx.notify();
}
pub fn toggle_bookmark_at_row(
&mut self,
row: DisplayRow,
window: &mut Window,
cx: &mut Context<Self>,
) {
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);
self.toggle_bookmark_at_anchor(anchor, window, cx);
}
pub fn toggle_bookmark_at_anchor(
&mut self,
anchor: Anchor,
window: &mut Window,
cx: &mut Context<Self>,
) {
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;
};
let Some(bookmark_store) = self.bookmark_store.clone() else {
return;
};
let target = BookmarkTarget {
buffer,
anchor,
buffer_anchor: position,
};
if Self::bookmark_exists_for_target(&bookmark_store, &target, cx) {
bookmark_store.update(cx, |bookmark_store, cx| {
bookmark_store.toggle_bookmark(target.buffer, position, String::new(), cx);
});
} else {
self.add_toggle_bookmark_blocks(vec![target], bookmark_store, window, cx)
}
cx.notify();
}
pub fn edit_bookmark(&mut self, _: &EditBookmark, window: &mut Window, cx: &mut Context<Self>) {
let snapshot = self.snapshot(window, cx);
let multi_buffer_snapshot = snapshot.buffer_snapshot();
let selection = self
.selections
.newest::<Point>(&snapshot.display_snapshot)
.head();
let anchor = multi_buffer_snapshot.anchor_before(Point::new(selection.row, 0));
self.edit_bookmark_at_anchor(anchor, window, cx);
}
pub fn edit_bookmark_at_anchor(
&mut self,
anchor: Anchor,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(bookmark_store) = self.bookmark_store.clone() else {
return;
};
let Some(project) = self.project() else {
return;
};
let editor_buffer_snapshot = self.buffer.read(cx).snapshot(cx);
let Some((buffer_anchor, _)) = editor_buffer_snapshot.anchor_to_buffer_anchor(anchor)
else {
return;
};
let Some(buffer) = project.read(cx).buffer_for_id(buffer_anchor.buffer_id, cx) else {
return;
};
let Some(label) = bookmark_store.update(cx, |store, cx| {
store
.find_bookmark(&buffer, buffer_anchor, cx)
.map(|bookmark| bookmark.label.clone())
}) else {
return;
};
self.add_edit_bookmark_block(
BookmarkTarget {
anchor,
buffer,
buffer_anchor,
},
&label,
bookmark_store,
window,
cx,
);
}
fn add_edit_bookmark_block(
&mut self,
target: BookmarkTarget,
label: &str,
bookmark_store: Entity<BookmarkStore>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.add_edit_block(
target.anchor,
label,
"Enter bookmark label (Optional)",
Some(Box::new(move |label, _, cx| {
bookmark_store.update(cx, |store, cx| {
store.edit_bookmark(&target.buffer, target.buffer_anchor, label, cx)
});
})),
None,
window,
cx,
);
}
fn add_toggle_bookmark_blocks(
&mut self,
targets: Vec<BookmarkTarget>,
bookmark_store: Entity<BookmarkStore>,
window: &mut Window,
cx: &mut Context<Self>,
) {
for target in targets {
let bookmark_store = bookmark_store.clone();
self.add_edit_block(
target.anchor,
"",
"Enter bookmark label (Optional)",
Some(Box::new(move |label: String, _, cx| {
bookmark_store.update(cx, |store, cx| {
store.toggle_bookmark(target.buffer, target.buffer_anchor, label, cx);
});
})),
None,
window,
cx,
);
}
}
fn toggle_bookmarks(
&mut self,
targets: Vec<BookmarkTarget>,
label: String,
cx: &mut Context<Self>,
) {
if let Some(bookmark_store) = self.bookmark_store.clone() {
bookmark_store.update(cx, |store, cx| {
for target in targets {
store.toggle_bookmark(target.buffer, target.buffer_anchor, label.clone(), cx);
}
});
}
}
pub fn go_to_next_bookmark(
&mut self,
_: &GoToNextBookmark,
window: &mut Window,
cx: &mut Context<Self>,
) {
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>,
) {
self.go_to_bookmark_impl(Direction::Prev, window, cx);
}
fn go_to_bookmark_impl(
&mut self,
direction: Direction,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(project) = &self.project else {
return;
};
let Some(bookmark_store) = &self.bookmark_store else {
return;
};
let selection = self
.selections
.newest::<MultiBufferOffset>(&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<Workspace>,
) {
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<MultiBufferOffset>,
multi_buffer_snapshot: &MultiBufferSnapshot,
project: &Entity<Project>,
bookmark_store: &Entity<BookmarkStore>,
cx: &mut Context<Self>,
) -> Vec<Anchor> {
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::<Vec<_>>()
})
.collect()
}
}