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}