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