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