diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 552624c82f..bd3980ecbd 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -240,6 +240,9 @@ ], "g ]": "editor::GoToDiagnostic", "g [": "editor::GoToPrevDiagnostic", + "g i": ["workspace::SendKeystrokes", "` ^ i"], + "g ,": "vim::ChangeListNewer", + "g ;": "vim::ChangeListOlder", "shift-h": "vim::WindowTop", "shift-m": "vim::WindowMiddle", "shift-l": "vim::WindowBottom", diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 5f989bed58..fdb62bc33c 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -477,6 +477,11 @@ impl DisplaySnapshot { .to_inlay_offset(anchor.to_offset(&self.buffer_snapshot)) } + pub fn display_point_to_anchor(&self, point: DisplayPoint, bias: Bias) -> Anchor { + self.buffer_snapshot + .anchor_at(point.to_offset(&self, bias), bias) + } + fn display_point_to_inlay_point(&self, point: DisplayPoint, bias: Bias) -> InlayPoint { let block_point = point.0; let wrap_point = self.block_snapshot.to_wrap_point(block_point); diff --git a/crates/vim/src/change_list.rs b/crates/vim/src/change_list.rs new file mode 100644 index 0000000000..e961c00880 --- /dev/null +++ b/crates/vim/src/change_list.rs @@ -0,0 +1,233 @@ +use editor::{display_map::ToDisplayPoint, movement, scroll::Autoscroll, Bias, Direction, Editor}; +use gpui::{actions, View}; +use ui::{ViewContext, WindowContext}; +use workspace::Workspace; + +use crate::{state::Mode, Vim}; + +actions!(vim, [ChangeListOlder, ChangeListNewer]); + +pub(crate) fn register(workspace: &mut Workspace, _: &mut ViewContext) { + workspace.register_action(|_, _: &ChangeListOlder, cx| { + Vim::update(cx, |vim, cx| { + move_to_change(vim, Direction::Prev, cx); + }) + }); + workspace.register_action(|_, _: &ChangeListNewer, cx| { + Vim::update(cx, |vim, cx| { + move_to_change(vim, Direction::Next, cx); + }) + }); +} + +fn move_to_change(vim: &mut Vim, direction: Direction, cx: &mut WindowContext) { + let count = vim.take_count(cx).unwrap_or(1); + let selections = vim.update_state(|state| { + if state.change_list.is_empty() { + return None; + } + + let prev = state + .change_list_position + .unwrap_or(state.change_list.len()); + let next = if direction == Direction::Prev { + prev.saturating_sub(count) + } else { + (prev + count).min(state.change_list.len() - 1) + }; + state.change_list_position = Some(next); + state.change_list.get(next).cloned() + }); + + let Some(selections) = selections else { + return; + }; + vim.update_active_editor(cx, |_, editor, cx| { + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + let map = s.display_map(); + s.select_display_ranges(selections.into_iter().map(|a| { + let point = a.to_display_point(&map); + point..point + })) + }) + }); +} + +pub(crate) fn push_to_change_list(vim: &mut Vim, editor: View, cx: &mut WindowContext) { + let (map, selections) = + editor.update(cx, |editor, cx| editor.selections.all_adjusted_display(cx)); + + let pop_state = + vim.state() + .change_list + .last() + .map(|previous| { + previous.len() == selections.len() + && previous.iter().enumerate().all(|(ix, p)| { + p.to_display_point(&map).row() == selections[ix].head().row() + }) + }) + .unwrap_or(false); + + let new_positions = selections + .into_iter() + .map(|s| { + let point = if vim.state().mode == Mode::Insert { + movement::saturating_left(&map, s.head()) + } else { + s.head() + }; + map.display_point_to_anchor(point, Bias::Left) + }) + .collect(); + + vim.update_state(|state| { + state.change_list_position.take(); + if pop_state { + state.change_list.pop(); + } + state.change_list.push(new_positions); + }) +} + +#[cfg(test)] +mod test { + use indoc::indoc; + + use crate::{state::Mode, test::NeovimBackedTestContext}; + + #[gpui::test] + async fn test_change_list_insert(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state("ˇ").await; + + cx.simulate_shared_keystrokes([ + "i", "1", "1", "escape", "shift-o", "2", "2", "escape", "shift-g", "o", "3", "3", + "escape", + ]) + .await; + + cx.assert_shared_state(indoc! { + "22 + 11 + 3ˇ3" + }) + .await; + + cx.simulate_shared_keystrokes(["g", ";"]).await; + // NOTE: this matches nvim when I type it into it + // but in tests, nvim always reports the column as 0... + cx.assert_state( + indoc! { + "22 + 11 + 3ˇ3" + }, + Mode::Normal, + ); + cx.simulate_shared_keystrokes(["g", ";"]).await; + cx.assert_state( + indoc! { + "2ˇ2 + 11 + 33" + }, + Mode::Normal, + ); + cx.simulate_shared_keystrokes(["g", ";"]).await; + cx.assert_state( + indoc! { + "22 + 1ˇ1 + 33" + }, + Mode::Normal, + ); + cx.simulate_shared_keystrokes(["g", ","]).await; + cx.assert_state( + indoc! { + "2ˇ2 + 11 + 33" + }, + Mode::Normal, + ); + cx.simulate_shared_keystrokes(["shift-g", "i", "4", "4", "escape"]) + .await; + cx.simulate_shared_keystrokes(["g", ";"]).await; + cx.assert_state( + indoc! { + "22 + 11 + 34ˇ43" + }, + Mode::Normal, + ); + cx.simulate_shared_keystrokes(["g", ";"]).await; + cx.assert_state( + indoc! { + "2ˇ2 + 11 + 3443" + }, + Mode::Normal, + ); + } + + #[gpui::test] + async fn test_change_list_delete(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.set_shared_state(indoc! { + "one two + three fˇour"}) + .await; + cx.simulate_shared_keystrokes(["x", "k", "d", "i", "w", "^", "x"]) + .await; + cx.assert_shared_state(indoc! { + "ˇne• + three fur"}) + .await; + cx.simulate_shared_keystrokes(["2", "g", ";"]).await; + cx.assert_shared_state(indoc! { + "ne• + three fˇur"}) + .await; + cx.simulate_shared_keystrokes(["g", ","]).await; + cx.assert_shared_state(indoc! { + "ˇne• + three fur"}) + .await; + } + + #[gpui::test] + async fn test_gi(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.set_shared_state(indoc! { + "one two + three fˇr"}) + .await; + cx.simulate_shared_keystrokes(["i", "o", "escape", "k", "g", "i"]) + .await; + cx.simulate_shared_keystrokes(["u", "escape"]).await; + cx.assert_shared_state(indoc! { + "one two + three foˇur"}) + .await; + } + + #[gpui::test] + async fn test_dot_mark(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.set_shared_state(indoc! { + "one two + three fˇr"}) + .await; + cx.simulate_shared_keystrokes(["i", "o", "escape", "k", "`", "."]) + .await; + cx.assert_shared_state(indoc! { + "one two + three fˇor"}) + .await; + } +} diff --git a/crates/vim/src/normal/mark.rs b/crates/vim/src/normal/mark.rs index 0c6ff2f137..840528fddf 100644 --- a/crates/vim/src/normal/mark.rs +++ b/crates/vim/src/normal/mark.rs @@ -68,9 +68,11 @@ pub fn create_mark_before(vim: &mut Vim, text: Arc, cx: &mut WindowContext) } pub fn jump(text: Arc, line: bool, cx: &mut WindowContext) { - let anchors = match &*text { - "{" | "}" => Vim::update(cx, |vim, cx| { - vim.update_active_editor(cx, |_, editor, cx| { + let anchors = Vim::update(cx, |vim, cx| { + vim.pop_operator(cx); + + match &*text { + "{" | "}" => vim.update_active_editor(cx, |_, editor, cx| { let (map, selections) = editor.selections.all_display(cx); selections .into_iter() @@ -84,13 +86,10 @@ pub fn jump(text: Arc, line: bool, cx: &mut WindowContext) { .anchor_before(point.to_offset(&map, Bias::Left)) }) .collect::>() - }) - }), - _ => Vim::read(cx).state().marks.get(&*text).cloned(), - }; - - Vim::update(cx, |vim, cx| { - vim.pop_operator(cx); + }), + "." => vim.state().change_list.last().cloned(), + _ => vim.state().marks.get(&*text).cloned(), + } }); let Some(anchors) = anchors else { return }; diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index ed9861a97a..c143935299 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -77,6 +77,8 @@ pub struct EditorState { pub replacements: Vec<(Range, String)>, pub marks: HashMap>, + pub change_list: Vec>, + pub change_list_position: Option, pub current_tx: Option, pub current_anchor: Option>, diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 20b07c8046..b8cfafc04d 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -3,6 +3,7 @@ #[cfg(test)] mod test; +mod change_list; mod command; mod editor_events; mod insert; @@ -17,6 +18,7 @@ mod utils; mod visual; use anyhow::Result; +use change_list::push_to_change_list; use collections::HashMap; use command_palette_hooks::{CommandPaletteFilter, CommandPaletteInterceptor}; use editor::{ @@ -159,6 +161,7 @@ fn register(workspace: &mut Workspace, cx: &mut ViewContext) { replace::register(workspace, cx); object::register(workspace, cx); visual::register(workspace, cx); + change_list::register(workspace, cx); } /// Called whenever an keystroke is typed so vim can observe all actions @@ -264,6 +267,7 @@ impl Vim { EditorEvent::TransactionUndone { transaction_id } => Vim::update(cx, |vim, cx| { vim.transaction_undone(transaction_id, cx); }), + EditorEvent::Edited => Vim::update(cx, |vim, cx| vim.transaction_ended(editor, cx)), _ => {} })); @@ -618,6 +622,10 @@ impl Vim { self.switch_mode(Mode::Normal, true, cx) } + fn transaction_ended(&mut self, editor: View, cx: &mut WindowContext) { + push_to_change_list(self, editor, cx) + } + fn local_selections_changed(&mut self, editor: View, cx: &mut WindowContext) { let newest = editor.read(cx).selections.newest_anchor().clone(); let is_multicursor = editor.read(cx).selections.count() > 1; diff --git a/crates/vim/test_data/test_change_list_delete.json b/crates/vim/test_data/test_change_list_delete.json new file mode 100644 index 0000000000..ad8b1cbd9e --- /dev/null +++ b/crates/vim/test_data/test_change_list_delete.json @@ -0,0 +1,16 @@ +{"Put":{"state":"one two\nthree fˇour"}} +{"Key":"x"} +{"Key":"k"} +{"Key":"d"} +{"Key":"i"} +{"Key":"w"} +{"Key":"^"} +{"Key":"x"} +{"Get":{"state":"ˇne \nthree fur","mode":"Normal"}} +{"Key":"2"} +{"Key":"g"} +{"Key":";"} +{"Get":{"state":"ne \nthree fˇur","mode":"Normal"}} +{"Key":"g"} +{"Key":","} +{"Get":{"state":"ˇne \nthree fur","mode":"Normal"}} diff --git a/crates/vim/test_data/test_change_list_insert.json b/crates/vim/test_data/test_change_list_insert.json new file mode 100644 index 0000000000..d72878d255 --- /dev/null +++ b/crates/vim/test_data/test_change_list_insert.json @@ -0,0 +1,32 @@ +{"Put":{"state":"ˇ"}} +{"Key":"i"} +{"Key":"1"} +{"Key":"1"} +{"Key":"escape"} +{"Key":"shift-o"} +{"Key":"2"} +{"Key":"2"} +{"Key":"escape"} +{"Key":"shift-g"} +{"Key":"o"} +{"Key":"3"} +{"Key":"3"} +{"Key":"escape"} +{"Get":{"state":"22\n11\n3ˇ3","mode":"Normal"}} +{"Key":"g"} +{"Key":";"} +{"Key":"g"} +{"Key":";"} +{"Key":"g"} +{"Key":";"} +{"Key":"g"} +{"Key":","} +{"Key":"shift-g"} +{"Key":"i"} +{"Key":"4"} +{"Key":"4"} +{"Key":"escape"} +{"Key":"g"} +{"Key":";"} +{"Key":"g"} +{"Key":";"} diff --git a/crates/vim/test_data/test_dot_mark.json b/crates/vim/test_data/test_dot_mark.json new file mode 100644 index 0000000000..27edd9d2ab --- /dev/null +++ b/crates/vim/test_data/test_dot_mark.json @@ -0,0 +1,8 @@ +{"Put":{"state":"one two\nthree fˇr"}} +{"Key":"i"} +{"Key":"o"} +{"Key":"escape"} +{"Key":"k"} +{"Key":"`"} +{"Key":"."} +{"Get":{"state":"one two\nthree fˇor","mode":"Normal"}} diff --git a/crates/vim/test_data/test_gi.json b/crates/vim/test_data/test_gi.json new file mode 100644 index 0000000000..a36a919751 --- /dev/null +++ b/crates/vim/test_data/test_gi.json @@ -0,0 +1,10 @@ +{"Put":{"state":"one two\nthree fˇr"}} +{"Key":"i"} +{"Key":"o"} +{"Key":"escape"} +{"Key":"k"} +{"Key":"g"} +{"Key":"i"} +{"Key":"u"} +{"Key":"escape"} +{"Get":{"state":"one two\nthree foˇur","mode":"Normal"}}