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