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