vim: View Marks (#26885)

AidanV and Conrad Irwin created

Closes #26884

Release Notes:

- vim: Added `:marks` which brings up list of current marks
- confirming on selected mark in the view jumps to that mark

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

crates/vim/src/command.rs     |   3 
crates/vim/src/normal/mark.rs |   5 
crates/vim/src/state.rs       | 379 ++++++++++++++++++++++++++++++++++++
crates/vim/src/vim.rs         |   3 
4 files changed, 381 insertions(+), 9 deletions(-)

Detailed changes

crates/vim/src/command.rs 🔗

@@ -39,7 +39,7 @@ use crate::{
     object::Object,
     state::{Mark, Mode},
     visual::VisualDeleteLine,
-    ToggleRegistersView, Vim,
+    ToggleMarksView, ToggleRegistersView, Vim,
 };
 
 #[derive(Clone, Debug, PartialEq)]
@@ -860,6 +860,7 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
             )
         }),
         VimCommand::new(("reg", "isters"), ToggleRegistersView).bang(ToggleRegistersView),
+        VimCommand::new(("marks", ""), ToggleMarksView).bang(ToggleMarksView),
         VimCommand::new(("sor", "t"), SortLinesCaseSensitive).range(select_range),
         VimCommand::new(("sort i", ""), SortLinesCaseInsensitive).range(select_range),
         VimCommand::str(("E", "xplore"), "project_panel::ToggleFocus"),

crates/vim/src/normal/mark.rs 🔗

