Closes #47251 Fix dot (`.`) repeat not correctly repeating the last change after replaying a macro (`@register`) ([#47251](https://github.com/zed-industries/zed/issues/47251)) When replaying a macro that contains text insertions, `replay_insert_event` calls `handle_input` directly and never emits `InputHandled`, so the `observe_insertion` subscription never fires. This left the dot register stale — `.` after `@register` would repeat an earlier change instead of the last one made by the macro. Fix by calling `observe_insertion` explicitly in the `ReplayableAction::Insertion` branch of `Replayer::next`. Release Notes: - Fixed dot (`.`) repeat not repeating the last change made by a macro (`@register`). --------- Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
1208 lines
40 KiB
Rust
1208 lines
40 KiB
Rust
use std::{cell::RefCell, rc::Rc};
|
|
|
|
use crate::{
|
|
Vim,
|
|
insert::NormalBefore,
|
|
motion::Motion,
|
|
normal::InsertBefore,
|
|
state::{Mode, Operator, RecordedSelection, ReplayableAction, VimGlobals},
|
|
};
|
|
use editor::Editor;
|
|
use gpui::{Action, App, Context, Window, actions};
|
|
use workspace::Workspace;
|
|
|
|
actions!(
|
|
vim,
|
|
[
|
|
/// Repeats the last change.
|
|
Repeat,
|
|
/// Ends the repeat recording.
|
|
EndRepeat,
|
|
/// Toggles macro recording.
|
|
ToggleRecord,
|
|
/// Replays the last recorded macro.
|
|
ReplayLastRecording
|
|
]
|
|
);
|
|
|
|
fn should_replay(action: &dyn Action) -> bool {
|
|
// skip so that we don't leave the character palette open
|
|
if editor::actions::ShowCharacterPalette.partial_eq(action) {
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
|
|
fn repeatable_insert(action: &ReplayableAction) -> Option<Box<dyn Action>> {
|
|
match action {
|
|
ReplayableAction::Action(action) => {
|
|
if super::InsertBefore.partial_eq(&**action)
|
|
|| super::InsertAfter.partial_eq(&**action)
|
|
|| super::InsertFirstNonWhitespace.partial_eq(&**action)
|
|
|| super::InsertEndOfLine.partial_eq(&**action)
|
|
{
|
|
Some(super::InsertBefore.boxed_clone())
|
|
} else if super::InsertLineAbove.partial_eq(&**action)
|
|
|| super::InsertLineBelow.partial_eq(&**action)
|
|
{
|
|
Some(super::InsertLineBelow.boxed_clone())
|
|
} else if crate::replace::ToggleReplace.partial_eq(&**action) {
|
|
Some(crate::replace::ToggleReplace.boxed_clone())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
ReplayableAction::Insertion { .. } => None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
|
|
Vim::action(editor, cx, |vim, _: &EndRepeat, window, cx| {
|
|
Vim::globals(cx).dot_replaying = false;
|
|
vim.switch_mode(Mode::Normal, false, window, cx)
|
|
});
|
|
|
|
Vim::action(editor, cx, |vim, _: &Repeat, window, cx| {
|
|
vim.repeat(false, window, cx)
|
|
});
|
|
|
|
Vim::action(editor, cx, |vim, _: &ToggleRecord, window, cx| {
|
|
let globals = Vim::globals(cx);
|
|
if let Some(char) = globals.recording_register.take() {
|
|
globals.last_recorded_register = Some(char)
|
|
} else {
|
|
vim.push_operator(Operator::RecordRegister, window, cx);
|
|
}
|
|
});
|
|
|
|
Vim::action(editor, cx, |vim, _: &ReplayLastRecording, window, cx| {
|
|
let Some(register) = Vim::globals(cx).last_recorded_register else {
|
|
return;
|
|
};
|
|
vim.replay_register(register, window, cx)
|
|
});
|
|
}
|
|
|
|
pub struct ReplayerState {
|
|
actions: Vec<ReplayableAction>,
|
|
running: bool,
|
|
ix: usize,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct Replayer(Rc<RefCell<ReplayerState>>);
|
|
|
|
impl Replayer {
|
|
pub fn new() -> Self {
|
|
Self(Rc::new(RefCell::new(ReplayerState {
|
|
actions: vec![],
|
|
running: false,
|
|
ix: 0,
|
|
})))
|
|
}
|
|
|
|
pub fn replay(&mut self, actions: Vec<ReplayableAction>, window: &mut Window, cx: &mut App) {
|
|
let mut lock = self.0.borrow_mut();
|
|
let range = lock.ix..lock.ix;
|
|
lock.actions.splice(range, actions);
|
|
if lock.running {
|
|
return;
|
|
}
|
|
lock.running = true;
|
|
let this = self.clone();
|
|
window.defer(cx, move |window, cx| {
|
|
this.next(window, cx);
|
|
let Some(workspace) = Workspace::for_window(window, cx) else {
|
|
return;
|
|
};
|
|
let Some(editor) = workspace
|
|
.read(cx)
|
|
.active_item(cx)
|
|
.and_then(|item| item.act_as::<Editor>(cx))
|
|
else {
|
|
return;
|
|
};
|
|
editor.update(cx, |editor, cx| {
|
|
editor
|
|
.buffer()
|
|
.update(cx, |multi, cx| multi.finalize_last_transaction(cx))
|
|
});
|
|
})
|
|
}
|
|
|
|
pub fn stop(self) {
|
|
self.0.borrow_mut().actions.clear()
|
|
}
|
|
|
|
pub fn next(self, window: &mut Window, cx: &mut App) {
|
|
let mut lock = self.0.borrow_mut();
|
|
let action = if lock.ix < 10000 {
|
|
lock.actions.get(lock.ix).cloned()
|
|
} else {
|
|
log::error!("Aborting replay after 10000 actions");
|
|
None
|
|
};
|
|
lock.ix += 1;
|
|
drop(lock);
|
|
let Some(action) = action else {
|
|
// The `globals.dot_replaying = false` is a fail-safe to ensure that
|
|
// this value is always reset, in the case that the focus is moved
|
|
// away from the editor, effectively preventing the `EndRepeat`
|
|
// action from being handled.
|
|
let globals = Vim::globals(cx);
|
|
globals.replayer.take();
|
|
globals.dot_replaying = false;
|
|
return;
|
|
};
|
|
match action {
|
|
ReplayableAction::Action(action) => {
|
|
if should_replay(&*action) {
|
|
window.dispatch_action(action.boxed_clone(), cx);
|
|
cx.defer(move |cx| Vim::globals(cx).observe_action(action.boxed_clone()));
|
|
}
|
|
}
|
|
ReplayableAction::Insertion {
|
|
text,
|
|
utf16_range_to_replace,
|
|
} => {
|
|
let Some(workspace) = Workspace::for_window(window, cx) else {
|
|
return;
|
|
};
|
|
let Some(editor) = workspace
|
|
.read(cx)
|
|
.active_item(cx)
|
|
.and_then(|item| item.act_as::<Editor>(cx))
|
|
else {
|
|
return;
|
|
};
|
|
editor.update(cx, |editor, cx| {
|
|
editor.replay_insert_event(&text, utf16_range_to_replace.clone(), window, cx)
|
|
})
|
|
}
|
|
}
|
|
window.defer(cx, move |window, cx| self.next(window, cx));
|
|
}
|
|
}
|
|
|
|
impl Vim {
|
|
pub(crate) fn record_register(
|
|
&mut self,
|
|
register: char,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let globals = Vim::globals(cx);
|
|
globals.recording_register = Some(register);
|
|
globals.recordings.remove(®ister);
|
|
globals.ignore_current_insertion = true;
|
|
self.clear_operator(window, cx)
|
|
}
|
|
|
|
pub(crate) fn replay_register(
|
|
&mut self,
|
|
mut register: char,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let mut count = Vim::take_count(cx).unwrap_or(1);
|
|
Vim::take_forced_motion(cx);
|
|
self.clear_operator(window, cx);
|
|
|
|
let globals = Vim::globals(cx);
|
|
if register == '@' {
|
|
let Some(last) = globals.last_replayed_register else {
|
|
return;
|
|
};
|
|
register = last;
|
|
}
|
|
let Some(actions) = globals.recordings.get(®ister) else {
|
|
return;
|
|
};
|
|
|
|
let mut repeated_actions = vec![];
|
|
while count > 0 {
|
|
repeated_actions.extend(actions.iter().cloned());
|
|
count -= 1
|
|
}
|
|
|
|
globals.last_replayed_register = Some(register);
|
|
let mut replayer = globals.replayer.get_or_insert_with(Replayer::new).clone();
|
|
replayer.replay(repeated_actions, window, cx);
|
|
}
|
|
|
|
pub(crate) fn repeat(
|
|
&mut self,
|
|
from_insert_mode: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if self.active_operator().is_some() {
|
|
Vim::update_globals(cx, |globals, _| {
|
|
globals.recording_actions.clear();
|
|
globals.recording_count = None;
|
|
globals.dot_recording = false;
|
|
globals.stop_recording_after_next_action = false;
|
|
});
|
|
self.clear_operator(window, cx);
|
|
return;
|
|
}
|
|
|
|
Vim::take_forced_motion(cx);
|
|
let count = Vim::take_count(cx);
|
|
|
|
let Some((mut actions, selection, mode)) = Vim::update_globals(cx, |globals, _| {
|
|
let actions = globals.recorded_actions.clone();
|
|
if actions.is_empty() {
|
|
return None;
|
|
}
|
|
if globals.replayer.is_none()
|
|
&& let Some(recording_register) = globals.recording_register
|
|
{
|
|
globals
|
|
.recordings
|
|
.entry(recording_register)
|
|
.or_default()
|
|
.push(ReplayableAction::Action(Repeat.boxed_clone()));
|
|
}
|
|
|
|
let mut mode = None;
|
|
let selection = globals.recorded_selection.clone();
|
|
match selection {
|
|
RecordedSelection::SingleLine { .. } | RecordedSelection::Visual { .. } => {
|
|
globals.recorded_count = None;
|
|
mode = Some(Mode::Visual);
|
|
}
|
|
RecordedSelection::VisualLine { .. } => {
|
|
globals.recorded_count = None;
|
|
mode = Some(Mode::VisualLine)
|
|
}
|
|
RecordedSelection::VisualBlock { .. } => {
|
|
globals.recorded_count = None;
|
|
mode = Some(Mode::VisualBlock)
|
|
}
|
|
RecordedSelection::None => {
|
|
if let Some(count) = count {
|
|
globals.recorded_count = Some(count);
|
|
}
|
|
}
|
|
}
|
|
|
|
Some((actions, selection, mode))
|
|
}) 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::<VimGlobals>().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)
|
|
}
|
|
|
|
match selection {
|
|
RecordedSelection::SingleLine { cols } => {
|
|
if cols > 1 {
|
|
self.visual_motion(Motion::Right, Some(cols as usize - 1), window, cx)
|
|
}
|
|
}
|
|
RecordedSelection::Visual { rows, cols } => {
|
|
self.visual_motion(
|
|
Motion::Down {
|
|
display_lines: false,
|
|
},
|
|
Some(rows as usize),
|
|
window,
|
|
cx,
|
|
);
|
|
self.visual_motion(
|
|
Motion::StartOfLine {
|
|
display_lines: false,
|
|
},
|
|
None,
|
|
window,
|
|
cx,
|
|
);
|
|
if cols > 1 {
|
|
self.visual_motion(Motion::Right, Some(cols as usize - 1), window, cx)
|
|
}
|
|
}
|
|
RecordedSelection::VisualBlock { rows, cols } => {
|
|
self.visual_motion(
|
|
Motion::Down {
|
|
display_lines: false,
|
|
},
|
|
Some(rows as usize),
|
|
window,
|
|
cx,
|
|
);
|
|
if cols > 1 {
|
|
self.visual_motion(Motion::Right, Some(cols as usize - 1), window, cx);
|
|
}
|
|
}
|
|
RecordedSelection::VisualLine { rows } => {
|
|
self.visual_motion(
|
|
Motion::Down {
|
|
display_lines: false,
|
|
},
|
|
Some(rows as usize),
|
|
window,
|
|
cx,
|
|
);
|
|
}
|
|
RecordedSelection::None => {}
|
|
}
|
|
}
|
|
|
|
// insert internally uses repeat to handle counts
|
|
// vim doesn't treat 3a1 as though you literally repeated a1
|
|
// 3 times, instead it inserts the content thrice at the insert position.
|
|
if let Some(to_repeat) = repeatable_insert(&actions[0]) {
|
|
if let Some(ReplayableAction::Action(action)) = actions.last()
|
|
&& NormalBefore.partial_eq(&**action)
|
|
{
|
|
actions.pop();
|
|
}
|
|
|
|
let mut new_actions = actions.clone();
|
|
actions[0] = ReplayableAction::Action(to_repeat.boxed_clone());
|
|
|
|
let mut count = cx.global::<VimGlobals>().recorded_count.unwrap_or(1);
|
|
|
|
// if we came from insert mode we're just doing repetitions 2 onwards.
|
|
if from_insert_mode {
|
|
count -= 1;
|
|
new_actions[0] = actions[0].clone();
|
|
}
|
|
|
|
for _ in 1..count {
|
|
new_actions.append(actions.clone().as_mut());
|
|
}
|
|
new_actions.push(ReplayableAction::Action(NormalBefore.boxed_clone()));
|
|
actions = new_actions;
|
|
}
|
|
|
|
actions.push(ReplayableAction::Action(EndRepeat.boxed_clone()));
|
|
|
|
if self.temp_mode {
|
|
self.temp_mode = false;
|
|
actions.push(ReplayableAction::Action(InsertBefore.boxed_clone()));
|
|
}
|
|
|
|
let globals = Vim::globals(cx);
|
|
globals.dot_replaying = true;
|
|
let mut replayer = globals.replayer.get_or_insert_with(Replayer::new).clone();
|
|
|
|
replayer.replay(actions, window, cx);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use editor::test::editor_lsp_test_context::EditorLspTestContext;
|
|
use futures::StreamExt;
|
|
use indoc::indoc;
|
|
|
|
use gpui::EntityInputHandler;
|
|
|
|
use crate::{
|
|
VimGlobals,
|
|
state::Mode,
|
|
test::{NeovimBackedTestContext, VimTestContext},
|
|
};
|
|
|
|
#[gpui::test]
|
|
async fn test_dot_repeat(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
// "o"
|
|
cx.set_shared_state("ˇhello").await;
|
|
cx.simulate_shared_keystrokes("o w o r l d escape").await;
|
|
cx.shared_state().await.assert_eq("hello\nworlˇd");
|
|
cx.simulate_shared_keystrokes(".").await;
|
|
cx.shared_state().await.assert_eq("hello\nworld\nworlˇd");
|
|
|
|
// "d"
|
|
cx.simulate_shared_keystrokes("^ d f o").await;
|
|
cx.simulate_shared_keystrokes("g g .").await;
|
|
cx.shared_state().await.assert_eq("ˇ\nworld\nrld");
|
|
|
|
// "p" (note that it pastes the current clipboard)
|
|
cx.simulate_shared_keystrokes("j y y p").await;
|
|
cx.simulate_shared_keystrokes("shift-g y y .").await;
|
|
cx.shared_state()
|
|
.await
|
|
.assert_eq("\nworld\nworld\nrld\nˇrld");
|
|
|
|
// "~" (note that counts apply to the action taken, not . itself)
|
|
cx.set_shared_state("ˇthe quick brown fox").await;
|
|
cx.simulate_shared_keystrokes("2 ~ .").await;
|
|
cx.set_shared_state("THE ˇquick brown fox").await;
|
|
cx.simulate_shared_keystrokes("3 .").await;
|
|
cx.set_shared_state("THE QUIˇck brown fox").await;
|
|
cx.run_until_parked();
|
|
cx.simulate_shared_keystrokes(".").await;
|
|
cx.shared_state().await.assert_eq("THE QUICK ˇbrown fox");
|
|
|
|
// "q l" (note after macro should be used last change made by macro)
|
|
cx.set_shared_state("ˇ").await;
|
|
cx.simulate_shared_keystrokes("q l shift-o h e l l o space w o r l d escape q")
|
|
.await;
|
|
cx.simulate_shared_keystrokes("@ l").await;
|
|
cx.shared_state()
|
|
.await
|
|
.assert_eq("hello worlˇd\nhello world\n");
|
|
cx.simulate_shared_keystrokes(".").await;
|
|
cx.shared_state()
|
|
.await
|
|
.assert_eq("hello worlˇd\nhello world\nhello world\n");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_dot_repeat_after_macro_change_motion(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = VimTestContext::new(cx, true).await;
|
|
|
|
cx.set_state("ˇfoo foo", Mode::Normal);
|
|
cx.simulate_keystrokes("q l c f o x escape q");
|
|
cx.assert_state("ˇxo foo", Mode::Normal);
|
|
|
|
cx.simulate_keystrokes("w @ l");
|
|
cx.assert_state("xo ˇxo", Mode::Normal);
|
|
|
|
cx.simulate_keystrokes(".");
|
|
cx.assert_state("xo ˇx", Mode::Normal);
|
|
}
|
|
|
|
#[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;
|
|
|
|
cx.set_state("hˇllo", Mode::Normal);
|
|
cx.simulate_keystrokes("i");
|
|
|
|
// simulate brazilian input for ä.
|
|
cx.update_editor(|editor, window, cx| {
|
|
editor.replace_and_mark_text_in_range(None, "\"", Some(1..1), window, cx);
|
|
editor.replace_text_in_range(None, "ä", window, cx);
|
|
});
|
|
cx.simulate_keystrokes("escape");
|
|
cx.assert_state("hˇällo", Mode::Normal);
|
|
cx.simulate_keystrokes(".");
|
|
cx.assert_state("hˇäällo", Mode::Normal);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_repeat_completion(cx: &mut gpui::TestAppContext) {
|
|
VimTestContext::init(cx);
|
|
let cx = EditorLspTestContext::new_rust(
|
|
lsp::ServerCapabilities {
|
|
completion_provider: Some(lsp::CompletionOptions {
|
|
trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
|
|
resolve_provider: Some(true),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
let mut cx = VimTestContext::new_with_lsp(cx, true);
|
|
|
|
cx.set_state(
|
|
indoc! {"
|
|
onˇe
|
|
two
|
|
three
|
|
"},
|
|
Mode::Normal,
|
|
);
|
|
|
|
let mut request = cx.set_request_handler::<lsp::request::Completion, _, _>(
|
|
move |_, params, _| async move {
|
|
let position = params.text_document_position.position;
|
|
Ok(Some(lsp::CompletionResponse::Array(vec![
|
|
lsp::CompletionItem {
|
|
label: "first".to_string(),
|
|
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
|
|
range: lsp::Range::new(position, position),
|
|
new_text: "first".to_string(),
|
|
})),
|
|
..Default::default()
|
|
},
|
|
lsp::CompletionItem {
|
|
label: "second".to_string(),
|
|
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
|
|
range: lsp::Range::new(position, position),
|
|
new_text: "second".to_string(),
|
|
})),
|
|
..Default::default()
|
|
},
|
|
])))
|
|
},
|
|
);
|
|
cx.simulate_keystrokes("a .");
|
|
request.next().await;
|
|
cx.condition(|editor, _| editor.context_menu_visible())
|
|
.await;
|
|
cx.simulate_keystrokes("down enter ! escape");
|
|
|
|
cx.assert_state(
|
|
indoc! {"
|
|
one.secondˇ!
|
|
two
|
|
three
|
|
"},
|
|
Mode::Normal,
|
|
);
|
|
cx.simulate_keystrokes("j .");
|
|
cx.assert_state(
|
|
indoc! {"
|
|
one.second!
|
|
two.secondˇ!
|
|
three
|
|
"},
|
|
Mode::Normal,
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_repeat_completion_unicode_bug(cx: &mut gpui::TestAppContext) {
|
|
VimTestContext::init(cx);
|
|
let cx = EditorLspTestContext::new_rust(
|
|
lsp::ServerCapabilities {
|
|
completion_provider: Some(lsp::CompletionOptions {
|
|
trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
|
|
resolve_provider: Some(true),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
let mut cx = VimTestContext::new_with_lsp(cx, true);
|
|
|
|
cx.set_state(
|
|
indoc! {"
|
|
ĩлˇк
|
|
ĩлк
|
|
"},
|
|
Mode::Normal,
|
|
);
|
|
|
|
let mut request = cx.set_request_handler::<lsp::request::Completion, _, _>(
|
|
move |_, params, _| async move {
|
|
let position = params.text_document_position.position;
|
|
let mut to_the_left = position;
|
|
to_the_left.character -= 2;
|
|
Ok(Some(lsp::CompletionResponse::Array(vec![
|
|
lsp::CompletionItem {
|
|
label: "oops".to_string(),
|
|
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
|
|
range: lsp::Range::new(to_the_left, position),
|
|
new_text: "к!".to_string(),
|
|
})),
|
|
..Default::default()
|
|
},
|
|
])))
|
|
},
|
|
);
|
|
cx.simulate_keystrokes("i .");
|
|
request.next().await;
|
|
cx.condition(|editor, _| editor.context_menu_visible())
|
|
.await;
|
|
cx.simulate_keystrokes("enter escape");
|
|
cx.assert_state(
|
|
indoc! {"
|
|
ĩкˇ!к
|
|
ĩлк
|
|
"},
|
|
Mode::Normal,
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_repeat_visual(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
// single-line (3 columns)
|
|
cx.set_shared_state(indoc! {
|
|
"ˇthe quick brown
|
|
fox jumps over
|
|
the lazy dog"
|
|
})
|
|
.await;
|
|
cx.simulate_shared_keystrokes("v i w s o escape").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"ˇo quick brown
|
|
fox jumps over
|
|
the lazy dog"
|
|
});
|
|
cx.simulate_shared_keystrokes("j w .").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"o quick brown
|
|
fox ˇops over
|
|
the lazy dog"
|
|
});
|
|
cx.simulate_shared_keystrokes("f r .").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"o quick brown
|
|
fox ops oveˇothe lazy dog"
|
|
});
|
|
|
|
// visual
|
|
cx.set_shared_state(indoc! {
|
|
"the ˇquick brown
|
|
fox jumps over
|
|
fox jumps over
|
|
fox jumps over
|
|
the lazy dog"
|
|
})
|
|
.await;
|
|
cx.simulate_shared_keystrokes("v j x").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"the ˇumps over
|
|
fox jumps over
|
|
fox jumps over
|
|
the lazy dog"
|
|
});
|
|
cx.simulate_shared_keystrokes(".").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"the ˇumps over
|
|
fox jumps over
|
|
the lazy dog"
|
|
});
|
|
cx.simulate_shared_keystrokes("w .").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"the umps ˇumps over
|
|
the lazy dog"
|
|
});
|
|
cx.simulate_shared_keystrokes("j .").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"the umps umps over
|
|
the ˇog"
|
|
});
|
|
|
|
// block mode (3 rows)
|
|
cx.set_shared_state(indoc! {
|
|
"ˇthe quick brown
|
|
fox jumps over
|
|
the lazy dog"
|
|
})
|
|
.await;
|
|
cx.simulate_shared_keystrokes("ctrl-v j j shift-i o escape")
|
|
.await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"ˇothe quick brown
|
|
ofox jumps over
|
|
othe lazy dog"
|
|
});
|
|
cx.simulate_shared_keystrokes("j 4 l .").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"othe quick brown
|
|
ofoxˇo jumps over
|
|
otheo lazy dog"
|
|
});
|
|
|
|
// line mode
|
|
cx.set_shared_state(indoc! {
|
|
"ˇthe quick brown
|
|
fox jumps over
|
|
the lazy dog"
|
|
})
|
|
.await;
|
|
cx.simulate_shared_keystrokes("shift-v shift-r o escape")
|
|
.await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"ˇo
|
|
fox jumps over
|
|
the lazy dog"
|
|
});
|
|
cx.simulate_shared_keystrokes("j .").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"o
|
|
ˇo
|
|
the lazy dog"
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_repeat_motion_counts(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state(indoc! {
|
|
"ˇthe quick brown
|
|
fox jumps over
|
|
the lazy dog"
|
|
})
|
|
.await;
|
|
cx.simulate_shared_keystrokes("3 d 3 l").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"ˇ brown
|
|
fox jumps over
|
|
the lazy dog"
|
|
});
|
|
cx.simulate_shared_keystrokes("j .").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
" brown
|
|
ˇ over
|
|
the lazy dog"
|
|
});
|
|
cx.simulate_shared_keystrokes("j 2 .").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
" brown
|
|
over
|
|
ˇe lazy dog"
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_record_interrupted(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = VimTestContext::new(cx, true).await;
|
|
|
|
cx.set_state("ˇhello\n", Mode::Normal);
|
|
cx.simulate_keystrokes("4 i j cmd-shift-p escape");
|
|
cx.simulate_keystrokes("escape");
|
|
cx.assert_state("ˇjhello\n", Mode::Normal);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_repeat_over_blur(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state("ˇhello hello hello\n").await;
|
|
cx.simulate_shared_keystrokes("c f o x escape").await;
|
|
cx.shared_state().await.assert_eq("ˇx hello hello\n");
|
|
cx.simulate_shared_keystrokes(": escape").await;
|
|
cx.simulate_shared_keystrokes(".").await;
|
|
cx.shared_state().await.assert_eq("ˇx hello\n");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_repeat_after_blur_resets_dot_replaying(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = VimTestContext::new(cx, true).await;
|
|
|
|
// Bind `ctrl-f` to the `buffer_search::Deploy` action so that this can
|
|
// be triggered while in Insert mode, ensuring that an action which
|
|
// moves the focus away from the editor, gets recorded.
|
|
cx.update(|_, cx| {
|
|
cx.bind_keys([gpui::KeyBinding::new(
|
|
"ctrl-f",
|
|
search::buffer_search::Deploy::find(),
|
|
None,
|
|
)])
|
|
});
|
|
|
|
cx.set_state("ˇhello", Mode::Normal);
|
|
|
|
// We're going to enter insert mode, which will start recording, type a
|
|
// character and then immediately use `ctrl-f` to trigger the buffer
|
|
// search. Triggering the buffer search will move focus away from the
|
|
// editor, effectively stopping the recording immediately after
|
|
// `buffer_search::Deploy` is recorded. The first `escape` is used to
|
|
// dismiss the search bar, while the second is used to move from Insert
|
|
// to Normal mode.
|
|
cx.simulate_keystrokes("i x ctrl-f escape escape");
|
|
cx.run_until_parked();
|
|
|
|
// Using the `.` key will dispatch the `vim::Repeat` action, repeating
|
|
// the set of recorded actions. This will eventually focus on the search
|
|
// bar, preventing the `EndRepeat` action from being correctly handled.
|
|
cx.simulate_keystrokes(".");
|
|
cx.run_until_parked();
|
|
|
|
// After replay finishes, even though the `EndRepeat` action wasn't
|
|
// handled, seeing as the editor lost focus during replay, the
|
|
// `dot_replaying` value should be set back to `false`.
|
|
assert!(
|
|
!cx.update(|_, cx| cx.global::<VimGlobals>().dot_replaying),
|
|
"dot_replaying should be false after repeat completes"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_undo_repeated_insert(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state("hellˇo").await;
|
|
cx.simulate_shared_keystrokes("3 a . escape").await;
|
|
cx.shared_state().await.assert_eq("hello..ˇ.");
|
|
cx.simulate_shared_keystrokes("u").await;
|
|
cx.shared_state().await.assert_eq("hellˇo");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_record_replay(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state("ˇhello world").await;
|
|
cx.simulate_shared_keystrokes("q w c w j escape q").await;
|
|
cx.shared_state().await.assert_eq("ˇj world");
|
|
cx.simulate_shared_keystrokes("2 l @ w").await;
|
|
cx.shared_state().await.assert_eq("j ˇj");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_record_replay_count(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state("ˇhello world!!").await;
|
|
cx.simulate_shared_keystrokes("q a v 3 l s 0 escape l q")
|
|
.await;
|
|
cx.shared_state().await.assert_eq("0ˇo world!!");
|
|
cx.simulate_shared_keystrokes("2 @ a").await;
|
|
cx.shared_state().await.assert_eq("000ˇ!");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_record_replay_dot(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state("ˇhello world").await;
|
|
cx.simulate_shared_keystrokes("q a r a l r b l q").await;
|
|
cx.shared_state().await.assert_eq("abˇllo world");
|
|
cx.simulate_shared_keystrokes(".").await;
|
|
cx.shared_state().await.assert_eq("abˇblo world");
|
|
cx.simulate_shared_keystrokes("shift-q").await;
|
|
cx.shared_state().await.assert_eq("ababˇo world");
|
|
cx.simulate_shared_keystrokes(".").await;
|
|
cx.shared_state().await.assert_eq("ababˇb world");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_record_replay_of_dot(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state("ˇhello world").await;
|
|
cx.simulate_shared_keystrokes("r o q w . q").await;
|
|
cx.shared_state().await.assert_eq("ˇoello world");
|
|
cx.simulate_shared_keystrokes("d l").await;
|
|
cx.shared_state().await.assert_eq("ˇello world");
|
|
cx.simulate_shared_keystrokes("@ w").await;
|
|
cx.shared_state().await.assert_eq("ˇllo world");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_record_replay_interleaved(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state("ˇhello world").await;
|
|
cx.simulate_shared_keystrokes("q z r a l q").await;
|
|
cx.shared_state().await.assert_eq("aˇello world");
|
|
cx.simulate_shared_keystrokes("q b @ z @ z q").await;
|
|
cx.shared_state().await.assert_eq("aaaˇlo world");
|
|
cx.simulate_shared_keystrokes("@ @").await;
|
|
cx.shared_state().await.assert_eq("aaaaˇo world");
|
|
cx.simulate_shared_keystrokes("@ b").await;
|
|
cx.shared_state().await.assert_eq("aaaaaaˇworld");
|
|
cx.simulate_shared_keystrokes("@ @").await;
|
|
cx.shared_state().await.assert_eq("aaaaaaaˇorld");
|
|
cx.simulate_shared_keystrokes("q z r b l q").await;
|
|
cx.shared_state().await.assert_eq("aaaaaaabˇrld");
|
|
cx.simulate_shared_keystrokes("@ b").await;
|
|
cx.shared_state().await.assert_eq("aaaaaaabbbˇd");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_repeat_clear(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = VimTestContext::new(cx, true).await;
|
|
|
|
// Check that, when repeat is preceded by something other than a number,
|
|
// the current operator is cleared, in order to prevent infinite loops.
|
|
cx.set_state("ˇhello world", Mode::Normal);
|
|
cx.simulate_keystrokes("d .");
|
|
assert_eq!(cx.active_operator(), None);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_repeat_clear_repeat(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state(indoc! {
|
|
"ˇthe quick brown
|
|
fox jumps over
|
|
the lazy dog"
|
|
})
|
|
.await;
|
|
cx.simulate_shared_keystrokes("d d").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"ˇfox jumps over
|
|
the lazy dog"
|
|
});
|
|
cx.simulate_shared_keystrokes("d . .").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"ˇthe lazy dog"
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_repeat_clear_count(cx: &mut gpui::TestAppContext) {
|
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
|
|
|
cx.set_shared_state(indoc! {
|
|
"ˇthe quick brown
|
|
fox jumps over
|
|
the lazy dog"
|
|
})
|
|
.await;
|
|
cx.simulate_shared_keystrokes("d d").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"ˇfox jumps over
|
|
the lazy dog"
|
|
});
|
|
cx.simulate_shared_keystrokes("2 d .").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"ˇfox jumps over
|
|
the lazy dog"
|
|
});
|
|
cx.simulate_shared_keystrokes(".").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"ˇthe lazy dog"
|
|
});
|
|
|
|
cx.set_shared_state(indoc! {
|
|
"ˇthe quick brown
|
|
fox jumps over
|
|
the lazy dog
|
|
the quick brown
|
|
fox jumps over
|
|
the lazy dog"
|
|
})
|
|
.await;
|
|
cx.simulate_shared_keystrokes("2 d d").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"ˇthe lazy dog
|
|
the quick brown
|
|
fox jumps over
|
|
the lazy dog"
|
|
});
|
|
cx.simulate_shared_keystrokes("5 d .").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"ˇthe lazy dog
|
|
the quick brown
|
|
fox jumps over
|
|
the lazy dog"
|
|
});
|
|
cx.simulate_shared_keystrokes(".").await;
|
|
cx.shared_state().await.assert_eq(indoc! {
|
|
"ˇfox jumps over
|
|
the lazy dog"
|
|
});
|
|
}
|
|
}
|