editor: Repaint gutter immediately when bookmarks change (#62847)

Closes #61005

The bookmark store notified on every change, but nothing was listening.
Editors only repainted their bookmark icons because the toggle actions
happened to notify the editor directly - so workspace::ClearBookmarks,
which never touches an editor, left stale icons in the gutter until
something else caused a repaint.

Now editors observe the bookmark store, so any change repaints them.
This also fixes toggling a bookmark in one split not updating another
split showing the same file.

Release Notes:

- Fixed bookmarks not disappearing from the gutter immediately when
running `workspace::ClearBookmarks`.
This commit is contained in:
Austin Cummings authored and GitHub committed 2026-08-24 20:01:46 +00:00
1 parent c8518b3317
commit 5a4473f92a
3 files changed
+120 -4

No files matched your search

-4
View File
@@ -116,8 +116,6 @@ impl Editor {
// Only add new (unnamed) bookmarks and leave existing ones unchanged.
self.toggle_bookmarks(absent_targets, String::new(), cx);
}
cx.notify();
}
pub fn toggle_bookmark_at_row(&mut self, row: DisplayRow, cx: &mut Context<Self>) {
@@ -145,8 +143,6 @@ impl Editor {
bookmark_store.update(cx, |bookmark_store, cx| {
bookmark_store.toggle_bookmark(buffer, position, String::new(), cx);
});
cx.notify();
}
pub fn edit_bookmark(&mut self, _: &EditBookmark, window: &mut Window, cx: &mut Context<Self>) {
+6
View File
@@ -2174,6 +2174,12 @@ impl Editor {
_ => {}
},
));
project_subscriptions.push(cx.observe(
&project.read(cx).bookmark_store(),
|_, _, cx| {
cx.notify();
},
));
let git_store = project.read(cx).git_store().clone();
let project = project.clone();
project_subscriptions.push(cx.subscribe(
+114
View File
@@ -33538,6 +33538,120 @@ async fn test_active_bookmarks(cx: &mut TestAppContext) {
assert!(!active.contains(&DisplayRow(8)));
}
#[gpui::test]
async fn test_clearing_bookmark_store_notifies_editor(cx: &mut TestAppContext) {
let mut ctx = BookmarkTestContext::new("Line 0\nLine 1\nLine 2\nLine 3", cx).await;
ctx.toggle_bookmarks_at_rows(&[1, 3]);
let active = ctx
.editor
.update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| {
editor.active_bookmarks(DisplayRow(0)..DisplayRow(4), window, cx)
});
assert!(active.contains(&DisplayRow(1)));
assert!(active.contains(&DisplayRow(3)));
let editor = ctx.editor.clone();
let editor_notified = Rc::new(RefCell::new(false));
let _subscription = ctx.cx.update(|_, cx| {
cx.observe(&editor, {
let editor_notified = editor_notified.clone();
move |_, _| *editor_notified.borrow_mut() = true
})
});
// `workspace::ClearBookmarks` mutates only the store, so the editor's gutter
// repaint relies entirely on its observation of the bookmark store.
ctx.project.update(&mut ctx.cx, |project, cx| {
project.bookmark_store().update(cx, |bookmark_store, cx| {
bookmark_store.clear_bookmarks(cx);
});
});
ctx.cx.run_until_parked();
assert!(
*editor_notified.borrow(),
"clearing the bookmark store should notify the editor so its gutter repaints"
);
let active = ctx
.editor
.update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| {
editor.active_bookmarks(DisplayRow(0)..DisplayRow(4), window, cx)
});
assert!(active.is_empty());
}
#[gpui::test]
async fn test_clear_bookmarks_action_repaints_workspace_editor(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/a"),
json!({ "main.rs": "Line 0\nLine 1\nLine 2\nLine 3" }),
)
.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 mut cx = VisualTestContext::from_window(*window, cx);
let workspace = window
.read_with(&mut cx, |mw, _| mw.workspace().clone())
.unwrap();
let worktree_id = workspace.update_in(&mut cx, |workspace, _, cx| {
workspace.project().update(cx, |project, cx| {
project.worktrees(cx).next().unwrap().read(cx).id()
})
});
let editor = workspace
.update_in(&mut cx, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
cx.run_until_parked();
editor.update_in(&mut cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([Point::new(1, 0)..Point::new(1, 0)])
});
editor.toggle_bookmark(&actions::ToggleBookmark, window, cx);
});
let active = editor.update_in(&mut cx, |editor, window, cx| {
editor.active_bookmarks(DisplayRow(0)..DisplayRow(4), window, cx)
});
assert!(active.contains(&DisplayRow(1)));
// Drain the effects still pending from the toggle above, so that the only
// thing that can notify the editor below is the action dispatch.
cx.run_until_parked();
let editor_notified = Rc::new(RefCell::new(false));
let _subscription = cx.update(|_, cx| {
cx.observe(&editor, {
let editor_notified = editor_notified.clone();
move |_, _| *editor_notified.borrow_mut() = true
})
});
cx.dispatch_action(workspace::ClearBookmarks);
cx.run_until_parked();
assert!(
*editor_notified.borrow(),
"workspace::ClearBookmarks should notify the editor so its gutter repaints"
);
let active = editor.update_in(&mut cx, |editor, window, cx| {
editor.active_bookmarks(DisplayRow(0)..DisplayRow(4), window, cx)
});
assert!(active.is_empty());
}
#[gpui::test]
async fn test_bookmark_not_available_in_single_line_editor(cx: &mut TestAppContext) {
init_test(cx, |_| {});