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