diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 1501d29c7b9b712f3f8edc25025545d0fa0baa08..6763c5cddb8bf2cda6aa4fa0988ff6be67119d3c 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -949,17 +949,16 @@ impl Vim { let current_line = point.row; let percentage = current_line as f32 / lines as f32; let modified = if buffer.is_dirty() { " [modified]" } else { "" }; - vim.status_label = Some( + vim.set_status_label( format!( "{}{} {} lines --{:.0}%--", filename, modified, lines, percentage * 100.0, - ) - .into(), + ), + cx, ); - cx.notify(); }); } diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index ec964ec9ae3af08b108aa027a0aa62883dbcbcc5..fab9b353e3e9bb5b5d00d9d415783b4a5a31ae95 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -50,6 +50,10 @@ impl Vim { }) .filter(|reg| !reg.text.is_empty()) else { + vim.set_status_label( + format!("Nothing in register {}", selected_register.unwrap_or('"')), + cx, + ); return; }; let clipboard_selections = clipboard_selections @@ -249,7 +253,7 @@ impl Vim { ) { self.stop_recording(cx); let selected_register = self.selected_register.take(); - self.update_editor(cx, |_, editor, cx| { + self.update_editor(cx, |vim, editor, cx| { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { @@ -262,6 +266,10 @@ impl Vim { globals.read_register(selected_register, Some(editor), cx) }) .filter(|reg| !reg.text.is_empty()) else { + vim.set_status_label( + format!("Nothing in register {}", selected_register.unwrap_or('"')), + cx, + ); return; }; editor.insert(&text, window, cx); @@ -286,7 +294,7 @@ impl Vim { ) { self.stop_recording(cx); let selected_register = self.selected_register.take(); - self.update_editor(cx, |_, editor, cx| { + self.update_editor(cx, |vim, editor, cx| { let text_layout_details = editor.text_layout_details(window, cx); editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); @@ -306,6 +314,10 @@ impl Vim { globals.read_register(selected_register, Some(editor), cx) }) .filter(|reg| !reg.text.is_empty()) else { + vim.set_status_label( + format!("Nothing in register {}", selected_register.unwrap_or('"')), + cx, + ); return; }; editor.insert(&text, window, cx); diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index 8a4bfc241d1b0c62b17464bfb1dd5076015ac638..387bca0912be303fbe86bf947446fe85a50d6022 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -291,6 +291,24 @@ impl Vim { }) else { return; }; + + // Dot repeat always uses the recorded register, ignoring any "X + // override, as the register is an inherent part of the recorded action. + // For numbered registers, Neovim increments on each dot repeat so after + // using `"1p`, using `.` will equate to `"2p", the next `.` to `"3p`, + // etc.. + let recorded_register = cx.global::().recorded_register_for_dot; + let next_register = recorded_register + .filter(|c| matches!(c, '1'..='9')) + .map(|c| ((c as u8 + 1).min(b'9')) as char); + + self.selected_register = next_register.or(recorded_register); + if let Some(next_register) = next_register { + Vim::update_globals(cx, |globals, _| { + globals.recorded_register_for_dot = Some(next_register) + }) + }; + if mode != Some(self.mode) { if let Some(mode) = mode { self.switch_mode(mode, false, window, cx) @@ -441,6 +459,207 @@ mod test { cx.shared_state().await.assert_eq("THE QUICK ˇbrown fox"); } + #[gpui::test] + async fn test_dot_repeat_registers_paste(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + // basic paste repeat uses the unnamed register + cx.set_shared_state("ˇhello\n").await; + cx.simulate_shared_keystrokes("y y p").await; + cx.shared_state().await.assert_eq("hello\nˇhello\n"); + cx.simulate_shared_keystrokes(".").await; + cx.shared_state().await.assert_eq("hello\nhello\nˇhello\n"); + + // "_ (blackhole) is recorded and replayed, so the pasted text is still + // the original yanked line. + cx.set_shared_state(indoc! {" + ˇone + two + three + four + "}) + .await; + cx.simulate_shared_keystrokes("y y j \" _ d d . p").await; + cx.shared_state().await.assert_eq(indoc! {" + one + four + ˇone + "}); + + // the recorded register is replayed, not whatever is in the unnamed register + cx.set_shared_state(indoc! {" + ˇone + two + "}) + .await; + cx.simulate_shared_keystrokes("y y j \" a y y \" a p .") + .await; + cx.shared_state().await.assert_eq(indoc! {" + one + two + two + ˇtwo + "}); + + // `"X.` ignores the override and always uses the recorded register. + // Both `dd` calls go into register `a`, so register `b` is empty and + // `"bp` pastes nothing. + cx.set_shared_state(indoc! {" + ˇone + two + three + "}) + .await; + cx.simulate_shared_keystrokes("\" a d d \" b .").await; + cx.shared_state().await.assert_eq(indoc! {" + ˇthree + "}); + cx.simulate_shared_keystrokes("\" a p \" b p").await; + cx.shared_state().await.assert_eq(indoc! {" + three + ˇtwo + "}); + + // numbered registers cycle on each dot repeat: "1p . . uses registers 2, 3, … + // Since the cycling behavior caps at register 9, the first line to be + // deleted `1`, is no longer in any of the registers. + cx.set_shared_state(indoc! {" + ˇone + two + three + four + five + six + seven + eight + nine + ten + "}) + .await; + cx.simulate_shared_keystrokes("d d . . . . . . . . .").await; + cx.shared_state().await.assert_eq(indoc! {"ˇ"}); + cx.simulate_shared_keystrokes("\" 1 p . . . . . . . . .") + .await; + cx.shared_state().await.assert_eq(indoc! {" + + ten + nine + eight + seven + six + five + four + three + two + ˇtwo"}); + + // unnamed register repeat: dd records None, so . pastes the same + // deleted text + cx.set_shared_state(indoc! {" + ˇone + two + three + "}) + .await; + cx.simulate_shared_keystrokes("d d p .").await; + cx.shared_state().await.assert_eq(indoc! {" + two + one + ˇone + three + "}); + + // After `"1p` cycles to `2`, using `"ap` resets recorded_register to `a`, + // so the next `.` uses `a` and not 3. + cx.set_shared_state(indoc! {" + one + two + ˇthree + "}) + .await; + cx.simulate_shared_keystrokes("\" 2 y y k k \" a y y j \" 1 y y k \" 1 p . \" a p .") + .await; + cx.shared_state().await.assert_eq(indoc! {" + one + two + three + one + ˇone + two + three + "}); + } + + // This needs to be a separate test from `test_dot_repeat_registers_paste` + // as Neovim doesn't have support for using registers in replace operations + // by default. + #[gpui::test] + async fn test_dot_repeat_registers_replace(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc! {" + line ˇone + line two + line three + "}, + Mode::Normal, + ); + + // 1. Yank `one` into register `a` + // 2. Move down and yank `two` into the default register + // 3. Replace `two` with the contents of register `a` + cx.simulate_keystrokes("\" a y w j y w \" a g R w"); + cx.assert_state( + indoc! {" + line one + line onˇe + line three + "}, + Mode::Normal, + ); + + // 1. Move down to `three` + // 2. Repeat the replace operation + cx.simulate_keystrokes("j ."); + cx.assert_state( + indoc! {" + line one + line one + line onˇe + "}, + Mode::Normal, + ); + + // Similar test, but this time using numbered registers, as those should + // automatically increase on successive uses of `.` . + cx.set_state( + indoc! {" + line ˇone + line two + line three + line four + "}, + Mode::Normal, + ); + + // 1. Yank `one` into register `1` + // 2. Yank `two` into register `2` + // 3. Move down and yank `three` into the default register + // 4. Replace `three` with the contents of register `1` + // 5. Move down and repeat + cx.simulate_keystrokes("\" 1 y w j \" 2 y w j y w \" 1 g R w j ."); + cx.assert_state( + indoc! {" + line one + line two + line one + line twˇo + "}, + Mode::Normal, + ); + } + #[gpui::test] async fn test_repeat_ime(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 4e71a698ff0789a462e5ec2e83d673421621c884..9ba744de6855e101a1871ddcf0a84cc3fc931830 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -232,7 +232,15 @@ pub struct VimGlobals { pub recorded_actions: Vec, pub recorded_selection: RecordedSelection, + /// The register being written to by the active `q{register}` macro + /// recording. pub recording_register: Option, + /// The register that was selected at the start of the current + /// dot-recording, for example, `"ap`. + pub recording_register_for_dot: Option, + /// The register from the last completed dot-recording. Used when replaying + /// with `.`. + pub recorded_register_for_dot: Option, pub last_recorded_register: Option, pub last_replayed_register: Option, pub replayer: Option, @@ -919,6 +927,7 @@ impl VimGlobals { self.dot_recording = false; self.recorded_actions = std::mem::take(&mut self.recording_actions); self.recorded_count = self.recording_count.take(); + self.recorded_register_for_dot = self.recording_register_for_dot.take(); self.stop_recording_after_next_action = false; } } @@ -946,6 +955,7 @@ impl VimGlobals { self.dot_recording = false; self.recorded_actions = std::mem::take(&mut self.recording_actions); self.recorded_count = self.recording_count.take(); + self.recorded_register_for_dot = self.recording_register_for_dot.take(); self.stop_recording_after_next_action = false; } } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 3085dc5b3763222eb4b06d2ee551e026feba0002..c1058f5738915359b107865bf99d9f2c73f2085d 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -996,7 +996,14 @@ impl Vim { cx: &mut Context, f: impl Fn(&mut Vim, &A, &mut Window, &mut Context) + 'static, ) { - let subscription = editor.register_action(cx.listener(f)); + let subscription = editor.register_action(cx.listener(move |vim, action, window, cx| { + if !Vim::globals(cx).dot_replaying { + if vim.status_label.take().is_some() { + cx.notify(); + } + } + f(vim, action, window, cx); + })); cx.on_release(|_, _| drop(subscription)).detach(); } @@ -1155,7 +1162,6 @@ impl Vim { let last_mode = self.mode; let prior_mode = self.last_mode; let prior_tx = self.current_tx; - self.status_label.take(); self.last_mode = last_mode; self.mode = mode; self.operator_stack.clear(); @@ -1586,6 +1592,7 @@ impl Vim { globals.dot_recording = true; globals.recording_actions = Default::default(); globals.recording_count = None; + globals.recording_register_for_dot = self.selected_register; let selections = self.editor().map(|editor| { editor.update(cx, |editor, cx| { @@ -2092,6 +2099,11 @@ impl Vim { editor.selections.set_line_mode(state.line_mode); editor.set_edit_predictions_hidden_for_vim_mode(state.hide_edit_predictions, window, cx); } + + fn set_status_label(&mut self, label: impl Into, cx: &mut Context) { + self.status_label = Some(label.into()); + cx.notify(); + } } struct VimEditorSettingsState { diff --git a/crates/vim/test_data/test_dot_repeat_registers.json b/crates/vim/test_data/test_dot_repeat_registers.json new file mode 100644 index 0000000000000000000000000000000000000000..76ca1af20fe14cacb23482cd6988dea16cfb9194 --- /dev/null +++ b/crates/vim/test_data/test_dot_repeat_registers.json @@ -0,0 +1,125 @@ +{"Put":{"state":"ˇhello\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"p"} +{"Get":{"state":"hello\nˇhello\n","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"hello\nhello\nˇhello\n","mode":"Normal"}} +{"Put":{"state":"ˇtocopytext\n1\n2\n3\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"_"} +{"Key":"d"} +{"Key":"d"} +{"Key":"."} +{"Key":"p"} +{"Get":{"state":"tocopytext\n3\nˇtocopytext\n","mode":"Normal"}} +{"Put":{"state":"ˇtocopytext\n1\n2\n3\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"1"} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"j"} +{"Key":"\""} +{"Key":"1"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"tocopytext\n1\n2\n3\nˇ1\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\n"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"d"} +{"Key":"d"} +{"Key":"\""} +{"Key":"b"} +{"Key":"."} +{"Get":{"state":"ˇthree\n","mode":"Normal"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"\""} +{"Key":"b"} +{"Key":"p"} +{"Get":{"state":"three\nˇtwo\n","mode":"Normal"}} +{"Put":{"state":"ˇline one\nline two\n"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"."} +{"Key":"\""} +{"Key":"b"} +{"Key":"."} +{"Get":{"state":"line one\nline two\nline one\nline one\nˇline one\n","mode":"Normal"}} +{"Put":{"state":"ˇ1\n2\n3\n4\n5\n6\n7\n8\n9\n"}} +{"Key":"d"} +{"Key":"d"} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Get":{"state":"ˇ","mode":"Normal"}} +{"Key":"\""} +{"Key":"1"} +{"Key":"p"} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Get":{"state":"\n9\n8\n7\n6\n5\n4\n3\n2\n1\nˇ1","mode":"Normal"}} +{"Put":{"state":"ˇa\nb\nc\n"}} +{"Key":"\""} +{"Key":"9"} +{"Key":"y"} +{"Key":"y"} +{"Key":"\""} +{"Key":"9"} +{"Key":"p"} +{"Key":"."} +{"Key":"."} +{"Get":{"state":"a\na\na\nˇa\nb\nc\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\n"}} +{"Key":"d"} +{"Key":"d"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"two\none\nˇone\nthree\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\n"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"1"} +{"Key":"y"} +{"Key":"y"} +{"Key":"k"} +{"Key":"\""} +{"Key":"1"} +{"Key":"p"} +{"Key":"."} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"one\ntwo\n9\none\nˇone\ntwo\nthree\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_dot_repeat_registers_paste.json b/crates/vim/test_data/test_dot_repeat_registers_paste.json new file mode 100644 index 0000000000000000000000000000000000000000..f5a08d432d0b1fda8ec1bfe71d7401ec8769d8d2 --- /dev/null +++ b/crates/vim/test_data/test_dot_repeat_registers_paste.json @@ -0,0 +1,105 @@ +{"Put":{"state":"ˇhello\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"p"} +{"Get":{"state":"hello\nˇhello\n","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"hello\nhello\nˇhello\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\nfour\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"_"} +{"Key":"d"} +{"Key":"d"} +{"Key":"."} +{"Key":"p"} +{"Get":{"state":"one\nfour\nˇone\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"a"} +{"Key":"y"} +{"Key":"y"} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"one\ntwo\ntwo\nˇtwo\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\n"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"d"} +{"Key":"d"} +{"Key":"\""} +{"Key":"b"} +{"Key":"."} +{"Get":{"state":"ˇthree\n","mode":"Normal"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"\""} +{"Key":"b"} +{"Key":"p"} +{"Get":{"state":"three\nˇtwo\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n"}} +{"Key":"d"} +{"Key":"d"} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Get":{"state":"ˇ","mode":"Normal"}} +{"Key":"\""} +{"Key":"1"} +{"Key":"p"} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Get":{"state":"\nten\nnine\neight\nseven\nsix\nfive\nfour\nthree\ntwo\nˇtwo","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\n"}} +{"Key":"d"} +{"Key":"d"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"two\none\nˇone\nthree\n","mode":"Normal"}} +{"Put":{"state":"one\ntwo\nˇthree\n"}} +{"Key":"\""} +{"Key":"2"} +{"Key":"y"} +{"Key":"y"} +{"Key":"k"} +{"Key":"k"} +{"Key":"\""} +{"Key":"a"} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"1"} +{"Key":"y"} +{"Key":"y"} +{"Key":"k"} +{"Key":"\""} +{"Key":"1"} +{"Key":"p"} +{"Key":"."} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"one\ntwo\nthree\none\nˇone\ntwo\nthree\n","mode":"Normal"}}