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