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