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