1use anyhow::{Result, anyhow};
2use collections::{HashMap, HashSet};
3use command_palette_hooks::{CommandInterceptItem, CommandInterceptResult};
4use editor::{
5 Bias, Editor, EditorSettings, SelectionEffects, ToPoint,
6 actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
7 display_map::ToDisplayPoint,
8};
9use futures::AsyncWriteExt as _;
10use gpui::{
11 Action, App, AppContext as _, Context, Global, Keystroke, Task, WeakEntity, Window, actions,
12};
13use itertools::Itertools;
14use language::Point;
15use multi_buffer::MultiBufferRow;
16use project::ProjectPath;
17use regex::Regex;
18use schemars::JsonSchema;
19use search::{BufferSearchBar, SearchOptions};
20use serde::Deserialize;
21use settings::{Settings, SettingsStore};
22use std::{
23 iter::Peekable,
24 ops::{Deref, Range},
25 path::{Path, PathBuf},
26 process::Stdio,
27 str::Chars,
28 sync::OnceLock,
29 time::Instant,
30};
31use task::{HideStrategy, RevealStrategy, SaveStrategy, SpawnInTerminal, TaskId};
32use ui::ActiveTheme;
33use util::{
34 ResultExt,
35 paths::PathStyle,
36 rel_path::{RelPath, RelPathBuf},
37};
38use workspace::{Item, SaveIntent, Workspace, notifications::NotifyResultExt};
39use workspace::{SplitDirection, notifications::DetachAndPromptErr};
40use zed_actions::{OpenDocs, RevealTarget};
41
42use crate::{
43 ToggleMarksView, ToggleRegistersView, Vim, VimSettings,
44 motion::{EndOfDocument, Motion, MotionKind, StartOfDocument},
45 normal::{
46 JoinLines,
47 search::{FindCommand, ReplaceCommand, Replacement},
48 },
49 object::Object,
50 rewrap::Rewrap,
51 state::{Mark, Mode},
52 visual::VisualDeleteLine,
53};
54
55/// Goes to the specified line number in the editor.
56#[derive(Clone, Debug, PartialEq, Action)]
57#[action(namespace = vim, no_json, no_register)]
58pub struct GoToLine {
59 range: CommandRange,
60}
61
62/// Yanks (copies) text based on the specified range.
63#[derive(Clone, Debug, PartialEq, Action)]
64#[action(namespace = vim, no_json, no_register)]
65pub struct YankCommand {
66 range: CommandRange,
67}
68
69/// Executes a command with the specified range.
70#[derive(Clone, Debug, PartialEq, Action)]
71#[action(namespace = vim, no_json, no_register)]
72pub struct WithRange {
73 restore_selection: bool,
74 range: CommandRange,
75 action: WrappedAction,
76}
77
78/// Executes a command with the specified count.
79#[derive(Clone, Debug, PartialEq, Action)]
80#[action(namespace = vim, no_json, no_register)]
81pub struct WithCount {
82 count: u32,
83 action: WrappedAction,
84}
85
86#[derive(Clone, Deserialize, JsonSchema, PartialEq)]
87pub enum VimOption {
88 Wrap(bool),
89 Number(bool),
90 RelativeNumber(bool),
91 IgnoreCase(bool),
92 GDefault(bool),
93}
94
95impl VimOption {
96 fn possible_commands(query: &str) -> Vec<CommandInterceptItem> {
97 let mut prefix_of_options = Vec::new();
98 let mut options = query.split(" ").collect::<Vec<_>>();
99 let prefix = options.pop().unwrap_or_default();
100 for option in options {
101 if let Some(opt) = Self::from(option) {
102 prefix_of_options.push(opt)
103 } else {
104 return vec![];
105 }
106 }
107
108 Self::possibilities(prefix)
109 .map(|possible| {
110 let mut options = prefix_of_options.clone();
111 options.push(possible);
112
113 CommandInterceptItem {
114 string: format!(
115 ":set {}",
116 options.iter().map(|opt| opt.to_string()).join(" ")
117 ),
118 action: VimSet { options }.boxed_clone(),
119 positions: vec![],
120 }
121 })
122 .collect()
123 }
124
125 fn possibilities(query: &str) -> impl Iterator<Item = Self> + '_ {
126 [
127 (None, VimOption::Wrap(true)),
128 (None, VimOption::Wrap(false)),
129 (None, VimOption::Number(true)),
130 (None, VimOption::Number(false)),
131 (None, VimOption::RelativeNumber(true)),
132 (None, VimOption::RelativeNumber(false)),
133 (Some("rnu"), VimOption::RelativeNumber(true)),
134 (Some("nornu"), VimOption::RelativeNumber(false)),
135 (None, VimOption::IgnoreCase(true)),
136 (None, VimOption::IgnoreCase(false)),
137 (Some("ic"), VimOption::IgnoreCase(true)),
138 (Some("noic"), VimOption::IgnoreCase(false)),
139 (None, VimOption::GDefault(true)),
140 (Some("gd"), VimOption::GDefault(true)),
141 (None, VimOption::GDefault(false)),
142 (Some("nogd"), VimOption::GDefault(false)),
143 ]
144 .into_iter()
145 .filter(move |(prefix, option)| prefix.unwrap_or(option.to_string()).starts_with(query))
146 .map(|(_, option)| option)
147 }
148
149 fn from(option: &str) -> Option<Self> {
150 match option {
151 "wrap" => Some(Self::Wrap(true)),
152 "nowrap" => Some(Self::Wrap(false)),
153
154 "number" => Some(Self::Number(true)),
155 "nu" => Some(Self::Number(true)),
156 "nonumber" => Some(Self::Number(false)),
157 "nonu" => Some(Self::Number(false)),
158
159 "relativenumber" => Some(Self::RelativeNumber(true)),
160 "rnu" => Some(Self::RelativeNumber(true)),
161 "norelativenumber" => Some(Self::RelativeNumber(false)),
162 "nornu" => Some(Self::RelativeNumber(false)),
163
164 "ignorecase" => Some(Self::IgnoreCase(true)),
165 "ic" => Some(Self::IgnoreCase(true)),
166 "noignorecase" => Some(Self::IgnoreCase(false)),
167 "noic" => Some(Self::IgnoreCase(false)),
168
169 "gdefault" => Some(Self::GDefault(true)),
170 "gd" => Some(Self::GDefault(true)),
171 "nogdefault" => Some(Self::GDefault(false)),
172 "nogd" => Some(Self::GDefault(false)),
173
174 _ => None,
175 }
176 }
177
178 fn to_string(&self) -> &'static str {
179 match self {
180 VimOption::Wrap(true) => "wrap",
181 VimOption::Wrap(false) => "nowrap",
182 VimOption::Number(true) => "number",
183 VimOption::Number(false) => "nonumber",
184 VimOption::RelativeNumber(true) => "relativenumber",
185 VimOption::RelativeNumber(false) => "norelativenumber",
186 VimOption::IgnoreCase(true) => "ignorecase",
187 VimOption::IgnoreCase(false) => "noignorecase",
188 VimOption::GDefault(true) => "gdefault",
189 VimOption::GDefault(false) => "nogdefault",
190 }
191 }
192}
193
194/// Sets vim options and configuration values.
195#[derive(Clone, PartialEq, Action)]
196#[action(namespace = vim, no_json, no_register)]
197pub struct VimSet {
198 options: Vec<VimOption>,
199}
200
201/// Saves the current file with optional save intent.
202#[derive(Clone, PartialEq, Action)]
203#[action(namespace = vim, no_json, no_register)]
204struct VimSave {
205 pub range: Option<CommandRange>,
206 pub save_intent: Option<SaveIntent>,
207 pub filename: String,
208}
209
210/// Deletes the specified marks from the editor.
211#[derive(Clone, PartialEq, Action)]
212#[action(namespace = vim, no_json, no_register)]
213struct VimSplit {
214 pub vertical: bool,
215 pub filename: String,
216}
217
218#[derive(Clone, PartialEq, Action)]
219#[action(namespace = vim, no_json, no_register)]
220enum DeleteMarks {
221 Marks(String),
222 AllLocal,
223}
224
225actions!(
226 vim,
227 [
228 /// Executes a command in visual mode.
229 VisualCommand,
230 /// Executes a command with a count prefix.
231 CountCommand,
232 /// Executes a shell command.
233 ShellCommand,
234 /// Indicates that an argument is required for the command.
235 ArgumentRequired
236 ]
237);
238
239/// Opens the specified file for editing.
240#[derive(Clone, PartialEq, Action)]
241#[action(namespace = vim, no_json, no_register)]
242struct VimEdit {
243 pub filename: String,
244}
245
246/// Pastes the specified file's contents.
247#[derive(Clone, PartialEq, Action)]
248#[action(namespace = vim, no_json, no_register)]
249struct VimRead {
250 pub range: Option<CommandRange>,
251 pub filename: String,
252}
253
254#[derive(Clone, PartialEq, Action)]
255#[action(namespace = vim, no_json, no_register)]
256struct VimNorm {
257 pub range: Option<CommandRange>,
258 pub command: String,
259 /// Places cursors at beginning of each given row.
260 /// Overrides given range and current cursor.
261 pub override_rows: Option<Vec<u32>>,
262}
263
264#[derive(Debug)]
265struct WrappedAction(Box<dyn Action>);
266
267impl PartialEq for WrappedAction {
268 fn eq(&self, other: &Self) -> bool {
269 self.0.partial_eq(&*other.0)
270 }
271}
272
273impl Clone for WrappedAction {
274 fn clone(&self) -> Self {
275 Self(self.0.boxed_clone())
276 }
277}
278
279impl Deref for WrappedAction {
280 type Target = dyn Action;
281 fn deref(&self) -> &dyn Action {
282 &*self.0
283 }
284}
285
286pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
287 Vim::action(editor, cx, |vim, action: &VimSet, _, cx| {
288 for option in action.options.iter() {
289 vim.update_editor(cx, |_, editor, cx| match option {
290 VimOption::Wrap(true) => {
291 editor
292 .set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx);
293 }
294 VimOption::Wrap(false) => {
295 editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx);
296 }
297 VimOption::Number(enabled) => {
298 editor.set_show_line_numbers(*enabled, cx);
299 }
300 VimOption::RelativeNumber(enabled) => {
301 editor.set_relative_line_number(Some(*enabled), cx);
302 }
303 VimOption::IgnoreCase(enabled) => {
304 let mut settings = EditorSettings::get_global(cx).clone();
305 settings.search.case_sensitive = !*enabled;
306 SettingsStore::update(cx, |store, _| {
307 store.override_global(settings);
308 });
309 }
310 VimOption::GDefault(enabled) => {
311 let mut settings = VimSettings::get_global(cx).clone();
312 settings.gdefault = *enabled;
313
314 SettingsStore::update(cx, |store, _| {
315 store.override_global(settings);
316 })
317 }
318 });
319 }
320 });
321 Vim::action(editor, cx, |vim, _: &VisualCommand, window, cx| {
322 let Some(workspace) = vim.workspace(window, cx) else {
323 return;
324 };
325 workspace.update(cx, |workspace, cx| {
326 command_palette::CommandPalette::toggle(workspace, "'<,'>", window, cx);
327 })
328 });
329
330 Vim::action(editor, cx, |vim, _: &ShellCommand, window, cx| {
331 let Some(workspace) = vim.workspace(window, cx) else {
332 return;
333 };
334 workspace.update(cx, |workspace, cx| {
335 command_palette::CommandPalette::toggle(workspace, "'<,'>!", window, cx);
336 })
337 });
338
339 Vim::action(editor, cx, |_, _: &ArgumentRequired, window, cx| {
340 let _ = window.prompt(
341 gpui::PromptLevel::Critical,
342 "Argument required",
343 None,
344 &["Cancel"],
345 cx,
346 );
347 });
348
349 Vim::action(editor, cx, |vim, _: &ShellCommand, window, cx| {
350 let Some(workspace) = vim.workspace(window, cx) else {
351 return;
352 };
353 workspace.update(cx, |workspace, cx| {
354 command_palette::CommandPalette::toggle(workspace, "'<,'>!", window, cx);
355 })
356 });
357
358 Vim::action(editor, cx, |vim, action: &VimSave, window, cx| {
359 if let Some(range) = &action.range {
360 vim.update_editor(cx, |vim, editor, cx| {
361 let Some(range) = range.buffer_range(vim, editor, window, cx).ok() else {
362 return;
363 };
364 let Some((line_ending, encoding, has_bom, text, whole_buffer)) = editor.buffer().update(cx, |multi, cx| {
365 Some(multi.as_singleton()?.update(cx, |buffer, _| {
366 (
367 buffer.line_ending(),
368 buffer.encoding(),
369 buffer.has_bom(),
370 buffer.as_rope().slice_rows(range.start.0..range.end.0 + 1),
371 range.start.0 == 0 && range.end.0 + 1 >= buffer.row_count(),
372 )
373 }))
374 }) else {
375 return;
376 };
377
378 let filename = action.filename.clone();
379 let filename = if filename.is_empty() {
380 let Some(file) = editor
381 .buffer()
382 .read(cx)
383 .as_singleton()
384 .and_then(|buffer| buffer.read(cx).file())
385 else {
386 let _ = window.prompt(
387 gpui::PromptLevel::Warning,
388 "No file name",
389 Some("Partial buffer write requires file name."),
390 &["Cancel"],
391 cx,
392 );
393 return;
394 };
395 file.path().display(file.path_style(cx)).to_string()
396 } else {
397 filename
398 };
399
400 if action.filename.is_empty() {
401 if whole_buffer {
402 if let Some(workspace) = vim.workspace(window, cx) {
403 workspace.update(cx, |workspace, cx| {
404 workspace
405 .save_active_item(
406 action.save_intent.unwrap_or(SaveIntent::Save),
407 window,
408 cx,
409 )
410 .detach_and_prompt_err("Failed to save", window, cx, |_, _, _| None);
411 });
412 }
413 return;
414 }
415 if Some(SaveIntent::Overwrite) != action.save_intent {
416 let _ = window.prompt(
417 gpui::PromptLevel::Warning,
418 "Use ! to write partial buffer",
419 Some("Overwriting the current file with selected buffer content requires '!'."),
420 &["Cancel"],
421 cx,
422 );
423 return;
424 }
425 editor.buffer().update(cx, |multi, cx| {
426 if let Some(buffer) = multi.as_singleton() {
427 buffer.update(cx, |buffer, _| buffer.set_conflict());
428 }
429 });
430 };
431
432 editor.project().unwrap().update(cx, |project, cx| {
433 let worktree = project.visible_worktrees(cx).next().unwrap();
434
435 worktree.update(cx, |worktree, cx| {
436 let path_style = worktree.path_style();
437 let Some(path) = RelPath::new(Path::new(&filename), path_style).ok() else {
438 return;
439 };
440
441 let rx = (worktree.entry_for_path(&path).is_some() && Some(SaveIntent::Overwrite) != action.save_intent).then(|| {
442 window.prompt(
443 gpui::PromptLevel::Warning,
444 &format!("{path:?} already exists. Do you want to replace it?"),
445 Some(
446 "A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
447 ),
448 &["Replace", "Cancel"],
449 cx
450 )
451 });
452 let filename = filename.clone();
453 cx.spawn_in(window, async move |this, cx| {
454 if let Some(rx) = rx
455 && Ok(0) != rx.await
456 {
457 return;
458 }
459
460 let _ = this.update_in(cx, |worktree, window, cx| {
461 let Some(path) = RelPath::new(Path::new(&filename), path_style).ok() else {
462 return;
463 };
464 worktree
465 .write_file(path.into_arc(), text.clone(), line_ending, encoding, has_bom, cx)
466 .detach_and_prompt_err("Failed to write lines", window, cx, |_, _, _| None);
467 });
468 })
469 .detach();
470 });
471 });
472 });
473 return;
474 }
475 if action.filename.is_empty() {
476 if let Some(workspace) = vim.workspace(window, cx) {
477 workspace.update(cx, |workspace, cx| {
478 workspace
479 .save_active_item(
480 action.save_intent.unwrap_or(SaveIntent::Save),
481 window,
482 cx,
483 )
484 .detach_and_prompt_err("Failed to save", window, cx, |_, _, _| None);
485 });
486 }
487 return;
488 }
489 vim.update_editor(cx, |_, editor, cx| {
490 let Some(project) = editor.project().cloned() else {
491 return;
492 };
493 let Some(worktree) = project.read(cx).visible_worktrees(cx).next() else {
494 return;
495 };
496 let path_style = worktree.read(cx).path_style();
497 let Ok(project_path) =
498 RelPath::new(Path::new(&action.filename), path_style).map(|path| ProjectPath {
499 worktree_id: worktree.read(cx).id(),
500 path: path.into_arc(),
501 })
502 else {
503 // TODO implement save_as with absolute path
504 Task::ready(Err::<(), _>(anyhow!(
505 "Cannot save buffer with absolute path"
506 )))
507 .detach_and_prompt_err(
508 "Failed to save",
509 window,
510 cx,
511 |_, _, _| None,
512 );
513 return;
514 };
515
516 if project.read(cx).entry_for_path(&project_path, cx).is_some()
517 && action.save_intent != Some(SaveIntent::Overwrite)
518 {
519 let answer = window.prompt(
520 gpui::PromptLevel::Critical,
521 &format!(
522 "{} already exists. Do you want to replace it?",
523 project_path.path.display(path_style)
524 ),
525 Some(
526 "A file or folder with the same name already exists. \
527 Replacing it will overwrite its current contents.",
528 ),
529 &["Replace", "Cancel"],
530 cx,
531 );
532 cx.spawn_in(window, async move |editor, cx| {
533 if answer.await.ok() != Some(0) {
534 return;
535 }
536
537 let _ = editor.update_in(cx, |editor, window, cx| {
538 editor
539 .save_as(project, project_path, window, cx)
540 .detach_and_prompt_err("Failed to :w", window, cx, |_, _, _| None);
541 });
542 })
543 .detach();
544 } else {
545 editor
546 .save_as(project, project_path, window, cx)
547 .detach_and_prompt_err("Failed to :w", window, cx, |_, _, _| None);
548 }
549 });
550 });
551
552 Vim::action(editor, cx, |vim, action: &VimSplit, window, cx| {
553 let Some(workspace) = vim.workspace(window, cx) else {
554 return;
555 };
556
557 workspace.update(cx, |workspace, cx| {
558 let project = workspace.project().clone();
559 let Some(worktree) = project.read(cx).visible_worktrees(cx).next() else {
560 return;
561 };
562 let path_style = worktree.read(cx).path_style();
563 let Some(path) = RelPath::new(Path::new(&action.filename), path_style).log_err() else {
564 return;
565 };
566 let project_path = ProjectPath {
567 worktree_id: worktree.read(cx).id(),
568 path: path.into_arc(),
569 };
570
571 let direction = if action.vertical {
572 SplitDirection::vertical(cx)
573 } else {
574 SplitDirection::horizontal(cx)
575 };
576
577 workspace
578 .split_path_preview(project_path, false, Some(direction), window, cx)
579 .detach_and_log_err(cx);
580 })
581 });
582
583 Vim::action(editor, cx, |vim, action: &DeleteMarks, window, cx| {
584 fn err(s: String, window: &mut Window, cx: &mut Context<Editor>) {
585 let _ = window.prompt(
586 gpui::PromptLevel::Critical,
587 &format!("Invalid argument: {}", s),
588 None,
589 &["Cancel"],
590 cx,
591 );
592 }
593 vim.update_editor(cx, |vim, editor, cx| match action {
594 DeleteMarks::Marks(s) => {
595 if s.starts_with('-') || s.ends_with('-') || s.contains(['\'', '`']) {
596 err(s.clone(), window, cx);
597 return;
598 }
599
600 let to_delete = if s.len() < 3 {
601 Some(s.clone())
602 } else {
603 s.chars()
604 .tuple_windows::<(_, _, _)>()
605 .map(|(a, b, c)| {
606 if b == '-' {
607 if match a {
608 'a'..='z' => a <= c && c <= 'z',
609 'A'..='Z' => a <= c && c <= 'Z',
610 '0'..='9' => a <= c && c <= '9',
611 _ => false,
612 } {
613 Some((a..=c).collect_vec())
614 } else {
615 None
616 }
617 } else if a == '-' {
618 if c == '-' { None } else { Some(vec![c]) }
619 } else if c == '-' {
620 if a == '-' { None } else { Some(vec![a]) }
621 } else {
622 Some(vec![a, b, c])
623 }
624 })
625 .fold_options(HashSet::<char>::default(), |mut set, chars| {
626 set.extend(chars.iter().copied());
627 set
628 })
629 .map(|set| set.iter().collect::<String>())
630 };
631
632 let Some(to_delete) = to_delete else {
633 err(s.clone(), window, cx);
634 return;
635 };
636
637 for c in to_delete.chars().filter(|c| !c.is_whitespace()) {
638 vim.delete_mark(c.to_string(), editor, window, cx);
639 }
640 }
641 DeleteMarks::AllLocal => {
642 for s in 'a'..='z' {
643 vim.delete_mark(s.to_string(), editor, window, cx);
644 }
645 }
646 });
647 });
648
649 Vim::action(editor, cx, |vim, action: &VimEdit, window, cx| {
650 vim.update_editor(cx, |vim, editor, cx| {
651 let Some(workspace) = vim.workspace(window, cx) else {
652 return;
653 };
654 let Some(project) = editor.project().cloned() else {
655 return;
656 };
657 let Some(worktree) = project.read(cx).visible_worktrees(cx).next() else {
658 return;
659 };
660 let path_style = worktree.read(cx).path_style();
661 let Some(path) = RelPath::new(Path::new(&action.filename), path_style).log_err() else {
662 return;
663 };
664 let project_path = ProjectPath {
665 worktree_id: worktree.read(cx).id(),
666 path: path.into_arc(),
667 };
668
669 let _ = workspace.update(cx, |workspace, cx| {
670 workspace
671 .open_path(project_path, None, true, window, cx)
672 .detach_and_log_err(cx);
673 });
674 });
675 });
676
677 Vim::action(editor, cx, |vim, action: &VimRead, window, cx| {
678 vim.update_editor(cx, |vim, editor, cx| {
679 let snapshot = editor.buffer().read(cx).snapshot(cx);
680 let end = if let Some(range) = action.range.clone() {
681 let Some(multi_range) = range.buffer_range(vim, editor, window, cx).log_err()
682 else {
683 return;
684 };
685
686 match &range.start {
687 // inserting text above the first line uses the command ":0r {name}"
688 Position::Line { row: 0, offset: 0 } if range.end.is_none() => {
689 snapshot.clip_point(Point::new(0, 0), Bias::Right)
690 }
691 _ => snapshot.clip_point(Point::new(multi_range.end.0 + 1, 0), Bias::Right),
692 }
693 } else {
694 let end_row = editor
695 .selections
696 .newest::<Point>(&editor.display_snapshot(cx))
697 .range()
698 .end
699 .row;
700 snapshot.clip_point(Point::new(end_row + 1, 0), Bias::Right)
701 };
702 let is_end_of_file = end == snapshot.max_point();
703 let edit_range = snapshot.anchor_before(end)..snapshot.anchor_before(end);
704
705 let mut text = if is_end_of_file {
706 String::from('\n')
707 } else {
708 String::new()
709 };
710
711 let mut task = None;
712 if action.filename.is_empty() {
713 text.push_str(
714 &editor
715 .buffer()
716 .read(cx)
717 .as_singleton()
718 .map(|buffer| buffer.read(cx).text())
719 .unwrap_or_default(),
720 );
721 } else {
722 if let Some(project) = editor.project().cloned() {
723 project.update(cx, |project, cx| {
724 let Some(worktree) = project.visible_worktrees(cx).next() else {
725 return;
726 };
727 let path_style = worktree.read(cx).path_style();
728 let Some(path) =
729 RelPath::new(Path::new(&action.filename), path_style).log_err()
730 else {
731 return;
732 };
733 task =
734 Some(worktree.update(cx, |worktree, cx| worktree.load_file(&path, cx)));
735 });
736 } else {
737 return;
738 }
739 };
740
741 cx.spawn_in(window, async move |editor, cx| {
742 if let Some(task) = task {
743 text.push_str(
744 &task
745 .await
746 .log_err()
747 .map(|loaded_file| loaded_file.text)
748 .unwrap_or_default(),
749 );
750 }
751
752 if !text.is_empty() && !is_end_of_file {
753 text.push('\n');
754 }
755
756 let _ = editor.update_in(cx, |editor, window, cx| {
757 editor.transact(window, cx, |editor, window, cx| {
758 editor.edit([(edit_range.clone(), text)], cx);
759 let snapshot = editor.buffer().read(cx).snapshot(cx);
760 editor.change_selections(Default::default(), window, cx, |s| {
761 let point = if is_end_of_file {
762 Point::new(
763 edit_range.start.to_point(&snapshot).row.saturating_add(1),
764 0,
765 )
766 } else {
767 Point::new(edit_range.start.to_point(&snapshot).row, 0)
768 };
769 s.select_ranges([point..point]);
770 })
771 });
772 });
773 })
774 .detach();
775 });
776 });
777
778 Vim::action(editor, cx, |vim, action: &VimNorm, window, cx| {
779 let keystrokes = action
780 .command
781 .chars()
782 .map(|c| Keystroke::parse(&c.to_string()).unwrap())
783 .collect();
784 vim.switch_mode(Mode::Normal, true, window, cx);
785 if let Some(override_rows) = &action.override_rows {
786 vim.update_editor(cx, |_, editor, cx| {
787 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
788 s.replace_cursors_with(|map| {
789 override_rows
790 .iter()
791 .map(|row| Point::new(*row, 0).to_display_point(map))
792 .collect()
793 });
794 });
795 });
796 } else if let Some(range) = &action.range {
797 let result = vim.update_editor(cx, |vim, editor, cx| {
798 let range = range.buffer_range(vim, editor, window, cx)?;
799 editor.change_selections(
800 SelectionEffects::no_scroll().nav_history(false),
801 window,
802 cx,
803 |s| {
804 s.select_ranges(
805 (range.start.0..=range.end.0)
806 .map(|line| Point::new(line, 0)..Point::new(line, 0)),
807 );
808 },
809 );
810 anyhow::Ok(())
811 });
812 if let Some(Err(err)) = result {
813 log::error!("Error selecting range: {}", err);
814 return;
815 }
816 };
817
818 let Some(workspace) = vim.workspace(window, cx) else {
819 return;
820 };
821 let task = workspace.update(cx, |workspace, cx| {
822 workspace.send_keystrokes_impl(keystrokes, window, cx)
823 });
824 let had_range = action.range.is_some();
825 let had_override = action.override_rows.is_some();
826
827 cx.spawn_in(window, async move |vim, cx| {
828 task.await;
829 vim.update_in(cx, |vim, window, cx| {
830 if matches!(vim.mode, Mode::Insert | Mode::Replace) {
831 vim.normal_before(&Default::default(), window, cx);
832 } else {
833 vim.switch_mode(Mode::Normal, true, window, cx);
834 }
835 if had_override || had_range {
836 vim.update_editor(cx, |_, editor, cx| {
837 editor.change_selections(SelectionEffects::default(), window, cx, |s| {
838 s.select_anchor_ranges([s.newest_anchor().range()]);
839 });
840 if let Some(tx_id) = editor
841 .buffer()
842 .update(cx, |multi, cx| multi.last_transaction_id(cx))
843 {
844 let last_sel = editor.selections.disjoint_anchors_arc();
845 editor.modify_transaction_selection_history(tx_id, |old| {
846 old.0 = old.0.get(..1).unwrap_or(&[]).into();
847 old.1 = Some(last_sel);
848 });
849 }
850 });
851 }
852 })
853 .log_err();
854 })
855 .detach();
856 });
857
858 Vim::action(editor, cx, |vim, _: &CountCommand, window, cx| {
859 let Some(workspace) = vim.workspace(window, cx) else {
860 return;
861 };
862 let count = Vim::take_count(cx).unwrap_or(1);
863 Vim::take_forced_motion(cx);
864 let n = if count > 1 {
865 format!(".,.+{}", count.saturating_sub(1))
866 } else {
867 ".".to_string()
868 };
869 workspace.update(cx, |workspace, cx| {
870 command_palette::CommandPalette::toggle(workspace, &n, window, cx);
871 })
872 });
873
874 Vim::action(editor, cx, |vim, action: &GoToLine, window, cx| {
875 vim.switch_mode(Mode::Normal, false, window, cx);
876 let result = vim.update_editor(cx, |vim, editor, cx| {
877 let snapshot = editor.snapshot(window, cx);
878 let buffer_row = action.range.head().buffer_row(vim, editor, window, cx)?;
879 let current = editor
880 .selections
881 .newest::<Point>(&editor.display_snapshot(cx));
882 let target = snapshot
883 .buffer_snapshot()
884 .clip_point(Point::new(buffer_row.0, current.head().column), Bias::Left);
885 editor.change_selections(Default::default(), window, cx, |s| {
886 s.select_ranges([target..target]);
887 });
888
889 anyhow::Ok(())
890 });
891 if let Some(e @ Err(_)) = result {
892 let Some(workspace) = vim.workspace(window, cx) else {
893 return;
894 };
895 workspace.update(cx, |workspace, cx| {
896 e.notify_err(workspace, cx);
897 });
898 }
899 });
900
901 Vim::action(editor, cx, |vim, action: &YankCommand, window, cx| {
902 vim.update_editor(cx, |vim, editor, cx| {
903 let snapshot = editor.snapshot(window, cx);
904 if let Ok(range) = action.range.buffer_range(vim, editor, window, cx) {
905 let end = if range.end < snapshot.buffer_snapshot().max_row() {
906 Point::new(range.end.0 + 1, 0)
907 } else {
908 snapshot.buffer_snapshot().max_point()
909 };
910 vim.copy_ranges(
911 editor,
912 MotionKind::Linewise,
913 true,
914 vec![Point::new(range.start.0, 0)..end],
915 window,
916 cx,
917 )
918 }
919 });
920 });
921
922 Vim::action(editor, cx, |_, action: &WithCount, window, cx| {
923 for _ in 0..action.count {
924 window.dispatch_action(action.action.boxed_clone(), cx)
925 }
926 });
927
928 Vim::action(editor, cx, |vim, action: &WithRange, window, cx| {
929 let result = vim.update_editor(cx, |vim, editor, cx| {
930 action.range.buffer_range(vim, editor, window, cx)
931 });
932
933 let range = match result {
934 None => return,
935 Some(e @ Err(_)) => {
936 let Some(workspace) = vim.workspace(window, cx) else {
937 return;
938 };
939 workspace.update(cx, |workspace, cx| {
940 e.notify_err(workspace, cx);
941 });
942 return;
943 }
944 Some(Ok(result)) => result,
945 };
946
947 let previous_selections = vim
948 .update_editor(cx, |_, editor, cx| {
949 let selections = action.restore_selection.then(|| {
950 editor
951 .selections
952 .disjoint_anchor_ranges()
953 .collect::<Vec<_>>()
954 });
955 let snapshot = editor.buffer().read(cx).snapshot(cx);
956 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
957 let end = Point::new(range.end.0, snapshot.line_len(range.end));
958 s.select_ranges([end..Point::new(range.start.0, 0)]);
959 });
960 selections
961 })
962 .flatten();
963 window.dispatch_action(action.action.boxed_clone(), cx);
964 cx.defer_in(window, move |vim, window, cx| {
965 vim.update_editor(cx, |_, editor, cx| {
966 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
967 if let Some(previous_selections) = previous_selections {
968 s.select_ranges(previous_selections);
969 } else {
970 s.select_ranges([
971 Point::new(range.start.0, 0)..Point::new(range.start.0, 0)
972 ]);
973 }
974 })
975 });
976 });
977 });
978
979 Vim::action(editor, cx, |vim, action: &OnMatchingLines, window, cx| {
980 action.run(vim, window, cx)
981 });
982
983 Vim::action(editor, cx, |vim, action: &ShellExec, window, cx| {
984 action.run(vim, window, cx)
985 })
986}
987
988#[derive(Default)]
989struct VimCommand {
990 prefix: &'static str,
991 suffix: &'static str,
992 action: Option<Box<dyn Action>>,
993 action_name: Option<&'static str>,
994 bang_action: Option<Box<dyn Action>>,
995 args: Option<
996 Box<dyn Fn(Box<dyn Action>, String) -> Option<Box<dyn Action>> + Send + Sync + 'static>,
997 >,
998 /// Optional range Range to use if no range is specified.
999 default_range: Option<CommandRange>,
1000 range: Option<
1001 Box<
1002 dyn Fn(Box<dyn Action>, &CommandRange) -> Option<Box<dyn Action>>
1003 + Send
1004 + Sync
1005 + 'static,
1006 >,
1007 >,
1008 has_count: bool,
1009 has_filename: bool,
1010}
1011
1012struct ParsedQuery {
1013 args: String,
1014 has_bang: bool,
1015 has_space: bool,
1016}
1017
1018impl VimCommand {
1019 fn new(pattern: (&'static str, &'static str), action: impl Action) -> Self {
1020 Self {
1021 prefix: pattern.0,
1022 suffix: pattern.1,
1023 action: Some(action.boxed_clone()),
1024 ..Default::default()
1025 }
1026 }
1027
1028 // from_str is used for actions in other crates.
1029 fn str(pattern: (&'static str, &'static str), action_name: &'static str) -> Self {
1030 Self {
1031 prefix: pattern.0,
1032 suffix: pattern.1,
1033 action_name: Some(action_name),
1034 ..Default::default()
1035 }
1036 }
1037
1038 fn bang(mut self, bang_action: impl Action) -> Self {
1039 self.bang_action = Some(bang_action.boxed_clone());
1040 self
1041 }
1042
1043 /// Set argument handler. Trailing whitespace in arguments will be preserved.
1044 fn args(
1045 mut self,
1046 f: impl Fn(Box<dyn Action>, String) -> Option<Box<dyn Action>> + Send + Sync + 'static,
1047 ) -> Self {
1048 self.args = Some(Box::new(f));
1049 self
1050 }
1051
1052 /// Set argument handler. Trailing whitespace in arguments will be trimmed.
1053 /// Supports filename autocompletion.
1054 fn filename(
1055 mut self,
1056 f: impl Fn(Box<dyn Action>, String) -> Option<Box<dyn Action>> + Send + Sync + 'static,
1057 ) -> Self {
1058 self.args = Some(Box::new(f));
1059 self.has_filename = true;
1060 self
1061 }
1062
1063 fn range(
1064 mut self,
1065 f: impl Fn(Box<dyn Action>, &CommandRange) -> Option<Box<dyn Action>> + Send + Sync + 'static,
1066 ) -> Self {
1067 self.range = Some(Box::new(f));
1068 self
1069 }
1070
1071 fn default_range(mut self, range: CommandRange) -> Self {
1072 self.default_range = Some(range);
1073 self
1074 }
1075
1076 fn count(mut self) -> Self {
1077 self.has_count = true;
1078 self
1079 }
1080
1081 fn generate_filename_completions(
1082 parsed_query: &ParsedQuery,
1083 workspace: WeakEntity<Workspace>,
1084 cx: &mut App,
1085 ) -> Task<Vec<String>> {
1086 let ParsedQuery {
1087 args,
1088 has_bang: _,
1089 has_space: _,
1090 } = parsed_query;
1091 let Some(workspace) = workspace.upgrade() else {
1092 return Task::ready(Vec::new());
1093 };
1094
1095 let (task, args_path) = workspace.update(cx, |workspace, cx| {
1096 let prefix = workspace
1097 .project()
1098 .read(cx)
1099 .visible_worktrees(cx)
1100 .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
1101 .next()
1102 .or_else(std::env::home_dir)
1103 .unwrap_or_else(|| PathBuf::from(""));
1104
1105 let rel_path = match RelPath::new(Path::new(&args), PathStyle::local()) {
1106 Ok(path) => path.to_rel_path_buf(),
1107 Err(_) => {
1108 return (Task::ready(Ok(Vec::new())), RelPathBuf::new());
1109 }
1110 };
1111
1112 let rel_path = if args.ends_with(PathStyle::local().primary_separator()) {
1113 rel_path
1114 } else {
1115 rel_path
1116 .parent()
1117 .map(|rel_path| rel_path.to_rel_path_buf())
1118 .unwrap_or(RelPathBuf::new())
1119 };
1120
1121 let task = workspace.project().update(cx, |project, cx| {
1122 let path = prefix
1123 .join(rel_path.as_std_path())
1124 .to_string_lossy()
1125 .to_string();
1126 project.list_directory(path, cx)
1127 });
1128
1129 (task, rel_path)
1130 });
1131
1132 cx.background_spawn(async move {
1133 let directories = task.await.unwrap_or_default();
1134 directories
1135 .iter()
1136 .map(|dir| {
1137 let path = RelPath::new(dir.path.as_path(), PathStyle::local())
1138 .map(|cow| cow.into_owned())
1139 .unwrap_or(RelPathBuf::new());
1140 let mut path_string = args_path
1141 .join(&path)
1142 .display(PathStyle::local())
1143 .to_string();
1144 if dir.is_dir {
1145 path_string.push_str(PathStyle::local().primary_separator());
1146 }
1147 path_string
1148 })
1149 .collect()
1150 })
1151 }
1152
1153 fn get_parsed_query(&self, query: String) -> Option<ParsedQuery> {
1154 let rest = query
1155 .strip_prefix(self.prefix)?
1156 .to_string()
1157 .chars()
1158 .zip_longest(self.suffix.to_string().chars())
1159 .skip_while(|e| e.clone().both().map(|(s, q)| s == q).unwrap_or(false))
1160 .filter_map(|e| e.left())
1161 .collect::<String>();
1162 let has_bang = rest.starts_with('!');
1163 let has_space = rest.starts_with("! ") || rest.starts_with(' ');
1164 let args = if has_bang {
1165 rest.strip_prefix('!')?.trim_start().to_string()
1166 } else if rest.is_empty() {
1167 "".into()
1168 } else {
1169 rest.strip_prefix(' ')?.trim_start().to_string()
1170 };
1171 Some(ParsedQuery {
1172 args,
1173 has_bang,
1174 has_space,
1175 })
1176 }
1177
1178 fn parse(
1179 &self,
1180 query: &str,
1181 range: &Option<CommandRange>,
1182 cx: &App,
1183 ) -> Option<Box<dyn Action>> {
1184 let ParsedQuery {
1185 args,
1186 has_bang,
1187 has_space: _,
1188 } = self.get_parsed_query(query.to_string())?;
1189 let action = if has_bang && let Some(bang_action) = self.bang_action.as_ref() {
1190 bang_action.boxed_clone()
1191 } else if let Some(action) = self.action.as_ref() {
1192 action.boxed_clone()
1193 } else if let Some(action_name) = self.action_name {
1194 cx.build_action(action_name, None).log_err()?
1195 } else {
1196 return None;
1197 };
1198
1199 // If the command does not accept args and we have args, we should do no
1200 // action.
1201 let action = if args.is_empty() {
1202 action
1203 } else if self.has_filename {
1204 self.args.as_ref()?(action, args.trim().into())?
1205 } else {
1206 self.args.as_ref()?(action, args)?
1207 };
1208
1209 let range = range.as_ref().or(self.default_range.as_ref());
1210 if let Some(range) = range {
1211 self.range.as_ref().and_then(|f| f(action, range))
1212 } else {
1213 Some(action)
1214 }
1215 }
1216
1217 // TODO: ranges with search queries
1218 fn parse_range(query: &str) -> (Option<CommandRange>, String) {
1219 let mut chars = query.chars().peekable();
1220
1221 match chars.peek() {
1222 Some('%') => {
1223 chars.next();
1224 return (
1225 Some(CommandRange {
1226 start: Position::Line { row: 1, offset: 0 },
1227 end: Some(Position::LastLine { offset: 0 }),
1228 }),
1229 chars.collect(),
1230 );
1231 }
1232 Some('*') => {
1233 chars.next();
1234 return (
1235 Some(CommandRange {
1236 start: Position::Mark {
1237 name: '<',
1238 offset: 0,
1239 },
1240 end: Some(Position::Mark {
1241 name: '>',
1242 offset: 0,
1243 }),
1244 }),
1245 chars.collect(),
1246 );
1247 }
1248 _ => {}
1249 }
1250
1251 let start = Self::parse_position(&mut chars);
1252
1253 match chars.peek() {
1254 Some(',' | ';') => {
1255 chars.next();
1256 (
1257 Some(CommandRange {
1258 start: start.unwrap_or(Position::CurrentLine { offset: 0 }),
1259 end: Self::parse_position(&mut chars),
1260 }),
1261 chars.collect(),
1262 )
1263 }
1264 _ => (
1265 start.map(|start| CommandRange { start, end: None }),
1266 chars.collect(),
1267 ),
1268 }
1269 }
1270
1271 fn parse_position(chars: &mut Peekable<Chars>) -> Option<Position> {
1272 match chars.peek()? {
1273 '0'..='9' => {
1274 let row = Self::parse_u32(chars);
1275 Some(Position::Line {
1276 row,
1277 offset: Self::parse_offset(chars),
1278 })
1279 }
1280 '\'' => {
1281 chars.next();
1282 let name = chars.next()?;
1283 Some(Position::Mark {
1284 name,
1285 offset: Self::parse_offset(chars),
1286 })
1287 }
1288 '.' => {
1289 chars.next();
1290 Some(Position::CurrentLine {
1291 offset: Self::parse_offset(chars),
1292 })
1293 }
1294 '+' | '-' => Some(Position::CurrentLine {
1295 offset: Self::parse_offset(chars),
1296 }),
1297 '$' => {
1298 chars.next();
1299 Some(Position::LastLine {
1300 offset: Self::parse_offset(chars),
1301 })
1302 }
1303 _ => None,
1304 }
1305 }
1306
1307 fn parse_offset(chars: &mut Peekable<Chars>) -> i32 {
1308 let mut res: i32 = 0;
1309 while matches!(chars.peek(), Some('+' | '-')) {
1310 let sign = if chars.next().unwrap() == '+' { 1 } else { -1 };
1311 let amount = if matches!(chars.peek(), Some('0'..='9')) {
1312 (Self::parse_u32(chars) as i32).saturating_mul(sign)
1313 } else {
1314 sign
1315 };
1316 res = res.saturating_add(amount)
1317 }
1318 res
1319 }
1320
1321 fn parse_u32(chars: &mut Peekable<Chars>) -> u32 {
1322 let mut res: u32 = 0;
1323 while matches!(chars.peek(), Some('0'..='9')) {
1324 res = res
1325 .saturating_mul(10)
1326 .saturating_add(chars.next().unwrap() as u32 - '0' as u32);
1327 }
1328 res
1329 }
1330}
1331
1332#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)]
1333enum Position {
1334 Line { row: u32, offset: i32 },
1335 Mark { name: char, offset: i32 },
1336 LastLine { offset: i32 },
1337 CurrentLine { offset: i32 },
1338}
1339
1340impl Position {
1341 fn buffer_row(
1342 &self,
1343 vim: &Vim,
1344 editor: &mut Editor,
1345 window: &mut Window,
1346 cx: &mut App,
1347 ) -> Result<MultiBufferRow> {
1348 let snapshot = editor.snapshot(window, cx);
1349 let target = match self {
1350 Position::Line { row, offset } => {
1351 if let Some(anchor) = editor.active_excerpt(cx).and_then(|(_, buffer, _)| {
1352 editor.buffer().read(cx).buffer_point_to_anchor(
1353 &buffer,
1354 Point::new(row.saturating_sub(1), 0),
1355 cx,
1356 )
1357 }) {
1358 anchor
1359 .to_point(&snapshot.buffer_snapshot())
1360 .row
1361 .saturating_add_signed(*offset)
1362 } else {
1363 row.saturating_add_signed(offset.saturating_sub(1))
1364 }
1365 }
1366 Position::Mark { name, offset } => {
1367 let Some(Mark::Local(anchors)) =
1368 vim.get_mark(&name.to_string(), editor, window, cx)
1369 else {
1370 anyhow::bail!("mark {name} not set");
1371 };
1372 let Some(mark) = anchors.last() else {
1373 anyhow::bail!("mark {name} contains empty anchors");
1374 };
1375 mark.to_point(&snapshot.buffer_snapshot())
1376 .row
1377 .saturating_add_signed(*offset)
1378 }
1379 Position::LastLine { offset } => snapshot
1380 .buffer_snapshot()
1381 .max_row()
1382 .0
1383 .saturating_add_signed(*offset),
1384 Position::CurrentLine { offset } => editor
1385 .selections
1386 .newest_anchor()
1387 .head()
1388 .to_point(&snapshot.buffer_snapshot())
1389 .row
1390 .saturating_add_signed(*offset),
1391 };
1392
1393 Ok(MultiBufferRow(target).min(snapshot.buffer_snapshot().max_row()))
1394 }
1395}
1396
1397#[derive(Clone, Debug, PartialEq)]
1398pub(crate) struct CommandRange {
1399 start: Position,
1400 end: Option<Position>,
1401}
1402
1403impl CommandRange {
1404 fn head(&self) -> &Position {
1405 self.end.as_ref().unwrap_or(&self.start)
1406 }
1407
1408 /// Convert the `CommandRange` into a `Range<MultiBufferRow>`.
1409 pub(crate) fn buffer_range(
1410 &self,
1411 vim: &Vim,
1412 editor: &mut Editor,
1413 window: &mut Window,
1414 cx: &mut App,
1415 ) -> Result<Range<MultiBufferRow>> {
1416 let start = self.start.buffer_row(vim, editor, window, cx)?;
1417 let end = if let Some(end) = self.end.as_ref() {
1418 end.buffer_row(vim, editor, window, cx)?
1419 } else {
1420 start
1421 };
1422 if end < start {
1423 anyhow::Ok(end..start)
1424 } else {
1425 anyhow::Ok(start..end)
1426 }
1427 }
1428
1429 pub fn as_count(&self) -> Option<u32> {
1430 if let CommandRange {
1431 start: Position::Line { row, offset: 0 },
1432 end: None,
1433 } = &self
1434 {
1435 Some(*row)
1436 } else {
1437 None
1438 }
1439 }
1440
1441 /// The `CommandRange` representing the entire buffer.
1442 fn buffer() -> Self {
1443 Self {
1444 start: Position::Line { row: 1, offset: 0 },
1445 end: Some(Position::LastLine { offset: 0 }),
1446 }
1447 }
1448}
1449
1450fn generate_commands(_: &App) -> Vec<VimCommand> {
1451 vec![
1452 VimCommand::new(
1453 ("w", "rite"),
1454 VimSave {
1455 save_intent: Some(SaveIntent::Save),
1456 filename: "".into(),
1457 range: None,
1458 },
1459 )
1460 .bang(VimSave {
1461 save_intent: Some(SaveIntent::Overwrite),
1462 filename: "".into(),
1463 range: None,
1464 })
1465 .filename(|action, filename| {
1466 Some(
1467 VimSave {
1468 save_intent: action
1469 .as_any()
1470 .downcast_ref::<VimSave>()
1471 .and_then(|action| action.save_intent),
1472 filename,
1473 range: None,
1474 }
1475 .boxed_clone(),
1476 )
1477 })
1478 .range(|action, range| {
1479 let mut action: VimSave = action.as_any().downcast_ref::<VimSave>().unwrap().clone();
1480 action.range.replace(range.clone());
1481 Some(Box::new(action))
1482 }),
1483 VimCommand::new(("e", "dit"), editor::actions::ReloadFile)
1484 .bang(editor::actions::ReloadFile)
1485 .filename(|_, filename| Some(VimEdit { filename }.boxed_clone())),
1486 VimCommand::new(
1487 ("r", "ead"),
1488 VimRead {
1489 range: None,
1490 filename: "".into(),
1491 },
1492 )
1493 .filename(|_, filename| {
1494 Some(
1495 VimRead {
1496 range: None,
1497 filename,
1498 }
1499 .boxed_clone(),
1500 )
1501 })
1502 .range(|action, range| {
1503 let mut action: VimRead = action.as_any().downcast_ref::<VimRead>().unwrap().clone();
1504 action.range.replace(range.clone());
1505 Some(Box::new(action))
1506 }),
1507 VimCommand::new(("sp", "lit"), workspace::SplitHorizontal::default()).filename(
1508 |_, filename| {
1509 Some(
1510 VimSplit {
1511 vertical: false,
1512 filename,
1513 }
1514 .boxed_clone(),
1515 )
1516 },
1517 ),
1518 VimCommand::new(("vs", "plit"), workspace::SplitVertical::default()).filename(
1519 |_, filename| {
1520 Some(
1521 VimSplit {
1522 vertical: true,
1523 filename,
1524 }
1525 .boxed_clone(),
1526 )
1527 },
1528 ),
1529 VimCommand::new(("tabe", "dit"), workspace::NewFile)
1530 .filename(|_action, filename| Some(VimEdit { filename }.boxed_clone())),
1531 VimCommand::new(("tabnew", ""), workspace::NewFile)
1532 .filename(|_action, filename| Some(VimEdit { filename }.boxed_clone())),
1533 VimCommand::new(
1534 ("q", "uit"),
1535 workspace::CloseActiveItem {
1536 save_intent: Some(SaveIntent::Close),
1537 close_pinned: false,
1538 },
1539 )
1540 .bang(workspace::CloseActiveItem {
1541 save_intent: Some(SaveIntent::Skip),
1542 close_pinned: true,
1543 }),
1544 VimCommand::new(
1545 ("wq", ""),
1546 workspace::CloseActiveItem {
1547 save_intent: Some(SaveIntent::Save),
1548 close_pinned: false,
1549 },
1550 )
1551 .bang(workspace::CloseActiveItem {
1552 save_intent: Some(SaveIntent::Overwrite),
1553 close_pinned: true,
1554 }),
1555 VimCommand::new(
1556 ("x", "it"),
1557 workspace::CloseActiveItem {
1558 save_intent: Some(SaveIntent::SaveAll),
1559 close_pinned: false,
1560 },
1561 )
1562 .bang(workspace::CloseActiveItem {
1563 save_intent: Some(SaveIntent::Overwrite),
1564 close_pinned: true,
1565 }),
1566 VimCommand::new(
1567 ("exi", "t"),
1568 workspace::CloseActiveItem {
1569 save_intent: Some(SaveIntent::SaveAll),
1570 close_pinned: false,
1571 },
1572 )
1573 .bang(workspace::CloseActiveItem {
1574 save_intent: Some(SaveIntent::Overwrite),
1575 close_pinned: true,
1576 }),
1577 VimCommand::new(
1578 ("up", "date"),
1579 workspace::Save {
1580 save_intent: Some(SaveIntent::SaveAll),
1581 },
1582 ),
1583 VimCommand::new(
1584 ("wa", "ll"),
1585 workspace::SaveAll {
1586 save_intent: Some(SaveIntent::SaveAll),
1587 },
1588 )
1589 .bang(workspace::SaveAll {
1590 save_intent: Some(SaveIntent::Overwrite),
1591 }),
1592 VimCommand::new(
1593 ("qa", "ll"),
1594 workspace::CloseAllItemsAndPanes {
1595 save_intent: Some(SaveIntent::Close),
1596 },
1597 )
1598 .bang(workspace::CloseAllItemsAndPanes {
1599 save_intent: Some(SaveIntent::Skip),
1600 }),
1601 VimCommand::new(
1602 ("quita", "ll"),
1603 workspace::CloseAllItemsAndPanes {
1604 save_intent: Some(SaveIntent::Close),
1605 },
1606 )
1607 .bang(workspace::CloseAllItemsAndPanes {
1608 save_intent: Some(SaveIntent::Skip),
1609 }),
1610 VimCommand::new(
1611 ("xa", "ll"),
1612 workspace::CloseAllItemsAndPanes {
1613 save_intent: Some(SaveIntent::SaveAll),
1614 },
1615 )
1616 .bang(workspace::CloseAllItemsAndPanes {
1617 save_intent: Some(SaveIntent::Overwrite),
1618 }),
1619 VimCommand::new(
1620 ("wqa", "ll"),
1621 workspace::CloseAllItemsAndPanes {
1622 save_intent: Some(SaveIntent::SaveAll),
1623 },
1624 )
1625 .bang(workspace::CloseAllItemsAndPanes {
1626 save_intent: Some(SaveIntent::Overwrite),
1627 }),
1628 VimCommand::new(("cq", "uit"), zed_actions::Quit),
1629 VimCommand::new(
1630 ("bd", "elete"),
1631 workspace::CloseItemInAllPanes {
1632 save_intent: Some(SaveIntent::Close),
1633 close_pinned: false,
1634 },
1635 )
1636 .bang(workspace::CloseItemInAllPanes {
1637 save_intent: Some(SaveIntent::Skip),
1638 close_pinned: true,
1639 }),
1640 VimCommand::new(
1641 ("norm", "al"),
1642 VimNorm {
1643 command: "".into(),
1644 range: None,
1645 override_rows: None,
1646 },
1647 )
1648 .args(|_, args| {
1649 Some(
1650 VimNorm {
1651 command: args,
1652 range: None,
1653 override_rows: None,
1654 }
1655 .boxed_clone(),
1656 )
1657 })
1658 .range(|action, range| {
1659 let mut action: VimNorm = action.as_any().downcast_ref::<VimNorm>().unwrap().clone();
1660 action.range.replace(range.clone());
1661 Some(Box::new(action))
1662 }),
1663 VimCommand::new(("bn", "ext"), workspace::ActivateNextItem).count(),
1664 VimCommand::new(("bN", "ext"), workspace::ActivatePreviousItem).count(),
1665 VimCommand::new(("bp", "revious"), workspace::ActivatePreviousItem).count(),
1666 VimCommand::new(("bf", "irst"), workspace::ActivateItem(0)),
1667 VimCommand::new(("br", "ewind"), workspace::ActivateItem(0)),
1668 VimCommand::new(("bl", "ast"), workspace::ActivateLastItem),
1669 VimCommand::str(("buffers", ""), "tab_switcher::ToggleAll"),
1670 VimCommand::str(("ls", ""), "tab_switcher::ToggleAll"),
1671 VimCommand::new(("new", ""), workspace::NewFileSplitHorizontal),
1672 VimCommand::new(("vne", "w"), workspace::NewFileSplitVertical),
1673 VimCommand::new(("tabn", "ext"), workspace::ActivateNextItem).count(),
1674 VimCommand::new(("tabp", "revious"), workspace::ActivatePreviousItem).count(),
1675 VimCommand::new(("tabN", "ext"), workspace::ActivatePreviousItem).count(),
1676 VimCommand::new(
1677 ("tabc", "lose"),
1678 workspace::CloseActiveItem {
1679 save_intent: Some(SaveIntent::Close),
1680 close_pinned: false,
1681 },
1682 ),
1683 VimCommand::new(
1684 ("tabo", "nly"),
1685 workspace::CloseOtherItems {
1686 save_intent: Some(SaveIntent::Close),
1687 close_pinned: false,
1688 },
1689 )
1690 .bang(workspace::CloseOtherItems {
1691 save_intent: Some(SaveIntent::Skip),
1692 close_pinned: false,
1693 }),
1694 VimCommand::new(
1695 ("on", "ly"),
1696 workspace::CloseInactiveTabsAndPanes {
1697 save_intent: Some(SaveIntent::Close),
1698 },
1699 )
1700 .bang(workspace::CloseInactiveTabsAndPanes {
1701 save_intent: Some(SaveIntent::Skip),
1702 }),
1703 VimCommand::str(("cl", "ist"), "diagnostics::Deploy"),
1704 VimCommand::new(("cc", ""), editor::actions::Hover),
1705 VimCommand::new(("ll", ""), editor::actions::Hover),
1706 VimCommand::new(("cn", "ext"), editor::actions::GoToDiagnostic::default())
1707 .range(wrap_count),
1708 VimCommand::new(
1709 ("cp", "revious"),
1710 editor::actions::GoToPreviousDiagnostic::default(),
1711 )
1712 .range(wrap_count),
1713 VimCommand::new(
1714 ("cN", "ext"),
1715 editor::actions::GoToPreviousDiagnostic::default(),
1716 )
1717 .range(wrap_count),
1718 VimCommand::new(
1719 ("lp", "revious"),
1720 editor::actions::GoToPreviousDiagnostic::default(),
1721 )
1722 .range(wrap_count),
1723 VimCommand::new(
1724 ("lN", "ext"),
1725 editor::actions::GoToPreviousDiagnostic::default(),
1726 )
1727 .range(wrap_count),
1728 VimCommand::new(("j", "oin"), JoinLines).range(select_range),
1729 VimCommand::new(("reflow", ""), Rewrap { line_length: None })
1730 .range(select_range)
1731 .args(|_action, args| {
1732 args.parse::<usize>().map_or(None, |length| {
1733 Some(Box::new(Rewrap {
1734 line_length: Some(length),
1735 }))
1736 })
1737 }),
1738 VimCommand::new(("fo", "ld"), editor::actions::FoldSelectedRanges).range(act_on_range),
1739 VimCommand::new(("foldo", "pen"), editor::actions::UnfoldLines)
1740 .bang(editor::actions::UnfoldRecursive)
1741 .range(act_on_range),
1742 VimCommand::new(("foldc", "lose"), editor::actions::Fold)
1743 .bang(editor::actions::FoldRecursive)
1744 .range(act_on_range),
1745 VimCommand::new(("dif", "fupdate"), editor::actions::ToggleSelectedDiffHunks)
1746 .range(act_on_range),
1747 VimCommand::str(("rev", "ert"), "git::Restore").range(act_on_range),
1748 VimCommand::new(("d", "elete"), VisualDeleteLine).range(select_range),
1749 VimCommand::new(("y", "ank"), gpui::NoAction).range(|_, range| {
1750 Some(
1751 YankCommand {
1752 range: range.clone(),
1753 }
1754 .boxed_clone(),
1755 )
1756 }),
1757 VimCommand::new(("reg", "isters"), ToggleRegistersView).bang(ToggleRegistersView),
1758 VimCommand::new(("di", "splay"), ToggleRegistersView).bang(ToggleRegistersView),
1759 VimCommand::new(("marks", ""), ToggleMarksView).bang(ToggleMarksView),
1760 VimCommand::new(("delm", "arks"), ArgumentRequired)
1761 .bang(DeleteMarks::AllLocal)
1762 .args(|_, args| Some(DeleteMarks::Marks(args).boxed_clone())),
1763 VimCommand::new(("sor", "t"), SortLinesCaseSensitive)
1764 .range(select_range)
1765 .default_range(CommandRange::buffer()),
1766 VimCommand::new(("sort i", ""), SortLinesCaseInsensitive)
1767 .range(select_range)
1768 .default_range(CommandRange::buffer()),
1769 VimCommand::str(("E", "xplore"), "project_panel::ToggleFocus"),
1770 VimCommand::str(("H", "explore"), "project_panel::ToggleFocus"),
1771 VimCommand::str(("L", "explore"), "project_panel::ToggleFocus"),
1772 VimCommand::str(("S", "explore"), "project_panel::ToggleFocus"),
1773 VimCommand::str(("Ve", "xplore"), "project_panel::ToggleFocus"),
1774 VimCommand::str(("te", "rm"), "terminal_panel::Toggle"),
1775 VimCommand::str(("T", "erm"), "terminal_panel::Toggle"),
1776 VimCommand::str(("C", "ollab"), "collab_panel::ToggleFocus"),
1777 VimCommand::str(("No", "tifications"), "notification_panel::ToggleFocus"),
1778 VimCommand::str(("A", "I"), "agent::ToggleFocus"),
1779 VimCommand::str(("G", "it"), "git_panel::ToggleFocus"),
1780 VimCommand::str(("D", "ebug"), "debug_panel::ToggleFocus"),
1781 VimCommand::new(("noh", "lsearch"), search::buffer_search::Dismiss),
1782 VimCommand::new(("$", ""), EndOfDocument),
1783 VimCommand::new(("%", ""), EndOfDocument),
1784 VimCommand::new(("0", ""), StartOfDocument),
1785 VimCommand::new(("ex", ""), editor::actions::ReloadFile).bang(editor::actions::ReloadFile),
1786 VimCommand::new(("cpp", "link"), editor::actions::CopyPermalinkToLine).range(act_on_range),
1787 VimCommand::str(("opt", "ions"), "zed::OpenDefaultSettings"),
1788 VimCommand::str(("map", ""), "vim::OpenDefaultKeymap"),
1789 VimCommand::new(("h", "elp"), OpenDocs),
1790 ]
1791}
1792
1793struct VimCommands(Vec<VimCommand>);
1794// safety: we only ever access this from the main thread (as ensured by the cx argument)
1795// actions are not Sync so we can't otherwise use a OnceLock.
1796unsafe impl Sync for VimCommands {}
1797impl Global for VimCommands {}
1798
1799fn commands(cx: &App) -> &Vec<VimCommand> {
1800 static COMMANDS: OnceLock<VimCommands> = OnceLock::new();
1801 &COMMANDS
1802 .get_or_init(|| VimCommands(generate_commands(cx)))
1803 .0
1804}
1805
1806fn act_on_range(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
1807 Some(
1808 WithRange {
1809 restore_selection: true,
1810 range: range.clone(),
1811 action: WrappedAction(action),
1812 }
1813 .boxed_clone(),
1814 )
1815}
1816
1817fn select_range(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
1818 Some(
1819 WithRange {
1820 restore_selection: false,
1821 range: range.clone(),
1822 action: WrappedAction(action),
1823 }
1824 .boxed_clone(),
1825 )
1826}
1827
1828fn wrap_count(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
1829 range.as_count().map(|count| {
1830 WithCount {
1831 count,
1832 action: WrappedAction(action),
1833 }
1834 .boxed_clone()
1835 })
1836}
1837
1838pub fn command_interceptor(
1839 mut input: &str,
1840 workspace: WeakEntity<Workspace>,
1841 cx: &mut App,
1842) -> Task<CommandInterceptResult> {
1843 while input.starts_with(':') {
1844 input = &input[1..];
1845 }
1846
1847 let (range, query) = VimCommand::parse_range(input);
1848 let range_prefix = input[0..(input.len() - query.len())].to_string();
1849 let has_trailing_space = query.ends_with(" ");
1850 let mut query = query.as_str().trim_start();
1851
1852 let on_matching_lines = (query.starts_with('g') || query.starts_with('v'))
1853 .then(|| {
1854 let (pattern, range, search, invert) = OnMatchingLines::parse(query, &range)?;
1855 let start_idx = query.len() - pattern.len();
1856 query = query[start_idx..].trim();
1857 Some((range, search, invert))
1858 })
1859 .flatten();
1860
1861 let mut action = if range.is_some() && query.is_empty() {
1862 Some(
1863 GoToLine {
1864 range: range.clone().unwrap(),
1865 }
1866 .boxed_clone(),
1867 )
1868 } else if query.starts_with('/') || query.starts_with('?') {
1869 Some(
1870 FindCommand {
1871 query: query[1..].to_string(),
1872 backwards: query.starts_with('?'),
1873 }
1874 .boxed_clone(),
1875 )
1876 } else if query.starts_with("se ") || query.starts_with("set ") {
1877 let (prefix, option) = query.split_once(' ').unwrap();
1878 let mut commands = VimOption::possible_commands(option);
1879 if !commands.is_empty() {
1880 let query = prefix.to_string() + " " + option;
1881 for command in &mut commands {
1882 command.positions = generate_positions(&command.string, &query);
1883 }
1884 }
1885 return Task::ready(CommandInterceptResult {
1886 results: commands,
1887 exclusive: false,
1888 });
1889 } else if query.starts_with('s') {
1890 let mut substitute = "substitute".chars().peekable();
1891 let mut query = query.chars().peekable();
1892 while substitute
1893 .peek()
1894 .is_some_and(|char| Some(char) == query.peek())
1895 {
1896 substitute.next();
1897 query.next();
1898 }
1899 if let Some(replacement) = Replacement::parse(query) {
1900 let range = range.clone().unwrap_or(CommandRange {
1901 start: Position::CurrentLine { offset: 0 },
1902 end: None,
1903 });
1904 Some(ReplaceCommand { replacement, range }.boxed_clone())
1905 } else {
1906 None
1907 }
1908 } else if query.contains('!') {
1909 ShellExec::parse(query, range.clone())
1910 } else if on_matching_lines.is_some() {
1911 commands(cx)
1912 .iter()
1913 .find_map(|command| command.parse(query, &None, cx))
1914 } else {
1915 None
1916 };
1917
1918 if let Some((range, search, invert)) = on_matching_lines
1919 && let Some(ref inner) = action
1920 {
1921 action = Some(Box::new(OnMatchingLines {
1922 range,
1923 search,
1924 action: WrappedAction(inner.boxed_clone()),
1925 invert,
1926 }));
1927 };
1928
1929 if let Some(action) = action {
1930 let string = input.to_string();
1931 let positions = generate_positions(&string, &(range_prefix + query));
1932 return Task::ready(CommandInterceptResult {
1933 results: vec![CommandInterceptItem {
1934 action,
1935 string,
1936 positions,
1937 }],
1938 exclusive: false,
1939 });
1940 }
1941
1942 let Some((mut results, filenames)) =
1943 commands(cx).iter().enumerate().find_map(|(idx, command)| {
1944 let action = command.parse(query, &range, cx)?;
1945 let parsed_query = command.get_parsed_query(query.into())?;
1946 let display_string = ":".to_owned()
1947 + &range_prefix
1948 + command.prefix
1949 + command.suffix
1950 + if parsed_query.has_bang { "!" } else { "" };
1951 let space = if parsed_query.has_space { " " } else { "" };
1952
1953 let string = format!("{}{}{}", &display_string, &space, &parsed_query.args);
1954 let positions = generate_positions(&string, &(range_prefix.clone() + query));
1955
1956 let results = vec![CommandInterceptItem {
1957 action,
1958 string,
1959 positions,
1960 }];
1961
1962 let no_args_positions =
1963 generate_positions(&display_string, &(range_prefix.clone() + query));
1964
1965 // The following are valid autocomplete scenarios:
1966 // :w!filename.txt
1967 // :w filename.txt
1968 // :w[space]
1969 if !command.has_filename
1970 || (!has_trailing_space && !parsed_query.has_bang && parsed_query.args.is_empty())
1971 {
1972 return Some((results, None));
1973 }
1974
1975 Some((
1976 results,
1977 Some((idx, parsed_query, display_string, no_args_positions)),
1978 ))
1979 })
1980 else {
1981 return Task::ready(CommandInterceptResult::default());
1982 };
1983
1984 if let Some((cmd_idx, parsed_query, display_string, no_args_positions)) = filenames {
1985 let filenames = VimCommand::generate_filename_completions(&parsed_query, workspace, cx);
1986 cx.spawn(async move |cx| {
1987 let filenames = filenames.await;
1988 const MAX_RESULTS: usize = 100;
1989 let executor = cx.background_executor().clone();
1990 let mut candidates = Vec::with_capacity(filenames.len());
1991
1992 for (idx, filename) in filenames.iter().enumerate() {
1993 candidates.push(fuzzy::StringMatchCandidate::new(idx, &filename));
1994 }
1995 let filenames = fuzzy::match_strings(
1996 &candidates,
1997 &parsed_query.args,
1998 false,
1999 true,
2000 MAX_RESULTS,
2001 &Default::default(),
2002 executor,
2003 )
2004 .await;
2005
2006 for fuzzy::StringMatch {
2007 candidate_id: _,
2008 score: _,
2009 positions,
2010 string,
2011 } in filenames
2012 {
2013 let offset = display_string.len() + 1;
2014 let mut positions: Vec<_> = positions.iter().map(|&pos| pos + offset).collect();
2015 positions.splice(0..0, no_args_positions.clone());
2016 let string = format!("{display_string} {string}");
2017 let (range, query) = VimCommand::parse_range(&string[1..]);
2018 let action =
2019 match cx.update(|cx| commands(cx).get(cmd_idx)?.parse(&query, &range, cx)) {
2020 Some(action) => action,
2021 _ => continue,
2022 };
2023 results.push(CommandInterceptItem {
2024 action,
2025 string,
2026 positions,
2027 });
2028 }
2029 CommandInterceptResult {
2030 results,
2031 exclusive: true,
2032 }
2033 })
2034 } else {
2035 Task::ready(CommandInterceptResult {
2036 results,
2037 exclusive: false,
2038 })
2039 }
2040}
2041
2042fn generate_positions(string: &str, query: &str) -> Vec<usize> {
2043 let mut positions = Vec::new();
2044 let mut chars = query.chars();
2045
2046 let Some(mut current) = chars.next() else {
2047 return positions;
2048 };
2049
2050 for (i, c) in string.char_indices() {
2051 if c == current {
2052 positions.push(i);
2053 if let Some(c) = chars.next() {
2054 current = c;
2055 } else {
2056 break;
2057 }
2058 }
2059 }
2060
2061 positions
2062}
2063
2064/// Applies a command to all lines matching a pattern.
2065#[derive(Debug, PartialEq, Clone, Action)]
2066#[action(namespace = vim, no_json, no_register)]
2067pub(crate) struct OnMatchingLines {
2068 range: CommandRange,
2069 search: String,
2070 action: WrappedAction,
2071 invert: bool,
2072}
2073
2074impl OnMatchingLines {
2075 // convert a vim query into something more usable by zed.
2076 // we don't attempt to fully convert between the two regex syntaxes,
2077 // but we do flip \( and \) to ( and ) (and vice-versa) in the pattern,
2078 // and convert \0..\9 to $0..$9 in the replacement so that common idioms work.
2079 pub(crate) fn parse(
2080 query: &str,
2081 range: &Option<CommandRange>,
2082 ) -> Option<(String, CommandRange, String, bool)> {
2083 let mut global = "global".chars().peekable();
2084 let mut query_chars = query.chars().peekable();
2085 let mut invert = false;
2086 if query_chars.peek() == Some(&'v') {
2087 invert = true;
2088 query_chars.next();
2089 }
2090 while global
2091 .peek()
2092 .is_some_and(|char| Some(char) == query_chars.peek())
2093 {
2094 global.next();
2095 query_chars.next();
2096 }
2097 if !invert && query_chars.peek() == Some(&'!') {
2098 invert = true;
2099 query_chars.next();
2100 }
2101 let range = range.clone().unwrap_or(CommandRange {
2102 start: Position::Line { row: 0, offset: 0 },
2103 end: Some(Position::LastLine { offset: 0 }),
2104 });
2105
2106 let delimiter = query_chars.next().filter(|c| {
2107 !c.is_alphanumeric() && *c != '"' && *c != '|' && *c != '\'' && *c != '!'
2108 })?;
2109
2110 let mut search = String::new();
2111 let mut escaped = false;
2112
2113 for c in query_chars.by_ref() {
2114 if escaped {
2115 escaped = false;
2116 // unescape escaped parens
2117 if c != '(' && c != ')' && c != delimiter {
2118 search.push('\\')
2119 }
2120 search.push(c)
2121 } else if c == '\\' {
2122 escaped = true;
2123 } else if c == delimiter {
2124 break;
2125 } else {
2126 // escape unescaped parens
2127 if c == '(' || c == ')' {
2128 search.push('\\')
2129 }
2130 search.push(c)
2131 }
2132 }
2133
2134 Some((query_chars.collect::<String>(), range, search, invert))
2135 }
2136
2137 pub fn run(&self, vim: &mut Vim, window: &mut Window, cx: &mut Context<Vim>) {
2138 let result = vim.update_editor(cx, |vim, editor, cx| {
2139 self.range.buffer_range(vim, editor, window, cx)
2140 });
2141
2142 let range = match result {
2143 None => return,
2144 Some(e @ Err(_)) => {
2145 let Some(workspace) = vim.workspace(window, cx) else {
2146 return;
2147 };
2148 workspace.update(cx, |workspace, cx| {
2149 e.notify_err(workspace, cx);
2150 });
2151 return;
2152 }
2153 Some(Ok(result)) => result,
2154 };
2155
2156 let mut action = self.action.boxed_clone();
2157 let mut last_pattern = self.search.clone();
2158
2159 let mut regexes = match Regex::new(&self.search) {
2160 Ok(regex) => vec![(regex, !self.invert)],
2161 e @ Err(_) => {
2162 let Some(workspace) = vim.workspace(window, cx) else {
2163 return;
2164 };
2165 workspace.update(cx, |workspace, cx| {
2166 e.notify_err(workspace, cx);
2167 });
2168 return;
2169 }
2170 };
2171 while let Some(inner) = action
2172 .boxed_clone()
2173 .as_any()
2174 .downcast_ref::<OnMatchingLines>()
2175 {
2176 let Some(regex) = Regex::new(&inner.search).ok() else {
2177 break;
2178 };
2179 last_pattern = inner.search.clone();
2180 action = inner.action.boxed_clone();
2181 regexes.push((regex, !inner.invert))
2182 }
2183
2184 if let Some(pane) = vim.pane(window, cx) {
2185 pane.update(cx, |pane, cx| {
2186 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>()
2187 {
2188 search_bar.update(cx, |search_bar, cx| {
2189 if search_bar.show(window, cx) {
2190 let _ = search_bar.search(
2191 &last_pattern,
2192 Some(SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE),
2193 false,
2194 window,
2195 cx,
2196 );
2197 }
2198 });
2199 }
2200 });
2201 };
2202
2203 vim.update_editor(cx, |_, editor, cx| {
2204 let snapshot = editor.snapshot(window, cx);
2205 let mut row = range.start.0;
2206
2207 let point_range = Point::new(range.start.0, 0)
2208 ..snapshot
2209 .buffer_snapshot()
2210 .clip_point(Point::new(range.end.0 + 1, 0), Bias::Left);
2211 cx.spawn_in(window, async move |editor, cx| {
2212 let new_selections = cx
2213 .background_spawn(async move {
2214 let mut line = String::new();
2215 let mut new_selections = Vec::new();
2216 let chunks = snapshot
2217 .buffer_snapshot()
2218 .text_for_range(point_range)
2219 .chain(["\n"]);
2220
2221 for chunk in chunks {
2222 for (newline_ix, text) in chunk.split('\n').enumerate() {
2223 if newline_ix > 0 {
2224 if regexes.iter().all(|(regex, should_match)| {
2225 regex.is_match(&line) == *should_match
2226 }) {
2227 new_selections
2228 .push(Point::new(row, 0).to_display_point(&snapshot))
2229 }
2230 row += 1;
2231 line.clear();
2232 }
2233 line.push_str(text)
2234 }
2235 }
2236
2237 new_selections
2238 })
2239 .await;
2240
2241 if new_selections.is_empty() {
2242 return;
2243 }
2244
2245 if let Some(vim_norm) = action.as_any().downcast_ref::<VimNorm>() {
2246 let mut vim_norm = vim_norm.clone();
2247 vim_norm.override_rows =
2248 Some(new_selections.iter().map(|point| point.row().0).collect());
2249 editor
2250 .update_in(cx, |_, window, cx| {
2251 window.dispatch_action(vim_norm.boxed_clone(), cx);
2252 })
2253 .log_err();
2254 return;
2255 }
2256
2257 editor
2258 .update_in(cx, |editor, window, cx| {
2259 editor.start_transaction_at(Instant::now(), window, cx);
2260 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2261 s.replace_cursors_with(|_| new_selections);
2262 });
2263 window.dispatch_action(action, cx);
2264
2265 cx.defer_in(window, move |editor, window, cx| {
2266 let newest = editor
2267 .selections
2268 .newest::<Point>(&editor.display_snapshot(cx));
2269 editor.change_selections(
2270 SelectionEffects::no_scroll(),
2271 window,
2272 cx,
2273 |s| {
2274 s.select(vec![newest]);
2275 },
2276 );
2277 editor.end_transaction_at(Instant::now(), cx);
2278 })
2279 })
2280 .log_err();
2281 })
2282 .detach();
2283 });
2284 }
2285}
2286
2287/// Executes a shell command and returns the output.
2288#[derive(Clone, Debug, PartialEq, Action)]
2289#[action(namespace = vim, no_json, no_register)]
2290pub struct ShellExec {
2291 command: String,
2292 range: Option<CommandRange>,
2293 is_read: bool,
2294}
2295
2296impl Vim {
2297 pub fn cancel_running_command(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2298 if self.running_command.take().is_some() {
2299 self.update_editor(cx, |_, editor, cx| {
2300 editor.transact(window, cx, |editor, _window, _cx| {
2301 editor.clear_row_highlights::<ShellExec>();
2302 })
2303 });
2304 }
2305 }
2306
2307 fn prepare_shell_command(
2308 &mut self,
2309 command: &str,
2310 _: &mut Window,
2311 cx: &mut Context<Self>,
2312 ) -> String {
2313 let mut ret = String::new();
2314 // N.B. non-standard escaping rules:
2315 // * !echo % => "echo README.md"
2316 // * !echo \% => "echo %"
2317 // * !echo \\% => echo \%
2318 // * !echo \\\% => echo \\%
2319 for c in command.chars() {
2320 if c != '%' && c != '!' {
2321 ret.push(c);
2322 continue;
2323 } else if ret.chars().last() == Some('\\') {
2324 ret.pop();
2325 ret.push(c);
2326 continue;
2327 }
2328 match c {
2329 '%' => {
2330 self.update_editor(cx, |_, editor, cx| {
2331 if let Some((_, buffer, _)) = editor.active_excerpt(cx)
2332 && let Some(file) = buffer.read(cx).file()
2333 && let Some(local) = file.as_local()
2334 {
2335 ret.push_str(&local.path().display(local.path_style(cx)));
2336 }
2337 });
2338 }
2339 '!' => {
2340 if let Some(command) = &self.last_command {
2341 ret.push_str(command)
2342 }
2343 }
2344 _ => {}
2345 }
2346 }
2347 self.last_command = Some(ret.clone());
2348 ret
2349 }
2350
2351 pub fn shell_command_motion(
2352 &mut self,
2353 motion: Motion,
2354 times: Option<usize>,
2355 forced_motion: bool,
2356 window: &mut Window,
2357 cx: &mut Context<Vim>,
2358 ) {
2359 self.stop_recording(cx);
2360 let Some(workspace) = self.workspace(window, cx) else {
2361 return;
2362 };
2363 let command = self.update_editor(cx, |_, editor, cx| {
2364 let snapshot = editor.snapshot(window, cx);
2365 let start = editor
2366 .selections
2367 .newest_display(&editor.display_snapshot(cx));
2368 let text_layout_details = editor.text_layout_details(window, cx);
2369 let (mut range, _) = motion
2370 .range(
2371 &snapshot,
2372 start.clone(),
2373 times,
2374 &text_layout_details,
2375 forced_motion,
2376 )
2377 .unwrap_or((start.range(), MotionKind::Exclusive));
2378 if range.start != start.start {
2379 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2380 s.select_ranges([
2381 range.start.to_point(&snapshot)..range.start.to_point(&snapshot)
2382 ]);
2383 })
2384 }
2385 if range.end.row() > range.start.row() && range.end.column() != 0 {
2386 *range.end.row_mut() -= 1
2387 }
2388 if range.end.row() == range.start.row() {
2389 ".!".to_string()
2390 } else {
2391 format!(".,.+{}!", (range.end.row() - range.start.row()).0)
2392 }
2393 });
2394 if let Some(command) = command {
2395 workspace.update(cx, |workspace, cx| {
2396 command_palette::CommandPalette::toggle(workspace, &command, window, cx);
2397 });
2398 }
2399 }
2400
2401 pub fn shell_command_object(
2402 &mut self,
2403 object: Object,
2404 around: bool,
2405 window: &mut Window,
2406 cx: &mut Context<Vim>,
2407 ) {
2408 self.stop_recording(cx);
2409 let Some(workspace) = self.workspace(window, cx) else {
2410 return;
2411 };
2412 let command = self.update_editor(cx, |_, editor, cx| {
2413 let snapshot = editor.snapshot(window, cx);
2414 let start = editor
2415 .selections
2416 .newest_display(&editor.display_snapshot(cx));
2417 let range = object
2418 .range(&snapshot, start.clone(), around, None)
2419 .unwrap_or(start.range());
2420 if range.start != start.start {
2421 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2422 s.select_ranges([
2423 range.start.to_point(&snapshot)..range.start.to_point(&snapshot)
2424 ]);
2425 })
2426 }
2427 if range.end.row() == range.start.row() {
2428 ".!".to_string()
2429 } else {
2430 format!(".,.+{}!", (range.end.row() - range.start.row()).0)
2431 }
2432 });
2433 if let Some(command) = command {
2434 workspace.update(cx, |workspace, cx| {
2435 command_palette::CommandPalette::toggle(workspace, &command, window, cx);
2436 });
2437 }
2438 }
2439}
2440
2441impl ShellExec {
2442 pub fn parse(query: &str, range: Option<CommandRange>) -> Option<Box<dyn Action>> {
2443 let (before, after) = query.split_once('!')?;
2444 let before = before.trim();
2445
2446 if !"read".starts_with(before) {
2447 return None;
2448 }
2449
2450 Some(
2451 ShellExec {
2452 command: after.trim().to_string(),
2453 range,
2454 is_read: !before.is_empty(),
2455 }
2456 .boxed_clone(),
2457 )
2458 }
2459
2460 pub fn run(&self, vim: &mut Vim, window: &mut Window, cx: &mut Context<Vim>) {
2461 let Some(workspace) = vim.workspace(window, cx) else {
2462 return;
2463 };
2464
2465 let project = workspace.read(cx).project().clone();
2466 let command = vim.prepare_shell_command(&self.command, window, cx);
2467
2468 if self.range.is_none() && !self.is_read {
2469 workspace.update(cx, |workspace, cx| {
2470 let project = workspace.project().read(cx);
2471 let cwd = project.first_project_directory(cx);
2472 let shell = project.terminal_settings(&cwd, cx).shell.clone();
2473
2474 let spawn_in_terminal = SpawnInTerminal {
2475 id: TaskId("vim".to_string()),
2476 full_label: command.clone(),
2477 label: command.clone(),
2478 command: Some(command.clone()),
2479 args: Vec::new(),
2480 command_label: command.clone(),
2481 cwd,
2482 env: HashMap::default(),
2483 use_new_terminal: true,
2484 allow_concurrent_runs: true,
2485 reveal: RevealStrategy::NoFocus,
2486 reveal_target: RevealTarget::Dock,
2487 hide: HideStrategy::Never,
2488 shell,
2489 show_summary: false,
2490 show_command: false,
2491 show_rerun: false,
2492 save: SaveStrategy::default(),
2493 };
2494
2495 let task_status = workspace.spawn_in_terminal(spawn_in_terminal, window, cx);
2496 cx.background_spawn(async move {
2497 match task_status.await {
2498 Some(Ok(status)) => {
2499 if status.success() {
2500 log::debug!("Vim shell exec succeeded");
2501 } else {
2502 log::debug!("Vim shell exec failed, code: {:?}", status.code());
2503 }
2504 }
2505 Some(Err(e)) => log::error!("Vim shell exec failed: {e}"),
2506 None => log::debug!("Vim shell exec got cancelled"),
2507 }
2508 })
2509 .detach();
2510 });
2511 return;
2512 };
2513
2514 let mut input_snapshot = None;
2515 let mut input_range = None;
2516 let mut needs_newline_prefix = false;
2517 vim.update_editor(cx, |vim, editor, cx| {
2518 let snapshot = editor.buffer().read(cx).snapshot(cx);
2519 let range = if let Some(range) = self.range.clone() {
2520 let Some(range) = range.buffer_range(vim, editor, window, cx).log_err() else {
2521 return;
2522 };
2523 Point::new(range.start.0, 0)
2524 ..snapshot.clip_point(Point::new(range.end.0 + 1, 0), Bias::Right)
2525 } else {
2526 let mut end = editor
2527 .selections
2528 .newest::<Point>(&editor.display_snapshot(cx))
2529 .range()
2530 .end;
2531 end = snapshot.clip_point(Point::new(end.row + 1, 0), Bias::Right);
2532 needs_newline_prefix = end == snapshot.max_point();
2533 end..end
2534 };
2535 if self.is_read {
2536 input_range =
2537 Some(snapshot.anchor_after(range.end)..snapshot.anchor_after(range.end));
2538 } else {
2539 input_range =
2540 Some(snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end));
2541 }
2542 editor.highlight_rows::<ShellExec>(
2543 input_range.clone().unwrap(),
2544 cx.theme().status().unreachable_background,
2545 Default::default(),
2546 cx,
2547 );
2548
2549 if !self.is_read {
2550 input_snapshot = Some(snapshot)
2551 }
2552 });
2553
2554 let Some(range) = input_range else { return };
2555
2556 let process_task = project.update(cx, |project, cx| project.exec_in_shell(command, cx));
2557
2558 let is_read = self.is_read;
2559
2560 let task = cx.spawn_in(window, async move |vim, cx| {
2561 let Some(mut process) = process_task.await.log_err() else {
2562 return;
2563 };
2564 process.stdout(Stdio::piped());
2565 process.stderr(Stdio::piped());
2566
2567 if input_snapshot.is_some() {
2568 process.stdin(Stdio::piped());
2569 } else {
2570 process.stdin(Stdio::null());
2571 };
2572
2573 let Some(mut running) = process.spawn().log_err() else {
2574 vim.update_in(cx, |vim, window, cx| {
2575 vim.cancel_running_command(window, cx);
2576 })
2577 .log_err();
2578 return;
2579 };
2580
2581 if let Some(mut stdin) = running.stdin.take()
2582 && let Some(snapshot) = input_snapshot
2583 {
2584 let range = range.clone();
2585 cx.background_spawn(async move {
2586 for chunk in snapshot.text_for_range(range) {
2587 if stdin.write_all(chunk.as_bytes()).await.log_err().is_none() {
2588 return;
2589 }
2590 }
2591 stdin.flush().await.log_err();
2592 })
2593 .detach();
2594 };
2595
2596 let output = cx.background_spawn(running.output()).await;
2597
2598 let Some(output) = output.log_err() else {
2599 vim.update_in(cx, |vim, window, cx| {
2600 vim.cancel_running_command(window, cx);
2601 })
2602 .log_err();
2603 return;
2604 };
2605 let mut text = String::new();
2606 if needs_newline_prefix {
2607 text.push('\n');
2608 }
2609 text.push_str(&String::from_utf8_lossy(&output.stdout));
2610 text.push_str(&String::from_utf8_lossy(&output.stderr));
2611 if !text.is_empty() && text.chars().last() != Some('\n') {
2612 text.push('\n');
2613 }
2614
2615 vim.update_in(cx, |vim, window, cx| {
2616 vim.update_editor(cx, |_, editor, cx| {
2617 editor.transact(window, cx, |editor, window, cx| {
2618 editor.edit([(range.clone(), text)], cx);
2619 let snapshot = editor.buffer().read(cx).snapshot(cx);
2620 editor.change_selections(Default::default(), window, cx, |s| {
2621 let point = if is_read {
2622 let point = range.end.to_point(&snapshot);
2623 Point::new(point.row.saturating_sub(1), 0)
2624 } else {
2625 let point = range.start.to_point(&snapshot);
2626 Point::new(point.row, 0)
2627 };
2628 s.select_ranges([point..point]);
2629 })
2630 })
2631 });
2632 vim.cancel_running_command(window, cx);
2633 })
2634 .log_err();
2635 });
2636 vim.running_command.replace(task);
2637 }
2638}
2639
2640#[cfg(test)]
2641mod test {
2642 use std::path::{Path, PathBuf};
2643
2644 use crate::{
2645 VimAddon,
2646 state::Mode,
2647 test::{NeovimBackedTestContext, VimTestContext},
2648 };
2649 use editor::{Editor, EditorSettings};
2650 use gpui::{Context, TestAppContext};
2651 use indoc::indoc;
2652 use settings::Settings;
2653 use util::path;
2654 use workspace::{OpenOptions, Workspace};
2655
2656 #[gpui::test]
2657 async fn test_command_basics(cx: &mut TestAppContext) {
2658 let mut cx = NeovimBackedTestContext::new(cx).await;
2659
2660 cx.set_shared_state(indoc! {"
2661 ˇa
2662 b
2663 c"})
2664 .await;
2665
2666 cx.simulate_shared_keystrokes(": j enter").await;
2667
2668 // hack: our cursor positioning after a join command is wrong
2669 cx.simulate_shared_keystrokes("^").await;
2670 cx.shared_state().await.assert_eq(indoc! {
2671 "ˇa b
2672 c"
2673 });
2674 }
2675
2676 #[gpui::test]
2677 async fn test_command_goto(cx: &mut TestAppContext) {
2678 let mut cx = NeovimBackedTestContext::new(cx).await;
2679
2680 cx.set_shared_state(indoc! {"
2681 ˇa
2682 b
2683 c"})
2684 .await;
2685 cx.simulate_shared_keystrokes(": 3 enter").await;
2686 cx.shared_state().await.assert_eq(indoc! {"
2687 a
2688 b
2689 ˇc"});
2690 }
2691
2692 #[gpui::test]
2693 async fn test_command_replace(cx: &mut TestAppContext) {
2694 let mut cx = NeovimBackedTestContext::new(cx).await;
2695
2696 cx.set_shared_state(indoc! {"
2697 ˇa
2698 b
2699 b
2700 c"})
2701 .await;
2702 cx.simulate_shared_keystrokes(": % s / b / d enter").await;
2703 cx.shared_state().await.assert_eq(indoc! {"
2704 a
2705 d
2706 ˇd
2707 c"});
2708 cx.simulate_shared_keystrokes(": % s : . : \\ 0 \\ 0 enter")
2709 .await;
2710 cx.shared_state().await.assert_eq(indoc! {"
2711 aa
2712 dd
2713 dd
2714 ˇcc"});
2715 cx.simulate_shared_keystrokes("k : s / d d / e e enter")
2716 .await;
2717 cx.shared_state().await.assert_eq(indoc! {"
2718 aa
2719 dd
2720 ˇee
2721 cc"});
2722 }
2723
2724 #[gpui::test]
2725 async fn test_command_search(cx: &mut TestAppContext) {
2726 let mut cx = NeovimBackedTestContext::new(cx).await;
2727
2728 cx.set_shared_state(indoc! {"
2729 ˇa
2730 b
2731 a
2732 c"})
2733 .await;
2734 cx.simulate_shared_keystrokes(": / b enter").await;
2735 cx.shared_state().await.assert_eq(indoc! {"
2736 a
2737 ˇb
2738 a
2739 c"});
2740 cx.simulate_shared_keystrokes(": ? a enter").await;
2741 cx.shared_state().await.assert_eq(indoc! {"
2742 ˇa
2743 b
2744 a
2745 c"});
2746 }
2747
2748 #[gpui::test]
2749 async fn test_command_write(cx: &mut TestAppContext) {
2750 let mut cx = VimTestContext::new(cx, true).await;
2751 let path = Path::new(path!("/root/dir/file.rs"));
2752 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2753
2754 cx.simulate_keystrokes("i @ escape");
2755 cx.simulate_keystrokes(": w enter");
2756
2757 assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@\n");
2758
2759 fs.as_fake().insert_file(path, b"oops\n".to_vec()).await;
2760
2761 // conflict!
2762 cx.simulate_keystrokes("i @ escape");
2763 cx.simulate_keystrokes(": w enter");
2764 cx.simulate_prompt_answer("Cancel");
2765
2766 assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "oops\n");
2767 assert!(!cx.has_pending_prompt());
2768 cx.simulate_keystrokes(": w !");
2769 cx.simulate_keystrokes("enter");
2770 assert!(!cx.has_pending_prompt());
2771 assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@@\n");
2772 }
2773
2774 #[gpui::test]
2775 async fn test_command_read(cx: &mut TestAppContext) {
2776 let mut cx = VimTestContext::new(cx, true).await;
2777
2778 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2779 let path = Path::new(path!("/root/dir/other.rs"));
2780 fs.as_fake().insert_file(path, "1\n2\n3".into()).await;
2781
2782 cx.workspace(|workspace, _, cx| {
2783 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
2784 });
2785
2786 // File without trailing newline
2787 cx.set_state("one\ntwo\nthreeˇ", Mode::Normal);
2788 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2789 cx.simulate_keystrokes("enter");
2790 cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3", Mode::Normal);
2791
2792 cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
2793 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2794 cx.simulate_keystrokes("enter");
2795 cx.assert_state("one\nˇ1\n2\n3\ntwo\nthree", Mode::Normal);
2796
2797 cx.set_state("one\nˇtwo\nthree", Mode::Normal);
2798 cx.simulate_keystrokes(": 0 r space d i r / o t h e r . r s");
2799 cx.simulate_keystrokes("enter");
2800 cx.assert_state("ˇ1\n2\n3\none\ntwo\nthree", Mode::Normal);
2801
2802 cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual);
2803 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2804 cx.simulate_keystrokes("enter");
2805 cx.run_until_parked();
2806 cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\nfive", Mode::Normal);
2807
2808 // Empty filename
2809 cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
2810 cx.simulate_keystrokes(": r");
2811 cx.simulate_keystrokes("enter");
2812 cx.assert_state("one\nˇone\ntwo\nthree\ntwo\nthree", Mode::Normal);
2813
2814 // File with trailing newline
2815 fs.as_fake().insert_file(path, "1\n2\n3\n".into()).await;
2816 cx.set_state("one\ntwo\nthreeˇ", Mode::Normal);
2817 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2818 cx.simulate_keystrokes("enter");
2819 cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal);
2820
2821 cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
2822 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2823 cx.simulate_keystrokes("enter");
2824 cx.assert_state("one\nˇ1\n2\n3\n\ntwo\nthree", Mode::Normal);
2825
2826 cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual);
2827 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2828 cx.simulate_keystrokes("enter");
2829 cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\n\nfive", Mode::Normal);
2830
2831 cx.set_state("«one\ntwo\nthreeˇ»", Mode::Visual);
2832 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2833 cx.simulate_keystrokes("enter");
2834 cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal);
2835
2836 // Empty file
2837 fs.as_fake().insert_file(path, "".into()).await;
2838 cx.set_state("ˇone\ntwo\nthree", Mode::Normal);
2839 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2840 cx.simulate_keystrokes("enter");
2841 cx.assert_state("one\nˇtwo\nthree", Mode::Normal);
2842 }
2843
2844 #[gpui::test]
2845 async fn test_command_quit(cx: &mut TestAppContext) {
2846 let mut cx = VimTestContext::new(cx, true).await;
2847
2848 cx.simulate_keystrokes(": n e w enter");
2849 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
2850 cx.simulate_keystrokes(": q enter");
2851 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
2852 cx.simulate_keystrokes(": n e w enter");
2853 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
2854 cx.simulate_keystrokes(": q a enter");
2855 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 0));
2856 }
2857
2858 #[gpui::test]
2859 async fn test_offsets(cx: &mut TestAppContext) {
2860 let mut cx = NeovimBackedTestContext::new(cx).await;
2861
2862 cx.set_shared_state("ˇ1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n")
2863 .await;
2864
2865 cx.simulate_shared_keystrokes(": + enter").await;
2866 cx.shared_state()
2867 .await
2868 .assert_eq("1\nˇ2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n");
2869
2870 cx.simulate_shared_keystrokes(": 1 0 - enter").await;
2871 cx.shared_state()
2872 .await
2873 .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\nˇ9\n10\n11\n");
2874
2875 cx.simulate_shared_keystrokes(": . - 2 enter").await;
2876 cx.shared_state()
2877 .await
2878 .assert_eq("1\n2\n3\n4\n5\n6\nˇ7\n8\n9\n10\n11\n");
2879
2880 cx.simulate_shared_keystrokes(": % enter").await;
2881 cx.shared_state()
2882 .await
2883 .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\nˇ");
2884 }
2885
2886 #[gpui::test]
2887 async fn test_command_ranges(cx: &mut TestAppContext) {
2888 let mut cx = NeovimBackedTestContext::new(cx).await;
2889
2890 cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
2891
2892 cx.simulate_shared_keystrokes(": 2 , 4 d enter").await;
2893 cx.shared_state().await.assert_eq("1\nˇ4\n3\n2\n1");
2894
2895 cx.simulate_shared_keystrokes(": 2 , 4 s o r t enter").await;
2896 cx.shared_state().await.assert_eq("1\nˇ2\n3\n4\n1");
2897
2898 cx.simulate_shared_keystrokes(": 2 , 4 j o i n enter").await;
2899 cx.shared_state().await.assert_eq("1\nˇ2 3 4\n1");
2900 }
2901
2902 #[gpui::test]
2903 async fn test_command_visual_replace(cx: &mut TestAppContext) {
2904 let mut cx = NeovimBackedTestContext::new(cx).await;
2905
2906 cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
2907
2908 cx.simulate_shared_keystrokes("v 2 j : s / . / k enter")
2909 .await;
2910 cx.shared_state().await.assert_eq("k\nk\nˇk\n4\n4\n3\n2\n1");
2911 }
2912
2913 #[track_caller]
2914 fn assert_active_item(
2915 workspace: &mut Workspace,
2916 expected_path: &str,
2917 expected_text: &str,
2918 cx: &mut Context<Workspace>,
2919 ) {
2920 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
2921
2922 let buffer = active_editor
2923 .read(cx)
2924 .buffer()
2925 .read(cx)
2926 .as_singleton()
2927 .unwrap();
2928
2929 let text = buffer.read(cx).text();
2930 let file = buffer.read(cx).file().unwrap();
2931 let file_path = file.as_local().unwrap().abs_path(cx);
2932
2933 assert_eq!(text, expected_text);
2934 assert_eq!(file_path, Path::new(expected_path));
2935 }
2936
2937 #[gpui::test]
2938 async fn test_command_gf(cx: &mut TestAppContext) {
2939 let mut cx = VimTestContext::new(cx, true).await;
2940
2941 // Assert base state, that we're in /root/dir/file.rs
2942 cx.workspace(|workspace, _, cx| {
2943 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
2944 });
2945
2946 // Insert a new file
2947 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2948 fs.as_fake()
2949 .insert_file(
2950 path!("/root/dir/file2.rs"),
2951 "This is file2.rs".as_bytes().to_vec(),
2952 )
2953 .await;
2954 fs.as_fake()
2955 .insert_file(
2956 path!("/root/dir/file3.rs"),
2957 "go to file3".as_bytes().to_vec(),
2958 )
2959 .await;
2960
2961 // Put the path to the second file into the currently open buffer
2962 cx.set_state(indoc! {"go to fiˇle2.rs"}, Mode::Normal);
2963
2964 // Go to file2.rs
2965 cx.simulate_keystrokes("g f");
2966
2967 // We now have two items
2968 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
2969 cx.workspace(|workspace, _, cx| {
2970 assert_active_item(
2971 workspace,
2972 path!("/root/dir/file2.rs"),
2973 "This is file2.rs",
2974 cx,
2975 );
2976 });
2977
2978 // Update editor to point to `file2.rs`
2979 cx.editor =
2980 cx.workspace(|workspace, _, cx| workspace.active_item_as::<Editor>(cx).unwrap());
2981
2982 // Put the path to the third file into the currently open buffer,
2983 // but remove its suffix, because we want that lookup to happen automatically.
2984 cx.set_state(indoc! {"go to fiˇle3"}, Mode::Normal);
2985
2986 // Go to file3.rs
2987 cx.simulate_keystrokes("g f");
2988
2989 // We now have three items
2990 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 3));
2991 cx.workspace(|workspace, _, cx| {
2992 assert_active_item(workspace, path!("/root/dir/file3.rs"), "go to file3", cx);
2993 });
2994 }
2995
2996 #[gpui::test]
2997 async fn test_command_write_filename(cx: &mut TestAppContext) {
2998 let mut cx = VimTestContext::new(cx, true).await;
2999
3000 cx.workspace(|workspace, _, cx| {
3001 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
3002 });
3003
3004 cx.simulate_keystrokes(": w space other.rs");
3005 cx.simulate_keystrokes("enter");
3006
3007 cx.workspace(|workspace, _, cx| {
3008 assert_active_item(workspace, path!("/root/other.rs"), "", cx);
3009 });
3010
3011 cx.simulate_keystrokes(": w space dir/file.rs");
3012 cx.simulate_keystrokes("enter");
3013
3014 cx.simulate_prompt_answer("Replace");
3015 cx.run_until_parked();
3016
3017 cx.workspace(|workspace, _, cx| {
3018 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
3019 });
3020
3021 cx.simulate_keystrokes(": w ! space other.rs");
3022 cx.simulate_keystrokes("enter");
3023
3024 cx.workspace(|workspace, _, cx| {
3025 assert_active_item(workspace, path!("/root/other.rs"), "", cx);
3026 });
3027 }
3028
3029 #[gpui::test]
3030 async fn test_command_write_range(cx: &mut TestAppContext) {
3031 let mut cx = VimTestContext::new(cx, true).await;
3032
3033 cx.workspace(|workspace, _, cx| {
3034 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
3035 });
3036
3037 cx.set_state(
3038 indoc! {"
3039 The quick
3040 brown« fox
3041 jumpsˇ» over
3042 the lazy dog
3043 "},
3044 Mode::Visual,
3045 );
3046
3047 cx.simulate_keystrokes(": w space dir/other.rs");
3048 cx.simulate_keystrokes("enter");
3049
3050 let other = path!("/root/dir/other.rs");
3051
3052 let _ = cx
3053 .workspace(|workspace, window, cx| {
3054 workspace.open_abs_path(PathBuf::from(other), OpenOptions::default(), window, cx)
3055 })
3056 .await;
3057
3058 cx.workspace(|workspace, _, cx| {
3059 assert_active_item(
3060 workspace,
3061 other,
3062 indoc! {"
3063 brown fox
3064 jumps over
3065 "},
3066 cx,
3067 );
3068 });
3069 }
3070
3071 #[gpui::test]
3072 async fn test_command_matching_lines(cx: &mut TestAppContext) {
3073 let mut cx = NeovimBackedTestContext::new(cx).await;
3074
3075 cx.set_shared_state(indoc! {"
3076 ˇa
3077 b
3078 a
3079 b
3080 a
3081 "})
3082 .await;
3083
3084 cx.simulate_shared_keystrokes(":").await;
3085 cx.simulate_shared_keystrokes("g / a / d").await;
3086 cx.simulate_shared_keystrokes("enter").await;
3087
3088 cx.shared_state().await.assert_eq(indoc! {"
3089 b
3090 b
3091 ˇ"});
3092
3093 cx.simulate_shared_keystrokes("u").await;
3094
3095 cx.shared_state().await.assert_eq(indoc! {"
3096 ˇa
3097 b
3098 a
3099 b
3100 a
3101 "});
3102
3103 cx.simulate_shared_keystrokes(":").await;
3104 cx.simulate_shared_keystrokes("v / a / d").await;
3105 cx.simulate_shared_keystrokes("enter").await;
3106
3107 cx.shared_state().await.assert_eq(indoc! {"
3108 a
3109 a
3110 ˇa"});
3111 }
3112
3113 #[gpui::test]
3114 async fn test_del_marks(cx: &mut TestAppContext) {
3115 let mut cx = NeovimBackedTestContext::new(cx).await;
3116
3117 cx.set_shared_state(indoc! {"
3118 ˇa
3119 b
3120 a
3121 b
3122 a
3123 "})
3124 .await;
3125
3126 cx.simulate_shared_keystrokes("m a").await;
3127
3128 let mark = cx.update_editor(|editor, window, cx| {
3129 let vim = editor.addon::<VimAddon>().unwrap().entity.clone();
3130 vim.update(cx, |vim, cx| vim.get_mark("a", editor, window, cx))
3131 });
3132 assert!(mark.is_some());
3133
3134 cx.simulate_shared_keystrokes(": d e l m space a").await;
3135 cx.simulate_shared_keystrokes("enter").await;
3136
3137 let mark = cx.update_editor(|editor, window, cx| {
3138 let vim = editor.addon::<VimAddon>().unwrap().entity.clone();
3139 vim.update(cx, |vim, cx| vim.get_mark("a", editor, window, cx))
3140 });
3141 assert!(mark.is_none())
3142 }
3143
3144 #[gpui::test]
3145 async fn test_normal_command(cx: &mut TestAppContext) {
3146 let mut cx = NeovimBackedTestContext::new(cx).await;
3147
3148 cx.set_shared_state(indoc! {"
3149 The quick
3150 brown« fox
3151 jumpsˇ» over
3152 the lazy dog
3153 "})
3154 .await;
3155
3156 cx.simulate_shared_keystrokes(": n o r m space w C w o r d")
3157 .await;
3158 cx.simulate_shared_keystrokes("enter").await;
3159
3160 cx.shared_state().await.assert_eq(indoc! {"
3161 The quick
3162 brown word
3163 jumps worˇd
3164 the lazy dog
3165 "});
3166
3167 cx.simulate_shared_keystrokes(": n o r m space _ w c i w t e s t")
3168 .await;
3169 cx.simulate_shared_keystrokes("enter").await;
3170
3171 cx.shared_state().await.assert_eq(indoc! {"
3172 The quick
3173 brown word
3174 jumps tesˇt
3175 the lazy dog
3176 "});
3177
3178 cx.simulate_shared_keystrokes("_ l v l : n o r m space s l a")
3179 .await;
3180 cx.simulate_shared_keystrokes("enter").await;
3181
3182 cx.shared_state().await.assert_eq(indoc! {"
3183 The quick
3184 brown word
3185 lˇaumps test
3186 the lazy dog
3187 "});
3188
3189 cx.set_shared_state(indoc! {"
3190 ˇThe quick
3191 brown fox
3192 jumps over
3193 the lazy dog
3194 "})
3195 .await;
3196
3197 cx.simulate_shared_keystrokes("c i w M y escape").await;
3198
3199 cx.shared_state().await.assert_eq(indoc! {"
3200 Mˇy quick
3201 brown fox
3202 jumps over
3203 the lazy dog
3204 "});
3205
3206 cx.simulate_shared_keystrokes(": n o r m space u").await;
3207 cx.simulate_shared_keystrokes("enter").await;
3208
3209 cx.shared_state().await.assert_eq(indoc! {"
3210 ˇThe quick
3211 brown fox
3212 jumps over
3213 the lazy dog
3214 "});
3215
3216 cx.set_shared_state(indoc! {"
3217 The« quick
3218 brownˇ» fox
3219 jumps over
3220 the lazy dog
3221 "})
3222 .await;
3223
3224 cx.simulate_shared_keystrokes(": n o r m space I 1 2 3")
3225 .await;
3226 cx.simulate_shared_keystrokes("enter").await;
3227 cx.simulate_shared_keystrokes("u").await;
3228
3229 cx.shared_state().await.assert_eq(indoc! {"
3230 ˇThe quick
3231 brown fox
3232 jumps over
3233 the lazy dog
3234 "});
3235
3236 cx.set_shared_state(indoc! {"
3237 ˇquick
3238 brown fox
3239 jumps over
3240 the lazy dog
3241 "})
3242 .await;
3243
3244 cx.simulate_shared_keystrokes(": n o r m space I T h e space")
3245 .await;
3246 cx.simulate_shared_keystrokes("enter").await;
3247
3248 cx.shared_state().await.assert_eq(indoc! {"
3249 Theˇ quick
3250 brown fox
3251 jumps over
3252 the lazy dog
3253 "});
3254
3255 // Once ctrl-v to input character literals is added there should be a test for redo
3256 }
3257
3258 #[gpui::test]
3259 async fn test_command_g_normal(cx: &mut TestAppContext) {
3260 let mut cx = NeovimBackedTestContext::new(cx).await;
3261
3262 cx.set_shared_state(indoc! {"
3263 ˇfoo
3264
3265 foo
3266 "})
3267 .await;
3268
3269 cx.simulate_shared_keystrokes(": % g / f o o / n o r m space A b a r")
3270 .await;
3271 cx.simulate_shared_keystrokes("enter").await;
3272 cx.run_until_parked();
3273
3274 cx.shared_state().await.assert_eq(indoc! {"
3275 foobar
3276
3277 foobaˇr
3278 "});
3279
3280 cx.simulate_shared_keystrokes("u").await;
3281
3282 cx.shared_state().await.assert_eq(indoc! {"
3283 foˇo
3284
3285 foo
3286 "});
3287 }
3288
3289 #[gpui::test]
3290 async fn test_command_tabnew(cx: &mut TestAppContext) {
3291 let mut cx = VimTestContext::new(cx, true).await;
3292
3293 // Create a new file to ensure that, when the filename is used with
3294 // `:tabnew`, it opens the existing file in a new tab.
3295 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
3296 fs.as_fake()
3297 .insert_file(path!("/root/dir/file_2.rs"), "file_2".as_bytes().to_vec())
3298 .await;
3299
3300 cx.simulate_keystrokes(": tabnew");
3301 cx.simulate_keystrokes("enter");
3302 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
3303
3304 // Assert that the new tab is empty and not associated with any file, as
3305 // no file path was provided to the `:tabnew` command.
3306 cx.workspace(|workspace, _window, cx| {
3307 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
3308 let buffer = active_editor
3309 .read(cx)
3310 .buffer()
3311 .read(cx)
3312 .as_singleton()
3313 .unwrap();
3314
3315 assert!(&buffer.read(cx).file().is_none());
3316 });
3317
3318 // Leverage the filename as an argument to the `:tabnew` command,
3319 // ensuring that the file, instead of an empty buffer, is opened in a
3320 // new tab.
3321 cx.simulate_keystrokes(": tabnew space dir/file_2.rs");
3322 cx.simulate_keystrokes("enter");
3323
3324 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 3));
3325 cx.workspace(|workspace, _, cx| {
3326 assert_active_item(workspace, path!("/root/dir/file_2.rs"), "file_2", cx);
3327 });
3328
3329 // If the `filename` argument provided to the `:tabnew` command is for a
3330 // file that doesn't yet exist, it should still associate the buffer
3331 // with that file path, so that when the buffer contents are saved, the
3332 // file is created.
3333 cx.simulate_keystrokes(": tabnew space dir/file_3.rs");
3334 cx.simulate_keystrokes("enter");
3335
3336 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 4));
3337 cx.workspace(|workspace, _, cx| {
3338 assert_active_item(workspace, path!("/root/dir/file_3.rs"), "", cx);
3339 });
3340 }
3341
3342 #[gpui::test]
3343 async fn test_command_tabedit(cx: &mut TestAppContext) {
3344 let mut cx = VimTestContext::new(cx, true).await;
3345
3346 // Create a new file to ensure that, when the filename is used with
3347 // `:tabedit`, it opens the existing file in a new tab.
3348 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
3349 fs.as_fake()
3350 .insert_file(path!("/root/dir/file_2.rs"), "file_2".as_bytes().to_vec())
3351 .await;
3352
3353 cx.simulate_keystrokes(": tabedit");
3354 cx.simulate_keystrokes("enter");
3355 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
3356
3357 // Assert that the new tab is empty and not associated with any file, as
3358 // no file path was provided to the `:tabedit` command.
3359 cx.workspace(|workspace, _window, cx| {
3360 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
3361 let buffer = active_editor
3362 .read(cx)
3363 .buffer()
3364 .read(cx)
3365 .as_singleton()
3366 .unwrap();
3367
3368 assert!(&buffer.read(cx).file().is_none());
3369 });
3370
3371 // Leverage the filename as an argument to the `:tabedit` command,
3372 // ensuring that the file, instead of an empty buffer, is opened in a
3373 // new tab.
3374 cx.simulate_keystrokes(": tabedit space dir/file_2.rs");
3375 cx.simulate_keystrokes("enter");
3376
3377 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 3));
3378 cx.workspace(|workspace, _, cx| {
3379 assert_active_item(workspace, path!("/root/dir/file_2.rs"), "file_2", cx);
3380 });
3381
3382 // If the `filename` argument provided to the `:tabedit` command is for a
3383 // file that doesn't yet exist, it should still associate the buffer
3384 // with that file path, so that when the buffer contents are saved, the
3385 // file is created.
3386 cx.simulate_keystrokes(": tabedit space dir/file_3.rs");
3387 cx.simulate_keystrokes("enter");
3388
3389 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 4));
3390 cx.workspace(|workspace, _, cx| {
3391 assert_active_item(workspace, path!("/root/dir/file_3.rs"), "", cx);
3392 });
3393 }
3394
3395 #[gpui::test]
3396 async fn test_ignorecase_command(cx: &mut TestAppContext) {
3397 let mut cx = VimTestContext::new(cx, true).await;
3398 cx.read(|cx| {
3399 assert_eq!(
3400 EditorSettings::get_global(cx).search.case_sensitive,
3401 false,
3402 "The `case_sensitive` setting should be `false` by default."
3403 );
3404 });
3405 cx.simulate_keystrokes(": set space noignorecase");
3406 cx.simulate_keystrokes("enter");
3407 cx.read(|cx| {
3408 assert_eq!(
3409 EditorSettings::get_global(cx).search.case_sensitive,
3410 true,
3411 "The `case_sensitive` setting should have been enabled with `:set noignorecase`."
3412 );
3413 });
3414 cx.simulate_keystrokes(": set space ignorecase");
3415 cx.simulate_keystrokes("enter");
3416 cx.read(|cx| {
3417 assert_eq!(
3418 EditorSettings::get_global(cx).search.case_sensitive,
3419 false,
3420 "The `case_sensitive` setting should have been disabled with `:set ignorecase`."
3421 );
3422 });
3423 cx.simulate_keystrokes(": set space noic");
3424 cx.simulate_keystrokes("enter");
3425 cx.read(|cx| {
3426 assert_eq!(
3427 EditorSettings::get_global(cx).search.case_sensitive,
3428 true,
3429 "The `case_sensitive` setting should have been enabled with `:set noic`."
3430 );
3431 });
3432 cx.simulate_keystrokes(": set space ic");
3433 cx.simulate_keystrokes("enter");
3434 cx.read(|cx| {
3435 assert_eq!(
3436 EditorSettings::get_global(cx).search.case_sensitive,
3437 false,
3438 "The `case_sensitive` setting should have been disabled with `:set ic`."
3439 );
3440 });
3441 }
3442
3443 #[gpui::test]
3444 async fn test_sort_commands(cx: &mut TestAppContext) {
3445 let mut cx = VimTestContext::new(cx, true).await;
3446
3447 cx.set_state(
3448 indoc! {"
3449 «hornet
3450 quirrel
3451 elderbug
3452 cornifer
3453 idaˇ»
3454 "},
3455 Mode::Visual,
3456 );
3457
3458 cx.simulate_keystrokes(": sort");
3459 cx.simulate_keystrokes("enter");
3460
3461 cx.assert_state(
3462 indoc! {"
3463 ˇcornifer
3464 elderbug
3465 hornet
3466 ida
3467 quirrel
3468 "},
3469 Mode::Normal,
3470 );
3471
3472 // Assert that, by default, `:sort` takes case into consideration.
3473 cx.set_state(
3474 indoc! {"
3475 «hornet
3476 quirrel
3477 Elderbug
3478 cornifer
3479 idaˇ»
3480 "},
3481 Mode::Visual,
3482 );
3483
3484 cx.simulate_keystrokes(": sort");
3485 cx.simulate_keystrokes("enter");
3486
3487 cx.assert_state(
3488 indoc! {"
3489 ˇElderbug
3490 cornifer
3491 hornet
3492 ida
3493 quirrel
3494 "},
3495 Mode::Normal,
3496 );
3497
3498 // Assert that, if the `i` option is passed, `:sort` ignores case.
3499 cx.set_state(
3500 indoc! {"
3501 «hornet
3502 quirrel
3503 Elderbug
3504 cornifer
3505 idaˇ»
3506 "},
3507 Mode::Visual,
3508 );
3509
3510 cx.simulate_keystrokes(": sort space i");
3511 cx.simulate_keystrokes("enter");
3512
3513 cx.assert_state(
3514 indoc! {"
3515 ˇcornifer
3516 Elderbug
3517 hornet
3518 ida
3519 quirrel
3520 "},
3521 Mode::Normal,
3522 );
3523
3524 // When no range is provided, sorts the whole buffer.
3525 cx.set_state(
3526 indoc! {"
3527 ˇhornet
3528 quirrel
3529 elderbug
3530 cornifer
3531 ida
3532 "},
3533 Mode::Normal,
3534 );
3535
3536 cx.simulate_keystrokes(": sort");
3537 cx.simulate_keystrokes("enter");
3538
3539 cx.assert_state(
3540 indoc! {"
3541 ˇcornifer
3542 elderbug
3543 hornet
3544 ida
3545 quirrel
3546 "},
3547 Mode::Normal,
3548 );
3549 }
3550
3551 #[gpui::test]
3552 async fn test_reflow(cx: &mut TestAppContext) {
3553 let mut cx = VimTestContext::new(cx, true).await;
3554
3555 cx.update_editor(|editor, _window, cx| {
3556 editor.set_hard_wrap(Some(10), cx);
3557 });
3558
3559 cx.set_state(
3560 indoc! {"
3561 ˇ0123456789 0123456789
3562 "},
3563 Mode::Normal,
3564 );
3565
3566 cx.simulate_keystrokes(": reflow");
3567 cx.simulate_keystrokes("enter");
3568
3569 cx.assert_state(
3570 indoc! {"
3571 0123456789
3572 ˇ0123456789
3573 "},
3574 Mode::Normal,
3575 );
3576
3577 cx.set_state(
3578 indoc! {"
3579 ˇ0123456789 0123456789
3580 "},
3581 Mode::VisualLine,
3582 );
3583
3584 cx.simulate_keystrokes("shift-v : reflow");
3585 cx.simulate_keystrokes("enter");
3586
3587 cx.assert_state(
3588 indoc! {"
3589 0123456789
3590 ˇ0123456789
3591 "},
3592 Mode::Normal,
3593 );
3594
3595 cx.set_state(
3596 indoc! {"
3597 ˇ0123 4567 0123 4567
3598 "},
3599 Mode::VisualLine,
3600 );
3601
3602 cx.simulate_keystrokes(": reflow space 7");
3603 cx.simulate_keystrokes("enter");
3604
3605 cx.assert_state(
3606 indoc! {"
3607 ˇ0123
3608 4567
3609 0123
3610 4567
3611 "},
3612 Mode::Normal,
3613 );
3614
3615 // Assert that, if `:reflow` is invoked with an invalid argument, it
3616 // does not actually have any effect in the buffer's contents.
3617 cx.set_state(
3618 indoc! {"
3619 ˇ0123 4567 0123 4567
3620 "},
3621 Mode::VisualLine,
3622 );
3623
3624 cx.simulate_keystrokes(": reflow space a");
3625 cx.simulate_keystrokes("enter");
3626
3627 cx.assert_state(
3628 indoc! {"
3629 ˇ0123 4567 0123 4567
3630 "},
3631 Mode::VisualLine,
3632 );
3633 }
3634}