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