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