1use anyhow::Result;
2use collections::{HashMap, HashSet};
3use command_palette_hooks::CommandInterceptResult;
4use editor::{
5 Bias, Editor, ToPoint,
6 actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
7 display_map::ToDisplayPoint,
8 scroll::Autoscroll,
9};
10use gpui::{Action, App, AppContext as _, Context, Global, Window, actions};
11use itertools::Itertools;
12use language::Point;
13use multi_buffer::MultiBufferRow;
14use project::ProjectPath;
15use regex::Regex;
16use schemars::JsonSchema;
17use search::{BufferSearchBar, SearchOptions};
18use serde::Deserialize;
19use std::{
20 io::Write,
21 iter::Peekable,
22 ops::{Deref, Range},
23 path::Path,
24 process::Stdio,
25 str::Chars,
26 sync::{Arc, OnceLock},
27 time::Instant,
28};
29use task::{HideStrategy, RevealStrategy, SpawnInTerminal, TaskId};
30use ui::ActiveTheme;
31use util::ResultExt;
32use workspace::notifications::DetachAndPromptErr;
33use workspace::{Item, SaveIntent, notifications::NotifyResultExt};
34use zed_actions::{OpenDocs, RevealTarget};
35
36use crate::{
37 ToggleMarksView, ToggleRegistersView, Vim,
38 motion::{EndOfDocument, Motion, MotionKind, StartOfDocument},
39 normal::{
40 JoinLines,
41 search::{FindCommand, ReplaceCommand, Replacement},
42 },
43 object::Object,
44 state::{Mark, Mode},
45 visual::VisualDeleteLine,
46};
47
48#[derive(Clone, Debug, PartialEq, Action)]
49#[action(namespace = vim, no_json, no_register)]
50pub struct GoToLine {
51 range: CommandRange,
52}
53
54#[derive(Clone, Debug, PartialEq, Action)]
55#[action(namespace = vim, no_json, no_register)]
56pub struct YankCommand {
57 range: CommandRange,
58}
59
60#[derive(Clone, Debug, PartialEq, Action)]
61#[action(namespace = vim, no_json, no_register)]
62pub struct WithRange {
63 restore_selection: bool,
64 range: CommandRange,
65 action: WrappedAction,
66}
67
68#[derive(Clone, Debug, PartialEq, Action)]
69#[action(namespace = vim, no_json, no_register)]
70pub struct WithCount {
71 count: u32,
72 action: WrappedAction,
73}
74
75#[derive(Clone, Deserialize, JsonSchema, PartialEq)]
76pub enum VimOption {
77 Wrap(bool),
78 Number(bool),
79 RelativeNumber(bool),
80}
81
82impl VimOption {
83 fn possible_commands(query: &str) -> Vec<CommandInterceptResult> {
84 let mut prefix_of_options = Vec::new();
85 let mut options = query.split(" ").collect::<Vec<_>>();
86 let prefix = options.pop().unwrap_or_default();
87 for option in options {
88 if let Some(opt) = Self::from(option) {
89 prefix_of_options.push(opt)
90 } else {
91 return vec![];
92 }
93 }
94
95 Self::possibilities(&prefix)
96 .map(|possible| {
97 let mut options = prefix_of_options.clone();
98 options.push(possible);
99
100 CommandInterceptResult {
101 string: format!(
102 ":set {}",
103 options.iter().map(|opt| opt.to_string()).join(" ")
104 ),
105 action: VimSet { options }.boxed_clone(),
106 positions: vec![],
107 }
108 })
109 .collect()
110 }
111
112 fn possibilities(query: &str) -> impl Iterator<Item = Self> + '_ {
113 [
114 (None, VimOption::Wrap(true)),
115 (None, VimOption::Wrap(false)),
116 (None, VimOption::Number(true)),
117 (None, VimOption::Number(false)),
118 (None, VimOption::RelativeNumber(true)),
119 (None, VimOption::RelativeNumber(false)),
120 (Some("rnu"), VimOption::RelativeNumber(true)),
121 (Some("nornu"), VimOption::RelativeNumber(false)),
122 ]
123 .into_iter()
124 .filter(move |(prefix, option)| prefix.unwrap_or(option.to_string()).starts_with(query))
125 .map(|(_, option)| option)
126 }
127
128 fn from(option: &str) -> Option<Self> {
129 match option {
130 "wrap" => Some(Self::Wrap(true)),
131 "nowrap" => Some(Self::Wrap(false)),
132
133 "number" => Some(Self::Number(true)),
134 "nu" => Some(Self::Number(true)),
135 "nonumber" => Some(Self::Number(false)),
136 "nonu" => Some(Self::Number(false)),
137
138 "relativenumber" => Some(Self::RelativeNumber(true)),
139 "rnu" => Some(Self::RelativeNumber(true)),
140 "norelativenumber" => Some(Self::RelativeNumber(false)),
141 "nornu" => Some(Self::RelativeNumber(false)),
142
143 _ => None,
144 }
145 }
146
147 fn to_string(&self) -> &'static str {
148 match self {
149 VimOption::Wrap(true) => "wrap",
150 VimOption::Wrap(false) => "nowrap",
151 VimOption::Number(true) => "number",
152 VimOption::Number(false) => "nonumber",
153 VimOption::RelativeNumber(true) => "relativenumber",
154 VimOption::RelativeNumber(false) => "norelativenumber",
155 }
156 }
157}
158
159#[derive(Clone, PartialEq, Action)]
160#[action(namespace = vim, no_json, no_register)]
161pub struct VimSet {
162 options: Vec<VimOption>,
163}
164
165#[derive(Clone, PartialEq, Action)]
166#[action(namespace = vim, no_json, no_register)]
167struct VimSave {
168 pub save_intent: Option<SaveIntent>,
169 pub filename: String,
170}
171
172#[derive(Clone, PartialEq, Action)]
173#[action(namespace = vim, no_json, no_register)]
174enum DeleteMarks {
175 Marks(String),
176 AllLocal,
177}
178
179actions!(
180 vim,
181 [VisualCommand, CountCommand, ShellCommand, ArgumentRequired]
182);
183#[derive(Clone, PartialEq, Action)]
184#[action(namespace = vim, no_json, no_register)]
185struct VimEdit {
186 pub filename: String,
187}
188
189#[derive(Debug)]
190struct WrappedAction(Box<dyn Action>);
191
192impl PartialEq for WrappedAction {
193 fn eq(&self, other: &Self) -> bool {
194 self.0.partial_eq(&*other.0)
195 }
196}
197
198impl Clone for WrappedAction {
199 fn clone(&self) -> Self {
200 Self(self.0.boxed_clone())
201 }
202}
203
204impl Deref for WrappedAction {
205 type Target = dyn Action;
206 fn deref(&self) -> &dyn Action {
207 &*self.0
208 }
209}
210
211pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
212 // Vim::action(editor, cx, |vim, action: &StartOfLine, window, cx| {
213 Vim::action(editor, cx, |vim, action: &VimSet, window, cx| {
214 for option in action.options.iter() {
215 vim.update_editor(window, cx, |_, editor, _, cx| match option {
216 VimOption::Wrap(true) => {
217 editor
218 .set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx);
219 }
220 VimOption::Wrap(false) => {
221 editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx);
222 }
223 VimOption::Number(enabled) => {
224 editor.set_show_line_numbers(*enabled, cx);
225 }
226 VimOption::RelativeNumber(enabled) => {
227 editor.set_relative_line_number(Some(*enabled), cx);
228 }
229 });
230 }
231 });
232 Vim::action(editor, cx, |vim, _: &VisualCommand, window, cx| {
233 let Some(workspace) = vim.workspace(window) else {
234 return;
235 };
236 workspace.update(cx, |workspace, cx| {
237 command_palette::CommandPalette::toggle(workspace, "'<,'>", window, cx);
238 })
239 });
240
241 Vim::action(editor, cx, |vim, _: &ShellCommand, window, cx| {
242 let Some(workspace) = vim.workspace(window) else {
243 return;
244 };
245 workspace.update(cx, |workspace, cx| {
246 command_palette::CommandPalette::toggle(workspace, "'<,'>!", window, cx);
247 })
248 });
249
250 Vim::action(editor, cx, |_, _: &ArgumentRequired, window, cx| {
251 let _ = window.prompt(
252 gpui::PromptLevel::Critical,
253 "Argument required",
254 None,
255 &["Cancel"],
256 cx,
257 );
258 });
259
260 Vim::action(editor, cx, |vim, _: &ShellCommand, window, cx| {
261 let Some(workspace) = vim.workspace(window) else {
262 return;
263 };
264 workspace.update(cx, |workspace, cx| {
265 command_palette::CommandPalette::toggle(workspace, "'<,'>!", window, cx);
266 })
267 });
268
269 Vim::action(editor, cx, |vim, action: &VimSave, window, cx| {
270 vim.update_editor(window, cx, |_, editor, window, cx| {
271 let Some(project) = editor.project.clone() else {
272 return;
273 };
274 let Some(worktree) = project.read(cx).visible_worktrees(cx).next() else {
275 return;
276 };
277 let project_path = ProjectPath {
278 worktree_id: worktree.read(cx).id(),
279 path: Arc::from(Path::new(&action.filename)),
280 };
281
282 if project.read(cx).entry_for_path(&project_path, cx).is_some() && action.save_intent != Some(SaveIntent::Overwrite) {
283 let answer = window.prompt(
284 gpui::PromptLevel::Critical,
285 &format!("{} already exists. Do you want to replace it?", project_path.path.to_string_lossy()),
286 Some(
287 "A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
288 ),
289 &["Replace", "Cancel"],
290 cx);
291 cx.spawn_in(window, async move |editor, cx| {
292 if answer.await.ok() != Some(0) {
293 return;
294 }
295
296 let _ = editor.update_in(cx, |editor, window, cx|{
297 editor
298 .save_as(project, project_path, window, cx)
299 .detach_and_prompt_err("Failed to :w", window, cx, |_, _, _| None);
300 });
301 }).detach();
302 } else {
303 editor
304 .save_as(project, project_path, window, cx)
305 .detach_and_prompt_err("Failed to :w", window, cx, |_, _, _| None);
306 }
307 });
308 });
309
310 Vim::action(editor, cx, |vim, action: &DeleteMarks, window, cx| {
311 fn err(s: String, window: &mut Window, cx: &mut Context<Editor>) {
312 let _ = window.prompt(
313 gpui::PromptLevel::Critical,
314 &format!("Invalid argument: {}", s),
315 None,
316 &["Cancel"],
317 cx,
318 );
319 }
320 vim.update_editor(window, cx, |vim, editor, window, cx| match action {
321 DeleteMarks::Marks(s) => {
322 if s.starts_with('-') || s.ends_with('-') || s.contains(['\'', '`']) {
323 err(s.clone(), window, cx);
324 return;
325 }
326
327 let to_delete = if s.len() < 3 {
328 Some(s.clone())
329 } else {
330 s.chars()
331 .tuple_windows::<(_, _, _)>()
332 .map(|(a, b, c)| {
333 if b == '-' {
334 if match a {
335 'a'..='z' => a <= c && c <= 'z',
336 'A'..='Z' => a <= c && c <= 'Z',
337 '0'..='9' => a <= c && c <= '9',
338 _ => false,
339 } {
340 Some((a..=c).collect_vec())
341 } else {
342 None
343 }
344 } else if a == '-' {
345 if c == '-' { None } else { Some(vec![c]) }
346 } else if c == '-' {
347 if a == '-' { None } else { Some(vec![a]) }
348 } else {
349 Some(vec![a, b, c])
350 }
351 })
352 .fold_options(HashSet::<char>::default(), |mut set, chars| {
353 set.extend(chars.iter().copied());
354 set
355 })
356 .map(|set| set.iter().collect::<String>())
357 };
358
359 let Some(to_delete) = to_delete else {
360 err(s.clone(), window, cx);
361 return;
362 };
363
364 for c in to_delete.chars().filter(|c| !c.is_whitespace()) {
365 vim.delete_mark(c.to_string(), editor, window, cx);
366 }
367 }
368 DeleteMarks::AllLocal => {
369 for s in 'a'..='z' {
370 vim.delete_mark(s.to_string(), editor, window, cx);
371 }
372 }
373 });
374 });
375
376 Vim::action(editor, cx, |vim, action: &VimEdit, window, cx| {
377 vim.update_editor(window, cx, |vim, editor, window, cx| {
378 let Some(workspace) = vim.workspace(window) else {
379 return;
380 };
381 let Some(project) = editor.project.clone() else {
382 return;
383 };
384 let Some(worktree) = project.read(cx).visible_worktrees(cx).next() else {
385 return;
386 };
387 let project_path = ProjectPath {
388 worktree_id: worktree.read(cx).id(),
389 path: Arc::from(Path::new(&action.filename)),
390 };
391
392 let _ = workspace.update(cx, |workspace, cx| {
393 workspace
394 .open_path(project_path, None, true, window, cx)
395 .detach_and_log_err(cx);
396 });
397 });
398 });
399
400 Vim::action(editor, cx, |vim, _: &CountCommand, window, cx| {
401 let Some(workspace) = vim.workspace(window) else {
402 return;
403 };
404 let count = Vim::take_count(cx).unwrap_or(1);
405 Vim::take_forced_motion(cx);
406 let n = if count > 1 {
407 format!(".,.+{}", count.saturating_sub(1))
408 } else {
409 ".".to_string()
410 };
411 workspace.update(cx, |workspace, cx| {
412 command_palette::CommandPalette::toggle(workspace, &n, window, cx);
413 })
414 });
415
416 Vim::action(editor, cx, |vim, action: &GoToLine, window, cx| {
417 vim.switch_mode(Mode::Normal, false, window, cx);
418 let result = vim.update_editor(window, cx, |vim, editor, window, cx| {
419 let snapshot = editor.snapshot(window, cx);
420 let buffer_row = action.range.head().buffer_row(vim, editor, window, cx)?;
421 let current = editor.selections.newest::<Point>(cx);
422 let target = snapshot
423 .buffer_snapshot
424 .clip_point(Point::new(buffer_row.0, current.head().column), Bias::Left);
425 editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
426 s.select_ranges([target..target]);
427 });
428
429 anyhow::Ok(())
430 });
431 if let Some(e @ Err(_)) = result {
432 let Some(workspace) = vim.workspace(window) else {
433 return;
434 };
435 workspace.update(cx, |workspace, cx| {
436 e.notify_err(workspace, cx);
437 });
438 return;
439 }
440 });
441
442 Vim::action(editor, cx, |vim, action: &YankCommand, window, cx| {
443 vim.update_editor(window, cx, |vim, editor, window, cx| {
444 let snapshot = editor.snapshot(window, cx);
445 if let Ok(range) = action.range.buffer_range(vim, editor, window, cx) {
446 let end = if range.end < snapshot.buffer_snapshot.max_row() {
447 Point::new(range.end.0 + 1, 0)
448 } else {
449 snapshot.buffer_snapshot.max_point()
450 };
451 vim.copy_ranges(
452 editor,
453 MotionKind::Linewise,
454 true,
455 vec![Point::new(range.start.0, 0)..end],
456 window,
457 cx,
458 )
459 }
460 });
461 });
462
463 Vim::action(editor, cx, |_, action: &WithCount, window, cx| {
464 for _ in 0..action.count {
465 window.dispatch_action(action.action.boxed_clone(), cx)
466 }
467 });
468
469 Vim::action(editor, cx, |vim, action: &WithRange, window, cx| {
470 let result = vim.update_editor(window, cx, |vim, editor, window, cx| {
471 action.range.buffer_range(vim, editor, window, cx)
472 });
473
474 let range = match result {
475 None => return,
476 Some(e @ Err(_)) => {
477 let Some(workspace) = vim.workspace(window) else {
478 return;
479 };
480 workspace.update(cx, |workspace, cx| {
481 e.notify_err(workspace, cx);
482 });
483 return;
484 }
485 Some(Ok(result)) => result,
486 };
487
488 let previous_selections = vim
489 .update_editor(window, cx, |_, editor, window, cx| {
490 let selections = action.restore_selection.then(|| {
491 editor
492 .selections
493 .disjoint_anchor_ranges()
494 .collect::<Vec<_>>()
495 });
496 editor.change_selections(None, window, cx, |s| {
497 let end = Point::new(range.end.0, s.buffer().line_len(range.end));
498 s.select_ranges([end..Point::new(range.start.0, 0)]);
499 });
500 selections
501 })
502 .flatten();
503 window.dispatch_action(action.action.boxed_clone(), cx);
504 cx.defer_in(window, move |vim, window, cx| {
505 vim.update_editor(window, cx, |_, editor, window, cx| {
506 editor.change_selections(None, window, cx, |s| {
507 if let Some(previous_selections) = previous_selections {
508 s.select_ranges(previous_selections);
509 } else {
510 s.select_ranges([
511 Point::new(range.start.0, 0)..Point::new(range.start.0, 0)
512 ]);
513 }
514 })
515 });
516 });
517 });
518
519 Vim::action(editor, cx, |vim, action: &OnMatchingLines, window, cx| {
520 action.run(vim, window, cx)
521 });
522
523 Vim::action(editor, cx, |vim, action: &ShellExec, window, cx| {
524 action.run(vim, window, cx)
525 })
526}
527
528#[derive(Default)]
529struct VimCommand {
530 prefix: &'static str,
531 suffix: &'static str,
532 action: Option<Box<dyn Action>>,
533 action_name: Option<&'static str>,
534 bang_action: Option<Box<dyn Action>>,
535 args: Option<
536 Box<dyn Fn(Box<dyn Action>, String) -> Option<Box<dyn Action>> + Send + Sync + 'static>,
537 >,
538 range: Option<
539 Box<
540 dyn Fn(Box<dyn Action>, &CommandRange) -> Option<Box<dyn Action>>
541 + Send
542 + Sync
543 + 'static,
544 >,
545 >,
546 has_count: bool,
547}
548
549impl VimCommand {
550 fn new(pattern: (&'static str, &'static str), action: impl Action) -> Self {
551 Self {
552 prefix: pattern.0,
553 suffix: pattern.1,
554 action: Some(action.boxed_clone()),
555 ..Default::default()
556 }
557 }
558
559 // from_str is used for actions in other crates.
560 fn str(pattern: (&'static str, &'static str), action_name: &'static str) -> Self {
561 Self {
562 prefix: pattern.0,
563 suffix: pattern.1,
564 action_name: Some(action_name),
565 ..Default::default()
566 }
567 }
568
569 fn bang(mut self, bang_action: impl Action) -> Self {
570 self.bang_action = Some(bang_action.boxed_clone());
571 self
572 }
573
574 fn args(
575 mut self,
576 f: impl Fn(Box<dyn Action>, String) -> Option<Box<dyn Action>> + Send + Sync + 'static,
577 ) -> Self {
578 self.args = Some(Box::new(f));
579 self
580 }
581
582 fn range(
583 mut self,
584 f: impl Fn(Box<dyn Action>, &CommandRange) -> Option<Box<dyn Action>> + Send + Sync + 'static,
585 ) -> Self {
586 self.range = Some(Box::new(f));
587 self
588 }
589
590 fn count(mut self) -> Self {
591 self.has_count = true;
592 self
593 }
594
595 fn parse(
596 &self,
597 query: &str,
598 range: &Option<CommandRange>,
599 cx: &App,
600 ) -> Option<Box<dyn Action>> {
601 let rest = query
602 .to_string()
603 .strip_prefix(self.prefix)?
604 .to_string()
605 .chars()
606 .zip_longest(self.suffix.to_string().chars())
607 .skip_while(|e| e.clone().both().map(|(s, q)| s == q).unwrap_or(false))
608 .filter_map(|e| e.left())
609 .collect::<String>();
610 let has_bang = rest.starts_with('!');
611 let args = if has_bang {
612 rest.strip_prefix('!')?.trim().to_string()
613 } else if rest.is_empty() {
614 "".into()
615 } else {
616 rest.strip_prefix(' ')?.trim().to_string()
617 };
618
619 let action = if has_bang && self.bang_action.is_some() {
620 self.bang_action.as_ref().unwrap().boxed_clone()
621 } else if let Some(action) = self.action.as_ref() {
622 action.boxed_clone()
623 } else if let Some(action_name) = self.action_name {
624 cx.build_action(action_name, None).log_err()?
625 } else {
626 return None;
627 };
628 if !args.is_empty() {
629 // if command does not accept args and we have args then we should do no action
630 if let Some(args_fn) = &self.args {
631 args_fn.deref()(action, args)
632 } else {
633 None
634 }
635 } else if let Some(range) = range {
636 self.range.as_ref().and_then(|f| f(action, range))
637 } else {
638 Some(action)
639 }
640 }
641
642 // TODO: ranges with search queries
643 fn parse_range(query: &str) -> (Option<CommandRange>, String) {
644 let mut chars = query.chars().peekable();
645
646 match chars.peek() {
647 Some('%') => {
648 chars.next();
649 return (
650 Some(CommandRange {
651 start: Position::Line { row: 1, offset: 0 },
652 end: Some(Position::LastLine { offset: 0 }),
653 }),
654 chars.collect(),
655 );
656 }
657 Some('*') => {
658 chars.next();
659 return (
660 Some(CommandRange {
661 start: Position::Mark {
662 name: '<',
663 offset: 0,
664 },
665 end: Some(Position::Mark {
666 name: '>',
667 offset: 0,
668 }),
669 }),
670 chars.collect(),
671 );
672 }
673 _ => {}
674 }
675
676 let start = Self::parse_position(&mut chars);
677
678 match chars.peek() {
679 Some(',' | ';') => {
680 chars.next();
681 (
682 Some(CommandRange {
683 start: start.unwrap_or(Position::CurrentLine { offset: 0 }),
684 end: Self::parse_position(&mut chars),
685 }),
686 chars.collect(),
687 )
688 }
689 _ => (
690 start.map(|start| CommandRange { start, end: None }),
691 chars.collect(),
692 ),
693 }
694 }
695
696 fn parse_position(chars: &mut Peekable<Chars>) -> Option<Position> {
697 match chars.peek()? {
698 '0'..='9' => {
699 let row = Self::parse_u32(chars);
700 Some(Position::Line {
701 row,
702 offset: Self::parse_offset(chars),
703 })
704 }
705 '\'' => {
706 chars.next();
707 let name = chars.next()?;
708 Some(Position::Mark {
709 name,
710 offset: Self::parse_offset(chars),
711 })
712 }
713 '.' => {
714 chars.next();
715 Some(Position::CurrentLine {
716 offset: Self::parse_offset(chars),
717 })
718 }
719 '+' | '-' => Some(Position::CurrentLine {
720 offset: Self::parse_offset(chars),
721 }),
722 '$' => {
723 chars.next();
724 Some(Position::LastLine {
725 offset: Self::parse_offset(chars),
726 })
727 }
728 _ => None,
729 }
730 }
731
732 fn parse_offset(chars: &mut Peekable<Chars>) -> i32 {
733 let mut res: i32 = 0;
734 while matches!(chars.peek(), Some('+' | '-')) {
735 let sign = if chars.next().unwrap() == '+' { 1 } else { -1 };
736 let amount = if matches!(chars.peek(), Some('0'..='9')) {
737 (Self::parse_u32(chars) as i32).saturating_mul(sign)
738 } else {
739 sign
740 };
741 res = res.saturating_add(amount)
742 }
743 res
744 }
745
746 fn parse_u32(chars: &mut Peekable<Chars>) -> u32 {
747 let mut res: u32 = 0;
748 while matches!(chars.peek(), Some('0'..='9')) {
749 res = res
750 .saturating_mul(10)
751 .saturating_add(chars.next().unwrap() as u32 - '0' as u32);
752 }
753 res
754 }
755}
756
757#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)]
758enum Position {
759 Line { row: u32, offset: i32 },
760 Mark { name: char, offset: i32 },
761 LastLine { offset: i32 },
762 CurrentLine { offset: i32 },
763}
764
765impl Position {
766 fn buffer_row(
767 &self,
768 vim: &Vim,
769 editor: &mut Editor,
770 window: &mut Window,
771 cx: &mut App,
772 ) -> Result<MultiBufferRow> {
773 let snapshot = editor.snapshot(window, cx);
774 let target = match self {
775 Position::Line { row, offset } => {
776 if let Some(anchor) = editor.active_excerpt(cx).and_then(|(_, buffer, _)| {
777 editor.buffer().read(cx).buffer_point_to_anchor(
778 &buffer,
779 Point::new(row.saturating_sub(1), 0),
780 cx,
781 )
782 }) {
783 anchor
784 .to_point(&snapshot.buffer_snapshot)
785 .row
786 .saturating_add_signed(*offset)
787 } else {
788 row.saturating_add_signed(offset.saturating_sub(1))
789 }
790 }
791 Position::Mark { name, offset } => {
792 let Some(Mark::Local(anchors)) =
793 vim.get_mark(&name.to_string(), editor, window, cx)
794 else {
795 anyhow::bail!("mark {name} not set");
796 };
797 let Some(mark) = anchors.last() else {
798 anyhow::bail!("mark {name} contains empty anchors");
799 };
800 mark.to_point(&snapshot.buffer_snapshot)
801 .row
802 .saturating_add_signed(*offset)
803 }
804 Position::LastLine { offset } => snapshot
805 .buffer_snapshot
806 .max_row()
807 .0
808 .saturating_add_signed(*offset),
809 Position::CurrentLine { offset } => editor
810 .selections
811 .newest_anchor()
812 .head()
813 .to_point(&snapshot.buffer_snapshot)
814 .row
815 .saturating_add_signed(*offset),
816 };
817
818 Ok(MultiBufferRow(target).min(snapshot.buffer_snapshot.max_row()))
819 }
820}
821
822#[derive(Clone, Debug, PartialEq)]
823pub(crate) struct CommandRange {
824 start: Position,
825 end: Option<Position>,
826}
827
828impl CommandRange {
829 fn head(&self) -> &Position {
830 self.end.as_ref().unwrap_or(&self.start)
831 }
832
833 pub(crate) fn buffer_range(
834 &self,
835 vim: &Vim,
836 editor: &mut Editor,
837 window: &mut Window,
838 cx: &mut App,
839 ) -> Result<Range<MultiBufferRow>> {
840 let start = self.start.buffer_row(vim, editor, window, cx)?;
841 let end = if let Some(end) = self.end.as_ref() {
842 end.buffer_row(vim, editor, window, cx)?
843 } else {
844 start
845 };
846 if end < start {
847 anyhow::Ok(end..start)
848 } else {
849 anyhow::Ok(start..end)
850 }
851 }
852
853 pub fn as_count(&self) -> Option<u32> {
854 if let CommandRange {
855 start: Position::Line { row, offset: 0 },
856 end: None,
857 } = &self
858 {
859 Some(*row)
860 } else {
861 None
862 }
863 }
864}
865
866fn generate_commands(_: &App) -> Vec<VimCommand> {
867 vec![
868 VimCommand::new(
869 ("w", "rite"),
870 workspace::Save {
871 save_intent: Some(SaveIntent::Save),
872 },
873 )
874 .bang(workspace::Save {
875 save_intent: Some(SaveIntent::Overwrite),
876 })
877 .args(|action, args| {
878 Some(
879 VimSave {
880 save_intent: action
881 .as_any()
882 .downcast_ref::<workspace::Save>()
883 .and_then(|action| action.save_intent),
884 filename: args,
885 }
886 .boxed_clone(),
887 )
888 }),
889 VimCommand::new(
890 ("q", "uit"),
891 workspace::CloseActiveItem {
892 save_intent: Some(SaveIntent::Close),
893 close_pinned: false,
894 },
895 )
896 .bang(workspace::CloseActiveItem {
897 save_intent: Some(SaveIntent::Skip),
898 close_pinned: true,
899 }),
900 VimCommand::new(
901 ("wq", ""),
902 workspace::CloseActiveItem {
903 save_intent: Some(SaveIntent::Save),
904 close_pinned: false,
905 },
906 )
907 .bang(workspace::CloseActiveItem {
908 save_intent: Some(SaveIntent::Overwrite),
909 close_pinned: true,
910 }),
911 VimCommand::new(
912 ("x", "it"),
913 workspace::CloseActiveItem {
914 save_intent: Some(SaveIntent::SaveAll),
915 close_pinned: false,
916 },
917 )
918 .bang(workspace::CloseActiveItem {
919 save_intent: Some(SaveIntent::Overwrite),
920 close_pinned: true,
921 }),
922 VimCommand::new(
923 ("exi", "t"),
924 workspace::CloseActiveItem {
925 save_intent: Some(SaveIntent::SaveAll),
926 close_pinned: false,
927 },
928 )
929 .bang(workspace::CloseActiveItem {
930 save_intent: Some(SaveIntent::Overwrite),
931 close_pinned: true,
932 }),
933 VimCommand::new(
934 ("up", "date"),
935 workspace::Save {
936 save_intent: Some(SaveIntent::SaveAll),
937 },
938 ),
939 VimCommand::new(
940 ("wa", "ll"),
941 workspace::SaveAll {
942 save_intent: Some(SaveIntent::SaveAll),
943 },
944 )
945 .bang(workspace::SaveAll {
946 save_intent: Some(SaveIntent::Overwrite),
947 }),
948 VimCommand::new(
949 ("qa", "ll"),
950 workspace::CloseAllItemsAndPanes {
951 save_intent: Some(SaveIntent::Close),
952 },
953 )
954 .bang(workspace::CloseAllItemsAndPanes {
955 save_intent: Some(SaveIntent::Skip),
956 }),
957 VimCommand::new(
958 ("quita", "ll"),
959 workspace::CloseAllItemsAndPanes {
960 save_intent: Some(SaveIntent::Close),
961 },
962 )
963 .bang(workspace::CloseAllItemsAndPanes {
964 save_intent: Some(SaveIntent::Skip),
965 }),
966 VimCommand::new(
967 ("xa", "ll"),
968 workspace::CloseAllItemsAndPanes {
969 save_intent: Some(SaveIntent::SaveAll),
970 },
971 )
972 .bang(workspace::CloseAllItemsAndPanes {
973 save_intent: Some(SaveIntent::Overwrite),
974 }),
975 VimCommand::new(
976 ("wqa", "ll"),
977 workspace::CloseAllItemsAndPanes {
978 save_intent: Some(SaveIntent::SaveAll),
979 },
980 )
981 .bang(workspace::CloseAllItemsAndPanes {
982 save_intent: Some(SaveIntent::Overwrite),
983 }),
984 VimCommand::new(("cq", "uit"), zed_actions::Quit),
985 VimCommand::new(("sp", "lit"), workspace::SplitHorizontal),
986 VimCommand::new(("vs", "plit"), workspace::SplitVertical),
987 VimCommand::new(
988 ("bd", "elete"),
989 workspace::CloseActiveItem {
990 save_intent: Some(SaveIntent::Close),
991 close_pinned: false,
992 },
993 )
994 .bang(workspace::CloseActiveItem {
995 save_intent: Some(SaveIntent::Skip),
996 close_pinned: true,
997 }),
998 VimCommand::new(("bn", "ext"), workspace::ActivateNextItem).count(),
999 VimCommand::new(("bN", "ext"), workspace::ActivatePreviousItem).count(),
1000 VimCommand::new(("bp", "revious"), workspace::ActivatePreviousItem).count(),
1001 VimCommand::new(("bf", "irst"), workspace::ActivateItem(0)),
1002 VimCommand::new(("br", "ewind"), workspace::ActivateItem(0)),
1003 VimCommand::new(("bl", "ast"), workspace::ActivateLastItem),
1004 VimCommand::str(("buffers", ""), "tab_switcher::ToggleAll"),
1005 VimCommand::str(("ls", ""), "tab_switcher::ToggleAll"),
1006 VimCommand::new(("new", ""), workspace::NewFileSplitHorizontal),
1007 VimCommand::new(("vne", "w"), workspace::NewFileSplitVertical),
1008 VimCommand::new(("tabe", "dit"), workspace::NewFile),
1009 VimCommand::new(("tabnew", ""), workspace::NewFile),
1010 VimCommand::new(("tabn", "ext"), workspace::ActivateNextItem).count(),
1011 VimCommand::new(("tabp", "revious"), workspace::ActivatePreviousItem).count(),
1012 VimCommand::new(("tabN", "ext"), workspace::ActivatePreviousItem).count(),
1013 VimCommand::new(
1014 ("tabc", "lose"),
1015 workspace::CloseActiveItem {
1016 save_intent: Some(SaveIntent::Close),
1017 close_pinned: false,
1018 },
1019 ),
1020 VimCommand::new(
1021 ("tabo", "nly"),
1022 workspace::CloseInactiveItems {
1023 save_intent: Some(SaveIntent::Close),
1024 close_pinned: false,
1025 },
1026 )
1027 .bang(workspace::CloseInactiveItems {
1028 save_intent: Some(SaveIntent::Skip),
1029 close_pinned: false,
1030 }),
1031 VimCommand::new(
1032 ("on", "ly"),
1033 workspace::CloseInactiveTabsAndPanes {
1034 save_intent: Some(SaveIntent::Close),
1035 },
1036 )
1037 .bang(workspace::CloseInactiveTabsAndPanes {
1038 save_intent: Some(SaveIntent::Skip),
1039 }),
1040 VimCommand::str(("cl", "ist"), "diagnostics::Deploy"),
1041 VimCommand::new(("cc", ""), editor::actions::Hover),
1042 VimCommand::new(("ll", ""), editor::actions::Hover),
1043 VimCommand::new(("cn", "ext"), editor::actions::GoToDiagnostic).range(wrap_count),
1044 VimCommand::new(("cp", "revious"), editor::actions::GoToPreviousDiagnostic)
1045 .range(wrap_count),
1046 VimCommand::new(("cN", "ext"), editor::actions::GoToPreviousDiagnostic).range(wrap_count),
1047 VimCommand::new(("lp", "revious"), editor::actions::GoToPreviousDiagnostic)
1048 .range(wrap_count),
1049 VimCommand::new(("lN", "ext"), editor::actions::GoToPreviousDiagnostic).range(wrap_count),
1050 VimCommand::new(("j", "oin"), JoinLines).range(select_range),
1051 VimCommand::new(("fo", "ld"), editor::actions::FoldSelectedRanges).range(act_on_range),
1052 VimCommand::new(("foldo", "pen"), editor::actions::UnfoldLines)
1053 .bang(editor::actions::UnfoldRecursive)
1054 .range(act_on_range),
1055 VimCommand::new(("foldc", "lose"), editor::actions::Fold)
1056 .bang(editor::actions::FoldRecursive)
1057 .range(act_on_range),
1058 VimCommand::new(("dif", "fupdate"), editor::actions::ToggleSelectedDiffHunks)
1059 .range(act_on_range),
1060 VimCommand::str(("rev", "ert"), "git::Restore").range(act_on_range),
1061 VimCommand::new(("d", "elete"), VisualDeleteLine).range(select_range),
1062 VimCommand::new(("y", "ank"), gpui::NoAction).range(|_, range| {
1063 Some(
1064 YankCommand {
1065 range: range.clone(),
1066 }
1067 .boxed_clone(),
1068 )
1069 }),
1070 VimCommand::new(("reg", "isters"), ToggleRegistersView).bang(ToggleRegistersView),
1071 VimCommand::new(("marks", ""), ToggleMarksView).bang(ToggleMarksView),
1072 VimCommand::new(("delm", "arks"), ArgumentRequired)
1073 .bang(DeleteMarks::AllLocal)
1074 .args(|_, args| Some(DeleteMarks::Marks(args).boxed_clone())),
1075 VimCommand::new(("sor", "t"), SortLinesCaseSensitive).range(select_range),
1076 VimCommand::new(("sort i", ""), SortLinesCaseInsensitive).range(select_range),
1077 VimCommand::str(("E", "xplore"), "project_panel::ToggleFocus"),
1078 VimCommand::str(("H", "explore"), "project_panel::ToggleFocus"),
1079 VimCommand::str(("L", "explore"), "project_panel::ToggleFocus"),
1080 VimCommand::str(("S", "explore"), "project_panel::ToggleFocus"),
1081 VimCommand::str(("Ve", "xplore"), "project_panel::ToggleFocus"),
1082 VimCommand::str(("te", "rm"), "terminal_panel::ToggleFocus"),
1083 VimCommand::str(("T", "erm"), "terminal_panel::ToggleFocus"),
1084 VimCommand::str(("C", "ollab"), "collab_panel::ToggleFocus"),
1085 VimCommand::str(("Ch", "at"), "chat_panel::ToggleFocus"),
1086 VimCommand::str(("No", "tifications"), "notification_panel::ToggleFocus"),
1087 VimCommand::str(("A", "I"), "agent::ToggleFocus"),
1088 VimCommand::str(("G", "it"), "git_panel::ToggleFocus"),
1089 VimCommand::new(("noh", "lsearch"), search::buffer_search::Dismiss),
1090 VimCommand::new(("$", ""), EndOfDocument),
1091 VimCommand::new(("%", ""), EndOfDocument),
1092 VimCommand::new(("0", ""), StartOfDocument),
1093 VimCommand::new(("e", "dit"), editor::actions::ReloadFile)
1094 .bang(editor::actions::ReloadFile)
1095 .args(|_, args| Some(VimEdit { filename: args }.boxed_clone())),
1096 VimCommand::new(("ex", ""), editor::actions::ReloadFile).bang(editor::actions::ReloadFile),
1097 VimCommand::new(("cpp", "link"), editor::actions::CopyPermalinkToLine).range(act_on_range),
1098 VimCommand::str(("opt", "ions"), "zed::OpenDefaultSettings"),
1099 VimCommand::str(("map", ""), "vim::OpenDefaultKeymap"),
1100 VimCommand::new(("h", "elp"), OpenDocs),
1101 ]
1102}
1103
1104struct VimCommands(Vec<VimCommand>);
1105// safety: we only ever access this from the main thread (as ensured by the cx argument)
1106// actions are not Sync so we can't otherwise use a OnceLock.
1107unsafe impl Sync for VimCommands {}
1108impl Global for VimCommands {}
1109
1110fn commands(cx: &App) -> &Vec<VimCommand> {
1111 static COMMANDS: OnceLock<VimCommands> = OnceLock::new();
1112 &COMMANDS
1113 .get_or_init(|| VimCommands(generate_commands(cx)))
1114 .0
1115}
1116
1117fn act_on_range(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
1118 Some(
1119 WithRange {
1120 restore_selection: true,
1121 range: range.clone(),
1122 action: WrappedAction(action),
1123 }
1124 .boxed_clone(),
1125 )
1126}
1127
1128fn select_range(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
1129 Some(
1130 WithRange {
1131 restore_selection: false,
1132 range: range.clone(),
1133 action: WrappedAction(action),
1134 }
1135 .boxed_clone(),
1136 )
1137}
1138
1139fn wrap_count(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
1140 range.as_count().map(|count| {
1141 WithCount {
1142 count,
1143 action: WrappedAction(action),
1144 }
1145 .boxed_clone()
1146 })
1147}
1148
1149pub fn command_interceptor(mut input: &str, cx: &App) -> Vec<CommandInterceptResult> {
1150 // NOTE: We also need to support passing arguments to commands like :w
1151 // (ideally with filename autocompletion).
1152 while input.starts_with(':') {
1153 input = &input[1..];
1154 }
1155
1156 let (range, query) = VimCommand::parse_range(input);
1157 let range_prefix = input[0..(input.len() - query.len())].to_string();
1158 let query = query.as_str().trim();
1159
1160 let action = if range.is_some() && query.is_empty() {
1161 Some(
1162 GoToLine {
1163 range: range.clone().unwrap(),
1164 }
1165 .boxed_clone(),
1166 )
1167 } else if query.starts_with('/') || query.starts_with('?') {
1168 Some(
1169 FindCommand {
1170 query: query[1..].to_string(),
1171 backwards: query.starts_with('?'),
1172 }
1173 .boxed_clone(),
1174 )
1175 } else if query.starts_with("se ") || query.starts_with("set ") {
1176 let (prefix, option) = query.split_once(' ').unwrap();
1177 let mut commands = VimOption::possible_commands(option);
1178 if !commands.is_empty() {
1179 let query = prefix.to_string() + " " + option;
1180 for command in &mut commands {
1181 command.positions = generate_positions(&command.string, &query);
1182 }
1183 }
1184 return commands;
1185 } else if query.starts_with('s') {
1186 let mut substitute = "substitute".chars().peekable();
1187 let mut query = query.chars().peekable();
1188 while substitute
1189 .peek()
1190 .is_some_and(|char| Some(char) == query.peek())
1191 {
1192 substitute.next();
1193 query.next();
1194 }
1195 if let Some(replacement) = Replacement::parse(query) {
1196 let range = range.clone().unwrap_or(CommandRange {
1197 start: Position::CurrentLine { offset: 0 },
1198 end: None,
1199 });
1200 Some(ReplaceCommand { replacement, range }.boxed_clone())
1201 } else {
1202 None
1203 }
1204 } else if query.starts_with('g') || query.starts_with('v') {
1205 let mut global = "global".chars().peekable();
1206 let mut query = query.chars().peekable();
1207 let mut invert = false;
1208 if query.peek() == Some(&'v') {
1209 invert = true;
1210 query.next();
1211 }
1212 while global.peek().is_some_and(|char| Some(char) == query.peek()) {
1213 global.next();
1214 query.next();
1215 }
1216 if !invert && query.peek() == Some(&'!') {
1217 invert = true;
1218 query.next();
1219 }
1220 let range = range.clone().unwrap_or(CommandRange {
1221 start: Position::Line { row: 0, offset: 0 },
1222 end: Some(Position::LastLine { offset: 0 }),
1223 });
1224 if let Some(action) = OnMatchingLines::parse(query, invert, range, cx) {
1225 Some(action.boxed_clone())
1226 } else {
1227 None
1228 }
1229 } else if query.contains('!') {
1230 ShellExec::parse(query, range.clone())
1231 } else {
1232 None
1233 };
1234 if let Some(action) = action {
1235 let string = input.to_string();
1236 let positions = generate_positions(&string, &(range_prefix + query));
1237 return vec![CommandInterceptResult {
1238 action,
1239 string,
1240 positions,
1241 }];
1242 }
1243
1244 for command in commands(cx).iter() {
1245 if let Some(action) = command.parse(query, &range, cx) {
1246 let mut string = ":".to_owned() + &range_prefix + command.prefix + command.suffix;
1247 if query.contains('!') {
1248 string.push('!');
1249 }
1250 let positions = generate_positions(&string, &(range_prefix + query));
1251
1252 return vec![CommandInterceptResult {
1253 action,
1254 string,
1255 positions,
1256 }];
1257 }
1258 }
1259 return Vec::default();
1260}
1261
1262fn generate_positions(string: &str, query: &str) -> Vec<usize> {
1263 let mut positions = Vec::new();
1264 let mut chars = query.chars();
1265
1266 let Some(mut current) = chars.next() else {
1267 return positions;
1268 };
1269
1270 for (i, c) in string.char_indices() {
1271 if c == current {
1272 positions.push(i);
1273 if let Some(c) = chars.next() {
1274 current = c;
1275 } else {
1276 break;
1277 }
1278 }
1279 }
1280
1281 positions
1282}
1283
1284#[derive(Debug, PartialEq, Clone, Action)]
1285#[action(namespace = vim, no_json, no_register)]
1286pub(crate) struct OnMatchingLines {
1287 range: CommandRange,
1288 search: String,
1289 action: WrappedAction,
1290 invert: bool,
1291}
1292
1293impl OnMatchingLines {
1294 // convert a vim query into something more usable by zed.
1295 // we don't attempt to fully convert between the two regex syntaxes,
1296 // but we do flip \( and \) to ( and ) (and vice-versa) in the pattern,
1297 // and convert \0..\9 to $0..$9 in the replacement so that common idioms work.
1298 pub(crate) fn parse(
1299 mut chars: Peekable<Chars>,
1300 invert: bool,
1301 range: CommandRange,
1302 cx: &App,
1303 ) -> Option<Self> {
1304 let delimiter = chars.next().filter(|c| {
1305 !c.is_alphanumeric() && *c != '"' && *c != '|' && *c != '\'' && *c != '!'
1306 })?;
1307
1308 let mut search = String::new();
1309 let mut escaped = false;
1310
1311 while let Some(c) = chars.next() {
1312 if escaped {
1313 escaped = false;
1314 // unescape escaped parens
1315 if c != '(' && c != ')' && c != delimiter {
1316 search.push('\\')
1317 }
1318 search.push(c)
1319 } else if c == '\\' {
1320 escaped = true;
1321 } else if c == delimiter {
1322 break;
1323 } else {
1324 // escape unescaped parens
1325 if c == '(' || c == ')' {
1326 search.push('\\')
1327 }
1328 search.push(c)
1329 }
1330 }
1331
1332 let command: String = chars.collect();
1333
1334 let action = WrappedAction(
1335 command_interceptor(&command, cx)
1336 .first()?
1337 .action
1338 .boxed_clone(),
1339 );
1340
1341 Some(Self {
1342 range,
1343 search,
1344 invert,
1345 action,
1346 })
1347 }
1348
1349 pub fn run(&self, vim: &mut Vim, window: &mut Window, cx: &mut Context<Vim>) {
1350 let result = vim.update_editor(window, cx, |vim, editor, window, cx| {
1351 self.range.buffer_range(vim, editor, window, cx)
1352 });
1353
1354 let range = match result {
1355 None => return,
1356 Some(e @ Err(_)) => {
1357 let Some(workspace) = vim.workspace(window) else {
1358 return;
1359 };
1360 workspace.update(cx, |workspace, cx| {
1361 e.notify_err(workspace, cx);
1362 });
1363 return;
1364 }
1365 Some(Ok(result)) => result,
1366 };
1367
1368 let mut action = self.action.boxed_clone();
1369 let mut last_pattern = self.search.clone();
1370
1371 let mut regexes = match Regex::new(&self.search) {
1372 Ok(regex) => vec![(regex, !self.invert)],
1373 e @ Err(_) => {
1374 let Some(workspace) = vim.workspace(window) else {
1375 return;
1376 };
1377 workspace.update(cx, |workspace, cx| {
1378 e.notify_err(workspace, cx);
1379 });
1380 return;
1381 }
1382 };
1383 while let Some(inner) = action
1384 .boxed_clone()
1385 .as_any()
1386 .downcast_ref::<OnMatchingLines>()
1387 {
1388 let Some(regex) = Regex::new(&inner.search).ok() else {
1389 break;
1390 };
1391 last_pattern = inner.search.clone();
1392 action = inner.action.boxed_clone();
1393 regexes.push((regex, !inner.invert))
1394 }
1395
1396 if let Some(pane) = vim.pane(window, cx) {
1397 pane.update(cx, |pane, cx| {
1398 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>()
1399 {
1400 search_bar.update(cx, |search_bar, cx| {
1401 if search_bar.show(window, cx) {
1402 let _ = search_bar.search(
1403 &last_pattern,
1404 Some(SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE),
1405 window,
1406 cx,
1407 );
1408 }
1409 });
1410 }
1411 });
1412 };
1413
1414 vim.update_editor(window, cx, |_, editor, window, cx| {
1415 let snapshot = editor.snapshot(window, cx);
1416 let mut row = range.start.0;
1417
1418 let point_range = Point::new(range.start.0, 0)
1419 ..snapshot
1420 .buffer_snapshot
1421 .clip_point(Point::new(range.end.0 + 1, 0), Bias::Left);
1422 cx.spawn_in(window, async move |editor, cx| {
1423 let new_selections = cx
1424 .background_spawn(async move {
1425 let mut line = String::new();
1426 let mut new_selections = Vec::new();
1427 let chunks = snapshot
1428 .buffer_snapshot
1429 .text_for_range(point_range)
1430 .chain(["\n"]);
1431
1432 for chunk in chunks {
1433 for (newline_ix, text) in chunk.split('\n').enumerate() {
1434 if newline_ix > 0 {
1435 if regexes.iter().all(|(regex, should_match)| {
1436 regex.is_match(&line) == *should_match
1437 }) {
1438 new_selections
1439 .push(Point::new(row, 0).to_display_point(&snapshot))
1440 }
1441 row += 1;
1442 line.clear();
1443 }
1444 line.push_str(text)
1445 }
1446 }
1447
1448 new_selections
1449 })
1450 .await;
1451
1452 if new_selections.is_empty() {
1453 return;
1454 }
1455 editor
1456 .update_in(cx, |editor, window, cx| {
1457 editor.start_transaction_at(Instant::now(), window, cx);
1458 editor.change_selections(None, window, cx, |s| {
1459 s.replace_cursors_with(|_| new_selections);
1460 });
1461 window.dispatch_action(action, cx);
1462 cx.defer_in(window, move |editor, window, cx| {
1463 let newest = editor.selections.newest::<Point>(cx).clone();
1464 editor.change_selections(None, window, cx, |s| {
1465 s.select(vec![newest]);
1466 });
1467 editor.end_transaction_at(Instant::now(), cx);
1468 })
1469 })
1470 .ok();
1471 })
1472 .detach();
1473 });
1474 }
1475}
1476
1477#[derive(Clone, Debug, PartialEq, Action)]
1478#[action(namespace = vim, no_json, no_register)]
1479pub struct ShellExec {
1480 command: String,
1481 range: Option<CommandRange>,
1482 is_read: bool,
1483}
1484
1485impl Vim {
1486 pub fn cancel_running_command(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1487 if self.running_command.take().is_some() {
1488 self.update_editor(window, cx, |_, editor, window, cx| {
1489 editor.transact(window, cx, |editor, _window, _cx| {
1490 editor.clear_row_highlights::<ShellExec>();
1491 })
1492 });
1493 }
1494 }
1495
1496 fn prepare_shell_command(
1497 &mut self,
1498 command: &str,
1499 window: &mut Window,
1500 cx: &mut Context<Self>,
1501 ) -> String {
1502 let mut ret = String::new();
1503 // N.B. non-standard escaping rules:
1504 // * !echo % => "echo README.md"
1505 // * !echo \% => "echo %"
1506 // * !echo \\% => echo \%
1507 // * !echo \\\% => echo \\%
1508 for c in command.chars() {
1509 if c != '%' && c != '!' {
1510 ret.push(c);
1511 continue;
1512 } else if ret.chars().last() == Some('\\') {
1513 ret.pop();
1514 ret.push(c);
1515 continue;
1516 }
1517 match c {
1518 '%' => {
1519 self.update_editor(window, cx, |_, editor, _window, cx| {
1520 if let Some((_, buffer, _)) = editor.active_excerpt(cx) {
1521 if let Some(file) = buffer.read(cx).file() {
1522 if let Some(local) = file.as_local() {
1523 if let Some(str) = local.path().to_str() {
1524 ret.push_str(str)
1525 }
1526 }
1527 }
1528 }
1529 });
1530 }
1531 '!' => {
1532 if let Some(command) = &self.last_command {
1533 ret.push_str(command)
1534 }
1535 }
1536 _ => {}
1537 }
1538 }
1539 self.last_command = Some(ret.clone());
1540 ret
1541 }
1542
1543 pub fn shell_command_motion(
1544 &mut self,
1545 motion: Motion,
1546 times: Option<usize>,
1547 forced_motion: bool,
1548 window: &mut Window,
1549 cx: &mut Context<Vim>,
1550 ) {
1551 self.stop_recording(cx);
1552 let Some(workspace) = self.workspace(window) else {
1553 return;
1554 };
1555 let command = self.update_editor(window, cx, |_, editor, window, cx| {
1556 let snapshot = editor.snapshot(window, cx);
1557 let start = editor.selections.newest_display(cx);
1558 let text_layout_details = editor.text_layout_details(window);
1559 let (mut range, _) = motion
1560 .range(
1561 &snapshot,
1562 start.clone(),
1563 times,
1564 &text_layout_details,
1565 forced_motion,
1566 )
1567 .unwrap_or((start.range(), MotionKind::Exclusive));
1568 if range.start != start.start {
1569 editor.change_selections(None, window, cx, |s| {
1570 s.select_ranges([
1571 range.start.to_point(&snapshot)..range.start.to_point(&snapshot)
1572 ]);
1573 })
1574 }
1575 if range.end.row() > range.start.row() && range.end.column() != 0 {
1576 *range.end.row_mut() -= 1
1577 }
1578 if range.end.row() == range.start.row() {
1579 ".!".to_string()
1580 } else {
1581 format!(".,.+{}!", (range.end.row() - range.start.row()).0)
1582 }
1583 });
1584 if let Some(command) = command {
1585 workspace.update(cx, |workspace, cx| {
1586 command_palette::CommandPalette::toggle(workspace, &command, window, cx);
1587 });
1588 }
1589 }
1590
1591 pub fn shell_command_object(
1592 &mut self,
1593 object: Object,
1594 around: bool,
1595 window: &mut Window,
1596 cx: &mut Context<Vim>,
1597 ) {
1598 self.stop_recording(cx);
1599 let Some(workspace) = self.workspace(window) else {
1600 return;
1601 };
1602 let command = self.update_editor(window, cx, |_, editor, window, cx| {
1603 let snapshot = editor.snapshot(window, cx);
1604 let start = editor.selections.newest_display(cx);
1605 let range = object
1606 .range(&snapshot, start.clone(), around)
1607 .unwrap_or(start.range());
1608 if range.start != start.start {
1609 editor.change_selections(None, window, cx, |s| {
1610 s.select_ranges([
1611 range.start.to_point(&snapshot)..range.start.to_point(&snapshot)
1612 ]);
1613 })
1614 }
1615 if range.end.row() == range.start.row() {
1616 ".!".to_string()
1617 } else {
1618 format!(".,.+{}!", (range.end.row() - range.start.row()).0)
1619 }
1620 });
1621 if let Some(command) = command {
1622 workspace.update(cx, |workspace, cx| {
1623 command_palette::CommandPalette::toggle(workspace, &command, window, cx);
1624 });
1625 }
1626 }
1627}
1628
1629impl ShellExec {
1630 pub fn parse(query: &str, range: Option<CommandRange>) -> Option<Box<dyn Action>> {
1631 let (before, after) = query.split_once('!')?;
1632 let before = before.trim();
1633
1634 if !"read".starts_with(before) {
1635 return None;
1636 }
1637
1638 Some(
1639 ShellExec {
1640 command: after.trim().to_string(),
1641 range,
1642 is_read: !before.is_empty(),
1643 }
1644 .boxed_clone(),
1645 )
1646 }
1647
1648 pub fn run(&self, vim: &mut Vim, window: &mut Window, cx: &mut Context<Vim>) {
1649 let Some(workspace) = vim.workspace(window) else {
1650 return;
1651 };
1652
1653 let project = workspace.read(cx).project().clone();
1654 let command = vim.prepare_shell_command(&self.command, window, cx);
1655
1656 if self.range.is_none() && !self.is_read {
1657 workspace.update(cx, |workspace, cx| {
1658 let project = workspace.project().read(cx);
1659 let cwd = project.first_project_directory(cx);
1660 let shell = project.terminal_settings(&cwd, cx).shell.clone();
1661
1662 let spawn_in_terminal = SpawnInTerminal {
1663 id: TaskId("vim".to_string()),
1664 full_label: command.clone(),
1665 label: command.clone(),
1666 command: command.clone(),
1667 args: Vec::new(),
1668 command_label: command.clone(),
1669 cwd,
1670 env: HashMap::default(),
1671 use_new_terminal: true,
1672 allow_concurrent_runs: true,
1673 reveal: RevealStrategy::NoFocus,
1674 reveal_target: RevealTarget::Dock,
1675 hide: HideStrategy::Never,
1676 shell,
1677 show_summary: false,
1678 show_command: false,
1679 show_rerun: false,
1680 };
1681
1682 let task_status = workspace.spawn_in_terminal(spawn_in_terminal, window, cx);
1683 cx.background_spawn(async move {
1684 match task_status.await {
1685 Some(Ok(status)) => {
1686 if status.success() {
1687 log::debug!("Vim shell exec succeeded");
1688 } else {
1689 log::debug!("Vim shell exec failed, code: {:?}", status.code());
1690 }
1691 }
1692 Some(Err(e)) => log::error!("Vim shell exec failed: {e}"),
1693 None => log::debug!("Vim shell exec got cancelled"),
1694 }
1695 })
1696 .detach();
1697 });
1698 return;
1699 };
1700
1701 let mut input_snapshot = None;
1702 let mut input_range = None;
1703 let mut needs_newline_prefix = false;
1704 vim.update_editor(window, cx, |vim, editor, window, cx| {
1705 let snapshot = editor.buffer().read(cx).snapshot(cx);
1706 let range = if let Some(range) = self.range.clone() {
1707 let Some(range) = range.buffer_range(vim, editor, window, cx).log_err() else {
1708 return;
1709 };
1710 Point::new(range.start.0, 0)
1711 ..snapshot.clip_point(Point::new(range.end.0 + 1, 0), Bias::Right)
1712 } else {
1713 let mut end = editor.selections.newest::<Point>(cx).range().end;
1714 end = snapshot.clip_point(Point::new(end.row + 1, 0), Bias::Right);
1715 needs_newline_prefix = end == snapshot.max_point();
1716 end..end
1717 };
1718 if self.is_read {
1719 input_range =
1720 Some(snapshot.anchor_after(range.end)..snapshot.anchor_after(range.end));
1721 } else {
1722 input_range =
1723 Some(snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end));
1724 }
1725 editor.highlight_rows::<ShellExec>(
1726 input_range.clone().unwrap(),
1727 cx.theme().status().unreachable_background,
1728 Default::default(),
1729 cx,
1730 );
1731
1732 if !self.is_read {
1733 input_snapshot = Some(snapshot)
1734 }
1735 });
1736
1737 let Some(range) = input_range else { return };
1738
1739 let mut process = project.read(cx).exec_in_shell(command, cx);
1740 process.stdout(Stdio::piped());
1741 process.stderr(Stdio::piped());
1742
1743 if input_snapshot.is_some() {
1744 process.stdin(Stdio::piped());
1745 } else {
1746 process.stdin(Stdio::null());
1747 };
1748
1749 util::set_pre_exec_to_start_new_session(&mut process);
1750 let is_read = self.is_read;
1751
1752 let task = cx.spawn_in(window, async move |vim, cx| {
1753 let Some(mut running) = process.spawn().log_err() else {
1754 vim.update_in(cx, |vim, window, cx| {
1755 vim.cancel_running_command(window, cx);
1756 })
1757 .log_err();
1758 return;
1759 };
1760
1761 if let Some(mut stdin) = running.stdin.take() {
1762 if let Some(snapshot) = input_snapshot {
1763 let range = range.clone();
1764 cx.background_spawn(async move {
1765 for chunk in snapshot.text_for_range(range) {
1766 if stdin.write_all(chunk.as_bytes()).log_err().is_none() {
1767 return;
1768 }
1769 }
1770 stdin.flush().log_err();
1771 })
1772 .detach();
1773 }
1774 };
1775
1776 let output = cx
1777 .background_spawn(async move { running.wait_with_output() })
1778 .await;
1779
1780 let Some(output) = output.log_err() else {
1781 vim.update_in(cx, |vim, window, cx| {
1782 vim.cancel_running_command(window, cx);
1783 })
1784 .log_err();
1785 return;
1786 };
1787 let mut text = String::new();
1788 if needs_newline_prefix {
1789 text.push('\n');
1790 }
1791 text.push_str(&String::from_utf8_lossy(&output.stdout));
1792 text.push_str(&String::from_utf8_lossy(&output.stderr));
1793 if !text.is_empty() && text.chars().last() != Some('\n') {
1794 text.push('\n');
1795 }
1796
1797 vim.update_in(cx, |vim, window, cx| {
1798 vim.update_editor(window, cx, |_, editor, window, cx| {
1799 editor.transact(window, cx, |editor, window, cx| {
1800 editor.edit([(range.clone(), text)], cx);
1801 let snapshot = editor.buffer().read(cx).snapshot(cx);
1802 editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
1803 let point = if is_read {
1804 let point = range.end.to_point(&snapshot);
1805 Point::new(point.row.saturating_sub(1), 0)
1806 } else {
1807 let point = range.start.to_point(&snapshot);
1808 Point::new(point.row, 0)
1809 };
1810 s.select_ranges([point..point]);
1811 })
1812 })
1813 });
1814 vim.cancel_running_command(window, cx);
1815 })
1816 .log_err();
1817 });
1818 vim.running_command.replace(task);
1819 }
1820}
1821
1822#[cfg(test)]
1823mod test {
1824 use std::path::Path;
1825
1826 use crate::{
1827 VimAddon,
1828 state::Mode,
1829 test::{NeovimBackedTestContext, VimTestContext},
1830 };
1831 use editor::Editor;
1832 use gpui::{Context, TestAppContext};
1833 use indoc::indoc;
1834 use util::path;
1835 use workspace::Workspace;
1836
1837 #[gpui::test]
1838 async fn test_command_basics(cx: &mut TestAppContext) {
1839 let mut cx = NeovimBackedTestContext::new(cx).await;
1840
1841 cx.set_shared_state(indoc! {"
1842 ˇa
1843 b
1844 c"})
1845 .await;
1846
1847 cx.simulate_shared_keystrokes(": j enter").await;
1848
1849 // hack: our cursor positioning after a join command is wrong
1850 cx.simulate_shared_keystrokes("^").await;
1851 cx.shared_state().await.assert_eq(indoc! {
1852 "ˇa b
1853 c"
1854 });
1855 }
1856
1857 #[gpui::test]
1858 async fn test_command_goto(cx: &mut TestAppContext) {
1859 let mut cx = NeovimBackedTestContext::new(cx).await;
1860
1861 cx.set_shared_state(indoc! {"
1862 ˇa
1863 b
1864 c"})
1865 .await;
1866 cx.simulate_shared_keystrokes(": 3 enter").await;
1867 cx.shared_state().await.assert_eq(indoc! {"
1868 a
1869 b
1870 ˇc"});
1871 }
1872
1873 #[gpui::test]
1874 async fn test_command_replace(cx: &mut TestAppContext) {
1875 let mut cx = NeovimBackedTestContext::new(cx).await;
1876
1877 cx.set_shared_state(indoc! {"
1878 ˇa
1879 b
1880 b
1881 c"})
1882 .await;
1883 cx.simulate_shared_keystrokes(": % s / b / d enter").await;
1884 cx.shared_state().await.assert_eq(indoc! {"
1885 a
1886 d
1887 ˇd
1888 c"});
1889 cx.simulate_shared_keystrokes(": % s : . : \\ 0 \\ 0 enter")
1890 .await;
1891 cx.shared_state().await.assert_eq(indoc! {"
1892 aa
1893 dd
1894 dd
1895 ˇcc"});
1896 cx.simulate_shared_keystrokes("k : s / d d / e e enter")
1897 .await;
1898 cx.shared_state().await.assert_eq(indoc! {"
1899 aa
1900 dd
1901 ˇee
1902 cc"});
1903 }
1904
1905 #[gpui::test]
1906 async fn test_command_search(cx: &mut TestAppContext) {
1907 let mut cx = NeovimBackedTestContext::new(cx).await;
1908
1909 cx.set_shared_state(indoc! {"
1910 ˇa
1911 b
1912 a
1913 c"})
1914 .await;
1915 cx.simulate_shared_keystrokes(": / b enter").await;
1916 cx.shared_state().await.assert_eq(indoc! {"
1917 a
1918 ˇb
1919 a
1920 c"});
1921 cx.simulate_shared_keystrokes(": ? a enter").await;
1922 cx.shared_state().await.assert_eq(indoc! {"
1923 ˇa
1924 b
1925 a
1926 c"});
1927 }
1928
1929 #[gpui::test]
1930 async fn test_command_write(cx: &mut TestAppContext) {
1931 let mut cx = VimTestContext::new(cx, true).await;
1932 let path = Path::new(path!("/root/dir/file.rs"));
1933 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
1934
1935 cx.simulate_keystrokes("i @ escape");
1936 cx.simulate_keystrokes(": w enter");
1937
1938 assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@\n");
1939
1940 fs.as_fake().insert_file(path, b"oops\n".to_vec()).await;
1941
1942 // conflict!
1943 cx.simulate_keystrokes("i @ escape");
1944 cx.simulate_keystrokes(": w enter");
1945 cx.simulate_prompt_answer("Cancel");
1946
1947 assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "oops\n");
1948 assert!(!cx.has_pending_prompt());
1949 cx.simulate_keystrokes(": w ! enter");
1950 assert!(!cx.has_pending_prompt());
1951 assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@@\n");
1952 }
1953
1954 #[gpui::test]
1955 async fn test_command_quit(cx: &mut TestAppContext) {
1956 let mut cx = VimTestContext::new(cx, true).await;
1957
1958 cx.simulate_keystrokes(": n e w enter");
1959 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
1960 cx.simulate_keystrokes(": q enter");
1961 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
1962 cx.simulate_keystrokes(": n e w enter");
1963 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
1964 cx.simulate_keystrokes(": q a enter");
1965 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 0));
1966 }
1967
1968 #[gpui::test]
1969 async fn test_offsets(cx: &mut TestAppContext) {
1970 let mut cx = NeovimBackedTestContext::new(cx).await;
1971
1972 cx.set_shared_state("ˇ1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n")
1973 .await;
1974
1975 cx.simulate_shared_keystrokes(": + enter").await;
1976 cx.shared_state()
1977 .await
1978 .assert_eq("1\nˇ2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n");
1979
1980 cx.simulate_shared_keystrokes(": 1 0 - enter").await;
1981 cx.shared_state()
1982 .await
1983 .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\nˇ9\n10\n11\n");
1984
1985 cx.simulate_shared_keystrokes(": . - 2 enter").await;
1986 cx.shared_state()
1987 .await
1988 .assert_eq("1\n2\n3\n4\n5\n6\nˇ7\n8\n9\n10\n11\n");
1989
1990 cx.simulate_shared_keystrokes(": % enter").await;
1991 cx.shared_state()
1992 .await
1993 .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\nˇ");
1994 }
1995
1996 #[gpui::test]
1997 async fn test_command_ranges(cx: &mut TestAppContext) {
1998 let mut cx = NeovimBackedTestContext::new(cx).await;
1999
2000 cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
2001
2002 cx.simulate_shared_keystrokes(": 2 , 4 d enter").await;
2003 cx.shared_state().await.assert_eq("1\nˇ4\n3\n2\n1");
2004
2005 cx.simulate_shared_keystrokes(": 2 , 4 s o r t enter").await;
2006 cx.shared_state().await.assert_eq("1\nˇ2\n3\n4\n1");
2007
2008 cx.simulate_shared_keystrokes(": 2 , 4 j o i n enter").await;
2009 cx.shared_state().await.assert_eq("1\nˇ2 3 4\n1");
2010 }
2011
2012 #[gpui::test]
2013 async fn test_command_visual_replace(cx: &mut TestAppContext) {
2014 let mut cx = NeovimBackedTestContext::new(cx).await;
2015
2016 cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
2017
2018 cx.simulate_shared_keystrokes("v 2 j : s / . / k enter")
2019 .await;
2020 cx.shared_state().await.assert_eq("k\nk\nˇk\n4\n4\n3\n2\n1");
2021 }
2022
2023 #[track_caller]
2024 fn assert_active_item(
2025 workspace: &mut Workspace,
2026 expected_path: &str,
2027 expected_text: &str,
2028 cx: &mut Context<Workspace>,
2029 ) {
2030 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
2031
2032 let buffer = active_editor
2033 .read(cx)
2034 .buffer()
2035 .read(cx)
2036 .as_singleton()
2037 .unwrap();
2038
2039 let text = buffer.read(cx).text();
2040 let file = buffer.read(cx).file().unwrap();
2041 let file_path = file.as_local().unwrap().abs_path(cx);
2042
2043 assert_eq!(text, expected_text);
2044 assert_eq!(file_path, Path::new(expected_path));
2045 }
2046
2047 #[gpui::test]
2048 async fn test_command_gf(cx: &mut TestAppContext) {
2049 let mut cx = VimTestContext::new(cx, true).await;
2050
2051 // Assert base state, that we're in /root/dir/file.rs
2052 cx.workspace(|workspace, _, cx| {
2053 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
2054 });
2055
2056 // Insert a new file
2057 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2058 fs.as_fake()
2059 .insert_file(
2060 path!("/root/dir/file2.rs"),
2061 "This is file2.rs".as_bytes().to_vec(),
2062 )
2063 .await;
2064 fs.as_fake()
2065 .insert_file(
2066 path!("/root/dir/file3.rs"),
2067 "go to file3".as_bytes().to_vec(),
2068 )
2069 .await;
2070
2071 // Put the path to the second file into the currently open buffer
2072 cx.set_state(indoc! {"go to fiˇle2.rs"}, Mode::Normal);
2073
2074 // Go to file2.rs
2075 cx.simulate_keystrokes("g f");
2076
2077 // We now have two items
2078 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
2079 cx.workspace(|workspace, _, cx| {
2080 assert_active_item(
2081 workspace,
2082 path!("/root/dir/file2.rs"),
2083 "This is file2.rs",
2084 cx,
2085 );
2086 });
2087
2088 // Update editor to point to `file2.rs`
2089 cx.editor =
2090 cx.workspace(|workspace, _, cx| workspace.active_item_as::<Editor>(cx).unwrap());
2091
2092 // Put the path to the third file into the currently open buffer,
2093 // but remove its suffix, because we want that lookup to happen automatically.
2094 cx.set_state(indoc! {"go to fiˇle3"}, Mode::Normal);
2095
2096 // Go to file3.rs
2097 cx.simulate_keystrokes("g f");
2098
2099 // We now have three items
2100 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 3));
2101 cx.workspace(|workspace, _, cx| {
2102 assert_active_item(workspace, path!("/root/dir/file3.rs"), "go to file3", cx);
2103 });
2104 }
2105
2106 #[gpui::test]
2107 async fn test_w_command(cx: &mut TestAppContext) {
2108 let mut cx = VimTestContext::new(cx, true).await;
2109
2110 cx.workspace(|workspace, _, cx| {
2111 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
2112 });
2113
2114 cx.simulate_keystrokes(": w space other.rs");
2115 cx.simulate_keystrokes("enter");
2116
2117 cx.workspace(|workspace, _, cx| {
2118 assert_active_item(workspace, path!("/root/other.rs"), "", cx);
2119 });
2120
2121 cx.simulate_keystrokes(": w space dir/file.rs");
2122 cx.simulate_keystrokes("enter");
2123
2124 cx.simulate_prompt_answer("Replace");
2125 cx.run_until_parked();
2126
2127 cx.workspace(|workspace, _, cx| {
2128 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
2129 });
2130
2131 cx.simulate_keystrokes(": w ! space other.rs");
2132 cx.simulate_keystrokes("enter");
2133
2134 cx.workspace(|workspace, _, cx| {
2135 assert_active_item(workspace, path!("/root/other.rs"), "", cx);
2136 });
2137 }
2138
2139 #[gpui::test]
2140 async fn test_command_matching_lines(cx: &mut TestAppContext) {
2141 let mut cx = NeovimBackedTestContext::new(cx).await;
2142
2143 cx.set_shared_state(indoc! {"
2144 ˇa
2145 b
2146 a
2147 b
2148 a
2149 "})
2150 .await;
2151
2152 cx.simulate_shared_keystrokes(":").await;
2153 cx.simulate_shared_keystrokes("g / a / d").await;
2154 cx.simulate_shared_keystrokes("enter").await;
2155
2156 cx.shared_state().await.assert_eq(indoc! {"
2157 b
2158 b
2159 ˇ"});
2160
2161 cx.simulate_shared_keystrokes("u").await;
2162
2163 cx.shared_state().await.assert_eq(indoc! {"
2164 ˇa
2165 b
2166 a
2167 b
2168 a
2169 "});
2170
2171 cx.simulate_shared_keystrokes(":").await;
2172 cx.simulate_shared_keystrokes("v / a / d").await;
2173 cx.simulate_shared_keystrokes("enter").await;
2174
2175 cx.shared_state().await.assert_eq(indoc! {"
2176 a
2177 a
2178 ˇa"});
2179 }
2180
2181 #[gpui::test]
2182 async fn test_del_marks(cx: &mut TestAppContext) {
2183 let mut cx = NeovimBackedTestContext::new(cx).await;
2184
2185 cx.set_shared_state(indoc! {"
2186 ˇa
2187 b
2188 a
2189 b
2190 a
2191 "})
2192 .await;
2193
2194 cx.simulate_shared_keystrokes("m a").await;
2195
2196 let mark = cx.update_editor(|editor, window, cx| {
2197 let vim = editor.addon::<VimAddon>().unwrap().entity.clone();
2198 vim.update(cx, |vim, cx| vim.get_mark("a", editor, window, cx))
2199 });
2200 assert!(mark.is_some());
2201
2202 cx.simulate_shared_keystrokes(": d e l m space a").await;
2203 cx.simulate_shared_keystrokes("enter").await;
2204
2205 let mark = cx.update_editor(|editor, window, cx| {
2206 let vim = editor.addon::<VimAddon>().unwrap().entity.clone();
2207 vim.update(cx, |vim, cx| vim.get_mark("a", editor, window, cx))
2208 });
2209 assert!(mark.is_none())
2210 }
2211}