vim: Add vim counts and vim shortcuts to project_panel (#36653)

AidanV created

Closes #10930 
Closes #11353

Release Notes:

- Adds commands to project_panel
  - `ctrl-u` scrolls the project_panel up half of the visible entries
  - `ctrl-d` scrolls the project_panel down half of the visible entries
  - `z z` scrolls current selection to center of window
  - `z t`  scrolls current selection to top of window
  - `z b` scrolls current selection to bottom of window
  - `{num} j` and `{num} k` now move up and  down with a count

Change summary

Cargo.lock                                |  1 
assets/keymaps/vim.json                   | 25 +++++++-
crates/gpui/src/elements/uniform_list.rs  | 54 +++++++++++++++----
crates/project_panel/src/project_panel.rs | 66 ++++++++++++++++++++++++
crates/vim/Cargo.toml                     |  2 
crates/vim/src/vim.rs                     | 53 ++++++++++++++++++++
6 files changed, 183 insertions(+), 18 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -17707,6 +17707,7 @@ dependencies = [
  "language",
  "log",
  "lsp",
+ "menu",
  "multi_buffer",
  "nvim-rs",
  "parking_lot",

assets/keymaps/vim.json 🔗

@@ -884,10 +884,12 @@
       "/": "project_panel::NewSearchInDirectory",
       "d": "project_panel::NewDirectory",
       "enter": "project_panel::OpenPermanent",
-      "escape": "project_panel::ToggleFocus",
+      "escape": "vim::ToggleProjectPanelFocus",
       "h": "project_panel::CollapseSelectedEntry",
-      "j": "menu::SelectNext",
-      "k": "menu::SelectPrevious",
+      "j": "vim::MenuSelectNext",
+      "k": "vim::MenuSelectPrevious",
+      "down": "vim::MenuSelectNext",
+      "up": "vim::MenuSelectPrevious",
       "l": "project_panel::ExpandSelectedEntry",
       "shift-d": "project_panel::Delete",
       "shift-r": "project_panel::Rename",
@@ -906,7 +908,22 @@
       "{": "project_panel::SelectPrevDirectory",
       "shift-g": "menu::SelectLast",
       "g g": "menu::SelectFirst",
-      "-": "project_panel::SelectParent"
+      "-": "project_panel::SelectParent",
+      "ctrl-u": "project_panel::ScrollUp",
+      "ctrl-d": "project_panel::ScrollDown",
+      "z t": "project_panel::ScrollCursorTop",
+      "z z": "project_panel::ScrollCursorCenter",
+      "z b": "project_panel::ScrollCursorBottom",
+      "0": ["vim::Number", 0],
+      "1": ["vim::Number", 1],
+      "2": ["vim::Number", 2],
+      "3": ["vim::Number", 3],
+      "4": ["vim::Number", 4],
+      "5": ["vim::Number", 5],
+      "6": ["vim::Number", 6],
+      "7": ["vim::Number", 7],
+      "8": ["vim::Number", 8],
+      "9": ["vim::Number", 9]
     }
   },
   {

crates/gpui/src/elements/uniform_list.rs 🔗

@@ -88,6 +88,10 @@ pub enum ScrollStrategy {
     /// May not be possible if there's not enough list items above the item scrolled to:
     /// in this case, the element will be placed at the closest possible position.
     Center,
+    /// Attempt to place the element at the bottom of the list's viewport.
+    /// May not be possible if there's not enough list items above the item scrolled to:
+    /// in this case, the element will be placed at the closest possible position.
+    Bottom,
 }
 
 #[derive(Clone, Copy, Debug)]
@@ -99,6 +103,7 @@ pub struct DeferredScrollToItem {
     pub strategy: ScrollStrategy,
     /// The offset in number of items
     pub offset: usize,
+    pub scroll_strict: bool,
 }
 
 #[derive(Clone, Debug, Default)]
@@ -133,12 +138,23 @@ impl UniformListScrollHandle {
         })))
     }
 
-    /// Scroll the list to the given item index.
+    /// Scroll the list so that the given item index is onscreen.
     pub fn scroll_to_item(&self, ix: usize, strategy: ScrollStrategy) {
         self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem {
             item_index: ix,
             strategy,
             offset: 0,
+            scroll_strict: false,
+        });
+    }
+
+    /// Scroll the list so that the given item index is at scroll strategy position.
+    pub fn scroll_to_item_strict(&self, ix: usize, strategy: ScrollStrategy) {
+        self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem {
+            item_index: ix,
+            strategy,
+            offset: 0,
+            scroll_strict: true,
         });
     }
 
