repeat.rs

  1use crate::{
  2    insert::NormalBefore,
  3    motion::Motion,
  4    state::{Mode, RecordedSelection, ReplayableAction},
  5    visual::visual_motion,
  6    Vim,
  7};
  8use gpui::{actions, Action, AppContext, WindowContext};
  9use workspace::Workspace;
 10
 11actions!(vim, [Repeat, EndRepeat,]);
 12
 13fn should_replay(action: &Box<dyn Action>) -> bool {
 14    // skip so that we don't leave the character palette open
 15    if editor::ShowCharacterPalette.id() == action.id() {
 16        return false;
 17    }
 18    true
 19}
 20
 21fn repeatable_insert(action: &ReplayableAction) -> Option<Box<dyn Action>> {
 22    match action {
 23        ReplayableAction::Action(action) => {
 24            if super::InsertBefore.id() == action.id()
 25                || super::InsertAfter.id() == action.id()
 26                || super::InsertFirstNonWhitespace.id() == action.id()
 27                || super::InsertEndOfLine.id() == action.id()
 28            {
 29                Some(super::InsertBefore.boxed_clone())
 30            } else if super::InsertLineAbove.id() == action.id()
 31                || super::InsertLineBelow.id() == action.id()
 32            {
 33                Some(super::InsertLineBelow.boxed_clone())
 34            } else {
 35                None
 36            }
 37        }
 38        ReplayableAction::Insertion { .. } => None,
 39    }
 40}
 41
 42pub(crate) fn init(cx: &mut AppContext) {
 43    cx.add_action(|_: &mut Workspace, _: &EndRepeat, cx| {
 44        Vim::update(cx, |vim, cx| {
 45            vim.workspace_state.replaying = false;
 46            vim.update_active_editor(cx, |editor, _| {
 47                editor.show_local_selections = true;
 48            });
 49            vim.switch_mode(Mode::Normal, false, cx)
 50        });
 51    });
 52
 53    cx.add_action(|_: &mut Workspace, _: &Repeat, cx| repeat(cx, false));
 54}
 55
 56pub(crate) fn repeat(cx: &mut WindowContext, from_insert_mode: bool) {
 57    let Some((mut actions, editor, selection)) = Vim::update(cx, |vim, cx| {
 58        let actions = vim.workspace_state.recorded_actions.clone();
 59        let Some(editor) = vim.active_editor.clone() else {
 60            return None;
 61        };
 62        let count = vim.take_count();
 63
 64        let selection = vim.workspace_state.recorded_selection.clone();
 65        match selection {
 66            RecordedSelection::SingleLine { .. } | RecordedSelection::Visual { .. } => {
 67                vim.workspace_state.recorded_count = None;
 68                vim.switch_mode(Mode::Visual, false, cx)
 69            }
 70            RecordedSelection::VisualLine { .. } => {
 71                vim.workspace_state.recorded_count = None;
 72                vim.switch_mode(Mode::VisualLine, false, cx)
 73            }
 74            RecordedSelection::VisualBlock { .. } => {
 75                vim.workspace_state.recorded_count = None;
 76                vim.switch_mode(Mode::VisualBlock, false, cx)
 77            }
 78            RecordedSelection::None => {
 79                if let Some(count) = count {
 80                    vim.workspace_state.recorded_count = Some(count);
 81                }
 82            }
 83        }
 84
 85        if let Some(editor) = editor.upgrade(cx) {
 86            editor.update(cx, |editor, _| {
 87                editor.show_local_selections = false;
 88            })
 89        } else {
 90            return None;
 91        }
 92
 93        Some((actions, editor, selection))
 94    }) else {
 95        return;
 96    };
 97
 98    match selection {
 99        RecordedSelection::SingleLine { cols } => {
100            if cols > 1 {
101                visual_motion(Motion::Right, Some(cols as usize - 1), cx)
102            }
103        }
104        RecordedSelection::Visual { rows, cols } => {
105            visual_motion(
106                Motion::Down {
107                    display_lines: false,
108                },
109                Some(rows as usize),
110                cx,
111            );
112            visual_motion(
113                Motion::StartOfLine {
114                    display_lines: false,
115                },
116                None,
117                cx,
118            );
119            if cols > 1 {
120                visual_motion(Motion::Right, Some(cols as usize - 1), cx)
121            }
122        }
123        RecordedSelection::VisualBlock { rows, cols } => {
124            visual_motion(
125                Motion::Down {
126                    display_lines: false,
127                },
128                Some(rows as usize),
129                cx,
130            );
131            if cols > 1 {
132                visual_motion(Motion::Right, Some(cols as usize - 1), cx);
133            }
134        }
135        RecordedSelection::VisualLine { rows } => {
136            visual_motion(
137                Motion::Down {
138                    display_lines: false,
139                },
140                Some(rows as usize),
141                cx,
142            );
143        }
144        RecordedSelection::None => {}
145    }
146
147    // insert internally uses repeat to handle counts
148    // vim doesn't treat 3a1 as though you literally repeated a1
149    // 3 times, instead it inserts the content thrice at the insert position.
150    if let Some(to_repeat) = repeatable_insert(&actions[0]) {
151        if let Some(ReplayableAction::Action(action)) = actions.last() {
152            if action.id() == NormalBefore.id() {
153                actions.pop();
154            }
155        }
156
157        let mut new_actions = actions.clone();
158        actions[0] = ReplayableAction::Action(to_repeat.boxed_clone());
159
160        let mut count = Vim::read(cx).workspace_state.recorded_count.unwrap_or(1);
161
162        // if we came from insert mode we're just doing repititions 2 onwards.
163        if from_insert_mode {
164            count -= 1;
165            new_actions[0] = actions[0].clone();
166        }
167
168        for _ in 1..count {
169            new_actions.append(actions.clone().as_mut());
170        }
171        new_actions.push(ReplayableAction::Action(NormalBefore.boxed_clone()));
172        actions = new_actions;
173    }
174
175    Vim::update(cx, |vim, _| vim.workspace_state.replaying = true);
176    let window = cx.window();
177    cx.app_context()
178        .spawn(move |mut cx| async move {
179            for action in actions {
180                match action {
181                    ReplayableAction::Action(action) => {
182                        if should_replay(&action) {
183                            window
184                                .dispatch_action(editor.id(), action.as_ref(), &mut cx)
185                                .ok_or_else(|| anyhow::anyhow!("window was closed"))
186                        } else {
187                            Ok(())
188                        }
189                    }
190                    ReplayableAction::Insertion {
191                        text,
192                        utf16_range_to_replace,
193                    } => editor.update(&mut cx, |editor, cx| {
194                        editor.replay_insert_event(&text, utf16_range_to_replace.clone(), cx)
195                    }),
196                }?
197            }
198            window
199                .dispatch_action(editor.id(), &EndRepeat, &mut cx)
200                .ok_or_else(|| anyhow::anyhow!("window was closed"))
201        })
202        .detach_and_log_err(cx);
203}
204
205#[cfg(test)]
206mod test {
207    use std::sync::Arc;
208
209    use editor::test::editor_lsp_test_context::EditorLspTestContext;
210    use futures::StreamExt;
211    use indoc::indoc;
212
213    use gpui::{executor::Deterministic, View};
214
215    use crate::{
216        state::Mode,
217        test::{NeovimBackedTestContext, VimTestContext},
218    };
219
220    #[gpui::test]
221    async fn test_dot_repeat(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppContext) {
222        let mut cx = NeovimBackedTestContext::new(cx).await;
223
224        // "o"
225        cx.set_shared_state("ˇhello").await;
226        cx.simulate_shared_keystrokes(["o", "w", "o", "r", "l", "d", "escape"])
227            .await;
228        cx.assert_shared_state("hello\nworlˇd").await;
229        cx.simulate_shared_keystrokes(["."]).await;
230        deterministic.run_until_parked();
231        cx.assert_shared_state("hello\nworld\nworlˇd").await;
232
233        // "d"
234        cx.simulate_shared_keystrokes(["^", "d", "f", "o"]).await;
235        cx.simulate_shared_keystrokes(["g", "g", "."]).await;
236        deterministic.run_until_parked();
237        cx.assert_shared_state("ˇ\nworld\nrld").await;
238
239        // "p" (note that it pastes the current clipboard)
240        cx.simulate_shared_keystrokes(["j", "y", "y", "p"]).await;
241        cx.simulate_shared_keystrokes(["shift-g", "y", "y", "."])
242            .await;
243        deterministic.run_until_parked();
244        cx.assert_shared_state("\nworld\nworld\nrld\nˇrld").await;
245
246        // "~" (note that counts apply to the action taken, not . itself)
247        cx.set_shared_state("ˇthe quick brown fox").await;
248        cx.simulate_shared_keystrokes(["2", "~", "."]).await;
249        deterministic.run_until_parked();
250        cx.set_shared_state("THE ˇquick brown fox").await;
251        cx.simulate_shared_keystrokes(["3", "."]).await;
252        deterministic.run_until_parked();
253        cx.set_shared_state("THE QUIˇck brown fox").await;
254        deterministic.run_until_parked();
255        cx.simulate_shared_keystrokes(["."]).await;
256        deterministic.run_until_parked();
257        cx.set_shared_state("THE QUICK ˇbrown fox").await;
258    }
259
260    #[gpui::test]
261    async fn test_repeat_ime(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppContext) {
262        let mut cx = VimTestContext::new(cx, true).await;
263
264        cx.set_state("hˇllo", Mode::Normal);
265        cx.simulate_keystrokes(["i"]);
266
267        // simulate brazilian input for ä.
268        cx.update_editor(|editor, cx| {
269            editor.replace_and_mark_text_in_range(None, "\"", Some(1..1), cx);
270            editor.replace_text_in_range(None, "ä", cx);
271        });
272        cx.simulate_keystrokes(["escape"]);
273        cx.assert_state("hˇällo", Mode::Normal);
274        cx.simulate_keystrokes(["."]);
275        deterministic.run_until_parked();
276        cx.assert_state("hˇäällo", Mode::Normal);
277    }
278
279    #[gpui::test]
280    async fn test_repeat_completion(
281        deterministic: Arc<Deterministic>,
282        cx: &mut gpui::TestAppContext,
283    ) {
284        let cx = EditorLspTestContext::new_rust(
285            lsp::ServerCapabilities {
286                completion_provider: Some(lsp::CompletionOptions {
287                    trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
288                    resolve_provider: Some(true),
289                    ..Default::default()
290                }),
291                ..Default::default()
292            },
293            cx,
294        )
295        .await;
296        let mut cx = VimTestContext::new_with_lsp(cx, true);
297
298        cx.set_state(
299            indoc! {"
300            onˇe
301            two
302            three
303        "},
304            Mode::Normal,
305        );
306
307        let mut request =
308            cx.handle_request::<lsp::request::Completion, _, _>(move |_, params, _| async move {
309                let position = params.text_document_position.position;
310                Ok(Some(lsp::CompletionResponse::Array(vec![
311                    lsp::CompletionItem {
312                        label: "first".to_string(),
313                        text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
314                            range: lsp::Range::new(position.clone(), position.clone()),
315                            new_text: "first".to_string(),
316                        })),
317                        ..Default::default()
318                    },
319                    lsp::CompletionItem {
320                        label: "second".to_string(),
321                        text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
322                            range: lsp::Range::new(position.clone(), position.clone()),
323                            new_text: "second".to_string(),
324                        })),
325                        ..Default::default()
326                    },
327                ])))
328            });
329        cx.simulate_keystrokes(["a", "."]);
330        request.next().await;
331        cx.condition(|editor, _| editor.context_menu_visible())
332            .await;
333        cx.simulate_keystrokes(["down", "enter", "!", "escape"]);
334
335        cx.assert_state(
336            indoc! {"
337                one.secondˇ!
338                two
339                three
340            "},
341            Mode::Normal,
342        );
343        cx.simulate_keystrokes(["j", "."]);
344        deterministic.run_until_parked();
345        cx.assert_state(
346            indoc! {"
347                one.second!
348                two.secondˇ!
349                three
350            "},
351            Mode::Normal,
352        );
353    }
354
355    #[gpui::test]
356    async fn test_repeat_visual(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppContext) {
357        let mut cx = NeovimBackedTestContext::new(cx).await;
358
359        // single-line (3 columns)
360        cx.set_shared_state(indoc! {
361            "ˇthe quick brown
362            fox jumps over
363            the lazy dog"
364        })
365        .await;
366        cx.simulate_shared_keystrokes(["v", "i", "w", "s", "o", "escape"])
367            .await;
368        cx.assert_shared_state(indoc! {
369            "ˇo quick brown
370            fox jumps over
371            the lazy dog"
372        })
373        .await;
374        cx.simulate_shared_keystrokes(["j", "w", "."]).await;
375        deterministic.run_until_parked();
376        cx.assert_shared_state(indoc! {
377            "o quick brown
378            fox ˇops over
379            the lazy dog"
380        })
381        .await;
382        cx.simulate_shared_keystrokes(["f", "r", "."]).await;
383        deterministic.run_until_parked();
384        cx.assert_shared_state(indoc! {
385            "o quick brown
386            fox ops oveˇothe lazy dog"
387        })
388        .await;
389
390        // visual
391        cx.set_shared_state(indoc! {
392            "the ˇquick brown
393            fox jumps over
394            fox jumps over
395            fox jumps over
396            the lazy dog"
397        })
398        .await;
399        cx.simulate_shared_keystrokes(["v", "j", "x"]).await;
400        cx.assert_shared_state(indoc! {
401            "the ˇumps over
402            fox jumps over
403            fox jumps over
404            the lazy dog"
405        })
406        .await;
407        cx.simulate_shared_keystrokes(["."]).await;
408        deterministic.run_until_parked();
409        cx.assert_shared_state(indoc! {
410            "the ˇumps over
411            fox jumps over
412            the lazy dog"
413        })
414        .await;
415        cx.simulate_shared_keystrokes(["w", "."]).await;
416        deterministic.run_until_parked();
417        cx.assert_shared_state(indoc! {
418            "the umps ˇumps over
419            the lazy dog"
420        })
421        .await;
422        cx.simulate_shared_keystrokes(["j", "."]).await;
423        deterministic.run_until_parked();
424        cx.assert_shared_state(indoc! {
425            "the umps umps over
426            the ˇog"
427        })
428        .await;
429
430        // block mode (3 rows)
431        cx.set_shared_state(indoc! {
432            "ˇthe quick brown
433            fox jumps over
434            the lazy dog"
435        })
436        .await;
437        cx.simulate_shared_keystrokes(["ctrl-v", "j", "j", "shift-i", "o", "escape"])
438            .await;
439        cx.assert_shared_state(indoc! {
440            "ˇothe quick brown
441            ofox jumps over
442            othe lazy dog"
443        })
444        .await;
445        cx.simulate_shared_keystrokes(["j", "4", "l", "."]).await;
446        deterministic.run_until_parked();
447        cx.assert_shared_state(indoc! {
448            "othe quick brown
449            ofoxˇo jumps over
450            otheo lazy dog"
451        })
452        .await;
453
454        // line mode
455        cx.set_shared_state(indoc! {
456            "ˇthe quick brown
457            fox jumps over
458            the lazy dog"
459        })
460        .await;
461        cx.simulate_shared_keystrokes(["shift-v", "shift-r", "o", "escape"])
462            .await;
463        cx.assert_shared_state(indoc! {
464            "ˇo
465            fox jumps over
466            the lazy dog"
467        })
468        .await;
469        cx.simulate_shared_keystrokes(["j", "."]).await;
470        deterministic.run_until_parked();
471        cx.assert_shared_state(indoc! {
472            "o
473            ˇo
474            the lazy dog"
475        })
476        .await;
477    }
478
479    #[gpui::test]
480    async fn test_repeat_motion_counts(
481        deterministic: Arc<Deterministic>,
482        cx: &mut gpui::TestAppContext,
483    ) {
484        let mut cx = NeovimBackedTestContext::new(cx).await;
485
486        cx.set_shared_state(indoc! {
487            "ˇthe quick brown
488            fox jumps over
489            the lazy dog"
490        })
491        .await;
492        cx.simulate_shared_keystrokes(["3", "d", "3", "l"]).await;
493        cx.assert_shared_state(indoc! {
494            "ˇ brown
495            fox jumps over
496            the lazy dog"
497        })
498        .await;
499        cx.simulate_shared_keystrokes(["j", "."]).await;
500        deterministic.run_until_parked();
501        cx.assert_shared_state(indoc! {
502            " brown
503            ˇ over
504            the lazy dog"
505        })
506        .await;
507        cx.simulate_shared_keystrokes(["j", "2", "."]).await;
508        deterministic.run_until_parked();
509        cx.assert_shared_state(indoc! {
510            " brown
511             over
512            ˇe lazy dog"
513        })
514        .await;
515    }
516}