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