@@ -152,6 +168,7 @@ impl UniformListScrollHandle {
             item_index: ix,
             strategy,
             offset,
+            scroll_strict: false,
         });
     }
 
@@ -368,24 +385,35 @@ impl Element for UniformList {
                             updated_scroll_offset.y = -(item_bottom - list_height) - padding.bottom;
                         }
 
-                        match deferred_scroll.strategy {
-                            ScrollStrategy::Top => {}
-                            ScrollStrategy::Center => {
-                                if scrolled_to_top {
+                        if deferred_scroll.scroll_strict
+                            || (scrolled_to_top
+                                && (item_top < scroll_top + offset_pixels
+                                    || item_bottom > scroll_top + list_height))
+                        {
+                            match deferred_scroll.strategy {
+                                ScrollStrategy::Top => {
+                                    updated_scroll_offset.y = -item_top
+                                        .max(Pixels::ZERO)
+                                        .min(content_height - list_height)
+                                        .max(Pixels::ZERO);
+                                }
+                                ScrollStrategy::Center => {
                                     let item_center = item_top + item_height / 2.0;
 
                                     let viewport_height = list_height - offset_pixels;
                                     let viewport_center = offset_pixels + viewport_height / 2.0;
                                     let target_scroll_top = item_center - viewport_center;
 
-                                    if item_top < scroll_top + offset_pixels
-                                        || item_bottom > scroll_top + list_height
-                                    {
-                                        updated_scroll_offset.y = -target_scroll_top
-                                            .max(Pixels::ZERO)
-                                            .min(content_height - list_height)
-                                            .max(Pixels::ZERO);
-                                    }
+                                    updated_scroll_offset.y = -target_scroll_top
+                                        .max(Pixels::ZERO)
+                                        .min(content_height - list_height)
+                                        .max(Pixels::ZERO);
+                                }
+                                ScrollStrategy::Bottom => {
+                                    updated_scroll_offset.y = -(item_bottom - list_height)
+                                        .max(Pixels::ZERO)
+                                        .min(content_height - list_height)
+                                        .max(Pixels::ZERO);
                                 }
                             }
                         }

crates/project_panel/src/project_panel.rs 🔗

@@ -85,6 +85,7 @@ pub struct ProjectPanel {
     // An update loop that keeps incrementing/decrementing scroll offset while there is a dragged entry that's
     // hovered over the start/end of a list.
     hover_scroll_task: Option<Task<()>>,
+    rendered_entries_len: usize,
     visible_entries: Vec<VisibleEntriesForWorktree>,
     /// Maps from leaf project entry ID to the currently selected ancestor.
     /// Relevant only for auto-fold dirs, where a single project panel entry may actually consist of several
@@ -278,6 +279,16 @@ actions!(
         UnfoldDirectory,
         /// Folds the selected directory.
         FoldDirectory,
+        /// Scroll half a page upwards
+        ScrollUp,
+        /// Scroll half a page downwards
+        ScrollDown,
+        /// Scroll until the cursor displays at the center
+        ScrollCursorCenter,
+        /// Scroll until the cursor displays at the top
+        ScrollCursorTop,
+        /// Scroll until the cursor displays at the bottom
+        ScrollCursorBottom,
         /// Selects the parent directory.
         SelectParent,
         /// Selects the next entry with git changes.
@@ -634,6 +645,7 @@ impl ProjectPanel {
                 hover_scroll_task: None,
                 fs: workspace.app_state().fs.clone(),
                 focus_handle,
+                rendered_entries_len: 0,
                 visible_entries: Default::default(),
                 ancestors: Default::default(),
                 folded_directory_drag_target: None,
@@ -2080,6 +2092,52 @@ impl ProjectPanel {
         }
     }
 
+    fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context<Self>) {
+        for _ in 0..self.rendered_entries_len / 2 {
+            window.dispatch_action(SelectPrevious.boxed_clone(), cx);
+        }
+    }
+
+    fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context<Self>) {
+        for _ in 0..self.rendered_entries_len / 2 {
+            window.dispatch_action(SelectNext.boxed_clone(), cx);
+        }
+    }
+
+    fn scroll_cursor_center(
+        &mut self,
+        _: &ScrollCursorCenter,
+        _: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
+            self.scroll_handle
+                .scroll_to_item_strict(index, ScrollStrategy::Center);
+            cx.notify();
+        }
+    }
+
+    fn scroll_cursor_top(&mut self, _: &ScrollCursorTop, _: &mut Window, cx: &mut Context<Self>) {
+        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
+            self.scroll_handle
+                .scroll_to_item_strict(index, ScrollStrategy::Top);
+            cx.notify();
+        }
+    }
+
+    fn scroll_cursor_bottom(
+        &mut self,
+        _: &ScrollCursorBottom,
+        _: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
+            self.scroll_handle
+                .scroll_to_item_strict(index, ScrollStrategy::Bottom);
+            cx.notify();
+        }
+    }
+
     fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
         if let Some(edit_state) = &self.edit_state
             && edit_state.processing_filename.is_none()