@@ -183,10 +183,13 @@ impl Vim {
         &mut self,
         text: Arc<str>,
         line: bool,
+        should_pop_operator: bool,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.pop_operator(window, cx);
+        if should_pop_operator {
+            self.pop_operator(window, cx);
+        }
         let mark = self
             .update_editor(window, cx, |vim, editor, window, cx| {
                 vim.get_mark(&text, editor, window, cx)

crates/vim/src/state.rs 🔗

@@ -2,27 +2,29 @@ use crate::command::command_interceptor;
 use crate::normal::repeat::Replayer;
 use crate::surrounds::SurroundsType;
 use crate::{motion::Motion, object::Object};
-use crate::{ToggleRegistersView, UseSystemClipboard, Vim, VimSettings};
+use crate::{ToggleMarksView, ToggleRegistersView, UseSystemClipboard, Vim, VimAddon, VimSettings};
 use anyhow::Result;
 use collections::HashMap;
 use command_palette_hooks::{CommandPaletteFilter, CommandPaletteInterceptor};
 use db::define_connection;
 use db::sqlez_macros::sql;
 use editor::display_map::{is_invisible, replacement};
-use editor::{Anchor, ClipboardSelection, Editor, MultiBuffer};
+use editor::{Anchor, ClipboardSelection, Editor, MultiBuffer, ToPoint as EditorToPoint};
 use gpui::{
-    Action, App, AppContext, BorrowAppContext, ClipboardEntry, ClipboardItem, Entity, EntityId,
-    Global, HighlightStyle, StyledText, Subscription, Task, TextStyle, WeakEntity,
+    Action, App, AppContext, BorrowAppContext, ClipboardEntry, ClipboardItem, DismissEvent, Entity,
+    EntityId, Global, HighlightStyle, StyledText, Subscription, Task, TextStyle, WeakEntity,
 };
-use language::{Buffer, BufferEvent, BufferId, Point};
+use language::{Buffer, BufferEvent, BufferId, Chunk, Point};
+use multi_buffer::MultiBufferRow;
 use picker::{Picker, PickerDelegate};
 use project::{Project, ProjectItem, ProjectPath};
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};
 use std::borrow::BorrowMut;
+use std::collections::HashSet;
 use std::path::Path;
 use std::{fmt::Display, ops::Range, sync::Arc};
-use text::Bias;
+use text::{Bias, ToPoint};
 use theme::ThemeSettings;
 use ui::{
     h_flex, rems, ActiveTheme, Context, Div, FluentBuilder, KeyBinding, ParentElement,
@@ -627,6 +629,11 @@ impl VimGlobals {
         })
         .detach();
 
+        cx.observe_new(move |workspace: &mut Workspace, window, _| {
+            MarksView::register(workspace, window);
+        })
+        .detach();
+
         let mut was_enabled = None;
 
         cx.observe_global::<SettingsStore>(move |cx| {
@@ -1232,6 +1239,366 @@ impl RegistersView {
     }
 }
 
+enum MarksMatchInfo {
+    Path(Arc<Path>),
+    Title(String),
+    Content {
+        line: String,
+        highlights: Vec<(Range<usize>, HighlightStyle)>,
+    },
+}
+
+impl MarksMatchInfo {
+    fn from_chunks<'a>(chunks: impl Iterator<Item = Chunk<'a>>, cx: &App) -> Self {
+        let mut line = String::new();
+        let mut highlights = Vec::new();
+        let mut offset = 0;
+        for chunk in chunks {
+            line.push_str(chunk.text);
+            if let Some(highlight_style) = chunk.syntax_highlight_id {
+                if let Some(highlight) = highlight_style.style(cx.theme().syntax()) {
+                    highlights.push((offset..offset + chunk.text.len(), highlight))
+                }
+            }
+            offset += chunk.text.len();
+        }
+        MarksMatchInfo::Content { line, highlights }
+    }
+}
+
+struct MarksMatch {
+    name: String,
+    position: Point,
+    info: MarksMatchInfo,
+}
+
+pub struct MarksViewDelegate {
+    selected_index: usize,
+    matches: Vec<MarksMatch>,
+    point_column_width: usize,
+    workspace: WeakEntity<Workspace>,
+}
+
+impl PickerDelegate for MarksViewDelegate {
+    type ListItem = Div;
+
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
+        self.selected_index = ix;
+        cx.notify();
+    }
+
+    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+        Arc::default()
+    }
+
+    fn update_matches(
+        &mut self,
+        _: String,
+        _: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> gpui::Task<()> {
+        let Some(workspace) = self.workspace.upgrade().clone() else {
+            return Task::ready(());
+        };
+        cx.spawn(async move |picker, cx| {
+            let mut matches = Vec::new();
+            let _ = workspace.update(cx, |workspace, cx| {
+                let entity_id = cx.entity_id();
+                let Some(editor) = workspace
+                    .active_item(cx)
+                    .and_then(|item| item.act_as::<Editor>(cx))
+                else {
+                    return;
+                };
+                let editor = editor.read(cx);
+                let mut has_seen = HashSet::new();
+                let Some(marks_state) = cx.global::<VimGlobals>().marks.get(&entity_id) else {
+                    return;
+                };
+                let marks_state = marks_state.read(cx);
+
+                if let Some(map) = marks_state
+                    .multibuffer_marks
+                    .get(&editor.buffer().entity_id())
+                {
+                    for (name, anchors) in map {
+                        if has_seen.contains(name) {
+                            continue;
+                        }
+                        has_seen.insert(name.clone());
+                        let Some(anchor) = anchors.first() else {
+                            continue;
+                        };
+
+                        let snapshot = editor.buffer().read(cx).snapshot(cx);
+                        let position = anchor.to_point(&snapshot);
+
+                        let chunks = snapshot.chunks(
+                            Point::new(position.row, 0)
+                                ..Point::new(
+                                    position.row,
+                                    snapshot.line_len(MultiBufferRow(position.row)),
+                                ),
+                            true,
+                        );
+                        matches.push(MarksMatch {
+                            name: name.clone(),
+                            position,
+                            info: MarksMatchInfo::from_chunks(chunks, cx),
+                        })
+                    }
+                }
+
+                if let Some(buffer) = editor.buffer().read(cx).as_singleton() {
+                    let buffer = buffer.read(cx);
+                    if let Some(map) = marks_state.buffer_marks.get(&buffer.remote_id()) {
+                        for (name, anchors) in map {
+                            if has_seen.contains(name) {
+                                continue;
+                            }
+                            has_seen.insert(name.clone());
+                            let Some(anchor) = anchors.first() else {
+                                continue;
+                            };
+                            let snapshot = buffer.snapshot();
+                            let position = anchor.to_point(&snapshot);
+                            let chunks = snapshot.chunks(
+                                Point::new(position.row, 0)
+                                    ..Point::new(position.row, snapshot.line_len(position.row)),
+                                true,
+                            );
+
+                            matches.push(MarksMatch {
+                                name: name.clone(),
+                                position,
+                                info: MarksMatchInfo::from_chunks(chunks, cx),
+                            })
+                        }
+                    }
+                }
+
+                for (name, mark_location) in marks_state.global_marks.iter() {
+                    if has_seen.contains(name) {
+                        continue;
+                    }
+                    has_seen.insert(name.clone());
+
+                    match mark_location {
+                        MarkLocation::Buffer(entity_id) => {
+                            if let Some(&anchor) = marks_state
+                                .multibuffer_marks
+                                .get(entity_id)
+                                .and_then(|map| map.get(name))
+                                .and_then(|anchors| anchors.first())
+                            {
+                                let Some((info, snapshot)) = workspace
+                                    .items(cx)
+                                    .filter_map(|item| item.act_as::<Editor>(cx))
+                                    .map(|entity| entity.read(cx).buffer())
+                                    .find(|buffer| buffer.entity_id().eq(entity_id))
+                                    .map(|buffer| {
+                                        (
+                                            MarksMatchInfo::Title(
+                                                buffer.read(cx).title(cx).to_string(),
+                                            ),
+                                            buffer.read(cx).snapshot(cx),
+                                        )
+                                    })
+                                else {
+                                    continue;
+                                };
+                                matches.push(MarksMatch {
+                                    name: name.clone(),
+                                    position: anchor.to_point(&snapshot),
+                                    info,
+                                });
+                            }
+                        }
+                        MarkLocation::Path(path) => {
+                            if let Some(&position) = marks_state
+                                .serialized_marks
+                                .get(path.as_ref())
+                                .and_then(|map| map.get(name))
+                                .and_then(|points| points.first())
+                            {
+                                let info = MarksMatchInfo::Path(path.clone());
+                                matches.push(MarksMatch {
+                                    name: name.clone(),
+                                    position,
+                                    info,
+                                });
+                            }
+                        }
+                    }
+                }
+            });
+            let _ = picker.update(cx, |picker, cx| {
+                matches.sort_by_key(|a| {
+                    (
+                        a.name.chars().next().map(|c| c.is_ascii_uppercase()),
+                        a.name.clone(),
+                    )
+                });
+                let digits = matches
+                    .iter()
+                    .map(|m| (m.position.row + 1).ilog10() + (m.position.column + 1).ilog10())
+                    .max()
+                    .unwrap_or_default();
+                picker.delegate.matches = matches;
+                picker.delegate.point_column_width = (digits + 4) as usize;
+                cx.notify();
+            });
+        })
+    }
+
+    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        let Some(vim) = self
+            .workspace
+            .upgrade()
+            .map(|w| w.read(cx))
+            .and_then(|w| w.focused_pane(window, cx).read(cx).active_item())
+            .and_then(|item| item.act_as::<Editor>(cx))
+            .and_then(|editor| editor.read(cx).addon::<VimAddon>().cloned())
+            .map(|addon| addon.entity)
+        else {
+            return;
+        };
+        let Some(text): Option<Arc<str>> = self
+            .matches
+            .get(self.selected_index)
+            .map(|m| Arc::from(m.name.to_string().into_boxed_str()))
+        else {
+            return;
+        };
+        vim.update(cx, |vim, cx| {
+            vim.jump(text, false, false, window, cx);
+        });
+
+        cx.emit(DismissEvent);
+    }
+
+    fn dismissed(&mut self, _: &mut Window, _: &mut Context<Picker<Self>>) {}
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let mark_match = self
+            .matches
+            .get(ix)
+            .expect("Invalid matches state: no element for index {ix}");
+
+        let mut left_output = String::new();
+        let mut left_runs = Vec::new();
+        left_output.push('`');
+        left_output.push_str(&mark_match.name);
+        left_runs.push((
+            0..left_output.len(),
+            HighlightStyle::color(cx.theme().colors().text_accent),
+        ));
+        left_output.push(' ');
+        left_output.push(' ');
+        let point_column = format!(
+            "{},{}",
+            mark_match.position.row + 1,
+            mark_match.position.column + 1
+        );
+        left_output.push_str(&point_column);
+        if let Some(padding) = self.point_column_width.checked_sub(point_column.len()) {
+            left_output.push_str(&" ".repeat(padding));
+        }
+
+        let (right_output, right_runs): (String, Vec<_>) = match &mark_match.info {
+            MarksMatchInfo::Path(path) => {
+                let s = path.to_string_lossy().to_string();
+                (
+                    s.clone(),
+                    vec![(0..s.len(), HighlightStyle::color(cx.theme().colors().text))],
+                )
+            }
+            MarksMatchInfo::Title(title) => (
+                title.clone(),
+                vec![(
+                    0..title.len(),
+                    HighlightStyle::color(cx.theme().colors().text),
+                )],
+            ),
+            MarksMatchInfo::Content { line, highlights } => (line.clone(), highlights.clone()),
+        };
+
+        let theme = ThemeSettings::get_global(cx);
+        let text_style = TextStyle {
+            color: cx.theme().colors().editor_foreground,
+            font_family: theme.buffer_font.family.clone(),
+            font_features: theme.buffer_font.features.clone(),
+            font_fallbacks: theme.buffer_font.fallbacks.clone(),
+            font_size: theme.buffer_font_size(cx).into(),
+            line_height: (theme.line_height() * theme.buffer_font_size(cx)).into(),
+            font_weight: theme.buffer_font.weight,
+            font_style: theme.buffer_font.style,
+            ..Default::default()
+        };
+
+        Some(
+            h_flex()
+                .when(selected, |el| el.bg(cx.theme().colors().element_selected))
+                .font_buffer(cx)
+                .text_buffer(cx)
+                .h(theme.buffer_font_size(cx) * theme.line_height())
+                .px_2()
+                .child(StyledText::new(left_output).with_default_highlights(&text_style, left_runs))
+                .child(
+                    StyledText::new(right_output).with_default_highlights(&text_style, right_runs),
+                ),
+        )
+    }
+}
+
+pub struct MarksView {}
+
+impl MarksView {
+    fn register(workspace: &mut Workspace, _window: Option<&mut Window>) {
+        workspace.register_action(|workspace, _: &ToggleMarksView, window, cx| {
+            Self::toggle(workspace, window, cx);
+        });
+    }
+
+    pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
+        let handle = cx.weak_entity();
+        workspace.toggle_modal(window, cx, move |window, cx| {
+            MarksView::new(handle, window, cx)
+        });
+    }
+
+    fn new(
+        workspace: WeakEntity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Picker<MarksViewDelegate>>,
+    ) -> Picker<MarksViewDelegate> {
+        let matches = Vec::default();
+        let delegate = MarksViewDelegate {
+            selected_index: 0,
+            point_column_width: 0,
+            matches,
+            workspace,
+        };
+        Picker::nonsearchable_uniform_list(delegate, window, cx)
+            .width(rems(36.))
+            .modal(true)
+    }
+}
+
 define_connection! (
     pub static ref DB: VimDb<WorkspaceDb> = &[
         sql! (

crates/vim/src/vim.rs 🔗

@@ -144,6 +144,7 @@ actions!(
         PushReplace,
         PushDeleteSurrounds,
         PushMark,
+        ToggleMarksView,
         PushIndent,
         PushOutdent,
         PushAutoIndent,
@@ -1599,7 +1600,7 @@ impl Vim {
                     self.select_register(text, window, cx);
                 }
             },
-            Some(Operator::Jump { line }) => self.jump(text, line, window, cx),
+            Some(Operator::Jump { line }) => self.jump(text, line, true, window, cx),
             _ => {
                 if self.mode == Mode::Replace {
                     self.multi_replace(text, window, cx)