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