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