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