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, 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 match_ranges = self.model.read(cx).match_ranges.clone();
490 let new_index = self.results_editor.update(cx, |editor, cx| {
491 editor.match_index_for_direction(&match_ranges, index, direction, cx)
492 });
493
494 let range_to_select = match_ranges[new_index].clone();
495 self.results_editor.update(cx, |editor, cx| {
496 editor.unfold_ranges([range_to_select.clone()], false, cx);
497 editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
498 s.select_ranges([range_to_select])
499 });
500 });
501 }
502 }
503
504 fn focus_query_editor(&self, cx: &mut ViewContext<Self>) {
505 self.query_editor.update(cx, |query_editor, cx| {
506 query_editor.select_all(&SelectAll, cx);
507 });
508 cx.focus(&self.query_editor);
509 }
510
511 fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
512 self.query_editor
513 .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
514 }
515
516 fn focus_results_editor(&self, cx: &mut ViewContext<Self>) {
517 self.query_editor.update(cx, |query_editor, cx| {
518 let cursor = query_editor.selections.newest_anchor().head();
519 query_editor.change_selections(None, cx, |s| s.select_ranges([cursor.clone()..cursor]));
520 });
521 cx.focus(&self.results_editor);
522 }
523
524 fn model_changed(&mut self, reset_selections: bool, cx: &mut ViewContext<Self>) {
525 let match_ranges = self.model.read(cx).match_ranges.clone();
526 if match_ranges.is_empty() {
527 self.active_match_index = None;
528 } else {
529 self.results_editor.update(cx, |editor, cx| {
530 if reset_selections {
531 editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
532 s.select_ranges(match_ranges.first().cloned())
533 });
534 }
535 editor.highlight_background::<Self>(
536 match_ranges,
537 |theme| theme.search.match_background,
538 cx,
539 );
540 });
541 if self.query_editor.is_focused(cx) {
542 self.focus_results_editor(cx);
543 }
544 }
545
546 cx.emit(ViewEvent::UpdateTab);
547 cx.notify();
548 }
549
550 fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
551 let results_editor = self.results_editor.read(cx);
552 let new_index = active_match_index(
553 &self.model.read(cx).match_ranges,
554 &results_editor.selections.newest_anchor().head(),
555 &results_editor.buffer().read(cx).snapshot(cx),
556 );
557 if self.active_match_index != new_index {
558 self.active_match_index = new_index;
559 cx.notify();
560 }
561 }
562
563 pub fn has_matches(&self) -> bool {
564 self.active_match_index.is_some()
565 }
566}
567
568impl Default for ProjectSearchBar {
569 fn default() -> Self {
570 Self::new()
571 }
572}
573
574impl ProjectSearchBar {
575 pub fn new() -> Self {
576 Self {
577 active_project_search: Default::default(),
578 subscription: Default::default(),
579 }
580 }
581
582 fn search(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
583 if let Some(search_view) = self.active_project_search.as_ref() {
584 search_view.update(cx, |search_view, cx| search_view.search(cx));
585 }
586 }
587
588 fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
589 if let Some(search_view) = workspace
590 .active_item(cx)
591 .and_then(|item| item.downcast::<ProjectSearchView>())
592 {
593 let new_query = search_view.update(cx, |search_view, cx| {
594 let new_query = search_view.build_search_query(cx);
595 if new_query.is_some() {
596 if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
597 search_view.query_editor.update(cx, |editor, cx| {
598 editor.set_text(old_query.as_str(), cx);
599 });
600 search_view.regex = old_query.is_regex();
601 search_view.whole_word = old_query.whole_word();
602 search_view.case_sensitive = old_query.case_sensitive();
603 }
604 }
605 new_query
606 });
607 if let Some(new_query) = new_query {
608 let model = cx.add_model(|cx| {
609 let mut model = ProjectSearch::new(workspace.project().clone(), cx);
610 model.search(new_query, cx);
611 model
612 });
613 workspace.add_item(
614 Box::new(cx.add_view(|cx| ProjectSearchView::new(model, cx))),
615 cx,
616 );
617 }
618 }
619 }
620
621 fn select_next_match(pane: &mut Pane, _: &SelectNextMatch, cx: &mut ViewContext<Pane>) {
622 if let Some(search_view) = pane
623 .active_item()
624 .and_then(|item| item.downcast::<ProjectSearchView>())
625 {
626 search_view.update(cx, |view, cx| view.select_match(Direction::Next, cx));
627 } else {
628 cx.propagate_action();
629 }
630 }
631
632 fn select_prev_match(pane: &mut Pane, _: &SelectPrevMatch, cx: &mut ViewContext<Pane>) {
633 if let Some(search_view) = pane
634 .active_item()
635 .and_then(|item| item.downcast::<ProjectSearchView>())
636 {
637 search_view.update(cx, |view, cx| view.select_match(Direction::Prev, cx));
638 } else {
639 cx.propagate_action();
640 }
641 }
642
643 fn toggle_focus(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext<Pane>) {
644 if let Some(search_view) = pane
645 .active_item()
646 .and_then(|item| item.downcast::<ProjectSearchView>())
647 {
648 search_view.update(cx, |search_view, cx| {
649 if search_view.query_editor.is_focused(cx) {
650 if !search_view.model.read(cx).match_ranges.is_empty() {
651 search_view.focus_results_editor(cx);
652 }
653 } else {
654 search_view.focus_query_editor(cx);
655 }
656 });
657 } else {
658 cx.propagate_action();
659 }
660 }
661
662 fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
663 if let Some(search_view) = self.active_project_search.as_ref() {
664 search_view.update(cx, |search_view, cx| {
665 if search_view.query_editor.is_focused(cx) {
666 if !search_view.model.read(cx).match_ranges.is_empty() {
667 search_view.focus_results_editor(cx);
668 }
669 } else {
670 cx.propagate_action();
671 }
672 });
673 } else {
674 cx.propagate_action();
675 }
676 }
677
678 fn toggle_search_option(&mut self, option: SearchOption, cx: &mut ViewContext<Self>) -> bool {
679 if let Some(search_view) = self.active_project_search.as_ref() {
680 search_view.update(cx, |search_view, cx| {
681 let value = match option {
682 SearchOption::WholeWord => &mut search_view.whole_word,
683 SearchOption::CaseSensitive => &mut search_view.case_sensitive,
684 SearchOption::Regex => &mut search_view.regex,
685 };
686 *value = !*value;
687 search_view.search(cx);
688 });
689 cx.notify();
690 true
691 } else {
692 false
693 }
694 }
695
696 fn render_nav_button(
697 &self,
698 icon: &str,
699 direction: Direction,
700 cx: &mut RenderContext<Self>,
701 ) -> ElementBox {
702 let action: Box<dyn Action>;
703 let tooltip;
704 match direction {
705 Direction::Prev => {
706 action = Box::new(SelectPrevMatch);
707 tooltip = "Select Previous Match";
708 }
709 Direction::Next => {
710 action = Box::new(SelectNextMatch);
711 tooltip = "Select Next Match";
712 }
713 };
714 let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
715
716 enum NavButton {}
717 MouseEventHandler::new::<NavButton, _, _>(direction as usize, cx, |state, cx| {
718 let style = &cx
719 .global::<Settings>()
720 .theme
721 .search
722 .option_button
723 .style_for(state, false);
724 Label::new(icon.to_string(), style.text.clone())
725 .contained()
726 .with_style(style.container)
727 .boxed()
728 })
729 .on_click(MouseButton::Left, {
730 let action = action.boxed_clone();
731 move |_, cx| cx.dispatch_any_action(action.boxed_clone())
732 })
733 .with_cursor_style(CursorStyle::PointingHand)
734 .with_tooltip::<NavButton, _>(
735 direction as usize,
736 tooltip.to_string(),
737 Some(action),
738 tooltip_style,
739 cx,
740 )
741 .boxed()
742 }
743
744 fn render_option_button(
745 &self,
746 icon: &str,
747 option: SearchOption,
748 cx: &mut RenderContext<Self>,
749 ) -> ElementBox {
750 let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
751 let is_active = self.is_option_enabled(option, cx);
752 MouseEventHandler::new::<Self, _, _>(option as usize, cx, |state, cx| {
753 let style = &cx
754 .global::<Settings>()
755 .theme
756 .search
757 .option_button
758 .style_for(state, is_active);
759 Label::new(icon.to_string(), style.text.clone())
760 .contained()
761 .with_style(style.container)
762 .boxed()
763 })
764 .on_click(MouseButton::Left, move |_, cx| {
765 cx.dispatch_any_action(option.to_toggle_action())
766 })
767 .with_cursor_style(CursorStyle::PointingHand)
768 .with_tooltip::<Self, _>(
769 option as usize,
770 format!("Toggle {}", option.label()),
771 Some(option.to_toggle_action()),
772 tooltip_style,
773 cx,
774 )
775 .boxed()
776 }
777
778 fn is_option_enabled(&self, option: SearchOption, cx: &AppContext) -> bool {
779 if let Some(search) = self.active_project_search.as_ref() {
780 let search = search.read(cx);
781 match option {
782 SearchOption::WholeWord => search.whole_word,
783 SearchOption::CaseSensitive => search.case_sensitive,
784 SearchOption::Regex => search.regex,
785 }
786 } else {
787 false
788 }
789 }
790}
791
792impl Entity for ProjectSearchBar {
793 type Event = ();
794}
795
796impl View for ProjectSearchBar {
797 fn ui_name() -> &'static str {
798 "ProjectSearchBar"
799 }
800
801 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
802 if let Some(search) = self.active_project_search.as_ref() {
803 let search = search.read(cx);
804 let theme = cx.global::<Settings>().theme.clone();
805 let editor_container = if search.query_contains_error {
806 theme.search.invalid_editor
807 } else {
808 theme.search.editor.input.container
809 };
810 Flex::row()
811 .with_child(
812 Flex::row()
813 .with_child(
814 ChildView::new(&search.query_editor)
815 .aligned()
816 .left()
817 .flex(1., true)
818 .boxed(),
819 )
820 .with_children(search.active_match_index.map(|match_ix| {
821 Label::new(
822 format!(
823 "{}/{}",
824 match_ix + 1,
825 search.model.read(cx).match_ranges.len()
826 ),
827 theme.search.match_index.text.clone(),
828 )
829 .contained()
830 .with_style(theme.search.match_index.container)
831 .aligned()
832 .boxed()
833 }))
834 .contained()
835 .with_style(editor_container)
836 .aligned()
837 .constrained()
838 .with_min_width(theme.search.editor.min_width)
839 .with_max_width(theme.search.editor.max_width)
840 .flex(1., false)
841 .boxed(),
842 )
843 .with_child(
844 Flex::row()
845 .with_child(self.render_nav_button("<", Direction::Prev, cx))
846 .with_child(self.render_nav_button(">", Direction::Next, cx))
847 .aligned()
848 .boxed(),
849 )
850 .with_child(
851 Flex::row()
852 .with_child(self.render_option_button(
853 "Case",
854 SearchOption::CaseSensitive,
855 cx,
856 ))
857 .with_child(self.render_option_button("Word", SearchOption::WholeWord, cx))
858 .with_child(self.render_option_button("Regex", SearchOption::Regex, cx))
859 .contained()
860 .with_style(theme.search.option_button_group)
861 .aligned()
862 .boxed(),
863 )
864 .contained()
865 .with_style(theme.search.container)
866 .aligned()
867 .left()
868 .named("project search")
869 } else {
870 Empty::new().boxed()
871 }
872 }
873}
874
875impl ToolbarItemView for ProjectSearchBar {
876 fn set_active_pane_item(
877 &mut self,
878 active_pane_item: Option<&dyn workspace::ItemHandle>,
879 cx: &mut ViewContext<Self>,
880 ) -> ToolbarItemLocation {
881 cx.notify();
882 self.subscription = None;
883 self.active_project_search = None;
884 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
885 let query_editor = search.read(cx).query_editor.clone();
886 cx.reparent(query_editor);
887 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
888 self.active_project_search = Some(search);
889 ToolbarItemLocation::PrimaryLeft {
890 flex: Some((1., false)),
891 }
892 } else {
893 ToolbarItemLocation::Hidden
894 }
895 }
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901 use editor::DisplayPoint;
902 use gpui::{color::Color, TestAppContext};
903 use project::FakeFs;
904 use serde_json::json;
905 use std::sync::Arc;
906
907 #[gpui::test]
908 async fn test_project_search(cx: &mut TestAppContext) {
909 let fonts = cx.font_cache();
910 let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default);
911 theme.search.match_background = Color::red();
912 cx.update(|cx| {
913 let mut settings = Settings::test(cx);
914 settings.theme = Arc::new(theme);
915 cx.set_global(settings);
916 cx.set_global(ActiveSearches::default());
917 });
918
919 let fs = FakeFs::new(cx.background());
920 fs.insert_tree(
921 "/dir",
922 json!({
923 "one.rs": "const ONE: usize = 1;",
924 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
925 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
926 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
927 }),
928 )
929 .await;
930 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
931 let search = cx.add_model(|cx| ProjectSearch::new(project, cx));
932 let (_, search_view) = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx));
933
934 search_view.update(cx, |search_view, cx| {
935 search_view
936 .query_editor
937 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
938 search_view.search(cx);
939 });
940 search_view.next_notification(cx).await;
941 search_view.update(cx, |search_view, cx| {
942 assert_eq!(
943 search_view
944 .results_editor
945 .update(cx, |editor, cx| editor.display_text(cx)),
946 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
947 );
948 assert_eq!(
949 search_view
950 .results_editor
951 .update(cx, |editor, cx| editor.all_background_highlights(cx)),
952 &[
953 (
954 DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
955 Color::red()
956 ),
957 (
958 DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
959 Color::red()
960 ),
961 (
962 DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
963 Color::red()
964 )
965 ]
966 );
967 assert_eq!(search_view.active_match_index, Some(0));
968 assert_eq!(
969 search_view
970 .results_editor
971 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
972 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
973 );
974
975 search_view.select_match(Direction::Next, cx);
976 });
977
978 search_view.update(cx, |search_view, cx| {
979 assert_eq!(search_view.active_match_index, Some(1));
980 assert_eq!(
981 search_view
982 .results_editor
983 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
984 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
985 );
986 search_view.select_match(Direction::Next, cx);
987 });
988
989 search_view.update(cx, |search_view, cx| {
990 assert_eq!(search_view.active_match_index, Some(2));
991 assert_eq!(
992 search_view
993 .results_editor
994 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
995 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
996 );
997 search_view.select_match(Direction::Next, cx);
998 });
999
1000 search_view.update(cx, |search_view, cx| {
1001 assert_eq!(search_view.active_match_index, Some(0));
1002 assert_eq!(
1003 search_view
1004 .results_editor
1005 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1006 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1007 );
1008 search_view.select_match(Direction::Prev, cx);
1009 });
1010
1011 search_view.update(cx, |search_view, cx| {
1012 assert_eq!(search_view.active_match_index, Some(2));
1013 assert_eq!(
1014 search_view
1015 .results_editor
1016 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1017 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1018 );
1019 search_view.select_match(Direction::Prev, cx);
1020 });
1021
1022 search_view.update(cx, |search_view, cx| {
1023 assert_eq!(search_view.active_match_index, Some(1));
1024 assert_eq!(
1025 search_view
1026 .results_editor
1027 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1028 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1029 );
1030 });
1031 }
1032}