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