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"])
226            .await;
227        cx.assert_shared_state("hello\nworlˇd").await;
228        cx.simulate_shared_keystrokes(["."]).await;
229        cx.assert_shared_state("hello\nworld\nworlˇd").await;
230
231        // "d"
232        cx.simulate_shared_keystrokes(["^", "d", "f", "o"]).await;
233        cx.simulate_shared_keystrokes(["g", "g", "."]).await;
234        cx.assert_shared_state("ˇ\nworld\nrld").await;
235
236        // "p" (note that it pastes the current clipboard)
237        cx.simulate_shared_keystrokes(["j", "y", "y", "p"]).await;
238        cx.simulate_shared_keystrokes(["shift-g", "y", "y", "."])
239            .await;
240        cx.assert_shared_state("\nworld\nworld\nrld\nˇrld").await;
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.assert_shared_state("THE QUICK ˇbrown fox").await;
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"])
356            .await;
357        cx.assert_shared_state(indoc! {
358            "ˇo quick brown
359            fox jumps over
360            the lazy dog"
361        })
362        .await;
363        cx.simulate_shared_keystrokes(["j", "w", "."]).await;
364        cx.assert_shared_state(indoc! {
365            "o quick brown
366            fox ˇops over
367            the lazy dog"
368        })
369        .await;
370        cx.simulate_shared_keystrokes(["f", "r", "."]).await;
371        cx.assert_shared_state(indoc! {
372            "o quick brown
373            fox ops oveˇothe lazy dog"
374        })
375        .await;
376
377        // visual
378        cx.set_shared_state(indoc! {
379            "the ˇquick brown
380            fox jumps over
381            fox jumps over
382            fox jumps over
383            the lazy dog"
384        })
385        .await;
386        cx.simulate_shared_keystrokes(["v", "j", "x"]).await;
387        cx.assert_shared_state(indoc! {
388            "the ˇumps over
389            fox jumps over
390            fox jumps over
391            the lazy dog"
392        })
393        .await;
394        cx.simulate_shared_keystrokes(["."]).await;
395        cx.assert_shared_state(indoc! {
396            "the ˇumps over
397            fox jumps over
398            the lazy dog"
399        })
400        .await;
401        cx.simulate_shared_keystrokes(["w", "."]).await;
402        cx.assert_shared_state(indoc! {
403            "the umps ˇumps over
404            the lazy dog"
405        })
406        .await;
407        cx.simulate_shared_keystrokes(["j", "."]).await;
408        cx.assert_shared_state(indoc! {
409            "the umps umps over
410            the ˇog"
411        })
412        .await;
413
414        // block mode (3 rows)
415        cx.set_shared_state(indoc! {
416            "ˇthe quick brown
417            fox jumps over
418            the lazy dog"
419        })
420        .await;
421        cx.simulate_shared_keystrokes(["ctrl-v", "j", "j", "shift-i", "o", "escape"])
422            .await;
423        cx.assert_shared_state(indoc! {
424            "ˇothe quick brown
425            ofox jumps over
426            othe lazy dog"
427        })
428        .await;
429        cx.simulate_shared_keystrokes(["j", "4", "l", "."]).await;
430        cx.assert_shared_state(indoc! {
431            "othe quick brown
432            ofoxˇo jumps over
433            otheo lazy dog"
434        })
435        .await;
436
437        // line mode
438        cx.set_shared_state(indoc! {
439            "ˇthe quick brown
440            fox jumps over
441            the lazy dog"
442        })
443        .await;
444        cx.simulate_shared_keystrokes(["shift-v", "shift-r", "o", "escape"])
445            .await;
446        cx.assert_shared_state(indoc! {
447            "ˇo
448            fox jumps over
449            the lazy dog"
450        })
451        .await;
452        cx.simulate_shared_keystrokes(["j", "."]).await;
453        cx.assert_shared_state(indoc! {
454            "o
455            ˇo
456            the lazy dog"
457        })
458        .await;
459    }
460
461    #[gpui::test]
462    async fn test_repeat_motion_counts(cx: &mut gpui::TestAppContext) {
463        let mut cx = NeovimBackedTestContext::new(cx).await;
464
465        cx.set_shared_state(indoc! {
466            "ˇthe quick brown
467            fox jumps over
468            the lazy dog"
469        })
470        .await;
471        cx.simulate_shared_keystrokes(["3", "d", "3", "l"]).await;
472        cx.assert_shared_state(indoc! {
473            "ˇ brown
474            fox jumps over
475            the lazy dog"
476        })
477        .await;
478        cx.simulate_shared_keystrokes(["j", "."]).await;
479        cx.assert_shared_state(indoc! {
480            " brown
481            ˇ over
482            the lazy dog"
483        })
484        .await;
485        cx.simulate_shared_keystrokes(["j", "2", "."]).await;
486        cx.assert_shared_state(indoc! {
487            " brown
488             over
489            ˇe lazy dog"
490        })
491        .await;
492    }
493
494    #[gpui::test]
495    async fn test_record_interrupted(cx: &mut gpui::TestAppContext) {
496        let mut cx = VimTestContext::new(cx, true).await;
497
498        cx.set_state("ˇhello\n", Mode::Normal);
499        cx.simulate_keystrokes(["4", "i", "j", "cmd-shift-p", "escape"]);
500        cx.simulate_keystrokes(["escape"]);
501        cx.assert_state("ˇjhello\n", Mode::Normal);
502    }
503
504    #[gpui::test]
505    async fn test_repeat_over_blur(cx: &mut gpui::TestAppContext) {
506        let mut cx = NeovimBackedTestContext::new(cx).await;
507
508        cx.set_shared_state("ˇhello hello hello\n").await;
509        cx.simulate_shared_keystrokes(["c", "f", "o", "x", "escape"])
510            .await;
511        cx.assert_shared_state("ˇx hello hello\n").await;
512        cx.simulate_shared_keystrokes([":", "escape"]).await;
513        cx.simulate_shared_keystrokes(["."]).await;
514        cx.assert_shared_state("ˇx hello\n").await;
515    }
516}