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