vim: Support for q and @ (#13761)
Fixes: #1504 Release Notes: - vim: Support for macros (`q` and `@`) to record and replay (#1506, #4448)
This commit is contained in:
parent
dceb0827e8
commit
3348c3ab4c
13 changed files with 491 additions and 316 deletions
|
@ -1,14 +1,17 @@
|
|||
use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc};
|
||||
|
||||
use crate::{
|
||||
insert::NormalBefore,
|
||||
motion::Motion,
|
||||
state::{Mode, RecordedSelection, ReplayableAction},
|
||||
state::{Mode, Operator, RecordedSelection, ReplayableAction},
|
||||
visual::visual_motion,
|
||||
Vim,
|
||||
};
|
||||
use gpui::{actions, Action, ViewContext, WindowContext};
|
||||
use util::ResultExt;
|
||||
use workspace::Workspace;
|
||||
|
||||
actions!(vim, [Repeat, EndRepeat]);
|
||||
actions!(vim, [Repeat, EndRepeat, ToggleRecord, ReplayLastRecording]);
|
||||
|
||||
fn should_replay(action: &Box<dyn Action>) -> bool {
|
||||
// skip so that we don't leave the character palette open
|
||||
|
@ -44,24 +47,148 @@ fn repeatable_insert(action: &ReplayableAction) -> Option<Box<dyn Action>> {
|
|||
pub(crate) fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
|
||||
workspace.register_action(|_: &mut Workspace, _: &EndRepeat, cx| {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
vim.workspace_state.replaying = false;
|
||||
vim.workspace_state.dot_replaying = false;
|
||||
vim.switch_mode(Mode::Normal, false, cx)
|
||||
});
|
||||
});
|
||||
|
||||
workspace.register_action(|_: &mut Workspace, _: &Repeat, cx| repeat(cx, false));
|
||||
workspace.register_action(|_: &mut Workspace, _: &ToggleRecord, cx| {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
if let Some(char) = vim.workspace_state.recording_register.take() {
|
||||
vim.workspace_state.last_recorded_register = Some(char)
|
||||
} else {
|
||||
vim.push_operator(Operator::RecordRegister, cx);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
workspace.register_action(|_: &mut Workspace, _: &ReplayLastRecording, cx| {
|
||||
let Some(register) = Vim::read(cx).workspace_state.last_recorded_register else {
|
||||
return;
|
||||
};
|
||||
replay_register(register, 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>, cx: &mut WindowContext) {
|
||||
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();
|
||||
cx.defer(move |cx| this.next(cx))
|
||||
}
|
||||
|
||||
pub fn stop(self) {
|
||||
self.0.borrow_mut().actions.clear()
|
||||
}
|
||||
|
||||
pub fn next(self, cx: &mut WindowContext) {
|
||||
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 {
|
||||
Vim::update(cx, |vim, _| vim.workspace_state.replayer.take());
|
||||
return;
|
||||
};
|
||||
match action {
|
||||
ReplayableAction::Action(action) => {
|
||||
if should_replay(&action) {
|
||||
cx.dispatch_action(action.boxed_clone());
|
||||
cx.defer(move |cx| observe_action(action.boxed_clone(), cx));
|
||||
}
|
||||
}
|
||||
ReplayableAction::Insertion {
|
||||
text,
|
||||
utf16_range_to_replace,
|
||||
} => {
|
||||
if let Some(editor) = Vim::read(cx).active_editor.clone() {
|
||||
editor
|
||||
.update(cx, |editor, cx| {
|
||||
editor.replay_insert_event(&text, utf16_range_to_replace.clone(), cx)
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.defer(move |cx| self.next(cx));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_register(register: char, cx: &mut WindowContext) {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
vim.workspace_state.recording_register = Some(register);
|
||||
vim.workspace_state.recordings.remove(®ister);
|
||||
vim.workspace_state.ignore_current_insertion = true;
|
||||
vim.clear_operator(cx)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn replay_register(mut register: char, cx: &mut WindowContext) {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
let mut count = vim.take_count(cx).unwrap_or(1);
|
||||
vim.clear_operator(cx);
|
||||
|
||||
if register == '@' {
|
||||
let Some(last) = vim.workspace_state.last_replayed_register else {
|
||||
return;
|
||||
};
|
||||
register = last;
|
||||
}
|
||||
let Some(actions) = vim.workspace_state.recordings.get(®ister) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut repeated_actions = vec![];
|
||||
while count > 0 {
|
||||
repeated_actions.extend(actions.iter().cloned());
|
||||
count -= 1
|
||||
}
|
||||
|
||||
vim.workspace_state.last_replayed_register = Some(register);
|
||||
|
||||
vim.workspace_state
|
||||
.replayer
|
||||
.get_or_insert_with(|| Replayer::new())
|
||||
.replay(repeated_actions, cx);
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn repeat(cx: &mut WindowContext, from_insert_mode: bool) {
|
||||
let Some((mut actions, editor, selection)) = Vim::update(cx, |vim, cx| {
|
||||
let Some((mut actions, selection)) = Vim::update(cx, |vim, cx| {
|
||||
let actions = vim.workspace_state.recorded_actions.clone();
|
||||
if actions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Some(editor) = vim.active_editor.clone() else {
|
||||
return None;
|
||||
};
|
||||
let count = vim.take_count(cx);
|
||||
|
||||
let selection = vim.workspace_state.recorded_selection.clone();
|
||||
|
@ -85,7 +212,17 @@ pub(crate) fn repeat(cx: &mut WindowContext, from_insert_mode: bool) {
|
|||
}
|
||||
}
|
||||
|
||||
Some((actions, editor, selection))
|
||||
if vim.workspace_state.replayer.is_none() {
|
||||
if let Some(recording_register) = vim.workspace_state.recording_register {
|
||||
vim.workspace_state
|
||||
.recordings
|
||||
.entry(recording_register)
|
||||
.or_default()
|
||||
.push(ReplayableAction::Action(Repeat.boxed_clone()));
|
||||
}
|
||||
}
|
||||
|
||||
Some((actions, selection))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
@ -167,42 +304,75 @@ pub(crate) fn repeat(cx: &mut WindowContext, from_insert_mode: bool) {
|
|||
actions = new_actions;
|
||||
}
|
||||
|
||||
Vim::update(cx, |vim, _| vim.workspace_state.replaying = true);
|
||||
let window = cx.window_handle();
|
||||
cx.spawn(move |mut cx| async move {
|
||||
editor.update(&mut cx, |editor, _| {
|
||||
editor.show_local_selections = false;
|
||||
})?;
|
||||
for action in actions {
|
||||
if !matches!(
|
||||
cx.update(|cx| Vim::read(cx).workspace_state.replaying),
|
||||
Ok(true)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
actions.push(ReplayableAction::Action(EndRepeat.boxed_clone()));
|
||||
|
||||
match action {
|
||||
ReplayableAction::Action(action) => {
|
||||
if should_replay(&action) {
|
||||
window.update(&mut cx, |_, cx| cx.dispatch_action(action))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
ReplayableAction::Insertion {
|
||||
text,
|
||||
utf16_range_to_replace,
|
||||
} => editor.update(&mut cx, |editor, cx| {
|
||||
editor.replay_insert_event(&text, utf16_range_to_replace.clone(), cx)
|
||||
}),
|
||||
}?
|
||||
}
|
||||
editor.update(&mut cx, |editor, _| {
|
||||
editor.show_local_selections = true;
|
||||
})?;
|
||||
window.update(&mut cx, |_, cx| cx.dispatch_action(EndRepeat.boxed_clone()))
|
||||
Vim::update(cx, |vim, cx| {
|
||||
vim.workspace_state.dot_replaying = true;
|
||||
|
||||
vim.workspace_state
|
||||
.replayer
|
||||
.get_or_insert_with(|| Replayer::new())
|
||||
.replay(actions, cx);
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
pub(crate) fn observe_action(action: Box<dyn Action>, cx: &mut WindowContext) {
|
||||
Vim::update(cx, |vim, _| {
|
||||
if vim.workspace_state.dot_recording {
|
||||
vim.workspace_state
|
||||
.recorded_actions
|
||||
.push(ReplayableAction::Action(action.boxed_clone()));
|
||||
|
||||
if vim.workspace_state.stop_recording_after_next_action {
|
||||
vim.workspace_state.dot_recording = false;
|
||||
vim.workspace_state.stop_recording_after_next_action = false;
|
||||
}
|
||||
}
|
||||
if vim.workspace_state.replayer.is_none() {
|
||||
if let Some(recording_register) = vim.workspace_state.recording_register {
|
||||
vim.workspace_state
|
||||
.recordings
|
||||
.entry(recording_register)
|
||||
.or_default()
|
||||
.push(ReplayableAction::Action(action));
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn observe_insertion(
|
||||
text: &Arc<str>,
|
||||
range_to_replace: Option<Range<isize>>,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
Vim::update(cx, |vim, _| {
|
||||
if vim.workspace_state.ignore_current_insertion {
|
||||
vim.workspace_state.ignore_current_insertion = false;
|
||||
return;
|
||||
}
|
||||
if vim.workspace_state.dot_recording {
|
||||
vim.workspace_state
|
||||
.recorded_actions
|
||||
.push(ReplayableAction::Insertion {
|
||||
text: text.clone(),
|
||||
utf16_range_to_replace: range_to_replace.clone(),
|
||||
});
|
||||
if vim.workspace_state.stop_recording_after_next_action {
|
||||
vim.workspace_state.dot_recording = false;
|
||||
vim.workspace_state.stop_recording_after_next_action = false;
|
||||
}
|
||||
}
|
||||
if let Some(recording_register) = vim.workspace_state.recording_register {
|
||||
vim.workspace_state
|
||||
.recordings
|
||||
.entry(recording_register)
|
||||
.or_default()
|
||||
.push(ReplayableAction::Insertion {
|
||||
text: text.clone(),
|
||||
utf16_range_to_replace: range_to_replace,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
@ -510,4 +680,76 @@ mod test {
|
|||
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");
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue