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