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