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