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 smallvec::SmallVec;
21use std::{
22 any::{Any, TypeId},
23 borrow::Cow,
24 collections::HashSet,
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, NextField]);
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 cx.capture_action(ProjectSearchBar::tab_previous);
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
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
80enum InputPanel {
81 Query,
82 Exclude,
83 Include,
84}
85
86pub struct ProjectSearchView {
87 model: ModelHandle<ProjectSearch>,
88 query_editor: ViewHandle<Editor>,
89 results_editor: ViewHandle<Editor>,
90 case_sensitive: bool,
91 whole_word: bool,
92 regex: bool,
93 panels_with_errors: HashSet<InputPanel>,
94 active_match_index: Option<usize>,
95 search_id: usize,
96 query_editor_was_focused: bool,
97 included_files_editor: ViewHandle<Editor>,
98 excluded_files_editor: ViewHandle<Editor>,
99}
100
101pub struct ProjectSearchBar {
102 active_project_search: Option<ViewHandle<ProjectSearchView>>,
103 subscription: Option<Subscription>,
104}
105
106impl Entity for ProjectSearch {
107 type Event = ();
108}
109
110impl ProjectSearch {
111 fn new(project: ModelHandle<Project>, cx: &mut ModelContext<Self>) -> Self {
112 let replica_id = project.read(cx).replica_id();
113 Self {
114 project,
115 excerpts: cx.add_model(|_| MultiBuffer::new(replica_id)),
116 pending_search: Default::default(),
117 match_ranges: Default::default(),
118 active_query: None,
119 search_id: 0,
120 }
121 }
122
123 fn clone(&self, cx: &mut ModelContext<Self>) -> ModelHandle<Self> {
124 cx.add_model(|cx| Self {
125 project: self.project.clone(),
126 excerpts: self
127 .excerpts
128 .update(cx, |excerpts, cx| cx.add_model(|cx| excerpts.clone(cx))),
129 pending_search: Default::default(),
130 match_ranges: self.match_ranges.clone(),
131 active_query: self.active_query.clone(),
132 search_id: self.search_id,
133 })
134 }
135
136 fn search(&mut self, query: SearchQuery, cx: &mut ModelContext<Self>) {
137 let search = self
138 .project
139 .update(cx, |project, cx| project.search(query.clone(), cx));
140 self.search_id += 1;
141 self.active_query = Some(query);
142 self.match_ranges.clear();
143 self.pending_search = Some(cx.spawn_weak(|this, mut cx| async move {
144 let matches = search.await.log_err()?;
145 let this = this.upgrade(&cx)?;
146 let mut matches = matches.into_iter().collect::<Vec<_>>();
147 let (_task, mut match_ranges) = this.update(&mut cx, |this, cx| {
148 this.match_ranges.clear();
149 matches.sort_by_key(|(buffer, _)| buffer.read(cx).file().map(|file| file.path()));
150 this.excerpts.update(cx, |excerpts, cx| {
151 excerpts.clear(cx);
152 excerpts.stream_excerpts_with_context_lines(matches, 1, cx)
153 })
154 });
155
156 while let Some(match_range) = match_ranges.next().await {
157 this.update(&mut cx, |this, cx| {
158 this.match_ranges.push(match_range);
159 while let Ok(Some(match_range)) = match_ranges.try_next() {
160 this.match_ranges.push(match_range);
161 }
162 cx.notify();
163 });
164 }
165
166 this.update(&mut cx, |this, cx| {
167 this.pending_search.take();
168 cx.notify();
169 });
170
171 None
172 }));
173 cx.notify();
174 }
175}
176
177pub enum ViewEvent {
178 UpdateTab,
179 Activate,
180 EditorEvent(editor::Event),
181}
182
183impl Entity for ProjectSearchView {
184 type Event = ViewEvent;
185}
186
187impl View for ProjectSearchView {
188 fn ui_name() -> &'static str {
189 "ProjectSearchView"
190 }
191
192 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
193 let model = &self.model.read(cx);
194 if model.match_ranges.is_empty() {
195 enum Status {}
196
197 let theme = theme::current(cx).clone();
198 let text = if self.query_editor.read(cx).text(cx).is_empty() {
199 ""
200 } else if model.pending_search.is_some() {
201 "Searching..."
202 } else {
203 "No results"
204 };
205 MouseEventHandler::<Status, _>::new(0, cx, |_, _| {
206 Label::new(text, theme.search.results_status.clone())
207 .aligned()
208 .contained()
209 .with_background_color(theme.editor.background)
210 .flex(1., true)
211 })
212 .on_down(MouseButton::Left, |_, _, cx| {
213 cx.focus_parent();
214 })
215 .into_any_named("project search view")
216 } else {
217 ChildView::new(&self.results_editor, cx)
218 .flex(1., true)
219 .into_any_named("project search view")
220 }
221 }
222
223 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
224 let handle = cx.weak_handle();
225 cx.update_global(|state: &mut ActiveSearches, cx| {
226 state
227 .0
228 .insert(self.model.read(cx).project.downgrade(), handle)
229 });
230
231 if cx.is_self_focused() {
232 if self.query_editor_was_focused {
233 cx.focus(&self.query_editor);
234 } else {
235 cx.focus(&self.results_editor);
236 }
237 }
238 }
239}
240
241impl Item for ProjectSearchView {
242 fn tab_tooltip_text(&self, cx: &AppContext) -> Option<Cow<str>> {
243 Some(self.query_editor.read(cx).text(cx).into())
244 }
245
246 fn act_as_type<'a>(
247 &'a self,
248 type_id: TypeId,
249 self_handle: &'a ViewHandle<Self>,
250 _: &'a AppContext,
251 ) -> Option<&'a AnyViewHandle> {
252 if type_id == TypeId::of::<Self>() {
253 Some(self_handle)
254 } else if type_id == TypeId::of::<Editor>() {
255 Some(&self.results_editor)
256 } else {
257 None
258 }
259 }
260
261 fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
262 self.results_editor
263 .update(cx, |editor, cx| editor.deactivated(cx));
264 }
265
266 fn tab_content<T: View>(
267 &self,
268 _detail: Option<usize>,
269 tab_theme: &theme::Tab,
270 cx: &AppContext,
271 ) -> AnyElement<T> {
272 Flex::row()
273 .with_child(
274 Svg::new("icons/magnifying_glass_12.svg")
275 .with_color(tab_theme.label.text.color)
276 .constrained()
277 .with_width(tab_theme.type_icon_width)
278 .aligned()
279 .contained()
280 .with_margin_right(tab_theme.spacing),
281 )
282 .with_children(self.model.read(cx).active_query.as_ref().map(|query| {
283 let query_text = util::truncate_and_trailoff(query.as_str(), MAX_TAB_TITLE_LEN);
284
285 Label::new(query_text, tab_theme.label.clone()).aligned()
286 }))
287 .into_any()
288 }
289
290 fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
291 self.results_editor.for_each_project_item(cx, f)
292 }
293
294 fn is_singleton(&self, _: &AppContext) -> bool {
295 false
296 }
297
298 fn can_save(&self, _: &AppContext) -> bool {
299 true
300 }
301
302 fn is_dirty(&self, cx: &AppContext) -> bool {
303 self.results_editor.read(cx).is_dirty(cx)
304 }
305
306 fn has_conflict(&self, cx: &AppContext) -> bool {
307 self.results_editor.read(cx).has_conflict(cx)
308 }
309
310 fn save(
311 &mut self,
312 project: ModelHandle<Project>,
313 cx: &mut ViewContext<Self>,
314 ) -> Task<anyhow::Result<()>> {
315 self.results_editor
316 .update(cx, |editor, cx| editor.save(project, cx))
317 }
318
319 fn save_as(
320 &mut self,
321 _: ModelHandle<Project>,
322 _: PathBuf,
323 _: &mut ViewContext<Self>,
324 ) -> Task<anyhow::Result<()>> {
325 unreachable!("save_as should not have been called")
326 }
327
328 fn reload(
329 &mut self,
330 project: ModelHandle<Project>,
331 cx: &mut ViewContext<Self>,
332 ) -> Task<anyhow::Result<()>> {
333 self.results_editor
334 .update(cx, |editor, cx| editor.reload(project, cx))
335 }
336
337 fn clone_on_split(&self, _workspace_id: WorkspaceId, cx: &mut ViewContext<Self>) -> Option<Self>
338 where
339 Self: Sized,
340 {
341 let model = self.model.update(cx, |model, cx| model.clone(cx));
342 Some(Self::new(model, cx))
343 }
344
345 fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
346 self.results_editor
347 .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx));
348 }
349
350 fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
351 self.results_editor.update(cx, |editor, _| {
352 editor.set_nav_history(Some(nav_history));
353 });
354 }
355
356 fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
357 self.results_editor
358 .update(cx, |editor, cx| editor.navigate(data, cx))
359 }
360
361 fn git_diff_recalc(
362 &mut self,
363 project: ModelHandle<Project>,
364 cx: &mut ViewContext<Self>,
365 ) -> Task<anyhow::Result<()>> {
366 self.results_editor
367 .update(cx, |editor, cx| editor.git_diff_recalc(project, cx))
368 }
369
370 fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> {
371 match event {
372 ViewEvent::UpdateTab => {
373 smallvec::smallvec![ItemEvent::UpdateBreadcrumbs, ItemEvent::UpdateTab]
374 }
375 ViewEvent::EditorEvent(editor_event) => Editor::to_item_events(editor_event),
376 _ => SmallVec::new(),
377 }
378 }
379
380 fn breadcrumb_location(&self) -> ToolbarItemLocation {
381 if self.has_matches() {
382 ToolbarItemLocation::Secondary
383 } else {
384 ToolbarItemLocation::Hidden
385 }
386 }
387
388 fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
389 self.results_editor.breadcrumbs(theme, cx)
390 }
391
392 fn serialized_item_kind() -> Option<&'static str> {
393 None
394 }
395
396 fn deserialize(
397 _project: ModelHandle<Project>,
398 _workspace: WeakViewHandle<Workspace>,
399 _workspace_id: workspace::WorkspaceId,
400 _item_id: workspace::ItemId,
401 _cx: &mut ViewContext<Pane>,
402 ) -> Task<anyhow::Result<ViewHandle<Self>>> {
403 unimplemented!()
404 }
405}
406
407impl ProjectSearchView {
408 fn new(model: ModelHandle<ProjectSearch>, cx: &mut ViewContext<Self>) -> Self {
409 let project;
410 let excerpts;
411 let mut query_text = String::new();
412 let mut regex = false;
413 let mut case_sensitive = false;
414 let mut whole_word = false;
415
416 {
417 let model = model.read(cx);
418 project = model.project.clone();
419 excerpts = model.excerpts.clone();
420 if let Some(active_query) = model.active_query.as_ref() {
421 query_text = active_query.as_str().to_string();
422 regex = active_query.is_regex();
423 case_sensitive = active_query.case_sensitive();
424 whole_word = active_query.whole_word();
425 }
426 }
427 cx.observe(&model, |this, _, cx| this.model_changed(cx))
428 .detach();
429
430 let query_editor = cx.add_view(|cx| {
431 let mut editor = Editor::single_line(
432 Some(Arc::new(|theme| theme.search.editor.input.clone())),
433 cx,
434 );
435 editor.set_text(query_text, cx);
436 editor
437 });
438 // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
439 cx.subscribe(&query_editor, |_, _, event, cx| {
440 cx.emit(ViewEvent::EditorEvent(event.clone()))
441 })
442 .detach();
443
444 let results_editor = cx.add_view(|cx| {
445 let mut editor = Editor::for_multibuffer(excerpts, Some(project), cx);
446 editor.set_searchable(false);
447 editor
448 });
449 cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab))
450 .detach();
451
452 cx.subscribe(&results_editor, |this, _, event, cx| {
453 if matches!(event, editor::Event::SelectionsChanged { .. }) {
454 this.update_match_index(cx);
455 }
456 // Reraise editor events for workspace item activation purposes
457 cx.emit(ViewEvent::EditorEvent(event.clone()));
458 })
459 .detach();
460
461 let included_files_editor = cx.add_view(|cx| {
462 let mut editor = Editor::single_line(
463 Some(Arc::new(|theme| {
464 theme.search.include_exclude_editor.input.clone()
465 })),
466 cx,
467 );
468 editor.set_placeholder_text("Include: crates/**/*.toml", cx);
469
470 editor
471 });
472 // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes
473 cx.subscribe(&included_files_editor, |_, _, event, cx| {
474 cx.emit(ViewEvent::EditorEvent(event.clone()))
475 })
476 .detach();
477
478 let excluded_files_editor = cx.add_view(|cx| {
479 let mut editor = Editor::single_line(
480 Some(Arc::new(|theme| {
481 theme.search.include_exclude_editor.input.clone()
482 })),
483 cx,
484 );
485 editor.set_placeholder_text("Exclude: vendor/*, *.lock", cx);
486
487 editor
488 });
489 // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes
490 cx.subscribe(&excluded_files_editor, |_, _, event, cx| {
491 cx.emit(ViewEvent::EditorEvent(event.clone()))
492 })
493 .detach();
494
495 let mut this = ProjectSearchView {
496 search_id: model.read(cx).search_id,
497 model,
498 query_editor,
499 results_editor,
500 case_sensitive,
501 whole_word,
502 regex,
503 panels_with_errors: HashSet::new(),
504 active_match_index: None,
505 query_editor_was_focused: false,
506 included_files_editor,
507 excluded_files_editor,
508 };
509 this.model_changed(cx);
510 this
511 }
512
513 // Re-activate the most recently activated search or the most recent if it has been closed.
514 // If no search exists in the workspace, create a new one.
515 fn deploy(
516 workspace: &mut Workspace,
517 _: &workspace::NewSearch,
518 cx: &mut ViewContext<Workspace>,
519 ) {
520 // Clean up entries for dropped projects
521 cx.update_global(|state: &mut ActiveSearches, cx| {
522 state.0.retain(|project, _| project.is_upgradable(cx))
523 });
524
525 let active_search = cx
526 .global::<ActiveSearches>()
527 .0
528 .get(&workspace.project().downgrade());
529
530 let existing = active_search
531 .and_then(|active_search| {
532 workspace
533 .items_of_type::<ProjectSearchView>(cx)
534 .find(|search| search == active_search)
535 })
536 .or_else(|| workspace.item_of_type::<ProjectSearchView>(cx));
537
538 let query = workspace.active_item(cx).and_then(|item| {
539 let editor = item.act_as::<Editor>(cx)?;
540 let query = editor.query_suggestion(cx);
541 if query.is_empty() {
542 None
543 } else {
544 Some(query)
545 }
546 });
547
548 let search = if let Some(existing) = existing {
549 workspace.activate_item(&existing, cx);
550 existing
551 } else {
552 let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
553 let view = cx.add_view(|cx| ProjectSearchView::new(model, cx));
554 workspace.add_item(Box::new(view.clone()), cx);
555 view
556 };
557
558 search.update(cx, |search, cx| {
559 if let Some(query) = query {
560 search.set_query(&query, cx);
561 }
562 search.focus_query_editor(cx)
563 });
564 }
565
566 fn search(&mut self, cx: &mut ViewContext<Self>) {
567 if let Some(query) = self.build_search_query(cx) {
568 self.model.update(cx, |model, cx| model.search(query, cx));
569 }
570 }
571
572 fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
573 let text = self.query_editor.read(cx).text(cx);
574 let included_files = match self
575 .included_files_editor
576 .read(cx)
577 .text(cx)
578 .split(',')
579 .map(str::trim)
580 .filter(|glob_str| !glob_str.is_empty())
581 .map(|glob_str| glob::Pattern::new(glob_str))
582 .collect::<Result<_, _>>()
583 {
584 Ok(included_files) => {
585 self.panels_with_errors.remove(&InputPanel::Include);
586 included_files
587 }
588 Err(_e) => {
589 self.panels_with_errors.insert(InputPanel::Include);
590 cx.notify();
591 return None;
592 }
593 };
594 let excluded_files = match self
595 .excluded_files_editor
596 .read(cx)
597 .text(cx)
598 .split(',')
599 .map(str::trim)
600 .filter(|glob_str| !glob_str.is_empty())
601 .map(|glob_str| glob::Pattern::new(glob_str))
602 .collect::<Result<_, _>>()
603 {
604 Ok(excluded_files) => {
605 self.panels_with_errors.remove(&InputPanel::Exclude);
606 excluded_files
607 }
608 Err(_e) => {
609 self.panels_with_errors.insert(InputPanel::Exclude);
610 cx.notify();
611 return None;
612 }
613 };
614 if self.regex {
615 match SearchQuery::regex(
616 text,
617 self.whole_word,
618 self.case_sensitive,
619 included_files,
620 excluded_files,
621 ) {
622 Ok(query) => {
623 self.panels_with_errors.remove(&InputPanel::Query);
624 Some(query)
625 }
626 Err(_e) => {
627 self.panels_with_errors.insert(InputPanel::Query);
628 cx.notify();
629 None
630 }
631 }
632 } else {
633 Some(SearchQuery::text(
634 text,
635 self.whole_word,
636 self.case_sensitive,
637 included_files,
638 excluded_files,
639 ))
640 }
641 }
642
643 fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
644 if let Some(index) = self.active_match_index {
645 let match_ranges = self.model.read(cx).match_ranges.clone();
646 let new_index = self.results_editor.update(cx, |editor, cx| {
647 editor.match_index_for_direction(&match_ranges, index, direction, cx)
648 });
649
650 let range_to_select = match_ranges[new_index].clone();
651 self.results_editor.update(cx, |editor, cx| {
652 editor.unfold_ranges([range_to_select.clone()], false, true, cx);
653 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
654 s.select_ranges([range_to_select])
655 });
656 });
657 }
658 }
659
660 fn focus_query_editor(&mut self, cx: &mut ViewContext<Self>) {
661 self.query_editor.update(cx, |query_editor, cx| {
662 query_editor.select_all(&SelectAll, cx);
663 });
664 self.query_editor_was_focused = true;
665 cx.focus(&self.query_editor);
666 }
667
668 fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
669 self.query_editor
670 .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
671 }
672
673 fn focus_results_editor(&mut self, cx: &mut ViewContext<Self>) {
674 self.query_editor.update(cx, |query_editor, cx| {
675 let cursor = query_editor.selections.newest_anchor().head();
676 query_editor.change_selections(None, cx, |s| s.select_ranges([cursor.clone()..cursor]));
677 });
678 self.query_editor_was_focused = false;
679 cx.focus(&self.results_editor);
680 }
681
682 fn model_changed(&mut self, cx: &mut ViewContext<Self>) {
683 let match_ranges = self.model.read(cx).match_ranges.clone();
684 if match_ranges.is_empty() {
685 self.active_match_index = None;
686 } else {
687 let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id);
688 let is_new_search = self.search_id != prev_search_id;
689 self.results_editor.update(cx, |editor, cx| {
690 if is_new_search {
691 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
692 s.select_ranges(match_ranges.first().cloned())
693 });
694 }
695 editor.highlight_background::<Self>(
696 match_ranges,
697 |theme| theme.search.match_background,
698 cx,
699 );
700 });
701 if is_new_search && self.query_editor.is_focused(cx) {
702 self.focus_results_editor(cx);
703 }
704 }
705
706 cx.emit(ViewEvent::UpdateTab);
707 cx.notify();
708 }
709
710 fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
711 let results_editor = self.results_editor.read(cx);
712 let new_index = active_match_index(
713 &self.model.read(cx).match_ranges,
714 &results_editor.selections.newest_anchor().head(),
715 &results_editor.buffer().read(cx).snapshot(cx),
716 );
717 if self.active_match_index != new_index {
718 self.active_match_index = new_index;
719 cx.notify();
720 }
721 }
722
723 pub fn has_matches(&self) -> bool {
724 self.active_match_index.is_some()
725 }
726}
727
728impl Default for ProjectSearchBar {
729 fn default() -> Self {
730 Self::new()
731 }
732}
733
734impl ProjectSearchBar {
735 pub fn new() -> Self {
736 Self {
737 active_project_search: Default::default(),
738 subscription: Default::default(),
739 }
740 }
741
742 fn search(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
743 if let Some(search_view) = self.active_project_search.as_ref() {
744 search_view.update(cx, |search_view, cx| search_view.search(cx));
745 }
746 }
747
748 fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
749 if let Some(search_view) = workspace
750 .active_item(cx)
751 .and_then(|item| item.downcast::<ProjectSearchView>())
752 {
753 let new_query = search_view.update(cx, |search_view, cx| {
754 let new_query = search_view.build_search_query(cx);
755 if new_query.is_some() {
756 if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
757 search_view.query_editor.update(cx, |editor, cx| {
758 editor.set_text(old_query.as_str(), cx);
759 });
760 search_view.regex = old_query.is_regex();
761 search_view.whole_word = old_query.whole_word();
762 search_view.case_sensitive = old_query.case_sensitive();
763 }
764 }
765 new_query
766 });
767 if let Some(new_query) = new_query {
768 let model = cx.add_model(|cx| {
769 let mut model = ProjectSearch::new(workspace.project().clone(), cx);
770 model.search(new_query, cx);
771 model
772 });
773 workspace.add_item(
774 Box::new(cx.add_view(|cx| ProjectSearchView::new(model, cx))),
775 cx,
776 );
777 }
778 }
779 }
780
781 fn select_next_match(pane: &mut Pane, _: &SelectNextMatch, cx: &mut ViewContext<Pane>) {
782 if let Some(search_view) = pane
783 .active_item()
784 .and_then(|item| item.downcast::<ProjectSearchView>())
785 {
786 search_view.update(cx, |view, cx| view.select_match(Direction::Next, cx));
787 } else {
788 cx.propagate_action();
789 }
790 }
791
792 fn select_prev_match(pane: &mut Pane, _: &SelectPrevMatch, cx: &mut ViewContext<Pane>) {
793 if let Some(search_view) = pane
794 .active_item()
795 .and_then(|item| item.downcast::<ProjectSearchView>())
796 {
797 search_view.update(cx, |view, cx| view.select_match(Direction::Prev, cx));
798 } else {
799 cx.propagate_action();
800 }
801 }
802
803 fn toggle_focus(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext<Pane>) {
804 if let Some(search_view) = pane
805 .active_item()
806 .and_then(|item| item.downcast::<ProjectSearchView>())
807 {
808 search_view.update(cx, |search_view, cx| {
809 if search_view.query_editor.is_focused(cx) {
810 if !search_view.model.read(cx).match_ranges.is_empty() {
811 search_view.focus_results_editor(cx);
812 }
813 } else {
814 search_view.focus_query_editor(cx);
815 }
816 });
817 } else {
818 cx.propagate_action();
819 }
820 }
821
822 fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
823 self.cycle_field(Direction::Next, cx);
824 }
825
826 fn tab_previous(&mut self, _: &editor::TabPrev, cx: &mut ViewContext<Self>) {
827 self.cycle_field(Direction::Prev, cx);
828 }
829
830 fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
831 let active_project_search = match &self.active_project_search {
832 Some(active_project_search) => active_project_search,
833
834 None => {
835 cx.propagate_action();
836 return;
837 }
838 };
839
840 active_project_search.update(cx, |project_view, cx| {
841 let views = &[
842 &project_view.query_editor,
843 &project_view.included_files_editor,
844 &project_view.excluded_files_editor,
845 ];
846
847 let current_index = match views
848 .iter()
849 .enumerate()
850 .find(|(_, view)| view.is_focused(cx))
851 {
852 Some((index, _)) => index,
853
854 None => {
855 cx.propagate_action();
856 return;
857 }
858 };
859
860 let new_index = match direction {
861 Direction::Next => (current_index + 1) % views.len(),
862 Direction::Prev if current_index == 0 => views.len() - 1,
863 Direction::Prev => (current_index - 1) % views.len(),
864 };
865 cx.focus(views[new_index]);
866 });
867 }
868
869 fn toggle_search_option(&mut self, option: SearchOption, cx: &mut ViewContext<Self>) -> bool {
870 if let Some(search_view) = self.active_project_search.as_ref() {
871 search_view.update(cx, |search_view, cx| {
872 let value = match option {
873 SearchOption::WholeWord => &mut search_view.whole_word,
874 SearchOption::CaseSensitive => &mut search_view.case_sensitive,
875 SearchOption::Regex => &mut search_view.regex,
876 };
877 *value = !*value;
878 search_view.search(cx);
879 });
880 cx.notify();
881 true
882 } else {
883 false
884 }
885 }
886
887 fn render_nav_button(
888 &self,
889 icon: &'static str,
890 direction: Direction,
891 cx: &mut ViewContext<Self>,
892 ) -> AnyElement<Self> {
893 let action: Box<dyn Action>;
894 let tooltip;
895 match direction {
896 Direction::Prev => {
897 action = Box::new(SelectPrevMatch);
898 tooltip = "Select Previous Match";
899 }
900 Direction::Next => {
901 action = Box::new(SelectNextMatch);
902 tooltip = "Select Next Match";
903 }
904 };
905 let tooltip_style = theme::current(cx).tooltip.clone();
906
907 enum NavButton {}
908 MouseEventHandler::<NavButton, _>::new(direction as usize, cx, |state, cx| {
909 let theme = theme::current(cx);
910 let style = theme.search.option_button.style_for(state, false);
911 Label::new(icon, style.text.clone())
912 .contained()
913 .with_style(style.container)
914 })
915 .on_click(MouseButton::Left, move |_, this, cx| {
916 if let Some(search) = this.active_project_search.as_ref() {
917 search.update(cx, |search, cx| search.select_match(direction, cx));
918 }
919 })
920 .with_cursor_style(CursorStyle::PointingHand)
921 .with_tooltip::<NavButton>(
922 direction as usize,
923 tooltip.to_string(),
924 Some(action),
925 tooltip_style,
926 cx,
927 )
928 .into_any()
929 }
930
931 fn render_option_button(
932 &self,
933 icon: &'static str,
934 option: SearchOption,
935 cx: &mut ViewContext<Self>,
936 ) -> AnyElement<Self> {
937 let tooltip_style = theme::current(cx).tooltip.clone();
938 let is_active = self.is_option_enabled(option, cx);
939 MouseEventHandler::<Self, _>::new(option as usize, cx, |state, cx| {
940 let theme = theme::current(cx);
941 let style = theme.search.option_button.style_for(state, is_active);
942 Label::new(icon, style.text.clone())
943 .contained()
944 .with_style(style.container)
945 })
946 .on_click(MouseButton::Left, move |_, this, cx| {
947 this.toggle_search_option(option, cx);
948 })
949 .with_cursor_style(CursorStyle::PointingHand)
950 .with_tooltip::<Self>(
951 option as usize,
952 format!("Toggle {}", option.label()),
953 Some(option.to_toggle_action()),
954 tooltip_style,
955 cx,
956 )
957 .into_any()
958 }
959
960 fn is_option_enabled(&self, option: SearchOption, cx: &AppContext) -> bool {
961 if let Some(search) = self.active_project_search.as_ref() {
962 let search = search.read(cx);
963 match option {
964 SearchOption::WholeWord => search.whole_word,
965 SearchOption::CaseSensitive => search.case_sensitive,
966 SearchOption::Regex => search.regex,
967 }
968 } else {
969 false
970 }
971 }
972}
973
974impl Entity for ProjectSearchBar {
975 type Event = ();
976}
977
978impl View for ProjectSearchBar {
979 fn ui_name() -> &'static str {
980 "ProjectSearchBar"
981 }
982
983 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
984 if let Some(search) = self.active_project_search.as_ref() {
985 let search = search.read(cx);
986 let theme = theme::current(cx).clone();
987 let query_container_style = if search.panels_with_errors.contains(&InputPanel::Query) {
988 theme.search.invalid_editor
989 } else {
990 theme.search.editor.input.container
991 };
992 let include_container_style =
993 if search.panels_with_errors.contains(&InputPanel::Include) {
994 theme.search.invalid_include_exclude_editor
995 } else {
996 theme.search.include_exclude_editor.input.container
997 };
998 let exclude_container_style =
999 if search.panels_with_errors.contains(&InputPanel::Exclude) {
1000 theme.search.invalid_include_exclude_editor
1001 } else {
1002 theme.search.include_exclude_editor.input.container
1003 };
1004
1005 let included_files_view = ChildView::new(&search.included_files_editor, cx)
1006 .aligned()
1007 .left()
1008 .flex(1.0, true);
1009 let excluded_files_view = ChildView::new(&search.excluded_files_editor, cx)
1010 .aligned()
1011 .right()
1012 .flex(1.0, true);
1013
1014 let row_spacing = theme.workspace.toolbar.container.padding.bottom;
1015
1016 Flex::column()
1017 .with_child(
1018 Flex::row()
1019 .with_child(
1020 Flex::row()
1021 .with_child(
1022 ChildView::new(&search.query_editor, cx)
1023 .aligned()
1024 .left()
1025 .flex(1., true),
1026 )
1027 .with_children(search.active_match_index.map(|match_ix| {
1028 Label::new(
1029 format!(
1030 "{}/{}",
1031 match_ix + 1,
1032 search.model.read(cx).match_ranges.len()
1033 ),
1034 theme.search.match_index.text.clone(),
1035 )
1036 .contained()
1037 .with_style(theme.search.match_index.container)
1038 .aligned()
1039 }))
1040 .contained()
1041 .with_style(query_container_style)
1042 .aligned()
1043 .constrained()
1044 .with_min_width(theme.search.editor.min_width)
1045 .with_max_width(theme.search.editor.max_width)
1046 .flex(1., false),
1047 )
1048 .with_child(
1049 Flex::row()
1050 .with_child(self.render_nav_button("<", Direction::Prev, cx))
1051 .with_child(self.render_nav_button(">", Direction::Next, cx))
1052 .aligned(),
1053 )
1054 .with_child(
1055 Flex::row()
1056 .with_child(self.render_option_button(
1057 "Case",
1058 SearchOption::CaseSensitive,
1059 cx,
1060 ))
1061 .with_child(self.render_option_button(
1062 "Word",
1063 SearchOption::WholeWord,
1064 cx,
1065 ))
1066 .with_child(self.render_option_button(
1067 "Regex",
1068 SearchOption::Regex,
1069 cx,
1070 ))
1071 .contained()
1072 .with_style(theme.search.option_button_group)
1073 .aligned(),
1074 )
1075 .contained()
1076 .with_margin_bottom(row_spacing),
1077 )
1078 .with_child(
1079 Flex::row()
1080 .with_child(
1081 Flex::row()
1082 .with_child(included_files_view)
1083 .contained()
1084 .with_style(include_container_style)
1085 .aligned()
1086 .constrained()
1087 .with_min_width(theme.search.include_exclude_editor.min_width)
1088 .with_max_width(theme.search.include_exclude_editor.max_width)
1089 .flex(1., false),
1090 )
1091 .with_child(
1092 Flex::row()
1093 .with_child(excluded_files_view)
1094 .contained()
1095 .with_style(exclude_container_style)
1096 .aligned()
1097 .constrained()
1098 .with_min_width(theme.search.include_exclude_editor.min_width)
1099 .with_max_width(theme.search.include_exclude_editor.max_width)
1100 .flex(1., false),
1101 ),
1102 )
1103 .contained()
1104 .with_style(theme.search.container)
1105 .aligned()
1106 .left()
1107 .into_any_named("project search")
1108 } else {
1109 Empty::new().into_any()
1110 }
1111 }
1112}
1113
1114impl ToolbarItemView for ProjectSearchBar {
1115 fn set_active_pane_item(
1116 &mut self,
1117 active_pane_item: Option<&dyn ItemHandle>,
1118 cx: &mut ViewContext<Self>,
1119 ) -> ToolbarItemLocation {
1120 cx.notify();
1121 self.subscription = None;
1122 self.active_project_search = None;
1123 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
1124 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
1125 self.active_project_search = Some(search);
1126 ToolbarItemLocation::PrimaryLeft {
1127 flex: Some((1., false)),
1128 }
1129 } else {
1130 ToolbarItemLocation::Hidden
1131 }
1132 }
1133
1134 fn row_count(&self) -> usize {
1135 2
1136 }
1137}
1138
1139#[cfg(test)]
1140pub mod tests {
1141 use super::*;
1142 use editor::DisplayPoint;
1143 use gpui::{color::Color, executor::Deterministic, TestAppContext};
1144 use project::FakeFs;
1145 use serde_json::json;
1146 use settings::SettingsStore;
1147 use std::sync::Arc;
1148 use theme::ThemeSettings;
1149
1150 #[gpui::test]
1151 async fn test_project_search(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
1152 init_test(cx);
1153
1154 let fs = FakeFs::new(cx.background());
1155 fs.insert_tree(
1156 "/dir",
1157 json!({
1158 "one.rs": "const ONE: usize = 1;",
1159 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1160 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1161 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1162 }),
1163 )
1164 .await;
1165 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1166 let search = cx.add_model(|cx| ProjectSearch::new(project, cx));
1167 let (_, search_view) = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx));
1168
1169 search_view.update(cx, |search_view, cx| {
1170 search_view
1171 .query_editor
1172 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1173 search_view.search(cx);
1174 });
1175 deterministic.run_until_parked();
1176 search_view.update(cx, |search_view, cx| {
1177 assert_eq!(
1178 search_view
1179 .results_editor
1180 .update(cx, |editor, cx| editor.display_text(cx)),
1181 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
1182 );
1183 assert_eq!(
1184 search_view
1185 .results_editor
1186 .update(cx, |editor, cx| editor.all_background_highlights(cx)),
1187 &[
1188 (
1189 DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
1190 Color::red()
1191 ),
1192 (
1193 DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
1194 Color::red()
1195 ),
1196 (
1197 DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
1198 Color::red()
1199 )
1200 ]
1201 );
1202 assert_eq!(search_view.active_match_index, Some(0));
1203 assert_eq!(
1204 search_view
1205 .results_editor
1206 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1207 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1208 );
1209
1210 search_view.select_match(Direction::Next, cx);
1211 });
1212
1213 search_view.update(cx, |search_view, cx| {
1214 assert_eq!(search_view.active_match_index, Some(1));
1215 assert_eq!(
1216 search_view
1217 .results_editor
1218 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1219 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1220 );
1221 search_view.select_match(Direction::Next, cx);
1222 });
1223
1224 search_view.update(cx, |search_view, cx| {
1225 assert_eq!(search_view.active_match_index, Some(2));
1226 assert_eq!(
1227 search_view
1228 .results_editor
1229 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1230 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1231 );
1232 search_view.select_match(Direction::Next, cx);
1233 });
1234
1235 search_view.update(cx, |search_view, cx| {
1236 assert_eq!(search_view.active_match_index, Some(0));
1237 assert_eq!(
1238 search_view
1239 .results_editor
1240 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1241 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1242 );
1243 search_view.select_match(Direction::Prev, cx);
1244 });
1245
1246 search_view.update(cx, |search_view, cx| {
1247 assert_eq!(search_view.active_match_index, Some(2));
1248 assert_eq!(
1249 search_view
1250 .results_editor
1251 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1252 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1253 );
1254 search_view.select_match(Direction::Prev, cx);
1255 });
1256
1257 search_view.update(cx, |search_view, cx| {
1258 assert_eq!(search_view.active_match_index, Some(1));
1259 assert_eq!(
1260 search_view
1261 .results_editor
1262 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1263 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1264 );
1265 });
1266 }
1267
1268 pub fn init_test(cx: &mut TestAppContext) {
1269 let fonts = cx.font_cache();
1270 let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default);
1271 theme.search.match_background = Color::red();
1272
1273 cx.update(|cx| {
1274 cx.set_global(SettingsStore::test(cx));
1275 cx.set_global(ActiveSearches::default());
1276
1277 theme::init((), cx);
1278 cx.update_global::<SettingsStore, _, _>(|store, _| {
1279 let mut settings = store.get::<ThemeSettings>(None).clone();
1280 settings.theme = Arc::new(theme);
1281 store.override_global(settings)
1282 });
1283
1284 language::init(cx);
1285 client::init_settings(cx);
1286 editor::init_settings(cx);
1287 workspace::init_settings(cx);
1288 });
1289 }
1290}