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