1use crate::{
2 SearchOptions, SelectAllMatches, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive,
3 ToggleRegex, ToggleWholeWord,
4};
5use collections::HashMap;
6use editor::Editor;
7use futures::channel::oneshot;
8use gpui::{
9 actions,
10 elements::*,
11 impl_actions,
12 platform::{CursorStyle, MouseButton},
13 Action, AnyViewHandle, AppContext, Entity, Subscription, Task, View, ViewContext, ViewHandle,
14 WindowContext,
15};
16use project::search::SearchQuery;
17use serde::Deserialize;
18use std::{any::Any, sync::Arc};
19use util::ResultExt;
20use workspace::{
21 item::ItemHandle,
22 searchable::{Direction, SearchEvent, SearchableItemHandle, WeakSearchableItemHandle},
23 Pane, ToolbarItemLocation, ToolbarItemView,
24};
25
26#[derive(Clone, Deserialize, PartialEq)]
27pub struct Deploy {
28 pub focus: bool,
29}
30
31actions!(buffer_search, [Dismiss, FocusEditor]);
32impl_actions!(buffer_search, [Deploy]);
33
34pub enum Event {
35 UpdateLocation,
36}
37
38pub fn init(cx: &mut AppContext) {
39 cx.add_action(BufferSearchBar::deploy);
40 cx.add_action(BufferSearchBar::dismiss);
41 cx.add_action(BufferSearchBar::focus_editor);
42 cx.add_action(BufferSearchBar::select_next_match);
43 cx.add_action(BufferSearchBar::select_prev_match);
44 cx.add_action(BufferSearchBar::select_all_matches);
45 cx.add_action(BufferSearchBar::select_next_match_on_pane);
46 cx.add_action(BufferSearchBar::select_prev_match_on_pane);
47 cx.add_action(BufferSearchBar::select_all_matches_on_pane);
48 cx.add_action(BufferSearchBar::handle_editor_cancel);
49 add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
50 add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
51 add_toggle_option_action::<ToggleRegex>(SearchOptions::REGEX, cx);
52}
53
54fn add_toggle_option_action<A: Action>(option: SearchOptions, cx: &mut AppContext) {
55 cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext<Pane>| {
56 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
57 search_bar.update(cx, |search_bar, cx| {
58 if search_bar.show(cx) {
59 search_bar.toggle_search_option(option, cx);
60 }
61 });
62 }
63 cx.propagate_action();
64 });
65}
66
67pub struct BufferSearchBar {
68 pub query_editor: ViewHandle<Editor>,
69 active_searchable_item: Option<Box<dyn SearchableItemHandle>>,
70 active_match_index: Option<usize>,
71 active_searchable_item_subscription: Option<Subscription>,
72 searchable_items_with_matches:
73 HashMap<Box<dyn WeakSearchableItemHandle>, Vec<Box<dyn Any + Send>>>,
74 pending_search: Option<Task<()>>,
75 search_options: SearchOptions,
76 default_options: SearchOptions,
77 query_contains_error: bool,
78 dismissed: bool,
79}
80
81impl Entity for BufferSearchBar {
82 type Event = Event;
83}
84
85impl View for BufferSearchBar {
86 fn ui_name() -> &'static str {
87 "BufferSearchBar"
88 }
89
90 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
91 if cx.is_self_focused() {
92 cx.focus(&self.query_editor);
93 }
94 }
95
96 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
97 let theme = theme::current(cx).clone();
98 let editor_container = if self.query_contains_error {
99 theme.search.invalid_editor
100 } else {
101 theme.search.editor.input.container
102 };
103 let supported_options = self
104 .active_searchable_item
105 .as_ref()
106 .map(|active_searchable_item| active_searchable_item.supported_options())
107 .unwrap_or_default();
108
109 Flex::row()
110 .with_child(
111 Flex::row()
112 .with_child(
113 Flex::row()
114 .with_child(
115 ChildView::new(&self.query_editor, cx)
116 .aligned()
117 .left()
118 .flex(1., true),
119 )
120 .with_children(self.active_searchable_item.as_ref().and_then(
121 |searchable_item| {
122 let matches = self
123 .searchable_items_with_matches
124 .get(&searchable_item.downgrade())?;
125 let message = if let Some(match_ix) = self.active_match_index {
126 format!("{}/{}", match_ix + 1, matches.len())
127 } else {
128 "No matches".to_string()
129 };
130
131 Some(
132 Label::new(message, theme.search.match_index.text.clone())
133 .contained()
134 .with_style(theme.search.match_index.container)
135 .aligned(),
136 )
137 },
138 ))
139 .contained()
140 .with_style(editor_container)
141 .aligned()
142 .constrained()
143 .with_min_width(theme.search.editor.min_width)
144 .with_max_width(theme.search.editor.max_width)
145 .flex(1., false),
146 )
147 .with_child(
148 Flex::row()
149 .with_child(self.render_nav_button("<", Direction::Prev, cx))
150 .with_child(self.render_nav_button(">", Direction::Next, cx))
151 .with_child(self.render_action_button("Select All", cx))
152 .aligned(),
153 )
154 .with_child(
155 Flex::row()
156 .with_children(self.render_search_option(
157 supported_options.case,
158 "Case",
159 SearchOptions::CASE_SENSITIVE,
160 cx,
161 ))
162 .with_children(self.render_search_option(
163 supported_options.word,
164 "Word",
165 SearchOptions::WHOLE_WORD,
166 cx,
167 ))
168 .with_children(self.render_search_option(
169 supported_options.regex,
170 "Regex",
171 SearchOptions::REGEX,
172 cx,
173 ))
174 .contained()
175 .with_style(theme.search.option_button_group)
176 .aligned(),
177 )
178 .flex(1., true),
179 )
180 .with_child(self.render_close_button(&theme.search, cx))
181 .contained()
182 .with_style(theme.search.container)
183 .into_any_named("search bar")
184 }
185}
186
187impl ToolbarItemView for BufferSearchBar {
188 fn set_active_pane_item(
189 &mut self,
190 item: Option<&dyn ItemHandle>,
191 cx: &mut ViewContext<Self>,
192 ) -> ToolbarItemLocation {
193 cx.notify();
194 self.active_searchable_item_subscription.take();
195 self.active_searchable_item.take();
196 self.pending_search.take();
197
198 if let Some(searchable_item_handle) =
199 item.and_then(|item| item.to_searchable_item_handle(cx))
200 {
201 let this = cx.weak_handle();
202 self.active_searchable_item_subscription =
203 Some(searchable_item_handle.subscribe_to_search_events(
204 cx,
205 Box::new(move |search_event, cx| {
206 if let Some(this) = this.upgrade(cx) {
207 this.update(cx, |this, cx| {
208 this.on_active_searchable_item_event(search_event, cx)
209 });
210 }
211 }),
212 ));
213
214 self.active_searchable_item = Some(searchable_item_handle);
215 let _ = self.update_matches(cx);
216 if !self.dismissed {
217 return ToolbarItemLocation::Secondary;
218 }
219 }
220
221 ToolbarItemLocation::Hidden
222 }
223
224 fn location_for_event(
225 &self,
226 _: &Self::Event,
227 _: ToolbarItemLocation,
228 _: &AppContext,
229 ) -> ToolbarItemLocation {
230 if self.active_searchable_item.is_some() && !self.dismissed {
231 ToolbarItemLocation::Secondary
232 } else {
233 ToolbarItemLocation::Hidden
234 }
235 }
236}
237
238impl BufferSearchBar {
239 pub fn new(cx: &mut ViewContext<Self>) -> Self {
240 let query_editor = cx.add_view(|cx| {
241 Editor::auto_height(
242 2,
243 Some(Arc::new(|theme| theme.search.editor.input.clone())),
244 cx,
245 )
246 });
247 cx.subscribe(&query_editor, Self::on_query_editor_event)
248 .detach();
249
250 Self {
251 query_editor,
252 active_searchable_item: None,
253 active_searchable_item_subscription: None,
254 active_match_index: None,
255 searchable_items_with_matches: Default::default(),
256 default_options: SearchOptions::NONE,
257 search_options: SearchOptions::NONE,
258 pending_search: None,
259 query_contains_error: false,
260 dismissed: true,
261 }
262 }
263
264 pub fn is_dismissed(&self) -> bool {
265 self.dismissed
266 }
267
268 pub fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext<Self>) {
269 self.dismissed = true;
270 for searchable_item in self.searchable_items_with_matches.keys() {
271 if let Some(searchable_item) =
272 WeakSearchableItemHandle::upgrade(searchable_item.as_ref(), cx)
273 {
274 searchable_item.clear_matches(cx);
275 }
276 }
277 if let Some(active_editor) = self.active_searchable_item.as_ref() {
278 cx.focus(active_editor.as_any());
279 }
280 cx.emit(Event::UpdateLocation);
281 cx.notify();
282 }
283
284 pub fn show(&mut self, cx: &mut ViewContext<Self>) -> bool {
285 if self.active_searchable_item.is_none() {
286 return false;
287 }
288 self.dismissed = false;
289 cx.notify();
290 cx.emit(Event::UpdateLocation);
291 true
292 }
293
294 pub fn search_suggested(&mut self, cx: &mut ViewContext<Self>) {
295 let search = self
296 .query_suggestion(cx)
297 .map(|suggestion| self.search(&suggestion, Some(self.default_options), cx));
298
299 if let Some(search) = search {
300 cx.spawn(|this, mut cx| async move {
301 search.await?;
302 this.update(&mut cx, |this, cx| this.activate_current_match(cx))
303 })
304 .detach_and_log_err(cx);
305 }
306 }
307
308 pub fn activate_current_match(&mut self, cx: &mut ViewContext<Self>) {
309 if let Some(match_ix) = self.active_match_index {
310 if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
311 if let Some(matches) = self
312 .searchable_items_with_matches
313 .get(&active_searchable_item.downgrade())
314 {
315 active_searchable_item.activate_match(match_ix, matches, cx)
316 }
317 }
318 }
319 }
320
321 pub fn select_query(&mut self, cx: &mut ViewContext<Self>) {
322 self.query_editor.update(cx, |query_editor, cx| {
323 query_editor.select_all(&Default::default(), cx);
324 });
325 }
326
327 pub fn query(&self, cx: &WindowContext) -> String {
328 self.query_editor.read(cx).text(cx)
329 }
330
331 pub fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> Option<String> {
332 self.active_searchable_item
333 .as_ref()
334 .map(|searchable_item| searchable_item.query_suggestion(cx))
335 }
336
337 pub fn search(
338 &mut self,
339 query: &str,
340 options: Option<SearchOptions>,
341 cx: &mut ViewContext<Self>,
342 ) -> oneshot::Receiver<()> {
343 let options = options.unwrap_or(self.default_options);
344 if query != self.query_editor.read(cx).text(cx) || self.search_options != options {
345 self.query_editor.update(cx, |query_editor, cx| {
346 query_editor.buffer().update(cx, |query_buffer, cx| {
347 let len = query_buffer.len(cx);
348 query_buffer.edit([(0..len, query)], None, cx);
349 });
350 });
351 self.search_options = options;
352 self.query_contains_error = false;
353 self.clear_matches(cx);
354 cx.notify();
355 }
356 self.update_matches(cx)
357 }
358
359 fn render_search_option(
360 &self,
361 option_supported: bool,
362 icon: &'static str,
363 option: SearchOptions,
364 cx: &mut ViewContext<Self>,
365 ) -> Option<AnyElement<Self>> {
366 if !option_supported {
367 return None;
368 }
369
370 let tooltip_style = theme::current(cx).tooltip.clone();
371 let is_active = self.search_options.contains(option);
372 Some(
373 MouseEventHandler::<Self, _>::new(option.bits as usize, cx, |state, cx| {
374 let theme = theme::current(cx);
375 let style = theme
376 .search
377 .option_button
378 .in_state(is_active)
379 .style_for(state);
380 Label::new(icon, style.text.clone())
381 .contained()
382 .with_style(style.container)
383 })
384 .on_click(MouseButton::Left, move |_, this, cx| {
385 this.toggle_search_option(option, cx);
386 })
387 .with_cursor_style(CursorStyle::PointingHand)
388 .with_tooltip::<Self>(
389 option.bits as usize,
390 format!("Toggle {}", option.label()),
391 Some(option.to_toggle_action()),
392 tooltip_style,
393 cx,
394 )
395 .into_any(),
396 )
397 }
398
399 fn render_nav_button(
400 &self,
401 icon: &'static str,
402 direction: Direction,
403 cx: &mut ViewContext<Self>,
404 ) -> AnyElement<Self> {
405 let action: Box<dyn Action>;
406 let tooltip;
407 match direction {
408 Direction::Prev => {
409 action = Box::new(SelectPrevMatch);
410 tooltip = "Select Previous Match";
411 }
412 Direction::Next => {
413 action = Box::new(SelectNextMatch);
414 tooltip = "Select Next Match";
415 }
416 };
417 let tooltip_style = theme::current(cx).tooltip.clone();
418
419 enum NavButton {}
420 MouseEventHandler::<NavButton, _>::new(direction as usize, cx, |state, cx| {
421 let theme = theme::current(cx);
422 let style = theme.search.option_button.inactive_state().style_for(state);
423 Label::new(icon, style.text.clone())
424 .contained()
425 .with_style(style.container)
426 })
427 .on_click(MouseButton::Left, {
428 move |_, this, cx| match direction {
429 Direction::Prev => this.select_prev_match(&Default::default(), cx),
430 Direction::Next => this.select_next_match(&Default::default(), cx),
431 }
432 })
433 .with_cursor_style(CursorStyle::PointingHand)
434 .with_tooltip::<NavButton>(
435 direction as usize,
436 tooltip.to_string(),
437 Some(action),
438 tooltip_style,
439 cx,
440 )
441 .into_any()
442 }
443
444 fn render_action_button(
445 &self,
446 icon: &'static str,
447 cx: &mut ViewContext<Self>,
448 ) -> AnyElement<Self> {
449 let tooltip = "Select All Matches";
450 let tooltip_style = theme::current(cx).tooltip.clone();
451 let action_type_id = 0_usize;
452
453 enum ActionButton {}
454 MouseEventHandler::<ActionButton, _>::new(action_type_id, cx, |state, cx| {
455 let theme = theme::current(cx);
456 let style = theme.search.action_button.style_for(state);
457 Label::new(icon, style.text.clone())
458 .contained()
459 .with_style(style.container)
460 })
461 .on_click(MouseButton::Left, move |_, this, cx| {
462 this.select_all_matches(&SelectAllMatches, cx)
463 })
464 .with_cursor_style(CursorStyle::PointingHand)
465 .with_tooltip::<ActionButton>(
466 action_type_id,
467 tooltip.to_string(),
468 Some(Box::new(SelectAllMatches)),
469 tooltip_style,
470 cx,
471 )
472 .into_any()
473 }
474
475 fn render_close_button(
476 &self,
477 theme: &theme::Search,
478 cx: &mut ViewContext<Self>,
479 ) -> AnyElement<Self> {
480 let tooltip = "Dismiss Buffer Search";
481 let tooltip_style = theme::current(cx).tooltip.clone();
482
483 enum CloseButton {}
484 MouseEventHandler::<CloseButton, _>::new(0, cx, |state, _| {
485 let style = theme.dismiss_button.style_for(state);
486 Svg::new("icons/x_mark_8.svg")
487 .with_color(style.color)
488 .constrained()
489 .with_width(style.icon_width)
490 .aligned()
491 .constrained()
492 .with_width(style.button_width)
493 .contained()
494 .with_style(style.container)
495 })
496 .on_click(MouseButton::Left, move |_, this, cx| {
497 this.dismiss(&Default::default(), cx)
498 })
499 .with_cursor_style(CursorStyle::PointingHand)
500 .with_tooltip::<CloseButton>(
501 0,
502 tooltip.to_string(),
503 Some(Box::new(Dismiss)),
504 tooltip_style,
505 cx,
506 )
507 .into_any()
508 }
509
510 fn deploy(pane: &mut Pane, action: &Deploy, cx: &mut ViewContext<Pane>) {
511 let mut propagate_action = true;
512 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
513 search_bar.update(cx, |search_bar, cx| {
514 if search_bar.show(cx) {
515 search_bar.search_suggested(cx);
516 if action.focus {
517 search_bar.select_query(cx);
518 cx.focus_self();
519 }
520 propagate_action = false;
521 }
522 });
523 }
524
525 if propagate_action {
526 cx.propagate_action();
527 }
528 }
529
530 fn handle_editor_cancel(pane: &mut Pane, _: &editor::Cancel, cx: &mut ViewContext<Pane>) {
531 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
532 if !search_bar.read(cx).dismissed {
533 search_bar.update(cx, |search_bar, cx| search_bar.dismiss(&Dismiss, cx));
534 return;
535 }
536 }
537 cx.propagate_action();
538 }
539
540 pub fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext<Self>) {
541 if let Some(active_editor) = self.active_searchable_item.as_ref() {
542 cx.focus(active_editor.as_any());
543 }
544 }
545
546 fn toggle_search_option(&mut self, search_option: SearchOptions, cx: &mut ViewContext<Self>) {
547 self.search_options.toggle(search_option);
548 self.default_options = self.search_options;
549 let _ = self.update_matches(cx);
550 cx.notify();
551 }
552
553 pub fn set_search_options(
554 &mut self,
555 search_options: SearchOptions,
556 cx: &mut ViewContext<Self>,
557 ) {
558 self.search_options = search_options;
559 cx.notify();
560 }
561
562 fn select_next_match(&mut self, _: &SelectNextMatch, cx: &mut ViewContext<Self>) {
563 self.select_match(Direction::Next, 1, cx);
564 }
565
566 fn select_prev_match(&mut self, _: &SelectPrevMatch, cx: &mut ViewContext<Self>) {
567 self.select_match(Direction::Prev, 1, cx);
568 }
569
570 fn select_all_matches(&mut self, _: &SelectAllMatches, cx: &mut ViewContext<Self>) {
571 if !self.dismissed && self.active_match_index.is_some() {
572 if let Some(searchable_item) = self.active_searchable_item.as_ref() {
573 if let Some(matches) = self
574 .searchable_items_with_matches
575 .get(&searchable_item.downgrade())
576 {
577 searchable_item.select_matches(matches, cx);
578 self.focus_editor(&FocusEditor, cx);
579 }
580 }
581 }
582 }
583
584 pub fn select_match(&mut self, direction: Direction, count: usize, cx: &mut ViewContext<Self>) {
585 if let Some(index) = self.active_match_index {
586 if let Some(searchable_item) = self.active_searchable_item.as_ref() {
587 if let Some(matches) = self
588 .searchable_items_with_matches
589 .get(&searchable_item.downgrade())
590 {
591 let new_match_index = searchable_item
592 .match_index_for_direction(matches, index, direction, count, cx);
593 searchable_item.update_matches(matches, cx);
594 searchable_item.activate_match(new_match_index, matches, cx);
595 }
596 }
597 }
598 }
599
600 fn select_next_match_on_pane(
601 pane: &mut Pane,
602 action: &SelectNextMatch,
603 cx: &mut ViewContext<Pane>,
604 ) {
605 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
606 search_bar.update(cx, |bar, cx| bar.select_next_match(action, cx));
607 }
608 }
609
610 fn select_prev_match_on_pane(
611 pane: &mut Pane,
612 action: &SelectPrevMatch,
613 cx: &mut ViewContext<Pane>,
614 ) {
615 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
616 search_bar.update(cx, |bar, cx| bar.select_prev_match(action, cx));
617 }
618 }
619
620 fn select_all_matches_on_pane(
621 pane: &mut Pane,
622 action: &SelectAllMatches,
623 cx: &mut ViewContext<Pane>,
624 ) {
625 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
626 search_bar.update(cx, |bar, cx| bar.select_all_matches(action, cx));
627 }
628 }
629
630 fn on_query_editor_event(
631 &mut self,
632 _: ViewHandle<Editor>,
633 event: &editor::Event,
634 cx: &mut ViewContext<Self>,
635 ) {
636 if let editor::Event::Edited { .. } = event {
637 self.query_contains_error = false;
638 self.clear_matches(cx);
639 let search = self.update_matches(cx);
640 cx.spawn(|this, mut cx| async move {
641 search.await?;
642 this.update(&mut cx, |this, cx| this.activate_current_match(cx))
643 })
644 .detach_and_log_err(cx);
645 }
646 }
647
648 fn on_active_searchable_item_event(&mut self, event: SearchEvent, cx: &mut ViewContext<Self>) {
649 match event {
650 SearchEvent::MatchesInvalidated => {
651 let _ = self.update_matches(cx);
652 }
653 SearchEvent::ActiveMatchChanged => self.update_match_index(cx),
654 }
655 }
656
657 fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
658 let mut active_item_matches = None;
659 for (searchable_item, matches) in self.searchable_items_with_matches.drain() {
660 if let Some(searchable_item) =
661 WeakSearchableItemHandle::upgrade(searchable_item.as_ref(), cx)
662 {
663 if Some(&searchable_item) == self.active_searchable_item.as_ref() {
664 active_item_matches = Some((searchable_item.downgrade(), matches));
665 } else {
666 searchable_item.clear_matches(cx);
667 }
668 }
669 }
670
671 self.searchable_items_with_matches
672 .extend(active_item_matches);
673 }
674
675 fn update_matches(&mut self, cx: &mut ViewContext<Self>) -> oneshot::Receiver<()> {
676 let (done_tx, done_rx) = oneshot::channel();
677 let query = self.query_editor.read(cx).text(cx);
678 self.pending_search.take();
679 if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
680 if query.is_empty() {
681 self.active_match_index.take();
682 active_searchable_item.clear_matches(cx);
683 let _ = done_tx.send(());
684 } else {
685 let query = if self.search_options.contains(SearchOptions::REGEX) {
686 match SearchQuery::regex(
687 query,
688 self.search_options.contains(SearchOptions::WHOLE_WORD),
689 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
690 Vec::new(),
691 Vec::new(),
692 ) {
693 Ok(query) => query,
694 Err(_) => {
695 self.query_contains_error = true;
696 cx.notify();
697 return done_rx;
698 }
699 }
700 } else {
701 SearchQuery::text(
702 query,
703 self.search_options.contains(SearchOptions::WHOLE_WORD),
704 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
705 Vec::new(),
706 Vec::new(),
707 )
708 };
709
710 let matches = active_searchable_item.find_matches(query, cx);
711
712 let active_searchable_item = active_searchable_item.downgrade();
713 self.pending_search = Some(cx.spawn(|this, mut cx| async move {
714 let matches = matches.await;
715 this.update(&mut cx, |this, cx| {
716 if let Some(active_searchable_item) =
717 WeakSearchableItemHandle::upgrade(active_searchable_item.as_ref(), cx)
718 {
719 this.searchable_items_with_matches
720 .insert(active_searchable_item.downgrade(), matches);
721
722 this.update_match_index(cx);
723 if !this.dismissed {
724 let matches = this
725 .searchable_items_with_matches
726 .get(&active_searchable_item.downgrade())
727 .unwrap();
728 active_searchable_item.update_matches(matches, cx);
729 let _ = done_tx.send(());
730 }
731 cx.notify();
732 }
733 })
734 .log_err();
735 }));
736 }
737 }
738 done_rx
739 }
740
741 fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
742 let new_index = self
743 .active_searchable_item
744 .as_ref()
745 .and_then(|searchable_item| {
746 let matches = self
747 .searchable_items_with_matches
748 .get(&searchable_item.downgrade())?;
749 searchable_item.active_match_index(matches, cx)
750 });
751 if new_index != self.active_match_index {
752 self.active_match_index = new_index;
753 cx.notify();
754 }
755 }
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761 use editor::{DisplayPoint, Editor};
762 use gpui::{color::Color, test::EmptyView, TestAppContext};
763 use language::Buffer;
764 use unindent::Unindent as _;
765
766 fn init_test(cx: &mut TestAppContext) -> (ViewHandle<Editor>, ViewHandle<BufferSearchBar>) {
767 crate::project_search::tests::init_test(cx);
768
769 let buffer = cx.add_model(|cx| {
770 Buffer::new(
771 0,
772 r#"
773 A regular expression (shortened as regex or regexp;[1] also referred to as
774 rational expression[2][3]) is a sequence of characters that specifies a search
775 pattern in text. Usually such patterns are used by string-searching algorithms
776 for "find" or "find and replace" operations on strings, or for input validation.
777 "#
778 .unindent(),
779 cx,
780 )
781 });
782 let (window_id, _root_view) = cx.add_window(|_| EmptyView);
783
784 let editor = cx.add_view(window_id, |cx| Editor::for_buffer(buffer.clone(), None, cx));
785
786 let search_bar = cx.add_view(window_id, |cx| {
787 let mut search_bar = BufferSearchBar::new(cx);
788 search_bar.set_active_pane_item(Some(&editor), cx);
789 search_bar.show(cx);
790 search_bar
791 });
792
793 (editor, search_bar)
794 }
795
796 #[gpui::test]
797 async fn test_search_simple(cx: &mut TestAppContext) {
798 let (editor, search_bar) = init_test(cx);
799
800 // Search for a string that appears with different casing.
801 // By default, search is case-insensitive.
802 search_bar
803 .update(cx, |search_bar, cx| search_bar.search("us", None, cx))
804 .await
805 .unwrap();
806 editor.update(cx, |editor, cx| {
807 assert_eq!(
808 editor.all_background_highlights(cx),
809 &[
810 (
811 DisplayPoint::new(2, 17)..DisplayPoint::new(2, 19),
812 Color::red(),
813 ),
814 (
815 DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45),
816 Color::red(),
817 ),
818 ]
819 );
820 });
821
822 // Switch to a case sensitive search.
823 search_bar.update(cx, |search_bar, cx| {
824 search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
825 });
826 editor.next_notification(cx).await;
827 editor.update(cx, |editor, cx| {
828 assert_eq!(
829 editor.all_background_highlights(cx),
830 &[(
831 DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45),
832 Color::red(),
833 )]
834 );
835 });
836
837 // Search for a string that appears both as a whole word and
838 // within other words. By default, all results are found.
839 search_bar
840 .update(cx, |search_bar, cx| search_bar.search("or", None, cx))
841 .await
842 .unwrap();
843 editor.update(cx, |editor, cx| {
844 assert_eq!(
845 editor.all_background_highlights(cx),
846 &[
847 (
848 DisplayPoint::new(0, 24)..DisplayPoint::new(0, 26),
849 Color::red(),
850 ),
851 (
852 DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43),
853 Color::red(),
854 ),
855 (
856 DisplayPoint::new(2, 71)..DisplayPoint::new(2, 73),
857 Color::red(),
858 ),
859 (
860 DisplayPoint::new(3, 1)..DisplayPoint::new(3, 3),
861 Color::red(),
862 ),
863 (
864 DisplayPoint::new(3, 11)..DisplayPoint::new(3, 13),
865 Color::red(),
866 ),
867 (
868 DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58),
869 Color::red(),
870 ),
871 (
872 DisplayPoint::new(3, 60)..DisplayPoint::new(3, 62),
873 Color::red(),
874 ),
875 ]
876 );
877 });
878
879 // Switch to a whole word search.
880 search_bar.update(cx, |search_bar, cx| {
881 search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
882 });
883 editor.next_notification(cx).await;
884 editor.update(cx, |editor, cx| {
885 assert_eq!(
886 editor.all_background_highlights(cx),
887 &[
888 (
889 DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43),
890 Color::red(),
891 ),
892 (
893 DisplayPoint::new(3, 11)..DisplayPoint::new(3, 13),
894 Color::red(),
895 ),
896 (
897 DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58),
898 Color::red(),
899 ),
900 ]
901 );
902 });
903
904 editor.update(cx, |editor, cx| {
905 editor.change_selections(None, cx, |s| {
906 s.select_display_ranges([DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)])
907 });
908 });
909 search_bar.update(cx, |search_bar, cx| {
910 assert_eq!(search_bar.active_match_index, Some(0));
911 search_bar.select_next_match(&SelectNextMatch, cx);
912 assert_eq!(
913 editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)),
914 [DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43)]
915 );
916 });
917 search_bar.read_with(cx, |search_bar, _| {
918 assert_eq!(search_bar.active_match_index, Some(0));
919 });
920
921 search_bar.update(cx, |search_bar, cx| {
922 search_bar.select_next_match(&SelectNextMatch, cx);
923 assert_eq!(
924 editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)),
925 [DisplayPoint::new(3, 11)..DisplayPoint::new(3, 13)]
926 );
927 });
928 search_bar.read_with(cx, |search_bar, _| {
929 assert_eq!(search_bar.active_match_index, Some(1));
930 });
931
932 search_bar.update(cx, |search_bar, cx| {
933 search_bar.select_next_match(&SelectNextMatch, cx);
934 assert_eq!(
935 editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)),
936 [DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58)]
937 );
938 });
939 search_bar.read_with(cx, |search_bar, _| {
940 assert_eq!(search_bar.active_match_index, Some(2));
941 });
942
943 search_bar.update(cx, |search_bar, cx| {
944 search_bar.select_next_match(&SelectNextMatch, cx);
945 assert_eq!(
946 editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)),
947 [DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43)]
948 );
949 });
950 search_bar.read_with(cx, |search_bar, _| {
951 assert_eq!(search_bar.active_match_index, Some(0));
952 });
953
954 search_bar.update(cx, |search_bar, cx| {
955 search_bar.select_prev_match(&SelectPrevMatch, cx);
956 assert_eq!(
957 editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)),
958 [DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58)]
959 );
960 });
961 search_bar.read_with(cx, |search_bar, _| {
962 assert_eq!(search_bar.active_match_index, Some(2));
963 });
964
965 search_bar.update(cx, |search_bar, cx| {
966 search_bar.select_prev_match(&SelectPrevMatch, cx);
967 assert_eq!(
968 editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)),
969 [DisplayPoint::new(3, 11)..DisplayPoint::new(3, 13)]
970 );
971 });
972 search_bar.read_with(cx, |search_bar, _| {
973 assert_eq!(search_bar.active_match_index, Some(1));
974 });
975
976 search_bar.update(cx, |search_bar, cx| {
977 search_bar.select_prev_match(&SelectPrevMatch, cx);
978 assert_eq!(
979 editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)),
980 [DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43)]
981 );
982 });
983 search_bar.read_with(cx, |search_bar, _| {
984 assert_eq!(search_bar.active_match_index, Some(0));
985 });
986
987 // Park the cursor in between matches and ensure that going to the previous match selects
988 // the closest match to the left.
989 editor.update(cx, |editor, cx| {
990 editor.change_selections(None, cx, |s| {
991 s.select_display_ranges([DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)])
992 });
993 });
994 search_bar.update(cx, |search_bar, cx| {
995 assert_eq!(search_bar.active_match_index, Some(1));
996 search_bar.select_prev_match(&SelectPrevMatch, cx);
997 assert_eq!(
998 editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)),
999 [DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43)]
1000 );
1001 });
1002 search_bar.read_with(cx, |search_bar, _| {
1003 assert_eq!(search_bar.active_match_index, Some(0));
1004 });
1005
1006 // Park the cursor in between matches and ensure that going to the next match selects the
1007 // closest match to the right.
1008 editor.update(cx, |editor, cx| {
1009 editor.change_selections(None, cx, |s| {
1010 s.select_display_ranges([DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)])
1011 });
1012 });
1013 search_bar.update(cx, |search_bar, cx| {
1014 assert_eq!(search_bar.active_match_index, Some(1));
1015 search_bar.select_next_match(&SelectNextMatch, cx);
1016 assert_eq!(
1017 editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1018 [DisplayPoint::new(3, 11)..DisplayPoint::new(3, 13)]
1019 );
1020 });
1021 search_bar.read_with(cx, |search_bar, _| {
1022 assert_eq!(search_bar.active_match_index, Some(1));
1023 });
1024
1025 // Park the cursor after the last match and ensure that going to the previous match selects
1026 // the last match.
1027 editor.update(cx, |editor, cx| {
1028 editor.change_selections(None, cx, |s| {
1029 s.select_display_ranges([DisplayPoint::new(3, 60)..DisplayPoint::new(3, 60)])
1030 });
1031 });
1032 search_bar.update(cx, |search_bar, cx| {
1033 assert_eq!(search_bar.active_match_index, Some(2));
1034 search_bar.select_prev_match(&SelectPrevMatch, cx);
1035 assert_eq!(
1036 editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1037 [DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58)]
1038 );
1039 });
1040 search_bar.read_with(cx, |search_bar, _| {
1041 assert_eq!(search_bar.active_match_index, Some(2));
1042 });
1043
1044 // Park the cursor after the last match and ensure that going to the next match selects the
1045 // first match.
1046 editor.update(cx, |editor, cx| {
1047 editor.change_selections(None, cx, |s| {
1048 s.select_display_ranges([DisplayPoint::new(3, 60)..DisplayPoint::new(3, 60)])
1049 });
1050 });
1051 search_bar.update(cx, |search_bar, cx| {
1052 assert_eq!(search_bar.active_match_index, Some(2));
1053 search_bar.select_next_match(&SelectNextMatch, cx);
1054 assert_eq!(
1055 editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1056 [DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43)]
1057 );
1058 });
1059 search_bar.read_with(cx, |search_bar, _| {
1060 assert_eq!(search_bar.active_match_index, Some(0));
1061 });
1062
1063 // Park the cursor before the first match and ensure that going to the previous match
1064 // selects the last match.
1065 editor.update(cx, |editor, cx| {
1066 editor.change_selections(None, cx, |s| {
1067 s.select_display_ranges([DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)])
1068 });
1069 });
1070 search_bar.update(cx, |search_bar, cx| {
1071 assert_eq!(search_bar.active_match_index, Some(0));
1072 search_bar.select_prev_match(&SelectPrevMatch, cx);
1073 assert_eq!(
1074 editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1075 [DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58)]
1076 );
1077 });
1078 search_bar.read_with(cx, |search_bar, _| {
1079 assert_eq!(search_bar.active_match_index, Some(2));
1080 });
1081 }
1082
1083 #[gpui::test]
1084 async fn test_search_option_handling(cx: &mut TestAppContext) {
1085 let (editor, search_bar) = init_test(cx);
1086
1087 // show with options should make current search case sensitive
1088 search_bar
1089 .update(cx, |search_bar, cx| {
1090 search_bar.show(cx);
1091 search_bar.search("us", Some(SearchOptions::CASE_SENSITIVE), cx)
1092 })
1093 .await
1094 .unwrap();
1095 editor.update(cx, |editor, cx| {
1096 assert_eq!(
1097 editor.all_background_highlights(cx),
1098 &[(
1099 DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45),
1100 Color::red(),
1101 )]
1102 );
1103 });
1104
1105 // search_suggested should restore default options
1106 search_bar.update(cx, |search_bar, cx| {
1107 search_bar.search_suggested(cx);
1108 assert_eq!(search_bar.search_options, SearchOptions::NONE)
1109 });
1110
1111 // toggling a search option should update the defaults
1112 search_bar
1113 .update(cx, |search_bar, cx| {
1114 search_bar.search("regex", Some(SearchOptions::CASE_SENSITIVE), cx)
1115 })
1116 .await
1117 .unwrap();
1118 search_bar.update(cx, |search_bar, cx| {
1119 search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx)
1120 });
1121 editor.next_notification(cx).await;
1122 editor.update(cx, |editor, cx| {
1123 assert_eq!(
1124 editor.all_background_highlights(cx),
1125 &[(
1126 DisplayPoint::new(0, 35)..DisplayPoint::new(0, 40),
1127 Color::red(),
1128 ),]
1129 );
1130 });
1131
1132 // defaults should still include whole word
1133 search_bar.update(cx, |search_bar, cx| {
1134 search_bar.search_suggested(cx);
1135 assert_eq!(
1136 search_bar.search_options,
1137 SearchOptions::CASE_SENSITIVE | SearchOptions::WHOLE_WORD
1138 )
1139 });
1140 }
1141
1142 #[gpui::test]
1143 async fn test_search_select_all_matches(cx: &mut TestAppContext) {
1144 crate::project_search::tests::init_test(cx);
1145
1146 let buffer_text = r#"
1147 A regular expression (shortened as regex or regexp;[1] also referred to as
1148 rational expression[2][3]) is a sequence of characters that specifies a search
1149 pattern in text. Usually such patterns are used by string-searching algorithms
1150 for "find" or "find and replace" operations on strings, or for input validation.
1151 "#
1152 .unindent();
1153 let expected_query_matches_count = buffer_text
1154 .chars()
1155 .filter(|c| c.to_ascii_lowercase() == 'a')
1156 .count();
1157 assert!(
1158 expected_query_matches_count > 1,
1159 "Should pick a query with multiple results"
1160 );
1161 let buffer = cx.add_model(|cx| Buffer::new(0, buffer_text, cx));
1162 let (window_id, _root_view) = cx.add_window(|_| EmptyView);
1163
1164 let editor = cx.add_view(window_id, |cx| Editor::for_buffer(buffer.clone(), None, cx));
1165
1166 let search_bar = cx.add_view(window_id, |cx| {
1167 let mut search_bar = BufferSearchBar::new(cx);
1168 search_bar.set_active_pane_item(Some(&editor), cx);
1169 search_bar.show(cx);
1170 search_bar
1171 });
1172
1173 search_bar
1174 .update(cx, |search_bar, cx| search_bar.search("a", None, cx))
1175 .await
1176 .unwrap();
1177 search_bar.update(cx, |search_bar, cx| {
1178 cx.focus(search_bar.query_editor.as_any());
1179 search_bar.activate_current_match(cx);
1180 });
1181
1182 cx.read_window(window_id, |cx| {
1183 assert!(
1184 !editor.is_focused(cx),
1185 "Initially, the editor should not be focused"
1186 );
1187 });
1188 let initial_selections = editor.update(cx, |editor, cx| {
1189 let initial_selections = editor.selections.display_ranges(cx);
1190 assert_eq!(
1191 initial_selections.len(), 1,
1192 "Expected to have only one selection before adding carets to all matches, but got: {initial_selections:?}",
1193 );
1194 initial_selections
1195 });
1196 search_bar.update(cx, |search_bar, _| {
1197 assert_eq!(search_bar.active_match_index, Some(0));
1198 });
1199
1200 search_bar.update(cx, |search_bar, cx| {
1201 cx.focus(search_bar.query_editor.as_any());
1202 search_bar.select_all_matches(&SelectAllMatches, cx);
1203 });
1204 cx.read_window(window_id, |cx| {
1205 assert!(
1206 editor.is_focused(cx),
1207 "Should focus editor after successful SelectAllMatches"
1208 );
1209 });
1210 search_bar.update(cx, |search_bar, cx| {
1211 let all_selections =
1212 editor.update(cx, |editor, cx| editor.selections.display_ranges(cx));
1213 assert_eq!(
1214 all_selections.len(),
1215 expected_query_matches_count,
1216 "Should select all `a` characters in the buffer, but got: {all_selections:?}"
1217 );
1218 assert_eq!(
1219 search_bar.active_match_index,
1220 Some(0),
1221 "Match index should not change after selecting all matches"
1222 );
1223 });
1224
1225 search_bar.update(cx, |search_bar, cx| {
1226 search_bar.select_next_match(&SelectNextMatch, cx);
1227 });
1228 cx.read_window(window_id, |cx| {
1229 assert!(
1230 editor.is_focused(cx),
1231 "Should still have editor focused after SelectNextMatch"
1232 );
1233 });
1234 search_bar.update(cx, |search_bar, cx| {
1235 let all_selections =
1236 editor.update(cx, |editor, cx| editor.selections.display_ranges(cx));
1237 assert_eq!(
1238 all_selections.len(),
1239 1,
1240 "On next match, should deselect items and select the next match"
1241 );
1242 assert_ne!(
1243 all_selections, initial_selections,
1244 "Next match should be different from the first selection"
1245 );
1246 assert_eq!(
1247 search_bar.active_match_index,
1248 Some(1),
1249 "Match index should be updated to the next one"
1250 );
1251 });
1252
1253 search_bar.update(cx, |search_bar, cx| {
1254 cx.focus(search_bar.query_editor.as_any());
1255 search_bar.select_all_matches(&SelectAllMatches, cx);
1256 });
1257 cx.read_window(window_id, |cx| {
1258 assert!(
1259 editor.is_focused(cx),
1260 "Should focus editor after successful SelectAllMatches"
1261 );
1262 });
1263 search_bar.update(cx, |search_bar, cx| {
1264 let all_selections =
1265 editor.update(cx, |editor, cx| editor.selections.display_ranges(cx));
1266 assert_eq!(
1267 all_selections.len(),
1268 expected_query_matches_count,
1269 "Should select all `a` characters in the buffer, but got: {all_selections:?}"
1270 );
1271 assert_eq!(
1272 search_bar.active_match_index,
1273 Some(1),
1274 "Match index should not change after selecting all matches"
1275 );
1276 });
1277
1278 search_bar.update(cx, |search_bar, cx| {
1279 search_bar.select_prev_match(&SelectPrevMatch, cx);
1280 });
1281 cx.read_window(window_id, |cx| {
1282 assert!(
1283 editor.is_focused(cx),
1284 "Should still have editor focused after SelectPrevMatch"
1285 );
1286 });
1287 let last_match_selections = search_bar.update(cx, |search_bar, cx| {
1288 let all_selections =
1289 editor.update(cx, |editor, cx| editor.selections.display_ranges(cx));
1290 assert_eq!(
1291 all_selections.len(),
1292 1,
1293 "On previous match, should deselect items and select the previous item"
1294 );
1295 assert_eq!(
1296 all_selections, initial_selections,
1297 "Previous match should be the same as the first selection"
1298 );
1299 assert_eq!(
1300 search_bar.active_match_index,
1301 Some(0),
1302 "Match index should be updated to the previous one"
1303 );
1304 all_selections
1305 });
1306
1307 search_bar
1308 .update(cx, |search_bar, cx| {
1309 cx.focus(search_bar.query_editor.as_any());
1310 search_bar.search("abas_nonexistent_match", None, cx)
1311 })
1312 .await
1313 .unwrap();
1314 search_bar.update(cx, |search_bar, cx| {
1315 search_bar.select_all_matches(&SelectAllMatches, cx);
1316 });
1317 cx.read_window(window_id, |cx| {
1318 assert!(
1319 !editor.is_focused(cx),
1320 "Should not switch focus to editor if SelectAllMatches does not find any matches"
1321 );
1322 });
1323 search_bar.update(cx, |search_bar, cx| {
1324 let all_selections =
1325 editor.update(cx, |editor, cx| editor.selections.display_ranges(cx));
1326 assert_eq!(
1327 all_selections, last_match_selections,
1328 "Should not select anything new if there are no matches"
1329 );
1330 assert!(
1331 search_bar.active_match_index.is_none(),
1332 "For no matches, there should be no active match index"
1333 );
1334 });
1335 }
1336}