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