From cdcc068906636f74a26e4ded50bb7140633188c0 Mon Sep 17 00:00:00 2001 From: Dino Date: Mon, 17 Nov 2025 11:14:49 +0000 Subject: [PATCH] vim: Fix temporary mode exit on end of line (#42742) When using the end of line motion ($) while in temporary mode, the cursor would be placed in insert mode just before the last character instead of after, just like in NeoVim. This happens because `EndOfLine` kind of assumes that we're in `Normal` mode and simply places the cursor in the last character instead of the newline character. This commit moves the cursor one position to the right when exiting temporary mode and the motion used was `Motion::EndOfLine` - Update `vim::normal::Vim.exit_temporary_normal` to now accept a `Option<&Motion>` argument, in case callers want this new logic to potentially be applied Closes #42278 Release Notes: - Fixed temporary mode exit when using `$` to move to the end of the line --- crates/vim/src/normal.rs | 53 ++++++++++++++++++- crates/vim/src/normal/scroll.rs | 2 +- crates/vim/src/normal/yank.rs | 4 +- .../src/test/neovim_backed_test_context.rs | 1 + crates/vim/test_data/test_temporary_mode.json | 27 ++++++++++ 5 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 crates/vim/test_data/test_temporary_mode.json diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index e200c24b94468b141020e12c0230fb1908ffbe8e..fae810d64c587f96c587057615b138b4baabd227 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -386,6 +386,8 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { + let temp_mode_motion = motion.clone(); + match operator { None => self.move_cursor(motion, times, window, cx), Some(Operator::Change) => self.change_motion(motion, times, forced_motion, window, cx), @@ -475,7 +477,7 @@ impl Vim { } } // Exit temporary normal mode (if active). - self.exit_temporary_normal(window, cx); + self.exit_temporary_normal(Some(&temp_mode_motion), window, cx); } pub fn normal_object( @@ -1052,9 +1054,25 @@ impl Vim { }); } - fn exit_temporary_normal(&mut self, window: &mut Window, cx: &mut Context) { + /// If temporary mode is enabled, switches back to insert mode, using the + /// provided `motion` to determine whether to move the cursor before + /// re-enabling insert mode, for example, when `EndOfLine` ($) is used. + fn exit_temporary_normal( + &mut self, + motion: Option<&Motion>, + window: &mut Window, + cx: &mut Context, + ) { if self.temp_mode { self.switch_mode(Mode::Insert, true, window, cx); + + // Since we're switching from `Normal` mode to `Insert` mode, we'll + // move the cursor one position to the right, to ensure that, for + // motions like `EndOfLine` ($), the cursor is actually at the end + // of line and not on the last character. + if matches!(motion, Some(Motion::EndOfLine { .. })) { + self.move_cursor(Motion::Right, Some(1), window, cx); + } } } } @@ -2269,4 +2287,35 @@ mod test { assert_eq!(workspace.active_pane().read(cx).active_item_index(), 1); }); } + + #[gpui::test] + async fn test_temporary_mode(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + // Test jumping to the end of the line ($). + cx.set_shared_state(indoc! {"lorem ˇipsum"}).await; + cx.simulate_shared_keystrokes("i").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("ctrl-o $").await; + cx.shared_state().await.assert_eq(indoc! {"lorem ipsumˇ"}); + + // Test jumping to the next word. + cx.set_shared_state(indoc! {"loremˇ ipsum dolor"}).await; + cx.simulate_shared_keystrokes("a").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("a n d space ctrl-o w").await; + cx.shared_state() + .await + .assert_eq(indoc! {"lorem and ipsum ˇdolor"}); + + // Test yanking to end of line ($). + cx.set_shared_state(indoc! {"lorem ˇipsum dolor"}).await; + cx.simulate_shared_keystrokes("i").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("a n d space ctrl-o y $") + .await; + cx.shared_state() + .await + .assert_eq(indoc! {"lorem and ˇipsum dolor"}); + } } diff --git a/crates/vim/src/normal/scroll.rs b/crates/vim/src/normal/scroll.rs index ff884e3b7393b39b86114338fe2af11e384e1fa0..9346d76323c4fb6c181fb914587a710c94be4537 100644 --- a/crates/vim/src/normal/scroll.rs +++ b/crates/vim/src/normal/scroll.rs @@ -96,7 +96,7 @@ impl Vim { ) { let amount = by(Vim::take_count(cx).map(|c| c as f32)); Vim::take_forced_motion(cx); - self.exit_temporary_normal(window, cx); + self.exit_temporary_normal(None, window, cx); self.update_editor(cx, |_, editor, cx| { scroll_editor(editor, move_cursor, amount, window, cx) }); diff --git a/crates/vim/src/normal/yank.rs b/crates/vim/src/normal/yank.rs index d5a45fca544d61735f62a8f46e849db2c009847f..4f1274dd88359fe8c3eb1b08ab3910c513b2d98d 100644 --- a/crates/vim/src/normal/yank.rs +++ b/crates/vim/src/normal/yank.rs @@ -59,7 +59,7 @@ impl Vim { }); }); }); - self.exit_temporary_normal(window, cx); + self.exit_temporary_normal(None, window, cx); } pub fn yank_object( @@ -90,7 +90,7 @@ impl Vim { }); }); }); - self.exit_temporary_normal(window, cx); + self.exit_temporary_normal(None, window, cx); } pub fn yank_selections_content( diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs index ce2bb6eb7b6f77788f3bc002ff979fdbb251cb94..21cdda111c4fdacaf0871dd087bca01de6f83957 100644 --- a/crates/vim/src/test/neovim_backed_test_context.rs +++ b/crates/vim/src/test/neovim_backed_test_context.rs @@ -31,6 +31,7 @@ pub struct SharedState { } impl SharedState { + /// Assert that both Zed and NeoVim have the same content and mode. #[track_caller] pub fn assert_matches(&self) { if self.neovim != self.editor || self.neovim_mode != self.editor_mode { diff --git a/crates/vim/test_data/test_temporary_mode.json b/crates/vim/test_data/test_temporary_mode.json new file mode 100644 index 0000000000000000000000000000000000000000..be370cf744f9fbd9bfed0a89a6db5ef7b6d568ad --- /dev/null +++ b/crates/vim/test_data/test_temporary_mode.json @@ -0,0 +1,27 @@ +{"Put":{"state":"lorem ˇipsum"}} +{"Key":"i"} +{"Get":{"state":"lorem ˇipsum","mode":"Insert"}} +{"Key":"ctrl-o"} +{"Key":"$"} +{"Get":{"state":"lorem ipsumˇ","mode":"Insert"}} +{"Put":{"state":"loremˇ ipsum dolor"}} +{"Key":"a"} +{"Get":{"state":"lorem ˇipsum dolor","mode":"Insert"}} +{"Key":"a"} +{"Key":"n"} +{"Key":"d"} +{"Key":"space"} +{"Key":"ctrl-o"} +{"Key":"w"} +{"Get":{"state":"lorem and ipsum ˇdolor","mode":"Insert"}} +{"Put":{"state":"lorem ˇipsum dolor"}} +{"Key":"i"} +{"Get":{"state":"lorem ˇipsum dolor","mode":"Insert"}} +{"Key":"a"} +{"Key":"n"} +{"Key":"d"} +{"Key":"space"} +{"Key":"ctrl-o"} +{"Key":"y"} +{"Key":"$"} +{"Get":{"state":"lorem and ˇipsum dolor","mode":"Insert"}}