editor: Auto-refresh Bookmarks tab when bookmarks change (#56363)

[## Context

Fixes a bug where the Bookmarks tab, opened via `editor: view
bookmarks`, showed a stale snapshot and had to be reopened to display
bookmarks added or removed while it was open.

Closes #56268

`view_bookmarks` built a one-time multibuffer and never subscribed to
`BookmarkStore`. The store also calls `cx.notify()` while resolving
serialized bookmarks into anchors, even though that bookkeeping does not
represent a user-visible bookmark change. Observing all notifications
would therefore feed resolution notifications back into the refresh
mechanism and cause redundant cancellation and requeue work. The store
now emits a dedicated `BookmarkStoreEvent::BookmarksChanged` from
bookmark mutation paths, while `resolve_all` deliberately does not emit
it.

Refresh state lives on a `BookmarksView` entity that holds the tab's
multibuffer. Split editors already share that multibuffer, so they share
the view as well. Each store event drives one reconciliation regardless
of how many split editors display it. The view remains alive as long as
any split retains it, so closing the original tab does not stop the
remaining split from refreshing.

Each refresh reconciles excerpts per path instead of rebuilding the
multibuffer: `set_excerpts_for_path` updates files that still contain
bookmarks, while `remove_excerpts` removes files whose final bookmark
was deleted. This follows the per-path reconciliation pattern used by
project diff views.

`view_bookmarks` creates the tab synchronously and populates it
asynchronously, following the existing patterns in
`ProjectDiagnosticsEditor::deploy` and
`ProjectSearchView::existing_or_new_search`. Repeated invocations focus
the existing Bookmarks tab instead of opening duplicates.

Video of the manual test:

[Screencast from 2026-05-11
02-03-38.webm](https://github.com/user-attachments/assets/8ac97245-09ff-4a79-9634-20cb8dfbb7eb)

## How to Review

**`crates/project/src/bookmark_store.rs`**

Adds `BookmarkStoreEvent::BookmarksChanged`, emitted from bookmark
mutation paths such as toggle, label edit, file rename or deletion,
clear, and deserialization, but not from anchor resolution. It also adds
`BookmarkStore::is_empty`.

**`crates/editor/src/bookmarks.rs`**

Adds `BookmarksView`, which holds the multibuffer, store subscription,
and in-flight refresh task. It reconciles excerpts and emits
`BookmarksViewEvent::Populated(ranges)` for per-editor highlights and
cursor placement. It also caches the latest populated ranges and replays
them when an editor attaches, allowing a split created after population
to display bookmark highlights immediately.

**`crates/editor/src/editor.rs`**

Adds the shared `bookmark_view`, a per-editor subscription to its
population events, and `bookmark_initial_selection_pending` for initial
cursor placement. `Editor::clone` attaches the split to the shared view
instead of creating another store subscription and refresh task.

**`crates/editor/src/editor_tests.rs`**

Adds five tests covering automatic addition and removal across multiple
files, removal of a file's path after its final bookmark is deleted,
initial cursor placement, a bookmark change racing initial population,
splitting while population is pending, cached highlights in a
late-attaching split, and continued refreshing after the original tab
releases its view handle.

## Self-Review Checklist

- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the UI/UX checklist
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- Fixed Bookmarks tab not updating when bookmarks are added or removed
while the tab is open](https://github.com/zed-industries/zed/pull/56363)

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
Saber OueslatiandKirill Bulatov authored and GitHub committed 2026-08-27 23:28:05 +00:00
1 parent 05154a14af
commit bb3b7953fe
7 files changed
+1408 -59

No files matched your search

+287 -17
View File
@@ -1,21 +1,33 @@
use std::ops::Range;
use std::{cmp::Reverse, ops::Range};
use gpui::Entity;
use collections::{HashMap, HashSet};
use futures::{StreamExt as _, channel::mpsc};
use gpui::{AppContext as _, Entity, EventEmitter, Subscription, Task};
use language::Buffer;
use multi_buffer::{Anchor, MultiBufferOffset, MultiBufferSnapshot, ToOffset as _};
use project::{Project, bookmark_store::BookmarkStore};
use multi_buffer::{
Anchor, Event as MultiBufferEvent, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot,
PathKey, ToOffset as _, ToPoint as _,
};
use project::{
Project,
bookmark_store::{BookmarkStore, BookmarkStoreEvent},
};
use rope::Point;
use text::Bias;
use theme::ActiveTheme as _;
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, ToggleBookmarkWithLabel, ViewBookmarks, scroll::Autoscroll,
EditBookmark, Editor, GoToNextBookmark, GoToPreviousBookmark, RowHighlightOptions,
SelectionEffects, ToggleBookmark, ToggleBookmarkWithLabel, ViewBookmarks,
multibuffer_context_lines, scroll::Autoscroll,
};
pub(crate) enum BookmarkRowHighlights {}
#[derive(Clone, Debug)]
struct BookmarkTarget {
buffer: Entity<Buffer>,
@@ -338,32 +350,146 @@ impl Editor {
cx: &mut Context<Workspace>,
) {
let bookmark_store = workspace.project().read(cx).bookmark_store();
bookmark_store.update(cx, |store, cx| store.forget_failed_paths(cx));
if Self::activate_bookmarks_tab(workspace, window, cx) {
return;
}
if bookmark_store.read(cx).is_empty() {
return;
}
cx.spawn_in(window, async move |workspace, cx| {
let Some(locations) = BookmarkStore::all_bookmark_locations(bookmark_store, cx)
let Some(locations) = BookmarkStore::all_bookmark_locations(bookmark_store.clone(), cx)
.await
.log_err()
else {
return;
};
if locations.is_empty() {
return;
}
workspace
.update_in(cx, |workspace, window, cx| {
Editor::open_locations_in_multibuffer(
workspace,
locations,
"Bookmarks".into(),
false,
false,
MultibufferSelectionMode::First,
window,
cx,
);
if Self::activate_bookmarks_tab(workspace, window, cx) {
return;
}
let capability = workspace.project().read(cx).capability();
let excerpt_buffer =
cx.new(|_cx| MultiBuffer::new(capability).with_title("Bookmarks".into()));
let bookmarks_tab_state = cx.new(|cx| {
BookmarksTabState::new(
excerpt_buffer.clone(),
bookmark_store,
locations,
cx,
)
});
let first_range = bookmarks_tab_state
.read(cx)
.populated_ranges
.first()
.cloned();
let editor = cx.new(|cx| {
let mut editor = Editor::for_multibuffer(
excerpt_buffer,
Some(workspace.project().clone()),
window,
cx,
);
editor.set_bookmarks_tab_state(bookmarks_tab_state, cx);
if let Some(first_range) = first_range {
editor.change_selections(
SelectionEffects::no_scroll(),
window,
cx,
|s| {
s.clear_disjoint();
s.select_anchor_ranges(std::iter::once(first_range));
},
);
}
editor
});
workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx);
})
.log_err();
})
.detach();
}
fn activate_bookmarks_tab(
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> bool {
let activation_history = workspace.recently_activated_items(cx);
let existing = workspace
.items_of_type::<Editor>(cx)
.filter(|editor| editor.read(cx).bookmarks_tab_state.is_some())
.max_by_key(|editor| {
(
activation_history
.get(&editor.entity_id())
.copied()
.unwrap_or(0),
editor.entity_id(),
)
});
existing.is_some_and(|existing| workspace.activate_item(&existing, true, true, window, cx))
}
pub(super) fn set_bookmarks_tab_state(
&mut self,
bookmarks_tab_state: Entity<BookmarksTabState>,
cx: &mut Context<Self>,
) {
self.bookmarks_tab_subscription = Some(cx.subscribe(
&bookmarks_tab_state,
|editor, _bookmarks_tab_state, event, cx| {
let BookmarksTabEvent::Populated(ranges) = event;
editor.on_bookmarks_populated(ranges, cx);
},
));
let populated_ranges = bookmarks_tab_state.read(cx).populated_ranges.clone();
self.bookmarks_tab_state = Some(bookmarks_tab_state);
self.on_bookmarks_populated(&populated_ranges, cx);
}
fn on_bookmarks_populated(&mut self, ranges: &[Range<Anchor>], cx: &mut Context<Self>) {
self.clear_row_highlights::<BookmarkRowHighlights>();
let snapshot = self.buffer.read(cx).snapshot(cx);
for range in ranges {
if !snapshot.can_resolve(&range.start) {
continue;
}
let row = range.start.to_point(&snapshot).row;
let start = snapshot.anchor_before(Point::new(row, 0));
let max_point = snapshot.max_point();
let end = if row >= max_point.row {
if max_point.column == 0 {
Anchor::Max
} else {
snapshot.anchor_before(max_point)
}
} else {
snapshot.anchor_before(Point::new(row + 1, 0))
};
self.highlight_rows::<BookmarkRowHighlights>(
start..end,
|cx| cx.theme().colors().editor_highlighted_line_background,
RowHighlightOptions::default(),
cx,
);
}
cx.notify();
}
fn bookmarks_in_range(
range: Range<MultiBufferOffset>,
multi_buffer_snapshot: &MultiBufferSnapshot,
@@ -398,3 +524,147 @@ impl Editor {
.collect()
}
}
pub(crate) struct BookmarksTabState {
multibuffer: Entity<MultiBuffer>,
populated_ranges: Vec<Range<Anchor>>,
populated_emit_scheduled: bool,
refresh_tx: mpsc::UnboundedSender<()>,
_refresh_task: Task<()>,
_store_subscription: Subscription,
_multibuffer_subscription: Subscription,
}
pub(crate) enum BookmarksTabEvent {
Populated(Vec<Range<Anchor>>),
}
impl EventEmitter<BookmarksTabEvent> for BookmarksTabState {}
impl BookmarksTabState {
fn new(
multibuffer: Entity<MultiBuffer>,
bookmark_store: Entity<BookmarkStore>,
locations: HashMap<Entity<Buffer>, Vec<Range<Point>>>,
cx: &mut Context<Self>,
) -> Self {
let store_subscription = cx.subscribe(
&bookmark_store,
|this, _bookmark_store, event: &BookmarkStoreEvent, _cx| {
if *event == BookmarkStoreEvent::BookmarksChanged {
this.schedule_refresh();
}
},
);
let multibuffer_subscription = cx.subscribe(
&multibuffer,
|this, _multibuffer, event: &MultiBufferEvent, cx| {
if let MultiBufferEvent::Edited { .. } = event {
this.schedule_populated_emit(cx);
}
},
);
let context_lines = multibuffer_context_lines(cx);
let populated_ranges = multibuffer.update(cx, |multibuffer, cx| {
set_bookmark_excerpts(multibuffer, locations, context_lines, cx)
});
let (refresh_tx, mut refresh_rx) = mpsc::unbounded::<()>();
let refresh_task = cx.spawn(async move |this, cx| {
while refresh_rx.next().await.is_some() {
while refresh_rx.try_recv().is_ok() {}
let Some(locations) =
BookmarkStore::all_bookmark_locations(bookmark_store.clone(), cx)
.await
.log_err()
else {
continue;
};
let applied = this.update(cx, |this, cx| {
let context_lines = multibuffer_context_lines(cx);
this.populated_ranges = this.multibuffer.update(cx, |multibuffer, cx| {
set_bookmark_excerpts(multibuffer, locations, context_lines, cx)
});
this.schedule_populated_emit(cx);
});
if applied.is_err() {
break;
}
}
});
Self {
multibuffer,
populated_ranges,
populated_emit_scheduled: false,
refresh_tx,
_refresh_task: refresh_task,
_store_subscription: store_subscription,
_multibuffer_subscription: multibuffer_subscription,
}
}
fn schedule_refresh(&self) {
self.refresh_tx.unbounded_send(()).ok();
}
fn schedule_populated_emit(&mut self, cx: &mut Context<Self>) {
if self.populated_emit_scheduled {
return;
}
self.populated_emit_scheduled = true;
let this = cx.weak_entity();
cx.defer(move |cx| {
this.update(cx, |this, cx| {
this.populated_emit_scheduled = false;
cx.emit(BookmarksTabEvent::Populated(this.populated_ranges.clone()));
})
.ok();
});
}
}
fn set_bookmark_excerpts(
multibuffer: &mut MultiBuffer,
locations: impl IntoIterator<Item = (Entity<Buffer>, Vec<Range<Point>>)>,
context_line_count: u32,
cx: &mut Context<MultiBuffer>,
) -> Vec<Range<Anchor>> {
let mut stale_paths = multibuffer
.snapshot(cx)
.buffers_with_paths()
.map(|(_, path_key)| path_key.clone())
.collect::<HashSet<_>>();
let mut anchor_ranges = <Vec<Range<Anchor>>>::new();
for (buffer, mut ranges) in locations {
ranges.sort_by_key(|range| (range.start, Reverse(range.end)));
let path_key = PathKey::for_buffer(&buffer, cx);
stale_paths.remove(&path_key);
multibuffer.set_excerpts_for_path(
path_key,
buffer.clone(),
ranges.clone(),
context_line_count,
cx,
);
let snapshot = multibuffer.snapshot(cx);
let buffer_snapshot = buffer.read(cx).snapshot();
anchor_ranges.extend(ranges.into_iter().filter_map(|range| {
let text_range = buffer_snapshot.anchor_range_inside(range);
let start = snapshot.anchor_in_buffer(text_range.start)?;
let end = snapshot.anchor_in_buffer(text_range.end)?;
Some(start..end)
}));
}
for path_key in stale_paths {
multibuffer.remove_excerpts(path_key, cx);
}
let snapshot = multibuffer.snapshot(cx);
anchor_ranges
.retain(|range| snapshot.can_resolve(&range.start) && snapshot.can_resolve(&range.end));
anchor_ranges.sort_by(|a, b| a.start.cmp(&b.start, &snapshot));
anchor_ranges
}
+1 -1
View File
@@ -487,7 +487,7 @@ impl Block {
}
}
fn is_header(&self) -> bool {
pub(crate) fn is_header(&self) -> bool {
match self {
Block::Custom(_) => false,
Block::FoldedBuffer { .. } => true,
+27
View File
@@ -273,6 +273,7 @@ pub use zed_actions::editor::RevealInFileManager;
use zed_actions::editor::{MoveDown, MoveUp};
use crate::{
bookmarks::BookmarksTabState,
code_context_menus::CompletionsMenuSource,
editor_settings::MultiCursorModifier,
hover_links::{find_url, find_url_from_range},
@@ -1130,6 +1131,8 @@ pub struct Editor {
expect_bounds_change: Option<Bounds<Pixels>>,
runnables: RunnableData,
bookmark_store: Option<Entity<BookmarkStore>>,
bookmarks_tab_state: Option<Entity<BookmarksTabState>>,
bookmarks_tab_subscription: Option<Subscription>,
breakpoint_store: Option<Entity<BreakpointStore>>,
gutter_hover_button: (Option<GutterHoverButton>, Option<Task<()>>),
pub(crate) gutter_diff_review_indicator: (Option<PhantomDiffReviewIndicator>, Option<Task<()>>),
@@ -1854,6 +1857,9 @@ impl Editor {
clone.needs_initial_data_update = self.enable_lsp_data;
clone.enable_runnables = self.enable_runnables;
clone.enable_code_lens = self.enable_code_lens;
if let Some(bookmarks_tab_state) = self.bookmarks_tab_state.clone() {
clone.set_bookmarks_tab_state(bookmarks_tab_state, cx);
}
clone
}
@@ -2450,6 +2456,8 @@ impl Editor {
pending_blame_hover_observation: None,
bookmark_store,
bookmarks_tab_state: None,
bookmarks_tab_subscription: None,
breakpoint_store,
gutter_hover_button: (None, None),
gutter_diff_review_indicator: (None, None),
@@ -9227,7 +9235,26 @@ impl Editor {
} else {
end.row().0
};
let mut header_rows = snapshot
.blocks_in_range(
DisplayRow(start_row)..DisplayRow(end_row.saturating_add(1)),
)
.filter(|(_, block)| block.is_header())
.map(|(block_row, block)| {
block_row.0..block_row.0.saturating_add(block.height())
})
.peekable();
for row in start_row..=end_row {
while header_rows
.next_if(|header_range| header_range.end <= row)
.is_some()
{}
if header_rows
.peek()
.is_some_and(|header_range| header_range.contains(&row))
{
continue;
}
let used_index =
used_highlight_orders.entry(row).or_insert(highlight.index);
if highlight.index >= *used_index {
+929 -2
View File
@@ -45,7 +45,7 @@ use parking_lot::Mutex;
use pretty_assertions::{assert_eq, assert_ne};
use project::{
FakeFs, Project, ProjectPath,
bookmark_store::SerializedBookmark,
bookmark_store::{BookmarkStore, BookmarkStoreEvent, SerializedBookmark},
debugger::breakpoint_store::{BreakpointState, SourceBreakpoint},
project_settings::LspSettings,
trusted_worktrees::{PathTrust, TrustedWorktrees},
@@ -74,7 +74,7 @@ use util::{
};
use workspace::{
CloseActiveItem, CloseAllItems, CloseOtherItems, MultiWorkspace, NavigationEntry, OpenOptions,
ToolbarItemLocation, ViewId,
Pane, SplitDirection, ToolbarItemLocation, ViewId, Workspace,
item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions},
register_project_item,
};
@@ -34185,6 +34185,707 @@ async fn test_empty_rename_is_no_op(cx: &mut TestAppContext) {
}
}
#[gpui::test]
async fn test_bookmarks_tab_auto_refresh(cx: &mut TestAppContext) {
let (workspace, _pane, project, editor, mut cx) = init_bookmarks_tab_test(
cx,
json!({
"main.rs": "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n",
"other.rs": "other0\nother1\nother2\nother3\n",
}),
)
.await;
toggle_bookmark_on_row(&editor, 0, &mut cx);
cx.run_until_parked();
let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx);
assert_eq!(
excerpt_count(&bookmarks_editor, &mut cx),
1,
"bookmarks editor should have 1 excerpt after first bookmark"
);
toggle_bookmark_on_row(&editor, 7, &mut cx);
cx.run_until_parked();
assert_eq!(
excerpt_count(&bookmarks_editor, &mut cx),
2,
"bookmarks editor should auto-refresh to 2 excerpts after second bookmark"
);
toggle_bookmark_on_row(&editor, 7, &mut cx);
cx.run_until_parked();
assert_eq!(
excerpt_count(&bookmarks_editor, &mut cx),
1,
"bookmarks editor should auto-refresh back to 1 excerpt after removing a bookmark"
);
assert_eq!(
cursor_line_text(&bookmarks_editor, &mut cx),
"line0",
"refreshing must keep the surviving excerpt intact, preserving selections in it"
);
let other_editor = open_bookmarks_test_editor(&workspace, &project, "other.rs", &mut cx).await;
toggle_bookmark_on_row(&other_editor, 1, &mut cx);
cx.run_until_parked();
assert_eq!(
bookmarked_paths(&bookmarks_editor, &mut cx),
vec!["main.rs", "other.rs"],
"bookmarks editor should hold one path per bookmarked file"
);
toggle_bookmark_on_row(&editor, 0, &mut cx);
cx.run_until_parked();
assert_eq!(
bookmarked_paths(&bookmarks_editor, &mut cx),
vec!["other.rs"],
"unbookmarking a file's last line should drop its path from the bookmarks editor"
);
}
#[gpui::test]
async fn test_bookmarks_tab_selects_first_bookmark_despite_concurrent_change(
cx: &mut TestAppContext,
) {
let (workspace, _pane, _project, editor, mut cx) = init_bookmarks_tab_test(
cx,
json!({
"main.rs": "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n",
}),
)
.await;
toggle_bookmark_on_row(&editor, 5, &mut cx);
cx.run_until_parked();
open_bookmarks_tab(&workspace, &mut cx);
toggle_bookmark_on_row(&editor, 9, &mut cx);
cx.run_until_parked();
let bookmarks_editor = find_bookmarks_editor(&workspace, &mut cx)
.expect("bookmarks editor should exist after view_bookmarks");
assert_eq!(
cursor_line_text(&bookmarks_editor, &mut cx),
"line5",
"a bookmark change racing the tab creation must not lose the initial selection"
);
}
#[gpui::test]
async fn test_view_bookmarks_without_resolvable_bookmarks_opens_no_tab(cx: &mut TestAppContext) {
let (workspace, _pane, project, _editor, mut cx) = init_bookmarks_tab_test(
cx,
json!({
"main.rs": "line0\nline1\n",
"other.rs": "other0\n",
"third.rs": "third0\n",
}),
)
.await;
let bookmark_store = cx.update(|_window, cx| project.read(cx).bookmark_store());
load_test_bookmarks(
&bookmark_store,
vec![(bookmark_path(path!("/a/third.rs")), vec![99])],
&mut cx,
)
.await;
let worktree_id = project.update(&mut cx, |project, cx| {
project
.worktrees(cx)
.next()
.expect("project should have at least one worktree")
.read(cx)
.id()
});
let other_buffer = project
.update(&mut cx, |project, cx| {
project.open_buffer((worktree_id, rel_path("other.rs")), cx)
})
.await
.expect("other.rs buffer should open");
let probe_anchor = other_buffer.update(&mut cx, |buffer, _| {
buffer.text_snapshot().anchor_before(Point::new(0, 0))
});
bookmark_store.update(&mut cx, |store, cx| {
assert_eq!(
store
.find_bookmark(&other_buffer, probe_anchor, cx)
.map(|bookmark| bookmark.label.clone()),
None,
"a probe on a bookmark-less file should find nothing"
);
});
open_bookmarks_tab(&workspace, &mut cx);
cx.run_until_parked();
assert_eq!(
find_bookmarks_editor(&workspace, &mut cx),
None,
"a store whose bookmarks all fail to resolve must not open an empty tab, even after a find_bookmark probe"
);
}
#[gpui::test]
async fn test_bookmarks_tab_survives_split(cx: &mut TestAppContext) {
let (workspace, pane, _project, editor, mut cx) = init_bookmarks_tab_test(
cx,
json!({
"main.rs": "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n",
}),
)
.await;
toggle_bookmark_on_row(&editor, 0, &mut cx);
cx.run_until_parked();
let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx);
let split_editor = bookmarks_editor.update_in(&mut cx, |editor, window, cx| {
cx.new(|cx| editor.clone(window, cx))
});
assert!(
split_editor.read_with(&cx, |editor, _| editor.bookmarks_tab_state.is_some()),
"split clone should share the bookmarks tab state"
);
assert!(
split_editor.read_with(&cx, |editor, cx| editor
.highlighted_rows::<crate::bookmarks::BookmarkRowHighlights>(cx)
.next()
.is_some()),
"split clone should carry over the bookmark line highlights"
);
toggle_bookmark_on_row(&editor, 7, &mut cx);
close_bookmarks_editor(&pane, bookmarks_editor, &mut cx);
assert_eq!(
excerpt_count(&split_editor, &mut cx),
2,
"split should refresh itself when the original tab closes with a refresh in flight"
);
toggle_bookmark_on_row(&editor, 7, &mut cx);
cx.run_until_parked();
assert_eq!(
excerpt_count(&split_editor, &mut cx),
1,
"split clone's subscription should keep refreshing after the original tab closes"
);
}
#[gpui::test]
async fn test_bookmark_resolution_drop_emits_store_event(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/a"),
json!({
"main.rs": "line0\nline1\n",
}),
)
.await;
let project = Project::test(fs, [path!("/a").as_ref()], cx).await;
let worktree_id = project.read_with(cx, |project, cx| {
project
.worktrees(cx)
.next()
.expect("project should have at least one worktree")
.read(cx)
.id()
});
let buffer = project
.update(cx, |project, cx| {
project.open_buffer((worktree_id, rel_path("main.rs")), cx)
})
.await
.expect("main.rs buffer should open");
let bookmark_store = project.read_with(cx, |project, _| project.bookmark_store());
let abs_path = cx
.update(|cx| BookmarkStore::abs_path_from_buffer(&buffer, cx))
.expect("buffer should have an absolute path");
bookmark_store
.update(cx, |store, cx| {
store.load_serialized_bookmarks(
BTreeMap::from([(abs_path, serialized_rows(&[0, 99]))]),
cx,
)
})
.await
.expect("loading serialized bookmarks should succeed");
let events = Rc::new(RefCell::new(Vec::<BookmarkStoreEvent>::new()));
let _subscription = cx.update({
let events = events.clone();
|cx| {
cx.subscribe(&bookmark_store, move |_, event: &BookmarkStoreEvent, _| {
events.borrow_mut().push(event.clone());
})
}
});
let (buffer_id, snapshot) =
buffer.read_with(cx, |buffer, _| (buffer.remote_id(), buffer.text_snapshot()));
let resolved_count = bookmark_store.update(cx, |store, cx| {
store
.bookmarks_for_buffer(
buffer.clone(),
text::Anchor::min_max_range_for_buffer(buffer_id),
&snapshot,
cx,
)
.len()
});
assert_eq!(
resolved_count, 1,
"only the in-range bookmark should survive resolution"
);
assert_eq!(
*events.borrow(),
vec![BookmarkStoreEvent::BookmarksChanged],
"dropping the out-of-range bookmark during resolution should emit BookmarksChanged"
);
let resolved_count = bookmark_store.update(cx, |store, cx| {
store
.bookmarks_for_buffer(
buffer.clone(),
text::Anchor::min_max_range_for_buffer(buffer_id),
&snapshot,
cx,
)
.len()
});
assert_eq!(
resolved_count, 1,
"an already-resolved entry should keep its bookmark"
);
assert_eq!(
*events.borrow(),
vec![BookmarkStoreEvent::BookmarksChanged],
"resolution of an already-loaded entry should not emit again"
);
bookmark_store.update(cx, |store, cx| {
store.edit_bookmark(
&buffer,
snapshot.anchor_before(Point::new(0, 0)),
"renamed".to_string(),
cx,
)
});
assert_eq!(
*events.borrow(),
vec![
BookmarkStoreEvent::BookmarksChanged,
BookmarkStoreEvent::LabelChanged
],
"editing a bookmark's label should emit LabelChanged rather than a set change"
);
}
#[gpui::test]
async fn test_bookmarks_tab_highlights_bookmark_on_trailing_empty_line(cx: &mut TestAppContext) {
let (workspace, _pane, _project, editor, mut cx) = init_bookmarks_tab_test(
cx,
json!({
"main.rs": "line0\nline1\n",
}),
)
.await;
toggle_bookmark_on_row(&editor, 2, &mut cx);
cx.run_until_parked();
let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx);
assert_eq!(
highlighted_display_rows_of(&bookmarks_editor, &mut cx),
vec![last_display_row(&bookmarks_editor, &mut cx)],
"a bookmark on the trailing empty line should still highlight its row"
);
}
#[gpui::test]
async fn test_bookmarks_tab_keeps_unopenable_paths(cx: &mut TestAppContext) {
let (workspace, _pane, project, editor, mut cx) = init_bookmarks_tab_test(
cx,
json!({
"main.rs": "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n",
"unopenable.rs": {},
}),
)
.await;
let bookmark_store = cx.update(|_window, cx| project.read(cx).bookmark_store());
let unopenable_path = bookmark_path(path!("/a/unopenable.rs"));
load_test_bookmarks(
&bookmark_store,
vec![
(bookmark_path(path!("/a/main.rs")), vec![0]),
(Arc::clone(&unopenable_path), vec![0]),
],
&mut cx,
)
.await;
let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx);
assert_eq!(
excerpt_count(&bookmarks_editor, &mut cx),
1,
"only the openable file should produce an excerpt"
);
assert_eq!(
failed_paths(&bookmark_store, &mut cx),
vec![Arc::clone(&unopenable_path)],
"the unopenable path should be recorded to avoid retrying it on every refresh"
);
toggle_bookmark_on_row(&editor, 7, &mut cx);
cx.run_until_parked();
assert_eq!(
excerpt_count(&bookmarks_editor, &mut cx),
2,
"refreshes should keep working while an unopenable path is recorded"
);
let serialized = bookmark_store.read_with(&cx, |store, cx| store.all_serialized_bookmarks(cx));
assert_eq!(
serialized.get(&unopenable_path).map(|rows| rows.len()),
Some(1),
"an unopenable path must keep its serialized bookmark instead of losing it"
);
}
#[gpui::test]
async fn test_view_bookmarks_activates_most_recent_tab(cx: &mut TestAppContext) {
let (workspace, pane, _project, editor, mut cx) = init_bookmarks_tab_test(
cx,
json!({
"main.rs": "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n",
}),
)
.await;
toggle_bookmark_on_row(&editor, 0, &mut cx);
cx.run_until_parked();
let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx);
let split_editor = bookmarks_editor.update_in(&mut cx, |editor, window, cx| {
cx.new(|cx| editor.clone(window, cx))
});
let second_pane = workspace.update_in(&mut cx, |workspace, window, cx| {
workspace.split_pane(pane.clone(), SplitDirection::Right, window, cx)
});
second_pane.update_in(&mut cx, |pane, window, cx| {
pane.add_item(Box::new(split_editor.clone()), true, true, None, window, cx);
});
cx.run_until_parked();
open_bookmarks_tab(&workspace, &mut cx);
cx.run_until_parked();
let active_editor = workspace.update(&mut cx, |workspace, cx| {
workspace.active_item_as::<Editor>(cx)
});
assert_eq!(
active_editor,
Some(split_editor),
"view_bookmarks should activate the most recently used bookmarks tab"
);
let bookmark_editor_count = workspace.update(&mut cx, |workspace, cx| {
workspace
.items_of_type::<Editor>(cx)
.filter(|editor| editor.read(cx).bookmarks_tab_state.is_some())
.count()
});
assert_eq!(
bookmark_editor_count, 2,
"activating an existing tab must not create another one"
);
}
#[gpui::test]
async fn test_bookmarks_tab_highlight_does_not_grow_past_last_row(cx: &mut TestAppContext) {
let (workspace, _pane, _project, editor, mut cx) = init_bookmarks_tab_test(
cx,
json!({
"main.rs": "line0\nline1\nline2",
}),
)
.await;
toggle_bookmark_on_row(&editor, 2, &mut cx);
cx.run_until_parked();
let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx);
editor.update_in(&mut cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([Point::new(2, 5)..Point::new(2, 5)])
});
editor.insert("\nline3", window, cx);
});
cx.run_until_parked();
assert_eq!(
highlighted_display_rows_of(&bookmarks_editor, &mut cx),
vec![display_row_for(
&bookmarks_editor,
Point::new(2, 0),
&mut cx
)],
"appending lines after a last-row bookmark must not stretch its highlight over the new rows"
);
}
#[gpui::test]
async fn test_bookmarks_tab_trailing_line_highlight_does_not_grow_on_edit(cx: &mut TestAppContext) {
let (workspace, _pane, _project, editor, mut cx) = init_bookmarks_tab_test(
cx,
json!({
"main.rs": "line0\nline1\n",
}),
)
.await;
toggle_bookmark_on_row(&editor, 2, &mut cx);
cx.run_until_parked();
let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx);
bookmarks_editor.update_in(&mut cx, |editor, window, cx| {
let max_point = editor.buffer.read(cx).snapshot(cx).max_point();
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([max_point..max_point])
});
editor.insert("new0\nnew1", window, cx);
});
cx.run_until_parked();
assert_eq!(
highlighted_display_rows_of(&bookmarks_editor, &mut cx),
vec![last_display_row(&bookmarks_editor, &mut cx)],
"typing at a trailing-line bookmark must move its single-row highlight, not stretch it"
);
}
#[gpui::test]
async fn test_bookmarks_tab_last_line_highlight_does_not_cover_next_file_header(
cx: &mut TestAppContext,
) {
let (workspace, _pane, project, editor, mut cx) = init_bookmarks_tab_test(
cx,
json!({
"main.rs": "line0\nline1\nline2",
"other.rs": "other0\nother1\n",
}),
)
.await;
toggle_bookmark_on_row(&editor, 2, &mut cx);
let other_editor = open_bookmarks_test_editor(&workspace, &project, "other.rs", &mut cx).await;
toggle_bookmark_on_row(&other_editor, 0, &mut cx);
cx.run_until_parked();
let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx);
assert_eq!(
highlighted_display_rows_of(&bookmarks_editor, &mut cx),
vec![
display_row_for(&bookmarks_editor, Point::new(2, 0), &mut cx),
display_row_for(&bookmarks_editor, Point::new(3, 0), &mut cx),
],
"a bookmark on a file's last line must not highlight the next file's header rows"
);
}
#[gpui::test]
async fn test_bookmarks_tab_trailing_line_highlight_does_not_cover_next_file_header(
cx: &mut TestAppContext,
) {
let (workspace, _pane, project, editor, mut cx) = init_bookmarks_tab_test(
cx,
json!({
"main.rs": "line0\nline1\n",
"other.rs": "other0\n",
}),
)
.await;
toggle_bookmark_on_row(&editor, 2, &mut cx);
let other_editor = open_bookmarks_test_editor(&workspace, &project, "other.rs", &mut cx).await;
toggle_bookmark_on_row(&other_editor, 0, &mut cx);
cx.run_until_parked();
let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx);
assert_eq!(
highlighted_display_rows_of(&bookmarks_editor, &mut cx),
vec![
display_row_for(&bookmarks_editor, Point::new(2, 0), &mut cx),
display_row_for(&bookmarks_editor, Point::new(3, 0), &mut cx),
],
"a bookmark on a trailing empty line must not highlight the next file's header rows"
);
}
#[gpui::test]
async fn test_worktree_add_retries_only_contained_failed_paths(cx: &mut TestAppContext) {
let (workspace, _pane, project, _editor, mut cx) = init_bookmarks_tab_test(
cx,
json!({
"main.rs": "line0\nline1\n",
}),
)
.await;
let bookmark_store = cx.update(|_window, cx| project.read(cx).bookmark_store());
let elsewhere_path = bookmark_path(path!("/elsewhere/file.rs"));
load_test_bookmarks(
&bookmark_store,
vec![
(bookmark_path(path!("/a/main.rs")), vec![0]),
(Arc::clone(&elsewhere_path), vec![0]),
],
&mut cx,
)
.await;
let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx);
assert_eq!(
excerpt_count(&bookmarks_editor, &mut cx),
1,
"the path outside all worktrees should not produce an excerpt yet"
);
assert_eq!(
failed_paths(&bookmark_store, &mut cx),
vec![Arc::clone(&elsewhere_path)],
"the path outside all worktrees should be recorded as failed to open"
);
let events = Rc::new(RefCell::new(Vec::<BookmarkStoreEvent>::new()));
let _subscription = cx.update({
let events = events.clone();
|_window, cx| {
cx.subscribe(&bookmark_store, move |_, event: &BookmarkStoreEvent, _| {
events.borrow_mut().push(event.clone());
})
}
});
let fs = project.read_with(&cx, |project, _| project.fs().clone());
fs.as_fake()
.insert_tree(path!("/unrelated"), json!({ "other.rs": "other0\n" }))
.await;
project
.update(&mut cx, |project, cx| {
project.create_worktree(path!("/unrelated"), true, cx)
})
.await
.expect("creating the unrelated worktree should succeed");
cx.run_until_parked();
assert_eq!(
*events.borrow(),
Vec::<BookmarkStoreEvent>::new(),
"a worktree containing no failed paths must not trigger a retry"
);
assert_eq!(
failed_paths(&bookmark_store, &mut cx),
vec![Arc::clone(&elsewhere_path)],
"an unrelated worktree must not clear the failed path cache"
);
fs.as_fake()
.insert_tree(path!("/elsewhere"), json!({ "file.rs": "else0\n" }))
.await;
project
.update(&mut cx, |project, cx| {
project.create_worktree(path!("/elsewhere"), true, cx)
})
.await
.expect("creating the containing worktree should succeed");
cx.run_until_parked();
assert_eq!(
failed_paths(&bookmark_store, &mut cx),
Vec::new(),
"adding a worktree containing a failed path must retry it"
);
assert_eq!(
excerpt_count(&bookmarks_editor, &mut cx),
2,
"the bookmarks tab should refresh and show the failed path's bookmark"
);
}
#[gpui::test]
async fn test_bookmarks_tab_retries_failed_path_when_file_appears(cx: &mut TestAppContext) {
let (workspace, _pane, project, _editor, mut cx) = init_bookmarks_tab_test(
cx,
json!({
"main.rs": "line0\nline1\n",
"late.rs": {},
}),
)
.await;
let bookmark_store = cx.update(|_window, cx| project.read(cx).bookmark_store());
let late_path = bookmark_path(path!("/a/late.rs"));
load_test_bookmarks(
&bookmark_store,
vec![
(bookmark_path(path!("/a/main.rs")), vec![0]),
(Arc::clone(&late_path), vec![0]),
],
&mut cx,
)
.await;
let bookmarks_editor = open_and_find_bookmarks_tab(&workspace, &mut cx);
assert_eq!(
excerpt_count(&bookmarks_editor, &mut cx),
1,
"the missing file should not produce an excerpt yet"
);
assert_eq!(
failed_paths(&bookmark_store, &mut cx),
vec![Arc::clone(&late_path)],
"the missing path should be recorded as failed to open"
);
let fs = project.read_with(&cx, |project, _| project.fs().clone());
fs.remove_dir(
std::path::Path::new(path!("/a/late.rs")),
fs::RemoveOptions {
recursive: true,
ignore_if_not_exists: false,
},
)
.await
.expect("removing the placeholder directory should succeed");
fs.as_fake()
.insert_file(path!("/a/late.rs"), "late0\n".as_bytes().to_vec())
.await;
cx.run_until_parked();
assert_eq!(
failed_paths(&bookmark_store, &mut cx),
Vec::new(),
"a failed path must be retried once its file appears on disk"
);
assert_eq!(
excerpt_count(&bookmarks_editor, &mut cx),
2,
"the bookmarks tab should refresh and show the late file's bookmark"
);
}
#[gpui::test]
async fn test_rename_with_duplicate_edits(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -44375,6 +45076,232 @@ async fn assert_range_format_merge(
assert!(!cx.read(|cx| editor.is_dirty(cx)));
}
async fn init_bookmarks_tab_test(
cx: &mut TestAppContext,
files: serde_json::Value,
) -> (
Entity<Workspace>,
Entity<Pane>,
Entity<Project>,
Entity<Editor>,
VisualTestContext,
) {
init_test(cx, |_| {});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/a"), files).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, |multi_workspace, _| multi_workspace.workspace().clone())
.expect("window should still be open");
let mut cx = VisualTestContext::from_window(*window, cx);
let editor = open_bookmarks_test_editor(&workspace, &project, "main.rs", &mut cx).await;
let pane = workspace.update(&mut cx, |workspace, _| workspace.active_pane().clone());
(workspace, pane, project, editor, cx)
}
async fn open_bookmarks_test_editor(
workspace: &Entity<Workspace>,
project: &Entity<Project>,
path: &str,
cx: &mut VisualTestContext,
) -> Entity<Editor> {
let worktree_id = project.update(cx, |project, cx| {
project
.worktrees(cx)
.next()
.expect("project should have at least one worktree")
.read(cx)
.id()
});
let buffer = project
.update(cx, |project, cx| {
project.open_buffer((worktree_id, rel_path(path)), cx)
})
.await
.expect("buffer should open");
let editor = workspace.update_in(cx, |_workspace, window, cx| {
let multibuffer = MultiBuffer::build_from_buffer(buffer, cx);
cx.new(|cx| {
Editor::new(
EditorMode::full(),
multibuffer,
Some(project.clone()),
window,
cx,
)
})
});
let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
pane.update_in(cx, |pane, window, cx| {
pane.add_item(Box::new(editor.clone()), true, true, None, window, cx);
});
editor
}
fn open_bookmarks_tab(workspace: &Entity<Workspace>, cx: &mut VisualTestContext) {
workspace.update_in(cx, |workspace, window, cx| {
Editor::view_bookmarks(workspace, &actions::ViewBookmarks, window, cx);
});
}
fn toggle_bookmark_on_row(editor: &Entity<Editor>, row: u32, cx: &mut VisualTestContext) {
editor.update_in(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([Point::new(row, 0)..Point::new(row, 0)])
});
editor.toggle_bookmark(&actions::ToggleBookmark, window, cx);
});
}
fn find_bookmarks_editor(
workspace: &Entity<Workspace>,
cx: &mut VisualTestContext,
) -> Option<Entity<Editor>> {
workspace.update(cx, |workspace, cx| {
workspace
.items_of_type::<Editor>(cx)
.find(|editor| editor.read(cx).bookmarks_tab_state.is_some())
})
}
fn excerpt_count(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> usize {
editor.update(cx, |editor, cx| {
editor.buffer.read(cx).snapshot(cx).excerpts().count()
})
}
fn bookmarked_paths(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> Vec<String> {
editor.update(cx, |editor, cx| {
let mut paths = editor
.buffer
.read(cx)
.snapshot(cx)
.buffers_with_paths()
.map(|(_, path_key)| path_key.path.as_unix_str().to_string())
.collect::<Vec<_>>();
paths.sort();
paths
})
}
fn cursor_line_text(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> String {
editor.update(cx, |editor, cx| {
let snapshot = editor.buffer.read(cx).snapshot(cx);
let head = editor
.selections
.newest::<Point>(&editor.display_snapshot(cx))
.head();
snapshot
.text_for_range(Point::new(head.row, 0)..Point::new(head.row + 1, 0))
.collect::<String>()
.trim_end()
.to_string()
})
}
fn close_bookmarks_editor(
pane: &Entity<Pane>,
bookmarks_editor: Entity<Editor>,
cx: &mut VisualTestContext,
) {
let weak_bookmarks_editor = bookmarks_editor.downgrade();
pane.update_in(cx, |pane, window, cx| {
pane.remove_item(weak_bookmarks_editor.entity_id(), false, false, window, cx);
});
drop(bookmarks_editor);
cx.executor()
.advance_clock(workspace::SERIALIZATION_THROTTLE_TIME);
cx.run_until_parked();
cx.update(|_window, _cx| {});
weak_bookmarks_editor.assert_released();
}
fn open_and_find_bookmarks_tab(
workspace: &Entity<Workspace>,
cx: &mut VisualTestContext,
) -> Entity<Editor> {
open_bookmarks_tab(workspace, cx);
cx.run_until_parked();
find_bookmarks_editor(workspace, cx)
.expect("bookmarks editor should exist after view_bookmarks")
}
fn bookmark_path(path: &str) -> Arc<std::path::Path> {
Arc::from(std::path::Path::new(path))
}
fn serialized_rows(rows: &[u32]) -> Vec<SerializedBookmark> {
rows.iter()
.map(|&row| SerializedBookmark {
row,
label: String::new(),
})
.collect()
}
async fn load_test_bookmarks(
bookmark_store: &Entity<BookmarkStore>,
entries: Vec<(Arc<std::path::Path>, Vec<u32>)>,
cx: &mut VisualTestContext,
) {
let entries = entries
.into_iter()
.map(|(path, rows)| (path, serialized_rows(&rows)))
.collect::<BTreeMap<_, _>>();
bookmark_store
.update(cx, |store, cx| store.load_serialized_bookmarks(entries, cx))
.await
.expect("loading serialized bookmarks should succeed");
}
fn failed_paths(
bookmark_store: &Entity<BookmarkStore>,
cx: &mut VisualTestContext,
) -> Vec<Arc<std::path::Path>> {
bookmark_store.read_with(cx, |store, _| {
let mut paths = store
.paths_failed_to_open()
.iter()
.cloned()
.collect::<Vec<_>>();
paths.sort();
paths
})
}
fn highlighted_display_rows_of(
editor: &Entity<Editor>,
cx: &mut VisualTestContext,
) -> Vec<DisplayRow> {
editor.update_in(cx, |editor, window, cx| {
editor
.highlighted_display_rows(window, cx)
.into_keys()
.collect()
})
}
fn display_row_for(
editor: &Entity<Editor>,
point: Point,
cx: &mut VisualTestContext,
) -> DisplayRow {
editor.update(cx, |editor, cx| {
editor
.display_snapshot(cx)
.point_to_display_point(point, text::Bias::Left)
.row()
})
}
fn last_display_row(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> DisplayRow {
let last_point = editor.update(cx, |editor, cx| {
editor.buffer.read(cx).snapshot(cx).max_point()
});
display_row_for(editor, last_point, cx)
}
#[gpui::test]
async fn test_scroll_range_hold_freezes_before_first_settled_frame(cx: &mut TestAppContext) {
init_test(cx, |_| {});
+3 -1
View File
@@ -2207,7 +2207,9 @@ impl Editor {
let excerpt_buffer = cx.new(|cx| {
let key = &mut key.1;
let mut multibuffer = MultiBuffer::new(capability);
for (buffer, mut ranges_for_buffer) in locations {
let mut sorted_locations = locations.into_iter().collect::<Vec<_>>();
sorted_locations.sort_by_key(|(buffer, _)| buffer.read(cx).remote_id());
for (buffer, mut ranges_for_buffer) in sorted_locations {
ranges_for_buffer.sort_by_key(|range| (range.start, Reverse(range.end)));
key.push((buffer.read(cx).remote_id(), ranges_for_buffer.clone()));
multibuffer.set_excerpts_for_path(