From f6bfa0915fe22bca6ab725013dca01aced30a3b1 Mon Sep 17 00:00:00 2001 From: guopenghui Date: Tue, 23 Jun 2026 04:47:44 +0800 Subject: [PATCH] Add named bookmark support (#57491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 🏳️‍⚧️ --- crates/editor/src/actions.rs | 2 + crates/editor/src/bookmarks.rs | 221 ++++++++++++-- crates/editor/src/editor.rs | 213 +++++++++----- crates/editor/src/editor_tests.rs | 274 +++++++++++++++--- crates/editor/src/element.rs | 1 + crates/project/src/bookmark_store.rs | 136 ++++++--- .../tests/integration/bookmark_store.rs | 75 ++++- crates/workspace/src/persistence.rs | 28 +- 8 files changed, 758 insertions(+), 192 deletions(-) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 2f7ee8aea56..270edbed1d1 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -856,6 +856,8 @@ actions!( Backtab, /// Toggles a bookmark at the current line. ToggleBookmark, + /// Edits the bookmark's label at the current line. + EditBookmark, /// 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 index fe047e7fa18..e2d1c570d4e 100644 --- a/crates/editor/src/bookmarks.rs +++ b/crates/editor/src/bookmarks.rs @@ -1,6 +1,7 @@ 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; @@ -11,11 +12,30 @@ use workspace::{Workspace, searchable::Direction}; use crate::display_map::DisplayRow; use crate::{ - Editor, GoToNextBookmark, GoToPreviousBookmark, MultibufferSelectionMode, SelectionEffects, - ToggleBookmark, ViewBookmarks, scroll::Autoscroll, + EditBookmark, Editor, GoToNextBookmark, GoToPreviousBookmark, MultibufferSelectionMode, + SelectionEffects, ToggleBookmark, ViewBookmarks, scroll::Autoscroll, }; +#[derive(Clone, Debug)] +struct BookmarkTarget { + buffer: Entity, + anchor: Anchor, + buffer_anchor: text::Anchor, +} + impl Editor { + fn bookmark_exists_for_target( + bookmark_store: &Entity, + target: &BookmarkTarget, + cx: &mut Context, + ) -> 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.show_bookmarks = Some(show_bookmarks); cx.notify(); @@ -41,6 +61,9 @@ impl Editor { selections.sort_by_key(|s| s.head()); selections.dedup_by_key(|s| s.head().row); + let mut exist_targets: Vec = vec![]; + let mut absent_targets: Vec = vec![]; + for selection in &selections { let head = selection.head(); let multibuffer_anchor = multi_buffer_snapshot.anchor_before(Point::new(head.row, 0)); @@ -50,43 +73,52 @@ impl Editor { { 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); - }); + 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, cx: &mut Context) { - let Some(bookmark_store) = &self.bookmark_store else { - return; - }; + pub fn toggle_bookmark_at_row( + &mut self, + row: DisplayRow, + window: &mut Window, + cx: &mut Context, + ) { 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(); + self.toggle_bookmark_at_anchor(anchor, window, cx); } - pub fn toggle_bookmark_at_anchor(&mut self, anchor: Anchor, cx: &mut Context) { - let Some(bookmark_store) = &self.bookmark_store else { - return; - }; + pub fn toggle_bookmark_at_anchor( + &mut self, + anchor: Anchor, + window: &mut Window, + cx: &mut Context, + ) { let buffer_snapshot = self.buffer.read(cx).snapshot(cx); let Some((position, _)) = buffer_snapshot.anchor_to_buffer_anchor(anchor) else { return; @@ -95,13 +127,142 @@ impl Editor { return; }; - bookmark_store.update(cx, |bookmark_store, cx| { - bookmark_store.toggle_bookmark(buffer, position, cx); - }); + 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) { + let snapshot = self.snapshot(window, cx); + let multi_buffer_snapshot = snapshot.buffer_snapshot(); + let selection = self + .selections + .newest::(&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, + ) { + 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, + window: &mut Window, + cx: &mut Context, + ) { + 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, + bookmark_store: Entity, + window: &mut Window, + cx: &mut Context, + ) { + 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, + label: String, + cx: &mut Context, + ) { + 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, @@ -233,9 +394,7 @@ impl Editor { ) }) .into_iter() - .filter_map(|bookmark| { - multi_buffer_snapshot.anchor_in_buffer(bookmark.anchor()) - }) + .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 30eaed6bd66..340f14d1923 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -3939,7 +3939,7 @@ impl Editor { }); for bookmark in bookmarks { let Some(multi_buffer_anchor) = - multi_buffer_snapshot.anchor_in_buffer(bookmark.anchor()) + multi_buffer_snapshot.anchor_in_buffer(bookmark.anchor) else { continue; }; @@ -3961,8 +3961,8 @@ impl Editor { .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_click(cx.listener(move |editor, _, window, cx| { + editor.toggle_bookmark_at_row(row, window, cx); })) .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { editor.set_gutter_context_menu(row, None, event.position(), window, cx); @@ -4100,6 +4100,7 @@ impl Editor { } else { "Add Bookmark" }; + let has_bookmark = bookmark.as_ref().is_some(); let run_to_cursor = window.is_action_available(&RunToCursor, cx); @@ -4250,12 +4251,28 @@ impl Editor { } }) .separator() - .entry(set_bookmark_msg, None, move |_window, cx| { - weak_editor - .update(cx, |this, cx| { - this.toggle_bookmark_at_anchor(anchor, cx); - }) - .log_err(); + .entry(set_bookmark_msg, Some(ToggleBookmark.boxed_clone()), { + let weak_editor = weak_editor.clone(); + move |window, cx| { + weak_editor + .update(cx, |this, cx| { + this.toggle_bookmark_at_anchor(anchor, window, cx); + }) + .log_err(); + } + }) + .when(has_bookmark, |this| { + this.entry( + "Edit Bookmark", + Some(EditBookmark.boxed_clone()), + move |window, cx| { + weak_editor + .update(cx, |this, cx| { + this.edit_bookmark_at_anchor(anchor, window, cx); + }) + .log_err(); + }, + ) }) }) } @@ -4435,7 +4452,7 @@ impl Editor { }; match intent { - Intent::SetBookmark => editor.toggle_bookmark_at_row(row, cx), + Intent::SetBookmark => editor.toggle_bookmark_at_row(row, window, cx), Intent::SetBreakpoint => editor.edit_breakpoint_at_anchor( position, Breakpoint::new_standard(), @@ -5751,24 +5768,29 @@ impl Editor { ); } - fn add_edit_breakpoint_block( + fn add_edit_block( &mut self, anchor: Anchor, - breakpoint: &Breakpoint, - edit_action: BreakpointPromptEditAction, + base_text: &str, + placeholder_text: &str, + confirm: Option, + cancel: Option, window: &mut Window, cx: &mut Context, ) { let weak_editor = cx.weak_entity(); let bp_prompt = cx.new(|cx| { - BreakpointPromptEditor::new( - weak_editor, - anchor, - breakpoint.clone(), - edit_action, - window, - cx, - ) + let mut prompt_editor = + PromptEditor::new(weak_editor, placeholder_text, base_text, window, cx); + + if let Some(callback) = confirm { + prompt_editor = prompt_editor.on_confirm(callback); + } + if let Some(callback) = cancel { + prompt_editor = prompt_editor.on_cancel(callback); + } + + prompt_editor }); let height = bp_prompt.update(cx, |this, cx| { @@ -5796,6 +5818,61 @@ impl Editor { }); } + fn add_edit_breakpoint_block( + &mut self, + anchor: Anchor, + breakpoint: &Breakpoint, + edit_action: BreakpointPromptEditAction, + window: &mut Window, + cx: &mut Context, + ) { + let base_text: &str = match edit_action { + BreakpointPromptEditAction::Log => breakpoint.message.as_ref(), + BreakpointPromptEditAction::Condition => breakpoint.condition.as_ref(), + BreakpointPromptEditAction::HitCondition => breakpoint.hit_condition.as_ref(), + } + .map(|msg| msg.as_ref()) + .unwrap_or_default(); + + let placeholder_text = match edit_action { + BreakpointPromptEditAction::Log => { + "Message to log when a breakpoint is hit. Expressions within {} are interpolated." + } + BreakpointPromptEditAction::Condition => { + "Condition when a breakpoint is hit. Expressions within {} are interpolated." + } + BreakpointPromptEditAction::HitCondition => "How many breakpoint hits to ignore", + }; + + let breakpoint = breakpoint.clone(); + self.add_edit_block( + anchor, + base_text, + placeholder_text, + Some(Box::new(move |message: String, editor: &mut Self, cx| { + editor.edit_breakpoint_at_anchor( + anchor, + breakpoint, + match edit_action { + BreakpointPromptEditAction::Log => { + BreakpointEditAction::EditLogMessage(message.into()) + } + BreakpointPromptEditAction::Condition => { + BreakpointEditAction::EditCondition(message.into()) + } + BreakpointPromptEditAction::HitCondition => { + BreakpointEditAction::EditHitCondition(message.into()) + } + }, + cx, + ); + })), + None, + window, + cx, + ); + } + pub(crate) fn breakpoint_at_row( &self, row: u32, @@ -5901,13 +5978,13 @@ impl Editor { .first() .and_then(|bookmark| { let bookmark_row = buffer_snapshot - .summary_for_anchor::(&bookmark.anchor()) + .summary_for_anchor::(&bookmark.anchor) .row; if bookmark_row == row { snapshot .buffer_snapshot() - .anchor_in_excerpt(bookmark.anchor()) + .anchor_in_excerpt(bookmark.anchor) } else { None } @@ -12048,42 +12125,35 @@ fn collapse_multiline_range(range: Range) -> Range { const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); +#[derive(Copy, Clone, Debug)] enum BreakpointPromptEditAction { Log, Condition, HitCondition, } -struct BreakpointPromptEditor { +type PromptEditorCallback = Box) + 'static>; + +struct PromptEditor { pub(crate) prompt: Entity, editor: WeakEntity, - breakpoint_anchor: Anchor, - breakpoint: Breakpoint, - edit_action: BreakpointPromptEditAction, + confirm_callback: Option, + cancel_callback: Option, block_ids: HashSet, editor_margins: Arc>, _subscriptions: Vec, } -impl BreakpointPromptEditor { +impl PromptEditor { const MAX_LINES: u8 = 4; fn new( editor: WeakEntity, - breakpoint_anchor: Anchor, - breakpoint: Breakpoint, - edit_action: BreakpointPromptEditAction, + placeholder_text: &str, + base_text: &str, window: &mut Window, cx: &mut Context, ) -> Self { - let base_text = match edit_action { - BreakpointPromptEditAction::Log => breakpoint.message.as_ref(), - BreakpointPromptEditAction::Condition => breakpoint.condition.as_ref(), - BreakpointPromptEditAction::HitCondition => breakpoint.hit_condition.as_ref(), - } - .map(|msg| msg.to_string()) - .unwrap_or_default(); - let buffer = cx.new(|cx| Buffer::local(base_text, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); @@ -12100,15 +12170,7 @@ impl BreakpointPromptEditor { ); prompt.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); prompt.set_show_cursor_when_unfocused(false, cx); - prompt.set_placeholder_text( - match edit_action { - BreakpointPromptEditAction::Log => "Message to log when a breakpoint is hit. Expressions within {} are interpolated.", - BreakpointPromptEditAction::Condition => "Condition when a breakpoint is hit. Expressions within {} are interpolated.", - BreakpointPromptEditAction::HitCondition => "How many breakpoint hits to ignore", - }, - window, - cx, - ); + prompt.set_placeholder_text(placeholder_text, window, cx); prompt }); @@ -12116,15 +12178,24 @@ impl BreakpointPromptEditor { Self { prompt, editor, - breakpoint_anchor, - breakpoint, - edit_action, + confirm_callback: None, + cancel_callback: None, editor_margins: Arc::new(Mutex::new(EditorMargins::default())), block_ids: Default::default(), _subscriptions: vec![], } } + fn on_confirm(mut self, confirm: PromptEditorCallback) -> Self { + self.confirm_callback = Some(confirm); + self + } + + fn on_cancel(mut self, cancel: PromptEditorCallback) -> Self { + self.cancel_callback = Some(cancel); + self + } + pub(crate) fn add_block_ids(&mut self, block_ids: Vec) { self.block_ids.extend(block_ids) } @@ -12137,28 +12208,15 @@ impl BreakpointPromptEditor { .buffer .read(cx) .as_singleton() - .expect("A multi buffer in breakpoint prompt isn't possible") + .expect("A multi buffer in prompt isn't possible") .read(cx) .as_rope() .to_string(); editor.update(cx, |editor, cx| { - editor.edit_breakpoint_at_anchor( - self.breakpoint_anchor, - self.breakpoint.clone(), - match self.edit_action { - BreakpointPromptEditAction::Log => { - BreakpointEditAction::EditLogMessage(message.into()) - } - BreakpointPromptEditAction::Condition => { - BreakpointEditAction::EditCondition(message.into()) - } - BreakpointPromptEditAction::HitCondition => { - BreakpointEditAction::EditHitCondition(message.into()) - } - }, - cx, - ); + if let Some(confirm) = self.confirm_callback.take() { + confirm(message, editor, cx); + } editor.remove_blocks(self.block_ids.clone(), None, cx); cx.focus_self(window); @@ -12169,6 +12227,21 @@ impl BreakpointPromptEditor { fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { self.editor .update(cx, |editor, cx| { + let message = self + .prompt + .read(cx) + .buffer + .read(cx) + .as_singleton() + .expect("A multi buffer in prompt isn't possible") + .read(cx) + .as_rope() + .to_string(); + + if let Some(cancel) = self.cancel_callback.take() { + cancel(message, editor, cx); + } + editor.remove_blocks(self.block_ids.clone(), None, cx); window.focus(&editor.focus_handle, cx); }) @@ -12228,7 +12301,7 @@ impl BreakpointPromptEditor { } } -impl Render for BreakpointPromptEditor { +impl Render for PromptEditor { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); let editor_margins = *self.editor_margins.lock(); @@ -12273,7 +12346,7 @@ impl Render for BreakpointPromptEditor { } } -impl Focusable for BreakpointPromptEditor { +impl Focusable for PromptEditor { fn focus_handle(&self, cx: &App) -> FocusHandle { self.prompt.focus_handle(cx) } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 21b62d96ab2..a3a0acd3924 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -28853,6 +28853,10 @@ impl BookmarkTestContext { }) } + fn assert_bookmarked_file_count(&self, expected_count: usize) { + assert_eq!(expected_count, self.all_bookmarks().len()); + } + fn assert_bookmark_rows(&self, expected_rows: Vec) { let abs_path = self.abs_path(); let bookmarks = self.all_bookmarks(); @@ -28867,20 +28871,134 @@ impl BookmarkTestContext { .get(&abs_path) .unwrap() .iter() - .map(|b| b.0) + .map(|b| b.row) .collect(); rows.sort(); assert_eq!(expected_rows, rows); } } - fn cursor_row(&mut self) -> u32 { + fn assert_bookmark_labels(&self, expected_labels: Vec<(u32, &str)>) { + let abs_path = self.abs_path(); + let bookmarks = self.all_bookmarks(); + let mut labels: Vec<(u32, &str)> = bookmarks + .get(&abs_path) + .unwrap() + .iter() + .map(|bookmark| (bookmark.row, bookmark.label.as_str())) + .collect(); + labels.sort_by_key(|(row, _)| *row); + assert_eq!(expected_labels, labels); + } + + fn confirm_action_available(&mut self) -> bool { + self.cx + .update(|window, cx| window.is_action_available(&menu::Confirm, cx)) + } + + fn select_rows(&mut self, rows: &[u32]) { + assert!(!rows.is_empty(), "expected at least one row to select"); + + self.editor + .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges( + rows.iter() + .copied() + .map(|row| Point::new(row, 0)..Point::new(row, 0)), + ) + }); + }); + } + + fn prompt_blocks(&mut self) -> Vec<(DisplayRow, u32)> { self.editor.update(&mut self.cx, |editor, cx| { let snapshot = editor.display_snapshot(cx); - editor.selections.newest::(&snapshot).head().row + let max_row = snapshot.max_point().row().next_row(); + + snapshot + .blocks_in_range(DisplayRow(0)..max_row) + .filter_map(|(row, block)| match block { + crate::display_map::Block::Custom(_) => Some((row, block.height())), + _ => None, + }) + .collect() }) } + fn assert_prompt_block_count(&mut self, expected_count: usize) { + assert_eq!(expected_count, self.prompt_blocks().len()); + } + + fn draw_window(&mut self) { + self.cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + } + + fn focus_bookmark_prompt_block(&mut self, block_index: usize) { + self.draw_window(); + + let prompt_blocks = self.prompt_blocks(); + let (block_row, block_height) = *prompt_blocks + .get(block_index) + .expect("expected bookmark prompt block"); + + let click_position = + self.editor + .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let block_top = DisplayPoint::new(block_row, 0); + let relative_block_top = editor + .display_to_pixel_point(block_top, &snapshot, window, cx) + .expect("expected prompt block to be visible"); + let line_height = editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()); + let editor_origin = editor + .last_position_map + .as_ref() + .expect("expected editor position map") + .text_hitbox + .bounds + .origin; + let editor_center_x = editor + .last_bounds + .expect("expected editor bounds") + .center() + .x; + + gpui::Point { + x: editor_center_x, + y: editor_origin.y + + relative_block_top.y + + line_height * (block_height as f32 / 2.), + } + }); + + self.cx + .simulate_click(click_position, gpui::Modifiers::none()); + self.cx.run_until_parked(); + + assert!( + self.confirm_action_available(), + "expected bookmark prompt block to be focused" + ); + } + + fn confirm_bookmark_prompt_at_block_index(&mut self, block_index: usize, label: &str) { + // Confirming a PromptEditor returns focus to the parent editor, so each remaining + // prompt block must be focused explicitly before typing into it. + self.focus_bookmark_prompt_block(block_index); + self.confirm_bookmark_prompt(label); + } + + fn cursor_row(&mut self) -> u32 { + self.cursor_point().row + } + fn cursor_point(&mut self) -> Point { self.editor.update(&mut self.cx, |editor, cx| { let snapshot = editor.display_snapshot(cx); @@ -28905,10 +29023,23 @@ impl BookmarkTestContext { }); } + fn confirm_bookmark_prompt(&mut self, label: &str) { + if !label.is_empty() { + self.cx.simulate_input(label); + } + self.cx.dispatch_action(menu::Confirm); + self.cx.run_until_parked(); + } + + fn add_bookmark_with_label(&mut self, label: &str) { + self.toggle_bookmark(); + self.confirm_bookmark_prompt(label); + } + fn toggle_bookmarks_at_rows(&mut self, rows: &[u32]) { for &row in rows { self.move_to_row(row); - self.toggle_bookmark(); + self.add_bookmark_with_label(""); } } @@ -28932,32 +29063,26 @@ async fn test_bookmark_toggling(cx: &mut TestAppContext) { let mut ctx = BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + ctx.add_bookmark_with_label(""); 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); }); + ctx.add_bookmark_with_label(""); - assert_eq!(1, ctx.all_bookmarks().len()); + ctx.assert_bookmarked_file_count(1); ctx.assert_bookmark_rows(vec![0, 3]); - 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.move_to_row(0); + ctx.toggle_bookmark(); - assert_eq!(1, ctx.all_bookmarks().len()); + ctx.assert_bookmarked_file_count(1); ctx.assert_bookmark_rows(vec![3]); - 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); - }); + ctx.move_to_row(3); + ctx.toggle_bookmark(); - assert_eq!(0, ctx.all_bookmarks().len()); + ctx.assert_bookmarked_file_count(0); ctx.assert_bookmark_rows(vec![]); } @@ -28966,29 +29091,51 @@ 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.select_rows(&[0, 1, 2]); ctx.toggle_bookmark(); - assert_eq!(1, ctx.all_bookmarks().len()); - ctx.assert_bookmark_rows(vec![0, 1, 2, 3]); + ctx.assert_prompt_block_count(3); + ctx.assert_bookmarked_file_count(0); - 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); - }); + ctx.confirm_bookmark_prompt_at_block_index(0, "first label"); + ctx.assert_prompt_block_count(2); + ctx.confirm_bookmark_prompt_at_block_index(0, "second label"); + ctx.assert_prompt_block_count(1); + ctx.confirm_bookmark_prompt_at_block_index(0, "third label"); + ctx.assert_prompt_block_count(0); - assert_eq!(0, ctx.all_bookmarks().len()); + ctx.assert_bookmarked_file_count(1); + ctx.assert_bookmark_labels(vec![ + (0, "first label"), + (1, "second label"), + (2, "third label"), + ]); + + ctx.select_rows(&[0, 1, 2, 3]); + ctx.toggle_bookmark(); + + ctx.assert_prompt_block_count(1); + ctx.assert_bookmark_labels(vec![ + (0, "first label"), + (1, "second label"), + (2, "third label"), + ]); + + ctx.confirm_bookmark_prompt_at_block_index(0, "fourth label"); + + ctx.assert_prompt_block_count(0); + ctx.assert_bookmark_labels(vec![ + (0, "first label"), + (1, "second label"), + (2, "third label"), + (3, "fourth label"), + ]); + + ctx.select_rows(&[0, 1, 2, 3]); + ctx.toggle_bookmark(); + + ctx.assert_prompt_block_count(0); + ctx.assert_bookmarked_file_count(0); ctx.assert_bookmark_rows(vec![]); } @@ -29000,8 +29147,8 @@ async fn test_bookmark_toggle_deduplicates_by_row(cx: &mut TestAppContext) { 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.add_bookmark_with_label(""); ctx.assert_bookmark_rows(vec![0]); @@ -29014,8 +29161,8 @@ async fn test_bookmark_toggle_deduplicates_by_row(cx: &mut TestAppContext) { window, cx, ); - editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); }); + ctx.toggle_bookmark(); ctx.assert_bookmark_rows(vec![]); } @@ -29026,7 +29173,7 @@ async fn test_bookmark_survives_edits(cx: &mut TestAppContext) { BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; ctx.move_to_row(2); - ctx.toggle_bookmark(); + ctx.add_bookmark_with_label(""); ctx.assert_bookmark_rows(vec![2]); ctx.editor @@ -29090,6 +29237,45 @@ async fn test_bookmark_not_available_in_single_line_editor(cx: &mut TestAppConte }); } +#[gpui::test] +async fn test_edit_bookmark_does_not_open_prompt_without_existing_bookmark( + cx: &mut TestAppContext, +) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + assert!(!ctx.confirm_action_available()); + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.edit_bookmark(&actions::EditBookmark, window, cx); + }); + + assert!(!ctx.confirm_action_available()); + ctx.assert_bookmark_rows(vec![]); +} + +#[gpui::test] +async fn test_edit_bookmark_updates_label_after_confirmation(cx: &mut TestAppContext) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + ctx.add_bookmark_with_label("old label"); + ctx.assert_bookmark_labels(vec![(0, "old label")]); + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.edit_bookmark(&actions::EditBookmark, window, cx); + }); + + assert!(ctx.confirm_action_available()); + ctx.cx.dispatch_action(SelectAll); + ctx.cx.simulate_input("new label"); + ctx.cx.dispatch_action(menu::Confirm); + + ctx.assert_bookmark_labels(vec![(0, "new label")]); +} + #[gpui::test] async fn test_bookmark_navigation_lands_at_column_zero(cx: &mut TestAppContext) { let mut ctx = @@ -29114,7 +29300,7 @@ async fn test_bookmark_navigation_lands_at_column_zero(cx: &mut TestAppContext) "Cursor should be at the 11th column before toggling bookmark, got column {column_before_toggle}" ); - ctx.toggle_bookmark(); + ctx.add_bookmark_with_label(""); ctx.editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { @@ -29149,8 +29335,8 @@ async fn test_bookmark_set_from_nonzero_column_toggles_off_from_column_zero( window, cx, ); - editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); }); + ctx.add_bookmark_with_label(""); ctx.assert_bookmark_rows(vec![1]); @@ -29164,8 +29350,8 @@ async fn test_bookmark_set_from_nonzero_column_toggles_off_from_column_zero( window, cx, ); - editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); }); + ctx.toggle_bookmark(); ctx.assert_bookmark_rows(vec![]); } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 06717e89782..634adcc1892 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -504,6 +504,7 @@ impl EditorElement { register_action(editor, window, Editor::spawn_nearest_task); register_action(editor, window, Editor::open_selections_in_multibuffer); register_action(editor, window, Editor::toggle_bookmark); + register_action(editor, window, Editor::edit_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); diff --git a/crates/project/src/bookmark_store.rs b/crates/project/src/bookmark_store.rs index c66fdbf2836..25c462359e7 100644 --- a/crates/project/src/bookmark_store.rs +++ b/crates/project/src/bookmark_store.rs @@ -10,22 +10,22 @@ use text::{BufferSnapshot, Point}; use crate::{ProjectPath, buffer_store::BufferStore, worktree_store::WorktreeStore}; -#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)] -pub struct BookmarkAnchor(text::Anchor); - -impl BookmarkAnchor { - pub fn anchor(&self) -> text::Anchor { - self.0 - } +#[derive(Clone, Debug)] +pub struct Bookmark { + pub anchor: text::Anchor, + pub label: String, } -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] -pub struct SerializedBookmark(pub u32); +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub struct SerializedBookmark { + pub row: u32, + pub label: String, +} #[derive(Debug)] pub struct BufferBookmarks { buffer: Entity, - bookmarks: Vec, + bookmarks: Vec, _subscription: Subscription, } @@ -52,7 +52,7 @@ impl BufferBookmarks { &self.buffer } - pub fn bookmarks(&self) -> &[BookmarkAnchor] { + pub fn bookmarks(&self) -> &[Bookmark] { &self.bookmarks } } @@ -122,38 +122,41 @@ impl BookmarkStore { buffer: &Entity, cx: &mut Context, ) { - let Some(BookmarkEntry::Unloaded(rows)) = self.bookmarks.get(abs_path) else { + let Some(BookmarkEntry::Unloaded(bookmarks)) = self.bookmarks.get(abs_path) else { return; }; let snapshot = buffer.read(cx).snapshot(); let max_point = snapshot.max_point(); - let anchors: Vec = rows + let bookmarks: Vec = bookmarks .iter() - .filter_map(|bookmark_row| { - let point = Point::new(bookmark_row.0, 0); + .filter_map(|bookmark| { + let point = Point::new(bookmark.row, 0); if point > max_point { log::warn!( "Skipping out-of-range bookmark: {} row {} (file has {} rows)", abs_path.display(), - bookmark_row.0, + bookmark.row, max_point.row ); return None; } let anchor = snapshot.anchor_after(point); - Some(BookmarkAnchor(anchor)) + Some(Bookmark { + anchor, + label: bookmark.label.clone(), + }) }) .collect(); - if anchors.is_empty() { + if bookmarks.is_empty() { self.bookmarks.remove(abs_path); } else { let mut buffer_bookmarks = BufferBookmarks::new(buffer.clone(), cx); - buffer_bookmarks.bookmarks = anchors; + buffer_bookmarks.bookmarks = bookmarks; self.bookmarks .insert(abs_path.clone(), BookmarkEntry::Loaded(buffer_bookmarks)); } @@ -167,11 +170,12 @@ impl BookmarkStore { /// Toggle a bookmark at the given anchor in the buffer. /// If a bookmark already exists on the same row, it will be removed. - /// Otherwise, a new bookmark will be added. + /// Otherwise, a new bookmark will be added with the given label. pub fn toggle_bookmark( &mut self, buffer: Entity, anchor: text::Anchor, + label: String, cx: &mut Context, ) { let Some(abs_path) = Self::abs_path_from_buffer(&buffer, cx) else { @@ -192,7 +196,8 @@ impl BookmarkStore { let snapshot = buffer.read(cx).text_snapshot(); let existing_index = buffer_bookmarks.bookmarks.iter().position(|existing| { - existing.0.summary::(&snapshot).row == anchor.summary::(&snapshot).row + existing.anchor.summary::(&snapshot).row + == anchor.summary::(&snapshot).row }); if let Some(index) = existing_index { @@ -201,12 +206,72 @@ impl BookmarkStore { self.bookmarks.remove(&abs_path); } } else { - buffer_bookmarks.bookmarks.push(BookmarkAnchor(anchor)); + buffer_bookmarks.bookmarks.push(Bookmark { anchor, label }); } cx.notify(); } + pub fn find_bookmark( + &mut self, + buffer: &Entity, + anchor: text::Anchor, + cx: &mut Context, + ) -> Option<&Bookmark> { + let Some(abs_path) = Self::abs_path_from_buffer(buffer, cx) else { + return None; + }; + + self.resolve_anchors_if_needed(&abs_path, buffer, cx); + + let entry = self + .bookmarks + .entry(abs_path.clone()) + .or_insert_with(|| BookmarkEntry::Loaded(BufferBookmarks::new(buffer.clone(), cx))); + + let BookmarkEntry::Loaded(buffer_bookmarks) = entry else { + unreachable!("resolve_if_needed should have converted to Loaded"); + }; + + let snapshot = buffer.read(cx).text_snapshot(); + + buffer_bookmarks.bookmarks.iter().find(|existing| { + existing.anchor.summary::(&snapshot).row + == anchor.summary::(&snapshot).row + }) + } + + pub fn edit_bookmark( + &mut self, + buffer: &Entity, + anchor: text::Anchor, + label: String, + cx: &mut Context, + ) { + let Some(abs_path) = Self::abs_path_from_buffer(buffer, cx) else { + return; + }; + + self.resolve_anchors_if_needed(&abs_path, buffer, cx); + + let Some(BookmarkEntry::Loaded(buffer_bookmarks)) = self.bookmarks.get_mut(&abs_path) + else { + return; + }; + + let snapshot = buffer.read(cx).text_snapshot(); + let row = anchor.summary::(&snapshot).row; + + if let Some(bookmark) = buffer_bookmarks + .bookmarks + .iter_mut() + .find(|existing| existing.anchor.summary::(&snapshot).row == row) + { + bookmark.label = label; + cx.notify(); + } + } + /// Returns the bookmarks for a given buffer within an optional range. /// Only returns bookmarks that have been resolved to anchors (loaded). /// Unloaded bookmarks for the given buffer will be resolved first. @@ -216,7 +281,7 @@ impl BookmarkStore { range: Range, buffer_snapshot: &BufferSnapshot, cx: &mut Context, - ) -> Vec { + ) -> Vec { let Some(abs_path) = Self::abs_path_from_buffer(&buffer, cx) else { return Vec::new(); }; @@ -232,17 +297,17 @@ impl BookmarkStore { .iter() .filter_map({ move |bookmark| { - if !buffer_snapshot.can_resolve(&bookmark.anchor()) { + if !buffer_snapshot.can_resolve(&bookmark.anchor) { return None; } - if bookmark.anchor().cmp(&range.start, buffer_snapshot).is_lt() - || bookmark.anchor().cmp(&range.end, buffer_snapshot).is_gt() + if bookmark.anchor.cmp(&range.start, buffer_snapshot).is_lt() + || bookmark.anchor.cmp(&range.end, buffer_snapshot).is_gt() { return None; } - Some(*bookmark) + Some(bookmark.clone()) } }) .collect() @@ -310,19 +375,22 @@ impl BookmarkStore { .bookmarks .iter() .filter_map(|bookmark| { - if !snapshot.can_resolve(&bookmark.anchor()) { + if !snapshot.can_resolve(&bookmark.anchor) { return None; } let row = - snapshot.summary_for_anchor::(&bookmark.anchor()).row; - Some(SerializedBookmark(row)) + snapshot.summary_for_anchor::(&bookmark.anchor).row; + Some(SerializedBookmark { + row, + label: bookmark.label.clone(), + }) }) .collect() } }; - rows.sort_unstable(); - rows.dedup(); + rows.sort_unstable_by_key(|a| a.row); + rows.dedup_by_key(|a| a.row); if rows.is_empty() { None @@ -346,8 +414,8 @@ impl BookmarkStore { let ranges: Vec> = bookmarks .bookmarks() .iter() - .map(|anchor| { - let row = snapshot.summary_for_anchor::(&anchor.anchor()).row; + .map(|bookmark| { + let row = snapshot.summary_for_anchor::(&bookmark.anchor).row; Point::row_range(row..row) }) .collect(); diff --git a/crates/project/tests/integration/bookmark_store.rs b/crates/project/tests/integration/bookmark_store.rs index 3b84a0c76b6..1329513b115 100644 --- a/crates/project/tests/integration/bookmark_store.rs +++ b/crates/project/tests/integration/bookmark_store.rs @@ -23,6 +23,13 @@ mod integration { Arc::from(Path::new(path)) } + fn serialized_bookmark(row: u32) -> SerializedBookmark { + SerializedBookmark { + row, + label: String::new(), + } + } + async fn open_buffer( project: &Entity, path: &str, @@ -49,12 +56,30 @@ mod integration { for &row in rows { let anchor = snapshot.anchor_after(text::Point::new(row, 0)); bookmark_store.update(cx, |store, cx| { - store.toggle_bookmark(buffer.clone(), anchor, cx); + store.toggle_bookmark(buffer.clone(), anchor, String::new(), cx); }); } }); } + fn add_labeled_bookmark( + project: &Entity, + buffer: &Entity, + row: u32, + label: &str, + cx: &mut TestAppContext, + ) { + let buffer = buffer.clone(); + project.update(cx, |project, cx| { + let bookmark_store = project.bookmark_store(); + let snapshot = buffer.read(cx).snapshot(); + let anchor = snapshot.anchor_after(text::Point::new(row, 0)); + bookmark_store.update(cx, |store, cx| { + store.toggle_bookmark(buffer.clone(), anchor, label.to_string(), cx); + }); + }); + } + fn get_all_bookmarks( project: &Entity, cx: &mut TestAppContext, @@ -75,7 +100,7 @@ mod integration { let path = project_path(path_str); map.insert( path.clone(), - rows.iter().map(|&row| SerializedBookmark(row)).collect(), + rows.iter().map(|&row| serialized_bookmark(row)).collect(), ); } map @@ -113,10 +138,26 @@ mod integration { let file_bookmarks = bookmarks .get(&path) .unwrap_or_else(|| panic!("Expected bookmarks for {}", path.display())); - let rows: Vec = file_bookmarks.iter().map(|b| b.0).collect(); + let rows: Vec = file_bookmarks.iter().map(|b| b.row).collect(); assert_eq!(rows, expected_rows, "Bookmark rows for {}", path.display()); } + fn assert_bookmark_labels( + bookmarks: &BTreeMap, Vec>, + path: &str, + expected: &[(u32, &str)], + ) { + let path = project_path(path); + let file_bookmarks = bookmarks + .get(&path) + .unwrap_or_else(|| panic!("Expected bookmarks for {}", path.display())); + let actual: Vec<_> = file_bookmarks + .iter() + .map(|bookmark| (bookmark.row, bookmark.label.as_str())) + .collect(); + assert_eq!(actual, expected, "Bookmark labels for {}", path.display()); + } + #[gpui::test] async fn test_all_serialized_bookmarks_empty(cx: &mut TestAppContext) { init_test(cx); @@ -152,6 +193,32 @@ mod integration { assert_bookmark_rows(&bookmarks, path!("/project/file1.rs"), &[0, 2]); } + #[gpui::test] + async fn test_all_serialized_bookmarks_includes_labels(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({"file1.rs": "line1\nline2\nline3\n"}), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let buffer = open_buffer(&project, path!("/project/file1.rs"), cx).await; + + add_labeled_bookmark(&project, &buffer, 0, "first", cx); + add_labeled_bookmark(&project, &buffer, 2, " keeps inner spaces ", cx); + + let bookmarks = get_all_bookmarks(&project, cx); + assert_bookmark_labels( + &bookmarks, + path!("/project/file1.rs"), + &[(0, "first"), (2, " keeps inner spaces ")], + ); + } + #[gpui::test] async fn test_all_serialized_bookmarks_multiple_files(cx: &mut TestAppContext) { init_test(cx); @@ -293,7 +360,7 @@ mod integration { .get(&project_path(path!("/project/file1.rs"))) .unwrap() .iter() - .map(|b| b.0) + .map(|b| b.row) .collect(); let mut deduped = rows.clone(); deduped.dedup(); diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 1295ce60e52..0baeb8f854f 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -394,12 +394,13 @@ pub async fn write_default_dock_state( #[derive(Debug)] pub struct Bookmark { pub row: u32, + pub label: String, } impl sqlez::bindable::StaticColumnCount for Bookmark { fn column_count() -> usize { - // row - 1 + // row, label + 2 } } @@ -409,7 +410,8 @@ impl sqlez::bindable::Bind for Bookmark { statement: &sqlez::statement::Statement, start_index: i32, ) -> anyhow::Result { - statement.bind(&self.row, start_index) + let next_index = statement.bind(&self.row, start_index)?; + statement.bind(&self.label, next_index) } } @@ -420,7 +422,9 @@ impl Column for Bookmark { .with_context(|| format!("Failed to read bookmark at index {start_index}"))? as u32; - Ok((Bookmark { row }, start_index + 1)) + let (label, next_index) = String::column(statement, start_index + 1)?; + + Ok((Bookmark { row, label }, next_index)) } } @@ -1044,6 +1048,9 @@ impl Domain for WorkspaceDb { ALTER TABLE workspaces ADD COLUMN identity_paths TEXT; ALTER TABLE workspaces ADD COLUMN identity_paths_order TEXT; ), + sql!( + ALTER TABLE bookmarks ADD COLUMN label TEXT NOT NULL DEFAULT ""; + ), ]; // Allow recovering from bad migration that was initially shipped to nightly @@ -1305,7 +1312,7 @@ impl WorkspaceDb { fn bookmarks(&self, workspace_id: WorkspaceId) -> BTreeMap, Vec> { let bookmarks: Result> = self .select_bound(sql! { - SELECT path, row + SELECT path, row, label FROM bookmarks WHERE workspace_id = ? ORDER BY path, row @@ -1324,7 +1331,10 @@ impl WorkspaceDb { let path: Arc = path.into(); map.entry(path.clone()) .or_default() - .push(SerializedBookmark(bookmark.row)) + .push(SerializedBookmark { + row: bookmark.row, + label: bookmark.label, + }) } map @@ -1485,9 +1495,9 @@ impl WorkspaceDb { for (path, bookmarks) in workspace.bookmarks { for bookmark in bookmarks { conn.exec_bound(sql!( - INSERT INTO bookmarks (workspace_id, path, row) - VALUES (?1, ?2, ?3); - ))?((workspace.id, path.as_ref(), bookmark.0)).context("Inserting bookmark")?; + INSERT INTO bookmarks (workspace_id, path, row, label) + VALUES (?1, ?2, ?3, ?4); + ))?((workspace.id, path.as_ref(), bookmark.row, bookmark.label)).context("Inserting bookmark")?; } }