1mod registrar;
2
3use crate::{
4 FocusSearch, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOption,
5 SearchOptions, SearchSource, SelectAllMatches, SelectNextMatch, SelectPreviousMatch,
6 ToggleCaseSensitive, ToggleRegex, ToggleReplace, ToggleSelection, ToggleWholeWord,
7 buffer_search::registrar::WithResultsOrExternalQuery,
8 search_bar::{
9 ActionButtonState, HistoryNavigationDirection, alignment_element,
10 filter_search_results_input, input_base_styles, render_action_button, render_text_input,
11 should_navigate_history,
12 },
13};
14use any_vec::AnyVec;
15use collections::HashMap;
16use editor::{
17 Editor, EditorSettings, MultiBufferOffset, SplittableEditor, ToggleSplitDiff,
18 actions::{Backtab, FoldAll, Tab, ToggleFoldAll, UnfoldAll},
19 scroll::Autoscroll,
20};
21use futures::channel::oneshot;
22use gpui::{
23 Action as _, App, ClickEvent, Context, Entity, EventEmitter, Focusable,
24 InteractiveElement as _, IntoElement, KeyContext, ParentElement as _, Render, ScrollHandle,
25 Styled, Subscription, Task, WeakEntity, Window, div,
26};
27use language::{Language, LanguageRegistry};
28use project::{
29 search::SearchQuery,
30 search_history::{SearchHistory, SearchHistoryCursor},
31};
32
33use fs::Fs;
34use settings::{DiffViewStyle, Settings, update_settings_file};
35use std::{any::TypeId, sync::Arc};
36use zed_actions::{
37 OpenSettingsAt, outline::ToggleOutline, workspace::CopyPath, workspace::CopyRelativePath,
38};
39
40use ui::{
41 BASE_REM_SIZE_IN_PX, IconButtonShape, PlatformStyle, TextSize, Tooltip, prelude::*,
42 render_modifiers, utils::SearchInputWidth,
43};
44use util::{ResultExt, paths::PathMatcher};
45use workspace::{
46 ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
47 item::{ItemBufferKind, ItemHandle},
48 searchable::{
49 Direction, FilteredSearchRange, SearchEvent, SearchToken, SearchableItemHandle,
50 WeakSearchableItemHandle,
51 },
52};
53
54pub use registrar::{DivRegistrar, register_pane_search_actions};
55use registrar::{ForDeployed, ForDismissed, SearchActionsRegistrar};
56
57const MAX_BUFFER_SEARCH_HISTORY_SIZE: usize = 50;
58
59pub use zed_actions::buffer_search::{
60 Deploy, DeployReplace, Dismiss, FocusEditor, UseSelectionForFind,
61};
62
63pub enum Event {
64 UpdateLocation,
65 Dismissed,
66}
67
68pub fn init(cx: &mut App) {
69 cx.observe_new(|workspace: &mut Workspace, _, _| BufferSearchBar::register(workspace))
70 .detach();
71}
72
73pub struct BufferSearchBar {
74 query_editor: Entity<Editor>,
75 query_editor_focused: bool,
76 replacement_editor: Entity<Editor>,
77 replacement_editor_focused: bool,
78 active_searchable_item: Option<Box<dyn SearchableItemHandle>>,
79 active_match_index: Option<usize>,
80 #[cfg(target_os = "macos")]
81 active_searchable_item_subscriptions: Option<[Subscription; 2]>,
82 #[cfg(not(target_os = "macos"))]
83 active_searchable_item_subscriptions: Option<Subscription>,
84 #[cfg(target_os = "macos")]
85 pending_external_query: Option<(String, SearchOptions)>,
86 active_search: Option<Arc<SearchQuery>>,
87 searchable_items_with_matches:
88 HashMap<Box<dyn WeakSearchableItemHandle>, (AnyVec<dyn Send>, SearchToken)>,
89 pending_search: Option<Task<()>>,
90 search_options: SearchOptions,
91 default_options: SearchOptions,
92 configured_options: SearchOptions,
93 query_error: Option<String>,
94 dismissed: bool,
95 search_history: SearchHistory,
96 search_history_cursor: SearchHistoryCursor,
97 replace_enabled: bool,
98 selection_search_enabled: Option<FilteredSearchRange>,
99 scroll_handle: ScrollHandle,
100 regex_language: Option<Arc<Language>>,
101 splittable_editor: Option<WeakEntity<SplittableEditor>>,
102 _splittable_editor_subscription: Option<Subscription>,
103}
104
105impl EventEmitter<Event> for BufferSearchBar {}
106impl EventEmitter<workspace::ToolbarItemEvent> for BufferSearchBar {}
107impl Render for BufferSearchBar {
108 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
109 let focus_handle = self.focus_handle(cx);
110
111 let has_splittable_editor = self.splittable_editor.is_some();
112 let split_buttons = if has_splittable_editor {
113 self.splittable_editor
114 .as_ref()
115 .and_then(|weak| weak.upgrade())
116 .map(|splittable_editor| {
117 let editor_ref = splittable_editor.read(cx);
118 let diff_view_style = editor_ref.diff_view_style();
119
120 let is_split_set = diff_view_style == DiffViewStyle::Split;
121 let is_split_active = editor_ref.is_split();
122 let min_columns =
123 EditorSettings::get_global(cx).minimum_split_diff_width as u32;
124
125 let split_icon = if is_split_set && !is_split_active {
126 IconName::DiffSplitAuto
127 } else {
128 IconName::DiffSplit
129 };
130
131 h_flex()
132 .gap_1()
133 .child(
134 IconButton::new("diff-unified", IconName::DiffUnified)
135 .icon_size(IconSize::Small)
136 .toggle_state(diff_view_style == DiffViewStyle::Unified)
137 .tooltip(Tooltip::text("Unified"))
138 .on_click({
139 let splittable_editor = splittable_editor.downgrade();
140 move |_, window, cx| {
141 update_settings_file(
142 <dyn Fs>::global(cx),
143 cx,
144 |settings, _| {
145 settings.editor.diff_view_style =
146 Some(DiffViewStyle::Unified);
147 },
148 );
149 if diff_view_style == DiffViewStyle::Split {
150 splittable_editor
151 .update(cx, |editor, cx| {
152 editor.toggle_split(
153 &ToggleSplitDiff,
154 window,
155 cx,
156 );
157 })
158 .ok();
159 }
160 }
161 }),
162 )
163 .child(
164 IconButton::new("diff-split", split_icon)
165 .toggle_state(diff_view_style == DiffViewStyle::Split)
166 .icon_size(IconSize::Small)
167 .tooltip(Tooltip::element(move |_, cx| {
168 let message = if is_split_set && !is_split_active {
169 format!("Split when wider than {} columns", min_columns)
170 .into()
171 } else {
172 SharedString::from("Split")
173 };
174
175 v_flex()
176 .child(message)
177 .child(
178 h_flex()
179 .gap_0p5()
180 .text_ui_sm(cx)
181 .text_color(Color::Muted.color(cx))
182 .children(render_modifiers(
183 &gpui::Modifiers::secondary_key(),
184 PlatformStyle::platform(),
185 None,
186 Some(TextSize::Small.rems(cx).into()),
187 false,
188 ))
189 .child("click to change min width"),
190 )
191 .into_any()
192 }))
193 .on_click({
194 let splittable_editor = splittable_editor.downgrade();
195 move |_, window, cx| {
196 if window.modifiers().secondary() {
197 window.dispatch_action(
198 OpenSettingsAt {
199 path: "minimum_split_diff_width".to_string(),
200 }
201 .boxed_clone(),
202 cx,
203 );
204 } else {
205 update_settings_file(
206 <dyn Fs>::global(cx),
207 cx,
208 |settings, _| {
209 settings.editor.diff_view_style =
210 Some(DiffViewStyle::Split);
211 },
212 );
213 if diff_view_style == DiffViewStyle::Unified {
214 splittable_editor
215 .update(cx, |editor, cx| {
216 editor.toggle_split(
217 &ToggleSplitDiff,
218 window,
219 cx,
220 );
221 })
222 .ok();
223 }
224 }
225 }
226 }),
227 )
228 })
229 } else {
230 None
231 };
232
233 let collapse_expand_button = if self.needs_expand_collapse_option(cx) {
234 let query_editor_focus = self.query_editor.focus_handle(cx);
235
236 let is_collapsed = self
237 .active_searchable_item
238 .as_ref()
239 .and_then(|item| item.act_as_type(TypeId::of::<Editor>(), cx))
240 .and_then(|item| item.downcast::<Editor>().ok())
241 .map(|editor: Entity<Editor>| editor.read(cx).has_any_buffer_folded(cx))
242 .unwrap_or_default();
243 let (icon, tooltip_label) = if is_collapsed {
244 (IconName::ChevronUpDown, "Expand All Files")
245 } else {
246 (IconName::ChevronDownUp, "Collapse All Files")
247 };
248
249 let collapse_expand_icon_button = |id| {
250 IconButton::new(id, icon)
251 .icon_size(IconSize::Small)
252 .tooltip(move |_, cx| {
253 Tooltip::for_action_in(
254 tooltip_label,
255 &ToggleFoldAll,
256 &query_editor_focus,
257 cx,
258 )
259 })
260 .on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
261 this.toggle_fold_all(&ToggleFoldAll, window, cx);
262 }))
263 };
264
265 if self.dismissed {
266 return h_flex()
267 .pl_0p5()
268 .gap_1()
269 .child(collapse_expand_icon_button(
270 "multibuffer-collapse-expand-empty",
271 ))
272 .when(has_splittable_editor, |this| this.children(split_buttons))
273 .into_any_element();
274 }
275
276 Some(
277 h_flex()
278 .gap_1()
279 .child(collapse_expand_icon_button("multibuffer-collapse-expand"))
280 .children(split_buttons)
281 .into_any_element(),
282 )
283 } else {
284 None
285 };
286
287 let narrow_mode =
288 self.scroll_handle.bounds().size.width / window.rem_size() < 340. / BASE_REM_SIZE_IN_PX;
289
290 let workspace::searchable::SearchOptions {
291 case,
292 word,
293 regex,
294 replacement,
295 selection,
296 select_all,
297 find_in_results,
298 } = self.supported_options(cx);
299
300 self.query_editor.update(cx, |query_editor, cx| {
301 if query_editor.placeholder_text(cx).is_none() {
302 query_editor.set_placeholder_text("Search…", window, cx);
303 }
304 });
305
306 self.replacement_editor.update(cx, |editor, cx| {
307 editor.set_placeholder_text("Replace with…", window, cx);
308 });
309
310 let mut color_override = None;
311 let match_text = self
312 .active_searchable_item
313 .as_ref()
314 .and_then(|searchable_item| {
315 if self.query(cx).is_empty() {
316 return None;
317 }
318 let matches_count = self
319 .searchable_items_with_matches
320 .get(&searchable_item.downgrade())
321 .map(|(matches, _)| matches.len())
322 .unwrap_or(0);
323 if let Some(match_ix) = self.active_match_index {
324 Some(format!("{}/{}", match_ix + 1, matches_count))
325 } else {
326 color_override = Some(Color::Error); // No matches found
327 None
328 }
329 })
330 .unwrap_or_else(|| "0/0".to_string());
331 let should_show_replace_input = self.replace_enabled && replacement;
332 let in_replace = self.replacement_editor.focus_handle(cx).is_focused(window);
333
334 let theme_colors = cx.theme().colors();
335 let query_border = if self.query_error.is_some() {
336 Color::Error.color(cx)
337 } else {
338 theme_colors.border
339 };
340 let replacement_border = theme_colors.border;
341
342 let container_width = window.viewport_size().width;
343 let input_width = SearchInputWidth::calc_width(container_width);
344
345 let input_base_styles =
346 |border_color| input_base_styles(border_color, |div| div.w(input_width));
347
348 let input_style = if find_in_results {
349 filter_search_results_input(query_border, |div| div.w(input_width), cx)
350 } else {
351 input_base_styles(query_border)
352 };
353
354 let query_column = input_style
355 .child(div().flex_1().min_w_0().py_1().child(render_text_input(
356 &self.query_editor,
357 color_override,
358 cx,
359 )))
360 .child(
361 h_flex()
362 .flex_none()
363 .gap_1()
364 .when(case, |div| {
365 div.child(SearchOption::CaseSensitive.as_button(
366 self.search_options,
367 SearchSource::Buffer,
368 focus_handle.clone(),
369 ))
370 })
371 .when(word, |div| {
372 div.child(SearchOption::WholeWord.as_button(
373 self.search_options,
374 SearchSource::Buffer,
375 focus_handle.clone(),
376 ))
377 })
378 .when(regex, |div| {
379 div.child(SearchOption::Regex.as_button(
380 self.search_options,
381 SearchSource::Buffer,
382 focus_handle.clone(),
383 ))
384 }),
385 );
386
387 let mode_column = h_flex()
388 .gap_1()
389 .min_w_64()
390 .when(replacement, |this| {
391 this.child(render_action_button(
392 "buffer-search-bar-toggle",
393 IconName::Replace,
394 self.replace_enabled.then_some(ActionButtonState::Toggled),
395 "Toggle Replace",
396 &ToggleReplace,
397 focus_handle.clone(),
398 ))
399 })
400 .when(selection, |this| {
401 this.child(
402 IconButton::new(
403 "buffer-search-bar-toggle-search-selection-button",
404 IconName::Quote,
405 )
406 .style(ButtonStyle::Subtle)
407 .shape(IconButtonShape::Square)
408 .when(self.selection_search_enabled.is_some(), |button| {
409 button.style(ButtonStyle::Filled)
410 })
411 .on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
412 this.toggle_selection(&ToggleSelection, window, cx);
413 }))
414 .toggle_state(self.selection_search_enabled.is_some())
415 .tooltip({
416 let focus_handle = focus_handle.clone();
417 move |_window, cx| {
418 Tooltip::for_action_in(
419 "Toggle Search Selection",
420 &ToggleSelection,
421 &focus_handle,
422 cx,
423 )
424 }
425 }),
426 )
427 })
428 .when(!find_in_results, |el| {
429 let query_focus = self.query_editor.focus_handle(cx);
430 let matches_column = h_flex()
431 .pl_2()
432 .ml_2()
433 .border_l_1()
434 .border_color(theme_colors.border_variant)
435 .child(render_action_button(
436 "buffer-search-nav-button",
437 ui::IconName::ChevronLeft,
438 self.active_match_index
439 .is_none()
440 .then_some(ActionButtonState::Disabled),
441 "Select Previous Match",
442 &SelectPreviousMatch,
443 query_focus.clone(),
444 ))
445 .child(render_action_button(
446 "buffer-search-nav-button",
447 ui::IconName::ChevronRight,
448 self.active_match_index
449 .is_none()
450 .then_some(ActionButtonState::Disabled),
451 "Select Next Match",
452 &SelectNextMatch,
453 query_focus.clone(),
454 ))
455 .when(!narrow_mode, |this| {
456 this.child(div().ml_2().min_w(rems_from_px(40.)).child(
457 Label::new(match_text).size(LabelSize::Small).color(
458 if self.active_match_index.is_some() {
459 Color::Default
460 } else {
461 Color::Disabled
462 },
463 ),
464 ))
465 });
466
467 el.when(select_all, |el| {
468 el.child(render_action_button(
469 "buffer-search-nav-button",
470 IconName::SelectAll,
471 Default::default(),
472 "Select All Matches",
473 &SelectAllMatches,
474 query_focus.clone(),
475 ))
476 })
477 .child(matches_column)
478 })
479 .when(find_in_results, |el| {
480 el.child(render_action_button(
481 "buffer-search",
482 IconName::Close,
483 Default::default(),
484 "Close Search Bar",
485 &Dismiss,
486 focus_handle.clone(),
487 ))
488 });
489
490 let has_collapse_button = collapse_expand_button.is_some();
491
492 let search_line = h_flex()
493 .w_full()
494 .gap_2()
495 .when(find_in_results, |el| el.child(alignment_element()))
496 .when(!find_in_results && has_collapse_button, |el| {
497 el.pl_0p5().child(collapse_expand_button.expect("button"))
498 })
499 .child(query_column)
500 .child(mode_column);
501
502 let replace_line = should_show_replace_input.then(|| {
503 let replace_column = input_base_styles(replacement_border).child(
504 div()
505 .flex_1()
506 .py_1()
507 .child(render_text_input(&self.replacement_editor, None, cx)),
508 );
509 let focus_handle = self.replacement_editor.read(cx).focus_handle(cx);
510
511 let replace_actions = h_flex()
512 .min_w_64()
513 .gap_1()
514 .child(render_action_button(
515 "buffer-search-replace-button",
516 IconName::ReplaceNext,
517 Default::default(),
518 "Replace Next Match",
519 &ReplaceNext,
520 focus_handle.clone(),
521 ))
522 .child(render_action_button(
523 "buffer-search-replace-button",
524 IconName::ReplaceAll,
525 Default::default(),
526 "Replace All Matches",
527 &ReplaceAll,
528 focus_handle,
529 ));
530
531 h_flex()
532 .w_full()
533 .gap_2()
534 .when(has_collapse_button, |this| this.child(alignment_element()))
535 .child(replace_column)
536 .child(replace_actions)
537 });
538
539 let mut key_context = KeyContext::new_with_defaults();
540 key_context.add("BufferSearchBar");
541 if in_replace {
542 key_context.add("in_replace");
543 }
544
545 let query_error_line = self.query_error.as_ref().map(|error| {
546 Label::new(error)
547 .size(LabelSize::Small)
548 .color(Color::Error)
549 .mt_neg_1()
550 .ml_2()
551 });
552
553 let search_line =
554 h_flex()
555 .relative()
556 .child(search_line)
557 .when(!narrow_mode && !find_in_results, |this| {
558 this.child(
559 h_flex()
560 .absolute()
561 .right_0()
562 .when(has_collapse_button, |this| {
563 this.pr_2()
564 .border_r_1()
565 .border_color(cx.theme().colors().border_variant)
566 })
567 .child(render_action_button(
568 "buffer-search",
569 IconName::Close,
570 Default::default(),
571 "Close Search Bar",
572 &Dismiss,
573 focus_handle.clone(),
574 )),
575 )
576 });
577
578 v_flex()
579 .id("buffer_search")
580 .gap_2()
581 .w_full()
582 .track_scroll(&self.scroll_handle)
583 .key_context(key_context)
584 .capture_action(cx.listener(Self::tab))
585 .capture_action(cx.listener(Self::backtab))
586 .capture_action(cx.listener(Self::toggle_fold_all))
587 .on_action(cx.listener(Self::previous_history_query))
588 .on_action(cx.listener(Self::next_history_query))
589 .on_action(cx.listener(Self::dismiss))
590 .on_action(cx.listener(Self::select_next_match))
591 .on_action(cx.listener(Self::select_prev_match))
592 .on_action(cx.listener(|this, _: &ToggleOutline, window, cx| {
593 if let Some(active_searchable_item) = &mut this.active_searchable_item {
594 active_searchable_item.relay_action(Box::new(ToggleOutline), window, cx);
595 }
596 }))
597 .on_action(cx.listener(|this, _: &CopyPath, window, cx| {
598 if let Some(active_searchable_item) = &mut this.active_searchable_item {
599 active_searchable_item.relay_action(Box::new(CopyPath), window, cx);
600 }
601 }))
602 .on_action(cx.listener(|this, _: &CopyRelativePath, window, cx| {
603 if let Some(active_searchable_item) = &mut this.active_searchable_item {
604 active_searchable_item.relay_action(Box::new(CopyRelativePath), window, cx);
605 }
606 }))
607 .when(replacement, |this| {
608 this.on_action(cx.listener(Self::toggle_replace))
609 .on_action(cx.listener(Self::replace_next))
610 .on_action(cx.listener(Self::replace_all))
611 })
612 .when(case, |this| {
613 this.on_action(cx.listener(Self::toggle_case_sensitive))
614 })
615 .when(word, |this| {
616 this.on_action(cx.listener(Self::toggle_whole_word))
617 })
618 .when(regex, |this| {
619 this.on_action(cx.listener(Self::toggle_regex))
620 })
621 .when(selection, |this| {
622 this.on_action(cx.listener(Self::toggle_selection))
623 })
624 .child(search_line)
625 .children(query_error_line)
626 .children(replace_line)
627 .into_any_element()
628 }
629}
630
631impl Focusable for BufferSearchBar {
632 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
633 self.query_editor.focus_handle(cx)
634 }
635}
636
637impl ToolbarItemView for BufferSearchBar {
638 fn contribute_context(&self, context: &mut KeyContext, _cx: &App) {
639 if !self.dismissed {
640 context.add("buffer_search_deployed");
641 }
642 }
643
644 fn set_active_pane_item(
645 &mut self,
646 item: Option<&dyn ItemHandle>,
647 window: &mut Window,
648 cx: &mut Context<Self>,
649 ) -> ToolbarItemLocation {
650 cx.notify();
651 self.active_searchable_item_subscriptions.take();
652 self.active_searchable_item.take();
653 self.splittable_editor = None;
654 self._splittable_editor_subscription = None;
655
656 self.pending_search.take();
657
658 if let Some(splittable_editor) = item
659 .and_then(|item| item.act_as_type(TypeId::of::<SplittableEditor>(), cx))
660 .and_then(|entity| entity.downcast::<SplittableEditor>().ok())
661 {
662 self._splittable_editor_subscription =
663 Some(cx.observe(&splittable_editor, |_, _, cx| cx.notify()));
664 self.splittable_editor = Some(splittable_editor.downgrade());
665 }
666
667 if let Some(searchable_item_handle) =
668 item.and_then(|item| item.to_searchable_item_handle(cx))
669 {
670 let this = cx.entity().downgrade();
671
672 let search_event_subscription = searchable_item_handle.subscribe_to_search_events(
673 window,
674 cx,
675 Box::new(move |search_event, window, cx| {
676 if let Some(this) = this.upgrade() {
677 this.update(cx, |this, cx| {
678 this.on_active_searchable_item_event(search_event, window, cx)
679 });
680 }
681 }),
682 );
683
684 #[cfg(target_os = "macos")]
685 {
686 let item_focus_handle = searchable_item_handle.item_focus_handle(cx);
687
688 self.active_searchable_item_subscriptions = Some([
689 search_event_subscription,
690 cx.on_focus(&item_focus_handle, window, |this, window, cx| {
691 if this.query_editor_focused || this.replacement_editor_focused {
692 // no need to read pasteboard since focus came from toolbar
693 return;
694 }
695
696 cx.defer_in(window, |this, window, cx| {
697 let Some(item) = cx.read_from_find_pasteboard() else {
698 return;
699 };
700 let Some(text) = item.text() else {
701 return;
702 };
703
704 if this.query(cx) == text {
705 return;
706 }
707
708 let search_options = item
709 .metadata()
710 .and_then(|m| m.parse().ok())
711 .and_then(SearchOptions::from_bits)
712 .unwrap_or(this.search_options);
713
714 if this.dismissed {
715 this.pending_external_query = Some((text, search_options));
716 } else {
717 drop(this.search(&text, Some(search_options), true, window, cx));
718 }
719 });
720 }),
721 ]);
722 }
723 #[cfg(not(target_os = "macos"))]
724 {
725 self.active_searchable_item_subscriptions = Some(search_event_subscription);
726 }
727
728 let is_project_search = searchable_item_handle.supported_options(cx).find_in_results;
729 self.active_searchable_item = Some(searchable_item_handle);
730 drop(self.update_matches(true, false, window, cx));
731 if self.needs_expand_collapse_option(cx) {
732 return ToolbarItemLocation::PrimaryLeft;
733 } else if !self.is_dismissed() {
734 if is_project_search {
735 self.dismiss(&Default::default(), window, cx);
736 } else {
737 return ToolbarItemLocation::Secondary;
738 }
739 }
740 }
741 ToolbarItemLocation::Hidden
742 }
743}
744
745impl BufferSearchBar {
746 pub fn query_editor_focused(&self) -> bool {
747 self.query_editor_focused
748 }
749
750 pub fn register(registrar: &mut impl SearchActionsRegistrar) {
751 registrar.register_handler(ForDeployed(|this, _: &FocusSearch, window, cx| {
752 this.query_editor.focus_handle(cx).focus(window, cx);
753 this.select_query(window, cx);
754 }));
755 registrar.register_handler(ForDeployed(
756 |this, action: &ToggleCaseSensitive, window, cx| {
757 if this.supported_options(cx).case {
758 this.toggle_case_sensitive(action, window, cx);
759 }
760 },
761 ));
762 registrar.register_handler(ForDeployed(|this, action: &ToggleWholeWord, window, cx| {
763 if this.supported_options(cx).word {
764 this.toggle_whole_word(action, window, cx);
765 }
766 }));
767 registrar.register_handler(ForDeployed(|this, action: &ToggleRegex, window, cx| {
768 if this.supported_options(cx).regex {
769 this.toggle_regex(action, window, cx);
770 }
771 }));
772 registrar.register_handler(ForDeployed(|this, action: &ToggleSelection, window, cx| {
773 if this.supported_options(cx).selection {
774 this.toggle_selection(action, window, cx);
775 } else {
776 cx.propagate();
777 }
778 }));
779 registrar.register_handler(ForDeployed(|this, action: &ToggleReplace, window, cx| {
780 if this.supported_options(cx).replacement {
781 this.toggle_replace(action, window, cx);
782 } else {
783 cx.propagate();
784 }
785 }));
786 registrar.register_handler(WithResultsOrExternalQuery(
787 |this, action: &SelectNextMatch, window, cx| {
788 if this.supported_options(cx).find_in_results {
789 cx.propagate();
790 } else {
791 this.select_next_match(action, window, cx);
792 }
793 },
794 ));
795 registrar.register_handler(WithResultsOrExternalQuery(
796 |this, action: &SelectPreviousMatch, window, cx| {
797 if this.supported_options(cx).find_in_results {
798 cx.propagate();
799 } else {
800 this.select_prev_match(action, window, cx);
801 }
802 },
803 ));
804 registrar.register_handler(WithResultsOrExternalQuery(
805 |this, action: &SelectAllMatches, window, cx| {
806 if this.supported_options(cx).find_in_results {
807 cx.propagate();
808 } else {
809 this.select_all_matches(action, window, cx);
810 }
811 },
812 ));
813 registrar.register_handler(ForDeployed(
814 |this, _: &editor::actions::Cancel, window, cx| {
815 this.dismiss(&Dismiss, window, cx);
816 },
817 ));
818 registrar.register_handler(ForDeployed(|this, _: &Dismiss, window, cx| {
819 this.dismiss(&Dismiss, window, cx);
820 }));
821
822 // register deploy buffer search for both search bar states, since we want to focus into the search bar
823 // when the deploy action is triggered in the buffer.
824 registrar.register_handler(ForDeployed(|this, deploy, window, cx| {
825 this.deploy(deploy, window, cx);
826 }));
827 registrar.register_handler(ForDismissed(|this, deploy, window, cx| {
828 this.deploy(deploy, window, cx);
829 }));
830 registrar.register_handler(ForDeployed(|this, _: &DeployReplace, window, cx| {
831 if this.supported_options(cx).find_in_results {
832 cx.propagate();
833 } else {
834 this.deploy(&Deploy::replace(), window, cx);
835 }
836 }));
837 registrar.register_handler(ForDismissed(|this, _: &DeployReplace, window, cx| {
838 if this.supported_options(cx).find_in_results {
839 cx.propagate();
840 } else {
841 this.deploy(&Deploy::replace(), window, cx);
842 }
843 }));
844 registrar.register_handler(ForDeployed(
845 |this, action: &UseSelectionForFind, window, cx| {
846 this.use_selection_for_find(action, window, cx);
847 },
848 ));
849 registrar.register_handler(ForDismissed(
850 |this, action: &UseSelectionForFind, window, cx| {
851 this.use_selection_for_find(action, window, cx);
852 },
853 ));
854 }
855
856 pub fn new(
857 languages: Option<Arc<LanguageRegistry>>,
858 window: &mut Window,
859 cx: &mut Context<Self>,
860 ) -> Self {
861 let query_editor = cx.new(|cx| {
862 let mut editor = Editor::auto_height(1, 4, window, cx);
863 editor.set_use_autoclose(false);
864 editor.set_use_selection_highlight(false);
865 editor
866 });
867 cx.subscribe_in(&query_editor, window, Self::on_query_editor_event)
868 .detach();
869 let replacement_editor = cx.new(|cx| Editor::auto_height(1, 4, window, cx));
870 cx.subscribe(&replacement_editor, Self::on_replacement_editor_event)
871 .detach();
872
873 let search_options = SearchOptions::from_settings(&EditorSettings::get_global(cx).search);
874 if let Some(languages) = languages {
875 let query_buffer = query_editor
876 .read(cx)
877 .buffer()
878 .read(cx)
879 .as_singleton()
880 .expect("query editor should be backed by a singleton buffer");
881
882 query_buffer
883 .read(cx)
884 .set_language_registry(languages.clone());
885
886 cx.spawn(async move |buffer_search_bar, cx| {
887 use anyhow::Context as _;
888
889 let regex_language = languages
890 .language_for_name("regex")
891 .await
892 .context("loading regex language")?;
893
894 buffer_search_bar
895 .update(cx, |buffer_search_bar, cx| {
896 buffer_search_bar.regex_language = Some(regex_language);
897 buffer_search_bar.adjust_query_regex_language(cx);
898 })
899 .ok();
900 anyhow::Ok(())
901 })
902 .detach_and_log_err(cx);
903 }
904
905 Self {
906 query_editor,
907 query_editor_focused: false,
908 replacement_editor,
909 replacement_editor_focused: false,
910 active_searchable_item: None,
911 active_searchable_item_subscriptions: None,
912 #[cfg(target_os = "macos")]
913 pending_external_query: None,
914 active_match_index: None,
915 searchable_items_with_matches: Default::default(),
916 default_options: search_options,
917 configured_options: search_options,
918 search_options,
919 pending_search: None,
920 query_error: None,
921 dismissed: true,
922 search_history: SearchHistory::new(
923 Some(MAX_BUFFER_SEARCH_HISTORY_SIZE),
924 project::search_history::QueryInsertionBehavior::ReplacePreviousIfContains,
925 ),
926 search_history_cursor: Default::default(),
927 active_search: None,
928 replace_enabled: false,
929 selection_search_enabled: None,
930 scroll_handle: ScrollHandle::new(),
931 regex_language: None,
932 splittable_editor: None,
933 _splittable_editor_subscription: None,
934 }
935 }
936
937 pub fn is_dismissed(&self) -> bool {
938 self.dismissed
939 }
940
941 pub fn dismiss(&mut self, _: &Dismiss, window: &mut Window, cx: &mut Context<Self>) {
942 self.dismissed = true;
943 cx.emit(Event::Dismissed);
944 self.query_error = None;
945 self.sync_select_next_case_sensitivity(cx);
946
947 for searchable_item in self.searchable_items_with_matches.keys() {
948 if let Some(searchable_item) =
949 WeakSearchableItemHandle::upgrade(searchable_item.as_ref(), cx)
950 {
951 searchable_item.clear_matches(window, cx);
952 }
953 }
954
955 let needs_collapse_expand = self.needs_expand_collapse_option(cx);
956
957 if let Some(active_editor) = self.active_searchable_item.as_mut() {
958 self.selection_search_enabled = None;
959 self.replace_enabled = false;
960 active_editor.search_bar_visibility_changed(false, window, cx);
961 active_editor.toggle_filtered_search_ranges(None, window, cx);
962 let handle = active_editor.item_focus_handle(cx);
963 self.focus(&handle, window, cx);
964 }
965
966 if needs_collapse_expand {
967 cx.emit(Event::UpdateLocation);
968 cx.emit(ToolbarItemEvent::ChangeLocation(
969 ToolbarItemLocation::PrimaryLeft,
970 ));
971 cx.notify();
972 return;
973 }
974 cx.emit(Event::UpdateLocation);
975 cx.emit(ToolbarItemEvent::ChangeLocation(
976 ToolbarItemLocation::Hidden,
977 ));
978 cx.notify();
979 }
980
981 pub fn deploy(&mut self, deploy: &Deploy, window: &mut Window, cx: &mut Context<Self>) -> bool {
982 let filtered_search_range = if deploy.selection_search_enabled {
983 Some(FilteredSearchRange::Default)
984 } else {
985 None
986 };
987 if self.show(window, cx) {
988 if let Some(active_item) = self.active_searchable_item.as_mut() {
989 active_item.toggle_filtered_search_ranges(filtered_search_range, window, cx);
990 }
991 self.search_suggested(window, cx);
992 self.smartcase(window, cx);
993 self.sync_select_next_case_sensitivity(cx);
994 self.replace_enabled |= deploy.replace_enabled;
995 self.selection_search_enabled =
996 self.selection_search_enabled
997 .or(if deploy.selection_search_enabled {
998 Some(FilteredSearchRange::Default)
999 } else {
1000 None
1001 });
1002 if deploy.focus {
1003 let mut handle = self.query_editor.focus_handle(cx);
1004 let mut select_query = true;
1005
1006 let has_seed_text = self.query_suggestion(false, window, cx).is_some();
1007 if deploy.replace_enabled && has_seed_text {
1008 handle = self.replacement_editor.focus_handle(cx);
1009 select_query = false;
1010 };
1011
1012 if select_query {
1013 self.select_query(window, cx);
1014 }
1015
1016 window.focus(&handle, cx);
1017 }
1018 return true;
1019 }
1020
1021 cx.propagate();
1022 false
1023 }
1024
1025 pub fn toggle(&mut self, action: &Deploy, window: &mut Window, cx: &mut Context<Self>) {
1026 if self.is_dismissed() {
1027 self.deploy(action, window, cx);
1028 } else {
1029 self.dismiss(&Dismiss, window, cx);
1030 }
1031 }
1032
1033 pub fn show(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1034 let Some(handle) = self.active_searchable_item.as_ref() else {
1035 return false;
1036 };
1037
1038 let configured_options =
1039 SearchOptions::from_settings(&EditorSettings::get_global(cx).search);
1040 let settings_changed = configured_options != self.configured_options;
1041
1042 if self.dismissed && settings_changed {
1043 // Only update configuration options when search bar is dismissed,
1044 // so we don't miss updates even after calling show twice
1045 self.configured_options = configured_options;
1046 self.search_options = configured_options;
1047 self.default_options = configured_options;
1048 }
1049
1050 // This isn't a normal setting; it's only applicable to vim search.
1051 self.search_options.remove(SearchOptions::BACKWARDS);
1052
1053 self.dismissed = false;
1054 self.adjust_query_regex_language(cx);
1055 handle.search_bar_visibility_changed(true, window, cx);
1056 cx.notify();
1057 cx.emit(Event::UpdateLocation);
1058 cx.emit(ToolbarItemEvent::ChangeLocation(
1059 if self.needs_expand_collapse_option(cx) {
1060 ToolbarItemLocation::PrimaryLeft
1061 } else {
1062 ToolbarItemLocation::Secondary
1063 },
1064 ));
1065 true
1066 }
1067
1068 fn supported_options(&self, cx: &mut Context<Self>) -> workspace::searchable::SearchOptions {
1069 self.active_searchable_item
1070 .as_ref()
1071 .map(|item| item.supported_options(cx))
1072 .unwrap_or_default()
1073 }
1074
1075 // We provide an expand/collapse button if we are in a multibuffer
1076 // and not doing a project search.
1077 fn needs_expand_collapse_option(&self, cx: &App) -> bool {
1078 if let Some(item) = &self.active_searchable_item {
1079 let buffer_kind = item.buffer_kind(cx);
1080
1081 if buffer_kind == ItemBufferKind::Singleton {
1082 return false;
1083 }
1084
1085 let workspace::searchable::SearchOptions {
1086 find_in_results, ..
1087 } = item.supported_options(cx);
1088 !find_in_results
1089 } else {
1090 false
1091 }
1092 }
1093
1094 fn toggle_fold_all(&mut self, _: &ToggleFoldAll, window: &mut Window, cx: &mut Context<Self>) {
1095 self.toggle_fold_all_in_item(window, cx);
1096 }
1097
1098 fn toggle_fold_all_in_item(&self, window: &mut Window, cx: &mut Context<Self>) {
1099 if let Some(item) = &self.active_searchable_item {
1100 if let Some(item) = item.act_as_type(TypeId::of::<Editor>(), cx) {
1101 let editor = item.downcast::<Editor>().expect("Is an editor");
1102 editor.update(cx, |editor, cx| {
1103 let is_collapsed = editor.has_any_buffer_folded(cx);
1104 if is_collapsed {
1105 editor.unfold_all(&UnfoldAll, window, cx);
1106 } else {
1107 editor.fold_all(&FoldAll, window, cx);
1108 }
1109 })
1110 }
1111 }
1112 }
1113
1114 pub fn search_suggested(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1115 let search = self.query_suggestion(false, window, cx).map(|suggestion| {
1116 self.search(&suggestion, Some(self.default_options), true, window, cx)
1117 });
1118
1119 #[cfg(target_os = "macos")]
1120 let search = search.or_else(|| {
1121 self.pending_external_query
1122 .take()
1123 .map(|(query, options)| self.search(&query, Some(options), true, window, cx))
1124 });
1125
1126 if let Some(search) = search {
1127 cx.spawn_in(window, async move |this, cx| {
1128 if search.await.is_ok() {
1129 this.update_in(cx, |this, window, cx| {
1130 if !this.dismissed {
1131 this.activate_current_match(window, cx)
1132 }
1133 })
1134 } else {
1135 Ok(())
1136 }
1137 })
1138 .detach_and_log_err(cx);
1139 }
1140 }
1141
1142 pub fn activate_current_match(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1143 if let Some(match_ix) = self.active_match_index
1144 && let Some(active_searchable_item) = self.active_searchable_item.as_ref()
1145 && let Some((matches, token)) = self
1146 .searchable_items_with_matches
1147 .get(&active_searchable_item.downgrade())
1148 {
1149 active_searchable_item.activate_match(match_ix, matches, *token, window, cx)
1150 }
1151 }
1152
1153 pub fn select_query(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1154 self.query_editor.update(cx, |query_editor, cx| {
1155 query_editor.select_all(&Default::default(), window, cx);
1156 });
1157 }
1158
1159 pub fn query(&self, cx: &App) -> String {
1160 self.query_editor.read(cx).text(cx)
1161 }
1162
1163 pub fn replacement(&self, cx: &mut App) -> String {
1164 self.replacement_editor.read(cx).text(cx)
1165 }
1166
1167 pub fn query_suggestion(
1168 &mut self,
1169 ignore_settings: bool,
1170 window: &mut Window,
1171 cx: &mut Context<Self>,
1172 ) -> Option<String> {
1173 self.active_searchable_item
1174 .as_ref()
1175 .map(|searchable_item| searchable_item.query_suggestion(ignore_settings, window, cx))
1176 .filter(|suggestion| !suggestion.is_empty())
1177 }
1178
1179 pub fn set_replacement(&mut self, replacement: Option<&str>, cx: &mut Context<Self>) {
1180 if replacement.is_none() {
1181 self.replace_enabled = false;
1182 return;
1183 }
1184 self.replace_enabled = true;
1185 self.replacement_editor
1186 .update(cx, |replacement_editor, cx| {
1187 replacement_editor
1188 .buffer()
1189 .update(cx, |replacement_buffer, cx| {
1190 let len = replacement_buffer.len(cx);
1191 replacement_buffer.edit(
1192 [(MultiBufferOffset(0)..len, replacement.unwrap())],
1193 None,
1194 cx,
1195 );
1196 });
1197 });
1198 }
1199
1200 pub fn focus_replace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1201 self.focus(&self.replacement_editor.focus_handle(cx), window, cx);
1202 cx.notify();
1203 }
1204
1205 pub fn search(
1206 &mut self,
1207 query: &str,
1208 options: Option<SearchOptions>,
1209 add_to_history: bool,
1210 window: &mut Window,
1211 cx: &mut Context<Self>,
1212 ) -> oneshot::Receiver<()> {
1213 let options = options.unwrap_or(self.default_options);
1214 let updated = query != self.query(cx) || self.search_options != options;
1215 if updated {
1216 self.query_editor.update(cx, |query_editor, cx| {
1217 query_editor.buffer().update(cx, |query_buffer, cx| {
1218 let len = query_buffer.len(cx);
1219 query_buffer.edit([(MultiBufferOffset(0)..len, query)], None, cx);
1220 });
1221 query_editor.request_autoscroll(Autoscroll::fit(), cx);
1222 });
1223 self.set_search_options(options, cx);
1224 self.clear_matches(window, cx);
1225 #[cfg(target_os = "macos")]
1226 self.update_find_pasteboard(cx);
1227 cx.notify();
1228 }
1229 self.update_matches(!updated, add_to_history, window, cx)
1230 }
1231
1232 #[cfg(target_os = "macos")]
1233 pub fn update_find_pasteboard(&mut self, cx: &mut App) {
1234 cx.write_to_find_pasteboard(gpui::ClipboardItem::new_string_with_metadata(
1235 self.query(cx),
1236 self.search_options.bits().to_string(),
1237 ));
1238 }
1239
1240 pub fn use_selection_for_find(
1241 &mut self,
1242 _: &UseSelectionForFind,
1243 window: &mut Window,
1244 cx: &mut Context<Self>,
1245 ) {
1246 let Some(search_text) = self.query_suggestion(true, window, cx) else {
1247 return;
1248 };
1249 self.query_editor.update(cx, |query_editor, cx| {
1250 query_editor.buffer().update(cx, |query_buffer, cx| {
1251 let len = query_buffer.len(cx);
1252 query_buffer.edit([(MultiBufferOffset(0)..len, search_text)], None, cx);
1253 });
1254 });
1255 #[cfg(target_os = "macos")]
1256 self.update_find_pasteboard(cx);
1257 cx.notify();
1258 }
1259
1260 pub fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
1261 if let Some(active_editor) = self.active_searchable_item.as_ref() {
1262 let handle = active_editor.item_focus_handle(cx);
1263 window.focus(&handle, cx);
1264 }
1265 }
1266
1267 pub fn toggle_search_option(
1268 &mut self,
1269 search_option: SearchOptions,
1270 window: &mut Window,
1271 cx: &mut Context<Self>,
1272 ) {
1273 self.search_options.toggle(search_option);
1274 self.default_options = self.search_options;
1275 drop(self.update_matches(false, false, window, cx));
1276 self.adjust_query_regex_language(cx);
1277 self.sync_select_next_case_sensitivity(cx);
1278 cx.notify();
1279 }
1280
1281 pub fn has_search_option(&mut self, search_option: SearchOptions) -> bool {
1282 self.search_options.contains(search_option)
1283 }
1284
1285 pub fn enable_search_option(
1286 &mut self,
1287 search_option: SearchOptions,
1288 window: &mut Window,
1289 cx: &mut Context<Self>,
1290 ) {
1291 if !self.search_options.contains(search_option) {
1292 self.toggle_search_option(search_option, window, cx)
1293 }
1294 }
1295
1296 pub fn set_search_within_selection(
1297 &mut self,
1298 search_within_selection: Option<FilteredSearchRange>,
1299 window: &mut Window,
1300 cx: &mut Context<Self>,
1301 ) -> Option<oneshot::Receiver<()>> {
1302 let active_item = self.active_searchable_item.as_mut()?;
1303 self.selection_search_enabled = search_within_selection;
1304 active_item.toggle_filtered_search_ranges(self.selection_search_enabled, window, cx);
1305 cx.notify();
1306 Some(self.update_matches(false, false, window, cx))
1307 }
1308
1309 pub fn set_search_options(&mut self, search_options: SearchOptions, cx: &mut Context<Self>) {
1310 self.search_options = search_options;
1311 self.adjust_query_regex_language(cx);
1312 self.sync_select_next_case_sensitivity(cx);
1313 cx.notify();
1314 }
1315
1316 pub fn clear_search_within_ranges(
1317 &mut self,
1318 search_options: SearchOptions,
1319 cx: &mut Context<Self>,
1320 ) {
1321 self.search_options = search_options;
1322 self.adjust_query_regex_language(cx);
1323 cx.notify();
1324 }
1325
1326 fn select_next_match(
1327 &mut self,
1328 _: &SelectNextMatch,
1329 window: &mut Window,
1330 cx: &mut Context<Self>,
1331 ) {
1332 self.select_match(Direction::Next, 1, window, cx);
1333 }
1334
1335 fn select_prev_match(
1336 &mut self,
1337 _: &SelectPreviousMatch,
1338 window: &mut Window,
1339 cx: &mut Context<Self>,
1340 ) {
1341 self.select_match(Direction::Prev, 1, window, cx);
1342 }
1343
1344 pub fn select_all_matches(
1345 &mut self,
1346 _: &SelectAllMatches,
1347 window: &mut Window,
1348 cx: &mut Context<Self>,
1349 ) {
1350 if !self.dismissed
1351 && self.active_match_index.is_some()
1352 && let Some(searchable_item) = self.active_searchable_item.as_ref()
1353 && let Some((matches, token)) = self
1354 .searchable_items_with_matches
1355 .get(&searchable_item.downgrade())
1356 {
1357 searchable_item.select_matches(matches, *token, window, cx);
1358 self.focus_editor(&FocusEditor, window, cx);
1359 }
1360 }
1361
1362 pub fn select_match(
1363 &mut self,
1364 direction: Direction,
1365 count: usize,
1366 window: &mut Window,
1367 cx: &mut Context<Self>,
1368 ) {
1369 #[cfg(target_os = "macos")]
1370 if let Some((query, options)) = self.pending_external_query.take() {
1371 let search_rx = self.search(&query, Some(options), true, window, cx);
1372 cx.spawn_in(window, async move |this, cx| {
1373 if search_rx.await.is_ok() {
1374 this.update_in(cx, |this, window, cx| {
1375 this.activate_current_match(window, cx);
1376 })
1377 .ok();
1378 }
1379 })
1380 .detach();
1381
1382 return;
1383 }
1384
1385 if let Some(index) = self.active_match_index
1386 && let Some(searchable_item) = self.active_searchable_item.as_ref()
1387 && let Some((matches, token)) = self
1388 .searchable_items_with_matches
1389 .get(&searchable_item.downgrade())
1390 .filter(|(matches, _)| !matches.is_empty())
1391 {
1392 // If 'wrapscan' is disabled, searches do not wrap around the end of the file.
1393 if !EditorSettings::get_global(cx).search_wrap
1394 && ((direction == Direction::Next && index + count >= matches.len())
1395 || (direction == Direction::Prev && index < count))
1396 {
1397 crate::show_no_more_matches(window, cx);
1398 return;
1399 }
1400 let new_match_index = searchable_item
1401 .match_index_for_direction(matches, index, direction, count, *token, window, cx);
1402 self.active_match_index = Some(new_match_index);
1403
1404 searchable_item.update_matches(matches, Some(new_match_index), *token, window, cx);
1405 searchable_item.activate_match(new_match_index, matches, *token, window, cx);
1406 }
1407 }
1408
1409 pub fn select_first_match(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1410 if let Some(searchable_item) = self.active_searchable_item.as_ref()
1411 && let Some((matches, token)) = self
1412 .searchable_items_with_matches
1413 .get(&searchable_item.downgrade())
1414 {
1415 if matches.is_empty() {
1416 return;
1417 }
1418 searchable_item.update_matches(matches, Some(0), *token, window, cx);
1419 searchable_item.activate_match(0, matches, *token, window, cx);
1420 }
1421 }
1422
1423 pub fn select_last_match(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1424 if let Some(searchable_item) = self.active_searchable_item.as_ref()
1425 && let Some((matches, token)) = self
1426 .searchable_items_with_matches
1427 .get(&searchable_item.downgrade())
1428 {
1429 if matches.is_empty() {
1430 return;
1431 }
1432 let new_match_index = matches.len() - 1;
1433 searchable_item.update_matches(matches, Some(new_match_index), *token, window, cx);
1434 searchable_item.activate_match(new_match_index, matches, *token, window, cx);
1435 }
1436 }
1437
1438 fn on_query_editor_event(
1439 &mut self,
1440 _editor: &Entity<Editor>,
1441 event: &editor::EditorEvent,
1442 window: &mut Window,
1443 cx: &mut Context<Self>,
1444 ) {
1445 match event {
1446 editor::EditorEvent::Focused => self.query_editor_focused = true,
1447 editor::EditorEvent::Blurred => self.query_editor_focused = false,
1448 editor::EditorEvent::Edited { .. } => {
1449 self.smartcase(window, cx);
1450 self.clear_matches(window, cx);
1451 let search = self.update_matches(false, true, window, cx);
1452
1453 cx.spawn_in(window, async move |this, cx| {
1454 if search.await.is_ok() {
1455 this.update_in(cx, |this, window, cx| {
1456 this.activate_current_match(window, cx);
1457 #[cfg(target_os = "macos")]
1458 this.update_find_pasteboard(cx);
1459 })?;
1460 }
1461 anyhow::Ok(())
1462 })
1463 .detach_and_log_err(cx);
1464 }
1465 _ => {}
1466 }
1467 }
1468
1469 fn on_replacement_editor_event(
1470 &mut self,
1471 _: Entity<Editor>,
1472 event: &editor::EditorEvent,
1473 _: &mut Context<Self>,
1474 ) {
1475 match event {
1476 editor::EditorEvent::Focused => self.replacement_editor_focused = true,
1477 editor::EditorEvent::Blurred => self.replacement_editor_focused = false,
1478 _ => {}
1479 }
1480 }
1481
1482 fn on_active_searchable_item_event(
1483 &mut self,
1484 event: &SearchEvent,
1485 window: &mut Window,
1486 cx: &mut Context<Self>,
1487 ) {
1488 match event {
1489 SearchEvent::MatchesInvalidated => {
1490 drop(self.update_matches(false, false, window, cx));
1491 }
1492 SearchEvent::ActiveMatchChanged => self.update_match_index(window, cx),
1493 }
1494 }
1495
1496 fn toggle_case_sensitive(
1497 &mut self,
1498 _: &ToggleCaseSensitive,
1499 window: &mut Window,
1500 cx: &mut Context<Self>,
1501 ) {
1502 self.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx)
1503 }
1504
1505 fn toggle_whole_word(
1506 &mut self,
1507 _: &ToggleWholeWord,
1508 window: &mut Window,
1509 cx: &mut Context<Self>,
1510 ) {
1511 self.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx)
1512 }
1513
1514 fn toggle_selection(
1515 &mut self,
1516 _: &ToggleSelection,
1517 window: &mut Window,
1518 cx: &mut Context<Self>,
1519 ) {
1520 self.set_search_within_selection(
1521 if let Some(_) = self.selection_search_enabled {
1522 None
1523 } else {
1524 Some(FilteredSearchRange::Default)
1525 },
1526 window,
1527 cx,
1528 );
1529 }
1530
1531 fn toggle_regex(&mut self, _: &ToggleRegex, window: &mut Window, cx: &mut Context<Self>) {
1532 self.toggle_search_option(SearchOptions::REGEX, window, cx)
1533 }
1534
1535 fn clear_active_searchable_item_matches(&mut self, window: &mut Window, cx: &mut App) {
1536 if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
1537 self.active_match_index = None;
1538 self.searchable_items_with_matches
1539 .remove(&active_searchable_item.downgrade());
1540 active_searchable_item.clear_matches(window, cx);
1541 }
1542 }
1543
1544 pub fn has_active_match(&self) -> bool {
1545 self.active_match_index.is_some()
1546 }
1547
1548 fn clear_matches(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1549 let mut active_item_matches = None;
1550 for (searchable_item, matches) in self.searchable_items_with_matches.drain() {
1551 if let Some(searchable_item) =
1552 WeakSearchableItemHandle::upgrade(searchable_item.as_ref(), cx)
1553 {
1554 if Some(&searchable_item) == self.active_searchable_item.as_ref() {
1555 active_item_matches = Some((searchable_item.downgrade(), matches));
1556 } else {
1557 searchable_item.clear_matches(window, cx);
1558 }
1559 }
1560 }
1561
1562 self.searchable_items_with_matches
1563 .extend(active_item_matches);
1564 }
1565
1566 fn update_matches(
1567 &mut self,
1568 reuse_existing_query: bool,
1569 add_to_history: bool,
1570 window: &mut Window,
1571 cx: &mut Context<Self>,
1572 ) -> oneshot::Receiver<()> {
1573 let (done_tx, done_rx) = oneshot::channel();
1574 let query = self.query(cx);
1575 self.pending_search.take();
1576 #[cfg(target_os = "macos")]
1577 self.pending_external_query.take();
1578
1579 if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
1580 self.query_error = None;
1581 if query.is_empty() {
1582 self.clear_active_searchable_item_matches(window, cx);
1583 let _ = done_tx.send(());
1584 cx.notify();
1585 } else {
1586 let query: Arc<_> = if let Some(search) =
1587 self.active_search.take().filter(|_| reuse_existing_query)
1588 {
1589 search
1590 } else {
1591 // Value doesn't matter, we only construct empty matchers with it
1592
1593 if self.search_options.contains(SearchOptions::REGEX) {
1594 match SearchQuery::regex(
1595 query,
1596 self.search_options.contains(SearchOptions::WHOLE_WORD),
1597 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1598 false,
1599 self.search_options
1600 .contains(SearchOptions::ONE_MATCH_PER_LINE),
1601 PathMatcher::default(),
1602 PathMatcher::default(),
1603 false,
1604 None,
1605 ) {
1606 Ok(query) => query.with_replacement(self.replacement(cx)),
1607 Err(e) => {
1608 self.query_error = Some(e.to_string());
1609 self.clear_active_searchable_item_matches(window, cx);
1610 cx.notify();
1611 return done_rx;
1612 }
1613 }
1614 } else {
1615 match SearchQuery::text(
1616 query,
1617 self.search_options.contains(SearchOptions::WHOLE_WORD),
1618 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1619 false,
1620 PathMatcher::default(),
1621 PathMatcher::default(),
1622 false,
1623 None,
1624 ) {
1625 Ok(query) => query.with_replacement(self.replacement(cx)),
1626 Err(e) => {
1627 self.query_error = Some(e.to_string());
1628 self.clear_active_searchable_item_matches(window, cx);
1629 cx.notify();
1630 return done_rx;
1631 }
1632 }
1633 }
1634 .into()
1635 };
1636
1637 self.active_search = Some(query.clone());
1638 let query_text = query.as_str().to_string();
1639
1640 let matches_with_token =
1641 active_searchable_item.find_matches_with_token(query, window, cx);
1642
1643 let active_searchable_item = active_searchable_item.downgrade();
1644 self.pending_search = Some(cx.spawn_in(window, async move |this, cx| {
1645 let (matches, token) = matches_with_token.await;
1646
1647 this.update_in(cx, |this, window, cx| {
1648 if let Some(active_searchable_item) =
1649 WeakSearchableItemHandle::upgrade(active_searchable_item.as_ref(), cx)
1650 {
1651 this.searchable_items_with_matches
1652 .insert(active_searchable_item.downgrade(), (matches, token));
1653
1654 this.update_match_index(window, cx);
1655
1656 if add_to_history {
1657 this.search_history
1658 .add(&mut this.search_history_cursor, query_text);
1659 }
1660 if !this.dismissed {
1661 let (matches, token) = this
1662 .searchable_items_with_matches
1663 .get(&active_searchable_item.downgrade())
1664 .unwrap();
1665 if matches.is_empty() {
1666 active_searchable_item.clear_matches(window, cx);
1667 } else {
1668 active_searchable_item.update_matches(
1669 matches,
1670 this.active_match_index,
1671 *token,
1672 window,
1673 cx,
1674 );
1675 }
1676 }
1677 let _ = done_tx.send(());
1678 cx.notify();
1679 }
1680 })
1681 .log_err();
1682 }));
1683 }
1684 }
1685 done_rx
1686 }
1687
1688 fn reverse_direction_if_backwards(&self, direction: Direction) -> Direction {
1689 if self.search_options.contains(SearchOptions::BACKWARDS) {
1690 direction.opposite()
1691 } else {
1692 direction
1693 }
1694 }
1695
1696 pub fn update_match_index(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1697 let direction = self.reverse_direction_if_backwards(Direction::Next);
1698 let new_index = self
1699 .active_searchable_item
1700 .as_ref()
1701 .and_then(|searchable_item| {
1702 let (matches, token) = self
1703 .searchable_items_with_matches
1704 .get(&searchable_item.downgrade())?;
1705 searchable_item.active_match_index(direction, matches, *token, window, cx)
1706 });
1707 if new_index != self.active_match_index {
1708 self.active_match_index = new_index;
1709 if !self.dismissed {
1710 if let Some(searchable_item) = self.active_searchable_item.as_ref() {
1711 if let Some((matches, token)) = self
1712 .searchable_items_with_matches
1713 .get(&searchable_item.downgrade())
1714 {
1715 if !matches.is_empty() {
1716 searchable_item.update_matches(matches, new_index, *token, window, cx);
1717 }
1718 }
1719 }
1720 }
1721 cx.notify();
1722 }
1723 }
1724
1725 fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
1726 self.cycle_field(Direction::Next, window, cx);
1727 }
1728
1729 fn backtab(&mut self, _: &Backtab, window: &mut Window, cx: &mut Context<Self>) {
1730 self.cycle_field(Direction::Prev, window, cx);
1731 }
1732 fn cycle_field(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
1733 let mut handles = vec![self.query_editor.focus_handle(cx)];
1734 if self.replace_enabled {
1735 handles.push(self.replacement_editor.focus_handle(cx));
1736 }
1737 if let Some(item) = self.active_searchable_item.as_ref() {
1738 handles.push(item.item_focus_handle(cx));
1739 }
1740 let current_index = match handles.iter().position(|focus| focus.is_focused(window)) {
1741 Some(index) => index,
1742 None => return,
1743 };
1744
1745 let new_index = match direction {
1746 Direction::Next => (current_index + 1) % handles.len(),
1747 Direction::Prev if current_index == 0 => handles.len() - 1,
1748 Direction::Prev => (current_index - 1) % handles.len(),
1749 };
1750 let next_focus_handle = &handles[new_index];
1751 self.focus(next_focus_handle, window, cx);
1752 cx.stop_propagation();
1753 }
1754
1755 fn next_history_query(
1756 &mut self,
1757 _: &NextHistoryQuery,
1758 window: &mut Window,
1759 cx: &mut Context<Self>,
1760 ) {
1761 if !should_navigate_history(&self.query_editor, HistoryNavigationDirection::Next, cx) {
1762 cx.propagate();
1763 return;
1764 }
1765
1766 if let Some(new_query) = self
1767 .search_history
1768 .next(&mut self.search_history_cursor)
1769 .map(str::to_string)
1770 {
1771 drop(self.search(&new_query, Some(self.search_options), false, window, cx));
1772 } else if let Some(draft) = self.search_history_cursor.take_draft() {
1773 drop(self.search(&draft, Some(self.search_options), false, window, cx));
1774 }
1775 }
1776
1777 fn previous_history_query(
1778 &mut self,
1779 _: &PreviousHistoryQuery,
1780 window: &mut Window,
1781 cx: &mut Context<Self>,
1782 ) {
1783 if !should_navigate_history(&self.query_editor, HistoryNavigationDirection::Previous, cx) {
1784 cx.propagate();
1785 return;
1786 }
1787
1788 if self.query(cx).is_empty()
1789 && let Some(new_query) = self
1790 .search_history
1791 .current(&self.search_history_cursor)
1792 .map(str::to_string)
1793 {
1794 drop(self.search(&new_query, Some(self.search_options), false, window, cx));
1795 return;
1796 }
1797
1798 let current_query = self.query(cx);
1799 if let Some(new_query) = self
1800 .search_history
1801 .previous(&mut self.search_history_cursor, ¤t_query)
1802 .map(str::to_string)
1803 {
1804 drop(self.search(&new_query, Some(self.search_options), false, window, cx));
1805 }
1806 }
1807
1808 fn focus(&self, handle: &gpui::FocusHandle, window: &mut Window, cx: &mut App) {
1809 window.invalidate_character_coordinates();
1810 window.focus(handle, cx);
1811 }
1812
1813 fn toggle_replace(&mut self, _: &ToggleReplace, window: &mut Window, cx: &mut Context<Self>) {
1814 if self.active_searchable_item.is_some() {
1815 self.replace_enabled = !self.replace_enabled;
1816 let handle = if self.replace_enabled {
1817 self.replacement_editor.focus_handle(cx)
1818 } else {
1819 self.query_editor.focus_handle(cx)
1820 };
1821 self.focus(&handle, window, cx);
1822 cx.notify();
1823 }
1824 }
1825
1826 fn replace_next(&mut self, _: &ReplaceNext, window: &mut Window, cx: &mut Context<Self>) {
1827 let mut should_propagate = true;
1828 if !self.dismissed
1829 && self.active_search.is_some()
1830 && let Some(searchable_item) = self.active_searchable_item.as_ref()
1831 && let Some(query) = self.active_search.as_ref()
1832 && let Some((matches, token)) = self
1833 .searchable_items_with_matches
1834 .get(&searchable_item.downgrade())
1835 {
1836 if let Some(active_index) = self.active_match_index {
1837 let query = query
1838 .as_ref()
1839 .clone()
1840 .with_replacement(self.replacement(cx));
1841 searchable_item.replace(matches.at(active_index), &query, *token, window, cx);
1842 self.select_next_match(&SelectNextMatch, window, cx);
1843 }
1844 should_propagate = false;
1845 }
1846 if !should_propagate {
1847 cx.stop_propagation();
1848 }
1849 }
1850
1851 pub fn replace_all(&mut self, _: &ReplaceAll, window: &mut Window, cx: &mut Context<Self>) {
1852 if !self.dismissed
1853 && self.active_search.is_some()
1854 && let Some(searchable_item) = self.active_searchable_item.as_ref()
1855 && let Some(query) = self.active_search.as_ref()
1856 && let Some((matches, token)) = self
1857 .searchable_items_with_matches
1858 .get(&searchable_item.downgrade())
1859 {
1860 let query = query
1861 .as_ref()
1862 .clone()
1863 .with_replacement(self.replacement(cx));
1864 searchable_item.replace_all(&mut matches.iter(), &query, *token, window, cx);
1865 }
1866 }
1867
1868 pub fn match_exists(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1869 self.update_match_index(window, cx);
1870 self.active_match_index.is_some()
1871 }
1872
1873 pub fn should_use_smartcase_search(&mut self, cx: &mut Context<Self>) -> bool {
1874 EditorSettings::get_global(cx).use_smartcase_search
1875 }
1876
1877 pub fn is_contains_uppercase(&mut self, str: &String) -> bool {
1878 str.chars().any(|c| c.is_uppercase())
1879 }
1880
1881 fn smartcase(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1882 if self.should_use_smartcase_search(cx) {
1883 let query = self.query(cx);
1884 if !query.is_empty() {
1885 let is_case = self.is_contains_uppercase(&query);
1886 if self.has_search_option(SearchOptions::CASE_SENSITIVE) != is_case {
1887 self.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
1888 }
1889 }
1890 }
1891 }
1892
1893 fn adjust_query_regex_language(&self, cx: &mut App) {
1894 let enable = self.search_options.contains(SearchOptions::REGEX);
1895 let query_buffer = self
1896 .query_editor
1897 .read(cx)
1898 .buffer()
1899 .read(cx)
1900 .as_singleton()
1901 .expect("query editor should be backed by a singleton buffer");
1902
1903 if enable {
1904 if let Some(regex_language) = self.regex_language.clone() {
1905 query_buffer.update(cx, |query_buffer, cx| {
1906 query_buffer.set_language(Some(regex_language), cx);
1907 })
1908 }
1909 } else {
1910 query_buffer.update(cx, |query_buffer, cx| {
1911 query_buffer.set_language(None, cx);
1912 })
1913 }
1914 }
1915
1916 /// Updates the searchable item's case sensitivity option to match the
1917 /// search bar's current case sensitivity setting. This ensures that
1918 /// editor's `select_next`/ `select_previous` operations respect the buffer
1919 /// search bar's search options.
1920 ///
1921 /// Clears the case sensitivity when the search bar is dismissed so that
1922 /// only the editor's settings are respected.
1923 fn sync_select_next_case_sensitivity(&self, cx: &mut Context<Self>) {
1924 let case_sensitive = match self.dismissed {
1925 true => None,
1926 false => Some(self.search_options.contains(SearchOptions::CASE_SENSITIVE)),
1927 };
1928
1929 if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
1930 active_searchable_item.set_search_is_case_sensitive(case_sensitive, cx);
1931 }
1932 }
1933}
1934
1935#[cfg(test)]
1936mod tests {
1937 use std::{ops::Range, time::Duration};
1938
1939 use super::*;
1940 use editor::{
1941 DisplayPoint, Editor, HighlightKey, MultiBuffer, PathKey,
1942 SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT, SearchSettings, SelectionEffects,
1943 display_map::DisplayRow, test::editor_test_context::EditorTestContext,
1944 };
1945 use gpui::{Hsla, TestAppContext, UpdateGlobal, VisualTestContext};
1946 use language::{Buffer, Point};
1947 use settings::{SearchSettingsContent, SettingsStore};
1948 use smol::stream::StreamExt as _;
1949 use unindent::Unindent as _;
1950 use util_macros::perf;
1951
1952 fn init_globals(cx: &mut TestAppContext) {
1953 cx.update(|cx| {
1954 let store = settings::SettingsStore::test(cx);
1955 cx.set_global(store);
1956 editor::init(cx);
1957
1958 theme_settings::init(theme::LoadThemes::JustBase, cx);
1959 crate::init(cx);
1960 });
1961 }
1962
1963 fn init_multibuffer_test(
1964 cx: &mut TestAppContext,
1965 ) -> (
1966 Entity<Editor>,
1967 Entity<BufferSearchBar>,
1968 &mut VisualTestContext,
1969 ) {
1970 init_globals(cx);
1971
1972 let buffer1 = cx.new(|cx| {
1973 Buffer::local(
1974 r#"
1975 A regular expression (shortened as regex or regexp;[1] also referred to as
1976 rational expression[2][3]) is a sequence of characters that specifies a search
1977 pattern in text. Usually such patterns are used by string-searching algorithms
1978 for "find" or "find and replace" operations on strings, or for input validation.
1979 "#
1980 .unindent(),
1981 cx,
1982 )
1983 });
1984
1985 let buffer2 = cx.new(|cx| {
1986 Buffer::local(
1987 r#"
1988 Some Additional text with the term regular expression in it.
1989 There two lines.
1990 "#
1991 .unindent(),
1992 cx,
1993 )
1994 });
1995
1996 let multibuffer = cx.new(|cx| {
1997 let mut buffer = MultiBuffer::new(language::Capability::ReadWrite);
1998
1999 //[ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))]
2000 buffer.set_excerpts_for_path(
2001 PathKey::sorted(0),
2002 buffer1,
2003 [Point::new(0, 0)..Point::new(3, 0)],
2004 0,
2005 cx,
2006 );
2007 buffer.set_excerpts_for_path(
2008 PathKey::sorted(1),
2009 buffer2,
2010 [Point::new(0, 0)..Point::new(1, 0)],
2011 0,
2012 cx,
2013 );
2014
2015 buffer
2016 });
2017 let mut editor = None;
2018 let window = cx.add_window(|window, cx| {
2019 let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
2020 "keymaps/default-macos.json",
2021 cx,
2022 )
2023 .unwrap();
2024 cx.bind_keys(default_key_bindings);
2025 editor =
2026 Some(cx.new(|cx| Editor::for_multibuffer(multibuffer.clone(), None, window, cx)));
2027
2028 let mut search_bar = BufferSearchBar::new(None, window, cx);
2029 search_bar.set_active_pane_item(Some(&editor.clone().unwrap()), window, cx);
2030 search_bar.show(window, cx);
2031 search_bar
2032 });
2033 let search_bar = window.root(cx).unwrap();
2034
2035 let cx = VisualTestContext::from_window(*window, cx).into_mut();
2036
2037 (editor.unwrap(), search_bar, cx)
2038 }
2039
2040 fn init_test(
2041 cx: &mut TestAppContext,
2042 ) -> (
2043 Entity<Editor>,
2044 Entity<BufferSearchBar>,
2045 &mut VisualTestContext,
2046 ) {
2047 init_globals(cx);
2048 let buffer = cx.new(|cx| {
2049 Buffer::local(
2050 r#"
2051 A regular expression (shortened as regex or regexp;[1] also referred to as
2052 rational expression[2][3]) is a sequence of characters that specifies a search
2053 pattern in text. Usually such patterns are used by string-searching algorithms
2054 for "find" or "find and replace" operations on strings, or for input validation.
2055 "#
2056 .unindent(),
2057 cx,
2058 )
2059 });
2060 let mut editor = None;
2061 let window = cx.add_window(|window, cx| {
2062 let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
2063 "keymaps/default-macos.json",
2064 cx,
2065 )
2066 .unwrap();
2067 cx.bind_keys(default_key_bindings);
2068 editor = Some(cx.new(|cx| Editor::for_buffer(buffer.clone(), None, window, cx)));
2069 let mut search_bar = BufferSearchBar::new(None, window, cx);
2070 search_bar.set_active_pane_item(Some(&editor.clone().unwrap()), window, cx);
2071 search_bar.show(window, cx);
2072 search_bar
2073 });
2074 let search_bar = window.root(cx).unwrap();
2075
2076 let cx = VisualTestContext::from_window(*window, cx).into_mut();
2077
2078 (editor.unwrap(), search_bar, cx)
2079 }
2080
2081 #[perf]
2082 #[gpui::test]
2083 async fn test_search_simple(cx: &mut TestAppContext) {
2084 let (editor, search_bar, cx) = init_test(cx);
2085 let display_points_of = |background_highlights: Vec<(Range<DisplayPoint>, Hsla)>| {
2086 background_highlights
2087 .into_iter()
2088 .map(|(range, _)| range)
2089 .collect::<Vec<_>>()
2090 };
2091 // Search for a string that appears with different casing.
2092 // By default, search is case-insensitive.
2093 search_bar
2094 .update_in(cx, |search_bar, window, cx| {
2095 search_bar.search("us", None, true, window, cx)
2096 })
2097 .await
2098 .unwrap();
2099 editor.update_in(cx, |editor, window, cx| {
2100 assert_eq!(
2101 display_points_of(editor.all_text_background_highlights(window, cx)),
2102 &[
2103 DisplayPoint::new(DisplayRow(2), 17)..DisplayPoint::new(DisplayRow(2), 19),
2104 DisplayPoint::new(DisplayRow(2), 43)..DisplayPoint::new(DisplayRow(2), 45),
2105 ]
2106 );
2107 });
2108
2109 // Switch to a case sensitive search.
2110 search_bar.update_in(cx, |search_bar, window, cx| {
2111 search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
2112 });
2113 let mut editor_notifications = cx.notifications(&editor);
2114 editor_notifications.next().await;
2115 editor.update_in(cx, |editor, window, cx| {
2116 assert_eq!(
2117 display_points_of(editor.all_text_background_highlights(window, cx)),
2118 &[DisplayPoint::new(DisplayRow(2), 43)..DisplayPoint::new(DisplayRow(2), 45),]
2119 );
2120 });
2121
2122 // Search for a string that appears both as a whole word and
2123 // within other words. By default, all results are found.
2124 search_bar
2125 .update_in(cx, |search_bar, window, cx| {
2126 search_bar.search("or", None, true, window, cx)
2127 })
2128 .await
2129 .unwrap();
2130 editor.update_in(cx, |editor, window, cx| {
2131 assert_eq!(
2132 display_points_of(editor.all_text_background_highlights(window, cx)),
2133 &[
2134 DisplayPoint::new(DisplayRow(0), 24)..DisplayPoint::new(DisplayRow(0), 26),
2135 DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43),
2136 DisplayPoint::new(DisplayRow(2), 71)..DisplayPoint::new(DisplayRow(2), 73),
2137 DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 3),
2138 DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13),
2139 DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58),
2140 DisplayPoint::new(DisplayRow(3), 60)..DisplayPoint::new(DisplayRow(3), 62),
2141 ]
2142 );
2143 });
2144
2145 // Switch to a whole word search.
2146 search_bar.update_in(cx, |search_bar, window, cx| {
2147 search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
2148 });
2149 let mut editor_notifications = cx.notifications(&editor);
2150 editor_notifications.next().await;
2151 editor.update_in(cx, |editor, window, cx| {
2152 assert_eq!(
2153 display_points_of(editor.all_text_background_highlights(window, cx)),
2154 &[
2155 DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43),
2156 DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13),
2157 DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58),
2158 ]
2159 );
2160 });
2161
2162 editor.update_in(cx, |editor, window, cx| {
2163 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2164 s.select_display_ranges([
2165 DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)
2166 ])
2167 });
2168 });
2169 search_bar.update_in(cx, |search_bar, window, cx| {
2170 assert_eq!(search_bar.active_match_index, Some(0));
2171 search_bar.select_next_match(&SelectNextMatch, window, cx);
2172 assert_eq!(
2173 editor.update(cx, |editor, cx| editor
2174 .selections
2175 .display_ranges(&editor.display_snapshot(cx))),
2176 [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)]
2177 );
2178 });
2179 search_bar.read_with(cx, |search_bar, _| {
2180 assert_eq!(search_bar.active_match_index, Some(0));
2181 });
2182
2183 search_bar.update_in(cx, |search_bar, window, cx| {
2184 search_bar.select_next_match(&SelectNextMatch, window, cx);
2185 assert_eq!(
2186 editor.update(cx, |editor, cx| editor
2187 .selections
2188 .display_ranges(&editor.display_snapshot(cx))),
2189 [DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13)]
2190 );
2191 });
2192 search_bar.read_with(cx, |search_bar, _| {
2193 assert_eq!(search_bar.active_match_index, Some(1));
2194 });
2195
2196 search_bar.update_in(cx, |search_bar, window, cx| {
2197 search_bar.select_next_match(&SelectNextMatch, window, cx);
2198 assert_eq!(
2199 editor.update(cx, |editor, cx| editor
2200 .selections
2201 .display_ranges(&editor.display_snapshot(cx))),
2202 [DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58)]
2203 );
2204 });
2205 search_bar.read_with(cx, |search_bar, _| {
2206 assert_eq!(search_bar.active_match_index, Some(2));
2207 });
2208
2209 search_bar.update_in(cx, |search_bar, window, cx| {
2210 search_bar.select_next_match(&SelectNextMatch, window, cx);
2211 assert_eq!(
2212 editor.update(cx, |editor, cx| editor
2213 .selections
2214 .display_ranges(&editor.display_snapshot(cx))),
2215 [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)]
2216 );
2217 });
2218 search_bar.read_with(cx, |search_bar, _| {
2219 assert_eq!(search_bar.active_match_index, Some(0));
2220 });
2221
2222 search_bar.update_in(cx, |search_bar, window, cx| {
2223 search_bar.select_prev_match(&SelectPreviousMatch, window, cx);
2224 assert_eq!(
2225 editor.update(cx, |editor, cx| editor
2226 .selections
2227 .display_ranges(&editor.display_snapshot(cx))),
2228 [DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58)]
2229 );
2230 });
2231 search_bar.read_with(cx, |search_bar, _| {
2232 assert_eq!(search_bar.active_match_index, Some(2));
2233 });
2234
2235 search_bar.update_in(cx, |search_bar, window, cx| {
2236 search_bar.select_prev_match(&SelectPreviousMatch, window, cx);
2237 assert_eq!(
2238 editor.update(cx, |editor, cx| editor
2239 .selections
2240 .display_ranges(&editor.display_snapshot(cx))),
2241 [DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13)]
2242 );
2243 });
2244 search_bar.read_with(cx, |search_bar, _| {
2245 assert_eq!(search_bar.active_match_index, Some(1));
2246 });
2247
2248 search_bar.update_in(cx, |search_bar, window, cx| {
2249 search_bar.select_prev_match(&SelectPreviousMatch, window, cx);
2250 assert_eq!(
2251 editor.update(cx, |editor, cx| editor
2252 .selections
2253 .display_ranges(&editor.display_snapshot(cx))),
2254 [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)]
2255 );
2256 });
2257 search_bar.read_with(cx, |search_bar, _| {
2258 assert_eq!(search_bar.active_match_index, Some(0));
2259 });
2260
2261 // Park the cursor in between matches and ensure that going to the previous match
2262 // selects the closest match to the left of the cursor.
2263 editor.update_in(cx, |editor, window, cx| {
2264 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2265 s.select_display_ranges([
2266 DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0)
2267 ])
2268 });
2269 });
2270 search_bar.update_in(cx, |search_bar, window, cx| {
2271 search_bar.select_prev_match(&SelectPreviousMatch, window, cx);
2272 assert_eq!(
2273 editor.update(cx, |editor, cx| editor
2274 .selections
2275 .display_ranges(&editor.display_snapshot(cx))),
2276 [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)]
2277 );
2278 });
2279 search_bar.read_with(cx, |search_bar, _| {
2280 assert_eq!(search_bar.active_match_index, Some(0));
2281 });
2282
2283 // Park the cursor in between matches and ensure that going to the next match
2284 // selects the closest match to the right of the cursor.
2285 editor.update_in(cx, |editor, window, cx| {
2286 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2287 s.select_display_ranges([
2288 DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0)
2289 ])
2290 });
2291 });
2292 search_bar.update_in(cx, |search_bar, window, cx| {
2293 search_bar.select_next_match(&SelectNextMatch, window, cx);
2294 assert_eq!(
2295 editor.update(cx, |editor, cx| editor
2296 .selections
2297 .display_ranges(&editor.display_snapshot(cx))),
2298 [DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13)]
2299 );
2300 });
2301 search_bar.read_with(cx, |search_bar, _| {
2302 assert_eq!(search_bar.active_match_index, Some(1));
2303 });
2304
2305 // Park the cursor after the last match and ensure that going to the previous match
2306 // selects the last match.
2307 editor.update_in(cx, |editor, window, cx| {
2308 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2309 s.select_display_ranges([
2310 DisplayPoint::new(DisplayRow(3), 60)..DisplayPoint::new(DisplayRow(3), 60)
2311 ])
2312 });
2313 });
2314 search_bar.update_in(cx, |search_bar, window, cx| {
2315 search_bar.select_prev_match(&SelectPreviousMatch, window, cx);
2316 assert_eq!(
2317 editor.update(cx, |editor, cx| editor
2318 .selections
2319 .display_ranges(&editor.display_snapshot(cx))),
2320 [DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58)]
2321 );
2322 });
2323 search_bar.read_with(cx, |search_bar, _| {
2324 assert_eq!(search_bar.active_match_index, Some(2));
2325 });
2326
2327 // Park the cursor after the last match and ensure that going to the next match
2328 // wraps around and selects the first match.
2329 editor.update_in(cx, |editor, window, cx| {
2330 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2331 s.select_display_ranges([
2332 DisplayPoint::new(DisplayRow(3), 60)..DisplayPoint::new(DisplayRow(3), 60)
2333 ])
2334 });
2335 });
2336 search_bar.update_in(cx, |search_bar, window, cx| {
2337 search_bar.select_next_match(&SelectNextMatch, window, cx);
2338 assert_eq!(
2339 editor.update(cx, |editor, cx| editor
2340 .selections
2341 .display_ranges(&editor.display_snapshot(cx))),
2342 [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)]
2343 );
2344 });
2345 search_bar.read_with(cx, |search_bar, _| {
2346 assert_eq!(search_bar.active_match_index, Some(0));
2347 });
2348
2349 // Park the cursor before the first match and ensure that going to the previous match
2350 // wraps around and selects the last match.
2351 editor.update_in(cx, |editor, window, cx| {
2352 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2353 s.select_display_ranges([
2354 DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)
2355 ])
2356 });
2357 });
2358 search_bar.update_in(cx, |search_bar, window, cx| {
2359 assert_eq!(search_bar.active_match_index, Some(0));
2360 search_bar.select_prev_match(&SelectPreviousMatch, window, cx);
2361 assert_eq!(
2362 editor.update(cx, |editor, cx| editor
2363 .selections
2364 .display_ranges(&editor.display_snapshot(cx))),
2365 [DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58)]
2366 );
2367 });
2368 search_bar.read_with(cx, |search_bar, _| {
2369 assert_eq!(search_bar.active_match_index, Some(2));
2370 });
2371 }
2372
2373 fn display_points_of(
2374 background_highlights: Vec<(Range<DisplayPoint>, Hsla)>,
2375 ) -> Vec<Range<DisplayPoint>> {
2376 background_highlights
2377 .into_iter()
2378 .map(|(range, _)| range)
2379 .collect::<Vec<_>>()
2380 }
2381
2382 #[perf]
2383 #[gpui::test]
2384 async fn test_search_option_handling(cx: &mut TestAppContext) {
2385 let (editor, search_bar, cx) = init_test(cx);
2386
2387 // show with options should make current search case sensitive
2388 search_bar
2389 .update_in(cx, |search_bar, window, cx| {
2390 search_bar.show(window, cx);
2391 search_bar.search("us", Some(SearchOptions::CASE_SENSITIVE), true, window, cx)
2392 })
2393 .await
2394 .unwrap();
2395 editor.update_in(cx, |editor, window, cx| {
2396 assert_eq!(
2397 display_points_of(editor.all_text_background_highlights(window, cx)),
2398 &[DisplayPoint::new(DisplayRow(2), 43)..DisplayPoint::new(DisplayRow(2), 45),]
2399 );
2400 });
2401
2402 // search_suggested should restore default options
2403 search_bar.update_in(cx, |search_bar, window, cx| {
2404 search_bar.search_suggested(window, cx);
2405 assert_eq!(search_bar.search_options, SearchOptions::NONE)
2406 });
2407
2408 // toggling a search option should update the defaults
2409 search_bar
2410 .update_in(cx, |search_bar, window, cx| {
2411 search_bar.search(
2412 "regex",
2413 Some(SearchOptions::CASE_SENSITIVE),
2414 true,
2415 window,
2416 cx,
2417 )
2418 })
2419 .await
2420 .unwrap();
2421 search_bar.update_in(cx, |search_bar, window, cx| {
2422 search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx)
2423 });
2424 let mut editor_notifications = cx.notifications(&editor);
2425 editor_notifications.next().await;
2426 editor.update_in(cx, |editor, window, cx| {
2427 assert_eq!(
2428 display_points_of(editor.all_text_background_highlights(window, cx)),
2429 &[DisplayPoint::new(DisplayRow(0), 35)..DisplayPoint::new(DisplayRow(0), 40),]
2430 );
2431 });
2432
2433 // defaults should still include whole word
2434 search_bar.update_in(cx, |search_bar, window, cx| {
2435 search_bar.search_suggested(window, cx);
2436 assert_eq!(
2437 search_bar.search_options,
2438 SearchOptions::CASE_SENSITIVE | SearchOptions::WHOLE_WORD
2439 )
2440 });
2441 }
2442
2443 #[perf]
2444 #[gpui::test]
2445 async fn test_search_select_all_matches(cx: &mut TestAppContext) {
2446 init_globals(cx);
2447 let buffer_text = r#"
2448 A regular expression (shortened as regex or regexp;[1] also referred to as
2449 rational expression[2][3]) is a sequence of characters that specifies a search
2450 pattern in text. Usually such patterns are used by string-searching algorithms
2451 for "find" or "find and replace" operations on strings, or for input validation.
2452 "#
2453 .unindent();
2454 let expected_query_matches_count = buffer_text
2455 .chars()
2456 .filter(|c| c.eq_ignore_ascii_case(&'a'))
2457 .count();
2458 assert!(
2459 expected_query_matches_count > 1,
2460 "Should pick a query with multiple results"
2461 );
2462 let buffer = cx.new(|cx| Buffer::local(buffer_text, cx));
2463 let window = cx.add_window(|_, _| gpui::Empty);
2464
2465 let editor = window.build_entity(cx, |window, cx| {
2466 Editor::for_buffer(buffer.clone(), None, window, cx)
2467 });
2468
2469 let search_bar = window.build_entity(cx, |window, cx| {
2470 let mut search_bar = BufferSearchBar::new(None, window, cx);
2471 search_bar.set_active_pane_item(Some(&editor), window, cx);
2472 search_bar.show(window, cx);
2473 search_bar
2474 });
2475
2476 window
2477 .update(cx, |_, window, cx| {
2478 search_bar.update(cx, |search_bar, cx| {
2479 search_bar.search("a", None, true, window, cx)
2480 })
2481 })
2482 .unwrap()
2483 .await
2484 .unwrap();
2485 let initial_selections = window
2486 .update(cx, |_, window, cx| {
2487 search_bar.update(cx, |search_bar, cx| {
2488 let handle = search_bar.query_editor.focus_handle(cx);
2489 window.focus(&handle, cx);
2490 search_bar.activate_current_match(window, cx);
2491 });
2492 assert!(
2493 !editor.read(cx).is_focused(window),
2494 "Initially, the editor should not be focused"
2495 );
2496 let initial_selections = editor.update(cx, |editor, cx| {
2497 let initial_selections = editor.selections.display_ranges(&editor.display_snapshot(cx));
2498 assert_eq!(
2499 initial_selections.len(), 1,
2500 "Expected to have only one selection before adding carets to all matches, but got: {initial_selections:?}",
2501 );
2502 initial_selections
2503 });
2504 search_bar.update(cx, |search_bar, cx| {
2505 assert_eq!(search_bar.active_match_index, Some(0));
2506 let handle = search_bar.query_editor.focus_handle(cx);
2507 window.focus(&handle, cx);
2508 search_bar.select_all_matches(&SelectAllMatches, window, cx);
2509 });
2510 assert!(
2511 editor.read(cx).is_focused(window),
2512 "Should focus editor after successful SelectAllMatches"
2513 );
2514 search_bar.update(cx, |search_bar, cx| {
2515 let all_selections =
2516 editor.update(cx, |editor, cx| editor.selections.display_ranges(&editor.display_snapshot(cx)));
2517 assert_eq!(
2518 all_selections.len(),
2519 expected_query_matches_count,
2520 "Should select all `a` characters in the buffer, but got: {all_selections:?}"
2521 );
2522 assert_eq!(
2523 search_bar.active_match_index,
2524 Some(0),
2525 "Match index should not change after selecting all matches"
2526 );
2527 });
2528
2529 search_bar.update(cx, |this, cx| this.select_next_match(&SelectNextMatch, window, cx));
2530 initial_selections
2531 }).unwrap();
2532
2533 window
2534 .update(cx, |_, window, cx| {
2535 assert!(
2536 editor.read(cx).is_focused(window),
2537 "Should still have editor focused after SelectNextMatch"
2538 );
2539 search_bar.update(cx, |search_bar, cx| {
2540 let all_selections = editor.update(cx, |editor, cx| {
2541 editor
2542 .selections
2543 .display_ranges(&editor.display_snapshot(cx))
2544 });
2545 assert_eq!(
2546 all_selections.len(),
2547 1,
2548 "On next match, should deselect items and select the next match"
2549 );
2550 assert_ne!(
2551 all_selections, initial_selections,
2552 "Next match should be different from the first selection"
2553 );
2554 assert_eq!(
2555 search_bar.active_match_index,
2556 Some(1),
2557 "Match index should be updated to the next one"
2558 );
2559 let handle = search_bar.query_editor.focus_handle(cx);
2560 window.focus(&handle, cx);
2561 search_bar.select_all_matches(&SelectAllMatches, window, cx);
2562 });
2563 })
2564 .unwrap();
2565 window
2566 .update(cx, |_, window, cx| {
2567 assert!(
2568 editor.read(cx).is_focused(window),
2569 "Should focus editor after successful SelectAllMatches"
2570 );
2571 search_bar.update(cx, |search_bar, cx| {
2572 let all_selections =
2573 editor.update(cx, |editor, cx| editor.selections.display_ranges(&editor.display_snapshot(cx)));
2574 assert_eq!(
2575 all_selections.len(),
2576 expected_query_matches_count,
2577 "Should select all `a` characters in the buffer, but got: {all_selections:?}"
2578 );
2579 assert_eq!(
2580 search_bar.active_match_index,
2581 Some(1),
2582 "Match index should not change after selecting all matches"
2583 );
2584 });
2585 search_bar.update(cx, |search_bar, cx| {
2586 search_bar.select_prev_match(&SelectPreviousMatch, window, cx);
2587 });
2588 })
2589 .unwrap();
2590 let last_match_selections = window
2591 .update(cx, |_, window, cx| {
2592 assert!(
2593 editor.read(cx).is_focused(window),
2594 "Should still have editor focused after SelectPreviousMatch"
2595 );
2596
2597 search_bar.update(cx, |search_bar, cx| {
2598 let all_selections = editor.update(cx, |editor, cx| {
2599 editor
2600 .selections
2601 .display_ranges(&editor.display_snapshot(cx))
2602 });
2603 assert_eq!(
2604 all_selections.len(),
2605 1,
2606 "On previous match, should deselect items and select the previous item"
2607 );
2608 assert_eq!(
2609 all_selections, initial_selections,
2610 "Previous match should be the same as the first selection"
2611 );
2612 assert_eq!(
2613 search_bar.active_match_index,
2614 Some(0),
2615 "Match index should be updated to the previous one"
2616 );
2617 all_selections
2618 })
2619 })
2620 .unwrap();
2621
2622 window
2623 .update(cx, |_, window, cx| {
2624 search_bar.update(cx, |search_bar, cx| {
2625 let handle = search_bar.query_editor.focus_handle(cx);
2626 window.focus(&handle, cx);
2627 search_bar.search("abas_nonexistent_match", None, true, window, cx)
2628 })
2629 })
2630 .unwrap()
2631 .await
2632 .unwrap();
2633 window
2634 .update(cx, |_, window, cx| {
2635 search_bar.update(cx, |search_bar, cx| {
2636 search_bar.select_all_matches(&SelectAllMatches, window, cx);
2637 });
2638 assert!(
2639 editor.update(cx, |this, _cx| !this.is_focused(window)),
2640 "Should not switch focus to editor if SelectAllMatches does not find any matches"
2641 );
2642 search_bar.update(cx, |search_bar, cx| {
2643 let all_selections =
2644 editor.update(cx, |editor, cx| editor.selections.display_ranges(&editor.display_snapshot(cx)));
2645 assert_eq!(
2646 all_selections, last_match_selections,
2647 "Should not select anything new if there are no matches"
2648 );
2649 assert!(
2650 search_bar.active_match_index.is_none(),
2651 "For no matches, there should be no active match index"
2652 );
2653 });
2654 })
2655 .unwrap();
2656 }
2657
2658 #[perf]
2659 #[gpui::test]
2660 async fn test_search_query_with_match_whole_word(cx: &mut TestAppContext) {
2661 init_globals(cx);
2662 let buffer_text = r#"
2663 self.buffer.update(cx, |buffer, cx| {
2664 buffer.edit(
2665 edits,
2666 Some(AutoindentMode::Block {
2667 original_indent_columns,
2668 }),
2669 cx,
2670 )
2671 });
2672
2673 this.buffer.update(cx, |buffer, cx| {
2674 buffer.edit([(end_of_line..start_of_next_line, replace)], None, cx)
2675 });
2676 "#
2677 .unindent();
2678 let buffer = cx.new(|cx| Buffer::local(buffer_text, cx));
2679 let cx = cx.add_empty_window();
2680
2681 let editor =
2682 cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx));
2683
2684 let search_bar = cx.new_window_entity(|window, cx| {
2685 let mut search_bar = BufferSearchBar::new(None, window, cx);
2686 search_bar.set_active_pane_item(Some(&editor), window, cx);
2687 search_bar.show(window, cx);
2688 search_bar
2689 });
2690
2691 search_bar
2692 .update_in(cx, |search_bar, window, cx| {
2693 search_bar.search(
2694 "edit\\(",
2695 Some(SearchOptions::WHOLE_WORD | SearchOptions::REGEX),
2696 true,
2697 window,
2698 cx,
2699 )
2700 })
2701 .await
2702 .unwrap();
2703
2704 search_bar.update_in(cx, |search_bar, window, cx| {
2705 search_bar.select_all_matches(&SelectAllMatches, window, cx);
2706 });
2707 search_bar.update(cx, |_, cx| {
2708 let all_selections = editor.update(cx, |editor, cx| {
2709 editor
2710 .selections
2711 .display_ranges(&editor.display_snapshot(cx))
2712 });
2713 assert_eq!(
2714 all_selections.len(),
2715 2,
2716 "Should select all `edit(` in the buffer, but got: {all_selections:?}"
2717 );
2718 });
2719
2720 search_bar
2721 .update_in(cx, |search_bar, window, cx| {
2722 search_bar.search(
2723 "edit(",
2724 Some(SearchOptions::WHOLE_WORD | SearchOptions::CASE_SENSITIVE),
2725 true,
2726 window,
2727 cx,
2728 )
2729 })
2730 .await
2731 .unwrap();
2732
2733 search_bar.update_in(cx, |search_bar, window, cx| {
2734 search_bar.select_all_matches(&SelectAllMatches, window, cx);
2735 });
2736 search_bar.update(cx, |_, cx| {
2737 let all_selections = editor.update(cx, |editor, cx| {
2738 editor
2739 .selections
2740 .display_ranges(&editor.display_snapshot(cx))
2741 });
2742 assert_eq!(
2743 all_selections.len(),
2744 2,
2745 "Should select all `edit(` in the buffer, but got: {all_selections:?}"
2746 );
2747 });
2748 }
2749
2750 #[perf]
2751 #[gpui::test]
2752 async fn test_search_query_history(cx: &mut TestAppContext) {
2753 let (_editor, search_bar, cx) = init_test(cx);
2754
2755 // Add 3 search items into the history.
2756 search_bar
2757 .update_in(cx, |search_bar, window, cx| {
2758 search_bar.search("a", None, true, window, cx)
2759 })
2760 .await
2761 .unwrap();
2762 search_bar
2763 .update_in(cx, |search_bar, window, cx| {
2764 search_bar.search("b", None, true, window, cx)
2765 })
2766 .await
2767 .unwrap();
2768 search_bar
2769 .update_in(cx, |search_bar, window, cx| {
2770 search_bar.search("c", Some(SearchOptions::CASE_SENSITIVE), true, window, cx)
2771 })
2772 .await
2773 .unwrap();
2774 // Ensure that the latest search is active.
2775 search_bar.update(cx, |search_bar, cx| {
2776 assert_eq!(search_bar.query(cx), "c");
2777 assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
2778 });
2779
2780 // Next history query after the latest should preserve the current query.
2781 search_bar.update_in(cx, |search_bar, window, cx| {
2782 search_bar.next_history_query(&NextHistoryQuery, window, cx);
2783 });
2784 cx.background_executor.run_until_parked();
2785 search_bar.update(cx, |search_bar, cx| {
2786 assert_eq!(search_bar.query(cx), "c");
2787 assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
2788 });
2789 search_bar.update_in(cx, |search_bar, window, cx| {
2790 search_bar.next_history_query(&NextHistoryQuery, window, cx);
2791 });
2792 cx.background_executor.run_until_parked();
2793 search_bar.update(cx, |search_bar, cx| {
2794 assert_eq!(search_bar.query(cx), "c");
2795 assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
2796 });
2797
2798 // Previous query should navigate backwards through history.
2799 search_bar.update_in(cx, |search_bar, window, cx| {
2800 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
2801 });
2802 cx.background_executor.run_until_parked();
2803 search_bar.update(cx, |search_bar, cx| {
2804 assert_eq!(search_bar.query(cx), "b");
2805 assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
2806 });
2807
2808 // Further previous items should go over the history in reverse order.
2809 search_bar.update_in(cx, |search_bar, window, cx| {
2810 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
2811 });
2812 cx.background_executor.run_until_parked();
2813 search_bar.update(cx, |search_bar, cx| {
2814 assert_eq!(search_bar.query(cx), "a");
2815 assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
2816 });
2817
2818 // Previous items should never go behind the first history item.
2819 search_bar.update_in(cx, |search_bar, window, cx| {
2820 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
2821 });
2822 cx.background_executor.run_until_parked();
2823 search_bar.update(cx, |search_bar, cx| {
2824 assert_eq!(search_bar.query(cx), "a");
2825 assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
2826 });
2827 search_bar.update_in(cx, |search_bar, window, cx| {
2828 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
2829 });
2830 cx.background_executor.run_until_parked();
2831 search_bar.update(cx, |search_bar, cx| {
2832 assert_eq!(search_bar.query(cx), "a");
2833 assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
2834 });
2835
2836 // Next items should go over the history in the original order.
2837 search_bar.update_in(cx, |search_bar, window, cx| {
2838 search_bar.next_history_query(&NextHistoryQuery, window, cx);
2839 });
2840 cx.background_executor.run_until_parked();
2841 search_bar.update(cx, |search_bar, cx| {
2842 assert_eq!(search_bar.query(cx), "b");
2843 assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
2844 });
2845
2846 search_bar
2847 .update_in(cx, |search_bar, window, cx| {
2848 search_bar.search("ba", None, true, window, cx)
2849 })
2850 .await
2851 .unwrap();
2852 search_bar.update(cx, |search_bar, cx| {
2853 assert_eq!(search_bar.query(cx), "ba");
2854 assert_eq!(search_bar.search_options, SearchOptions::NONE);
2855 });
2856
2857 // New search input should add another entry to history and move the selection to the end of the history.
2858 search_bar.update_in(cx, |search_bar, window, cx| {
2859 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
2860 });
2861 cx.background_executor.run_until_parked();
2862 search_bar.update(cx, |search_bar, cx| {
2863 assert_eq!(search_bar.query(cx), "c");
2864 assert_eq!(search_bar.search_options, SearchOptions::NONE);
2865 });
2866 search_bar.update_in(cx, |search_bar, window, cx| {
2867 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
2868 });
2869 cx.background_executor.run_until_parked();
2870 search_bar.update(cx, |search_bar, cx| {
2871 assert_eq!(search_bar.query(cx), "b");
2872 assert_eq!(search_bar.search_options, SearchOptions::NONE);
2873 });
2874 search_bar.update_in(cx, |search_bar, window, cx| {
2875 search_bar.next_history_query(&NextHistoryQuery, window, cx);
2876 });
2877 cx.background_executor.run_until_parked();
2878 search_bar.update(cx, |search_bar, cx| {
2879 assert_eq!(search_bar.query(cx), "c");
2880 assert_eq!(search_bar.search_options, SearchOptions::NONE);
2881 });
2882 search_bar.update_in(cx, |search_bar, window, cx| {
2883 search_bar.next_history_query(&NextHistoryQuery, window, cx);
2884 });
2885 cx.background_executor.run_until_parked();
2886 search_bar.update(cx, |search_bar, cx| {
2887 assert_eq!(search_bar.query(cx), "ba");
2888 assert_eq!(search_bar.search_options, SearchOptions::NONE);
2889 });
2890 search_bar.update_in(cx, |search_bar, window, cx| {
2891 search_bar.next_history_query(&NextHistoryQuery, window, cx);
2892 });
2893 cx.background_executor.run_until_parked();
2894 search_bar.update(cx, |search_bar, cx| {
2895 assert_eq!(search_bar.query(cx), "ba");
2896 assert_eq!(search_bar.search_options, SearchOptions::NONE);
2897 });
2898 }
2899
2900 #[perf]
2901 #[gpui::test]
2902 async fn test_search_query_history_autoscroll(cx: &mut TestAppContext) {
2903 let (_editor, search_bar, cx) = init_test(cx);
2904
2905 // Add a long multi-line query that exceeds the editor's max
2906 // visible height (4 lines), then a short query.
2907 let long_query = "line1\nline2\nline3\nline4\nline5\nline6";
2908 search_bar
2909 .update_in(cx, |search_bar, window, cx| {
2910 search_bar.search(long_query, None, true, window, cx)
2911 })
2912 .await
2913 .unwrap();
2914 search_bar
2915 .update_in(cx, |search_bar, window, cx| {
2916 search_bar.search("short", None, true, window, cx)
2917 })
2918 .await
2919 .unwrap();
2920
2921 // Navigate back to the long entry. Since "short" is single-line,
2922 // the history navigation is allowed.
2923 search_bar.update_in(cx, |search_bar, window, cx| {
2924 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
2925 });
2926 cx.background_executor.run_until_parked();
2927 search_bar.update(cx, |search_bar, cx| {
2928 assert_eq!(search_bar.query(cx), long_query);
2929 });
2930
2931 // The cursor should be scrolled into view despite the content
2932 // exceeding the editor's max visible height.
2933 search_bar.update_in(cx, |search_bar, window, cx| {
2934 let snapshot = search_bar
2935 .query_editor
2936 .update(cx, |editor, cx| editor.snapshot(window, cx));
2937 let cursor_row = search_bar
2938 .query_editor
2939 .read(cx)
2940 .selections
2941 .newest_display(&snapshot)
2942 .head()
2943 .row();
2944 let scroll_top = search_bar
2945 .query_editor
2946 .update(cx, |editor, cx| editor.scroll_position(cx).y);
2947 let visible_lines = search_bar
2948 .query_editor
2949 .read(cx)
2950 .visible_line_count()
2951 .unwrap_or(0.0);
2952 let scroll_bottom = scroll_top + visible_lines;
2953 assert!(
2954 (cursor_row.0 as f64) < scroll_bottom,
2955 "cursor row {cursor_row:?} should be visible (scroll range {scroll_top}..{scroll_bottom})"
2956 );
2957 });
2958 }
2959
2960 #[perf]
2961 #[gpui::test]
2962 async fn test_replace_simple(cx: &mut TestAppContext) {
2963 let (editor, search_bar, cx) = init_test(cx);
2964
2965 search_bar
2966 .update_in(cx, |search_bar, window, cx| {
2967 search_bar.search("expression", None, true, window, cx)
2968 })
2969 .await
2970 .unwrap();
2971
2972 search_bar.update_in(cx, |search_bar, window, cx| {
2973 search_bar.replacement_editor.update(cx, |editor, cx| {
2974 // We use $1 here as initially we should be in Text mode, where `$1` should be treated literally.
2975 editor.set_text("expr$1", window, cx);
2976 });
2977 search_bar.replace_all(&ReplaceAll, window, cx)
2978 });
2979 assert_eq!(
2980 editor.read_with(cx, |this, cx| { this.text(cx) }),
2981 r#"
2982 A regular expr$1 (shortened as regex or regexp;[1] also referred to as
2983 rational expr$1[2][3]) is a sequence of characters that specifies a search
2984 pattern in text. Usually such patterns are used by string-searching algorithms
2985 for "find" or "find and replace" operations on strings, or for input validation.
2986 "#
2987 .unindent()
2988 );
2989
2990 // Search for word boundaries and replace just a single one.
2991 search_bar
2992 .update_in(cx, |search_bar, window, cx| {
2993 search_bar.search("or", Some(SearchOptions::WHOLE_WORD), true, window, cx)
2994 })
2995 .await
2996 .unwrap();
2997
2998 search_bar.update_in(cx, |search_bar, window, cx| {
2999 search_bar.replacement_editor.update(cx, |editor, cx| {
3000 editor.set_text("banana", window, cx);
3001 });
3002 search_bar.replace_next(&ReplaceNext, window, cx)
3003 });
3004 // Notice how the first or in the text (shORtened) is not replaced. Neither are the remaining hits of `or` in the text.
3005 assert_eq!(
3006 editor.read_with(cx, |this, cx| { this.text(cx) }),
3007 r#"
3008 A regular expr$1 (shortened as regex banana regexp;[1] also referred to as
3009 rational expr$1[2][3]) is a sequence of characters that specifies a search
3010 pattern in text. Usually such patterns are used by string-searching algorithms
3011 for "find" or "find and replace" operations on strings, or for input validation.
3012 "#
3013 .unindent()
3014 );
3015 // Let's turn on regex mode.
3016 search_bar
3017 .update_in(cx, |search_bar, window, cx| {
3018 search_bar.search(
3019 "\\[([^\\]]+)\\]",
3020 Some(SearchOptions::REGEX),
3021 true,
3022 window,
3023 cx,
3024 )
3025 })
3026 .await
3027 .unwrap();
3028 search_bar.update_in(cx, |search_bar, window, cx| {
3029 search_bar.replacement_editor.update(cx, |editor, cx| {
3030 editor.set_text("${1}number", window, cx);
3031 });
3032 search_bar.replace_all(&ReplaceAll, window, cx)
3033 });
3034 assert_eq!(
3035 editor.read_with(cx, |this, cx| { this.text(cx) }),
3036 r#"
3037 A regular expr$1 (shortened as regex banana regexp;1number also referred to as
3038 rational expr$12number3number) is a sequence of characters that specifies a search
3039 pattern in text. Usually such patterns are used by string-searching algorithms
3040 for "find" or "find and replace" operations on strings, or for input validation.
3041 "#
3042 .unindent()
3043 );
3044 // Now with a whole-word twist.
3045 search_bar
3046 .update_in(cx, |search_bar, window, cx| {
3047 search_bar.search(
3048 "a\\w+s",
3049 Some(SearchOptions::REGEX | SearchOptions::WHOLE_WORD),
3050 true,
3051 window,
3052 cx,
3053 )
3054 })
3055 .await
3056 .unwrap();
3057 search_bar.update_in(cx, |search_bar, window, cx| {
3058 search_bar.replacement_editor.update(cx, |editor, cx| {
3059 editor.set_text("things", window, cx);
3060 });
3061 search_bar.replace_all(&ReplaceAll, window, cx)
3062 });
3063 // The only word affected by this edit should be `algorithms`, even though there's a bunch
3064 // of words in this text that would match this regex if not for WHOLE_WORD.
3065 assert_eq!(
3066 editor.read_with(cx, |this, cx| { this.text(cx) }),
3067 r#"
3068 A regular expr$1 (shortened as regex banana regexp;1number also referred to as
3069 rational expr$12number3number) is a sequence of characters that specifies a search
3070 pattern in text. Usually such patterns are used by string-searching things
3071 for "find" or "find and replace" operations on strings, or for input validation.
3072 "#
3073 .unindent()
3074 );
3075 }
3076
3077 #[gpui::test]
3078 async fn test_replace_focus(cx: &mut TestAppContext) {
3079 let (editor, search_bar, cx) = init_test(cx);
3080
3081 editor.update_in(cx, |editor, window, cx| {
3082 editor.set_text("What a bad day!", window, cx)
3083 });
3084
3085 search_bar
3086 .update_in(cx, |search_bar, window, cx| {
3087 search_bar.search("bad", None, true, window, cx)
3088 })
3089 .await
3090 .unwrap();
3091
3092 // Calling `toggle_replace` in the search bar ensures that the "Replace
3093 // *" buttons are rendered, so we can then simulate clicking the
3094 // buttons.
3095 search_bar.update_in(cx, |search_bar, window, cx| {
3096 search_bar.toggle_replace(&ToggleReplace, window, cx)
3097 });
3098
3099 search_bar.update_in(cx, |search_bar, window, cx| {
3100 search_bar.replacement_editor.update(cx, |editor, cx| {
3101 editor.set_text("great", window, cx);
3102 });
3103 });
3104
3105 // Focus on the editor instead of the search bar, as we want to ensure
3106 // that pressing the "Replace Next Match" button will work, even if the
3107 // search bar is not focused.
3108 cx.focus(&editor);
3109
3110 // We'll not simulate clicking the "Replace Next Match " button, asserting that
3111 // the replacement was done.
3112 let button_bounds = cx
3113 .debug_bounds("ICON-ReplaceNext")
3114 .expect("'Replace Next Match' button should be visible");
3115 cx.simulate_click(button_bounds.center(), gpui::Modifiers::none());
3116
3117 assert_eq!(
3118 editor.read_with(cx, |editor, cx| editor.text(cx)),
3119 "What a great day!"
3120 );
3121 }
3122
3123 struct ReplacementTestParams<'a> {
3124 editor: &'a Entity<Editor>,
3125 search_bar: &'a Entity<BufferSearchBar>,
3126 cx: &'a mut VisualTestContext,
3127 search_text: &'static str,
3128 search_options: Option<SearchOptions>,
3129 replacement_text: &'static str,
3130 replace_all: bool,
3131 expected_text: String,
3132 }
3133
3134 async fn run_replacement_test(options: ReplacementTestParams<'_>) {
3135 options
3136 .search_bar
3137 .update_in(options.cx, |search_bar, window, cx| {
3138 if let Some(options) = options.search_options {
3139 search_bar.set_search_options(options, cx);
3140 }
3141 search_bar.search(
3142 options.search_text,
3143 options.search_options,
3144 true,
3145 window,
3146 cx,
3147 )
3148 })
3149 .await
3150 .unwrap();
3151
3152 options
3153 .search_bar
3154 .update_in(options.cx, |search_bar, window, cx| {
3155 search_bar.replacement_editor.update(cx, |editor, cx| {
3156 editor.set_text(options.replacement_text, window, cx);
3157 });
3158
3159 if options.replace_all {
3160 search_bar.replace_all(&ReplaceAll, window, cx)
3161 } else {
3162 search_bar.replace_next(&ReplaceNext, window, cx)
3163 }
3164 });
3165
3166 assert_eq!(
3167 options
3168 .editor
3169 .read_with(options.cx, |this, cx| { this.text(cx) }),
3170 options.expected_text
3171 );
3172 }
3173
3174 #[perf]
3175 #[gpui::test]
3176 async fn test_replace_special_characters(cx: &mut TestAppContext) {
3177 let (editor, search_bar, cx) = init_test(cx);
3178
3179 run_replacement_test(ReplacementTestParams {
3180 editor: &editor,
3181 search_bar: &search_bar,
3182 cx,
3183 search_text: "expression",
3184 search_options: None,
3185 replacement_text: r"\n",
3186 replace_all: true,
3187 expected_text: r#"
3188 A regular \n (shortened as regex or regexp;[1] also referred to as
3189 rational \n[2][3]) is a sequence of characters that specifies a search
3190 pattern in text. Usually such patterns are used by string-searching algorithms
3191 for "find" or "find and replace" operations on strings, or for input validation.
3192 "#
3193 .unindent(),
3194 })
3195 .await;
3196
3197 run_replacement_test(ReplacementTestParams {
3198 editor: &editor,
3199 search_bar: &search_bar,
3200 cx,
3201 search_text: "or",
3202 search_options: Some(SearchOptions::WHOLE_WORD | SearchOptions::REGEX),
3203 replacement_text: r"\\\n\\\\",
3204 replace_all: false,
3205 expected_text: r#"
3206 A regular \n (shortened as regex \
3207 \\ regexp;[1] also referred to as
3208 rational \n[2][3]) is a sequence of characters that specifies a search
3209 pattern in text. Usually such patterns are used by string-searching algorithms
3210 for "find" or "find and replace" operations on strings, or for input validation.
3211 "#
3212 .unindent(),
3213 })
3214 .await;
3215
3216 run_replacement_test(ReplacementTestParams {
3217 editor: &editor,
3218 search_bar: &search_bar,
3219 cx,
3220 search_text: r"(that|used) ",
3221 search_options: Some(SearchOptions::REGEX),
3222 replacement_text: r"$1\n",
3223 replace_all: true,
3224 expected_text: r#"
3225 A regular \n (shortened as regex \
3226 \\ regexp;[1] also referred to as
3227 rational \n[2][3]) is a sequence of characters that
3228 specifies a search
3229 pattern in text. Usually such patterns are used
3230 by string-searching algorithms
3231 for "find" or "find and replace" operations on strings, or for input validation.
3232 "#
3233 .unindent(),
3234 })
3235 .await;
3236 }
3237
3238 #[gpui::test]
3239 async fn test_deploy_replace_focuses_replacement_editor(cx: &mut TestAppContext) {
3240 init_globals(cx);
3241 let (editor, search_bar, cx) = init_test(cx);
3242
3243 editor.update_in(cx, |editor, window, cx| {
3244 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
3245 s.select_display_ranges([
3246 DisplayPoint::new(DisplayRow(0), 8)..DisplayPoint::new(DisplayRow(0), 16)
3247 ])
3248 });
3249 });
3250
3251 search_bar.update_in(cx, |search_bar, window, cx| {
3252 search_bar.deploy(
3253 &Deploy {
3254 focus: true,
3255 replace_enabled: true,
3256 selection_search_enabled: false,
3257 },
3258 window,
3259 cx,
3260 );
3261 });
3262 cx.run_until_parked();
3263
3264 search_bar.update_in(cx, |search_bar, window, cx| {
3265 assert!(
3266 search_bar
3267 .replacement_editor
3268 .focus_handle(cx)
3269 .is_focused(window),
3270 "replacement editor should be focused when deploying replace with a selection",
3271 );
3272 assert!(
3273 !search_bar.query_editor.focus_handle(cx).is_focused(window),
3274 "search editor should not be focused when replacement editor is focused",
3275 );
3276 });
3277 }
3278
3279 #[perf]
3280 #[gpui::test]
3281 async fn test_find_matches_in_selections_singleton_buffer_multiple_selections(
3282 cx: &mut TestAppContext,
3283 ) {
3284 init_globals(cx);
3285 let buffer = cx.new(|cx| {
3286 Buffer::local(
3287 r#"
3288 aaa bbb aaa ccc
3289 aaa bbb aaa ccc
3290 aaa bbb aaa ccc
3291 aaa bbb aaa ccc
3292 aaa bbb aaa ccc
3293 aaa bbb aaa ccc
3294 "#
3295 .unindent(),
3296 cx,
3297 )
3298 });
3299 let cx = cx.add_empty_window();
3300 let editor =
3301 cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx));
3302
3303 let search_bar = cx.new_window_entity(|window, cx| {
3304 let mut search_bar = BufferSearchBar::new(None, window, cx);
3305 search_bar.set_active_pane_item(Some(&editor), window, cx);
3306 search_bar.show(window, cx);
3307 search_bar
3308 });
3309
3310 editor.update_in(cx, |editor, window, cx| {
3311 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
3312 s.select_ranges(vec![Point::new(1, 0)..Point::new(2, 4)])
3313 })
3314 });
3315
3316 search_bar.update_in(cx, |search_bar, window, cx| {
3317 let deploy = Deploy {
3318 focus: true,
3319 replace_enabled: false,
3320 selection_search_enabled: true,
3321 };
3322 search_bar.deploy(&deploy, window, cx);
3323 });
3324
3325 cx.run_until_parked();
3326
3327 search_bar
3328 .update_in(cx, |search_bar, window, cx| {
3329 search_bar.search("aaa", None, true, window, cx)
3330 })
3331 .await
3332 .unwrap();
3333
3334 editor.update(cx, |editor, cx| {
3335 assert_eq!(
3336 editor.search_background_highlights(cx),
3337 &[
3338 Point::new(1, 0)..Point::new(1, 3),
3339 Point::new(1, 8)..Point::new(1, 11),
3340 Point::new(2, 0)..Point::new(2, 3),
3341 ]
3342 );
3343 });
3344 }
3345
3346 #[perf]
3347 #[gpui::test]
3348 async fn test_find_matches_in_selections_multiple_excerpts_buffer_multiple_selections(
3349 cx: &mut TestAppContext,
3350 ) {
3351 init_globals(cx);
3352 let text = r#"
3353 aaa bbb aaa ccc
3354 aaa bbb aaa ccc
3355 aaa bbb aaa ccc
3356 aaa bbb aaa ccc
3357 aaa bbb aaa ccc
3358 aaa bbb aaa ccc
3359
3360 aaa bbb aaa ccc
3361 aaa bbb aaa ccc
3362 aaa bbb aaa ccc
3363 aaa bbb aaa ccc
3364 aaa bbb aaa ccc
3365 aaa bbb aaa ccc
3366 "#
3367 .unindent();
3368
3369 let cx = cx.add_empty_window();
3370 let editor = cx.new_window_entity(|window, cx| {
3371 let multibuffer = MultiBuffer::build_multi(
3372 [
3373 (
3374 &text,
3375 vec![
3376 Point::new(0, 0)..Point::new(2, 0),
3377 Point::new(4, 0)..Point::new(5, 0),
3378 ],
3379 ),
3380 (&text, vec![Point::new(9, 0)..Point::new(11, 0)]),
3381 ],
3382 cx,
3383 );
3384 Editor::for_multibuffer(multibuffer, None, window, cx)
3385 });
3386
3387 let search_bar = cx.new_window_entity(|window, cx| {
3388 let mut search_bar = BufferSearchBar::new(None, window, cx);
3389 search_bar.set_active_pane_item(Some(&editor), window, cx);
3390 search_bar.show(window, cx);
3391 search_bar
3392 });
3393
3394 editor.update_in(cx, |editor, window, cx| {
3395 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
3396 s.select_ranges(vec![
3397 Point::new(1, 0)..Point::new(1, 4),
3398 Point::new(5, 3)..Point::new(6, 4),
3399 ])
3400 })
3401 });
3402
3403 search_bar.update_in(cx, |search_bar, window, cx| {
3404 let deploy = Deploy {
3405 focus: true,
3406 replace_enabled: false,
3407 selection_search_enabled: true,
3408 };
3409 search_bar.deploy(&deploy, window, cx);
3410 });
3411
3412 cx.run_until_parked();
3413
3414 search_bar
3415 .update_in(cx, |search_bar, window, cx| {
3416 search_bar.search("aaa", None, true, window, cx)
3417 })
3418 .await
3419 .unwrap();
3420
3421 editor.update(cx, |editor, cx| {
3422 assert_eq!(
3423 editor.search_background_highlights(cx),
3424 &[
3425 Point::new(1, 0)..Point::new(1, 3),
3426 Point::new(5, 8)..Point::new(5, 11),
3427 Point::new(6, 0)..Point::new(6, 3),
3428 ]
3429 );
3430 });
3431 }
3432
3433 #[perf]
3434 #[gpui::test]
3435 async fn test_hides_and_uses_secondary_when_in_singleton_buffer(cx: &mut TestAppContext) {
3436 let (editor, search_bar, cx) = init_test(cx);
3437
3438 let initial_location = search_bar.update_in(cx, |search_bar, window, cx| {
3439 search_bar.set_active_pane_item(Some(&editor), window, cx)
3440 });
3441
3442 assert_eq!(initial_location, ToolbarItemLocation::Secondary);
3443
3444 let mut events = cx.events::<ToolbarItemEvent, BufferSearchBar>(&search_bar);
3445
3446 search_bar.update_in(cx, |search_bar, window, cx| {
3447 search_bar.dismiss(&Dismiss, window, cx);
3448 });
3449
3450 assert_eq!(
3451 events.try_recv().unwrap(),
3452 (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Hidden))
3453 );
3454
3455 search_bar.update_in(cx, |search_bar, window, cx| {
3456 search_bar.show(window, cx);
3457 });
3458
3459 assert_eq!(
3460 events.try_recv().unwrap(),
3461 (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Secondary))
3462 );
3463 }
3464
3465 #[perf]
3466 #[gpui::test]
3467 async fn test_uses_primary_left_when_in_multi_buffer(cx: &mut TestAppContext) {
3468 let (editor, search_bar, cx) = init_multibuffer_test(cx);
3469
3470 let initial_location = search_bar.update_in(cx, |search_bar, window, cx| {
3471 search_bar.set_active_pane_item(Some(&editor), window, cx)
3472 });
3473
3474 assert_eq!(initial_location, ToolbarItemLocation::PrimaryLeft);
3475
3476 let mut events = cx.events::<ToolbarItemEvent, BufferSearchBar>(&search_bar);
3477
3478 search_bar.update_in(cx, |search_bar, window, cx| {
3479 search_bar.dismiss(&Dismiss, window, cx);
3480 });
3481
3482 assert_eq!(
3483 events.try_recv().unwrap(),
3484 (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::PrimaryLeft))
3485 );
3486
3487 search_bar.update_in(cx, |search_bar, window, cx| {
3488 search_bar.show(window, cx);
3489 });
3490
3491 assert_eq!(
3492 events.try_recv().unwrap(),
3493 (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::PrimaryLeft))
3494 );
3495 }
3496
3497 #[perf]
3498 #[gpui::test]
3499 async fn test_hides_and_uses_secondary_when_part_of_project_search(cx: &mut TestAppContext) {
3500 let (editor, search_bar, cx) = init_multibuffer_test(cx);
3501
3502 editor.update(cx, |editor, _| {
3503 editor.set_in_project_search(true);
3504 });
3505
3506 let initial_location = search_bar.update_in(cx, |search_bar, window, cx| {
3507 search_bar.set_active_pane_item(Some(&editor), window, cx)
3508 });
3509
3510 assert_eq!(initial_location, ToolbarItemLocation::Hidden);
3511
3512 let mut events = cx.events::<ToolbarItemEvent, BufferSearchBar>(&search_bar);
3513
3514 search_bar.update_in(cx, |search_bar, window, cx| {
3515 search_bar.dismiss(&Dismiss, window, cx);
3516 });
3517
3518 assert_eq!(
3519 events.try_recv().unwrap(),
3520 (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Hidden))
3521 );
3522
3523 search_bar.update_in(cx, |search_bar, window, cx| {
3524 search_bar.show(window, cx);
3525 });
3526
3527 assert_eq!(
3528 events.try_recv().unwrap(),
3529 (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Secondary))
3530 );
3531 }
3532
3533 #[perf]
3534 #[gpui::test]
3535 async fn test_sets_collapsed_when_editor_fold_events_emitted(cx: &mut TestAppContext) {
3536 let (editor, search_bar, cx) = init_multibuffer_test(cx);
3537
3538 search_bar.update_in(cx, |search_bar, window, cx| {
3539 search_bar.set_active_pane_item(Some(&editor), window, cx);
3540 });
3541
3542 editor.update_in(cx, |editor, window, cx| {
3543 editor.fold_all(&FoldAll, window, cx);
3544 });
3545 cx.run_until_parked();
3546
3547 let is_collapsed = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
3548 assert!(is_collapsed);
3549
3550 editor.update_in(cx, |editor, window, cx| {
3551 editor.unfold_all(&UnfoldAll, window, cx);
3552 });
3553 cx.run_until_parked();
3554
3555 let is_collapsed = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
3556 assert!(!is_collapsed);
3557 }
3558
3559 #[perf]
3560 #[gpui::test]
3561 async fn test_collapse_state_syncs_after_manual_buffer_fold(cx: &mut TestAppContext) {
3562 let (editor, search_bar, cx) = init_multibuffer_test(cx);
3563
3564 search_bar.update_in(cx, |search_bar, window, cx| {
3565 search_bar.set_active_pane_item(Some(&editor), window, cx);
3566 });
3567
3568 // Fold all buffers via fold_all
3569 editor.update_in(cx, |editor, window, cx| {
3570 editor.fold_all(&FoldAll, window, cx);
3571 });
3572 cx.run_until_parked();
3573
3574 let has_any_folded = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
3575 assert!(
3576 has_any_folded,
3577 "All buffers should be folded after fold_all"
3578 );
3579
3580 // Manually unfold one buffer (simulating a chevron click)
3581 let first_buffer_id = editor.read_with(cx, |editor, cx| {
3582 editor
3583 .buffer()
3584 .read(cx)
3585 .snapshot(cx)
3586 .excerpts()
3587 .nth(0)
3588 .unwrap()
3589 .context
3590 .start
3591 .buffer_id
3592 });
3593 editor.update_in(cx, |editor, _window, cx| {
3594 editor.unfold_buffer(first_buffer_id, cx);
3595 });
3596
3597 let has_any_folded = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
3598 assert!(
3599 has_any_folded,
3600 "Should still report folds when only one buffer is unfolded"
3601 );
3602
3603 // Manually unfold the second buffer too
3604 let second_buffer_id = editor.read_with(cx, |editor, cx| {
3605 editor
3606 .buffer()
3607 .read(cx)
3608 .snapshot(cx)
3609 .excerpts()
3610 .nth(1)
3611 .unwrap()
3612 .context
3613 .start
3614 .buffer_id
3615 });
3616 editor.update_in(cx, |editor, _window, cx| {
3617 editor.unfold_buffer(second_buffer_id, cx);
3618 });
3619
3620 let has_any_folded = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
3621 assert!(
3622 !has_any_folded,
3623 "No folds should remain after unfolding all buffers individually"
3624 );
3625
3626 // Manually fold one buffer back
3627 editor.update_in(cx, |editor, _window, cx| {
3628 editor.fold_buffer(first_buffer_id, cx);
3629 });
3630
3631 let has_any_folded = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
3632 assert!(
3633 has_any_folded,
3634 "Should report folds after manually folding one buffer"
3635 );
3636 }
3637
3638 #[perf]
3639 #[gpui::test]
3640 async fn test_search_options_changes(cx: &mut TestAppContext) {
3641 let (_editor, search_bar, cx) = init_test(cx);
3642 update_search_settings(
3643 SearchSettings {
3644 button: true,
3645 whole_word: false,
3646 case_sensitive: false,
3647 include_ignored: false,
3648 regex: false,
3649 center_on_match: false,
3650 },
3651 cx,
3652 );
3653
3654 let deploy = Deploy {
3655 focus: true,
3656 replace_enabled: false,
3657 selection_search_enabled: true,
3658 };
3659
3660 search_bar.update_in(cx, |search_bar, window, cx| {
3661 assert_eq!(
3662 search_bar.search_options,
3663 SearchOptions::NONE,
3664 "Should have no search options enabled by default"
3665 );
3666 search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
3667 assert_eq!(
3668 search_bar.search_options,
3669 SearchOptions::WHOLE_WORD,
3670 "Should enable the option toggled"
3671 );
3672 assert!(
3673 !search_bar.dismissed,
3674 "Search bar should be present and visible"
3675 );
3676 search_bar.deploy(&deploy, window, cx);
3677 assert_eq!(
3678 search_bar.search_options,
3679 SearchOptions::WHOLE_WORD,
3680 "After (re)deploying, the option should still be enabled"
3681 );
3682
3683 search_bar.dismiss(&Dismiss, window, cx);
3684 search_bar.deploy(&deploy, window, cx);
3685 assert_eq!(
3686 search_bar.search_options,
3687 SearchOptions::WHOLE_WORD,
3688 "After hiding and showing the search bar, search options should be preserved"
3689 );
3690
3691 search_bar.toggle_search_option(SearchOptions::REGEX, window, cx);
3692 search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
3693 assert_eq!(
3694 search_bar.search_options,
3695 SearchOptions::REGEX,
3696 "Should enable the options toggled"
3697 );
3698 assert!(
3699 !search_bar.dismissed,
3700 "Search bar should be present and visible"
3701 );
3702 search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
3703 });
3704
3705 update_search_settings(
3706 SearchSettings {
3707 button: true,
3708 whole_word: false,
3709 case_sensitive: true,
3710 include_ignored: false,
3711 regex: false,
3712 center_on_match: false,
3713 },
3714 cx,
3715 );
3716 search_bar.update_in(cx, |search_bar, window, cx| {
3717 assert_eq!(
3718 search_bar.search_options,
3719 SearchOptions::REGEX | SearchOptions::WHOLE_WORD,
3720 "Should have no search options enabled by default"
3721 );
3722
3723 search_bar.deploy(&deploy, window, cx);
3724 assert_eq!(
3725 search_bar.search_options,
3726 SearchOptions::REGEX | SearchOptions::WHOLE_WORD,
3727 "Toggling a non-dismissed search bar with custom options should not change the default options"
3728 );
3729 search_bar.dismiss(&Dismiss, window, cx);
3730 search_bar.deploy(&deploy, window, cx);
3731 assert_eq!(
3732 search_bar.configured_options,
3733 SearchOptions::CASE_SENSITIVE,
3734 "After a settings update and toggling the search bar, configured options should be updated"
3735 );
3736 assert_eq!(
3737 search_bar.search_options,
3738 SearchOptions::CASE_SENSITIVE,
3739 "After a settings update and toggling the search bar, configured options should be used"
3740 );
3741 });
3742
3743 update_search_settings(
3744 SearchSettings {
3745 button: true,
3746 whole_word: true,
3747 case_sensitive: true,
3748 include_ignored: false,
3749 regex: false,
3750 center_on_match: false,
3751 },
3752 cx,
3753 );
3754
3755 search_bar.update_in(cx, |search_bar, window, cx| {
3756 search_bar.deploy(&deploy, window, cx);
3757 search_bar.dismiss(&Dismiss, window, cx);
3758 search_bar.show(window, cx);
3759 assert_eq!(
3760 search_bar.search_options,
3761 SearchOptions::CASE_SENSITIVE | SearchOptions::WHOLE_WORD,
3762 "Calling deploy on an already deployed search bar should not prevent settings updates from being detected"
3763 );
3764 });
3765 }
3766
3767 #[gpui::test]
3768 async fn test_select_occurrence_case_sensitivity(cx: &mut TestAppContext) {
3769 let (editor, search_bar, cx) = init_test(cx);
3770 let mut editor_cx = EditorTestContext::for_editor_in(editor, cx).await;
3771
3772 // Start with case sensitive search settings.
3773 let mut search_settings = SearchSettings::default();
3774 search_settings.case_sensitive = true;
3775 update_search_settings(search_settings, cx);
3776 search_bar.update(cx, |search_bar, cx| {
3777 let mut search_options = search_bar.search_options;
3778 search_options.insert(SearchOptions::CASE_SENSITIVE);
3779 search_bar.set_search_options(search_options, cx);
3780 });
3781
3782 editor_cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo");
3783 editor_cx.update_editor(|e, window, cx| {
3784 e.select_next(&Default::default(), window, cx).unwrap();
3785 });
3786 editor_cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»");
3787
3788 // Update the search bar's case sensitivite toggle, so we can later
3789 // confirm that `select_next` will now be case-insensitive.
3790 editor_cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo");
3791 search_bar.update_in(cx, |search_bar, window, cx| {
3792 search_bar.toggle_case_sensitive(&Default::default(), window, cx);
3793 });
3794 editor_cx.update_editor(|e, window, cx| {
3795 e.select_next(&Default::default(), window, cx).unwrap();
3796 });
3797 editor_cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\nFoo\nfoo");
3798
3799 // Confirm that, after dismissing the search bar, only the editor's
3800 // search settings actually affect the behavior of `select_next`.
3801 search_bar.update_in(cx, |search_bar, window, cx| {
3802 search_bar.dismiss(&Default::default(), window, cx);
3803 });
3804 editor_cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo");
3805 editor_cx.update_editor(|e, window, cx| {
3806 e.select_next(&Default::default(), window, cx).unwrap();
3807 });
3808 editor_cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»");
3809
3810 // Update the editor's search settings, disabling case sensitivity, to
3811 // check that the value is respected.
3812 let mut search_settings = SearchSettings::default();
3813 search_settings.case_sensitive = false;
3814 update_search_settings(search_settings, cx);
3815 editor_cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo");
3816 editor_cx.update_editor(|e, window, cx| {
3817 e.select_next(&Default::default(), window, cx).unwrap();
3818 });
3819 editor_cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\nFoo\nfoo");
3820 }
3821
3822 #[gpui::test]
3823 async fn test_regex_search_does_not_highlight_non_matching_occurrences(
3824 cx: &mut TestAppContext,
3825 ) {
3826 init_globals(cx);
3827 let buffer = cx.new(|cx| {
3828 Buffer::local(
3829 "something is at the top\nsomething is behind something\nsomething is at the bottom\n",
3830 cx,
3831 )
3832 });
3833 let cx = cx.add_empty_window();
3834 let editor =
3835 cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx));
3836 let search_bar = cx.new_window_entity(|window, cx| {
3837 let mut search_bar = BufferSearchBar::new(None, window, cx);
3838 search_bar.set_active_pane_item(Some(&editor), window, cx);
3839 search_bar.show(window, cx);
3840 search_bar
3841 });
3842
3843 search_bar.update_in(cx, |search_bar, window, cx| {
3844 search_bar.toggle_search_option(SearchOptions::REGEX, window, cx);
3845 });
3846
3847 search_bar
3848 .update_in(cx, |search_bar, window, cx| {
3849 search_bar.search("^something", None, true, window, cx)
3850 })
3851 .await
3852 .unwrap();
3853
3854 search_bar.update_in(cx, |search_bar, window, cx| {
3855 search_bar.select_next_match(&SelectNextMatch, window, cx);
3856 });
3857
3858 // Advance past the debounce so the selection occurrence highlight would
3859 // have fired if it were not suppressed by the active buffer search.
3860 cx.executor()
3861 .advance_clock(SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(1));
3862 cx.run_until_parked();
3863
3864 editor.update(cx, |editor, cx| {
3865 assert!(
3866 !editor.has_background_highlights(HighlightKey::SelectedTextHighlight),
3867 "selection occurrence highlights must be suppressed during buffer search"
3868 );
3869 assert_eq!(
3870 editor.search_background_highlights(cx).len(),
3871 3,
3872 "expected exactly 3 search highlights (one per line start)"
3873 );
3874 });
3875
3876 // Manually select "something" — this should restore occurrence highlights
3877 // because it clears the search-navigation flag.
3878 editor.update_in(cx, |editor, window, cx| {
3879 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
3880 s.select_ranges([Point::new(0, 0)..Point::new(0, 9)])
3881 });
3882 });
3883
3884 cx.executor()
3885 .advance_clock(SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(1));
3886 cx.run_until_parked();
3887
3888 editor.update(cx, |editor, _cx| {
3889 assert!(
3890 editor.has_background_highlights(HighlightKey::SelectedTextHighlight),
3891 "selection occurrence highlights must be restored after a manual selection"
3892 );
3893 });
3894 }
3895
3896 fn update_search_settings(search_settings: SearchSettings, cx: &mut TestAppContext) {
3897 cx.update(|cx| {
3898 SettingsStore::update_global(cx, |store, cx| {
3899 store.update_user_settings(cx, |settings| {
3900 settings.editor.search = Some(SearchSettingsContent {
3901 button: Some(search_settings.button),
3902 whole_word: Some(search_settings.whole_word),
3903 case_sensitive: Some(search_settings.case_sensitive),
3904 include_ignored: Some(search_settings.include_ignored),
3905 regex: Some(search_settings.regex),
3906 center_on_match: Some(search_settings.center_on_match),
3907 });
3908 });
3909 });
3910 });
3911 }
3912}