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