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
1403 searchable_item.update_matches(matches, Some(new_match_index), *token, window, cx);
1404 searchable_item.activate_match(new_match_index, matches, *token, window, cx);
1405 }
1406 }
1407
1408 pub fn select_first_match(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1409 if let Some(searchable_item) = self.active_searchable_item.as_ref()
1410 && let Some((matches, token)) = self
1411 .searchable_items_with_matches
1412 .get(&searchable_item.downgrade())
1413 {
1414 if matches.is_empty() {
1415 return;
1416 }
1417 searchable_item.update_matches(matches, Some(0), *token, window, cx);
1418 searchable_item.activate_match(0, matches, *token, window, cx);
1419 }
1420 }
1421
1422 pub fn select_last_match(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1423 if let Some(searchable_item) = self.active_searchable_item.as_ref()
1424 && let Some((matches, token)) = self
1425 .searchable_items_with_matches
1426 .get(&searchable_item.downgrade())
1427 {
1428 if matches.is_empty() {
1429 return;
1430 }
1431 let new_match_index = matches.len() - 1;
1432 searchable_item.update_matches(matches, Some(new_match_index), *token, window, cx);
1433 searchable_item.activate_match(new_match_index, matches, *token, window, cx);
1434 }
1435 }
1436
1437 fn on_query_editor_event(
1438 &mut self,
1439 _editor: &Entity<Editor>,
1440 event: &editor::EditorEvent,
1441 window: &mut Window,
1442 cx: &mut Context<Self>,
1443 ) {
1444 match event {
1445 editor::EditorEvent::Focused => self.query_editor_focused = true,
1446 editor::EditorEvent::Blurred => self.query_editor_focused = false,
1447 editor::EditorEvent::Edited { .. } => {
1448 self.smartcase(window, cx);
1449 self.clear_matches(window, cx);
1450 let search = self.update_matches(false, true, window, cx);
1451
1452 cx.spawn_in(window, async move |this, cx| {
1453 if search.await.is_ok() {
1454 this.update_in(cx, |this, window, cx| {
1455 this.activate_current_match(window, cx);
1456 #[cfg(target_os = "macos")]
1457 this.update_find_pasteboard(cx);
1458 })?;
1459 }
1460 anyhow::Ok(())
1461 })
1462 .detach_and_log_err(cx);
1463 }
1464 _ => {}
1465 }
1466 }
1467
1468 fn on_replacement_editor_event(
1469 &mut self,
1470 _: Entity<Editor>,
1471 event: &editor::EditorEvent,
1472 _: &mut Context<Self>,
1473 ) {
1474 match event {
1475 editor::EditorEvent::Focused => self.replacement_editor_focused = true,
1476 editor::EditorEvent::Blurred => self.replacement_editor_focused = false,
1477 _ => {}
1478 }
1479 }
1480
1481 fn on_active_searchable_item_event(
1482 &mut self,
1483 event: &SearchEvent,
1484 window: &mut Window,
1485 cx: &mut Context<Self>,
1486 ) {
1487 match event {
1488 SearchEvent::MatchesInvalidated => {
1489 drop(self.update_matches(false, false, window, cx));
1490 }
1491 SearchEvent::ActiveMatchChanged => self.update_match_index(window, cx),
1492 }
1493 }
1494
1495 fn toggle_case_sensitive(
1496 &mut self,
1497 _: &ToggleCaseSensitive,
1498 window: &mut Window,
1499 cx: &mut Context<Self>,
1500 ) {
1501 self.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx)
1502 }
1503
1504 fn toggle_whole_word(
1505 &mut self,
1506 _: &ToggleWholeWord,
1507 window: &mut Window,
1508 cx: &mut Context<Self>,
1509 ) {
1510 self.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx)
1511 }
1512
1513 fn toggle_selection(
1514 &mut self,
1515 _: &ToggleSelection,
1516 window: &mut Window,
1517 cx: &mut Context<Self>,
1518 ) {
1519 self.set_search_within_selection(
1520 if let Some(_) = self.selection_search_enabled {
1521 None
1522 } else {
1523 Some(FilteredSearchRange::Default)
1524 },
1525 window,
1526 cx,
1527 );
1528 }
1529
1530 fn toggle_regex(&mut self, _: &ToggleRegex, window: &mut Window, cx: &mut Context<Self>) {
1531 self.toggle_search_option(SearchOptions::REGEX, window, cx)
1532 }
1533
1534 fn clear_active_searchable_item_matches(&mut self, window: &mut Window, cx: &mut App) {
1535 if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
1536 self.active_match_index = None;
1537 self.searchable_items_with_matches
1538 .remove(&active_searchable_item.downgrade());
1539 active_searchable_item.clear_matches(window, cx);
1540 }
1541 }
1542
1543 pub fn has_active_match(&self) -> bool {
1544 self.active_match_index.is_some()
1545 }
1546
1547 fn clear_matches(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1548 let mut active_item_matches = None;
1549 for (searchable_item, matches) in self.searchable_items_with_matches.drain() {
1550 if let Some(searchable_item) =
1551 WeakSearchableItemHandle::upgrade(searchable_item.as_ref(), cx)
1552 {
1553 if Some(&searchable_item) == self.active_searchable_item.as_ref() {
1554 active_item_matches = Some((searchable_item.downgrade(), matches));
1555 } else {
1556 searchable_item.clear_matches(window, cx);
1557 }
1558 }
1559 }
1560
1561 self.searchable_items_with_matches
1562 .extend(active_item_matches);
1563 }
1564
1565 fn update_matches(
1566 &mut self,
1567 reuse_existing_query: bool,
1568 add_to_history: bool,
1569 window: &mut Window,
1570 cx: &mut Context<Self>,
1571 ) -> oneshot::Receiver<()> {
1572 let (done_tx, done_rx) = oneshot::channel();
1573 let query = self.query(cx);
1574 self.pending_search.take();
1575 #[cfg(target_os = "macos")]
1576 self.pending_external_query.take();
1577
1578 if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
1579 self.query_error = None;
1580 if query.is_empty() {
1581 self.clear_active_searchable_item_matches(window, cx);
1582 let _ = done_tx.send(());
1583 cx.notify();
1584 } else {
1585 let query: Arc<_> = if let Some(search) =
1586 self.active_search.take().filter(|_| reuse_existing_query)
1587 {
1588 search
1589 } else {
1590 // Value doesn't matter, we only construct empty matchers with it
1591
1592 if self.search_options.contains(SearchOptions::REGEX) {
1593 match SearchQuery::regex(
1594 query,
1595 self.search_options.contains(SearchOptions::WHOLE_WORD),
1596 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1597 false,
1598 self.search_options
1599 .contains(SearchOptions::ONE_MATCH_PER_LINE),
1600 PathMatcher::default(),
1601 PathMatcher::default(),
1602 false,
1603 None,
1604 ) {
1605 Ok(query) => query.with_replacement(self.replacement(cx)),
1606 Err(e) => {
1607 self.query_error = Some(e.to_string());
1608 self.clear_active_searchable_item_matches(window, cx);
1609 cx.notify();
1610 return done_rx;
1611 }
1612 }
1613 } else {
1614 match SearchQuery::text(
1615 query,
1616 self.search_options.contains(SearchOptions::WHOLE_WORD),
1617 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1618 false,
1619 PathMatcher::default(),
1620 PathMatcher::default(),
1621 false,
1622 None,
1623 ) {
1624 Ok(query) => query.with_replacement(self.replacement(cx)),
1625 Err(e) => {
1626 self.query_error = Some(e.to_string());
1627 self.clear_active_searchable_item_matches(window, cx);
1628 cx.notify();
1629 return done_rx;
1630 }
1631 }
1632 }
1633 .into()
1634 };
1635
1636 self.active_search = Some(query.clone());
1637 let query_text = query.as_str().to_string();
1638
1639 let matches_with_token =
1640 active_searchable_item.find_matches_with_token(query, window, cx);
1641
1642 let active_searchable_item = active_searchable_item.downgrade();
1643 self.pending_search = Some(cx.spawn_in(window, async move |this, cx| {
1644 let (matches, token) = matches_with_token.await;
1645
1646 this.update_in(cx, |this, window, cx| {
1647 if let Some(active_searchable_item) =
1648 WeakSearchableItemHandle::upgrade(active_searchable_item.as_ref(), cx)
1649 {
1650 this.searchable_items_with_matches
1651 .insert(active_searchable_item.downgrade(), (matches, token));
1652
1653 this.update_match_index(window, cx);
1654
1655 if add_to_history {
1656 this.search_history
1657 .add(&mut this.search_history_cursor, query_text);
1658 }
1659 if !this.dismissed {
1660 let (matches, token) = this
1661 .searchable_items_with_matches
1662 .get(&active_searchable_item.downgrade())
1663 .unwrap();
1664 if matches.is_empty() {
1665 active_searchable_item.clear_matches(window, cx);
1666 } else {
1667 active_searchable_item.update_matches(
1668 matches,
1669 this.active_match_index,
1670 *token,
1671 window,
1672 cx,
1673 );
1674 }
1675 }
1676 let _ = done_tx.send(());
1677 cx.notify();
1678 }
1679 })
1680 .log_err();
1681 }));
1682 }
1683 }
1684 done_rx
1685 }
1686
1687 fn reverse_direction_if_backwards(&self, direction: Direction) -> Direction {
1688 if self.search_options.contains(SearchOptions::BACKWARDS) {
1689 direction.opposite()
1690 } else {
1691 direction
1692 }
1693 }
1694
1695 pub fn update_match_index(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1696 let direction = self.reverse_direction_if_backwards(Direction::Next);
1697 let new_index = self
1698 .active_searchable_item
1699 .as_ref()
1700 .and_then(|searchable_item| {
1701 let (matches, token) = self
1702 .searchable_items_with_matches
1703 .get(&searchable_item.downgrade())?;
1704 searchable_item.active_match_index(direction, matches, *token, window, cx)
1705 });
1706 if new_index != self.active_match_index {
1707 self.active_match_index = new_index;
1708 if !self.dismissed {
1709 if let Some(searchable_item) = self.active_searchable_item.as_ref() {
1710 if let Some((matches, token)) = self
1711 .searchable_items_with_matches
1712 .get(&searchable_item.downgrade())
1713 {
1714 if !matches.is_empty() {
1715 searchable_item.update_matches(matches, new_index, *token, window, cx);
1716 }
1717 }
1718 }
1719 }
1720 cx.notify();
1721 }
1722 }
1723
1724 fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
1725 self.cycle_field(Direction::Next, window, cx);
1726 }
1727
1728 fn backtab(&mut self, _: &Backtab, window: &mut Window, cx: &mut Context<Self>) {
1729 self.cycle_field(Direction::Prev, window, cx);
1730 }
1731 fn cycle_field(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
1732 let mut handles = vec![self.query_editor.focus_handle(cx)];
1733 if self.replace_enabled {
1734 handles.push(self.replacement_editor.focus_handle(cx));
1735 }
1736 if let Some(item) = self.active_searchable_item.as_ref() {
1737 handles.push(item.item_focus_handle(cx));
1738 }
1739 let current_index = match handles.iter().position(|focus| focus.is_focused(window)) {
1740 Some(index) => index,
1741 None => return,
1742 };
1743
1744 let new_index = match direction {
1745 Direction::Next => (current_index + 1) % handles.len(),
1746 Direction::Prev if current_index == 0 => handles.len() - 1,
1747 Direction::Prev => (current_index - 1) % handles.len(),
1748 };
1749 let next_focus_handle = &handles[new_index];
1750 self.focus(next_focus_handle, window, cx);
1751 cx.stop_propagation();
1752 }
1753
1754 fn next_history_query(
1755 &mut self,
1756 _: &NextHistoryQuery,
1757 window: &mut Window,
1758 cx: &mut Context<Self>,
1759 ) {
1760 if !should_navigate_history(&self.query_editor, HistoryNavigationDirection::Next, cx) {
1761 cx.propagate();
1762 return;
1763 }
1764
1765 if let Some(new_query) = self
1766 .search_history
1767 .next(&mut self.search_history_cursor)
1768 .map(str::to_string)
1769 {
1770 drop(self.search(&new_query, Some(self.search_options), false, window, cx));
1771 } else if let Some(draft) = self.search_history_cursor.take_draft() {
1772 drop(self.search(&draft, Some(self.search_options), false, window, cx));
1773 }
1774 }
1775
1776 fn previous_history_query(
1777 &mut self,
1778 _: &PreviousHistoryQuery,
1779 window: &mut Window,
1780 cx: &mut Context<Self>,
1781 ) {
1782 if !should_navigate_history(&self.query_editor, HistoryNavigationDirection::Previous, cx) {
1783 cx.propagate();
1784 return;
1785 }
1786
1787 if self.query(cx).is_empty()
1788 && let Some(new_query) = self
1789 .search_history
1790 .current(&self.search_history_cursor)
1791 .map(str::to_string)
1792 {
1793 drop(self.search(&new_query, Some(self.search_options), false, window, cx));
1794 return;
1795 }
1796
1797 let current_query = self.query(cx);
1798 if let Some(new_query) = self
1799 .search_history
1800 .previous(&mut self.search_history_cursor, ¤t_query)
1801 .map(str::to_string)
1802 {
1803 drop(self.search(&new_query, Some(self.search_options), false, window, cx));
1804 }
1805 }
1806
1807 fn focus(&self, handle: &gpui::FocusHandle, window: &mut Window, cx: &mut App) {
1808 window.invalidate_character_coordinates();
1809 window.focus(handle, cx);
1810 }
1811
1812 fn toggle_replace(&mut self, _: &ToggleReplace, window: &mut Window, cx: &mut Context<Self>) {
1813 if self.active_searchable_item.is_some() {
1814 self.replace_enabled = !self.replace_enabled;
1815 let handle = if self.replace_enabled {
1816 self.replacement_editor.focus_handle(cx)
1817 } else {
1818 self.query_editor.focus_handle(cx)
1819 };
1820 self.focus(&handle, window, cx);
1821 cx.notify();
1822 }
1823 }
1824
1825 fn replace_next(&mut self, _: &ReplaceNext, window: &mut Window, cx: &mut Context<Self>) {
1826 let mut should_propagate = true;
1827 if !self.dismissed
1828 && self.active_search.is_some()
1829 && let Some(searchable_item) = self.active_searchable_item.as_ref()
1830 && let Some(query) = self.active_search.as_ref()
1831 && let Some((matches, token)) = self
1832 .searchable_items_with_matches
1833 .get(&searchable_item.downgrade())
1834 {
1835 if let Some(active_index) = self.active_match_index {
1836 let query = query
1837 .as_ref()
1838 .clone()
1839 .with_replacement(self.replacement(cx));
1840 searchable_item.replace(matches.at(active_index), &query, *token, window, cx);
1841 self.select_next_match(&SelectNextMatch, window, cx);
1842 }
1843 should_propagate = false;
1844 }
1845 if !should_propagate {
1846 cx.stop_propagation();
1847 }
1848 }
1849
1850 pub fn replace_all(&mut self, _: &ReplaceAll, window: &mut Window, cx: &mut Context<Self>) {
1851 if !self.dismissed
1852 && self.active_search.is_some()
1853 && let Some(searchable_item) = self.active_searchable_item.as_ref()
1854 && let Some(query) = self.active_search.as_ref()
1855 && let Some((matches, token)) = self
1856 .searchable_items_with_matches
1857 .get(&searchable_item.downgrade())
1858 {
1859 let query = query
1860 .as_ref()
1861 .clone()
1862 .with_replacement(self.replacement(cx));
1863 searchable_item.replace_all(&mut matches.iter(), &query, *token, window, cx);
1864 }
1865 }
1866
1867 pub fn match_exists(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1868 self.update_match_index(window, cx);
1869 self.active_match_index.is_some()
1870 }
1871
1872 pub fn should_use_smartcase_search(&mut self, cx: &mut Context<Self>) -> bool {
1873 EditorSettings::get_global(cx).use_smartcase_search
1874 }
1875
1876 pub fn is_contains_uppercase(&mut self, str: &String) -> bool {
1877 str.chars().any(|c| c.is_uppercase())
1878 }
1879
1880 fn smartcase(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1881 if self.should_use_smartcase_search(cx) {
1882 let query = self.query(cx);
1883 if !query.is_empty() {
1884 let is_case = self.is_contains_uppercase(&query);
1885 if self.has_search_option(SearchOptions::CASE_SENSITIVE) != is_case {
1886 self.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
1887 }
1888 }
1889 }
1890 }
1891
1892 fn adjust_query_regex_language(&self, cx: &mut App) {
1893 let enable = self.search_options.contains(SearchOptions::REGEX);
1894 let query_buffer = self
1895 .query_editor
1896 .read(cx)
1897 .buffer()
1898 .read(cx)
1899 .as_singleton()
1900 .expect("query editor should be backed by a singleton buffer");
1901
1902 if enable {
1903 if let Some(regex_language) = self.regex_language.clone() {
1904 query_buffer.update(cx, |query_buffer, cx| {
1905 query_buffer.set_language(Some(regex_language), cx);
1906 })
1907 }
1908 } else {
1909 query_buffer.update(cx, |query_buffer, cx| {
1910 query_buffer.set_language(None, cx);
1911 })
1912 }
1913 }
1914
1915 /// Updates the searchable item's case sensitivity option to match the
1916 /// search bar's current case sensitivity setting. This ensures that
1917 /// editor's `select_next`/ `select_previous` operations respect the buffer
1918 /// search bar's search options.
1919 ///
1920 /// Clears the case sensitivity when the search bar is dismissed so that
1921 /// only the editor's settings are respected.
1922 fn sync_select_next_case_sensitivity(&self, cx: &mut Context<Self>) {
1923 let case_sensitive = match self.dismissed {
1924 true => None,
1925 false => Some(self.search_options.contains(SearchOptions::CASE_SENSITIVE)),
1926 };
1927
1928 if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
1929 active_searchable_item.set_search_is_case_sensitive(case_sensitive, cx);
1930 }
1931 }
1932}
1933
1934#[cfg(test)]
1935mod tests {
1936 use std::{ops::Range, time::Duration};
1937
1938 use super::*;
1939 use editor::{
1940 DisplayPoint, Editor, HighlightKey, MultiBuffer, PathKey,
1941 SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT, SearchSettings, SelectionEffects,
1942 display_map::DisplayRow, test::editor_test_context::EditorTestContext,
1943 };
1944 use gpui::{Hsla, TestAppContext, UpdateGlobal, VisualTestContext};
1945 use language::{Buffer, Point};
1946 use settings::{SearchSettingsContent, SettingsStore};
1947 use smol::stream::StreamExt as _;
1948 use unindent::Unindent as _;
1949 use util_macros::perf;
1950
1951 fn init_globals(cx: &mut TestAppContext) {
1952 cx.update(|cx| {
1953 let store = settings::SettingsStore::test(cx);
1954 cx.set_global(store);
1955 editor::init(cx);
1956
1957 theme_settings::init(theme::LoadThemes::JustBase, cx);
1958 crate::init(cx);
1959 });
1960 }
1961
1962 fn init_multibuffer_test(
1963 cx: &mut TestAppContext,
1964 ) -> (
1965 Entity<Editor>,
1966 Entity<BufferSearchBar>,
1967 &mut VisualTestContext,
1968 ) {
1969 init_globals(cx);
1970
1971 let buffer1 = cx.new(|cx| {
1972 Buffer::local(
1973 r#"
1974 A regular expression (shortened as regex or regexp;[1] also referred to as
1975 rational expression[2][3]) is a sequence of characters that specifies a search
1976 pattern in text. Usually such patterns are used by string-searching algorithms
1977 for "find" or "find and replace" operations on strings, or for input validation.
1978 "#
1979 .unindent(),
1980 cx,
1981 )
1982 });
1983
1984 let buffer2 = cx.new(|cx| {
1985 Buffer::local(
1986 r#"
1987 Some Additional text with the term regular expression in it.
1988 There two lines.
1989 "#
1990 .unindent(),
1991 cx,
1992 )
1993 });
1994
1995 let multibuffer = cx.new(|cx| {
1996 let mut buffer = MultiBuffer::new(language::Capability::ReadWrite);
1997
1998 //[ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))]
1999 buffer.set_excerpts_for_path(
2000 PathKey::sorted(0),
2001 buffer1,
2002 [Point::new(0, 0)..Point::new(3, 0)],
2003 0,
2004 cx,
2005 );
2006 buffer.set_excerpts_for_path(
2007 PathKey::sorted(1),
2008 buffer2,
2009 [Point::new(0, 0)..Point::new(1, 0)],
2010 0,
2011 cx,
2012 );
2013
2014 buffer
2015 });
2016 let mut editor = None;
2017 let window = cx.add_window(|window, cx| {
2018 let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
2019 "keymaps/default-macos.json",
2020 cx,
2021 )
2022 .unwrap();
2023 cx.bind_keys(default_key_bindings);
2024 editor =
2025 Some(cx.new(|cx| Editor::for_multibuffer(multibuffer.clone(), None, window, cx)));
2026
2027 let mut search_bar = BufferSearchBar::new(None, window, cx);
2028 search_bar.set_active_pane_item(Some(&editor.clone().unwrap()), window, cx);
2029 search_bar.show(window, cx);
2030 search_bar
2031 });
2032 let search_bar = window.root(cx).unwrap();
2033
2034 let cx = VisualTestContext::from_window(*window, cx).into_mut();
2035
2036 (editor.unwrap(), search_bar, cx)
2037 }
2038
2039 fn init_test(
2040 cx: &mut TestAppContext,
2041 ) -> (
2042 Entity<Editor>,
2043 Entity<BufferSearchBar>,
2044 &mut VisualTestContext,
2045 ) {
2046 init_globals(cx);
2047 let buffer = cx.new(|cx| {
2048 Buffer::local(
2049 r#"
2050 A regular expression (shortened as regex or regexp;[1] also referred to as
2051 rational expression[2][3]) is a sequence of characters that specifies a search
2052 pattern in text. Usually such patterns are used by string-searching algorithms
2053 for "find" or "find and replace" operations on strings, or for input validation.
2054 "#
2055 .unindent(),
2056 cx,
2057 )
2058 });
2059 let mut editor = None;
2060 let window = cx.add_window(|window, cx| {
2061 let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
2062 "keymaps/default-macos.json",
2063 cx,
2064 )
2065 .unwrap();
2066 cx.bind_keys(default_key_bindings);
2067 editor = Some(cx.new(|cx| Editor::for_buffer(buffer.clone(), None, window, cx)));
2068 let mut search_bar = BufferSearchBar::new(None, window, cx);
2069 search_bar.set_active_pane_item(Some(&editor.clone().unwrap()), window, cx);
2070 search_bar.show(window, cx);
2071 search_bar
2072 });
2073 let search_bar = window.root(cx).unwrap();
2074
2075 let cx = VisualTestContext::from_window(*window, cx).into_mut();
2076
2077 (editor.unwrap(), search_bar, cx)
2078 }
2079
2080 #[perf]
2081 #[gpui::test]
2082 async fn test_search_simple(cx: &mut TestAppContext) {
2083 let (editor, search_bar, cx) = init_test(cx);
2084 let display_points_of = |background_highlights: Vec<(Range<DisplayPoint>, Hsla)>| {
2085 background_highlights
2086 .into_iter()
2087 .map(|(range, _)| range)
2088 .collect::<Vec<_>>()
2089 };
2090 // Search for a string that appears with different casing.
2091 // By default, search is case-insensitive.
2092 search_bar
2093 .update_in(cx, |search_bar, window, cx| {
2094 search_bar.search("us", None, true, window, cx)
2095 })
2096 .await
2097 .unwrap();
2098 editor.update_in(cx, |editor, window, cx| {
2099 assert_eq!(
2100 display_points_of(editor.all_text_background_highlights(window, cx)),
2101 &[
2102 DisplayPoint::new(DisplayRow(2), 17)..DisplayPoint::new(DisplayRow(2), 19),
2103 DisplayPoint::new(DisplayRow(2), 43)..DisplayPoint::new(DisplayRow(2), 45),
2104 ]
2105 );
2106 });
2107
2108 // Switch to a case sensitive search.
2109 search_bar.update_in(cx, |search_bar, window, cx| {
2110 search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
2111 });
2112 let mut editor_notifications = cx.notifications(&editor);
2113 editor_notifications.next().await;
2114 editor.update_in(cx, |editor, window, cx| {
2115 assert_eq!(
2116 display_points_of(editor.all_text_background_highlights(window, cx)),
2117 &[DisplayPoint::new(DisplayRow(2), 43)..DisplayPoint::new(DisplayRow(2), 45),]
2118 );
2119 });
2120
2121 // Search for a string that appears both as a whole word and
2122 // within other words. By default, all results are found.
2123 search_bar
2124 .update_in(cx, |search_bar, window, cx| {
2125 search_bar.search("or", None, true, window, cx)
2126 })
2127 .await
2128 .unwrap();
2129 editor.update_in(cx, |editor, window, cx| {
2130 assert_eq!(
2131 display_points_of(editor.all_text_background_highlights(window, cx)),
2132 &[
2133 DisplayPoint::new(DisplayRow(0), 24)..DisplayPoint::new(DisplayRow(0), 26),
2134 DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43),
2135 DisplayPoint::new(DisplayRow(2), 71)..DisplayPoint::new(DisplayRow(2), 73),
2136 DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 3),
2137 DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13),
2138 DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58),
2139 DisplayPoint::new(DisplayRow(3), 60)..DisplayPoint::new(DisplayRow(3), 62),
2140 ]
2141 );
2142 });
2143
2144 // Switch to a whole word search.
2145 search_bar.update_in(cx, |search_bar, window, cx| {
2146 search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
2147 });
2148 let mut editor_notifications = cx.notifications(&editor);
2149 editor_notifications.next().await;
2150 editor.update_in(cx, |editor, window, cx| {
2151 assert_eq!(
2152 display_points_of(editor.all_text_background_highlights(window, cx)),
2153 &[
2154 DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43),
2155 DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13),
2156 DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58),
2157 ]
2158 );
2159 });
2160
2161 editor.update_in(cx, |editor, window, cx| {
2162 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2163 s.select_display_ranges([
2164 DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)
2165 ])
2166 });
2167 });
2168 search_bar.update_in(cx, |search_bar, window, cx| {
2169 assert_eq!(search_bar.active_match_index, Some(0));
2170 search_bar.select_next_match(&SelectNextMatch, window, cx);
2171 assert_eq!(
2172 editor.update(cx, |editor, cx| editor
2173 .selections
2174 .display_ranges(&editor.display_snapshot(cx))),
2175 [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)]
2176 );
2177 });
2178 search_bar.read_with(cx, |search_bar, _| {
2179 assert_eq!(search_bar.active_match_index, Some(0));
2180 });
2181
2182 search_bar.update_in(cx, |search_bar, window, cx| {
2183 search_bar.select_next_match(&SelectNextMatch, window, cx);
2184 assert_eq!(
2185 editor.update(cx, |editor, cx| editor
2186 .selections
2187 .display_ranges(&editor.display_snapshot(cx))),
2188 [DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13)]
2189 );
2190 });
2191 search_bar.read_with(cx, |search_bar, _| {
2192 assert_eq!(search_bar.active_match_index, Some(1));
2193 });
2194
2195 search_bar.update_in(cx, |search_bar, window, cx| {
2196 search_bar.select_next_match(&SelectNextMatch, window, cx);
2197 assert_eq!(
2198 editor.update(cx, |editor, cx| editor
2199 .selections
2200 .display_ranges(&editor.display_snapshot(cx))),
2201 [DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58)]
2202 );
2203 });
2204 search_bar.read_with(cx, |search_bar, _| {
2205 assert_eq!(search_bar.active_match_index, Some(2));
2206 });
2207
2208 search_bar.update_in(cx, |search_bar, window, cx| {
2209 search_bar.select_next_match(&SelectNextMatch, window, cx);
2210 assert_eq!(
2211 editor.update(cx, |editor, cx| editor
2212 .selections
2213 .display_ranges(&editor.display_snapshot(cx))),
2214 [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)]
2215 );
2216 });
2217 search_bar.read_with(cx, |search_bar, _| {
2218 assert_eq!(search_bar.active_match_index, Some(0));
2219 });
2220
2221 search_bar.update_in(cx, |search_bar, window, cx| {
2222 search_bar.select_prev_match(&SelectPreviousMatch, window, cx);
2223 assert_eq!(
2224 editor.update(cx, |editor, cx| editor
2225 .selections
2226 .display_ranges(&editor.display_snapshot(cx))),
2227 [DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58)]
2228 );
2229 });
2230 search_bar.read_with(cx, |search_bar, _| {
2231 assert_eq!(search_bar.active_match_index, Some(2));
2232 });
2233
2234 search_bar.update_in(cx, |search_bar, window, cx| {
2235 search_bar.select_prev_match(&SelectPreviousMatch, window, cx);
2236 assert_eq!(
2237 editor.update(cx, |editor, cx| editor
2238 .selections
2239 .display_ranges(&editor.display_snapshot(cx))),
2240 [DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13)]
2241 );
2242 });
2243 search_bar.read_with(cx, |search_bar, _| {
2244 assert_eq!(search_bar.active_match_index, Some(1));
2245 });
2246
2247 search_bar.update_in(cx, |search_bar, window, cx| {
2248 search_bar.select_prev_match(&SelectPreviousMatch, window, cx);
2249 assert_eq!(
2250 editor.update(cx, |editor, cx| editor
2251 .selections
2252 .display_ranges(&editor.display_snapshot(cx))),
2253 [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)]
2254 );
2255 });
2256 search_bar.read_with(cx, |search_bar, _| {
2257 assert_eq!(search_bar.active_match_index, Some(0));
2258 });
2259
2260 // Park the cursor in between matches and ensure that going to the previous match selects
2261 // the closest match to the left.
2262 editor.update_in(cx, |editor, window, cx| {
2263 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2264 s.select_display_ranges([
2265 DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0)
2266 ])
2267 });
2268 });
2269 search_bar.update_in(cx, |search_bar, window, cx| {
2270 assert_eq!(search_bar.active_match_index, Some(1));
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 selects the
2284 // closest match to the right.
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 assert_eq!(search_bar.active_match_index, Some(1));
2294 search_bar.select_next_match(&SelectNextMatch, window, cx);
2295 assert_eq!(
2296 editor.update(cx, |editor, cx| editor
2297 .selections
2298 .display_ranges(&editor.display_snapshot(cx))),
2299 [DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13)]
2300 );
2301 });
2302 search_bar.read_with(cx, |search_bar, _| {
2303 assert_eq!(search_bar.active_match_index, Some(1));
2304 });
2305
2306 // Park the cursor after the last match and ensure that going to the previous match selects
2307 // the last match.
2308 editor.update_in(cx, |editor, window, cx| {
2309 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2310 s.select_display_ranges([
2311 DisplayPoint::new(DisplayRow(3), 60)..DisplayPoint::new(DisplayRow(3), 60)
2312 ])
2313 });
2314 });
2315 search_bar.update_in(cx, |search_bar, window, cx| {
2316 assert_eq!(search_bar.active_match_index, Some(2));
2317 search_bar.select_prev_match(&SelectPreviousMatch, window, cx);
2318 assert_eq!(
2319 editor.update(cx, |editor, cx| editor
2320 .selections
2321 .display_ranges(&editor.display_snapshot(cx))),
2322 [DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58)]
2323 );
2324 });
2325 search_bar.read_with(cx, |search_bar, _| {
2326 assert_eq!(search_bar.active_match_index, Some(2));
2327 });
2328
2329 // Park the cursor after the last match and ensure that going to the next match selects the
2330 // first match.
2331 editor.update_in(cx, |editor, window, cx| {
2332 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2333 s.select_display_ranges([
2334 DisplayPoint::new(DisplayRow(3), 60)..DisplayPoint::new(DisplayRow(3), 60)
2335 ])
2336 });
2337 });
2338 search_bar.update_in(cx, |search_bar, window, cx| {
2339 assert_eq!(search_bar.active_match_index, Some(2));
2340 search_bar.select_next_match(&SelectNextMatch, window, cx);
2341 assert_eq!(
2342 editor.update(cx, |editor, cx| editor
2343 .selections
2344 .display_ranges(&editor.display_snapshot(cx))),
2345 [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)]
2346 );
2347 });
2348 search_bar.read_with(cx, |search_bar, _| {
2349 assert_eq!(search_bar.active_match_index, Some(0));
2350 });
2351
2352 // Park the cursor before the first match and ensure that going to the previous match
2353 // selects the last match.
2354 editor.update_in(cx, |editor, window, cx| {
2355 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2356 s.select_display_ranges([
2357 DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)
2358 ])
2359 });
2360 });
2361 search_bar.update_in(cx, |search_bar, window, cx| {
2362 assert_eq!(search_bar.active_match_index, Some(0));
2363 search_bar.select_prev_match(&SelectPreviousMatch, window, cx);
2364 assert_eq!(
2365 editor.update(cx, |editor, cx| editor
2366 .selections
2367 .display_ranges(&editor.display_snapshot(cx))),
2368 [DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58)]
2369 );
2370 });
2371 search_bar.read_with(cx, |search_bar, _| {
2372 assert_eq!(search_bar.active_match_index, Some(2));
2373 });
2374 }
2375
2376 fn display_points_of(
2377 background_highlights: Vec<(Range<DisplayPoint>, Hsla)>,
2378 ) -> Vec<Range<DisplayPoint>> {
2379 background_highlights
2380 .into_iter()
2381 .map(|(range, _)| range)
2382 .collect::<Vec<_>>()
2383 }
2384
2385 #[perf]
2386 #[gpui::test]
2387 async fn test_search_option_handling(cx: &mut TestAppContext) {
2388 let (editor, search_bar, cx) = init_test(cx);
2389
2390 // show with options should make current search case sensitive
2391 search_bar
2392 .update_in(cx, |search_bar, window, cx| {
2393 search_bar.show(window, cx);
2394 search_bar.search("us", Some(SearchOptions::CASE_SENSITIVE), true, window, cx)
2395 })
2396 .await
2397 .unwrap();
2398 editor.update_in(cx, |editor, window, cx| {
2399 assert_eq!(
2400 display_points_of(editor.all_text_background_highlights(window, cx)),
2401 &[DisplayPoint::new(DisplayRow(2), 43)..DisplayPoint::new(DisplayRow(2), 45),]
2402 );
2403 });
2404
2405 // search_suggested should restore default options
2406 search_bar.update_in(cx, |search_bar, window, cx| {
2407 search_bar.search_suggested(window, cx);
2408 assert_eq!(search_bar.search_options, SearchOptions::NONE)
2409 });
2410
2411 // toggling a search option should update the defaults
2412 search_bar
2413 .update_in(cx, |search_bar, window, cx| {
2414 search_bar.search(
2415 "regex",
2416 Some(SearchOptions::CASE_SENSITIVE),
2417 true,
2418 window,
2419 cx,
2420 )
2421 })
2422 .await
2423 .unwrap();
2424 search_bar.update_in(cx, |search_bar, window, cx| {
2425 search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx)
2426 });
2427 let mut editor_notifications = cx.notifications(&editor);
2428 editor_notifications.next().await;
2429 editor.update_in(cx, |editor, window, cx| {
2430 assert_eq!(
2431 display_points_of(editor.all_text_background_highlights(window, cx)),
2432 &[DisplayPoint::new(DisplayRow(0), 35)..DisplayPoint::new(DisplayRow(0), 40),]
2433 );
2434 });
2435
2436 // defaults should still include whole word
2437 search_bar.update_in(cx, |search_bar, window, cx| {
2438 search_bar.search_suggested(window, cx);
2439 assert_eq!(
2440 search_bar.search_options,
2441 SearchOptions::CASE_SENSITIVE | SearchOptions::WHOLE_WORD
2442 )
2443 });
2444 }
2445
2446 #[perf]
2447 #[gpui::test]
2448 async fn test_search_select_all_matches(cx: &mut TestAppContext) {
2449 init_globals(cx);
2450 let buffer_text = r#"
2451 A regular expression (shortened as regex or regexp;[1] also referred to as
2452 rational expression[2][3]) is a sequence of characters that specifies a search
2453 pattern in text. Usually such patterns are used by string-searching algorithms
2454 for "find" or "find and replace" operations on strings, or for input validation.
2455 "#
2456 .unindent();
2457 let expected_query_matches_count = buffer_text
2458 .chars()
2459 .filter(|c| c.eq_ignore_ascii_case(&'a'))
2460 .count();
2461 assert!(
2462 expected_query_matches_count > 1,
2463 "Should pick a query with multiple results"
2464 );
2465 let buffer = cx.new(|cx| Buffer::local(buffer_text, cx));
2466 let window = cx.add_window(|_, _| gpui::Empty);
2467
2468 let editor = window.build_entity(cx, |window, cx| {
2469 Editor::for_buffer(buffer.clone(), None, window, cx)
2470 });
2471
2472 let search_bar = window.build_entity(cx, |window, cx| {
2473 let mut search_bar = BufferSearchBar::new(None, window, cx);
2474 search_bar.set_active_pane_item(Some(&editor), window, cx);
2475 search_bar.show(window, cx);
2476 search_bar
2477 });
2478
2479 window
2480 .update(cx, |_, window, cx| {
2481 search_bar.update(cx, |search_bar, cx| {
2482 search_bar.search("a", None, true, window, cx)
2483 })
2484 })
2485 .unwrap()
2486 .await
2487 .unwrap();
2488 let initial_selections = window
2489 .update(cx, |_, window, cx| {
2490 search_bar.update(cx, |search_bar, cx| {
2491 let handle = search_bar.query_editor.focus_handle(cx);
2492 window.focus(&handle, cx);
2493 search_bar.activate_current_match(window, cx);
2494 });
2495 assert!(
2496 !editor.read(cx).is_focused(window),
2497 "Initially, the editor should not be focused"
2498 );
2499 let initial_selections = editor.update(cx, |editor, cx| {
2500 let initial_selections = editor.selections.display_ranges(&editor.display_snapshot(cx));
2501 assert_eq!(
2502 initial_selections.len(), 1,
2503 "Expected to have only one selection before adding carets to all matches, but got: {initial_selections:?}",
2504 );
2505 initial_selections
2506 });
2507 search_bar.update(cx, |search_bar, cx| {
2508 assert_eq!(search_bar.active_match_index, Some(0));
2509 let handle = search_bar.query_editor.focus_handle(cx);
2510 window.focus(&handle, cx);
2511 search_bar.select_all_matches(&SelectAllMatches, window, cx);
2512 });
2513 assert!(
2514 editor.read(cx).is_focused(window),
2515 "Should focus editor after successful SelectAllMatches"
2516 );
2517 search_bar.update(cx, |search_bar, cx| {
2518 let all_selections =
2519 editor.update(cx, |editor, cx| editor.selections.display_ranges(&editor.display_snapshot(cx)));
2520 assert_eq!(
2521 all_selections.len(),
2522 expected_query_matches_count,
2523 "Should select all `a` characters in the buffer, but got: {all_selections:?}"
2524 );
2525 assert_eq!(
2526 search_bar.active_match_index,
2527 Some(0),
2528 "Match index should not change after selecting all matches"
2529 );
2530 });
2531
2532 search_bar.update(cx, |this, cx| this.select_next_match(&SelectNextMatch, window, cx));
2533 initial_selections
2534 }).unwrap();
2535
2536 window
2537 .update(cx, |_, window, cx| {
2538 assert!(
2539 editor.read(cx).is_focused(window),
2540 "Should still have editor focused after SelectNextMatch"
2541 );
2542 search_bar.update(cx, |search_bar, cx| {
2543 let all_selections = editor.update(cx, |editor, cx| {
2544 editor
2545 .selections
2546 .display_ranges(&editor.display_snapshot(cx))
2547 });
2548 assert_eq!(
2549 all_selections.len(),
2550 1,
2551 "On next match, should deselect items and select the next match"
2552 );
2553 assert_ne!(
2554 all_selections, initial_selections,
2555 "Next match should be different from the first selection"
2556 );
2557 assert_eq!(
2558 search_bar.active_match_index,
2559 Some(1),
2560 "Match index should be updated to the next one"
2561 );
2562 let handle = search_bar.query_editor.focus_handle(cx);
2563 window.focus(&handle, cx);
2564 search_bar.select_all_matches(&SelectAllMatches, window, cx);
2565 });
2566 })
2567 .unwrap();
2568 window
2569 .update(cx, |_, window, cx| {
2570 assert!(
2571 editor.read(cx).is_focused(window),
2572 "Should focus editor after successful SelectAllMatches"
2573 );
2574 search_bar.update(cx, |search_bar, cx| {
2575 let all_selections =
2576 editor.update(cx, |editor, cx| editor.selections.display_ranges(&editor.display_snapshot(cx)));
2577 assert_eq!(
2578 all_selections.len(),
2579 expected_query_matches_count,
2580 "Should select all `a` characters in the buffer, but got: {all_selections:?}"
2581 );
2582 assert_eq!(
2583 search_bar.active_match_index,
2584 Some(1),
2585 "Match index should not change after selecting all matches"
2586 );
2587 });
2588 search_bar.update(cx, |search_bar, cx| {
2589 search_bar.select_prev_match(&SelectPreviousMatch, window, cx);
2590 });
2591 })
2592 .unwrap();
2593 let last_match_selections = window
2594 .update(cx, |_, window, cx| {
2595 assert!(
2596 editor.read(cx).is_focused(window),
2597 "Should still have editor focused after SelectPreviousMatch"
2598 );
2599
2600 search_bar.update(cx, |search_bar, cx| {
2601 let all_selections = editor.update(cx, |editor, cx| {
2602 editor
2603 .selections
2604 .display_ranges(&editor.display_snapshot(cx))
2605 });
2606 assert_eq!(
2607 all_selections.len(),
2608 1,
2609 "On previous match, should deselect items and select the previous item"
2610 );
2611 assert_eq!(
2612 all_selections, initial_selections,
2613 "Previous match should be the same as the first selection"
2614 );
2615 assert_eq!(
2616 search_bar.active_match_index,
2617 Some(0),
2618 "Match index should be updated to the previous one"
2619 );
2620 all_selections
2621 })
2622 })
2623 .unwrap();
2624
2625 window
2626 .update(cx, |_, window, cx| {
2627 search_bar.update(cx, |search_bar, cx| {
2628 let handle = search_bar.query_editor.focus_handle(cx);
2629 window.focus(&handle, cx);
2630 search_bar.search("abas_nonexistent_match", None, true, window, cx)
2631 })
2632 })
2633 .unwrap()
2634 .await
2635 .unwrap();
2636 window
2637 .update(cx, |_, window, cx| {
2638 search_bar.update(cx, |search_bar, cx| {
2639 search_bar.select_all_matches(&SelectAllMatches, window, cx);
2640 });
2641 assert!(
2642 editor.update(cx, |this, _cx| !this.is_focused(window)),
2643 "Should not switch focus to editor if SelectAllMatches does not find any matches"
2644 );
2645 search_bar.update(cx, |search_bar, cx| {
2646 let all_selections =
2647 editor.update(cx, |editor, cx| editor.selections.display_ranges(&editor.display_snapshot(cx)));
2648 assert_eq!(
2649 all_selections, last_match_selections,
2650 "Should not select anything new if there are no matches"
2651 );
2652 assert!(
2653 search_bar.active_match_index.is_none(),
2654 "For no matches, there should be no active match index"
2655 );
2656 });
2657 })
2658 .unwrap();
2659 }
2660
2661 #[perf]
2662 #[gpui::test]
2663 async fn test_search_query_with_match_whole_word(cx: &mut TestAppContext) {
2664 init_globals(cx);
2665 let buffer_text = r#"
2666 self.buffer.update(cx, |buffer, cx| {
2667 buffer.edit(
2668 edits,
2669 Some(AutoindentMode::Block {
2670 original_indent_columns,
2671 }),
2672 cx,
2673 )
2674 });
2675
2676 this.buffer.update(cx, |buffer, cx| {
2677 buffer.edit([(end_of_line..start_of_next_line, replace)], None, cx)
2678 });
2679 "#
2680 .unindent();
2681 let buffer = cx.new(|cx| Buffer::local(buffer_text, cx));
2682 let cx = cx.add_empty_window();
2683
2684 let editor =
2685 cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx));
2686
2687 let search_bar = cx.new_window_entity(|window, cx| {
2688 let mut search_bar = BufferSearchBar::new(None, window, cx);
2689 search_bar.set_active_pane_item(Some(&editor), window, cx);
2690 search_bar.show(window, cx);
2691 search_bar
2692 });
2693
2694 search_bar
2695 .update_in(cx, |search_bar, window, cx| {
2696 search_bar.search(
2697 "edit\\(",
2698 Some(SearchOptions::WHOLE_WORD | SearchOptions::REGEX),
2699 true,
2700 window,
2701 cx,
2702 )
2703 })
2704 .await
2705 .unwrap();
2706
2707 search_bar.update_in(cx, |search_bar, window, cx| {
2708 search_bar.select_all_matches(&SelectAllMatches, window, cx);
2709 });
2710 search_bar.update(cx, |_, cx| {
2711 let all_selections = editor.update(cx, |editor, cx| {
2712 editor
2713 .selections
2714 .display_ranges(&editor.display_snapshot(cx))
2715 });
2716 assert_eq!(
2717 all_selections.len(),
2718 2,
2719 "Should select all `edit(` in the buffer, but got: {all_selections:?}"
2720 );
2721 });
2722
2723 search_bar
2724 .update_in(cx, |search_bar, window, cx| {
2725 search_bar.search(
2726 "edit(",
2727 Some(SearchOptions::WHOLE_WORD | SearchOptions::CASE_SENSITIVE),
2728 true,
2729 window,
2730 cx,
2731 )
2732 })
2733 .await
2734 .unwrap();
2735
2736 search_bar.update_in(cx, |search_bar, window, cx| {
2737 search_bar.select_all_matches(&SelectAllMatches, window, cx);
2738 });
2739 search_bar.update(cx, |_, cx| {
2740 let all_selections = editor.update(cx, |editor, cx| {
2741 editor
2742 .selections
2743 .display_ranges(&editor.display_snapshot(cx))
2744 });
2745 assert_eq!(
2746 all_selections.len(),
2747 2,
2748 "Should select all `edit(` in the buffer, but got: {all_selections:?}"
2749 );
2750 });
2751 }
2752
2753 #[perf]
2754 #[gpui::test]
2755 async fn test_search_query_history(cx: &mut TestAppContext) {
2756 let (_editor, search_bar, cx) = init_test(cx);
2757
2758 // Add 3 search items into the history.
2759 search_bar
2760 .update_in(cx, |search_bar, window, cx| {
2761 search_bar.search("a", None, true, window, cx)
2762 })
2763 .await
2764 .unwrap();
2765 search_bar
2766 .update_in(cx, |search_bar, window, cx| {
2767 search_bar.search("b", None, true, window, cx)
2768 })
2769 .await
2770 .unwrap();
2771 search_bar
2772 .update_in(cx, |search_bar, window, cx| {
2773 search_bar.search("c", Some(SearchOptions::CASE_SENSITIVE), true, window, cx)
2774 })
2775 .await
2776 .unwrap();
2777 // Ensure that the latest search is active.
2778 search_bar.update(cx, |search_bar, cx| {
2779 assert_eq!(search_bar.query(cx), "c");
2780 assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
2781 });
2782
2783 // Next history query after the latest should preserve the current query.
2784 search_bar.update_in(cx, |search_bar, window, cx| {
2785 search_bar.next_history_query(&NextHistoryQuery, window, cx);
2786 });
2787 cx.background_executor.run_until_parked();
2788 search_bar.update(cx, |search_bar, cx| {
2789 assert_eq!(search_bar.query(cx), "c");
2790 assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
2791 });
2792 search_bar.update_in(cx, |search_bar, window, cx| {
2793 search_bar.next_history_query(&NextHistoryQuery, window, cx);
2794 });
2795 cx.background_executor.run_until_parked();
2796 search_bar.update(cx, |search_bar, cx| {
2797 assert_eq!(search_bar.query(cx), "c");
2798 assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
2799 });
2800
2801 // Previous query should navigate backwards through history.
2802 search_bar.update_in(cx, |search_bar, window, cx| {
2803 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
2804 });
2805 cx.background_executor.run_until_parked();
2806 search_bar.update(cx, |search_bar, cx| {
2807 assert_eq!(search_bar.query(cx), "b");
2808 assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
2809 });
2810
2811 // Further previous items should go over the history in reverse order.
2812 search_bar.update_in(cx, |search_bar, window, cx| {
2813 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
2814 });
2815 cx.background_executor.run_until_parked();
2816 search_bar.update(cx, |search_bar, cx| {
2817 assert_eq!(search_bar.query(cx), "a");
2818 assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
2819 });
2820
2821 // Previous items should never go behind the first history item.
2822 search_bar.update_in(cx, |search_bar, window, cx| {
2823 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
2824 });
2825 cx.background_executor.run_until_parked();
2826 search_bar.update(cx, |search_bar, cx| {
2827 assert_eq!(search_bar.query(cx), "a");
2828 assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
2829 });
2830 search_bar.update_in(cx, |search_bar, window, cx| {
2831 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
2832 });
2833 cx.background_executor.run_until_parked();
2834 search_bar.update(cx, |search_bar, cx| {
2835 assert_eq!(search_bar.query(cx), "a");
2836 assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
2837 });
2838
2839 // Next items should go over the history in the original order.
2840 search_bar.update_in(cx, |search_bar, window, cx| {
2841 search_bar.next_history_query(&NextHistoryQuery, window, cx);
2842 });
2843 cx.background_executor.run_until_parked();
2844 search_bar.update(cx, |search_bar, cx| {
2845 assert_eq!(search_bar.query(cx), "b");
2846 assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
2847 });
2848
2849 search_bar
2850 .update_in(cx, |search_bar, window, cx| {
2851 search_bar.search("ba", None, true, window, cx)
2852 })
2853 .await
2854 .unwrap();
2855 search_bar.update(cx, |search_bar, cx| {
2856 assert_eq!(search_bar.query(cx), "ba");
2857 assert_eq!(search_bar.search_options, SearchOptions::NONE);
2858 });
2859
2860 // New search input should add another entry to history and move the selection to the end of the history.
2861 search_bar.update_in(cx, |search_bar, window, cx| {
2862 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
2863 });
2864 cx.background_executor.run_until_parked();
2865 search_bar.update(cx, |search_bar, cx| {
2866 assert_eq!(search_bar.query(cx), "c");
2867 assert_eq!(search_bar.search_options, SearchOptions::NONE);
2868 });
2869 search_bar.update_in(cx, |search_bar, window, cx| {
2870 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
2871 });
2872 cx.background_executor.run_until_parked();
2873 search_bar.update(cx, |search_bar, cx| {
2874 assert_eq!(search_bar.query(cx), "b");
2875 assert_eq!(search_bar.search_options, SearchOptions::NONE);
2876 });
2877 search_bar.update_in(cx, |search_bar, window, cx| {
2878 search_bar.next_history_query(&NextHistoryQuery, window, cx);
2879 });
2880 cx.background_executor.run_until_parked();
2881 search_bar.update(cx, |search_bar, cx| {
2882 assert_eq!(search_bar.query(cx), "c");
2883 assert_eq!(search_bar.search_options, SearchOptions::NONE);
2884 });
2885 search_bar.update_in(cx, |search_bar, window, cx| {
2886 search_bar.next_history_query(&NextHistoryQuery, window, cx);
2887 });
2888 cx.background_executor.run_until_parked();
2889 search_bar.update(cx, |search_bar, cx| {
2890 assert_eq!(search_bar.query(cx), "ba");
2891 assert_eq!(search_bar.search_options, SearchOptions::NONE);
2892 });
2893 search_bar.update_in(cx, |search_bar, window, cx| {
2894 search_bar.next_history_query(&NextHistoryQuery, window, cx);
2895 });
2896 cx.background_executor.run_until_parked();
2897 search_bar.update(cx, |search_bar, cx| {
2898 assert_eq!(search_bar.query(cx), "ba");
2899 assert_eq!(search_bar.search_options, SearchOptions::NONE);
2900 });
2901 }
2902
2903 #[perf]
2904 #[gpui::test]
2905 async fn test_search_query_history_autoscroll(cx: &mut TestAppContext) {
2906 let (_editor, search_bar, cx) = init_test(cx);
2907
2908 // Add a long multi-line query that exceeds the editor's max
2909 // visible height (4 lines), then a short query.
2910 let long_query = "line1\nline2\nline3\nline4\nline5\nline6";
2911 search_bar
2912 .update_in(cx, |search_bar, window, cx| {
2913 search_bar.search(long_query, None, true, window, cx)
2914 })
2915 .await
2916 .unwrap();
2917 search_bar
2918 .update_in(cx, |search_bar, window, cx| {
2919 search_bar.search("short", None, true, window, cx)
2920 })
2921 .await
2922 .unwrap();
2923
2924 // Navigate back to the long entry. Since "short" is single-line,
2925 // the history navigation is allowed.
2926 search_bar.update_in(cx, |search_bar, window, cx| {
2927 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
2928 });
2929 cx.background_executor.run_until_parked();
2930 search_bar.update(cx, |search_bar, cx| {
2931 assert_eq!(search_bar.query(cx), long_query);
2932 });
2933
2934 // The cursor should be scrolled into view despite the content
2935 // exceeding the editor's max visible height.
2936 search_bar.update_in(cx, |search_bar, window, cx| {
2937 let snapshot = search_bar
2938 .query_editor
2939 .update(cx, |editor, cx| editor.snapshot(window, cx));
2940 let cursor_row = search_bar
2941 .query_editor
2942 .read(cx)
2943 .selections
2944 .newest_display(&snapshot)
2945 .head()
2946 .row();
2947 let scroll_top = search_bar
2948 .query_editor
2949 .update(cx, |editor, cx| editor.scroll_position(cx).y);
2950 let visible_lines = search_bar
2951 .query_editor
2952 .read(cx)
2953 .visible_line_count()
2954 .unwrap_or(0.0);
2955 let scroll_bottom = scroll_top + visible_lines;
2956 assert!(
2957 (cursor_row.0 as f64) < scroll_bottom,
2958 "cursor row {cursor_row:?} should be visible (scroll range {scroll_top}..{scroll_bottom})"
2959 );
2960 });
2961 }
2962
2963 #[perf]
2964 #[gpui::test]
2965 async fn test_replace_simple(cx: &mut TestAppContext) {
2966 let (editor, search_bar, cx) = init_test(cx);
2967
2968 search_bar
2969 .update_in(cx, |search_bar, window, cx| {
2970 search_bar.search("expression", None, true, window, cx)
2971 })
2972 .await
2973 .unwrap();
2974
2975 search_bar.update_in(cx, |search_bar, window, cx| {
2976 search_bar.replacement_editor.update(cx, |editor, cx| {
2977 // We use $1 here as initially we should be in Text mode, where `$1` should be treated literally.
2978 editor.set_text("expr$1", window, cx);
2979 });
2980 search_bar.replace_all(&ReplaceAll, window, cx)
2981 });
2982 assert_eq!(
2983 editor.read_with(cx, |this, cx| { this.text(cx) }),
2984 r#"
2985 A regular expr$1 (shortened as regex or regexp;[1] also referred to as
2986 rational expr$1[2][3]) is a sequence of characters that specifies a search
2987 pattern in text. Usually such patterns are used by string-searching algorithms
2988 for "find" or "find and replace" operations on strings, or for input validation.
2989 "#
2990 .unindent()
2991 );
2992
2993 // Search for word boundaries and replace just a single one.
2994 search_bar
2995 .update_in(cx, |search_bar, window, cx| {
2996 search_bar.search("or", Some(SearchOptions::WHOLE_WORD), true, window, cx)
2997 })
2998 .await
2999 .unwrap();
3000
3001 search_bar.update_in(cx, |search_bar, window, cx| {
3002 search_bar.replacement_editor.update(cx, |editor, cx| {
3003 editor.set_text("banana", window, cx);
3004 });
3005 search_bar.replace_next(&ReplaceNext, window, cx)
3006 });
3007 // Notice how the first or in the text (shORtened) is not replaced. Neither are the remaining hits of `or` in the text.
3008 assert_eq!(
3009 editor.read_with(cx, |this, cx| { this.text(cx) }),
3010 r#"
3011 A regular expr$1 (shortened as regex banana regexp;[1] also referred to as
3012 rational expr$1[2][3]) is a sequence of characters that specifies a search
3013 pattern in text. Usually such patterns are used by string-searching algorithms
3014 for "find" or "find and replace" operations on strings, or for input validation.
3015 "#
3016 .unindent()
3017 );
3018 // Let's turn on regex mode.
3019 search_bar
3020 .update_in(cx, |search_bar, window, cx| {
3021 search_bar.search(
3022 "\\[([^\\]]+)\\]",
3023 Some(SearchOptions::REGEX),
3024 true,
3025 window,
3026 cx,
3027 )
3028 })
3029 .await
3030 .unwrap();
3031 search_bar.update_in(cx, |search_bar, window, cx| {
3032 search_bar.replacement_editor.update(cx, |editor, cx| {
3033 editor.set_text("${1}number", window, cx);
3034 });
3035 search_bar.replace_all(&ReplaceAll, window, cx)
3036 });
3037 assert_eq!(
3038 editor.read_with(cx, |this, cx| { this.text(cx) }),
3039 r#"
3040 A regular expr$1 (shortened as regex banana regexp;1number also referred to as
3041 rational expr$12number3number) is a sequence of characters that specifies a search
3042 pattern in text. Usually such patterns are used by string-searching algorithms
3043 for "find" or "find and replace" operations on strings, or for input validation.
3044 "#
3045 .unindent()
3046 );
3047 // Now with a whole-word twist.
3048 search_bar
3049 .update_in(cx, |search_bar, window, cx| {
3050 search_bar.search(
3051 "a\\w+s",
3052 Some(SearchOptions::REGEX | SearchOptions::WHOLE_WORD),
3053 true,
3054 window,
3055 cx,
3056 )
3057 })
3058 .await
3059 .unwrap();
3060 search_bar.update_in(cx, |search_bar, window, cx| {
3061 search_bar.replacement_editor.update(cx, |editor, cx| {
3062 editor.set_text("things", window, cx);
3063 });
3064 search_bar.replace_all(&ReplaceAll, window, cx)
3065 });
3066 // The only word affected by this edit should be `algorithms`, even though there's a bunch
3067 // of words in this text that would match this regex if not for WHOLE_WORD.
3068 assert_eq!(
3069 editor.read_with(cx, |this, cx| { this.text(cx) }),
3070 r#"
3071 A regular expr$1 (shortened as regex banana regexp;1number also referred to as
3072 rational expr$12number3number) is a sequence of characters that specifies a search
3073 pattern in text. Usually such patterns are used by string-searching things
3074 for "find" or "find and replace" operations on strings, or for input validation.
3075 "#
3076 .unindent()
3077 );
3078 }
3079
3080 #[gpui::test]
3081 async fn test_replace_focus(cx: &mut TestAppContext) {
3082 let (editor, search_bar, cx) = init_test(cx);
3083
3084 editor.update_in(cx, |editor, window, cx| {
3085 editor.set_text("What a bad day!", window, cx)
3086 });
3087
3088 search_bar
3089 .update_in(cx, |search_bar, window, cx| {
3090 search_bar.search("bad", None, true, window, cx)
3091 })
3092 .await
3093 .unwrap();
3094
3095 // Calling `toggle_replace` in the search bar ensures that the "Replace
3096 // *" buttons are rendered, so we can then simulate clicking the
3097 // buttons.
3098 search_bar.update_in(cx, |search_bar, window, cx| {
3099 search_bar.toggle_replace(&ToggleReplace, window, cx)
3100 });
3101
3102 search_bar.update_in(cx, |search_bar, window, cx| {
3103 search_bar.replacement_editor.update(cx, |editor, cx| {
3104 editor.set_text("great", window, cx);
3105 });
3106 });
3107
3108 // Focus on the editor instead of the search bar, as we want to ensure
3109 // that pressing the "Replace Next Match" button will work, even if the
3110 // search bar is not focused.
3111 cx.focus(&editor);
3112
3113 // We'll not simulate clicking the "Replace Next Match " button, asserting that
3114 // the replacement was done.
3115 let button_bounds = cx
3116 .debug_bounds("ICON-ReplaceNext")
3117 .expect("'Replace Next Match' button should be visible");
3118 cx.simulate_click(button_bounds.center(), gpui::Modifiers::none());
3119
3120 assert_eq!(
3121 editor.read_with(cx, |editor, cx| editor.text(cx)),
3122 "What a great day!"
3123 );
3124 }
3125
3126 struct ReplacementTestParams<'a> {
3127 editor: &'a Entity<Editor>,
3128 search_bar: &'a Entity<BufferSearchBar>,
3129 cx: &'a mut VisualTestContext,
3130 search_text: &'static str,
3131 search_options: Option<SearchOptions>,
3132 replacement_text: &'static str,
3133 replace_all: bool,
3134 expected_text: String,
3135 }
3136
3137 async fn run_replacement_test(options: ReplacementTestParams<'_>) {
3138 options
3139 .search_bar
3140 .update_in(options.cx, |search_bar, window, cx| {
3141 if let Some(options) = options.search_options {
3142 search_bar.set_search_options(options, cx);
3143 }
3144 search_bar.search(
3145 options.search_text,
3146 options.search_options,
3147 true,
3148 window,
3149 cx,
3150 )
3151 })
3152 .await
3153 .unwrap();
3154
3155 options
3156 .search_bar
3157 .update_in(options.cx, |search_bar, window, cx| {
3158 search_bar.replacement_editor.update(cx, |editor, cx| {
3159 editor.set_text(options.replacement_text, window, cx);
3160 });
3161
3162 if options.replace_all {
3163 search_bar.replace_all(&ReplaceAll, window, cx)
3164 } else {
3165 search_bar.replace_next(&ReplaceNext, window, cx)
3166 }
3167 });
3168
3169 assert_eq!(
3170 options
3171 .editor
3172 .read_with(options.cx, |this, cx| { this.text(cx) }),
3173 options.expected_text
3174 );
3175 }
3176
3177 #[perf]
3178 #[gpui::test]
3179 async fn test_replace_special_characters(cx: &mut TestAppContext) {
3180 let (editor, search_bar, cx) = init_test(cx);
3181
3182 run_replacement_test(ReplacementTestParams {
3183 editor: &editor,
3184 search_bar: &search_bar,
3185 cx,
3186 search_text: "expression",
3187 search_options: None,
3188 replacement_text: r"\n",
3189 replace_all: true,
3190 expected_text: r#"
3191 A regular \n (shortened as regex or regexp;[1] also referred to as
3192 rational \n[2][3]) is a sequence of characters that specifies a search
3193 pattern in text. Usually such patterns are used by string-searching algorithms
3194 for "find" or "find and replace" operations on strings, or for input validation.
3195 "#
3196 .unindent(),
3197 })
3198 .await;
3199
3200 run_replacement_test(ReplacementTestParams {
3201 editor: &editor,
3202 search_bar: &search_bar,
3203 cx,
3204 search_text: "or",
3205 search_options: Some(SearchOptions::WHOLE_WORD | SearchOptions::REGEX),
3206 replacement_text: r"\\\n\\\\",
3207 replace_all: false,
3208 expected_text: r#"
3209 A regular \n (shortened as regex \
3210 \\ regexp;[1] also referred to as
3211 rational \n[2][3]) is a sequence of characters that specifies a search
3212 pattern in text. Usually such patterns are used by string-searching algorithms
3213 for "find" or "find and replace" operations on strings, or for input validation.
3214 "#
3215 .unindent(),
3216 })
3217 .await;
3218
3219 run_replacement_test(ReplacementTestParams {
3220 editor: &editor,
3221 search_bar: &search_bar,
3222 cx,
3223 search_text: r"(that|used) ",
3224 search_options: Some(SearchOptions::REGEX),
3225 replacement_text: r"$1\n",
3226 replace_all: true,
3227 expected_text: r#"
3228 A regular \n (shortened as regex \
3229 \\ regexp;[1] also referred to as
3230 rational \n[2][3]) is a sequence of characters that
3231 specifies a search
3232 pattern in text. Usually such patterns are used
3233 by string-searching algorithms
3234 for "find" or "find and replace" operations on strings, or for input validation.
3235 "#
3236 .unindent(),
3237 })
3238 .await;
3239 }
3240
3241 #[gpui::test]
3242 async fn test_deploy_replace_focuses_replacement_editor(cx: &mut TestAppContext) {
3243 init_globals(cx);
3244 let (editor, search_bar, cx) = init_test(cx);
3245
3246 editor.update_in(cx, |editor, window, cx| {
3247 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
3248 s.select_display_ranges([
3249 DisplayPoint::new(DisplayRow(0), 8)..DisplayPoint::new(DisplayRow(0), 16)
3250 ])
3251 });
3252 });
3253
3254 search_bar.update_in(cx, |search_bar, window, cx| {
3255 search_bar.deploy(
3256 &Deploy {
3257 focus: true,
3258 replace_enabled: true,
3259 selection_search_enabled: false,
3260 },
3261 window,
3262 cx,
3263 );
3264 });
3265 cx.run_until_parked();
3266
3267 search_bar.update_in(cx, |search_bar, window, cx| {
3268 assert!(
3269 search_bar
3270 .replacement_editor
3271 .focus_handle(cx)
3272 .is_focused(window),
3273 "replacement editor should be focused when deploying replace with a selection",
3274 );
3275 assert!(
3276 !search_bar.query_editor.focus_handle(cx).is_focused(window),
3277 "search editor should not be focused when replacement editor is focused",
3278 );
3279 });
3280 }
3281
3282 #[perf]
3283 #[gpui::test]
3284 async fn test_find_matches_in_selections_singleton_buffer_multiple_selections(
3285 cx: &mut TestAppContext,
3286 ) {
3287 init_globals(cx);
3288 let buffer = cx.new(|cx| {
3289 Buffer::local(
3290 r#"
3291 aaa bbb aaa ccc
3292 aaa bbb aaa ccc
3293 aaa bbb aaa ccc
3294 aaa bbb aaa ccc
3295 aaa bbb aaa ccc
3296 aaa bbb aaa ccc
3297 "#
3298 .unindent(),
3299 cx,
3300 )
3301 });
3302 let cx = cx.add_empty_window();
3303 let editor =
3304 cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx));
3305
3306 let search_bar = cx.new_window_entity(|window, cx| {
3307 let mut search_bar = BufferSearchBar::new(None, window, cx);
3308 search_bar.set_active_pane_item(Some(&editor), window, cx);
3309 search_bar.show(window, cx);
3310 search_bar
3311 });
3312
3313 editor.update_in(cx, |editor, window, cx| {
3314 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
3315 s.select_ranges(vec![Point::new(1, 0)..Point::new(2, 4)])
3316 })
3317 });
3318
3319 search_bar.update_in(cx, |search_bar, window, cx| {
3320 let deploy = Deploy {
3321 focus: true,
3322 replace_enabled: false,
3323 selection_search_enabled: true,
3324 };
3325 search_bar.deploy(&deploy, window, cx);
3326 });
3327
3328 cx.run_until_parked();
3329
3330 search_bar
3331 .update_in(cx, |search_bar, window, cx| {
3332 search_bar.search("aaa", None, true, window, cx)
3333 })
3334 .await
3335 .unwrap();
3336
3337 editor.update(cx, |editor, cx| {
3338 assert_eq!(
3339 editor.search_background_highlights(cx),
3340 &[
3341 Point::new(1, 0)..Point::new(1, 3),
3342 Point::new(1, 8)..Point::new(1, 11),
3343 Point::new(2, 0)..Point::new(2, 3),
3344 ]
3345 );
3346 });
3347 }
3348
3349 #[perf]
3350 #[gpui::test]
3351 async fn test_find_matches_in_selections_multiple_excerpts_buffer_multiple_selections(
3352 cx: &mut TestAppContext,
3353 ) {
3354 init_globals(cx);
3355 let text = r#"
3356 aaa bbb aaa ccc
3357 aaa bbb aaa ccc
3358 aaa bbb aaa ccc
3359 aaa bbb aaa ccc
3360 aaa bbb aaa ccc
3361 aaa bbb aaa ccc
3362
3363 aaa bbb aaa ccc
3364 aaa bbb aaa ccc
3365 aaa bbb aaa ccc
3366 aaa bbb aaa ccc
3367 aaa bbb aaa ccc
3368 aaa bbb aaa ccc
3369 "#
3370 .unindent();
3371
3372 let cx = cx.add_empty_window();
3373 let editor = cx.new_window_entity(|window, cx| {
3374 let multibuffer = MultiBuffer::build_multi(
3375 [
3376 (
3377 &text,
3378 vec![
3379 Point::new(0, 0)..Point::new(2, 0),
3380 Point::new(4, 0)..Point::new(5, 0),
3381 ],
3382 ),
3383 (&text, vec![Point::new(9, 0)..Point::new(11, 0)]),
3384 ],
3385 cx,
3386 );
3387 Editor::for_multibuffer(multibuffer, None, window, cx)
3388 });
3389
3390 let search_bar = cx.new_window_entity(|window, cx| {
3391 let mut search_bar = BufferSearchBar::new(None, window, cx);
3392 search_bar.set_active_pane_item(Some(&editor), window, cx);
3393 search_bar.show(window, cx);
3394 search_bar
3395 });
3396
3397 editor.update_in(cx, |editor, window, cx| {
3398 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
3399 s.select_ranges(vec![
3400 Point::new(1, 0)..Point::new(1, 4),
3401 Point::new(5, 3)..Point::new(6, 4),
3402 ])
3403 })
3404 });
3405
3406 search_bar.update_in(cx, |search_bar, window, cx| {
3407 let deploy = Deploy {
3408 focus: true,
3409 replace_enabled: false,
3410 selection_search_enabled: true,
3411 };
3412 search_bar.deploy(&deploy, window, cx);
3413 });
3414
3415 cx.run_until_parked();
3416
3417 search_bar
3418 .update_in(cx, |search_bar, window, cx| {
3419 search_bar.search("aaa", None, true, window, cx)
3420 })
3421 .await
3422 .unwrap();
3423
3424 editor.update(cx, |editor, cx| {
3425 assert_eq!(
3426 editor.search_background_highlights(cx),
3427 &[
3428 Point::new(1, 0)..Point::new(1, 3),
3429 Point::new(5, 8)..Point::new(5, 11),
3430 Point::new(6, 0)..Point::new(6, 3),
3431 ]
3432 );
3433 });
3434 }
3435
3436 #[perf]
3437 #[gpui::test]
3438 async fn test_hides_and_uses_secondary_when_in_singleton_buffer(cx: &mut TestAppContext) {
3439 let (editor, search_bar, cx) = init_test(cx);
3440
3441 let initial_location = search_bar.update_in(cx, |search_bar, window, cx| {
3442 search_bar.set_active_pane_item(Some(&editor), window, cx)
3443 });
3444
3445 assert_eq!(initial_location, ToolbarItemLocation::Secondary);
3446
3447 let mut events = cx.events::<ToolbarItemEvent, BufferSearchBar>(&search_bar);
3448
3449 search_bar.update_in(cx, |search_bar, window, cx| {
3450 search_bar.dismiss(&Dismiss, window, cx);
3451 });
3452
3453 assert_eq!(
3454 events.try_recv().unwrap(),
3455 (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Hidden))
3456 );
3457
3458 search_bar.update_in(cx, |search_bar, window, cx| {
3459 search_bar.show(window, cx);
3460 });
3461
3462 assert_eq!(
3463 events.try_recv().unwrap(),
3464 (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Secondary))
3465 );
3466 }
3467
3468 #[perf]
3469 #[gpui::test]
3470 async fn test_uses_primary_left_when_in_multi_buffer(cx: &mut TestAppContext) {
3471 let (editor, search_bar, cx) = init_multibuffer_test(cx);
3472
3473 let initial_location = search_bar.update_in(cx, |search_bar, window, cx| {
3474 search_bar.set_active_pane_item(Some(&editor), window, cx)
3475 });
3476
3477 assert_eq!(initial_location, ToolbarItemLocation::PrimaryLeft);
3478
3479 let mut events = cx.events::<ToolbarItemEvent, BufferSearchBar>(&search_bar);
3480
3481 search_bar.update_in(cx, |search_bar, window, cx| {
3482 search_bar.dismiss(&Dismiss, window, cx);
3483 });
3484
3485 assert_eq!(
3486 events.try_recv().unwrap(),
3487 (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::PrimaryLeft))
3488 );
3489
3490 search_bar.update_in(cx, |search_bar, window, cx| {
3491 search_bar.show(window, cx);
3492 });
3493
3494 assert_eq!(
3495 events.try_recv().unwrap(),
3496 (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::PrimaryLeft))
3497 );
3498 }
3499
3500 #[perf]
3501 #[gpui::test]
3502 async fn test_hides_and_uses_secondary_when_part_of_project_search(cx: &mut TestAppContext) {
3503 let (editor, search_bar, cx) = init_multibuffer_test(cx);
3504
3505 editor.update(cx, |editor, _| {
3506 editor.set_in_project_search(true);
3507 });
3508
3509 let initial_location = search_bar.update_in(cx, |search_bar, window, cx| {
3510 search_bar.set_active_pane_item(Some(&editor), window, cx)
3511 });
3512
3513 assert_eq!(initial_location, ToolbarItemLocation::Hidden);
3514
3515 let mut events = cx.events::<ToolbarItemEvent, BufferSearchBar>(&search_bar);
3516
3517 search_bar.update_in(cx, |search_bar, window, cx| {
3518 search_bar.dismiss(&Dismiss, window, cx);
3519 });
3520
3521 assert_eq!(
3522 events.try_recv().unwrap(),
3523 (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Hidden))
3524 );
3525
3526 search_bar.update_in(cx, |search_bar, window, cx| {
3527 search_bar.show(window, cx);
3528 });
3529
3530 assert_eq!(
3531 events.try_recv().unwrap(),
3532 (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Secondary))
3533 );
3534 }
3535
3536 #[perf]
3537 #[gpui::test]
3538 async fn test_sets_collapsed_when_editor_fold_events_emitted(cx: &mut TestAppContext) {
3539 let (editor, search_bar, cx) = init_multibuffer_test(cx);
3540
3541 search_bar.update_in(cx, |search_bar, window, cx| {
3542 search_bar.set_active_pane_item(Some(&editor), window, cx);
3543 });
3544
3545 editor.update_in(cx, |editor, window, cx| {
3546 editor.fold_all(&FoldAll, window, cx);
3547 });
3548 cx.run_until_parked();
3549
3550 let is_collapsed = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
3551 assert!(is_collapsed);
3552
3553 editor.update_in(cx, |editor, window, cx| {
3554 editor.unfold_all(&UnfoldAll, window, cx);
3555 });
3556 cx.run_until_parked();
3557
3558 let is_collapsed = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
3559 assert!(!is_collapsed);
3560 }
3561
3562 #[perf]
3563 #[gpui::test]
3564 async fn test_collapse_state_syncs_after_manual_buffer_fold(cx: &mut TestAppContext) {
3565 let (editor, search_bar, cx) = init_multibuffer_test(cx);
3566
3567 search_bar.update_in(cx, |search_bar, window, cx| {
3568 search_bar.set_active_pane_item(Some(&editor), window, cx);
3569 });
3570
3571 // Fold all buffers via fold_all
3572 editor.update_in(cx, |editor, window, cx| {
3573 editor.fold_all(&FoldAll, window, cx);
3574 });
3575 cx.run_until_parked();
3576
3577 let has_any_folded = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
3578 assert!(
3579 has_any_folded,
3580 "All buffers should be folded after fold_all"
3581 );
3582
3583 // Manually unfold one buffer (simulating a chevron click)
3584 let first_buffer_id = editor.read_with(cx, |editor, cx| {
3585 editor
3586 .buffer()
3587 .read(cx)
3588 .snapshot(cx)
3589 .excerpts()
3590 .nth(0)
3591 .unwrap()
3592 .context
3593 .start
3594 .buffer_id
3595 });
3596 editor.update_in(cx, |editor, _window, cx| {
3597 editor.unfold_buffer(first_buffer_id, cx);
3598 });
3599
3600 let has_any_folded = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
3601 assert!(
3602 has_any_folded,
3603 "Should still report folds when only one buffer is unfolded"
3604 );
3605
3606 // Manually unfold the second buffer too
3607 let second_buffer_id = editor.read_with(cx, |editor, cx| {
3608 editor
3609 .buffer()
3610 .read(cx)
3611 .snapshot(cx)
3612 .excerpts()
3613 .nth(1)
3614 .unwrap()
3615 .context
3616 .start
3617 .buffer_id
3618 });
3619 editor.update_in(cx, |editor, _window, cx| {
3620 editor.unfold_buffer(second_buffer_id, cx);
3621 });
3622
3623 let has_any_folded = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
3624 assert!(
3625 !has_any_folded,
3626 "No folds should remain after unfolding all buffers individually"
3627 );
3628
3629 // Manually fold one buffer back
3630 editor.update_in(cx, |editor, _window, cx| {
3631 editor.fold_buffer(first_buffer_id, cx);
3632 });
3633
3634 let has_any_folded = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
3635 assert!(
3636 has_any_folded,
3637 "Should report folds after manually folding one buffer"
3638 );
3639 }
3640
3641 #[perf]
3642 #[gpui::test]
3643 async fn test_search_options_changes(cx: &mut TestAppContext) {
3644 let (_editor, search_bar, cx) = init_test(cx);
3645 update_search_settings(
3646 SearchSettings {
3647 button: true,
3648 whole_word: false,
3649 case_sensitive: false,
3650 include_ignored: false,
3651 regex: false,
3652 center_on_match: false,
3653 },
3654 cx,
3655 );
3656
3657 let deploy = Deploy {
3658 focus: true,
3659 replace_enabled: false,
3660 selection_search_enabled: true,
3661 };
3662
3663 search_bar.update_in(cx, |search_bar, window, cx| {
3664 assert_eq!(
3665 search_bar.search_options,
3666 SearchOptions::NONE,
3667 "Should have no search options enabled by default"
3668 );
3669 search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
3670 assert_eq!(
3671 search_bar.search_options,
3672 SearchOptions::WHOLE_WORD,
3673 "Should enable the option toggled"
3674 );
3675 assert!(
3676 !search_bar.dismissed,
3677 "Search bar should be present and visible"
3678 );
3679 search_bar.deploy(&deploy, window, cx);
3680 assert_eq!(
3681 search_bar.search_options,
3682 SearchOptions::WHOLE_WORD,
3683 "After (re)deploying, the option should still be enabled"
3684 );
3685
3686 search_bar.dismiss(&Dismiss, window, cx);
3687 search_bar.deploy(&deploy, window, cx);
3688 assert_eq!(
3689 search_bar.search_options,
3690 SearchOptions::WHOLE_WORD,
3691 "After hiding and showing the search bar, search options should be preserved"
3692 );
3693
3694 search_bar.toggle_search_option(SearchOptions::REGEX, window, cx);
3695 search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
3696 assert_eq!(
3697 search_bar.search_options,
3698 SearchOptions::REGEX,
3699 "Should enable the options toggled"
3700 );
3701 assert!(
3702 !search_bar.dismissed,
3703 "Search bar should be present and visible"
3704 );
3705 search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
3706 });
3707
3708 update_search_settings(
3709 SearchSettings {
3710 button: true,
3711 whole_word: false,
3712 case_sensitive: true,
3713 include_ignored: false,
3714 regex: false,
3715 center_on_match: false,
3716 },
3717 cx,
3718 );
3719 search_bar.update_in(cx, |search_bar, window, cx| {
3720 assert_eq!(
3721 search_bar.search_options,
3722 SearchOptions::REGEX | SearchOptions::WHOLE_WORD,
3723 "Should have no search options enabled by default"
3724 );
3725
3726 search_bar.deploy(&deploy, window, cx);
3727 assert_eq!(
3728 search_bar.search_options,
3729 SearchOptions::REGEX | SearchOptions::WHOLE_WORD,
3730 "Toggling a non-dismissed search bar with custom options should not change the default options"
3731 );
3732 search_bar.dismiss(&Dismiss, window, cx);
3733 search_bar.deploy(&deploy, window, cx);
3734 assert_eq!(
3735 search_bar.configured_options,
3736 SearchOptions::CASE_SENSITIVE,
3737 "After a settings update and toggling the search bar, configured options should be updated"
3738 );
3739 assert_eq!(
3740 search_bar.search_options,
3741 SearchOptions::CASE_SENSITIVE,
3742 "After a settings update and toggling the search bar, configured options should be used"
3743 );
3744 });
3745
3746 update_search_settings(
3747 SearchSettings {
3748 button: true,
3749 whole_word: true,
3750 case_sensitive: true,
3751 include_ignored: false,
3752 regex: false,
3753 center_on_match: false,
3754 },
3755 cx,
3756 );
3757
3758 search_bar.update_in(cx, |search_bar, window, cx| {
3759 search_bar.deploy(&deploy, window, cx);
3760 search_bar.dismiss(&Dismiss, window, cx);
3761 search_bar.show(window, cx);
3762 assert_eq!(
3763 search_bar.search_options,
3764 SearchOptions::CASE_SENSITIVE | SearchOptions::WHOLE_WORD,
3765 "Calling deploy on an already deployed search bar should not prevent settings updates from being detected"
3766 );
3767 });
3768 }
3769
3770 #[gpui::test]
3771 async fn test_select_occurrence_case_sensitivity(cx: &mut TestAppContext) {
3772 let (editor, search_bar, cx) = init_test(cx);
3773 let mut editor_cx = EditorTestContext::for_editor_in(editor, cx).await;
3774
3775 // Start with case sensitive search settings.
3776 let mut search_settings = SearchSettings::default();
3777 search_settings.case_sensitive = true;
3778 update_search_settings(search_settings, cx);
3779 search_bar.update(cx, |search_bar, cx| {
3780 let mut search_options = search_bar.search_options;
3781 search_options.insert(SearchOptions::CASE_SENSITIVE);
3782 search_bar.set_search_options(search_options, cx);
3783 });
3784
3785 editor_cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo");
3786 editor_cx.update_editor(|e, window, cx| {
3787 e.select_next(&Default::default(), window, cx).unwrap();
3788 });
3789 editor_cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»");
3790
3791 // Update the search bar's case sensitivite toggle, so we can later
3792 // confirm that `select_next` will now be case-insensitive.
3793 editor_cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo");
3794 search_bar.update_in(cx, |search_bar, window, cx| {
3795 search_bar.toggle_case_sensitive(&Default::default(), window, cx);
3796 });
3797 editor_cx.update_editor(|e, window, cx| {
3798 e.select_next(&Default::default(), window, cx).unwrap();
3799 });
3800 editor_cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\nFoo\nfoo");
3801
3802 // Confirm that, after dismissing the search bar, only the editor's
3803 // search settings actually affect the behavior of `select_next`.
3804 search_bar.update_in(cx, |search_bar, window, cx| {
3805 search_bar.dismiss(&Default::default(), window, cx);
3806 });
3807 editor_cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo");
3808 editor_cx.update_editor(|e, window, cx| {
3809 e.select_next(&Default::default(), window, cx).unwrap();
3810 });
3811 editor_cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»");
3812
3813 // Update the editor's search settings, disabling case sensitivity, to
3814 // check that the value is respected.
3815 let mut search_settings = SearchSettings::default();
3816 search_settings.case_sensitive = false;
3817 update_search_settings(search_settings, cx);
3818 editor_cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo");
3819 editor_cx.update_editor(|e, window, cx| {
3820 e.select_next(&Default::default(), window, cx).unwrap();
3821 });
3822 editor_cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\nFoo\nfoo");
3823 }
3824
3825 #[gpui::test]
3826 async fn test_regex_search_does_not_highlight_non_matching_occurrences(
3827 cx: &mut TestAppContext,
3828 ) {
3829 init_globals(cx);
3830 let buffer = cx.new(|cx| {
3831 Buffer::local(
3832 "something is at the top\nsomething is behind something\nsomething is at the bottom\n",
3833 cx,
3834 )
3835 });
3836 let cx = cx.add_empty_window();
3837 let editor =
3838 cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx));
3839 let search_bar = cx.new_window_entity(|window, cx| {
3840 let mut search_bar = BufferSearchBar::new(None, window, cx);
3841 search_bar.set_active_pane_item(Some(&editor), window, cx);
3842 search_bar.show(window, cx);
3843 search_bar
3844 });
3845
3846 search_bar.update_in(cx, |search_bar, window, cx| {
3847 search_bar.toggle_search_option(SearchOptions::REGEX, window, cx);
3848 });
3849
3850 search_bar
3851 .update_in(cx, |search_bar, window, cx| {
3852 search_bar.search("^something", None, true, window, cx)
3853 })
3854 .await
3855 .unwrap();
3856
3857 search_bar.update_in(cx, |search_bar, window, cx| {
3858 search_bar.select_next_match(&SelectNextMatch, window, cx);
3859 });
3860
3861 // Advance past the debounce so the selection occurrence highlight would
3862 // have fired if it were not suppressed by the active buffer search.
3863 cx.executor()
3864 .advance_clock(SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(1));
3865 cx.run_until_parked();
3866
3867 editor.update(cx, |editor, cx| {
3868 assert!(
3869 !editor.has_background_highlights(HighlightKey::SelectedTextHighlight),
3870 "selection occurrence highlights must be suppressed during buffer search"
3871 );
3872 assert_eq!(
3873 editor.search_background_highlights(cx).len(),
3874 3,
3875 "expected exactly 3 search highlights (one per line start)"
3876 );
3877 });
3878
3879 // Manually select "something" — this should restore occurrence highlights
3880 // because it clears the search-navigation flag.
3881 editor.update_in(cx, |editor, window, cx| {
3882 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
3883 s.select_ranges([Point::new(0, 0)..Point::new(0, 9)])
3884 });
3885 });
3886
3887 cx.executor()
3888 .advance_clock(SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(1));
3889 cx.run_until_parked();
3890
3891 editor.update(cx, |editor, _cx| {
3892 assert!(
3893 editor.has_background_highlights(HighlightKey::SelectedTextHighlight),
3894 "selection occurrence highlights must be restored after a manual selection"
3895 );
3896 });
3897 }
3898
3899 fn update_search_settings(search_settings: SearchSettings, cx: &mut TestAppContext) {
3900 cx.update(|cx| {
3901 SettingsStore::update_global(cx, |store, cx| {
3902 store.update_user_settings(cx, |settings| {
3903 settings.editor.search = Some(SearchSettingsContent {
3904 button: Some(search_settings.button),
3905 whole_word: Some(search_settings.whole_word),
3906 case_sensitive: Some(search_settings.case_sensitive),
3907 include_ignored: Some(search_settings.include_ignored),
3908 regex: Some(search_settings.regex),
3909 center_on_match: Some(search_settings.center_on_match),
3910 });
3911 });
3912 });
3913 });
3914 }
3915}