Files
oak-gpui/crates/vim/src/normal/repeat.rs
T
Conrad Irwin 36d51fe4a5 vim: Improve lifecycle (#16477)
Closes #13579

A major painpoint in the Vim crate has been life-cycle management. We
used to have one global Vim instance that tried to track per-editor
state; this led to a number of subtle issues (e.g. #13579, the mode
indicator being global, and quick toggling between windows letting vim
mode's notion of the active editor get out of sync).

This PR changes the internal structure of the code so that there is now
one `Vim` instance per `Editor` (stored as an `Addon`); and the global
stuff is separated out. This fixes the above problems, and tidies up a
bunch of the mess in the codebase.

Release Notes:

* vim: Fixed accidental visual mode in project search and go to
references
([#13579](https://github.com/zed-industries/zed/issues/13579)).
2024-08-20 20:48:50 -06:00

702 lines
24 KiB
Rust

use std::{cell::RefCell, rc::Rc};
use crate::{
insert::NormalBefore,
motion::Motion,
state::{Mode, Operator, RecordedSelection, ReplayableAction, VimGlobals},
Vim,
};
use editor::Editor;
use gpui::{actions, Action, ViewContext, WindowContext};
use util::ResultExt;
use workspace::Workspace;
actions!(vim, [Repeat, EndRepeat, ToggleRecord, 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 ViewContext<Vim>) {
Vim::action(editor, cx, |vim, _: &EndRepeat, cx| {
Vim::globals(cx).dot_replaying = false;
vim.switch_mode(Mode::Normal, false, cx)
});
Vim::action(editor, cx, |vim, _: &Repeat, cx| vim.repeat(false, cx));
Vim::action(editor, cx, |vim, _: &ToggleRecord, 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, cx);
}
});
Vim::action(editor, cx, |vim, _: &ReplayLastRecording, cx| {
let Some(register) = Vim::globals(cx).last_recorded_register else {
return;
};
vim.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::globals(cx).replayer.take();
return;
};
match action {
ReplayableAction::Action(action) => {
if should_replay(&*action) {
cx.dispatch_action(action.boxed_clone());
cx.defer(move |cx| Vim::globals(cx).observe_action(action.boxed_clone()));
}
}
ReplayableAction::Insertion {
text,
utf16_range_to_replace,
} => {
cx.window_handle()
.update(cx, |handle, cx| {
let Ok(workspace) = handle.downcast::<Workspace>() else {
return;
};
let Some(editor) = workspace.read(cx).active_item_as::<Editor>(cx) else {
return;
};
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));
}
}
impl Vim {
pub(crate) fn record_register(&mut self, register: char, cx: &mut ViewContext<Self>) {
let globals = Vim::globals(cx);
globals.recording_register = Some(register);
globals.recordings.remove(&register);
globals.ignore_current_insertion = true;
self.clear_operator(cx)
}
pub(crate) fn replay_register(&mut self, mut register: char, cx: &mut ViewContext<Self>) {
let mut count = self.take_count(cx).unwrap_or(1);
self.clear_operator(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(&register) 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, cx);
}
pub(crate) fn repeat(&mut self, from_insert_mode: bool, cx: &mut ViewContext<Self>) {
let count = self.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() {
if 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;
};
if let Some(mode) = mode {
self.switch_mode(mode, false, cx)
}
match selection {
RecordedSelection::SingleLine { cols } => {
if cols > 1 {
self.visual_motion(Motion::Right, Some(cols as usize - 1), cx)
}
}
RecordedSelection::Visual { rows, cols } => {
self.visual_motion(
Motion::Down {
display_lines: false,
},
Some(rows as usize),
cx,
);
self.visual_motion(
Motion::StartOfLine {
display_lines: false,
},
None,
cx,
);
if cols > 1 {
self.visual_motion(Motion::Right, Some(cols as usize - 1), cx)
}
}
RecordedSelection::VisualBlock { rows, cols } => {
self.visual_motion(
Motion::Down {
display_lines: false,
},
Some(rows as usize),
cx,
);
if cols > 1 {
self.visual_motion(Motion::Right, Some(cols as usize - 1), cx);
}
}
RecordedSelection::VisualLine { rows } => {
self.visual_motion(
Motion::Down {
display_lines: false,
},
Some(rows as usize),
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() {
if 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()));
let globals = Vim::globals(cx);
globals.dot_replaying = true;
let mut replayer = globals
.replayer
.get_or_insert_with(|| Replayer::new())
.clone();
replayer.replay(actions, cx);
}
}
#[cfg(test)]
mod test {
use editor::test::editor_lsp_test_context::EditorLspTestContext;
use futures::StreamExt;
use indoc::indoc;
use gpui::ViewInputHandler;
use crate::{
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");
}
#[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, cx| {
editor.replace_and_mark_text_in_range(None, "\"", Some(1..1), cx);
editor.replace_text_in_range(None, "ä", 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.handle_request::<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_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_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");
}
}