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