1use crate::{
2 active_match_index, match_index_for_direction, Direction, SearchOption, SelectMatch,
3 ToggleSearchOption,
4};
5use collections::HashMap;
6use editor::{Anchor, Autoscroll, Editor, MultiBuffer, SelectAll};
7use gpui::{
8 action, elements::*, keymap::Binding, platform::CursorStyle, AppContext, ElementBox, Entity,
9 ModelContext, ModelHandle, MutableAppContext, RenderContext, Task, View, ViewContext,
10 ViewHandle, WeakModelHandle,
11};
12use project::{search::SearchQuery, Project};
13use std::{
14 any::{Any, TypeId},
15 ops::Range,
16 path::PathBuf,
17};
18use util::ResultExt as _;
19use workspace::{Item, ItemHandle, ItemNavHistory, ItemView, Settings, Workspace};
20
21action!(Deploy);
22action!(Search);
23action!(SearchInNew);
24action!(ToggleFocus);
25
26const MAX_TAB_TITLE_LEN: usize = 24;
27
28#[derive(Default)]
29struct ActiveSearches(HashMap<WeakModelHandle<Project>, WeakModelHandle<ProjectSearch>>);
30
31pub fn init(cx: &mut MutableAppContext) {
32 cx.add_app_state(ActiveSearches::default());
33 cx.add_bindings([
34 Binding::new("cmd-shift-F", ToggleFocus, Some("ProjectSearchView")),
35 Binding::new("cmd-f", ToggleFocus, Some("ProjectSearchView")),
36 Binding::new("cmd-shift-F", Deploy, Some("Workspace")),
37 Binding::new("enter", Search, Some("ProjectSearchView")),
38 Binding::new("cmd-enter", SearchInNew, Some("ProjectSearchView")),
39 Binding::new(
40 "cmd-g",
41 SelectMatch(Direction::Next),
42 Some("ProjectSearchView"),
43 ),
44 Binding::new(
45 "cmd-shift-G",
46 SelectMatch(Direction::Prev),
47 Some("ProjectSearchView"),
48 ),
49 ]);
50 cx.add_action(ProjectSearchView::deploy);
51 cx.add_action(ProjectSearchView::search);
52 cx.add_action(ProjectSearchView::search_in_new);
53 cx.add_action(ProjectSearchView::toggle_search_option);
54 cx.add_action(ProjectSearchView::select_match);
55 cx.add_action(ProjectSearchView::toggle_focus);
56 cx.capture_action(ProjectSearchView::tab);
57}
58
59struct ProjectSearch {
60 project: ModelHandle<Project>,
61 excerpts: ModelHandle<MultiBuffer>,
62 pending_search: Option<Task<Option<()>>>,
63 match_ranges: Vec<Range<Anchor>>,
64 active_query: Option<SearchQuery>,
65}
66
67struct ProjectSearchView {
68 model: ModelHandle<ProjectSearch>,
69 query_editor: ViewHandle<Editor>,
70 results_editor: ViewHandle<Editor>,
71 case_sensitive: bool,
72 whole_word: bool,
73 regex: bool,
74 query_contains_error: bool,
75 active_match_index: Option<usize>,
76}
77
78impl Entity for ProjectSearch {
79 type Event = ();
80}
81
82impl ProjectSearch {
83 fn new(project: ModelHandle<Project>, cx: &mut ModelContext<Self>) -> Self {
84 let replica_id = project.read(cx).replica_id();
85 Self {
86 project,
87 excerpts: cx.add_model(|_| MultiBuffer::new(replica_id)),
88 pending_search: Default::default(),
89 match_ranges: Default::default(),
90 active_query: None,
91 }
92 }
93
94 fn clone(&self, cx: &mut ModelContext<Self>) -> ModelHandle<Self> {
95 cx.add_model(|cx| Self {
96 project: self.project.clone(),
97 excerpts: self
98 .excerpts
99 .update(cx, |excerpts, cx| cx.add_model(|cx| excerpts.clone(cx))),
100 pending_search: Default::default(),
101 match_ranges: self.match_ranges.clone(),
102 active_query: self.active_query.clone(),
103 })
104 }
105
106 fn search(&mut self, query: SearchQuery, cx: &mut ModelContext<Self>) {
107 let search = self
108 .project
109 .update(cx, |project, cx| project.search(query.clone(), cx));
110 self.active_query = Some(query);
111 self.match_ranges.clear();
112 self.pending_search = Some(cx.spawn_weak(|this, mut cx| async move {
113 let matches = search.await.log_err()?;
114 if let Some(this) = this.upgrade(&cx) {
115 this.update(&mut cx, |this, cx| {
116 this.match_ranges.clear();
117 let mut matches = matches.into_iter().collect::<Vec<_>>();
118 matches
119 .sort_by_key(|(buffer, _)| buffer.read(cx).file().map(|file| file.path()));
120 this.excerpts.update(cx, |excerpts, cx| {
121 excerpts.clear(cx);
122 for (buffer, buffer_matches) in matches {
123 let ranges_to_highlight = excerpts.push_excerpts_with_context_lines(
124 buffer,
125 buffer_matches.clone(),
126 1,
127 cx,
128 );
129 this.match_ranges.extend(ranges_to_highlight);
130 }
131 });
132 this.pending_search.take();
133 cx.notify();
134 });
135 }
136 None
137 }));
138 cx.notify();
139 }
140}
141
142impl Item for ProjectSearch {
143 type View = ProjectSearchView;
144
145 fn build_view(
146 model: ModelHandle<Self>,
147 _: &Workspace,
148 nav_history: ItemNavHistory,
149 cx: &mut gpui::ViewContext<Self::View>,
150 ) -> Self::View {
151 ProjectSearchView::new(model, Some(nav_history), cx)
152 }
153
154 fn project_path(&self) -> Option<project::ProjectPath> {
155 None
156 }
157}
158
159enum ViewEvent {
160 UpdateTab,
161}
162
163impl Entity for ProjectSearchView {
164 type Event = ViewEvent;
165}
166
167impl View for ProjectSearchView {
168 fn ui_name() -> &'static str {
169 "ProjectSearchView"
170 }
171
172 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
173 let model = &self.model.read(cx);
174 let results = if model.match_ranges.is_empty() {
175 let theme = &cx.app_state::<Settings>().theme;
176 let text = if self.query_editor.read(cx).text(cx).is_empty() {
177 ""
178 } else if model.pending_search.is_some() {
179 "Searching..."
180 } else {
181 "No results"
182 };
183 Label::new(text.to_string(), theme.search.results_status.clone())
184 .aligned()
185 .contained()
186 .with_background_color(theme.editor.background)
187 .flexible(1., true)
188 .boxed()
189 } else {
190 ChildView::new(&self.results_editor)
191 .flexible(1., true)
192 .boxed()
193 };
194
195 Flex::column()
196 .with_child(self.render_query_editor(cx))
197 .with_child(results)
198 .boxed()
199 }
200
201 fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
202 cx.update_app_state(|state: &mut ActiveSearches, cx| {
203 state.0.insert(
204 self.model.read(cx).project.downgrade(),
205 self.model.downgrade(),
206 )
207 });
208
209 if self.model.read(cx).match_ranges.is_empty() {
210 cx.focus(&self.query_editor);
211 } else {
212 self.focus_results_editor(cx);
213 }
214 }
215}
216
217impl ItemView for ProjectSearchView {
218 fn act_as_type(
219 &self,
220 type_id: TypeId,
221 self_handle: &ViewHandle<Self>,
222 _: &gpui::AppContext,
223 ) -> Option<gpui::AnyViewHandle> {
224 if type_id == TypeId::of::<Self>() {
225 Some(self_handle.into())
226 } else if type_id == TypeId::of::<Editor>() {
227 Some((&self.results_editor).into())
228 } else {
229 None
230 }
231 }
232
233 fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
234 self.results_editor
235 .update(cx, |editor, cx| editor.deactivated(cx));
236 }
237
238 fn item(&self, _: &gpui::AppContext) -> Box<dyn ItemHandle> {
239 Box::new(self.model.clone())
240 }
241
242 fn tab_content(&self, tab_theme: &theme::Tab, cx: &gpui::AppContext) -> ElementBox {
243 let settings = cx.app_state::<Settings>();
244 let search_theme = &settings.theme.search;
245 Flex::row()
246 .with_child(
247 Svg::new("icons/magnifier.svg")
248 .with_color(tab_theme.label.text.color)
249 .constrained()
250 .with_width(search_theme.tab_icon_width)
251 .aligned()
252 .boxed(),
253 )
254 .with_children(self.model.read(cx).active_query.as_ref().map(|query| {
255 let query_text = if query.as_str().len() > MAX_TAB_TITLE_LEN {
256 query.as_str()[..MAX_TAB_TITLE_LEN].to_string() + "…"
257 } else {
258 query.as_str().to_string()
259 };
260
261 Label::new(query_text, tab_theme.label.clone())
262 .aligned()
263 .contained()
264 .with_margin_left(search_theme.tab_icon_spacing)
265 .boxed()
266 }))
267 .boxed()
268 }
269
270 fn project_path(&self, _: &gpui::AppContext) -> Option<project::ProjectPath> {
271 None
272 }
273
274 fn can_save(&self, _: &gpui::AppContext) -> bool {
275 true
276 }
277
278 fn is_dirty(&self, cx: &AppContext) -> bool {
279 self.results_editor.read(cx).is_dirty(cx)
280 }
281
282 fn has_conflict(&self, cx: &AppContext) -> bool {
283 self.results_editor.read(cx).has_conflict(cx)
284 }
285
286 fn save(
287 &mut self,
288 project: ModelHandle<Project>,
289 cx: &mut ViewContext<Self>,
290 ) -> Task<anyhow::Result<()>> {
291 self.results_editor
292 .update(cx, |editor, cx| editor.save(project, cx))
293 }
294
295 fn can_save_as(&self, _: &gpui::AppContext) -> bool {
296 false
297 }
298
299 fn save_as(
300 &mut self,
301 _: ModelHandle<Project>,
302 _: PathBuf,
303 _: &mut ViewContext<Self>,
304 ) -> Task<anyhow::Result<()>> {
305 unreachable!("save_as should not have been called")
306 }
307
308 fn clone_on_split(
309 &self,
310 nav_history: ItemNavHistory,
311 cx: &mut ViewContext<Self>,
312 ) -> Option<Self>
313 where
314 Self: Sized,
315 {
316 let model = self.model.update(cx, |model, cx| model.clone(cx));
317 Some(Self::new(model, Some(nav_history), cx))
318 }
319
320 fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) {
321 self.results_editor
322 .update(cx, |editor, cx| editor.navigate(data, cx));
323 }
324
325 fn should_update_tab_on_event(event: &ViewEvent) -> bool {
326 matches!(event, ViewEvent::UpdateTab)
327 }
328}
329
330impl ProjectSearchView {
331 fn new(
332 model: ModelHandle<ProjectSearch>,
333 nav_history: Option<ItemNavHistory>,
334 cx: &mut ViewContext<Self>,
335 ) -> Self {
336 let project;
337 let excerpts;
338 let mut query_text = String::new();
339 let mut regex = false;
340 let mut case_sensitive = false;
341 let mut whole_word = false;
342
343 {
344 let model = model.read(cx);
345 project = model.project.clone();
346 excerpts = model.excerpts.clone();
347 if let Some(active_query) = model.active_query.as_ref() {
348 query_text = active_query.as_str().to_string();
349 regex = active_query.is_regex();
350 case_sensitive = active_query.case_sensitive();
351 whole_word = active_query.whole_word();
352 }
353 }
354 cx.observe(&model, |this, _, cx| this.model_changed(true, cx))
355 .detach();
356
357 let query_editor = cx.add_view(|cx| {
358 let mut editor =
359 Editor::single_line(Some(|theme| theme.search.editor.input.clone()), cx);
360 editor.set_text(query_text, cx);
361 editor
362 });
363
364 let results_editor = cx.add_view(|cx| {
365 let mut editor = Editor::for_buffer(excerpts, Some(project), cx);
366 editor.set_searchable(false);
367 editor.set_nav_history(nav_history);
368 editor
369 });
370 cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab))
371 .detach();
372 cx.subscribe(&results_editor, |this, _, event, cx| {
373 if matches!(event, editor::Event::SelectionsChanged) {
374 this.update_match_index(cx);
375 }
376 })
377 .detach();
378
379 let mut this = ProjectSearchView {
380 model,
381 query_editor,
382 results_editor,
383 case_sensitive,
384 whole_word,
385 regex,
386 query_contains_error: false,
387 active_match_index: None,
388 };
389 this.model_changed(false, cx);
390 this
391 }
392
393 // Re-activate the most recently activated search or the most recent if it has been closed.
394 // If no search exists in the workspace, create a new one.
395 fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
396 // Clean up entries for dropped projects
397 cx.update_app_state(|state: &mut ActiveSearches, cx| {
398 state.0.retain(|project, _| project.is_upgradable(cx))
399 });
400
401 let active_search = cx
402 .app_state::<ActiveSearches>()
403 .0
404 .get(&workspace.project().downgrade());
405
406 let existing = active_search
407 .and_then(|active_search| {
408 workspace
409 .items_of_type::<ProjectSearch>(cx)
410 .find(|search| search == active_search)
411 })
412 .or_else(|| workspace.item_of_type::<ProjectSearch>(cx));
413
414 if let Some(existing) = existing {
415 workspace.activate_item(&existing, cx);
416 } else {
417 let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
418 workspace.open_item(model, cx);
419 }
420 }
421
422 fn search(&mut self, _: &Search, cx: &mut ViewContext<Self>) {
423 if let Some(query) = self.build_search_query(cx) {
424 self.model.update(cx, |model, cx| model.search(query, cx));
425 }
426 }
427
428 fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
429 if let Some(search_view) = workspace
430 .active_item(cx)
431 .and_then(|item| item.downcast::<ProjectSearchView>())
432 {
433 let new_query = search_view.update(cx, |search_view, cx| {
434 let new_query = search_view.build_search_query(cx);
435 if new_query.is_some() {
436 if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
437 search_view.query_editor.update(cx, |editor, cx| {
438 editor.set_text(old_query.as_str(), cx);
439 });
440 search_view.regex = old_query.is_regex();
441 search_view.whole_word = old_query.whole_word();
442 search_view.case_sensitive = old_query.case_sensitive();
443 }
444 }
445 new_query
446 });
447 if let Some(new_query) = new_query {
448 let model = cx.add_model(|cx| {
449 let mut model = ProjectSearch::new(workspace.project().clone(), cx);
450 model.search(new_query, cx);
451 model
452 });
453 workspace.open_item(model, cx);
454 }
455 }
456 }
457
458 fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
459 let text = self.query_editor.read(cx).text(cx);
460 if self.regex {
461 match SearchQuery::regex(text, self.whole_word, self.case_sensitive) {
462 Ok(query) => Some(query),
463 Err(_) => {
464 self.query_contains_error = true;
465 cx.notify();
466 None
467 }
468 }
469 } else {
470 Some(SearchQuery::text(
471 text,
472 self.whole_word,
473 self.case_sensitive,
474 ))
475 }
476 }
477
478 fn toggle_search_option(
479 &mut self,
480 ToggleSearchOption(option): &ToggleSearchOption,
481 cx: &mut ViewContext<Self>,
482 ) {
483 let value = match option {
484 SearchOption::WholeWord => &mut self.whole_word,
485 SearchOption::CaseSensitive => &mut self.case_sensitive,
486 SearchOption::Regex => &mut self.regex,
487 };
488 *value = !*value;
489 self.search(&Search, cx);
490 cx.notify();
491 }
492
493 fn select_match(&mut self, &SelectMatch(direction): &SelectMatch, cx: &mut ViewContext<Self>) {
494 if let Some(index) = self.active_match_index {
495 let model = self.model.read(cx);
496 let results_editor = self.results_editor.read(cx);
497 let new_index = match_index_for_direction(
498 &model.match_ranges,
499 &results_editor.newest_anchor_selection().head(),
500 index,
501 direction,
502 &results_editor.buffer().read(cx).read(cx),
503 );
504 let range_to_select = model.match_ranges[new_index].clone();
505 self.results_editor.update(cx, |editor, cx| {
506 editor.select_ranges([range_to_select], Some(Autoscroll::Fit), cx);
507 });
508 }
509 }
510
511 fn toggle_focus(&mut self, _: &ToggleFocus, cx: &mut ViewContext<Self>) {
512 if self.query_editor.is_focused(cx) {
513 if !self.model.read(cx).match_ranges.is_empty() {
514 self.focus_results_editor(cx);
515 }
516 } else {
517 self.focus_query_editor(cx);
518 }
519 }
520
521 fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
522 if self.query_editor.is_focused(cx) {
523 if !self.model.read(cx).match_ranges.is_empty() {
524 self.focus_results_editor(cx);
525 }
526 } else {
527 cx.propagate_action()
528 }
529 }
530
531 fn focus_query_editor(&self, cx: &mut ViewContext<Self>) {
532 self.query_editor.update(cx, |query_editor, cx| {
533 query_editor.select_all(&SelectAll, cx);
534 });
535 cx.focus(&self.query_editor);
536 }
537
538 fn focus_results_editor(&self, cx: &mut ViewContext<Self>) {
539 self.query_editor.update(cx, |query_editor, cx| {
540 let cursor = query_editor.newest_anchor_selection().head();
541 query_editor.select_ranges([cursor.clone()..cursor], None, cx);
542 });
543 cx.focus(&self.results_editor);
544 }
545
546 fn model_changed(&mut self, reset_selections: bool, cx: &mut ViewContext<Self>) {
547 let match_ranges = self.model.read(cx).match_ranges.clone();
548 if match_ranges.is_empty() {
549 self.active_match_index = None;
550 } else {
551 self.results_editor.update(cx, |editor, cx| {
552 if reset_selections {
553 editor.select_ranges(match_ranges.first().cloned(), Some(Autoscroll::Fit), cx);
554 }
555 let theme = &cx.app_state::<Settings>().theme.search;
556 editor.highlight_background::<Self>(match_ranges, theme.match_background, cx);
557 });
558 if self.query_editor.is_focused(cx) {
559 self.focus_results_editor(cx);
560 }
561 }
562
563 cx.emit(ViewEvent::UpdateTab);
564 cx.notify();
565 }
566
567 fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
568 let results_editor = self.results_editor.read(cx);
569 let new_index = active_match_index(
570 &self.model.read(cx).match_ranges,
571 &results_editor.newest_anchor_selection().head(),
572 &results_editor.buffer().read(cx).read(cx),
573 );
574 if self.active_match_index != new_index {
575 self.active_match_index = new_index;
576 cx.notify();
577 }
578 }
579
580 fn render_query_editor(&self, cx: &mut RenderContext<Self>) -> ElementBox {
581 let theme = cx.app_state::<Settings>().theme.clone();
582 let editor_container = if self.query_contains_error {
583 theme.search.invalid_editor
584 } else {
585 theme.search.editor.input.container
586 };
587 Flex::row()
588 .with_child(
589 ChildView::new(&self.query_editor)
590 .contained()
591 .with_style(editor_container)
592 .aligned()
593 .constrained()
594 .with_max_width(theme.search.editor.max_width)
595 .boxed(),
596 )
597 .with_child(
598 Flex::row()
599 .with_child(self.render_option_button("Case", SearchOption::CaseSensitive, cx))
600 .with_child(self.render_option_button("Word", SearchOption::WholeWord, cx))
601 .with_child(self.render_option_button("Regex", SearchOption::Regex, cx))
602 .contained()
603 .with_style(theme.search.option_button_group)
604 .aligned()
605 .boxed(),
606 )
607 .with_children({
608 self.active_match_index.into_iter().flat_map(|match_ix| {
609 [
610 Flex::row()
611 .with_child(self.render_nav_button("<", Direction::Prev, cx))
612 .with_child(self.render_nav_button(">", Direction::Next, cx))
613 .aligned()
614 .boxed(),
615 Label::new(
616 format!(
617 "{}/{}",
618 match_ix + 1,
619 self.model.read(cx).match_ranges.len()
620 ),
621 theme.search.match_index.text.clone(),
622 )
623 .contained()
624 .with_style(theme.search.match_index.container)
625 .aligned()
626 .boxed(),
627 ]
628 })
629 })
630 .contained()
631 .with_style(theme.search.container)
632 .constrained()
633 .with_height(theme.workspace.toolbar.height)
634 .named("project search")
635 }
636
637 fn render_option_button(
638 &self,
639 icon: &str,
640 option: SearchOption,
641 cx: &mut RenderContext<Self>,
642 ) -> ElementBox {
643 let is_active = self.is_option_enabled(option);
644 MouseEventHandler::new::<Self, _, _>(option as usize, cx, |state, cx| {
645 let theme = &cx.app_state::<Settings>().theme.search;
646 let style = match (is_active, state.hovered) {
647 (false, false) => &theme.option_button,
648 (false, true) => &theme.hovered_option_button,
649 (true, false) => &theme.active_option_button,
650 (true, true) => &theme.active_hovered_option_button,
651 };
652 Label::new(icon.to_string(), style.text.clone())
653 .contained()
654 .with_style(style.container)
655 .boxed()
656 })
657 .on_click(move |cx| cx.dispatch_action(ToggleSearchOption(option)))
658 .with_cursor_style(CursorStyle::PointingHand)
659 .boxed()
660 }
661
662 fn is_option_enabled(&self, option: SearchOption) -> bool {
663 match option {
664 SearchOption::WholeWord => self.whole_word,
665 SearchOption::CaseSensitive => self.case_sensitive,
666 SearchOption::Regex => self.regex,
667 }
668 }
669
670 fn render_nav_button(
671 &self,
672 icon: &str,
673 direction: Direction,
674 cx: &mut RenderContext<Self>,
675 ) -> ElementBox {
676 enum NavButton {}
677 MouseEventHandler::new::<NavButton, _, _>(direction as usize, cx, |state, cx| {
678 let theme = &cx.app_state::<Settings>().theme.search;
679 let style = if state.hovered {
680 &theme.hovered_option_button
681 } else {
682 &theme.option_button
683 };
684 Label::new(icon.to_string(), style.text.clone())
685 .contained()
686 .with_style(style.container)
687 .boxed()
688 })
689 .on_click(move |cx| cx.dispatch_action(SelectMatch(direction)))
690 .with_cursor_style(CursorStyle::PointingHand)
691 .boxed()
692 }
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698 use editor::DisplayPoint;
699 use gpui::{color::Color, TestAppContext};
700 use project::FakeFs;
701 use serde_json::json;
702 use std::sync::Arc;
703
704 #[gpui::test]
705 async fn test_project_search(cx: &mut TestAppContext) {
706 let fonts = cx.font_cache();
707 let mut theme = gpui::fonts::with_font_cache(fonts.clone(), || theme::Theme::default());
708 theme.search.match_background = Color::red();
709 let settings = Settings::new("Courier", &fonts, Arc::new(theme)).unwrap();
710 cx.update(|cx| cx.add_app_state(settings));
711
712 let fs = FakeFs::new(cx.background());
713 fs.insert_tree(
714 "/dir",
715 json!({
716 "one.rs": "const ONE: usize = 1;",
717 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
718 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
719 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
720 }),
721 )
722 .await;
723 let project = Project::test(fs.clone(), cx);
724 let (tree, _) = project
725 .update(cx, |project, cx| {
726 project.find_or_create_local_worktree("/dir", true, cx)
727 })
728 .await
729 .unwrap();
730 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
731 .await;
732
733 let search = cx.add_model(|cx| ProjectSearch::new(project, cx));
734 let search_view = cx.add_view(Default::default(), |cx| {
735 ProjectSearchView::new(search.clone(), None, cx)
736 });
737
738 search_view.update(cx, |search_view, cx| {
739 search_view
740 .query_editor
741 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
742 search_view.search(&Search, cx);
743 });
744 search_view.next_notification(&cx).await;
745 search_view.update(cx, |search_view, cx| {
746 assert_eq!(
747 search_view
748 .results_editor
749 .update(cx, |editor, cx| editor.display_text(cx)),
750 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
751 );
752 assert_eq!(
753 search_view
754 .results_editor
755 .update(cx, |editor, cx| editor.all_background_highlights(cx)),
756 &[
757 (
758 DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
759 Color::red()
760 ),
761 (
762 DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
763 Color::red()
764 ),
765 (
766 DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
767 Color::red()
768 )
769 ]
770 );
771 assert_eq!(search_view.active_match_index, Some(0));
772 assert_eq!(
773 search_view
774 .results_editor
775 .update(cx, |editor, cx| editor.selected_display_ranges(cx)),
776 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
777 );
778
779 search_view.select_match(&SelectMatch(Direction::Next), cx);
780 });
781
782 search_view.update(cx, |search_view, cx| {
783 assert_eq!(search_view.active_match_index, Some(1));
784 assert_eq!(
785 search_view
786 .results_editor
787 .update(cx, |editor, cx| editor.selected_display_ranges(cx)),
788 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
789 );
790 search_view.select_match(&SelectMatch(Direction::Next), cx);
791 });
792
793 search_view.update(cx, |search_view, cx| {
794 assert_eq!(search_view.active_match_index, Some(2));
795 assert_eq!(
796 search_view
797 .results_editor
798 .update(cx, |editor, cx| editor.selected_display_ranges(cx)),
799 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
800 );
801 search_view.select_match(&SelectMatch(Direction::Next), cx);
802 });
803
804 search_view.update(cx, |search_view, cx| {
805 assert_eq!(search_view.active_match_index, Some(0));
806 assert_eq!(
807 search_view
808 .results_editor
809 .update(cx, |editor, cx| editor.selected_display_ranges(cx)),
810 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
811 );
812 search_view.select_match(&SelectMatch(Direction::Prev), cx);
813 });
814
815 search_view.update(cx, |search_view, cx| {
816 assert_eq!(search_view.active_match_index, Some(2));
817 assert_eq!(
818 search_view
819 .results_editor
820 .update(cx, |editor, cx| editor.selected_display_ranges(cx)),
821 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
822 );
823 search_view.select_match(&SelectMatch(Direction::Prev), cx);
824 });
825
826 search_view.update(cx, |search_view, cx| {
827 assert_eq!(search_view.active_match_index, Some(1));
828 assert_eq!(
829 search_view
830 .results_editor
831 .update(cx, |editor, cx| editor.selected_display_ranges(cx)),
832 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
833 );
834 });
835 }
836}