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