vim: Fix key navigation on folded buffer headers (#25944)

Conrad Irwin and João Marcos created

Closes #24243

Release Notes:

- vim: Fix j/k on folded multibuffer headers

---------

Co-authored-by: João Marcos <marcospb19@hotmail.com>

Change summary

Cargo.lock                                    |   1 
crates/editor/src/display_map.rs              |   5 
crates/editor/src/display_map/block_map.rs    |   9 
crates/editor/src/test/editor_test_context.rs |  91 +++++++++
crates/vim/Cargo.toml                         |   1 
crates/vim/src/motion.rs                      |  17 +
crates/vim/src/test.rs                        | 205 ++++++++++++++++++++
crates/vim/src/test/vim_test_context.rs       |  36 ++-
8 files changed, 348 insertions(+), 17 deletions(-)

Detailed changes

Cargo.lock 🔗

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

crates/editor/src/display_map.rs 🔗

@@ -1124,6 +1124,11 @@ impl DisplaySnapshot {
         self.block_snapshot.is_block_line(BlockRow(display_row.0))
     }
 
+    pub fn is_folded_buffer_header(&self, display_row: DisplayRow) -> bool {
+        self.block_snapshot
+            .is_folded_buffer_header(BlockRow(display_row.0))
+    }
+
     pub fn soft_wrap_indent(&self, display_row: DisplayRow) -> Option<u32> {
         let wrap_row = self
             .block_snapshot

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

@@ -1618,6 +1618,15 @@ impl BlockSnapshot {
         cursor.item().map_or(false, |t| t.block.is_some())
     }
 
+    pub(super) fn is_folded_buffer_header(&self, row: BlockRow) -> bool {
+        let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>(&());
+        cursor.seek(&row, Bias::Right, &());
+        let Some(transform) = cursor.item() else {
+            return false;
+        };
+        matches!(transform.block, Some(Block::FoldedBuffer { .. }))
+    }
+
     pub(super) fn is_line_replaced(&self, row: MultiBufferRow) -> bool {
         let wrap_point = self
             .wrap_snapshot

crates/editor/src/test/editor_test_context.rs 🔗

@@ -89,6 +89,16 @@ impl EditorTestContext {
         Path::new("/root")
     }
 
+    pub async fn for_editor_in(editor: Entity<Editor>, cx: &mut gpui::VisualTestContext) -> Self {
+        cx.focus(&editor);
+        Self {
+            window: cx.windows()[0],
+            cx: cx.clone(),
+            editor,
+            assertion_cx: AssertionContextManager::new(),
+        }
+    }
+
     pub async fn for_editor(editor: WindowHandle<Editor>, cx: &mut gpui::TestAppContext) -> Self {
         let editor_view = editor.root(cx).unwrap();
         Self {
@@ -381,6 +391,76 @@ impl EditorTestContext {
         assert_state_with_diff(&self.editor, &mut self.cx, &expected_diff_text);
     }
 
+    #[track_caller]
+    pub fn assert_excerpts_with_selections(&mut self, marked_text: &str) {
+        let expected_excerpts = marked_text
+            .strip_prefix("[EXCERPT]\n")
+            .unwrap()
+            .split("[EXCERPT]\n")
+            .collect::<Vec<_>>();
+
+        let (selections, excerpts) = self.update_editor(|editor, _, cx| {
+            let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx);
+
+            let selections = editor.selections.disjoint_anchors();
+            let excerpts = multibuffer_snapshot
+                .excerpts()
+                .map(|(e_id, snapshot, range)| (e_id, snapshot.clone(), range))
+                .collect::<Vec<_>>();
+
+            (selections, excerpts)
+        });
+
+        assert_eq!(excerpts.len(), expected_excerpts.len());
+
+        for (ix, (excerpt_id, snapshot, range)) in excerpts.into_iter().enumerate() {
+            let is_folded = self
+                .update_editor(|editor, _, cx| editor.is_buffer_folded(snapshot.remote_id(), cx));
+            let (expected_text, expected_selections) =
+                marked_text_ranges(expected_excerpts[ix], true);
+            if expected_text == "[FOLDED]\n" {
+                assert!(is_folded, "excerpt {} should be folded", ix);
+                let is_selected = selections.iter().any(|s| s.head().excerpt_id == excerpt_id);
+                if expected_selections.len() > 0 {
+                    assert!(
+                        is_selected,
+                        "excerpt {} should be selected. Got {:?}",
+                        ix,
+                        self.editor_state()
+                    );
+                } else {
+                    assert!(!is_selected, "excerpt {} should not be selected", ix);
+                }
+                continue;
+            }
+            assert!(!is_folded, "excerpt {} should not be folded", ix);
+            assert_eq!(
+                snapshot
+                    .text_for_range(range.context.clone())
+                    .collect::<String>(),
+                expected_text
+            );
+
+            let selections = selections
+                .iter()
+                .filter(|s| s.head().excerpt_id == excerpt_id)
+                .map(|s| {
+                    let head = text::ToOffset::to_offset(&s.head().text_anchor, &snapshot)
+                        - text::ToOffset::to_offset(&range.context.start, &snapshot);
+                    let tail = text::ToOffset::to_offset(&s.head().text_anchor, &snapshot)
+                        - text::ToOffset::to_offset(&range.context.start, &snapshot);
+                    tail..head
+                })
+                .collect::<Vec<_>>();
+            // todo: selections that cross excerpt boundaries..
+            assert_eq!(
+                selections, expected_selections,
+                "excerpt {} has incorrect selections",
+                ix,
+            );
+        }
+    }
+
     /// Make an assertion about the editor's text and the ranges and directions
     /// of its selections using a string containing embedded range markers.
     ///
@@ -392,6 +472,17 @@ impl EditorTestContext {
         self.assert_selections(expected_selections, marked_text.to_string())
     }
 
+    /// Make an assertion about the editor's text and the ranges and directions
+    /// of its selections using a string containing embedded range markers.
+    ///
+    /// See the `util::test::marked_text_ranges` function for more information.
+    #[track_caller]
+    pub fn assert_display_state(&mut self, marked_text: &str) {
+        let (expected_text, expected_selections) = marked_text_ranges(marked_text, true);
+        pretty_assertions::assert_eq!(self.display_text(), expected_text, "unexpected buffer text");
+        self.assert_selections(expected_selections, marked_text.to_string())
+    }
+
     pub fn editor_state(&mut self) -> String {
         generate_marked_text(self.buffer_text().as_str(), &self.editor_selections(), true)
     }

crates/vim/Cargo.toml 🔗

@@ -55,6 +55,7 @@ git_ui.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 indoc.workspace = true
 language = { workspace = true, features = ["test-support"] }
+project = { workspace = true, features = ["test-support"] }
 lsp = { workspace = true, features = ["test-support"] }
 parking_lot.workspace = true
 project_panel.workspace = true

crates/vim/src/motion.rs 🔗

@@ -1329,12 +1329,25 @@ pub(crate) fn start_of_relative_buffer_row(
 
 fn up_down_buffer_rows(
     map: &DisplaySnapshot,
-    point: DisplayPoint,
+    mut point: DisplayPoint,
     mut goal: SelectionGoal,
-    times: isize,
+    mut times: isize,
     text_layout_details: &TextLayoutDetails,
 ) -> (DisplayPoint, SelectionGoal) {
     let bias = if times < 0 { Bias::Left } else { Bias::Right };
+
+    while map.is_folded_buffer_header(point.row()) {
+        if times < 0 {
+            (point, _) = movement::up(map, point, goal, true, text_layout_details);
+            times += 1;
+        } else if times > 0 {
+            (point, _) = movement::down(map, point, goal, true, text_layout_details);
+            times -= 1;
+        } else {
+            break;
+        }
+    }
+
     let start = map.display_point_to_fold_point(point, Bias::Left);
     let begin_folded_line = map.fold_point_to_display_point(
         map.fold_snapshot

crates/vim/src/test.rs 🔗

@@ -6,9 +6,13 @@ use std::time::Duration;
 
 use collections::HashMap;
 use command_palette::CommandPalette;
-use editor::{actions::DeleteLine, display_map::DisplayRow, DisplayPoint};
+use editor::{
+    actions::DeleteLine, display_map::DisplayRow, test::editor_test_context::EditorTestContext,
+    DisplayPoint, Editor, EditorMode, MultiBuffer,
+};
 use futures::StreamExt;
 use gpui::{KeyBinding, Modifiers, MouseButton, TestAppContext};
+use language::Point;
 pub use neovim_backed_test_context::*;
 use settings::SettingsStore;
 pub use vim_test_context::*;
@@ -1707,3 +1711,202 @@ async fn test_ctrl_o_dot(cx: &mut gpui::TestAppContext) {
     cx.simulate_shared_keystrokes("l l escape .").await;
     cx.shared_state().await.assert_eq("hellˇllo world.");
 }
+
+#[gpui::test]
+async fn test_folded_multibuffer_excerpts(cx: &mut gpui::TestAppContext) {
+    VimTestContext::init(cx);
+    cx.update(|cx| {
+        VimTestContext::init_keybindings(true, cx);
+    });
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        let multi_buffer = MultiBuffer::build_multi(
+            [
+                ("111\n222\n333\n444\n", vec![Point::row_range(0..2)]),
+                ("aaa\nbbb\nccc\nddd\n", vec![Point::row_range(0..2)]),
+                ("AAA\nBBB\nCCC\nDDD\n", vec![Point::row_range(0..2)]),
+                ("one\ntwo\nthr\nfou\n", vec![Point::row_range(0..2)]),
+            ],
+            cx,
+        );
+        let mut editor = Editor::new(
+            EditorMode::Full,
+            multi_buffer.clone(),
+            None,
+            true,
+            window,
+            cx,
+        );
+
+        let buffer_ids = multi_buffer.read(cx).excerpt_buffer_ids();
+        // fold all but the second buffer, so that we test navigating between two
+        // adjacent folded buffers, as well as folded buffers at the start and
+        // end the multibuffer
+        editor.fold_buffer(buffer_ids[0], cx);
+        editor.fold_buffer(buffer_ids[2], cx);
+        editor.fold_buffer(buffer_ids[3], cx);
+
+        editor
+    });
+    let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await;
+
+    cx.assert_excerpts_with_selections(indoc! {"
+        [EXCERPT]
+        ˇ[FOLDED]
+        [EXCERPT]
+        aaa
+        bbb
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        [FOLDED]
+        "
+    });
+    cx.simulate_keystroke("j");
+    cx.assert_excerpts_with_selections(indoc! {"
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        ˇaaa
+        bbb
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        [FOLDED]
+        "
+    });
+    cx.simulate_keystroke("j");
+    cx.simulate_keystroke("j");
+    cx.assert_excerpts_with_selections(indoc! {"
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        aaa
+        bbb
+        ˇ[EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        [FOLDED]
+        "
+    });
+    cx.simulate_keystroke("j");
+    cx.assert_excerpts_with_selections(indoc! {"
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        aaa
+        bbb
+        [EXCERPT]
+        ˇ[FOLDED]
+        [EXCERPT]
+        [FOLDED]
+        "
+    });
+    cx.simulate_keystroke("j");
+    cx.assert_excerpts_with_selections(indoc! {"
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        aaa
+        bbb
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        ˇ[FOLDED]
+        "
+    });
+    cx.simulate_keystroke("k");
+    cx.assert_excerpts_with_selections(indoc! {"
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        aaa
+        bbb
+        [EXCERPT]
+        ˇ[FOLDED]
+        [EXCERPT]
+        [FOLDED]
+        "
+    });
+    cx.simulate_keystroke("k");
+    cx.simulate_keystroke("k");
+    cx.simulate_keystroke("k");
+    cx.assert_excerpts_with_selections(indoc! {"
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        ˇaaa
+        bbb
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        [FOLDED]
+        "
+    });
+    cx.simulate_keystroke("k");
+    cx.assert_excerpts_with_selections(indoc! {"
+        [EXCERPT]
+        ˇ[FOLDED]
+        [EXCERPT]
+        aaa
+        bbb
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        [FOLDED]
+        "
+    });
+    cx.simulate_keystroke("shift-g");
+    cx.assert_excerpts_with_selections(indoc! {"
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        aaa
+        bbb
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        ˇ[FOLDED]
+        "
+    });
+    cx.simulate_keystrokes("g g");
+    cx.assert_excerpts_with_selections(indoc! {"
+        [EXCERPT]
+        ˇ[FOLDED]
+        [EXCERPT]
+        aaa
+        bbb
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        [FOLDED]
+        "
+    });
+    cx.update_editor(|editor, _, cx| {
+        let buffer_ids = editor.buffer().read(cx).excerpt_buffer_ids();
+        editor.fold_buffer(buffer_ids[1], cx);
+    });
+
+    cx.assert_excerpts_with_selections(indoc! {"
+        [EXCERPT]
+        ˇ[FOLDED]
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        [FOLDED]
+        "
+    });
+    cx.simulate_keystrokes("2 j");
+    cx.assert_excerpts_with_selections(indoc! {"
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        [FOLDED]
+        [EXCERPT]
+        ˇ[FOLDED]
+        [EXCERPT]
+        [FOLDED]
+        "
+    });
+}

crates/vim/src/test/vim_test_context.rs 🔗

@@ -24,6 +24,10 @@ impl VimTestContext {
             git_ui::init(cx);
             crate::init(cx);
             search::init(cx);
+            language::init(cx);
+            editor::init_settings(cx);
+            project::Project::init_settings(cx);
+            theme::init(theme::LoadThemes::JustBase, cx);
         });
     }
 
@@ -56,22 +60,26 @@ impl VimTestContext {
         )
     }
 
+    pub fn init_keybindings(enabled: bool, cx: &mut App) {
+        SettingsStore::update_global(cx, |store, cx| {
+            store.update_user_settings::<VimModeSetting>(cx, |s| *s = Some(enabled));
+        });
+        let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
+            "keymaps/default-macos.json",
+            cx,
+        )
+        .unwrap();
+        cx.bind_keys(default_key_bindings);
+        if enabled {
+            let vim_key_bindings =
+                settings::KeymapFile::load_asset("keymaps/vim.json", cx).unwrap();
+            cx.bind_keys(vim_key_bindings);
+        }
+    }
+
     pub fn new_with_lsp(mut cx: EditorLspTestContext, enabled: bool) -> VimTestContext {
         cx.update(|_, cx| {
-            SettingsStore::update_global(cx, |store, cx| {
-                store.update_user_settings::<VimModeSetting>(cx, |s| *s = Some(enabled));
-            });
-            let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
-                "keymaps/default-macos.json",
-                cx,
-            )
-            .unwrap();
-            cx.bind_keys(default_key_bindings);
-            if enabled {
-                let vim_key_bindings =
-                    settings::KeymapFile::load_asset("keymaps/vim.json", cx).unwrap();
-                cx.bind_keys(vim_key_bindings);
-            }
+            Self::init_keybindings(enabled, cx);
         });
 
         // Setup search toolbars and keypress hook