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