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            match action {
176                ReplayableAction::Action(action) => {
177                    if should_replay(&action) {
178                        window.update(&mut cx, |_, cx| cx.dispatch_action(action))
179                    } else {
180                        Ok(())
181                    }
182                }
183                ReplayableAction::Insertion {
184                    text,
185                    utf16_range_to_replace,
186                } => editor.update(&mut cx, |editor, cx| {
187                    editor.replay_insert_event(&text, utf16_range_to_replace.clone(), cx)
188                }),
189            }?
190        }
191        editor.update(&mut cx, |editor, _| {
192            editor.show_local_selections = true;
193        })?;
194        window.update(&mut cx, |_, cx| cx.dispatch_action(EndRepeat.boxed_clone()))
195    })
196    .detach_and_log_err(cx);
197}
198
199#[cfg(test)]
200mod test {
201    use editor::test::editor_lsp_test_context::EditorLspTestContext;
202    use futures::StreamExt;
203    use indoc::indoc;
204
205    use gpui::ViewInputHandler;
206
207    use crate::{
208        state::Mode,
209        test::{NeovimBackedTestContext, VimTestContext},
210    };
211
212    #[gpui::test]
213    async fn test_dot_repeat(cx: &mut gpui::TestAppContext) {
214        let mut cx = NeovimBackedTestContext::new(cx).await;
215
216        // "o"
217        cx.set_shared_state("ˇhello").await;
218        cx.simulate_shared_keystrokes(["o", "w", "o", "r", "l", "d", "escape"])
219            .await;
220        cx.assert_shared_state("hello\nworlˇd").await;
221        cx.simulate_shared_keystrokes(["."]).await;
222        cx.assert_shared_state("hello\nworld\nworlˇd").await;
223
224        // "d"
225        cx.simulate_shared_keystrokes(["^", "d", "f", "o"]).await;
226        cx.simulate_shared_keystrokes(["g", "g", "."]).await;
227        cx.assert_shared_state("ˇ\nworld\nrld").await;
228
229        // "p" (note that it pastes the current clipboard)
230        cx.simulate_shared_keystrokes(["j", "y", "y", "p"]).await;
231        cx.simulate_shared_keystrokes(["shift-g", "y", "y", "."])
232            .await;
233        cx.assert_shared_state("\nworld\nworld\nrld\nˇrld").await;
234
235        // "~" (note that counts apply to the action taken, not . itself)
236        cx.set_shared_state("ˇthe quick brown fox").await;
237        cx.simulate_shared_keystrokes(["2", "~", "."]).await;
238        cx.set_shared_state("THE ˇquick brown fox").await;
239        cx.simulate_shared_keystrokes(["3", "."]).await;
240        cx.set_shared_state("THE QUIˇck brown fox").await;
241        cx.run_until_parked();
242        cx.simulate_shared_keystrokes(["."]).await;
243        cx.assert_shared_state("THE QUICK ˇbrown fox").await;
244    }
245
246    #[gpui::test]
247    async fn test_repeat_ime(cx: &mut gpui::TestAppContext) {
248        let mut cx = VimTestContext::new(cx, true).await;
249
250        cx.set_state("hˇllo", Mode::Normal);
251        cx.simulate_keystrokes(["i"]);
252
253        // simulate brazilian input for ä.
254        cx.update_editor(|editor, cx| {
255            editor.replace_and_mark_text_in_range(None, "\"", Some(1..1), cx);
256            editor.replace_text_in_range(None, "ä", cx);
257        });
258        cx.simulate_keystrokes(["escape"]);
259        cx.assert_state("hˇällo", Mode::Normal);
260        cx.simulate_keystrokes(["."]);
261        cx.assert_state("hˇäällo", Mode::Normal);
262    }
263
264    #[gpui::test]
265    async fn test_repeat_completion(cx: &mut gpui::TestAppContext) {
266        VimTestContext::init(cx);
267        let cx = EditorLspTestContext::new_rust(
268            lsp::ServerCapabilities {
269                completion_provider: Some(lsp::CompletionOptions {
270                    trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
271                    resolve_provider: Some(true),
272                    ..Default::default()
273                }),
274                ..Default::default()
275            },
276            cx,
277        )
278        .await;
279        let mut cx = VimTestContext::new_with_lsp(cx, true);
280
281        cx.set_state(
282            indoc! {"
283            onˇe
284            two
285            three
286        "},
287            Mode::Normal,
288        );
289
290        let mut request =
291            cx.handle_request::<lsp::request::Completion, _, _>(move |_, params, _| async move {
292                let position = params.text_document_position.position;
293                Ok(Some(lsp::CompletionResponse::Array(vec![
294                    lsp::CompletionItem {
295                        label: "first".to_string(),
296                        text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
297                            range: lsp::Range::new(position, position),
298                            new_text: "first".to_string(),
299                        })),
300                        ..Default::default()
301                    },
302                    lsp::CompletionItem {
303                        label: "second".to_string(),
304                        text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
305                            range: lsp::Range::new(position, position),
306                            new_text: "second".to_string(),
307                        })),
308                        ..Default::default()
309                    },
310                ])))
311            });
312        cx.simulate_keystrokes(["a", "."]);
313        request.next().await;
314        cx.condition(|editor, _| editor.context_menu_visible())
315            .await;
316        cx.simulate_keystrokes(["down", "enter", "!", "escape"]);
317
318        cx.assert_state(
319            indoc! {"
320                one.secondˇ!
321                two
322                three
323            "},
324            Mode::Normal,
325        );
326        cx.simulate_keystrokes(["j", "."]);
327        cx.assert_state(
328            indoc! {"
329                one.second!
330                two.secondˇ!
331                three
332            "},
333            Mode::Normal,
334        );
335    }
336
337    #[gpui::test]
338    async fn test_repeat_visual(cx: &mut gpui::TestAppContext) {
339        let mut cx = NeovimBackedTestContext::new(cx).await;
340
341        // single-line (3 columns)
342        cx.set_shared_state(indoc! {
343            "ˇthe quick brown
344            fox jumps over
345            the lazy dog"
346        })
347        .await;
348        cx.simulate_shared_keystrokes(["v", "i", "w", "s", "o", "escape"])
349            .await;
350        cx.assert_shared_state(indoc! {
351            "ˇo quick brown
352            fox jumps over
353            the lazy dog"
354        })
355        .await;
356        cx.simulate_shared_keystrokes(["j", "w", "."]).await;
357        cx.assert_shared_state(indoc! {
358            "o quick brown
359            fox ˇops over
360            the lazy dog"
361        })
362        .await;
363        cx.simulate_shared_keystrokes(["f", "r", "."]).await;
364        cx.assert_shared_state(indoc! {
365            "o quick brown
366            fox ops oveˇothe lazy dog"
367        })
368        .await;
369
370        // visual
371        cx.set_shared_state(indoc! {
372            "the ˇquick brown
373            fox jumps over
374            fox jumps over
375            fox jumps over
376            the lazy dog"
377        })
378        .await;
379        cx.simulate_shared_keystrokes(["v", "j", "x"]).await;
380        cx.assert_shared_state(indoc! {
381            "the ˇumps over
382            fox jumps over
383            fox jumps over
384            the lazy dog"
385        })
386        .await;
387        cx.simulate_shared_keystrokes(["."]).await;
388        cx.assert_shared_state(indoc! {
389            "the ˇumps over
390            fox jumps over
391            the lazy dog"
392        })
393        .await;
394        cx.simulate_shared_keystrokes(["w", "."]).await;
395        cx.assert_shared_state(indoc! {
396            "the umps ˇumps over
397            the lazy dog"
398        })
399        .await;
400        cx.simulate_shared_keystrokes(["j", "."]).await;
401        cx.assert_shared_state(indoc! {
402            "the umps umps over
403            the ˇog"
404        })
405        .await;
406
407        // block mode (3 rows)
408        cx.set_shared_state(indoc! {
409            "ˇthe quick brown
410            fox jumps over
411            the lazy dog"
412        })
413        .await;
414        cx.simulate_shared_keystrokes(["ctrl-v", "j", "j", "shift-i", "o", "escape"])
415            .await;
416        cx.assert_shared_state(indoc! {
417            "ˇothe quick brown
418            ofox jumps over
419            othe lazy dog"
420        })
421        .await;
422        cx.simulate_shared_keystrokes(["j", "4", "l", "."]).await;
423        cx.assert_shared_state(indoc! {
424            "othe quick brown
425            ofoxˇo jumps over
426            otheo lazy dog"
427        })
428        .await;
429
430        // line mode
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(["shift-v", "shift-r", "o", "escape"])
438            .await;
439        cx.assert_shared_state(indoc! {
440            "ˇo
441            fox jumps over
442            the lazy dog"
443        })
444        .await;
445        cx.simulate_shared_keystrokes(["j", "."]).await;
446        cx.assert_shared_state(indoc! {
447            "o
448            ˇo
449            the lazy dog"
450        })
451        .await;
452    }
453
454    #[gpui::test]
455    async fn test_repeat_motion_counts(cx: &mut gpui::TestAppContext) {
456        let mut cx = NeovimBackedTestContext::new(cx).await;
457
458        cx.set_shared_state(indoc! {
459            "ˇthe quick brown
460            fox jumps over
461            the lazy dog"
462        })
463        .await;
464        cx.simulate_shared_keystrokes(["3", "d", "3", "l"]).await;
465        cx.assert_shared_state(indoc! {
466            "ˇ brown
467            fox jumps over
468            the lazy dog"
469        })
470        .await;
471        cx.simulate_shared_keystrokes(["j", "."]).await;
472        cx.assert_shared_state(indoc! {
473            " brown
474            ˇ over
475            the lazy dog"
476        })
477        .await;
478        cx.simulate_shared_keystrokes(["j", "2", "."]).await;
479        cx.assert_shared_state(indoc! {
480            " brown
481             over
482            ˇe lazy dog"
483        })
484        .await;
485    }
486
487    #[gpui::test]
488    async fn test_record_interrupted(cx: &mut gpui::TestAppContext) {
489        let mut cx = VimTestContext::new(cx, true).await;
490
491        cx.set_state("ˇhello\n", Mode::Normal);
492        cx.simulate_keystrokes(["4", "i", "j", "cmd-shift-p", "escape"]);
493        cx.simulate_keystrokes(["escape"]);
494        cx.assert_state("ˇjhello\n", Mode::Normal);
495    }
496
497    #[gpui::test]
498    async fn test_repeat_over_blur(cx: &mut gpui::TestAppContext) {
499        let mut cx = NeovimBackedTestContext::new(cx).await;
500
501        cx.set_shared_state("ˇhello hello hello\n").await;
502        cx.simulate_shared_keystrokes(["c", "f", "o", "x", "escape"])
503            .await;
504        cx.assert_shared_state("ˇx hello hello\n").await;
505        cx.simulate_shared_keystrokes([":", "escape"]).await;
506        cx.simulate_shared_keystrokes(["."]).await;
507        cx.assert_shared_state("ˇx hello\n").await;
508    }
509}