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