diff --git a/Cargo.lock b/Cargo.lock index 14df2f4c42..b2bcec6ee0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14920,6 +14920,7 @@ dependencies = [ "multi_buffer", "nvim-rs", "parking_lot", + "project", "project_panel", "regex", "release_channel", diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 59a852f1d9..f3cbf2dd96 100644 --- a/crates/editor/src/display_map.rs +++ b/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 { let wrap_row = self .block_snapshot diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 85f4f44fd6..9c4da5929b 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/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 diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index 18b9b7a916..8fe9f293ee 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/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, 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, 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::>(); + + 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::>(); + + (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::(), + 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::>(); + // 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) } diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index d368fc102e..cd6ea3a082 100644 --- a/crates/vim/Cargo.toml +++ b/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 diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 89ad495fd7..7f3b831f08 100644 --- a/crates/vim/src/motion.rs +++ b/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 diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 6c336375ae..76fead6f77 100644 --- a/crates/vim/src/test.rs +++ b/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] + " + }); +} diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index 2c7f1caeef..34eec0408e 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/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::(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::(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