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