vim: Add registers view (#25945)

AidanV and Conrad Irwin created

Closes #18157

Release Notes:

- vim: Added `:reg[isters]` to show the current values of registers

---------

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

Change summary

Cargo.lock                                  |   1 
crates/editor/src/display_map.rs            |   2 
crates/editor/src/display_map/invisibles.rs |   2 
crates/vim/Cargo.toml                       |   1 
crates/vim/src/command.rs                   |   3 
crates/vim/src/state.rs                     | 212 ++++++++++++++++++++++
crates/vim/src/vim.rs                       |   1 
7 files changed, 213 insertions(+), 9 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -14987,6 +14987,7 @@ dependencies = [
  "multi_buffer",
  "nvim-rs",
  "parking_lot",
+ "picker",
  "project",
  "project_panel",
  "regex",

crates/editor/src/display_map.rs 🔗

@@ -43,7 +43,7 @@ use gpui::{App, Context, Entity, Font, HighlightStyle, LineLayout, Pixels, Under
 pub use inlay_map::Inlay;
 use inlay_map::{InlayMap, InlaySnapshot};
 pub use inlay_map::{InlayOffset, InlayPoint};
-use invisibles::{is_invisible, replacement};
+pub use invisibles::{is_invisible, replacement};
 use language::{
     language_settings::language_settings, ChunkRenderer, OffsetUtf16, Point,
     Subscription as BufferSubscription,

crates/editor/src/display_map/invisibles.rs 🔗

@@ -45,7 +45,7 @@ pub fn is_invisible(c: char) -> bool {
 // ASCII control characters have fancy unicode glyphs, everything else
 // is replaced by a space - unless it is used in combining characters in
 // which case we need to leave it in the string.
-pub(crate) fn replacement(c: char) -> Option<&'static str> {
+pub fn replacement(c: char) -> Option<&'static str> {
     if c <= '\x1f' {
         Some(C0_SYMBOLS[c as usize])
     } else if c == '\x7f' {

crates/vim/Cargo.toml 🔗

@@ -31,6 +31,7 @@ libc.workspace = true
 log.workspace = true
 multi_buffer.workspace = true
 nvim-rs = { git = "https://github.com/KillTheMule/nvim-rs", branch = "master", features = ["use_tokio"], optional = true }
+picker.workspace = true
 regex.workspace = true
 schemars.workspace = true
 search.workspace = true

crates/vim/src/command.rs 🔗

@@ -39,7 +39,7 @@ use crate::{
     object::Object,
     state::Mode,
     visual::VisualDeleteLine,
-    Vim,
+    ToggleRegistersView, Vim,
 };
 
 #[derive(Clone, Debug, PartialEq)]
@@ -853,6 +853,7 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
                 .boxed_clone(),
             )
         }),
+        VimCommand::new(("reg", "isters"), ToggleRegistersView).bang(ToggleRegistersView),
         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/state.rs 🔗

@@ -2,20 +2,28 @@ use crate::command::command_interceptor;
 use crate::normal::repeat::Replayer;
 use crate::surrounds::SurroundsType;
 use crate::{motion::Motion, object::Object};
-use crate::{UseSystemClipboard, Vim, VimSettings};
+use crate::{ToggleRegistersView, UseSystemClipboard, Vim, VimSettings};
 use collections::HashMap;
 use command_palette_hooks::{CommandPaletteFilter, CommandPaletteInterceptor};
+use editor::display_map::{is_invisible, replacement};
 use editor::{Anchor, ClipboardSelection, Editor};
 use gpui::{
-    Action, App, BorrowAppContext, ClipboardEntry, ClipboardItem, Entity, Global, WeakEntity,
+    Action, App, BorrowAppContext, ClipboardEntry, ClipboardItem, Entity, Global, HighlightStyle,
+    StyledText, Task, TextStyle, WeakEntity,
 };
 use language::Point;
+use picker::{Picker, PickerDelegate};
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};
 use std::borrow::BorrowMut;
 use std::{fmt::Display, ops::Range, sync::Arc};
-use ui::{Context, KeyBinding, SharedString};
+use theme::ThemeSettings;
+use ui::{
+    h_flex, rems, ActiveTheme, Context, Div, FluentBuilder, KeyBinding, ParentElement,
+    SharedString, Styled, StyledTypography, Window,
+};
 use workspace::searchable::Direction;
+use workspace::Workspace;
 
 #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
 pub enum Mode {
@@ -215,6 +223,11 @@ impl VimGlobals {
         })
         .detach();
 
+        cx.observe_new(|workspace: &mut Workspace, window, _| {
+            RegistersView::register(workspace, window);
+        })
+        .detach();
+
         cx.observe_global::<SettingsStore>(move |cx| {
             if Vim::enabled(cx) {
                 KeyBinding::set_vim_mode(cx, true);
@@ -315,10 +328,10 @@ impl VimGlobals {
     }
 
     pub(crate) fn read_register(
-        &mut self,
+        &self,
         register: Option<char>,
         editor: Option<&mut Editor>,
-        cx: &mut Context<Editor>,
+        cx: &mut App,
     ) -> Option<Register> {
         let Some(register) = register.filter(|reg| *reg != '"') else {
             let setting = VimSettings::get_global(cx).use_system_clipboard;
@@ -363,7 +376,7 @@ impl VimGlobals {
         }
     }
 
-    fn system_clipboard_is_newer(&self, cx: &mut Context<Editor>) -> bool {
+    fn system_clipboard_is_newer(&self, cx: &App) -> bool {
         cx.read_from_clipboard().is_some_and(|item| {
             if let Some(last_state) = &self.last_yank {
                 Some(last_state.as_ref()) != item.text().as_deref()
@@ -599,3 +612,190 @@ impl Operator {
         }
     }
 }
+
+struct RegisterMatch {
+    name: char,
+    contents: SharedString,
+}
+
+pub struct RegistersViewDelegate {
+    selected_index: usize,
+    matches: Vec<RegisterMatch>,
+}
+
+impl PickerDelegate for RegistersViewDelegate {
+    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,
+        _: &mut Context<Picker<Self>>,
+    ) -> gpui::Task<()> {
+        Task::ready(())
+    }
+
+    fn confirm(&mut self, _: bool, _: &mut Window, _: &mut Context<Picker<Self>>) {}
+
+    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 register_match = self
+            .matches
+            .get(ix)
+            .expect("Invalid matches state: no element for index {ix}");
+
+        let mut output = String::new();
+        let mut runs = Vec::new();
+        output.push('"');
+        output.push(register_match.name);
+        runs.push((
+            0..output.len(),
+            HighlightStyle::color(cx.theme().colors().text_accent),
+        ));
+        output.push(' ');
+        output.push(' ');
+        let mut base = output.len();
+        for (ix, c) in register_match.contents.char_indices() {
+            if ix > 100 {
+                break;
+            }
+            let replace = match c {
+                '\t' => Some("\\t".to_string()),
+                '\n' => Some("\\n".to_string()),
+                '\r' => Some("\\r".to_string()),
+                c if is_invisible(c) => {
+                    if c <= '\x1f' {
+                        replacement(c).map(|s| s.to_string())
+                    } else {
+                        Some(format!("\\u{:04X}", c as u32))
+                    }
+                }
+                _ => None,
+            };
+            let Some(replace) = replace else {
+                output.push(c);
+                continue;
+            };
+            output.push_str(&replace);
+            runs.push((
+                base + ix..base + ix + replace.len(),
+                HighlightStyle::color(cx.theme().colors().text_muted),
+            ));
+            base += replace.len() - c.len_utf8();
+        }
+
+        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()
+                .gap_1()
+                .child(StyledText::new(output).with_default_highlights(&text_style, runs)),
+        )
+    }
+}
+
+pub struct RegistersView {}
+
+impl RegistersView {
+    fn register(workspace: &mut Workspace, _window: Option<&mut Window>) {
+        workspace.register_action(|workspace, _: &ToggleRegistersView, window, cx| {
+            Self::toggle(workspace, window, cx);
+        });
+    }
+
+    pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
+        let editor = workspace
+            .active_item(cx)
+            .and_then(|item| item.act_as::<Editor>(cx));
+        workspace.toggle_modal(window, cx, move |window, cx| {
+            RegistersView::new(editor, window, cx)
+        });
+    }
+
+    fn new(
+        editor: Option<Entity<Editor>>,
+        window: &mut Window,
+        cx: &mut Context<Picker<RegistersViewDelegate>>,
+    ) -> Picker<RegistersViewDelegate> {
+        let mut matches = Vec::default();
+        cx.update_global(|globals: &mut VimGlobals, cx| {
+            for name in ['"', '+', '*'] {
+                if let Some(register) = globals.read_register(Some(name), None, cx) {
+                    matches.push(RegisterMatch {
+                        name,
+                        contents: register.text.clone(),
+                    })
+                }
+            }
+            if let Some(editor) = editor {
+                let register = editor.update(cx, |editor, cx| {
+                    globals.read_register(Some('%'), Some(editor), cx)
+                });
+                if let Some(register) = register {
+                    matches.push(RegisterMatch {
+                        name: '%',
+                        contents: register.text.clone(),
+                    })
+                }
+            }
+            for (name, register) in globals.registers.iter() {
+                if ['"', '+', '*', '%'].contains(name) {
+                    continue;
+                };
+                matches.push(RegisterMatch {
+                    name: *name,
+                    contents: register.text.clone(),
+                })
+            }
+        });
+        matches.sort_by(|a, b| a.name.cmp(&b.name));
+        let delegate = RegistersViewDelegate {
+            selected_index: 0,
+            matches,
+        };
+
+        Picker::nonsearchable_uniform_list(delegate, window, cx)
+            .width(rems(36.))
+            .modal(true)
+    }
+}

crates/vim/src/vim.rs 🔗

@@ -152,6 +152,7 @@ actions!(
         PushLowercase,
         PushUppercase,
         PushOppositeCase,
+        ToggleRegistersView,
         PushRegister,
         PushRecordRegister,
         PushReplayRegister,