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