Skip to repository content1610 lines · 53.7 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:37:32.578Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
search.rs
1use editor::{Editor, EditorSettings};
2use gpui::{Action, Context, TaskExt, Window, actions};
3use language::Point;
4use schemars::JsonSchema;
5use search::{BufferSearchBar, SearchOptions, buffer_search};
6use serde::Deserialize;
7use settings::Settings;
8use std::{iter::Peekable, str::Chars};
9use util::serde::default_true;
10use workspace::{notifications::NotifyResultExt, searchable::Direction};
11
12use crate::{
13 Vim, VimSettings,
14 command::CommandRange,
15 motion::Motion,
16 state::{Mode, SearchState},
17};
18
19/// Moves to the next search match.
20#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Action)]
21#[action(namespace = vim)]
22#[serde(deny_unknown_fields)]
23pub(crate) struct MoveToNext {
24 #[serde(default = "default_true")]
25 case_sensitive: bool,
26 #[serde(default)]
27 partial_word: bool,
28 #[serde(default = "default_true")]
29 regex: bool,
30}
31
32/// Moves to the previous search match.
33#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Action)]
34#[action(namespace = vim)]
35#[serde(deny_unknown_fields)]
36pub(crate) struct MoveToPrevious {
37 #[serde(default = "default_true")]
38 case_sensitive: bool,
39 #[serde(default)]
40 partial_word: bool,
41 #[serde(default = "default_true")]
42 regex: bool,
43}
44
45/// Searches for the word under the cursor without moving.
46#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Action)]
47#[action(namespace = vim)]
48#[serde(deny_unknown_fields)]
49pub(crate) struct SearchUnderCursor {
50 #[serde(default = "default_true")]
51 case_sensitive: bool,
52 #[serde(default)]
53 partial_word: bool,
54 #[serde(default = "default_true")]
55 regex: bool,
56}
57
58/// Searches for the word under the cursor without moving (backwards).
59#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Action)]
60#[action(namespace = vim)]
61#[serde(deny_unknown_fields)]
62pub(crate) struct SearchUnderCursorPrevious {
63 #[serde(default = "default_true")]
64 case_sensitive: bool,
65 #[serde(default)]
66 partial_word: bool,
67 #[serde(default = "default_true")]
68 regex: bool,
69}
70
71/// Initiates a search operation with the specified parameters.
72#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Action)]
73#[action(namespace = vim)]
74#[serde(deny_unknown_fields)]
75pub(crate) struct Search {
76 #[serde(default)]
77 backwards: bool,
78 #[serde(default = "default_true")]
79 regex: bool,
80}
81
82/// Executes a find command to search for patterns in the buffer.
83#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Action)]
84#[action(namespace = vim)]
85#[serde(deny_unknown_fields)]
86pub struct FindCommand {
87 pub query: String,
88 pub backwards: bool,
89}
90
91/// Executes a search and replace command within the specified range.
92#[derive(Clone, Debug, PartialEq, Action)]
93#[action(namespace = vim, no_json, no_register)]
94pub struct ReplaceCommand {
95 pub(crate) range: CommandRange,
96 pub(crate) replacement: Replacement,
97}
98
99#[derive(Clone, Debug, PartialEq)]
100pub struct Replacement {
101 search: String,
102 replacement: String,
103 case_sensitive: Option<bool>,
104 flag_n: bool,
105 flag_g: bool,
106 flag_c: bool,
107}
108
109actions!(
110 vim,
111 [
112 /// Submits the current search query.
113 SearchSubmit,
114 /// Moves to the next search match.
115 MoveToNextMatch,
116 /// Moves to the previous search match.
117 MoveToPreviousMatch
118 ]
119);
120
121pub(crate) fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
122 Vim::action(editor, cx, Vim::move_to_next);
123 Vim::action(editor, cx, Vim::move_to_previous);
124 Vim::action(editor, cx, Vim::search_under_cursor);
125 Vim::action(editor, cx, Vim::search_under_cursor_previous);
126 Vim::action(editor, cx, Vim::move_to_next_match);
127 Vim::action(editor, cx, Vim::move_to_previous_match);
128 Vim::action(editor, cx, Vim::search);
129 Vim::action(editor, cx, Vim::search_deploy);
130 Vim::action(editor, cx, Vim::find_command);
131 Vim::action(editor, cx, Vim::replace_command);
132}
133
134impl Vim {
135 fn search_under_cursor(
136 &mut self,
137 action: &SearchUnderCursor,
138 window: &mut Window,
139 cx: &mut Context<Self>,
140 ) {
141 self.move_to_internal(
142 Direction::Next,
143 action.case_sensitive,
144 !action.partial_word,
145 action.regex,
146 false,
147 window,
148 cx,
149 )
150 }
151
152 fn search_under_cursor_previous(
153 &mut self,
154 action: &SearchUnderCursorPrevious,
155 window: &mut Window,
156 cx: &mut Context<Self>,
157 ) {
158 self.move_to_internal(
159 Direction::Prev,
160 action.case_sensitive,
161 !action.partial_word,
162 action.regex,
163 false,
164 window,
165 cx,
166 )
167 }
168
169 fn move_to_next(&mut self, action: &MoveToNext, window: &mut Window, cx: &mut Context<Self>) {
170 self.move_to_internal(
171 Direction::Next,
172 action.case_sensitive,
173 !action.partial_word,
174 action.regex,
175 true,
176 window,
177 cx,
178 )
179 }
180
181 fn move_to_previous(
182 &mut self,
183 action: &MoveToPrevious,
184 window: &mut Window,
185 cx: &mut Context<Self>,
186 ) {
187 self.move_to_internal(
188 Direction::Prev,
189 action.case_sensitive,
190 !action.partial_word,
191 action.regex,
192 true,
193 window,
194 cx,
195 )
196 }
197
198 fn move_to_next_match(
199 &mut self,
200 _: &MoveToNextMatch,
201 window: &mut Window,
202 cx: &mut Context<Self>,
203 ) {
204 self.move_to_match_internal(self.search.direction, window, cx)
205 }
206
207 fn move_to_previous_match(
208 &mut self,
209 _: &MoveToPreviousMatch,
210 window: &mut Window,
211 cx: &mut Context<Self>,
212 ) {
213 self.move_to_match_internal(self.search.direction.opposite(), window, cx)
214 }
215
216 fn search(&mut self, action: &Search, window: &mut Window, cx: &mut Context<Self>) {
217 let Some(pane) = self.pane(window, cx) else {
218 return;
219 };
220 let direction = if action.backwards {
221 Direction::Prev
222 } else {
223 Direction::Next
224 };
225 let count = Vim::take_count(cx).unwrap_or(1);
226 Vim::take_forced_motion(cx);
227 let prior_selections = self.editor_selections(window, cx);
228
229 let Some(search_bar) = pane
230 .read(cx)
231 .toolbar()
232 .read(cx)
233 .item_of_type::<BufferSearchBar>()
234 else {
235 return;
236 };
237
238 let shown = search_bar.update(cx, |search_bar, cx| {
239 if !search_bar.show(window, cx) {
240 return false;
241 }
242
243 search_bar.select_query(window, cx);
244 cx.focus_self(window);
245
246 search_bar.set_replacement(None, cx);
247 let mut options = SearchOptions::NONE;
248 if action.regex && VimSettings::get_global(cx).use_regex_search {
249 options |= SearchOptions::REGEX;
250 }
251 if action.backwards {
252 options |= SearchOptions::BACKWARDS;
253 }
254 if EditorSettings::get_global(cx).search.case_sensitive {
255 options |= SearchOptions::CASE_SENSITIVE;
256 }
257 search_bar.set_search_options(options, cx);
258 true
259 });
260
261 if !shown {
262 return;
263 }
264
265 let subscription = cx.subscribe_in(&search_bar, window, |vim, _, event, window, cx| {
266 if let buffer_search::Event::Dismissed = event {
267 if !vim.search.prior_selections.is_empty() {
268 let prior_selections: Vec<_> = vim.search.prior_selections.drain(..).collect();
269 vim.update_editor(cx, |_, editor, cx| {
270 editor.change_selections(Default::default(), window, cx, |s| {
271 s.select_ranges(prior_selections);
272 });
273 });
274 }
275 }
276 });
277
278 let prior_mode = if self.temp_mode {
279 Mode::Insert
280 } else {
281 self.mode
282 };
283
284 self.search = SearchState {
285 direction,
286 count,
287 cmd_f_search: false,
288 prior_selections,
289 prior_operator: self.operator_stack.last().cloned(),
290 prior_mode,
291 helix_select: false,
292 _dismiss_subscription: Some(subscription),
293 }
294 }
295
296 // hook into the existing to clear out any vim search state on cmd+f or edit -> find.
297 fn search_deploy(&mut self, _: &buffer_search::Deploy, _: &mut Window, cx: &mut Context<Self>) {
298 // Preserve the current mode when resetting search state
299 let current_mode = self.mode;
300 self.search = Default::default();
301 self.search.prior_mode = current_mode;
302 self.search.cmd_f_search = true;
303 cx.propagate();
304 }
305
306 pub fn search_submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
307 self.store_visual_marks(window, cx);
308 let Some(pane) = self.pane(window, cx) else {
309 return;
310 };
311 let new_selections = self.editor_selections(window, cx);
312 let result = pane.update(cx, |pane, cx| {
313 let search_bar = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>()?;
314 if self.search.helix_select {
315 search_bar.update(cx, |search_bar, cx| {
316 search_bar.select_all_matches(&Default::default(), window, cx)
317 });
318 return None;
319 }
320 search_bar.update(cx, |search_bar, cx| {
321 let mut count = self.search.count;
322 let direction = self.search.direction;
323 search_bar.has_active_match();
324 let new_head = new_selections.last()?.start;
325 let is_different_head = self
326 .search
327 .prior_selections
328 .last()
329 .is_none_or(|range| range.start != new_head);
330
331 if is_different_head {
332 count = count.saturating_sub(1)
333 }
334 self.search.count = 1;
335 search_bar.select_match(direction, count, window, cx);
336 search_bar.focus_editor(&Default::default(), window, cx);
337
338 let prior_selections: Vec<_> = self.search.prior_selections.drain(..).collect();
339 let prior_mode = self.search.prior_mode;
340 let prior_operator = self.search.prior_operator.take();
341
342 let query = search_bar.query(cx).into();
343 Vim::globals(cx).registers.insert('/', query);
344 Some((prior_selections, prior_mode, prior_operator))
345 })
346 });
347
348 let Some((mut prior_selections, prior_mode, prior_operator)) = result else {
349 return;
350 };
351
352 let new_selections = self.editor_selections(window, cx);
353
354 // If the active editor has changed during a search, don't panic.
355 if prior_selections.iter().any(|s| {
356 self.update_editor(cx, |_, editor, cx| {
357 !s.start
358 .is_valid(&editor.snapshot(window, cx).buffer_snapshot())
359 })
360 .unwrap_or(true)
361 }) {
362 prior_selections.clear();
363 }
364
365 if prior_mode != self.mode {
366 self.switch_mode(prior_mode, true, window, cx);
367 }
368 if let Some(operator) = prior_operator {
369 self.push_operator(operator, window, cx);
370 };
371 self.search_motion(
372 Motion::ZedSearchResult {
373 prior_selections,
374 new_selections,
375 },
376 window,
377 cx,
378 );
379 }
380
381 pub fn move_to_match_internal(
382 &mut self,
383 direction: Direction,
384 window: &mut Window,
385 cx: &mut Context<Self>,
386 ) {
387 let Some(pane) = self.pane(window, cx) else {
388 return;
389 };
390 let count = Vim::take_count(cx).unwrap_or(1);
391 Vim::take_forced_motion(cx);
392
393 if self.search.cmd_f_search {
394 self.search.cmd_f_search = false;
395 if self.mode.is_visual() {
396 self.switch_mode(Mode::Normal, false, window, cx);
397 }
398 self.sync_vim_settings(window, cx);
399 }
400
401 let prior_selections = self.editor_selections(window, cx);
402
403 let success = pane.update(cx, |pane, cx| {
404 let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() else {
405 return false;
406 };
407 search_bar.update(cx, |search_bar, cx| {
408 if !search_bar.has_active_match() || !search_bar.show(window, cx) {
409 return false;
410 }
411 search_bar.select_match(direction, count, window, cx);
412 true
413 })
414 });
415 if !success {
416 return;
417 }
418
419 let new_selections = self.editor_selections(window, cx);
420 self.search_motion(
421 Motion::ZedSearchResult {
422 prior_selections,
423 new_selections,
424 },
425 window,
426 cx,
427 );
428 }
429
430 pub fn move_to_internal(
431 &mut self,
432 direction: Direction,
433 case_sensitive: bool,
434 whole_word: bool,
435 regex: bool,
436 move_cursor: bool,
437 window: &mut Window,
438 cx: &mut Context<Self>,
439 ) {
440 let Some(pane) = self.pane(window, cx) else {
441 return;
442 };
443 let count = Vim::take_count(cx).unwrap_or(1);
444 Vim::take_forced_motion(cx);
445
446 if self.search.cmd_f_search {
447 self.search.cmd_f_search = false;
448 if self.mode.is_visual() {
449 self.switch_mode(Mode::Normal, false, window, cx);
450 }
451 self.sync_vim_settings(window, cx);
452 }
453
454 let prior_selections = self.editor_selections(window, cx);
455 let vim = cx.entity();
456
457 let searched = pane.update(cx, |pane, cx| {
458 self.search.direction = direction;
459 let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() else {
460 return false;
461 };
462 let search = search_bar.update(cx, |search_bar, cx| {
463 let mut options = SearchOptions::NONE;
464 if case_sensitive {
465 options |= SearchOptions::CASE_SENSITIVE;
466 }
467 if regex {
468 options |= SearchOptions::REGEX;
469 }
470 if whole_word {
471 options |= SearchOptions::WHOLE_WORD;
472 }
473 if !search_bar.show(window, cx) {
474 return None;
475 }
476 let Some(query) = search_bar.query_suggestion(
477 Some(settings::SeedQuerySetting::Always),
478 window,
479 cx,
480 ) else {
481 drop(search_bar.search("", None, false, window, cx));
482 return None;
483 };
484
485 let query = regex::escape(&query);
486 Some(search_bar.search(&query, Some(options), true, window, cx))
487 });
488
489 let Some(search) = search else { return false };
490
491 if move_cursor {
492 let search_bar = search_bar.downgrade();
493 cx.spawn_in(window, async move |_, cx| {
494 search.await?;
495 search_bar.update_in(cx, |search_bar, window, cx| {
496 search_bar.select_match(direction, count, window, cx);
497
498 vim.update(cx, |vim, cx| {
499 let new_selections = vim.editor_selections(window, cx);
500 vim.search_motion(
501 Motion::ZedSearchResult {
502 prior_selections,
503 new_selections,
504 },
505 window,
506 cx,
507 )
508 });
509 })?;
510 anyhow::Ok(())
511 })
512 .detach_and_log_err(cx);
513 }
514 true
515 });
516 if !searched {
517 self.clear_operator(window, cx)
518 }
519
520 if self.mode.is_visual() {
521 self.switch_mode(Mode::Normal, false, window, cx)
522 }
523 }
524
525 fn find_command(&mut self, action: &FindCommand, window: &mut Window, cx: &mut Context<Self>) {
526 let Some(pane) = self.pane(window, cx) else {
527 return;
528 };
529 pane.update(cx, |pane, cx| {
530 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
531 let search = search_bar.update(cx, |search_bar, cx| {
532 if !search_bar.show(window, cx) {
533 return None;
534 }
535 let mut query = action.query.clone();
536 if query.is_empty() {
537 query = search_bar.query(cx);
538 };
539
540 let mut options = SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE;
541 if search_bar.should_use_smartcase_search(cx) {
542 options.set(
543 SearchOptions::CASE_SENSITIVE,
544 search_bar.is_contains_uppercase(&query),
545 );
546 }
547
548 Some(search_bar.search(&query, Some(options), true, window, cx))
549 });
550 let Some(search) = search else { return };
551 let search_bar = search_bar.downgrade();
552 let direction = if action.backwards {
553 Direction::Prev
554 } else {
555 Direction::Next
556 };
557 cx.spawn_in(window, async move |_, cx| {
558 search.await?;
559 search_bar.update_in(cx, |search_bar, window, cx| {
560 search_bar.select_match(direction, 1, window, cx)
561 })?;
562 anyhow::Ok(())
563 })
564 .detach_and_log_err(cx);
565 }
566 })
567 }
568
569 fn replace_command(
570 &mut self,
571 action: &ReplaceCommand,
572 window: &mut Window,
573 cx: &mut Context<Self>,
574 ) {
575 let replacement = action.replacement.clone();
576 let Some(((pane, workspace), editor)) = self
577 .pane(window, cx)
578 .zip(self.workspace(window, cx))
579 .zip(self.editor())
580 else {
581 return;
582 };
583 if let Some(result) = self.update_editor(cx, |vim, editor, cx| {
584 let range = action.range.buffer_range(vim, editor, window, cx)?;
585 let snapshot = editor.snapshot(window, cx);
586 let snapshot = snapshot.buffer_snapshot();
587 let end_point = Point::new(range.end.0, snapshot.line_len(range.end));
588 let range = snapshot.anchor_before(Point::new(range.start.0, 0))
589 ..snapshot.anchor_after(end_point);
590 editor.set_search_within_ranges(&[range], cx);
591 anyhow::Ok(())
592 }) {
593 workspace.update(cx, |workspace, cx| {
594 result.notify_err(workspace, cx);
595 })
596 }
597 let Some(search_bar) = pane.update(cx, |pane, cx| {
598 pane.toolbar().read(cx).item_of_type::<BufferSearchBar>()
599 }) else {
600 return;
601 };
602 let mut options = SearchOptions::REGEX;
603 let search = search_bar.update(cx, |search_bar, cx| {
604 if !search_bar.show(window, cx) {
605 return None;
606 }
607
608 let search = if replacement.search.is_empty() {
609 search_bar.query(cx)
610 } else {
611 replacement.search
612 };
613
614 if let Some(case) = replacement.case_sensitive {
615 options.set(SearchOptions::CASE_SENSITIVE, case)
616 } else if search_bar.should_use_smartcase_search(cx) {
617 options.set(
618 SearchOptions::CASE_SENSITIVE,
619 search_bar.is_contains_uppercase(&search),
620 );
621 } else {
622 // Fallback: no explicit i/I flags and smartcase disabled;
623 // use global editor.search.case_sensitive.
624 options.set(
625 SearchOptions::CASE_SENSITIVE,
626 EditorSettings::get_global(cx).search.case_sensitive,
627 )
628 }
629
630 // gdefault inverts the behavior of the 'g' flag.
631 let replace_all = VimSettings::get_global(cx).gdefault != replacement.flag_g;
632 if !replace_all {
633 options.set(SearchOptions::ONE_MATCH_PER_LINE, true);
634 }
635
636 search_bar.set_replacement(Some(&replacement.replacement), cx);
637 if replacement.flag_c {
638 search_bar.focus_replace(window, cx);
639 }
640 Some(search_bar.search(&search, Some(options), true, window, cx))
641 });
642 if replacement.flag_n {
643 self.move_cursor(
644 Motion::StartOfLine {
645 display_lines: false,
646 },
647 None,
648 window,
649 cx,
650 );
651 return;
652 }
653 let Some(search) = search else { return };
654 let search_bar = search_bar.downgrade();
655 cx.spawn_in(window, async move |vim, cx| {
656 search.await?;
657 search_bar.update_in(cx, |search_bar, window, cx| {
658 if replacement.flag_c {
659 search_bar.select_first_match(window, cx);
660 return;
661 }
662 search_bar.select_last_match(window, cx);
663 search_bar.replace_all(&Default::default(), window, cx);
664 editor.update(cx, |editor, cx| editor.clear_search_within_ranges(cx));
665 let _ = search_bar.search(&search_bar.query(cx), None, false, window, cx);
666 vim.update(cx, |vim, cx| {
667 vim.move_cursor(
668 Motion::StartOfLine {
669 display_lines: false,
670 },
671 None,
672 window,
673 cx,
674 )
675 })
676 .ok();
677
678 // Disable the `ONE_MATCH_PER_LINE` search option when finished, as
679 // this is not properly supported outside of vim mode, and
680 // not disabling it makes the "Replace All Matches" button
681 // actually replace only the first match on each line.
682 options.set(SearchOptions::ONE_MATCH_PER_LINE, false);
683 search_bar.set_search_options(options, cx);
684 })
685 })
686 .detach_and_log_err(cx);
687 }
688}
689
690impl Replacement {
691 // convert a vim query into something more usable by zed.
692 // we don't attempt to fully convert between the two regex syntaxes,
693 // but we do flip \( and \) to ( and ) (and vice-versa) in the pattern,
694 // convert \0..\9 to $0..$9 in the replacement so that common idioms work,
695 // and escape literal `$` to `$$` in the replacement so vim's literal `$`
696 // is not interpreted as a Rust regex capture-group reference.
697 pub(crate) fn parse(mut chars: Peekable<Chars>) -> Option<Replacement> {
698 let delimiter = chars
699 .next()
700 .filter(|c| !c.is_alphanumeric() && *c != '"' && *c != '|' && *c != '\'')?;
701
702 let mut search = String::new();
703 let mut replacement = String::new();
704 let mut flags = String::new();
705
706 let mut buffer = &mut search;
707
708 let mut escaped = false;
709 // 0 - parsing search
710 // 1 - parsing replacement
711 // 2 - parsing flags
712 let mut phase = 0;
713
714 for c in chars {
715 if escaped {
716 escaped = false;
717 if phase == 1 && c.is_ascii_digit() {
718 buffer.push('$')
719 } else if phase == 1 && c == '$' {
720 // Second '$' escapes by fallthrough
721 buffer.push('$')
722 // unescape escaped parens
723 } else if phase == 0 && (c == '(' || c == ')') {
724 } else if c != delimiter {
725 buffer.push('\\')
726 }
727 buffer.push(c)
728 } else if c == '\\' {
729 escaped = true;
730 } else if c == delimiter {
731 if phase == 0 {
732 buffer = &mut replacement;
733 phase = 1;
734 } else if phase == 1 {
735 buffer = &mut flags;
736 phase = 2;
737 } else {
738 break;
739 }
740 } else {
741 // escape unescaped parens
742 if phase == 0 && (c == '(' || c == ')') {
743 buffer.push('\\')
744 } else if phase == 1 && c == '$' {
745 // '$' is not special in the replacement clause,
746 // so we also escape here.
747 buffer.push('$')
748 }
749 buffer.push(c)
750 }
751 }
752
753 let mut replacement = Replacement {
754 search,
755 replacement,
756 case_sensitive: None,
757 flag_g: false,
758 flag_n: false,
759 flag_c: false,
760 };
761
762 for c in flags.chars() {
763 match c {
764 'g' => replacement.flag_g = !replacement.flag_g,
765 'n' => replacement.flag_n = true,
766 'c' => replacement.flag_c = true,
767 'i' => replacement.case_sensitive = Some(false),
768 'I' => replacement.case_sensitive = Some(true),
769 _ => {}
770 }
771 }
772
773 Some(replacement)
774 }
775}
776
777#[cfg(test)]
778mod test {
779 use std::time::Duration;
780
781 use crate::{
782 state::Mode,
783 test::{NeovimBackedTestContext, VimTestContext},
784 };
785 use editor::{DisplayPoint, display_map::DisplayRow};
786
787 use indoc::indoc;
788 use search::BufferSearchBar;
789 use settings::SettingsStore;
790
791 #[test]
792 fn test_replacement_parse_escaped_dollar() {
793 let parsed = super::Replacement::parse(r"/\$test/\$rest/g".chars().peekable())
794 .expect("parse should succeed");
795
796 assert_eq!(parsed.search, r"\$test");
797 assert_eq!(parsed.replacement, "$$rest");
798 assert!(parsed.flag_g);
799 }
800
801 #[gpui::test]
802 async fn test_move_to_next(cx: &mut gpui::TestAppContext) {
803 let mut cx = VimTestContext::new(cx, true).await;
804 cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal);
805
806 cx.simulate_keystrokes("*");
807 cx.run_until_parked();
808 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
809
810 cx.simulate_keystrokes("*");
811 cx.run_until_parked();
812 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
813
814 cx.simulate_keystrokes("#");
815 cx.run_until_parked();
816 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
817
818 cx.simulate_keystrokes("#");
819 cx.run_until_parked();
820 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
821
822 cx.simulate_keystrokes("2 *");
823 cx.run_until_parked();
824 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
825
826 cx.simulate_keystrokes("g *");
827 cx.run_until_parked();
828 cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
829
830 cx.simulate_keystrokes("n");
831 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
832
833 cx.simulate_keystrokes("g #");
834 cx.run_until_parked();
835 cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
836 }
837
838 #[gpui::test]
839 async fn test_move_to_next_with_no_search_wrap(cx: &mut gpui::TestAppContext) {
840 let mut cx = VimTestContext::new(cx, true).await;
841
842 cx.update_global(|store: &mut SettingsStore, cx| {
843 store.update_user_settings(cx, |s| s.editor.search_wrap = Some(false));
844 });
845
846 cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal);
847
848 cx.simulate_keystrokes("*");
849 cx.run_until_parked();
850 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
851
852 cx.simulate_keystrokes("*");
853 cx.run_until_parked();
854 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
855
856 cx.simulate_keystrokes("#");
857 cx.run_until_parked();
858 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
859
860 cx.simulate_keystrokes("3 *");
861 cx.run_until_parked();
862 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
863
864 cx.simulate_keystrokes("g *");
865 cx.run_until_parked();
866 cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
867
868 cx.simulate_keystrokes("n");
869 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
870
871 cx.simulate_keystrokes("g #");
872 cx.run_until_parked();
873 cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
874 }
875
876 #[gpui::test]
877 async fn test_search(cx: &mut gpui::TestAppContext) {
878 let mut cx = VimTestContext::new(cx, true).await;
879
880 cx.set_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
881 cx.simulate_keystrokes("/ c c");
882
883 let search_bar = cx.workspace(|workspace, _, cx| {
884 workspace
885 .active_pane()
886 .read(cx)
887 .toolbar()
888 .read(cx)
889 .item_of_type::<BufferSearchBar>()
890 .expect("Buffer search bar should be deployed")
891 });
892
893 cx.update_entity(search_bar, |bar, _window, cx| {
894 assert_eq!(bar.query(cx), "cc");
895 });
896
897 cx.run_until_parked();
898
899 cx.update_editor(|editor, window, cx| {
900 let highlights = editor.all_text_background_highlights(window, cx);
901 assert_eq!(3, highlights.len());
902 assert_eq!(
903 DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 2),
904 highlights[0].0
905 )
906 });
907
908 cx.simulate_keystrokes("enter");
909 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
910
911 // n to go to next/N to go to previous
912 cx.simulate_keystrokes("n");
913 cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
914 cx.simulate_keystrokes("shift-n");
915 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
916
917 // ?<enter> to go to previous
918 cx.simulate_keystrokes("? enter");
919 cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
920 cx.simulate_keystrokes("? enter");
921 cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
922
923 // /<enter> to go to next
924 cx.simulate_keystrokes("/ enter");
925 cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
926
927 // ?{search}<enter> to search backwards
928 cx.simulate_keystrokes("? b enter");
929 cx.assert_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
930
931 // works with counts
932 cx.simulate_keystrokes("4 / c");
933 cx.simulate_keystrokes("enter");
934 cx.assert_state("aa\nbb\ncc\ncˇc\ncc\n", Mode::Normal);
935
936 // check that searching resumes from cursor, not previous match
937 cx.set_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
938 cx.simulate_keystrokes("/ d");
939 cx.simulate_keystrokes("enter");
940 cx.assert_state("aa\nbb\nˇdd\ncc\nbb\n", Mode::Normal);
941 cx.update_editor(|editor, window, cx| {
942 editor.move_to_beginning(&Default::default(), window, cx)
943 });
944 cx.assert_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
945 cx.simulate_keystrokes("/ b");
946 cx.simulate_keystrokes("enter");
947 cx.assert_state("aa\nˇbb\ndd\ncc\nbb\n", Mode::Normal);
948
949 // check that searching switches to normal mode if in visual mode
950 cx.set_state("ˇone two one", Mode::Normal);
951 cx.simulate_keystrokes("v l l");
952 cx.assert_editor_state("«oneˇ» two one");
953 cx.simulate_keystrokes("*");
954 cx.assert_state("one two ˇone", Mode::Normal);
955
956 // check that a backward search after last match works correctly
957 cx.set_state("aa\naa\nbbˇ", Mode::Normal);
958 cx.simulate_keystrokes("? a a");
959 cx.simulate_keystrokes("enter");
960 cx.assert_state("aa\nˇaa\nbb", Mode::Normal);
961
962 // check that searching with unable search wrap
963 cx.update_global(|store: &mut SettingsStore, cx| {
964 store.update_user_settings(cx, |s| s.editor.search_wrap = Some(false));
965 });
966 cx.set_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
967 cx.simulate_keystrokes("/ c c enter");
968
969 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
970
971 // n to go to next/N to go to previous
972 cx.simulate_keystrokes("n");
973 cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
974 cx.simulate_keystrokes("shift-n");
975 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
976
977 // ?<enter> to go to previous
978 cx.simulate_keystrokes("? enter");
979 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
980 cx.simulate_keystrokes("? enter");
981 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
982 }
983
984 #[gpui::test]
985 async fn test_non_vim_search(cx: &mut gpui::TestAppContext) {
986 let mut cx = VimTestContext::new(cx, false).await;
987 cx.cx.set_state("ˇone one one one");
988 cx.run_until_parked();
989 cx.simulate_keystrokes("cmd-f");
990 cx.run_until_parked();
991
992 cx.assert_editor_state("«oneˇ» one one one");
993 cx.simulate_keystrokes("enter");
994 cx.assert_editor_state("one «oneˇ» one one");
995 cx.simulate_keystrokes("shift-enter");
996 cx.assert_editor_state("«oneˇ» one one one");
997 }
998
999 #[gpui::test]
1000 async fn test_non_vim_search_in_vim_mode(cx: &mut gpui::TestAppContext) {
1001 let mut cx = VimTestContext::new(cx, true).await;
1002 cx.cx.set_state("ˇone one one one");
1003 cx.run_until_parked();
1004 cx.simulate_keystrokes("cmd-f");
1005 cx.run_until_parked();
1006
1007 cx.assert_state("«oneˇ» one one one", Mode::Visual);
1008 cx.simulate_keystrokes("enter");
1009 cx.run_until_parked();
1010 cx.assert_state("one «oneˇ» one one", Mode::Visual);
1011 cx.simulate_keystrokes("shift-enter");
1012 cx.run_until_parked();
1013 cx.assert_state("«oneˇ» one one one", Mode::Visual);
1014
1015 cx.simulate_keystrokes("escape");
1016 cx.run_until_parked();
1017 cx.assert_state("«oneˇ» one one one", Mode::Visual);
1018 }
1019
1020 #[gpui::test]
1021 async fn test_non_vim_search_in_vim_insert_mode(cx: &mut gpui::TestAppContext) {
1022 let mut cx = VimTestContext::new(cx, true).await;
1023 cx.set_state("ˇone one one one", Mode::Insert);
1024 cx.run_until_parked();
1025 cx.simulate_keystrokes("cmd-f");
1026 cx.run_until_parked();
1027
1028 cx.assert_state("«oneˇ» one one one", Mode::Insert);
1029 cx.simulate_keystrokes("enter");
1030 cx.run_until_parked();
1031 cx.assert_state("one «oneˇ» one one", Mode::Insert);
1032
1033 cx.simulate_keystrokes("escape");
1034 cx.run_until_parked();
1035 cx.assert_state("one «oneˇ» one one", Mode::Insert);
1036 }
1037
1038 #[gpui::test]
1039 async fn test_n_after_cmd_f_search(cx: &mut gpui::TestAppContext) {
1040 let mut cx = VimTestContext::new(cx, true).await;
1041 cx.set_state("ˇone two one two one", Mode::Normal);
1042 cx.run_until_parked();
1043
1044 // Use cmd+f to search (non-vim style)
1045 cx.simulate_keystrokes("cmd-f");
1046 cx.run_until_parked();
1047 cx.simulate_keystrokes("escape");
1048 cx.run_until_parked();
1049
1050 // Now use n to go to next match — should move cursor, not create selection
1051 cx.simulate_keystrokes("n");
1052 cx.run_until_parked();
1053 cx.assert_state("one two ˇone two one", Mode::Normal);
1054
1055 cx.simulate_keystrokes("n");
1056 cx.run_until_parked();
1057 cx.assert_state("one two one two ˇone", Mode::Normal);
1058 }
1059
1060 #[gpui::test]
1061 async fn test_star_after_cmd_f_search(cx: &mut gpui::TestAppContext) {
1062 let mut cx = VimTestContext::new(cx, true).await;
1063 cx.set_state("ˇone two one two one", Mode::Normal);
1064 cx.run_until_parked();
1065
1066 // Use cmd+f to search (non-vim style)
1067 cx.simulate_keystrokes("cmd-f");
1068 cx.run_until_parked();
1069 cx.simulate_keystrokes("escape");
1070 cx.run_until_parked();
1071
1072 // Now use * to search under cursor — should move cursor, not create selection
1073 cx.simulate_keystrokes("*");
1074 cx.run_until_parked();
1075 cx.assert_state("one two ˇone two one", Mode::Normal);
1076 }
1077
1078 #[gpui::test]
1079 async fn test_visual_star_hash(cx: &mut gpui::TestAppContext) {
1080 let mut cx = NeovimBackedTestContext::new(cx).await;
1081
1082 cx.set_shared_state("ˇa.c. abcd a.c. abcd").await;
1083 cx.simulate_shared_keystrokes("v 3 l *").await;
1084 cx.shared_state().await.assert_eq("a.c. abcd ˇa.c. abcd");
1085 }
1086
1087 #[gpui::test]
1088 async fn test_d_search(cx: &mut gpui::TestAppContext) {
1089 let mut cx = NeovimBackedTestContext::new(cx).await;
1090
1091 cx.set_shared_state("ˇa.c. abcd a.c. abcd").await;
1092 cx.simulate_shared_keystrokes("d / c d").await;
1093 cx.simulate_shared_keystrokes("enter").await;
1094 cx.shared_state().await.assert_eq("ˇcd a.c. abcd");
1095 }
1096
1097 #[gpui::test]
1098 async fn test_backwards_n(cx: &mut gpui::TestAppContext) {
1099 let mut cx = NeovimBackedTestContext::new(cx).await;
1100
1101 cx.set_shared_state("ˇa b a b a b a").await;
1102 cx.simulate_shared_keystrokes("*").await;
1103 cx.simulate_shared_keystrokes("n").await;
1104 cx.shared_state().await.assert_eq("a b a b ˇa b a");
1105 cx.simulate_shared_keystrokes("#").await;
1106 cx.shared_state().await.assert_eq("a b ˇa b a b a");
1107 cx.simulate_shared_keystrokes("n").await;
1108 cx.shared_state().await.assert_eq("ˇa b a b a b a");
1109 }
1110
1111 #[gpui::test]
1112 async fn test_v_search(cx: &mut gpui::TestAppContext) {
1113 let mut cx = NeovimBackedTestContext::new(cx).await;
1114
1115 cx.set_shared_state("ˇa.c. abcd a.c. abcd").await;
1116 cx.simulate_shared_keystrokes("v / c d").await;
1117 cx.simulate_shared_keystrokes("enter").await;
1118 cx.shared_state().await.assert_eq("«a.c. abcˇ»d a.c. abcd");
1119
1120 cx.set_shared_state("a a aˇ a a a").await;
1121 cx.simulate_shared_keystrokes("v / a").await;
1122 cx.simulate_shared_keystrokes("enter").await;
1123 cx.shared_state().await.assert_eq("a a a« aˇ» a a");
1124 cx.simulate_shared_keystrokes("/ enter").await;
1125 cx.shared_state().await.assert_eq("a a a« a aˇ» a");
1126 cx.simulate_shared_keystrokes("? enter").await;
1127 cx.shared_state().await.assert_eq("a a a« aˇ» a a");
1128 cx.simulate_shared_keystrokes("? enter").await;
1129 cx.shared_state().await.assert_eq("a a «ˇa »a a a");
1130 cx.simulate_shared_keystrokes("/ enter").await;
1131 cx.shared_state().await.assert_eq("a a a« aˇ» a a");
1132 cx.simulate_shared_keystrokes("/ enter").await;
1133 cx.shared_state().await.assert_eq("a a a« a aˇ» a");
1134 }
1135
1136 #[gpui::test]
1137 async fn test_v_search_aa(cx: &mut gpui::TestAppContext) {
1138 let mut cx = NeovimBackedTestContext::new(cx).await;
1139
1140 cx.set_shared_state("ˇaa aa").await;
1141 cx.simulate_shared_keystrokes("v / a a").await;
1142 cx.simulate_shared_keystrokes("enter").await;
1143 cx.shared_state().await.assert_eq("«aa aˇ»a");
1144 }
1145
1146 #[gpui::test]
1147 async fn test_visual_block_search(cx: &mut gpui::TestAppContext) {
1148 let mut cx = NeovimBackedTestContext::new(cx).await;
1149
1150 cx.set_shared_state(indoc! {
1151 "ˇone two
1152 three four
1153 five six
1154 "
1155 })
1156 .await;
1157 cx.simulate_shared_keystrokes("ctrl-v j / f").await;
1158 cx.simulate_shared_keystrokes("enter").await;
1159 cx.shared_state().await.assert_eq(indoc! {
1160 "«one twoˇ»
1161 «three fˇ»our
1162 five six
1163 "
1164 });
1165 }
1166
1167 #[gpui::test]
1168 async fn test_replace_with_range_at_start(cx: &mut gpui::TestAppContext) {
1169 let mut cx = NeovimBackedTestContext::new(cx).await;
1170
1171 cx.set_shared_state(indoc! {
1172 "ˇa
1173 a
1174 a
1175 a
1176 a
1177 a
1178 a
1179 "
1180 })
1181 .await;
1182 cx.simulate_shared_keystrokes(": 2 , 5 s / ^ / b").await;
1183 cx.simulate_shared_keystrokes("enter").await;
1184 cx.shared_state().await.assert_eq(indoc! {
1185 "a
1186 ba
1187 ba
1188 ba
1189 ˇba
1190 a
1191 a
1192 "
1193 });
1194
1195 cx.simulate_shared_keystrokes("/ a").await;
1196 cx.simulate_shared_keystrokes("enter").await;
1197 cx.shared_state().await.assert_eq(indoc! {
1198 "a
1199 ba
1200 ba
1201 ba
1202 bˇa
1203 a
1204 a
1205 "
1206 });
1207 }
1208
1209 #[gpui::test]
1210 async fn test_search_skipping(cx: &mut gpui::TestAppContext) {
1211 let mut cx = NeovimBackedTestContext::new(cx).await;
1212 cx.set_shared_state(indoc! {
1213 "ˇaa aa aa"
1214 })
1215 .await;
1216
1217 cx.simulate_shared_keystrokes("/ a a").await;
1218 cx.simulate_shared_keystrokes("enter").await;
1219
1220 cx.shared_state().await.assert_eq(indoc! {
1221 "aa ˇaa aa"
1222 });
1223
1224 cx.simulate_shared_keystrokes("left / a a").await;
1225 cx.simulate_shared_keystrokes("enter").await;
1226
1227 cx.shared_state().await.assert_eq(indoc! {
1228 "aa ˇaa aa"
1229 });
1230 }
1231
1232 #[gpui::test]
1233 async fn test_replace_n(cx: &mut gpui::TestAppContext) {
1234 let mut cx = NeovimBackedTestContext::new(cx).await;
1235 cx.set_shared_state(indoc! {
1236 "ˇaa
1237 bb
1238 aa"
1239 })
1240 .await;
1241
1242 cx.simulate_shared_keystrokes(": s / b b / d d / n").await;
1243 cx.simulate_shared_keystrokes("enter").await;
1244
1245 cx.shared_state().await.assert_eq(indoc! {
1246 "ˇaa
1247 bb
1248 aa"
1249 });
1250
1251 let search_bar = cx.update_workspace(|workspace, _, cx| {
1252 workspace.active_pane().update(cx, |pane, cx| {
1253 pane.toolbar()
1254 .read(cx)
1255 .item_of_type::<BufferSearchBar>()
1256 .unwrap()
1257 })
1258 });
1259 cx.update_entity(search_bar, |search_bar, _, cx| {
1260 assert!(!search_bar.is_dismissed());
1261 assert_eq!(search_bar.query(cx), "bb".to_string());
1262 assert_eq!(search_bar.replacement(cx), "dd".to_string());
1263 })
1264 }
1265
1266 #[gpui::test]
1267 async fn test_replace_literal_dollar(cx: &mut gpui::TestAppContext) {
1268 let mut cx = NeovimBackedTestContext::new(cx).await;
1269 cx.set_shared_state(indoc! {
1270 "ˇBase=hello
1271 echo $Base"
1272 })
1273 .await;
1274
1275 cx.simulate_shared_keystrokes(
1276 ": % s / \\ $ shift-b a s e / \\ $ shift-b a s e shift-n e w / g",
1277 )
1278 .await;
1279 cx.simulate_shared_keystrokes("enter").await;
1280
1281 cx.shared_state().await.assert_eq(indoc! {
1282 "Base=hello
1283 ˇecho $BaseNew"
1284 });
1285 }
1286
1287 #[gpui::test]
1288 async fn test_replace_g(cx: &mut gpui::TestAppContext) {
1289 let mut cx = NeovimBackedTestContext::new(cx).await;
1290 cx.set_shared_state(indoc! {
1291 "ˇaa aa aa aa
1292 aa
1293 aa"
1294 })
1295 .await;
1296
1297 cx.simulate_shared_keystrokes(": s / a a / b b").await;
1298 cx.simulate_shared_keystrokes("enter").await;
1299 cx.shared_state().await.assert_eq(indoc! {
1300 "ˇbb aa aa aa
1301 aa
1302 aa"
1303 });
1304 cx.simulate_shared_keystrokes(": s / a a / b b / g").await;
1305 cx.simulate_shared_keystrokes("enter").await;
1306 cx.shared_state().await.assert_eq(indoc! {
1307 "ˇbb bb bb bb
1308 aa
1309 aa"
1310 });
1311 }
1312
1313 #[gpui::test]
1314 async fn test_replace_gdefault(cx: &mut gpui::TestAppContext) {
1315 let mut cx = NeovimBackedTestContext::new(cx).await;
1316
1317 // Set the `gdefault` option in both Zed and Neovim.
1318 cx.simulate_shared_keystrokes(": s e t space g d e f a u l t")
1319 .await;
1320 cx.simulate_shared_keystrokes("enter").await;
1321
1322 cx.set_shared_state(indoc! {
1323 "ˇaa aa aa aa
1324 aa
1325 aa"
1326 })
1327 .await;
1328
1329 // With gdefault on, :s/// replaces all matches (like :s///g normally).
1330 cx.simulate_shared_keystrokes(": s / a a / b b").await;
1331 cx.simulate_shared_keystrokes("enter").await;
1332 cx.shared_state().await.assert_eq(indoc! {
1333 "ˇbb bb bb bb
1334 aa
1335 aa"
1336 });
1337
1338 // With gdefault on, :s///g replaces only the first match.
1339 cx.simulate_shared_keystrokes(": s / b b / c c / g").await;
1340 cx.simulate_shared_keystrokes("enter").await;
1341 cx.shared_state().await.assert_eq(indoc! {
1342 "ˇcc bb bb bb
1343 aa
1344 aa"
1345 });
1346
1347 // Each successive `/g` flag should invert the one before it.
1348 cx.simulate_shared_keystrokes(": s / b b / d d / g g").await;
1349 cx.simulate_shared_keystrokes("enter").await;
1350 cx.shared_state().await.assert_eq(indoc! {
1351 "ˇcc dd dd dd
1352 aa
1353 aa"
1354 });
1355
1356 cx.simulate_shared_keystrokes(": s / c c / e e / g g g")
1357 .await;
1358 cx.simulate_shared_keystrokes("enter").await;
1359 cx.shared_state().await.assert_eq(indoc! {
1360 "ˇee dd dd dd
1361 aa
1362 aa"
1363 });
1364 }
1365
1366 #[gpui::test]
1367 async fn test_replace_c(cx: &mut gpui::TestAppContext) {
1368 let mut cx = VimTestContext::new(cx, true).await;
1369 cx.set_state(
1370 indoc! {
1371 "ˇaa
1372 aa
1373 aa"
1374 },
1375 Mode::Normal,
1376 );
1377
1378 cx.simulate_keystrokes("v j : s / a a / d d / c");
1379 cx.simulate_keystrokes("enter");
1380
1381 cx.assert_state(
1382 indoc! {
1383 "ˇaa
1384 aa
1385 aa"
1386 },
1387 Mode::Normal,
1388 );
1389
1390 cx.simulate_keystrokes("enter");
1391
1392 cx.assert_state(
1393 indoc! {
1394 "dd
1395 ˇaa
1396 aa"
1397 },
1398 Mode::Normal,
1399 );
1400
1401 cx.simulate_keystrokes("enter");
1402 cx.assert_state(
1403 indoc! {
1404 "dd
1405 ddˇ
1406 aa"
1407 },
1408 Mode::Normal,
1409 );
1410 cx.simulate_keystrokes("enter");
1411 cx.assert_state(
1412 indoc! {
1413 "dd
1414 ddˇ
1415 aa"
1416 },
1417 Mode::Normal,
1418 );
1419 }
1420
1421 #[gpui::test]
1422 async fn test_replace_with_range(cx: &mut gpui::TestAppContext) {
1423 let mut cx = NeovimBackedTestContext::new(cx).await;
1424
1425 cx.set_shared_state(indoc! {
1426 "ˇa
1427 a
1428 a
1429 a
1430 a
1431 a
1432 a
1433 "
1434 })
1435 .await;
1436 cx.simulate_shared_keystrokes(": 2 , 5 s / a / b").await;
1437 cx.simulate_shared_keystrokes("enter").await;
1438 cx.shared_state().await.assert_eq(indoc! {
1439 "a
1440 b
1441 b
1442 b
1443 ˇb
1444 a
1445 a
1446 "
1447 });
1448 cx.executor().advance_clock(Duration::from_millis(250));
1449 cx.run_until_parked();
1450
1451 cx.simulate_shared_keystrokes("/ a enter").await;
1452 cx.shared_state().await.assert_eq(indoc! {
1453 "a
1454 b
1455 b
1456 b
1457 b
1458 ˇa
1459 a
1460 "
1461 });
1462 }
1463
1464 #[gpui::test]
1465 async fn test_search_dismiss_restores_cursor(cx: &mut gpui::TestAppContext) {
1466 let mut cx = VimTestContext::new(cx, true).await;
1467 cx.set_state("ˇhello world\nfoo bar\nhello again\n", Mode::Normal);
1468
1469 // Move cursor to line 2
1470 cx.simulate_keystrokes("j");
1471 cx.run_until_parked();
1472 cx.assert_state("hello world\nˇfoo bar\nhello again\n", Mode::Normal);
1473
1474 // Open search
1475 cx.simulate_keystrokes("/");
1476 cx.run_until_parked();
1477
1478 // Dismiss search with Escape - cursor should return to line 2
1479 cx.simulate_keystrokes("escape");
1480 cx.run_until_parked();
1481 // Cursor should be restored to line 2 where it was when search was opened
1482 cx.assert_state("hello world\nˇfoo bar\nhello again\n", Mode::Normal);
1483 }
1484
1485 #[gpui::test]
1486 async fn test_search_dismiss_restores_cursor_no_matches(cx: &mut gpui::TestAppContext) {
1487 let mut cx = VimTestContext::new(cx, true).await;
1488 cx.set_state("ˇapple\nbanana\ncherry\n", Mode::Normal);
1489
1490 // Move cursor to line 2
1491 cx.simulate_keystrokes("j");
1492 cx.run_until_parked();
1493 cx.assert_state("apple\nˇbanana\ncherry\n", Mode::Normal);
1494
1495 // Open search and type query for something that doesn't exist
1496 cx.simulate_keystrokes("/ n o n e x i s t e n t");
1497 cx.run_until_parked();
1498
1499 // Dismiss search with Escape - cursor should still be at original position
1500 cx.simulate_keystrokes("escape");
1501 cx.run_until_parked();
1502 cx.assert_state("apple\nˇbanana\ncherry\n", Mode::Normal);
1503 }
1504
1505 #[gpui::test]
1506 async fn test_search_dismiss_after_editor_focus_does_not_restore(
1507 cx: &mut gpui::TestAppContext,
1508 ) {
1509 let mut cx = VimTestContext::new(cx, true).await;
1510 cx.set_state("ˇhello world\nfoo bar\nhello again\n", Mode::Normal);
1511
1512 // Move cursor to line 2
1513 cx.simulate_keystrokes("j");
1514 cx.run_until_parked();
1515 cx.assert_state("hello world\nˇfoo bar\nhello again\n", Mode::Normal);
1516
1517 // Open search and type a query that matches line 3
1518 cx.simulate_keystrokes("/ a g a i n");
1519 cx.run_until_parked();
1520
1521 // Simulate the editor gaining focus while search is still open
1522 // This represents the user clicking in the editor
1523 cx.update_editor(|_, window, cx| cx.focus_self(window));
1524 cx.run_until_parked();
1525
1526 // Now dismiss the search bar directly
1527 cx.workspace(|workspace, window, cx| {
1528 let pane = workspace.active_pane().read(cx);
1529 if let Some(search_bar) = pane
1530 .toolbar()
1531 .read(cx)
1532 .item_of_type::<search::BufferSearchBar>()
1533 {
1534 search_bar.update(cx, |bar, cx| {
1535 bar.dismiss(&search::buffer_search::Dismiss, window, cx)
1536 });
1537 }
1538 });
1539 cx.run_until_parked();
1540
1541 // Cursor should NOT be restored to line 2 (row 1) where search was opened.
1542 // Since the user "clicked" in the editor (by focusing it), prior_selections
1543 // was cleared, so dismiss should not restore the cursor.
1544 // The cursor should be at the match location on line 3 (row 2).
1545 cx.assert_state("hello world\nfoo bar\nhello ˇagain\n", Mode::Normal);
1546 }
1547
1548 #[gpui::test]
1549 async fn test_vim_search_respects_search_settings(cx: &mut gpui::TestAppContext) {
1550 let mut cx = VimTestContext::new(cx, true).await;
1551
1552 cx.update_global(|store: &mut SettingsStore, cx| {
1553 store.update_user_settings(cx, |settings| {
1554 settings.vim.get_or_insert_default().use_regex_search = Some(false);
1555 });
1556 });
1557
1558 cx.set_state("ˇcontent", Mode::Normal);
1559 cx.simulate_keystrokes("/");
1560 cx.run_until_parked();
1561
1562 // Verify search options are set from settings
1563 let search_bar = cx.workspace(|workspace, _, cx| {
1564 workspace
1565 .active_pane()
1566 .read(cx)
1567 .toolbar()
1568 .read(cx)
1569 .item_of_type::<BufferSearchBar>()
1570 .expect("Buffer search bar should be active")
1571 });
1572
1573 cx.update_entity(search_bar, |bar, _window, _cx| {
1574 assert!(
1575 !bar.has_search_option(search::SearchOptions::REGEX),
1576 "Vim search open without regex mode"
1577 );
1578 });
1579
1580 cx.simulate_keystrokes("escape");
1581 cx.run_until_parked();
1582
1583 cx.update_global(|store: &mut SettingsStore, cx| {
1584 store.update_user_settings(cx, |settings| {
1585 settings.vim.get_or_insert_default().use_regex_search = Some(true);
1586 });
1587 });
1588
1589 cx.simulate_keystrokes("/");
1590 cx.run_until_parked();
1591
1592 let search_bar = cx.workspace(|workspace, _, cx| {
1593 workspace
1594 .active_pane()
1595 .read(cx)
1596 .toolbar()
1597 .read(cx)
1598 .item_of_type::<BufferSearchBar>()
1599 .expect("Buffer search bar should be active")
1600 });
1601
1602 cx.update_entity(search_bar, |bar, _window, _cx| {
1603 assert!(
1604 bar.has_search_option(search::SearchOptions::REGEX),
1605 "Vim search opens with regex mode"
1606 );
1607 });
1608 }
1609}
1610