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 editor.highlight_background::<Self>(
477 match_ranges,
478 |theme| theme.search.match_background,
479 cx,
480 );
481 });
482 if self.query_editor.is_focused(cx) {
483 self.focus_results_editor(cx);
484 }
485 }
486
487 cx.emit(ViewEvent::UpdateTab);
488 cx.notify();
489 }
490
491 fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
492 let results_editor = self.results_editor.read(cx);
493 let new_index = active_match_index(
494 &self.model.read(cx).match_ranges,
495 &results_editor.newest_anchor_selection().head(),
496 &results_editor.buffer().read(cx).read(cx),
497 );
498 if self.active_match_index != new_index {
499 self.active_match_index = new_index;
500 cx.notify();
501 }
502 }
503
504 pub fn has_matches(&self) -> bool {
505 self.active_match_index.is_some()
506 }
507}
508
509impl ProjectSearchBar {
510 pub fn new() -> Self {
511 Self {
512 active_project_search: Default::default(),
513 subscription: Default::default(),
514 }
515 }
516
517 fn search(&mut self, _: &Search, cx: &mut ViewContext<Self>) {
518 if let Some(search_view) = self.active_project_search.as_ref() {
519 search_view.update(cx, |search_view, cx| search_view.search(cx));
520 }
521 }
522
523 fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
524 if let Some(search_view) = workspace
525 .active_item(cx)
526 .and_then(|item| item.downcast::<ProjectSearchView>())
527 {
528 let new_query = search_view.update(cx, |search_view, cx| {
529 let new_query = search_view.build_search_query(cx);
530 if new_query.is_some() {
531 if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
532 search_view.query_editor.update(cx, |editor, cx| {
533 editor.set_text(old_query.as_str(), cx);
534 });
535 search_view.regex = old_query.is_regex();
536 search_view.whole_word = old_query.whole_word();
537 search_view.case_sensitive = old_query.case_sensitive();
538 }
539 }
540 new_query
541 });
542 if let Some(new_query) = new_query {
543 let model = cx.add_model(|cx| {
544 let mut model = ProjectSearch::new(workspace.project().clone(), cx);
545 model.search(new_query, cx);
546 model
547 });
548 workspace.add_item(
549 Box::new(cx.add_view(|cx| ProjectSearchView::new(model, cx))),
550 cx,
551 );
552 }
553 }
554 }
555
556 fn select_next_match(pane: &mut Pane, _: &SelectNextMatch, cx: &mut ViewContext<Pane>) {
557 if let Some(search_view) = pane
558 .active_item()
559 .and_then(|item| item.downcast::<ProjectSearchView>())
560 {
561 search_view.update(cx, |view, cx| view.select_match(Direction::Next, cx));
562 } else {
563 cx.propagate_action();
564 }
565 }
566
567 fn select_prev_match(pane: &mut Pane, _: &SelectPrevMatch, cx: &mut ViewContext<Pane>) {
568 if let Some(search_view) = pane
569 .active_item()
570 .and_then(|item| item.downcast::<ProjectSearchView>())
571 {
572 search_view.update(cx, |view, cx| view.select_match(Direction::Prev, cx));
573 } else {
574 cx.propagate_action();
575 }
576 }
577
578 fn toggle_focus(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext<Pane>) {
579 if let Some(search_view) = pane
580 .active_item()
581 .and_then(|item| item.downcast::<ProjectSearchView>())
582 {
583 search_view.update(cx, |search_view, cx| {
584 if search_view.query_editor.is_focused(cx) {
585 if !search_view.model.read(cx).match_ranges.is_empty() {
586 search_view.focus_results_editor(cx);
587 }
588 } else {
589 search_view.focus_query_editor(cx);
590 }
591 });
592 } else {
593 cx.propagate_action();
594 }
595 }
596
597 fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
598 if let Some(search_view) = self.active_project_search.as_ref() {
599 search_view.update(cx, |search_view, cx| {
600 if search_view.query_editor.is_focused(cx) {
601 if !search_view.model.read(cx).match_ranges.is_empty() {
602 search_view.focus_results_editor(cx);
603 }
604 } else {
605 cx.propagate_action();
606 }
607 });
608 } else {
609 cx.propagate_action();
610 }
611 }
612
613 fn toggle_search_option(
614 &mut self,
615 ToggleSearchOption(option): &ToggleSearchOption,
616 cx: &mut ViewContext<Self>,
617 ) {
618 if let Some(search_view) = self.active_project_search.as_ref() {
619 search_view.update(cx, |search_view, cx| {
620 let value = match option {
621 SearchOption::WholeWord => &mut search_view.whole_word,
622 SearchOption::CaseSensitive => &mut search_view.case_sensitive,
623 SearchOption::Regex => &mut search_view.regex,
624 };
625 *value = !*value;
626 search_view.search(cx);
627 });
628 cx.notify();
629 }
630 }
631
632 fn render_nav_button(
633 &self,
634 icon: &str,
635 direction: Direction,
636 cx: &mut RenderContext<Self>,
637 ) -> ElementBox {
638 enum NavButton {}
639 MouseEventHandler::new::<NavButton, _, _>(direction as usize, cx, |state, cx| {
640 let theme = &cx.global::<Settings>().theme.search;
641 let style = if state.hovered {
642 &theme.hovered_option_button
643 } else {
644 &theme.option_button
645 };
646 Label::new(icon.to_string(), style.text.clone())
647 .contained()
648 .with_style(style.container)
649 .boxed()
650 })
651 .on_click(move |cx| match direction {
652 Direction::Prev => cx.dispatch_action(SelectPrevMatch),
653 Direction::Next => cx.dispatch_action(SelectNextMatch),
654 })
655 .with_cursor_style(CursorStyle::PointingHand)
656 .boxed()
657 }
658
659 fn render_option_button(
660 &self,
661 icon: &str,
662 option: SearchOption,
663 cx: &mut RenderContext<Self>,
664 ) -> ElementBox {
665 let is_active = self.is_option_enabled(option, cx);
666 MouseEventHandler::new::<ProjectSearchBar, _, _>(option as usize, cx, |state, cx| {
667 let theme = &cx.global::<Settings>().theme.search;
668 let style = match (is_active, state.hovered) {
669 (false, false) => &theme.option_button,
670 (false, true) => &theme.hovered_option_button,
671 (true, false) => &theme.active_option_button,
672 (true, true) => &theme.active_hovered_option_button,
673 };
674 Label::new(icon.to_string(), style.text.clone())
675 .contained()
676 .with_style(style.container)
677 .boxed()
678 })
679 .on_click(move |cx| cx.dispatch_action(ToggleSearchOption(option)))
680 .with_cursor_style(CursorStyle::PointingHand)
681 .boxed()
682 }
683
684 fn is_option_enabled(&self, option: SearchOption, cx: &AppContext) -> bool {
685 if let Some(search) = self.active_project_search.as_ref() {
686 let search = search.read(cx);
687 match option {
688 SearchOption::WholeWord => search.whole_word,
689 SearchOption::CaseSensitive => search.case_sensitive,
690 SearchOption::Regex => search.regex,
691 }
692 } else {
693 false
694 }
695 }
696}
697
698impl Entity for ProjectSearchBar {
699 type Event = ();
700}
701
702impl View for ProjectSearchBar {
703 fn ui_name() -> &'static str {
704 "ProjectSearchBar"
705 }
706
707 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
708 if let Some(search) = self.active_project_search.as_ref() {
709 let search = search.read(cx);
710 let theme = cx.global::<Settings>().theme.clone();
711 let editor_container = if search.query_contains_error {
712 theme.search.invalid_editor
713 } else {
714 theme.search.editor.input.container
715 };
716 Flex::row()
717 .with_child(
718 Flex::row()
719 .with_child(
720 ChildView::new(&search.query_editor)
721 .aligned()
722 .left()
723 .flex(1., true)
724 .boxed(),
725 )
726 .with_children(search.active_match_index.map(|match_ix| {
727 Label::new(
728 format!(
729 "{}/{}",
730 match_ix + 1,
731 search.model.read(cx).match_ranges.len()
732 ),
733 theme.search.match_index.text.clone(),
734 )
735 .contained()
736 .with_style(theme.search.match_index.container)
737 .aligned()
738 .boxed()
739 }))
740 .contained()
741 .with_style(editor_container)
742 .aligned()
743 .constrained()
744 .with_min_width(theme.search.editor.min_width)
745 .with_max_width(theme.search.editor.max_width)
746 .flex(1., false)
747 .boxed(),
748 )
749 .with_child(
750 Flex::row()
751 .with_child(self.render_nav_button("<", Direction::Prev, cx))
752 .with_child(self.render_nav_button(">", Direction::Next, cx))
753 .aligned()
754 .boxed(),
755 )
756 .with_child(
757 Flex::row()
758 .with_child(self.render_option_button(
759 "Case",
760 SearchOption::CaseSensitive,
761 cx,
762 ))
763 .with_child(self.render_option_button("Word", SearchOption::WholeWord, cx))
764 .with_child(self.render_option_button("Regex", SearchOption::Regex, cx))
765 .contained()
766 .with_style(theme.search.option_button_group)
767 .aligned()
768 .boxed(),
769 )
770 .contained()
771 .with_style(theme.search.container)
772 .aligned()
773 .left()
774 .named("project search")
775 } else {
776 Empty::new().boxed()
777 }
778 }
779}
780
781impl ToolbarItemView for ProjectSearchBar {
782 fn set_active_pane_item(
783 &mut self,
784 active_pane_item: Option<&dyn workspace::ItemHandle>,
785 cx: &mut ViewContext<Self>,
786 ) -> ToolbarItemLocation {
787 cx.notify();
788 self.subscription = None;
789 self.active_project_search = None;
790 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
791 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
792 self.active_project_search = Some(search);
793 ToolbarItemLocation::PrimaryLeft {
794 flex: Some((1., false)),
795 }
796 } else {
797 ToolbarItemLocation::Hidden
798 }
799 }
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805 use editor::DisplayPoint;
806 use gpui::{color::Color, TestAppContext};
807 use project::FakeFs;
808 use serde_json::json;
809 use std::sync::Arc;
810
811 #[gpui::test]
812 async fn test_project_search(cx: &mut TestAppContext) {
813 let fonts = cx.font_cache();
814 let mut theme = gpui::fonts::with_font_cache(fonts.clone(), || theme::Theme::default());
815 theme.search.match_background = Color::red();
816 let settings = Settings::new("Courier", &fonts, Arc::new(theme)).unwrap();
817 cx.update(|cx| cx.set_global(settings));
818
819 let fs = FakeFs::new(cx.background());
820 fs.insert_tree(
821 "/dir",
822 json!({
823 "one.rs": "const ONE: usize = 1;",
824 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
825 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
826 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
827 }),
828 )
829 .await;
830 let project = Project::test(fs.clone(), cx);
831 let (tree, _) = project
832 .update(cx, |project, cx| {
833 project.find_or_create_local_worktree("/dir", true, cx)
834 })
835 .await
836 .unwrap();
837 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
838 .await;
839
840 let search = cx.add_model(|cx| ProjectSearch::new(project, cx));
841 let search_view = cx.add_view(Default::default(), |cx| {
842 ProjectSearchView::new(search.clone(), cx)
843 });
844
845 search_view.update(cx, |search_view, cx| {
846 search_view
847 .query_editor
848 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
849 search_view.search(cx);
850 });
851 search_view.next_notification(&cx).await;
852 search_view.update(cx, |search_view, cx| {
853 assert_eq!(
854 search_view
855 .results_editor
856 .update(cx, |editor, cx| editor.display_text(cx)),
857 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
858 );
859 assert_eq!(
860 search_view
861 .results_editor
862 .update(cx, |editor, cx| editor.all_background_highlights(cx)),
863 &[
864 (
865 DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
866 Color::red()
867 ),
868 (
869 DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
870 Color::red()
871 ),
872 (
873 DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
874 Color::red()
875 )
876 ]
877 );
878 assert_eq!(search_view.active_match_index, Some(0));
879 assert_eq!(
880 search_view
881 .results_editor
882 .update(cx, |editor, cx| editor.selected_display_ranges(cx)),
883 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
884 );
885
886 search_view.select_match(Direction::Next, cx);
887 });
888
889 search_view.update(cx, |search_view, cx| {
890 assert_eq!(search_view.active_match_index, Some(1));
891 assert_eq!(
892 search_view
893 .results_editor
894 .update(cx, |editor, cx| editor.selected_display_ranges(cx)),
895 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
896 );
897 search_view.select_match(Direction::Next, cx);
898 });
899
900 search_view.update(cx, |search_view, cx| {
901 assert_eq!(search_view.active_match_index, Some(2));
902 assert_eq!(
903 search_view
904 .results_editor
905 .update(cx, |editor, cx| editor.selected_display_ranges(cx)),
906 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
907 );
908 search_view.select_match(Direction::Next, cx);
909 });
910
911 search_view.update(cx, |search_view, cx| {
912 assert_eq!(search_view.active_match_index, Some(0));
913 assert_eq!(
914 search_view
915 .results_editor
916 .update(cx, |editor, cx| editor.selected_display_ranges(cx)),
917 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
918 );
919 search_view.select_match(Direction::Prev, cx);
920 });
921
922 search_view.update(cx, |search_view, cx| {
923 assert_eq!(search_view.active_match_index, Some(2));
924 assert_eq!(
925 search_view
926 .results_editor
927 .update(cx, |editor, cx| editor.selected_display_ranges(cx)),
928 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
929 );
930 search_view.select_match(Direction::Prev, cx);
931 });
932
933 search_view.update(cx, |search_view, cx| {
934 assert_eq!(search_view.active_match_index, Some(1));
935 assert_eq!(
936 search_view
937 .results_editor
938 .update(cx, |editor, cx| editor.selected_display_ranges(cx)),
939 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
940 );
941 });
942 }
943}