@@ -5198,6 +5256,11 @@ impl Render for ProjectPanel {
                     },
                 ))
                 .key_context(self.dispatch_context(window, cx))
+                .on_action(cx.listener(Self::scroll_up))
+                .on_action(cx.listener(Self::scroll_down))
+                .on_action(cx.listener(Self::scroll_cursor_center))
+                .on_action(cx.listener(Self::scroll_cursor_top))
+                .on_action(cx.listener(Self::scroll_cursor_bottom))
                 .on_action(cx.listener(Self::select_next))
                 .on_action(cx.listener(Self::select_previous))
                 .on_action(cx.listener(Self::select_first))
@@ -5272,7 +5335,8 @@ impl Render for ProjectPanel {
                         .child(
                             uniform_list("entries", item_count, {
                                 cx.processor(|this, range: Range<usize>, window, cx| {
-                                    let mut items = Vec::with_capacity(range.end - range.start);
+                                    this.rendered_entries_len = range.end - range.start;
+                                    let mut items = Vec::with_capacity(this.rendered_entries_len);
                                     this.for_each_visible_entry(
                                         range,
                                         window,

crates/vim/Cargo.toml 🔗

@@ -34,6 +34,7 @@ multi_buffer.workspace = true
 nvim-rs = { git = "https://github.com/KillTheMule/nvim-rs", rev = "764dd270c642f77f10f3e19d05cc178a6cbe69f3", features = ["use_tokio"], optional = true }
 picker.workspace = true
 project.workspace = true
+project_panel.workspace = true
 regex.workspace = true
 schemars.workspace = true
 search.workspace = true
@@ -43,6 +44,7 @@ settings.workspace = true
 task.workspace = true
 text.workspace = true
 theme.workspace = true
+menu.workspace = true
 tokio = { version = "1.15", features = ["full"], optional = true }
 ui.workspace = true
 util.workspace = true

crates/vim/src/vim.rs 🔗

@@ -240,6 +240,12 @@ actions!(
         PushReplaceWithRegister,
         /// Toggles comments.
         PushToggleComments,
+        /// Selects (count) next menu item
+        MenuSelectNext,
+        /// Selects (count) previous menu item
+        MenuSelectPrevious,
+        /// Clears count or toggles project panel focus
+        ToggleProjectPanelFocus,
         /// Starts a match operation.
         PushHelixMatch,
     ]
@@ -271,6 +277,53 @@ pub fn init(cx: &mut App) {
             })
         });
 
+        workspace.register_action(|_, _: &MenuSelectNext, window, cx| {
+            let count = Vim::take_count(cx).unwrap_or(1);
+
+            for _ in 0..count {
+                window.dispatch_action(menu::SelectNext.boxed_clone(), cx);
+            }
+        });
+
+        workspace.register_action(|_, _: &MenuSelectPrevious, window, cx| {
+            let count = Vim::take_count(cx).unwrap_or(1);
+
+            for _ in 0..count {
+                window.dispatch_action(menu::SelectPrevious.boxed_clone(), cx);
+            }
+        });
+
+        workspace.register_action(|_, _: &ToggleProjectPanelFocus, window, cx| {
+            if Vim::take_count(cx).is_none() {
+                window.dispatch_action(project_panel::ToggleFocus.boxed_clone(), cx);
+            }
+        });
+
+        workspace.register_action(|workspace, n: &Number, window, cx| {
+            let vim = workspace
+                .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());
+            if let Some(vim) = vim {
+                let digit = n.0;
+                vim.entity.update(cx, |_, cx| {
+                    cx.defer_in(window, move |vim, window, cx| {
+                        vim.push_count_digit(digit, window, cx)
+                    })
+                });
+            } else {
+                let count = Vim::globals(cx).pre_count.unwrap_or(0);
+                Vim::globals(cx).pre_count = Some(
+                    count
+                        .checked_mul(10)
+                        .and_then(|c| c.checked_add(n.0))
+                        .unwrap_or(count),
+                );
+            };
+        });
+
         workspace.register_action(|_, _: &OpenDefaultKeymap, _, cx| {
             cx.emit(workspace::Event::OpenBundledFile {
                 text: settings::vim_keymap(),