1use std::{cell::RefCell, rc::Rc};
2
3use crate::{
4 Vim,
5 insert::NormalBefore,
6 motion::Motion,
7 normal::InsertBefore,
8 state::{Mode, Operator, RecordedSelection, ReplayableAction, VimGlobals},
9};
10use editor::Editor;
11use gpui::{Action, App, Context, Window, actions};
12use workspace::Workspace;
13
14actions!(vim, [Repeat, EndRepeat, ToggleRecord, ReplayLastRecording]);
15
16fn should_replay(action: &dyn Action) -> bool {
17 // skip so that we don't leave the character palette open
18 if editor::actions::ShowCharacterPalette.partial_eq(action) {
19 return false;
20 }
21 true
22}
23
24fn repeatable_insert(action: &ReplayableAction) -> Option<Box<dyn Action>> {
25 match action {
26 ReplayableAction::Action(action) => {
27 if super::InsertBefore.partial_eq(&**action)
28 || super::InsertAfter.partial_eq(&**action)
29 || super::InsertFirstNonWhitespace.partial_eq(&**action)
30 || super::InsertEndOfLine.partial_eq(&**action)
31 {
32 Some(super::InsertBefore.boxed_clone())
33 } else if super::InsertLineAbove.partial_eq(&**action)
34 || super::InsertLineBelow.partial_eq(&**action)
35 {
36 Some(super::InsertLineBelow.boxed_clone())
37 } else if crate::replace::ToggleReplace.partial_eq(&**action) {
38 Some(crate::replace::ToggleReplace.boxed_clone())
39 } else {
40 None
41 }
42 }
43 ReplayableAction::Insertion { .. } => None,
44 }
45}
46
47pub(crate) fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
48 Vim::action(editor, cx, |vim, _: &EndRepeat, window, cx| {
49 Vim::globals(cx).dot_replaying = false;
50 vim.switch_mode(Mode::Normal, false, window, cx)
51 });
52
53 Vim::action(editor, cx, |vim, _: &Repeat, window, cx| {
54 vim.repeat(false, window, cx)
55 });
56
57 Vim::action(editor, cx, |vim, _: &ToggleRecord, window, cx| {
58 let globals = Vim::globals(cx);
59 if let Some(char) = globals.recording_register.take() {
60 globals.last_recorded_register = Some(char)
61 } else {
62 vim.push_operator(Operator::RecordRegister, window, cx);
63 }
64 });
65
66 Vim::action(editor, cx, |vim, _: &ReplayLastRecording, window, cx| {
67 let Some(register) = Vim::globals(cx).last_recorded_register else {
68 return;
69 };
70 vim.replay_register(register, window, cx)
71 });
72}
73
74pub struct ReplayerState {
75 actions: Vec<ReplayableAction>,
76 running: bool,
77 ix: usize,
78}
79
80#[derive(Clone)]
81pub struct Replayer(Rc<RefCell<ReplayerState>>);
82
83impl Replayer {
84 pub fn new() -> Self {
85 Self(Rc::new(RefCell::new(ReplayerState {
86 actions: vec![],
87 running: false,
88 ix: 0,
89 })))
90 }
91
92 pub fn replay(&mut self, actions: Vec<ReplayableAction>, window: &mut Window, cx: &mut App) {
93 let mut lock = self.0.borrow_mut();
94 let range = lock.ix..lock.ix;
95 lock.actions.splice(range, actions);
96 if lock.running {
97 return;
98 }
99 lock.running = true;
100 let this = self.clone();
101 window.defer(cx, move |window, cx| this.next(window, cx))
102 }
103
104 pub fn stop(self) {
105 self.0.borrow_mut().actions.clear()
106 }
107
108 pub fn next(self, window: &mut Window, cx: &mut App) {
109 let mut lock = self.0.borrow_mut();
110 let action = if lock.ix < 10000 {
111 lock.actions.get(lock.ix).cloned()
112 } else {
113 log::error!("Aborting replay after 10000 actions");
114 None
115 };
116 lock.ix += 1;
117 drop(lock);
118 let Some(action) = action else {
119 Vim::globals(cx).replayer.take();
120 return;
121 };
122 match action {
123 ReplayableAction::Action(action) => {
124 if should_replay(&*action) {
125 window.dispatch_action(action.boxed_clone(), cx);
126 cx.defer(move |cx| Vim::globals(cx).observe_action(action.boxed_clone()));
127 }
128 }
129 ReplayableAction::Insertion {
130 text,
131 utf16_range_to_replace,
132 } => {
133 let Some(Some(workspace)) = window.root::<Workspace>() else {
134 return;
135 };
136 let Some(editor) = workspace
137 .read(cx)
138 .active_item(cx)
139 .and_then(|item| item.act_as::<Editor>(cx))
140 else {
141 return;
142 };
143 editor.update(cx, |editor, cx| {
144 editor.replay_insert_event(&text, utf16_range_to_replace.clone(), window, cx)
145 })
146 }
147 }
148 window.defer(cx, move |window, cx| self.next(window, cx));
149 }
150}
151
152impl Vim {
153 pub(crate) fn record_register(
154 &mut self,
155 register: char,
156 window: &mut Window,
157 cx: &mut Context<Self>,
158 ) {
159 let globals = Vim::globals(cx);
160 globals.recording_register = Some(register);
161 globals.recordings.remove(®ister);
162 globals.ignore_current_insertion = true;
163 self.clear_operator(window, cx)
164 }
165
166 pub(crate) fn replay_register(
167 &mut self,
168 mut register: char,
169 window: &mut Window,
170 cx: &mut Context<Self>,
171 ) {
172 let mut count = Vim::take_count(cx).unwrap_or(1);
173 self.clear_operator(window, cx);
174
175 let globals = Vim::globals(cx);
176 if register == '@' {
177 let Some(last) = globals.last_replayed_register else {
178 return;
179 };
180 register = last;
181 }
182 let Some(actions) = globals.recordings.get(®ister) else {
183 return;
184 };
185
186 let mut repeated_actions = vec![];
187 while count > 0 {
188 repeated_actions.extend(actions.iter().cloned());
189 count -= 1
190 }
191
192 globals.last_replayed_register = Some(register);
193 let mut replayer = globals.replayer.get_or_insert_with(Replayer::new).clone();
194 replayer.replay(repeated_actions, window, cx);
195 }
196
197 pub(crate) fn repeat(
198 &mut self,
199 from_insert_mode: bool,
200 window: &mut Window,
201 cx: &mut Context<Self>,
202 ) {
203 let count = Vim::take_count(cx);
204
205 let Some((mut actions, selection, mode)) = Vim::update_globals(cx, |globals, _| {
206 let actions = globals.recorded_actions.clone();
207 if actions.is_empty() {
208 return None;
209 }
210 if globals.replayer.is_none() {
211 if let Some(recording_register) = globals.recording_register {
212 globals
213 .recordings
214 .entry(recording_register)
215 .or_default()
216 .push(ReplayableAction::Action(Repeat.boxed_clone()));
217 }
218 }
219
220 let mut mode = None;
221 let selection = globals.recorded_selection.clone();
222 match selection {
223 RecordedSelection::SingleLine { .. } | RecordedSelection::Visual { .. } => {
224 globals.recorded_count = None;
225 mode = Some(Mode::Visual);
226 }
227 RecordedSelection::VisualLine { .. } => {
228 globals.recorded_count = None;
229 mode = Some(Mode::VisualLine)
230 }
231 RecordedSelection::VisualBlock { .. } => {
232 globals.recorded_count = None;
233 mode = Some(Mode::VisualBlock)
234 }
235 RecordedSelection::None => {
236 if let Some(count) = count {
237 globals.recorded_count = Some(count);
238 }
239 }
240 }
241
242 Some((actions, selection, mode))
243 }) else {
244 return;
245 };
246 if let Some(mode) = mode {
247 self.switch_mode(mode, false, window, cx)
248 }
249
250 match selection {
251 RecordedSelection::SingleLine { cols } => {
252 if cols > 1 {
253 self.visual_motion(Motion::Right, Some(cols as usize - 1), window, cx)
254 }
255 }
256 RecordedSelection::Visual { rows, cols } => {
257 self.visual_motion(
258 Motion::Down {
259 display_lines: false,
260 },
261 Some(rows as usize),
262 window,
263 cx,
264 );
265 self.visual_motion(
266 Motion::StartOfLine {
267 display_lines: false,
268 },
269 None,
270 window,
271 cx,
272 );
273 if cols > 1 {
274 self.visual_motion(Motion::Right, Some(cols as usize - 1), window, cx)
275 }
276 }
277 RecordedSelection::VisualBlock { rows, cols } => {
278 self.visual_motion(
279 Motion::Down {
280 display_lines: false,
281 },
282 Some(rows as usize),
283 window,
284 cx,
285 );
286 if cols > 1 {
287 self.visual_motion(Motion::Right, Some(cols as usize - 1), window, cx);
288 }
289 }
290 RecordedSelection::VisualLine { rows } => {
291 self.visual_motion(
292 Motion::Down {
293 display_lines: false,
294 },
295 Some(rows as usize),
296 window,
297 cx,
298 );
299 }
300 RecordedSelection::None => {}
301 }
302
303 // insert internally uses repeat to handle counts
304 // vim doesn't treat 3a1 as though you literally repeated a1
305 // 3 times, instead it inserts the content thrice at the insert position.
306 if let Some(to_repeat) = repeatable_insert(&actions[0]) {
307 if let Some(ReplayableAction::Action(action)) = actions.last() {
308 if NormalBefore.partial_eq(&**action) {
309 actions.pop();
310 }
311 }
312
313 let mut new_actions = actions.clone();
314 actions[0] = ReplayableAction::Action(to_repeat.boxed_clone());
315
316 let mut count = cx.global::<VimGlobals>().recorded_count.unwrap_or(1);
317
318 // if we came from insert mode we're just doing repetitions 2 onwards.
319 if from_insert_mode {
320 count -= 1;
321 new_actions[0] = actions[0].clone();
322 }
323
324 for _ in 1..count {
325 new_actions.append(actions.clone().as_mut());
326 }
327 new_actions.push(ReplayableAction::Action(NormalBefore.boxed_clone()));
328 actions = new_actions;
329 }
330
331 actions.push(ReplayableAction::Action(EndRepeat.boxed_clone()));
332
333 if self.temp_mode {
334 self.temp_mode = false;
335 actions.push(ReplayableAction::Action(InsertBefore.boxed_clone()));
336 }
337
338 let globals = Vim::globals(cx);
339 globals.dot_replaying = true;
340 let mut replayer = globals.replayer.get_or_insert_with(Replayer::new).clone();
341
342 replayer.replay(actions, window, cx);
343 }
344}
345
346#[cfg(test)]
347mod test {
348 use editor::test::editor_lsp_test_context::EditorLspTestContext;
349 use futures::StreamExt;
350 use indoc::indoc;
351
352 use gpui::EntityInputHandler;
353
354 use crate::{
355 state::Mode,
356 test::{NeovimBackedTestContext, VimTestContext},
357 };
358
359 #[gpui::test]
360 async fn test_dot_repeat(cx: &mut gpui::TestAppContext) {
361 let mut cx = NeovimBackedTestContext::new(cx).await;
362
363 // "o"
364 cx.set_shared_state("ˇhello").await;
365 cx.simulate_shared_keystrokes("o w o r l d escape").await;
366 cx.shared_state().await.assert_eq("hello\nworlˇd");
367 cx.simulate_shared_keystrokes(".").await;
368 cx.shared_state().await.assert_eq("hello\nworld\nworlˇd");
369
370 // "d"
371 cx.simulate_shared_keystrokes("^ d f o").await;
372 cx.simulate_shared_keystrokes("g g .").await;
373 cx.shared_state().await.assert_eq("ˇ\nworld\nrld");
374
375 // "p" (note that it pastes the current clipboard)
376 cx.simulate_shared_keystrokes("j y y p").await;
377 cx.simulate_shared_keystrokes("shift-g y y .").await;
378 cx.shared_state()
379 .await
380 .assert_eq("\nworld\nworld\nrld\nˇrld");
381
382 // "~" (note that counts apply to the action taken, not . itself)
383 cx.set_shared_state("ˇthe quick brown fox").await;
384 cx.simulate_shared_keystrokes("2 ~ .").await;
385 cx.set_shared_state("THE ˇquick brown fox").await;
386 cx.simulate_shared_keystrokes("3 .").await;
387 cx.set_shared_state("THE QUIˇck brown fox").await;
388 cx.run_until_parked();
389 cx.simulate_shared_keystrokes(".").await;
390 cx.shared_state().await.assert_eq("THE QUICK ˇbrown fox");
391 }
392
393 #[gpui::test]
394 async fn test_repeat_ime(cx: &mut gpui::TestAppContext) {
395 let mut cx = VimTestContext::new(cx, true).await;
396
397 cx.set_state("hˇllo", Mode::Normal);
398 cx.simulate_keystrokes("i");
399
400 // simulate brazilian input for ä.
401 cx.update_editor(|editor, window, cx| {
402 editor.replace_and_mark_text_in_range(None, "\"", Some(1..1), window, cx);
403 editor.replace_text_in_range(None, "ä", window, cx);
404 });
405 cx.simulate_keystrokes("escape");
406 cx.assert_state("hˇällo", Mode::Normal);
407 cx.simulate_keystrokes(".");
408 cx.assert_state("hˇäällo", Mode::Normal);
409 }
410
411 #[gpui::test]
412 async fn test_repeat_completion(cx: &mut gpui::TestAppContext) {
413 VimTestContext::init(cx);
414 let cx = EditorLspTestContext::new_rust(
415 lsp::ServerCapabilities {
416 completion_provider: Some(lsp::CompletionOptions {
417 trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
418 resolve_provider: Some(true),
419 ..Default::default()
420 }),
421 ..Default::default()
422 },
423 cx,
424 )
425 .await;
426 let mut cx = VimTestContext::new_with_lsp(cx, true);
427
428 cx.set_state(
429 indoc! {"
430 onˇe
431 two
432 three
433 "},
434 Mode::Normal,
435 );
436
437 let mut request = cx.set_request_handler::<lsp::request::Completion, _, _>(
438 move |_, params, _| async move {
439 let position = params.text_document_position.position;
440 Ok(Some(lsp::CompletionResponse::Array(vec![
441 lsp::CompletionItem {
442 label: "first".to_string(),
443 text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
444 range: lsp::Range::new(position, position),
445 new_text: "first".to_string(),
446 })),
447 ..Default::default()
448 },
449 lsp::CompletionItem {
450 label: "second".to_string(),
451 text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
452 range: lsp::Range::new(position, position),
453 new_text: "second".to_string(),
454 })),
455 ..Default::default()
456 },
457 ])))
458 },
459 );
460 cx.simulate_keystrokes("a .");
461 request.next().await;
462 cx.condition(|editor, _| editor.context_menu_visible())
463 .await;
464 cx.simulate_keystrokes("down enter ! escape");
465
466 cx.assert_state(
467 indoc! {"
468 one.secondˇ!
469 two
470 three
471 "},
472 Mode::Normal,
473 );
474 cx.simulate_keystrokes("j .");
475 cx.assert_state(
476 indoc! {"
477 one.second!
478 two.secondˇ!
479 three
480 "},
481 Mode::Normal,
482 );
483 }
484
485 #[gpui::test]
486 async fn test_repeat_visual(cx: &mut gpui::TestAppContext) {
487 let mut cx = NeovimBackedTestContext::new(cx).await;
488
489 // single-line (3 columns)
490 cx.set_shared_state(indoc! {
491 "ˇthe quick brown
492 fox jumps over
493 the lazy dog"
494 })
495 .await;
496 cx.simulate_shared_keystrokes("v i w s o escape").await;
497 cx.shared_state().await.assert_eq(indoc! {
498 "ˇo quick brown
499 fox jumps over
500 the lazy dog"
501 });
502 cx.simulate_shared_keystrokes("j w .").await;
503 cx.shared_state().await.assert_eq(indoc! {
504 "o quick brown
505 fox ˇops over
506 the lazy dog"
507 });
508 cx.simulate_shared_keystrokes("f r .").await;
509 cx.shared_state().await.assert_eq(indoc! {
510 "o quick brown
511 fox ops oveˇothe lazy dog"
512 });
513
514 // visual
515 cx.set_shared_state(indoc! {
516 "the ˇquick brown
517 fox jumps over
518 fox jumps over
519 fox jumps over
520 the lazy dog"
521 })
522 .await;
523 cx.simulate_shared_keystrokes("v j x").await;
524 cx.shared_state().await.assert_eq(indoc! {
525 "the ˇumps over
526 fox jumps over
527 fox jumps over
528 the lazy dog"
529 });
530 cx.simulate_shared_keystrokes(".").await;
531 cx.shared_state().await.assert_eq(indoc! {
532 "the ˇumps over
533 fox jumps over
534 the lazy dog"
535 });
536 cx.simulate_shared_keystrokes("w .").await;
537 cx.shared_state().await.assert_eq(indoc! {
538 "the umps ˇumps over
539 the lazy dog"
540 });
541 cx.simulate_shared_keystrokes("j .").await;
542 cx.shared_state().await.assert_eq(indoc! {
543 "the umps umps over
544 the ˇog"
545 });
546
547 // block mode (3 rows)
548 cx.set_shared_state(indoc! {
549 "ˇthe quick brown
550 fox jumps over
551 the lazy dog"
552 })
553 .await;
554 cx.simulate_shared_keystrokes("ctrl-v j j shift-i o escape")
555 .await;
556 cx.shared_state().await.assert_eq(indoc! {
557 "ˇothe quick brown
558 ofox jumps over
559 othe lazy dog"
560 });
561 cx.simulate_shared_keystrokes("j 4 l .").await;
562 cx.shared_state().await.assert_eq(indoc! {
563 "othe quick brown
564 ofoxˇo jumps over
565 otheo lazy dog"
566 });
567
568 // line mode
569 cx.set_shared_state(indoc! {
570 "ˇthe quick brown
571 fox jumps over
572 the lazy dog"
573 })
574 .await;
575 cx.simulate_shared_keystrokes("shift-v shift-r o escape")
576 .await;
577 cx.shared_state().await.assert_eq(indoc! {
578 "ˇo
579 fox jumps over
580 the lazy dog"
581 });
582 cx.simulate_shared_keystrokes("j .").await;
583 cx.shared_state().await.assert_eq(indoc! {
584 "o
585 ˇo
586 the lazy dog"
587 });
588 }
589
590 #[gpui::test]
591 async fn test_repeat_motion_counts(cx: &mut gpui::TestAppContext) {
592 let mut cx = NeovimBackedTestContext::new(cx).await;
593
594 cx.set_shared_state(indoc! {
595 "ˇthe quick brown
596 fox jumps over
597 the lazy dog"
598 })
599 .await;
600 cx.simulate_shared_keystrokes("3 d 3 l").await;
601 cx.shared_state().await.assert_eq(indoc! {
602 "ˇ brown
603 fox jumps over
604 the lazy dog"
605 });
606 cx.simulate_shared_keystrokes("j .").await;
607 cx.shared_state().await.assert_eq(indoc! {
608 " brown
609 ˇ over
610 the lazy dog"
611 });
612 cx.simulate_shared_keystrokes("j 2 .").await;
613 cx.shared_state().await.assert_eq(indoc! {
614 " brown
615 over
616 ˇe lazy dog"
617 });
618 }
619
620 #[gpui::test]
621 async fn test_record_interrupted(cx: &mut gpui::TestAppContext) {
622 let mut cx = VimTestContext::new(cx, true).await;
623
624 cx.set_state("ˇhello\n", Mode::Normal);
625 cx.simulate_keystrokes("4 i j cmd-shift-p escape");
626 cx.simulate_keystrokes("escape");
627 cx.assert_state("ˇjhello\n", Mode::Normal);
628 }
629
630 #[gpui::test]
631 async fn test_repeat_over_blur(cx: &mut gpui::TestAppContext) {
632 let mut cx = NeovimBackedTestContext::new(cx).await;
633
634 cx.set_shared_state("ˇhello hello hello\n").await;
635 cx.simulate_shared_keystrokes("c f o x escape").await;
636 cx.shared_state().await.assert_eq("ˇx hello hello\n");
637 cx.simulate_shared_keystrokes(": escape").await;
638 cx.simulate_shared_keystrokes(".").await;
639 cx.shared_state().await.assert_eq("ˇx hello\n");
640 }
641
642 #[gpui::test]
643 async fn test_undo_repeated_insert(cx: &mut gpui::TestAppContext) {
644 let mut cx = NeovimBackedTestContext::new(cx).await;
645
646 cx.set_shared_state("hellˇo").await;
647 cx.simulate_shared_keystrokes("3 a . escape").await;
648 cx.shared_state().await.assert_eq("hello..ˇ.");
649 cx.simulate_shared_keystrokes("u").await;
650 cx.shared_state().await.assert_eq("hellˇo");
651 }
652
653 #[gpui::test]
654 async fn test_record_replay(cx: &mut gpui::TestAppContext) {
655 let mut cx = NeovimBackedTestContext::new(cx).await;
656
657 cx.set_shared_state("ˇhello world").await;
658 cx.simulate_shared_keystrokes("q w c w j escape q").await;
659 cx.shared_state().await.assert_eq("ˇj world");
660 cx.simulate_shared_keystrokes("2 l @ w").await;
661 cx.shared_state().await.assert_eq("j ˇj");
662 }
663
664 #[gpui::test]
665 async fn test_record_replay_count(cx: &mut gpui::TestAppContext) {
666 let mut cx = NeovimBackedTestContext::new(cx).await;
667
668 cx.set_shared_state("ˇhello world!!").await;
669 cx.simulate_shared_keystrokes("q a v 3 l s 0 escape l q")
670 .await;
671 cx.shared_state().await.assert_eq("0ˇo world!!");
672 cx.simulate_shared_keystrokes("2 @ a").await;
673 cx.shared_state().await.assert_eq("000ˇ!");
674 }
675
676 #[gpui::test]
677 async fn test_record_replay_dot(cx: &mut gpui::TestAppContext) {
678 let mut cx = NeovimBackedTestContext::new(cx).await;
679
680 cx.set_shared_state("ˇhello world").await;
681 cx.simulate_shared_keystrokes("q a r a l r b l q").await;
682 cx.shared_state().await.assert_eq("abˇllo world");
683 cx.simulate_shared_keystrokes(".").await;
684 cx.shared_state().await.assert_eq("abˇblo world");
685 cx.simulate_shared_keystrokes("shift-q").await;
686 cx.shared_state().await.assert_eq("ababˇo world");
687 cx.simulate_shared_keystrokes(".").await;
688 cx.shared_state().await.assert_eq("ababˇb world");
689 }
690
691 #[gpui::test]
692 async fn test_record_replay_of_dot(cx: &mut gpui::TestAppContext) {
693 let mut cx = NeovimBackedTestContext::new(cx).await;
694
695 cx.set_shared_state("ˇhello world").await;
696 cx.simulate_shared_keystrokes("r o q w . q").await;
697 cx.shared_state().await.assert_eq("ˇoello world");
698 cx.simulate_shared_keystrokes("d l").await;
699 cx.shared_state().await.assert_eq("ˇello world");
700 cx.simulate_shared_keystrokes("@ w").await;
701 cx.shared_state().await.assert_eq("ˇllo world");
702 }
703
704 #[gpui::test]
705 async fn test_record_replay_interleaved(cx: &mut gpui::TestAppContext) {
706 let mut cx = NeovimBackedTestContext::new(cx).await;
707
708 cx.set_shared_state("ˇhello world").await;
709 cx.simulate_shared_keystrokes("q z r a l q").await;
710 cx.shared_state().await.assert_eq("aˇello world");
711 cx.simulate_shared_keystrokes("q b @ z @ z q").await;
712 cx.shared_state().await.assert_eq("aaaˇlo world");
713 cx.simulate_shared_keystrokes("@ @").await;
714 cx.shared_state().await.assert_eq("aaaaˇo world");
715 cx.simulate_shared_keystrokes("@ b").await;
716 cx.shared_state().await.assert_eq("aaaaaaˇworld");
717 cx.simulate_shared_keystrokes("@ @").await;
718 cx.shared_state().await.assert_eq("aaaaaaaˇorld");
719 cx.simulate_shared_keystrokes("q z r b l q").await;
720 cx.shared_state().await.assert_eq("aaaaaaabˇrld");
721 cx.simulate_shared_keystrokes("@ b").await;
722 cx.shared_state().await.assert_eq("aaaaaaabbbˇd");
723 }
724}