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