From 8e8a772c2d40348740125b7ca851c94fdac0072b Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 8 Jul 2025 21:24:43 -0600 Subject: [PATCH] vim: Add U to undo last line (#33571) Closes #14760 Still TODO: * Vim actually undoes *many* changes if they're all on the same line. Release Notes: - vim: Add `U` to return to the last changed line and undo --- assets/keymaps/vim.json | 1 + crates/editor/src/editor.rs | 42 +++- crates/vim/src/normal.rs | 216 +++++++++++++++++- crates/vim/src/vim.rs | 2 + crates/vim/test_data/test_undo_last_line.json | 14 ++ .../test_undo_last_line_newline.json | 15 ++ ...t_undo_last_line_newline_many_changes.json | 21 ++ 7 files changed, 303 insertions(+), 8 deletions(-) create mode 100644 crates/vim/test_data/test_undo_last_line.json create mode 100644 crates/vim/test_data/test_undo_last_line_newline.json create mode 100644 crates/vim/test_data/test_undo_last_line_newline_many_changes.json diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 4b48b26ef4..571192a479 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -364,6 +364,7 @@ "p": "vim::Paste", "shift-p": ["vim::Paste", { "before": true }], "u": "vim::Undo", + "shift-u": "vim::UndoLastLine", "r": "vim::PushReplace", "s": "vim::Substitute", "shift-s": "vim::SubstituteLine", diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 6d529287a7..03e2124742 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -865,9 +865,19 @@ pub trait Addon: 'static { } } +struct ChangeLocation { + current: Option>, + original: Vec, +} +impl ChangeLocation { + fn locations(&self) -> &[Anchor] { + self.current.as_ref().unwrap_or(&self.original) + } +} + /// A set of caret positions, registered when the editor was edited. pub struct ChangeList { - changes: Vec>, + changes: Vec, /// Currently "selected" change. position: Option, } @@ -894,20 +904,38 @@ impl ChangeList { (prev + count).min(self.changes.len() - 1) }; self.position = Some(next); - self.changes.get(next).map(|anchors| anchors.as_slice()) + self.changes.get(next).map(|change| change.locations()) } /// Adds a new change to the list, resetting the change list position. - pub fn push_to_change_list(&mut self, pop_state: bool, new_positions: Vec) { + pub fn push_to_change_list(&mut self, group: bool, new_positions: Vec) { self.position.take(); - if pop_state { - self.changes.pop(); + if let Some(last) = self.changes.last_mut() + && group + { + last.current = Some(new_positions) + } else { + self.changes.push(ChangeLocation { + original: new_positions, + current: None, + }); } - self.changes.push(new_positions.clone()); } pub fn last(&self) -> Option<&[Anchor]> { - self.changes.last().map(|anchors| anchors.as_slice()) + self.changes.last().map(|change| change.locations()) + } + + pub fn last_before_grouping(&self) -> Option<&[Anchor]> { + self.changes.last().map(|change| change.original.as_slice()) + } + + pub fn invert_last_group(&mut self) { + if let Some(last) = self.changes.last_mut() { + if let Some(current) = last.current.as_mut() { + mem::swap(&mut last.original, current); + } + } } } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index f772c446fe..baaf6bc3c4 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -24,9 +24,9 @@ use crate::{ }; use collections::BTreeSet; use convert::ConvertTarget; -use editor::Bias; use editor::Editor; use editor::{Anchor, SelectionEffects}; +use editor::{Bias, ToPoint}; use editor::{display_map::ToDisplayPoint, movement}; use gpui::{Context, Window, actions}; use language::{Point, SelectionGoal}; @@ -90,6 +90,8 @@ actions!( Undo, /// Redoes the last undone change. Redo, + /// Undoes all changes to the most recently changed line. + UndoLastLine, ] ); @@ -194,6 +196,120 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { } }); }); + Vim::action(editor, cx, |vim, _: &UndoLastLine, window, cx| { + Vim::take_forced_motion(cx); + vim.update_editor(window, cx, |vim, editor, window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let Some(last_change) = editor.change_list.last_before_grouping() else { + return; + }; + + let anchors = last_change.iter().cloned().collect::>(); + let mut last_row = None; + let ranges: Vec<_> = anchors + .iter() + .filter_map(|anchor| { + let point = anchor.to_point(&snapshot); + if last_row == Some(point.row) { + return None; + } + last_row = Some(point.row); + let line_range = Point::new(point.row, 0) + ..Point::new(point.row, snapshot.line_len(MultiBufferRow(point.row))); + Some(( + snapshot.anchor_before(line_range.start) + ..snapshot.anchor_after(line_range.end), + line_range, + )) + }) + .collect(); + + let edits = editor.buffer().update(cx, |buffer, cx| { + let current_content = ranges + .iter() + .map(|(anchors, _)| { + buffer + .snapshot(cx) + .text_for_range(anchors.clone()) + .collect::() + }) + .collect::>(); + let mut content_before_undo = current_content.clone(); + let mut undo_count = 0; + + loop { + let undone_tx = buffer.undo(cx); + undo_count += 1; + let mut content_after_undo = Vec::new(); + + let mut line_changed = false; + for ((anchors, _), text_before_undo) in + ranges.iter().zip(content_before_undo.iter()) + { + let snapshot = buffer.snapshot(cx); + let text_after_undo = + snapshot.text_for_range(anchors.clone()).collect::(); + + if &text_after_undo != text_before_undo { + line_changed = true; + } + content_after_undo.push(text_after_undo); + } + + content_before_undo = content_after_undo; + if !line_changed { + break; + } + if undone_tx == vim.undo_last_line_tx { + break; + } + } + + let edits = ranges + .into_iter() + .zip(content_before_undo.into_iter().zip(current_content)) + .filter_map(|((_, mut points), (mut old_text, new_text))| { + if new_text == old_text { + return None; + } + let common_suffix_starts_at = old_text + .char_indices() + .rev() + .zip(new_text.chars().rev()) + .find_map( + |((i, a), b)| { + if a != b { Some(i + a.len_utf8()) } else { None } + }, + ) + .unwrap_or(old_text.len()); + points.end.column -= (old_text.len() - common_suffix_starts_at) as u32; + old_text = old_text.split_at(common_suffix_starts_at).0.to_string(); + let common_prefix_len = old_text + .char_indices() + .zip(new_text.chars()) + .find_map(|((i, a), b)| if a != b { Some(i) } else { None }) + .unwrap_or(0); + points.start.column = common_prefix_len as u32; + old_text = old_text.split_at(common_prefix_len).1.to_string(); + + Some((points, old_text)) + }) + .collect::>(); + + for _ in 0..undo_count { + buffer.redo(cx); + } + edits + }); + vim.undo_last_line_tx = editor.transact(window, cx, |editor, window, cx| { + editor.change_list.invert_last_group(); + editor.edit(edits, cx); + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_anchor_ranges(anchors.into_iter().map(|a| a..a)); + }) + }); + }); + }); repeat::register(editor, cx); scroll::register(editor, cx); @@ -1876,4 +1992,102 @@ mod test { cx.simulate_shared_keystrokes("ctrl-o").await; cx.shared_state().await.assert_matches(); } + + #[gpui::test] + async fn test_undo_last_line(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + ˇfn a() { } + fn a() { } + fn a() { } + "}) + .await; + // do a jump to reset vim's undo grouping + cx.simulate_shared_keystrokes("shift-g").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("r a").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("shift-u").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("shift-u").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("g g shift-u").await; + cx.shared_state().await.assert_matches(); + } + + #[gpui::test] + async fn test_undo_last_line_newline(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + ˇfn a() { } + fn a() { } + fn a() { } + "}) + .await; + // do a jump to reset vim's undo grouping + cx.simulate_shared_keystrokes("shift-g k").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("o h e l l o escape").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("shift-u").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("shift-u").await; + } + + #[gpui::test] + async fn test_undo_last_line_newline_many_changes(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + ˇfn a() { } + fn a() { } + fn a() { } + "}) + .await; + // do a jump to reset vim's undo grouping + cx.simulate_shared_keystrokes("x shift-g k").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("x f a x f { x").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("shift-u").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("shift-u").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("shift-u").await; + cx.shared_state().await.assert_matches(); + cx.simulate_shared_keystrokes("shift-u").await; + cx.shared_state().await.assert_matches(); + } + + #[gpui::test] + async fn test_undo_last_line_multicursor(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc! {" + ˇone two ˇone + two ˇone two + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("3 r a"); + cx.assert_state( + indoc! {" + aaˇa two aaˇa + two aaˇa two + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("escape escape"); + cx.simulate_keystrokes("shift-u"); + cx.set_state( + indoc! {" + onˇe two onˇe + two onˇe two + "}, + Mode::Normal, + ); + } } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 9229f145d9..95a08d7c66 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -375,6 +375,7 @@ pub(crate) struct Vim { pub(crate) current_tx: Option, pub(crate) current_anchor: Option>, pub(crate) undo_modes: HashMap, + pub(crate) undo_last_line_tx: Option, selected_register: Option, pub search: SearchState, @@ -422,6 +423,7 @@ impl Vim { stored_visual_mode: None, current_tx: None, + undo_last_line_tx: None, current_anchor: None, undo_modes: HashMap::default(), diff --git a/crates/vim/test_data/test_undo_last_line.json b/crates/vim/test_data/test_undo_last_line.json new file mode 100644 index 0000000000..a2f6fc0995 --- /dev/null +++ b/crates/vim/test_data/test_undo_last_line.json @@ -0,0 +1,14 @@ +{"Put":{"state":"ˇfn a() { }\nfn a() { }\nfn a() { }\n"}} +{"Key":"shift-g"} +{"Get":{"state":"fn a() { }\nfn a() { }\nfn a() { }\nˇ","mode":"Normal"}} +{"Key":"r"} +{"Key":"a"} +{"Get":{"state":"fn a() { }\nfn a() { }\nfn a() { }\nˇ","mode":"Normal"}} +{"Key":"shift-u"} +{"Get":{"state":"ˇ\nfn a() { }\nfn a() { }\n","mode":"Normal"}} +{"Key":"shift-u"} +{"Get":{"state":"ˇfn a() { }\nfn a() { }\nfn a() { }\n","mode":"Normal"}} +{"Key":"g"} +{"Key":"g"} +{"Key":"shift-u"} +{"Get":{"state":"ˇ\nfn a() { }\nfn a() { }\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_undo_last_line_newline.json b/crates/vim/test_data/test_undo_last_line_newline.json new file mode 100644 index 0000000000..2b21ccef09 --- /dev/null +++ b/crates/vim/test_data/test_undo_last_line_newline.json @@ -0,0 +1,15 @@ +{"Put":{"state":"ˇfn a() { }\nfn a() { }\nfn a() { }\n"}} +{"Key":"shift-g"} +{"Key":"k"} +{"Get":{"state":"fn a() { }\nfn a() { }\nˇfn a() { }\n","mode":"Normal"}} +{"Key":"o"} +{"Key":"h"} +{"Key":"e"} +{"Key":"l"} +{"Key":"l"} +{"Key":"o"} +{"Key":"escape"} +{"Get":{"state":"fn a() { }\nfn a() { }\nfn a() { }\nhellˇo\n","mode":"Normal"}} +{"Key":"shift-u"} +{"Get":{"state":"fn a() { }\nfn a() { }\nfn a() { }\nˇ\n","mode":"Normal"}} +{"Key":"shift-u"} diff --git a/crates/vim/test_data/test_undo_last_line_newline_many_changes.json b/crates/vim/test_data/test_undo_last_line_newline_many_changes.json new file mode 100644 index 0000000000..6615e8d79a --- /dev/null +++ b/crates/vim/test_data/test_undo_last_line_newline_many_changes.json @@ -0,0 +1,21 @@ +{"Put":{"state":"ˇfn a() { }\nfn a() { }\nfn a() { }\n"}} +{"Key":"x"} +{"Key":"shift-g"} +{"Key":"k"} +{"Get":{"state":"n a() { }\nfn a() { }\nˇfn a() { }\n","mode":"Normal"}} +{"Key":"x"} +{"Key":"f"} +{"Key":"a"} +{"Key":"x"} +{"Key":"f"} +{"Key":"{"} +{"Key":"x"} +{"Get":{"state":"n a() { }\nfn a() { }\nn () ˇ }\n","mode":"Normal"}} +{"Key":"shift-u"} +{"Get":{"state":"n a() { }\nfn a() { }\nˇfn a() { }\n","mode":"Normal"}} +{"Key":"shift-u"} +{"Get":{"state":"n a() { }\nfn a() { }\nn () ˇ }\n","mode":"Normal"}} +{"Key":"shift-u"} +{"Get":{"state":"n a() { }\nfn a() { }\nˇfn a() { }\n","mode":"Normal"}} +{"Key":"shift-u"} +{"Get":{"state":"n a() { }\nfn a() { }\nn () ˇ }\n","mode":"Normal"}}