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