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, AnyViewHandle, AppContext, ElementBox, Entity, ModelContext, ModelHandle,
16 RenderContext, Subscription, Task, View, ViewContext, ViewHandle, WeakModelHandle,
17 WeakViewHandle,
18};
19use menu::Confirm;
20use project::{search::SearchQuery, Project};
21use settings::Settings;
22use smallvec::SmallVec;
23use std::{
24 any::{Any, TypeId},
25 borrow::Cow,
26 mem,
27 ops::Range,
28 path::PathBuf,
29 sync::Arc,
30};
31use util::ResultExt as _;
32use workspace::{
33 item::{Item, ItemEvent, ItemHandle},
34 searchable::{Direction, SearchableItem, SearchableItemHandle},
35 ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId,
36};
37
38actions!(project_search, [SearchInNew, ToggleFocus]);
39
40#[derive(Default)]
41struct ActiveSearches(HashMap<WeakModelHandle<Project>, WeakViewHandle<ProjectSearchView>>);
42
43pub fn init(cx: &mut AppContext) {
44 cx.set_global(ActiveSearches::default());
45 cx.add_action(ProjectSearchView::deploy);
46 cx.add_action(ProjectSearchBar::search);
47 cx.add_action(ProjectSearchBar::search_in_new);
48 cx.add_action(ProjectSearchBar::select_next_match);
49 cx.add_action(ProjectSearchBar::select_prev_match);
50 cx.add_action(ProjectSearchBar::toggle_focus);
51 cx.capture_action(ProjectSearchBar::tab);
52 add_toggle_option_action::<ToggleCaseSensitive>(SearchOption::CaseSensitive, cx);
53 add_toggle_option_action::<ToggleWholeWord>(SearchOption::WholeWord, cx);
54 add_toggle_option_action::<ToggleRegex>(SearchOption::Regex, cx);
55}
56
57fn add_toggle_option_action<A: Action>(option: SearchOption, cx: &mut AppContext) {
58 cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext<Pane>| {
59 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<ProjectSearchBar>() {
60 if search_bar.update(cx, |search_bar, cx| {
61 search_bar.toggle_search_option(option, cx)
62 }) {
63 return;
64 }
65 }
66 cx.propagate_action();
67 });
68}
69
70struct ProjectSearch {
71 project: ModelHandle<Project>,
72 excerpts: ModelHandle<MultiBuffer>,
73 pending_search: Option<Task<Option<()>>>,
74 match_ranges: Vec<Range<Anchor>>,
75 active_query: Option<SearchQuery>,
76 search_id: usize,
77}
78
79pub struct ProjectSearchView {
80 model: ModelHandle<ProjectSearch>,
81 query_editor: ViewHandle<Editor>,
82 results_editor: ViewHandle<Editor>,
83 case_sensitive: bool,
84 whole_word: bool,
85 regex: bool,
86 query_contains_error: bool,
87 active_match_index: Option<usize>,
88 search_id: usize,
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 RenderContext<Self>) -> ElementBox {
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 .boxed()
202 })
203 .on_down(MouseButton::Left, |_, cx| {
204 cx.focus_parent_view();
205 })
206 .boxed()
207 } else {
208 ChildView::new(&self.results_editor, cx)
209 .flex(1., true)
210 .boxed()
211 }
212 }
213
214 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
215 let handle = cx.weak_handle();
216 cx.update_global(|state: &mut ActiveSearches, cx| {
217 state
218 .0
219 .insert(self.model.read(cx).project.downgrade(), handle)
220 });
221
222 if cx.is_self_focused() {
223 self.focus_query_editor(cx);
224 }
225 }
226}
227
228impl Item for ProjectSearchView {
229 fn tab_tooltip_text(&self, cx: &AppContext) -> Option<Cow<str>> {
230 Some(self.query_editor.read(cx).text(cx).into())
231 }
232
233 fn act_as_type<'a>(
234 &'a self,
235 type_id: TypeId,
236 self_handle: &'a ViewHandle<Self>,
237 _: &'a AppContext,
238 ) -> Option<&'a AnyViewHandle> {
239 if type_id == TypeId::of::<Self>() {
240 Some(self_handle)
241 } else if type_id == TypeId::of::<Editor>() {
242 Some(&self.results_editor)
243 } else {
244 None
245 }
246 }
247
248 fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
249 self.results_editor
250 .update(cx, |editor, cx| editor.deactivated(cx));
251 }
252
253 fn tab_content(
254 &self,
255 _detail: Option<usize>,
256 tab_theme: &theme::Tab,
257 cx: &AppContext,
258 ) -> ElementBox {
259 Flex::row()
260 .with_child(
261 Svg::new("icons/magnifying_glass_12.svg")
262 .with_color(tab_theme.label.text.color)
263 .constrained()
264 .with_width(tab_theme.type_icon_width)
265 .aligned()
266 .contained()
267 .with_margin_right(tab_theme.spacing)
268 .boxed(),
269 )
270 .with_children(self.model.read(cx).active_query.as_ref().map(|query| {
271 let query_text = util::truncate_and_trailoff(query.as_str(), MAX_TAB_TITLE_LEN);
272
273 Label::new(query_text, tab_theme.label.clone())
274 .aligned()
275 .boxed()
276 }))
277 .boxed()
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<ElementBox>> {
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 };
457 this.model_changed(cx);
458 this
459 }
460
461 // Re-activate the most recently activated search or the most recent if it has been closed.
462 // If no search exists in the workspace, create a new one.
463 fn deploy(
464 workspace: &mut Workspace,
465 _: &workspace::NewSearch,
466 cx: &mut ViewContext<Workspace>,
467 ) {
468 // Clean up entries for dropped projects
469 cx.update_global(|state: &mut ActiveSearches, cx| {
470 state.0.retain(|project, _| project.is_upgradable(cx))
471 });
472
473 let active_search = cx
474 .global::<ActiveSearches>()
475 .0
476 .get(&workspace.project().downgrade());
477
478 let existing = active_search
479 .and_then(|active_search| {
480 workspace
481 .items_of_type::<ProjectSearchView>(cx)
482 .find(|search| search == active_search)
483 })
484 .or_else(|| workspace.item_of_type::<ProjectSearchView>(cx));
485
486 let query = workspace.active_item(cx).and_then(|item| {
487 let editor = item.act_as::<Editor>(cx)?;
488 let query = editor.query_suggestion(cx);
489 if query.is_empty() {
490 None
491 } else {
492 Some(query)
493 }
494 });
495
496 let search = if let Some(existing) = existing {
497 workspace.activate_item(&existing, cx);
498 existing
499 } else {
500 let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
501 let view = cx.add_view(|cx| ProjectSearchView::new(model, cx));
502 workspace.add_item(Box::new(view.clone()), cx);
503 view
504 };
505
506 search.update(cx, |search, cx| {
507 if let Some(query) = query {
508 search.set_query(&query, cx);
509 }
510 search.focus_query_editor(cx)
511 });
512 }
513
514 fn search(&mut self, cx: &mut ViewContext<Self>) {
515 if let Some(query) = self.build_search_query(cx) {
516 self.model.update(cx, |model, cx| model.search(query, cx));
517 }
518 }
519
520 fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
521 let text = self.query_editor.read(cx).text(cx);
522 if self.regex {
523 match SearchQuery::regex(text, self.whole_word, self.case_sensitive) {
524 Ok(query) => Some(query),
525 Err(_) => {
526 self.query_contains_error = true;
527 cx.notify();
528 None
529 }
530 }
531 } else {
532 Some(SearchQuery::text(
533 text,
534 self.whole_word,
535 self.case_sensitive,
536 ))
537 }
538 }
539
540 fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
541 if let Some(index) = self.active_match_index {
542 let match_ranges = self.model.read(cx).match_ranges.clone();
543 let new_index = self.results_editor.update(cx, |editor, cx| {
544 editor.match_index_for_direction(&match_ranges, index, direction, cx)
545 });
546
547 let range_to_select = match_ranges[new_index].clone();
548 self.results_editor.update(cx, |editor, cx| {
549 editor.unfold_ranges([range_to_select.clone()], false, true, cx);
550 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
551 s.select_ranges([range_to_select])
552 });
553 });
554 }
555 }
556
557 fn focus_query_editor(&self, cx: &mut ViewContext<Self>) {
558 self.query_editor.update(cx, |query_editor, cx| {
559 query_editor.select_all(&SelectAll, cx);
560 });
561 cx.focus(&self.query_editor);
562 }
563
564 fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
565 self.query_editor
566 .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
567 }
568
569 fn focus_results_editor(&self, cx: &mut ViewContext<Self>) {
570 self.query_editor.update(cx, |query_editor, cx| {
571 let cursor = query_editor.selections.newest_anchor().head();
572 query_editor.change_selections(None, cx, |s| s.select_ranges([cursor.clone()..cursor]));
573 });
574 cx.focus(&self.results_editor);
575 }
576
577 fn model_changed(&mut self, cx: &mut ViewContext<Self>) {
578 let match_ranges = self.model.read(cx).match_ranges.clone();
579 if match_ranges.is_empty() {
580 self.active_match_index = None;
581 } else {
582 let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id);
583 let is_new_search = self.search_id != prev_search_id;
584 self.results_editor.update(cx, |editor, cx| {
585 if is_new_search {
586 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
587 s.select_ranges(match_ranges.first().cloned())
588 });
589 }
590 editor.highlight_background::<Self>(
591 match_ranges,
592 |theme| theme.search.match_background,
593 cx,
594 );
595 });
596 if is_new_search && self.query_editor.is_focused(cx) {
597 self.focus_results_editor(cx);
598 }
599 }
600
601 cx.emit(ViewEvent::UpdateTab);
602 cx.notify();
603 }
604
605 fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
606 let results_editor = self.results_editor.read(cx);
607 let new_index = active_match_index(
608 &self.model.read(cx).match_ranges,
609 &results_editor.selections.newest_anchor().head(),
610 &results_editor.buffer().read(cx).snapshot(cx),
611 );
612 if self.active_match_index != new_index {
613 self.active_match_index = new_index;
614 cx.notify();
615 }
616 }
617
618 pub fn has_matches(&self) -> bool {
619 self.active_match_index.is_some()
620 }
621}
622
623impl Default for ProjectSearchBar {
624 fn default() -> Self {
625 Self::new()
626 }
627}
628
629impl ProjectSearchBar {
630 pub fn new() -> Self {
631 Self {
632 active_project_search: Default::default(),
633 subscription: Default::default(),
634 }
635 }
636
637 fn search(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
638 if let Some(search_view) = self.active_project_search.as_ref() {
639 search_view.update(cx, |search_view, cx| search_view.search(cx));
640 }
641 }
642
643 fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
644 if let Some(search_view) = workspace
645 .active_item(cx)
646 .and_then(|item| item.downcast::<ProjectSearchView>())
647 {
648 let new_query = search_view.update(cx, |search_view, cx| {
649 let new_query = search_view.build_search_query(cx);
650 if new_query.is_some() {
651 if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
652 search_view.query_editor.update(cx, |editor, cx| {
653 editor.set_text(old_query.as_str(), cx);
654 });
655 search_view.regex = old_query.is_regex();
656 search_view.whole_word = old_query.whole_word();
657 search_view.case_sensitive = old_query.case_sensitive();
658 }
659 }
660 new_query
661 });
662 if let Some(new_query) = new_query {
663 let model = cx.add_model(|cx| {
664 let mut model = ProjectSearch::new(workspace.project().clone(), cx);
665 model.search(new_query, cx);
666 model
667 });
668 workspace.add_item(
669 Box::new(cx.add_view(|cx| ProjectSearchView::new(model, cx))),
670 cx,
671 );
672 }
673 }
674 }
675
676 fn select_next_match(pane: &mut Pane, _: &SelectNextMatch, cx: &mut ViewContext<Pane>) {
677 if let Some(search_view) = pane
678 .active_item()
679 .and_then(|item| item.downcast::<ProjectSearchView>())
680 {
681 search_view.update(cx, |view, cx| view.select_match(Direction::Next, cx));
682 } else {
683 cx.propagate_action();
684 }
685 }
686
687 fn select_prev_match(pane: &mut Pane, _: &SelectPrevMatch, cx: &mut ViewContext<Pane>) {
688 if let Some(search_view) = pane
689 .active_item()
690 .and_then(|item| item.downcast::<ProjectSearchView>())
691 {
692 search_view.update(cx, |view, cx| view.select_match(Direction::Prev, cx));
693 } else {
694 cx.propagate_action();
695 }
696 }
697
698 fn toggle_focus(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext<Pane>) {
699 if let Some(search_view) = pane
700 .active_item()
701 .and_then(|item| item.downcast::<ProjectSearchView>())
702 {
703 search_view.update(cx, |search_view, cx| {
704 if search_view.query_editor.is_focused(cx) {
705 if !search_view.model.read(cx).match_ranges.is_empty() {
706 search_view.focus_results_editor(cx);
707 }
708 } else {
709 search_view.focus_query_editor(cx);
710 }
711 });
712 } else {
713 cx.propagate_action();
714 }
715 }
716
717 fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
718 if let Some(search_view) = self.active_project_search.as_ref() {
719 search_view.update(cx, |search_view, cx| {
720 if search_view.query_editor.is_focused(cx) {
721 if !search_view.model.read(cx).match_ranges.is_empty() {
722 search_view.focus_results_editor(cx);
723 }
724 } else {
725 cx.propagate_action();
726 }
727 });
728 } else {
729 cx.propagate_action();
730 }
731 }
732
733 fn toggle_search_option(&mut self, option: SearchOption, cx: &mut ViewContext<Self>) -> bool {
734 if let Some(search_view) = self.active_project_search.as_ref() {
735 search_view.update(cx, |search_view, cx| {
736 let value = match option {
737 SearchOption::WholeWord => &mut search_view.whole_word,
738 SearchOption::CaseSensitive => &mut search_view.case_sensitive,
739 SearchOption::Regex => &mut search_view.regex,
740 };
741 *value = !*value;
742 search_view.search(cx);
743 });
744 cx.notify();
745 true
746 } else {
747 false
748 }
749 }
750
751 fn render_nav_button(
752 &self,
753 icon: &'static str,
754 direction: Direction,
755 cx: &mut RenderContext<Self>,
756 ) -> ElementBox {
757 let action: Box<dyn Action>;
758 let tooltip;
759 match direction {
760 Direction::Prev => {
761 action = Box::new(SelectPrevMatch);
762 tooltip = "Select Previous Match";
763 }
764 Direction::Next => {
765 action = Box::new(SelectNextMatch);
766 tooltip = "Select Next Match";
767 }
768 };
769 let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
770
771 enum NavButton {}
772 MouseEventHandler::<NavButton>::new(direction as usize, cx, |state, cx| {
773 let style = &cx
774 .global::<Settings>()
775 .theme
776 .search
777 .option_button
778 .style_for(state, false);
779 Label::new(icon, style.text.clone())
780 .contained()
781 .with_style(style.container)
782 .boxed()
783 })
784 .on_click(MouseButton::Left, {
785 let action = action.boxed_clone();
786 move |_, cx| cx.dispatch_any_action(action.boxed_clone())
787 })
788 .with_cursor_style(CursorStyle::PointingHand)
789 .with_tooltip::<NavButton, _>(
790 direction as usize,
791 tooltip.to_string(),
792 Some(action),
793 tooltip_style,
794 cx,
795 )
796 .boxed()
797 }
798
799 fn render_option_button(
800 &self,
801 icon: &'static str,
802 option: SearchOption,
803 cx: &mut RenderContext<Self>,
804 ) -> ElementBox {
805 let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
806 let is_active = self.is_option_enabled(option, cx);
807 MouseEventHandler::<Self>::new(option as usize, cx, |state, cx| {
808 let style = &cx
809 .global::<Settings>()
810 .theme
811 .search
812 .option_button
813 .style_for(state, is_active);
814 Label::new(icon, style.text.clone())
815 .contained()
816 .with_style(style.container)
817 .boxed()
818 })
819 .on_click(MouseButton::Left, move |_, cx| {
820 cx.dispatch_any_action(option.to_toggle_action())
821 })
822 .with_cursor_style(CursorStyle::PointingHand)
823 .with_tooltip::<Self, _>(
824 option as usize,
825 format!("Toggle {}", option.label()),
826 Some(option.to_toggle_action()),
827 tooltip_style,
828 cx,
829 )
830 .boxed()
831 }
832
833 fn is_option_enabled(&self, option: SearchOption, cx: &AppContext) -> bool {
834 if let Some(search) = self.active_project_search.as_ref() {
835 let search = search.read(cx);
836 match option {
837 SearchOption::WholeWord => search.whole_word,
838 SearchOption::CaseSensitive => search.case_sensitive,
839 SearchOption::Regex => search.regex,
840 }
841 } else {
842 false
843 }
844 }
845}
846
847impl Entity for ProjectSearchBar {
848 type Event = ();
849}
850
851impl View for ProjectSearchBar {
852 fn ui_name() -> &'static str {
853 "ProjectSearchBar"
854 }
855
856 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
857 if let Some(search) = self.active_project_search.as_ref() {
858 let search = search.read(cx);
859 let theme = cx.global::<Settings>().theme.clone();
860 let editor_container = if search.query_contains_error {
861 theme.search.invalid_editor
862 } else {
863 theme.search.editor.input.container
864 };
865 Flex::row()
866 .with_child(
867 Flex::row()
868 .with_child(
869 ChildView::new(&search.query_editor, cx)
870 .aligned()
871 .left()
872 .flex(1., true)
873 .boxed(),
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 .boxed()
888 }))
889 .contained()
890 .with_style(editor_container)
891 .aligned()
892 .constrained()
893 .with_min_width(theme.search.editor.min_width)
894 .with_max_width(theme.search.editor.max_width)
895 .flex(1., false)
896 .boxed(),
897 )
898 .with_child(
899 Flex::row()
900 .with_child(self.render_nav_button("<", Direction::Prev, cx))
901 .with_child(self.render_nav_button(">", Direction::Next, cx))
902 .aligned()
903 .boxed(),
904 )
905 .with_child(
906 Flex::row()
907 .with_child(self.render_option_button(
908 "Case",
909 SearchOption::CaseSensitive,
910 cx,
911 ))
912 .with_child(self.render_option_button("Word", SearchOption::WholeWord, cx))
913 .with_child(self.render_option_button("Regex", SearchOption::Regex, cx))
914 .contained()
915 .with_style(theme.search.option_button_group)
916 .aligned()
917 .boxed(),
918 )
919 .contained()
920 .with_style(theme.search.container)
921 .aligned()
922 .left()
923 .named("project search")
924 } else {
925 Empty::new().boxed()
926 }
927 }
928}
929
930impl ToolbarItemView for ProjectSearchBar {
931 fn set_active_pane_item(
932 &mut self,
933 active_pane_item: Option<&dyn ItemHandle>,
934 cx: &mut ViewContext<Self>,
935 ) -> ToolbarItemLocation {
936 cx.notify();
937 self.subscription = None;
938 self.active_project_search = None;
939 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
940 let query_editor = search.read(cx).query_editor.clone();
941 cx.reparent(&query_editor);
942 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
943 self.active_project_search = Some(search);
944 ToolbarItemLocation::PrimaryLeft {
945 flex: Some((1., false)),
946 }
947 } else {
948 ToolbarItemLocation::Hidden
949 }
950 }
951}
952
953#[cfg(test)]
954mod tests {
955 use super::*;
956 use editor::DisplayPoint;
957 use gpui::{color::Color, executor::Deterministic, TestAppContext};
958 use project::FakeFs;
959 use serde_json::json;
960 use std::sync::Arc;
961
962 #[gpui::test]
963 async fn test_project_search(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
964 let fonts = cx.font_cache();
965 let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default);
966 theme.search.match_background = Color::red();
967 cx.update(|cx| {
968 let mut settings = Settings::test(cx);
969 settings.theme = Arc::new(theme);
970 cx.set_global(settings);
971 cx.set_global(ActiveSearches::default());
972 });
973
974 let fs = FakeFs::new(cx.background());
975 fs.insert_tree(
976 "/dir",
977 json!({
978 "one.rs": "const ONE: usize = 1;",
979 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
980 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
981 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
982 }),
983 )
984 .await;
985 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
986 let search = cx.add_model(|cx| ProjectSearch::new(project, cx));
987 let (_, search_view) = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx));
988
989 search_view.update(cx, |search_view, cx| {
990 search_view
991 .query_editor
992 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
993 search_view.search(cx);
994 });
995 deterministic.run_until_parked();
996 search_view.update(cx, |search_view, cx| {
997 assert_eq!(
998 search_view
999 .results_editor
1000 .update(cx, |editor, cx| editor.display_text(cx)),
1001 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
1002 );
1003 assert_eq!(
1004 search_view
1005 .results_editor
1006 .update(cx, |editor, cx| editor.all_background_highlights(cx)),
1007 &[
1008 (
1009 DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
1010 Color::red()
1011 ),
1012 (
1013 DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
1014 Color::red()
1015 ),
1016 (
1017 DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
1018 Color::red()
1019 )
1020 ]
1021 );
1022 assert_eq!(search_view.active_match_index, Some(0));
1023 assert_eq!(
1024 search_view
1025 .results_editor
1026 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1027 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1028 );
1029
1030 search_view.select_match(Direction::Next, cx);
1031 });
1032
1033 search_view.update(cx, |search_view, cx| {
1034 assert_eq!(search_view.active_match_index, Some(1));
1035 assert_eq!(
1036 search_view
1037 .results_editor
1038 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1039 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1040 );
1041 search_view.select_match(Direction::Next, cx);
1042 });
1043
1044 search_view.update(cx, |search_view, cx| {
1045 assert_eq!(search_view.active_match_index, Some(2));
1046 assert_eq!(
1047 search_view
1048 .results_editor
1049 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1050 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1051 );
1052 search_view.select_match(Direction::Next, cx);
1053 });
1054
1055 search_view.update(cx, |search_view, cx| {
1056 assert_eq!(search_view.active_match_index, Some(0));
1057 assert_eq!(
1058 search_view
1059 .results_editor
1060 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1061 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1062 );
1063 search_view.select_match(Direction::Prev, cx);
1064 });
1065
1066 search_view.update(cx, |search_view, cx| {
1067 assert_eq!(search_view.active_match_index, Some(2));
1068 assert_eq!(
1069 search_view
1070 .results_editor
1071 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1072 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1073 );
1074 search_view.select_match(Direction::Prev, cx);
1075 });
1076
1077 search_view.update(cx, |search_view, cx| {
1078 assert_eq!(search_view.active_match_index, Some(1));
1079 assert_eq!(
1080 search_view
1081 .results_editor
1082 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1083 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1084 );
1085 });
1086 }
1087}