1use crate::{
2 SearchOptions, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex,
3 ToggleWholeWord,
4};
5use anyhow::Result;
6use collections::HashMap;
7use editor::{
8 items::active_match_index, scroll::autoscroll::Autoscroll, Anchor, Editor, MultiBuffer,
9 SelectAll, MAX_TAB_TITLE_LEN,
10};
11use futures::StreamExt;
12use globset::{Glob, GlobMatcher};
13use gpui::{
14 actions,
15 elements::*,
16 platform::{CursorStyle, MouseButton},
17 Action, AnyElement, AnyViewHandle, AppContext, Entity, ModelContext, ModelHandle, Subscription,
18 Task, View, ViewContext, ViewHandle, WeakModelHandle, WeakViewHandle,
19};
20use menu::Confirm;
21use postage::stream::Stream;
22use project::{search::SearchQuery, Entry, Project};
23use semantic_index::SemanticIndex;
24use smallvec::SmallVec;
25use std::{
26 any::{Any, TypeId},
27 borrow::Cow,
28 collections::HashSet,
29 mem,
30 ops::{Not, Range},
31 path::PathBuf,
32 sync::Arc,
33};
34use util::ResultExt as _;
35use workspace::{
36 item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
37 searchable::{Direction, SearchableItem, SearchableItemHandle},
38 ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId,
39};
40
41actions!(
42 project_search,
43 [SearchInNew, ToggleFocus, NextField, ToggleSemanticSearch]
44);
45
46#[derive(Default)]
47struct ActiveSearches(HashMap<WeakModelHandle<Project>, WeakViewHandle<ProjectSearchView>>);
48
49pub fn init(cx: &mut AppContext) {
50 cx.set_global(ActiveSearches::default());
51 cx.add_action(ProjectSearchView::deploy);
52 cx.add_action(ProjectSearchView::move_focus_to_results);
53 cx.add_action(ProjectSearchBar::search);
54 cx.add_action(ProjectSearchBar::search_in_new);
55 cx.add_action(ProjectSearchBar::select_next_match);
56 cx.add_action(ProjectSearchBar::select_prev_match);
57 cx.capture_action(ProjectSearchBar::tab);
58 cx.capture_action(ProjectSearchBar::tab_previous);
59 add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
60 add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
61 add_toggle_option_action::<ToggleRegex>(SearchOptions::REGEX, cx);
62}
63
64fn add_toggle_option_action<A: Action>(option: SearchOptions, cx: &mut AppContext) {
65 cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext<Pane>| {
66 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<ProjectSearchBar>() {
67 if search_bar.update(cx, |search_bar, cx| {
68 search_bar.toggle_search_option(option, cx)
69 }) {
70 return;
71 }
72 }
73 cx.propagate_action();
74 });
75}
76
77struct ProjectSearch {
78 project: ModelHandle<Project>,
79 excerpts: ModelHandle<MultiBuffer>,
80 pending_search: Option<Task<Option<()>>>,
81 match_ranges: Vec<Range<Anchor>>,
82 active_query: Option<SearchQuery>,
83 search_id: usize,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87enum InputPanel {
88 Query,
89 Exclude,
90 Include,
91}
92
93pub struct ProjectSearchView {
94 model: ModelHandle<ProjectSearch>,
95 query_editor: ViewHandle<Editor>,
96 results_editor: ViewHandle<Editor>,
97 semantic: Option<SemanticSearchState>,
98 search_options: SearchOptions,
99 panels_with_errors: HashSet<InputPanel>,
100 active_match_index: Option<usize>,
101 search_id: usize,
102 query_editor_was_focused: bool,
103 included_files_editor: ViewHandle<Editor>,
104 excluded_files_editor: ViewHandle<Editor>,
105}
106
107struct SemanticSearchState {
108 file_count: usize,
109 outstanding_file_count: usize,
110 _progress_task: Task<()>,
111}
112
113pub struct ProjectSearchBar {
114 active_project_search: Option<ViewHandle<ProjectSearchView>>,
115 subscription: Option<Subscription>,
116}
117
118impl Entity for ProjectSearch {
119 type Event = ();
120}
121
122impl ProjectSearch {
123 fn new(project: ModelHandle<Project>, cx: &mut ModelContext<Self>) -> Self {
124 let replica_id = project.read(cx).replica_id();
125 Self {
126 project,
127 excerpts: cx.add_model(|_| MultiBuffer::new(replica_id)),
128 pending_search: Default::default(),
129 match_ranges: Default::default(),
130 active_query: None,
131 search_id: 0,
132 }
133 }
134
135 fn clone(&self, cx: &mut ModelContext<Self>) -> ModelHandle<Self> {
136 cx.add_model(|cx| Self {
137 project: self.project.clone(),
138 excerpts: self
139 .excerpts
140 .update(cx, |excerpts, cx| cx.add_model(|cx| excerpts.clone(cx))),
141 pending_search: Default::default(),
142 match_ranges: self.match_ranges.clone(),
143 active_query: self.active_query.clone(),
144 search_id: self.search_id,
145 })
146 }
147
148 fn search(&mut self, query: SearchQuery, cx: &mut ModelContext<Self>) {
149 let search = self
150 .project
151 .update(cx, |project, cx| project.search(query.clone(), cx));
152 self.search_id += 1;
153 self.active_query = Some(query);
154 self.match_ranges.clear();
155 self.pending_search = Some(cx.spawn_weak(|this, mut cx| async move {
156 let matches = search.await.log_err()?;
157 let this = this.upgrade(&cx)?;
158 let mut matches = matches.into_iter().collect::<Vec<_>>();
159 let (_task, mut match_ranges) = this.update(&mut cx, |this, cx| {
160 this.match_ranges.clear();
161 matches.sort_by_key(|(buffer, _)| buffer.read(cx).file().map(|file| file.path()));
162 this.excerpts.update(cx, |excerpts, cx| {
163 excerpts.clear(cx);
164 excerpts.stream_excerpts_with_context_lines(matches, 1, cx)
165 })
166 });
167
168 while let Some(match_range) = match_ranges.next().await {
169 this.update(&mut cx, |this, cx| {
170 this.match_ranges.push(match_range);
171 while let Ok(Some(match_range)) = match_ranges.try_next() {
172 this.match_ranges.push(match_range);
173 }
174 cx.notify();
175 });
176 }
177
178 this.update(&mut cx, |this, cx| {
179 this.pending_search.take();
180 cx.notify();
181 });
182
183 None
184 }));
185 cx.notify();
186 }
187
188 fn semantic_search(
189 &mut self,
190 query: String,
191 include_files: Vec<GlobMatcher>,
192 exclude_files: Vec<GlobMatcher>,
193 cx: &mut ModelContext<Self>,
194 ) {
195 let search = SemanticIndex::global(cx).map(|index| {
196 index.update(cx, |semantic_index, cx| {
197 semantic_index.search_project(
198 self.project.clone(),
199 query.clone(),
200 10,
201 include_files,
202 exclude_files,
203 cx,
204 )
205 })
206 });
207 self.search_id += 1;
208 self.match_ranges.clear();
209 self.pending_search = Some(cx.spawn(|this, mut cx| async move {
210 let results = search?.await.log_err()?;
211
212 let (_task, mut match_ranges) = this.update(&mut cx, |this, cx| {
213 this.excerpts.update(cx, |excerpts, cx| {
214 excerpts.clear(cx);
215
216 let matches = results
217 .into_iter()
218 .map(|result| (result.buffer, vec![result.range.start..result.range.start]))
219 .collect();
220
221 excerpts.stream_excerpts_with_context_lines(matches, 3, cx)
222 })
223 });
224
225 while let Some(match_range) = match_ranges.next().await {
226 this.update(&mut cx, |this, cx| {
227 this.match_ranges.push(match_range);
228 while let Ok(Some(match_range)) = match_ranges.try_next() {
229 this.match_ranges.push(match_range);
230 }
231 cx.notify();
232 });
233 }
234
235 this.update(&mut cx, |this, cx| {
236 this.pending_search.take();
237 cx.notify();
238 });
239
240 None
241 }));
242 cx.notify();
243 }
244}
245
246pub enum ViewEvent {
247 UpdateTab,
248 Activate,
249 EditorEvent(editor::Event),
250}
251
252impl Entity for ProjectSearchView {
253 type Event = ViewEvent;
254}
255
256impl View for ProjectSearchView {
257 fn ui_name() -> &'static str {
258 "ProjectSearchView"
259 }
260
261 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
262 let model = &self.model.read(cx);
263 if model.match_ranges.is_empty() {
264 enum Status {}
265
266 let theme = theme::current(cx).clone();
267 let text = if model.pending_search.is_some() {
268 Cow::Borrowed("Searching...")
269 } else if let Some(semantic) = &self.semantic {
270 if semantic.outstanding_file_count > 0 {
271 Cow::Owned(format!(
272 "Indexing. {} of {}...",
273 semantic.file_count - semantic.outstanding_file_count,
274 semantic.file_count
275 ))
276 } else {
277 Cow::Borrowed("Indexing complete")
278 }
279 } else if self.query_editor.read(cx).text(cx).is_empty() {
280 Cow::Borrowed("")
281 } else {
282 Cow::Borrowed("No results")
283 };
284
285 MouseEventHandler::<Status, _>::new(0, cx, |_, _| {
286 Label::new(text, theme.search.results_status.clone())
287 .aligned()
288 .contained()
289 .with_background_color(theme.editor.background)
290 .flex(1., true)
291 })
292 .on_down(MouseButton::Left, |_, _, cx| {
293 cx.focus_parent();
294 })
295 .into_any_named("project search view")
296 } else {
297 ChildView::new(&self.results_editor, cx)
298 .flex(1., true)
299 .into_any_named("project search view")
300 }
301 }
302
303 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
304 let handle = cx.weak_handle();
305 cx.update_global(|state: &mut ActiveSearches, cx| {
306 state
307 .0
308 .insert(self.model.read(cx).project.downgrade(), handle)
309 });
310
311 if cx.is_self_focused() {
312 if self.query_editor_was_focused {
313 cx.focus(&self.query_editor);
314 } else {
315 cx.focus(&self.results_editor);
316 }
317 }
318 }
319}
320
321impl Item for ProjectSearchView {
322 fn tab_tooltip_text(&self, cx: &AppContext) -> Option<Cow<str>> {
323 let query_text = self.query_editor.read(cx).text(cx);
324
325 query_text
326 .is_empty()
327 .not()
328 .then(|| query_text.into())
329 .or_else(|| Some("Project Search".into()))
330 }
331
332 fn act_as_type<'a>(
333 &'a self,
334 type_id: TypeId,
335 self_handle: &'a ViewHandle<Self>,
336 _: &'a AppContext,
337 ) -> Option<&'a AnyViewHandle> {
338 if type_id == TypeId::of::<Self>() {
339 Some(self_handle)
340 } else if type_id == TypeId::of::<Editor>() {
341 Some(&self.results_editor)
342 } else {
343 None
344 }
345 }
346
347 fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
348 self.results_editor
349 .update(cx, |editor, cx| editor.deactivated(cx));
350 }
351
352 fn tab_content<T: View>(
353 &self,
354 _detail: Option<usize>,
355 tab_theme: &theme::Tab,
356 cx: &AppContext,
357 ) -> AnyElement<T> {
358 Flex::row()
359 .with_child(
360 Svg::new("icons/magnifying_glass_12.svg")
361 .with_color(tab_theme.label.text.color)
362 .constrained()
363 .with_width(tab_theme.type_icon_width)
364 .aligned()
365 .contained()
366 .with_margin_right(tab_theme.spacing),
367 )
368 .with_children(self.model.read(cx).active_query.as_ref().map(|query| {
369 let query_text = util::truncate_and_trailoff(query.as_str(), MAX_TAB_TITLE_LEN);
370
371 Label::new(query_text, tab_theme.label.clone()).aligned()
372 }))
373 .into_any()
374 }
375
376 fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
377 self.results_editor.for_each_project_item(cx, f)
378 }
379
380 fn is_singleton(&self, _: &AppContext) -> bool {
381 false
382 }
383
384 fn can_save(&self, _: &AppContext) -> bool {
385 true
386 }
387
388 fn is_dirty(&self, cx: &AppContext) -> bool {
389 self.results_editor.read(cx).is_dirty(cx)
390 }
391
392 fn has_conflict(&self, cx: &AppContext) -> bool {
393 self.results_editor.read(cx).has_conflict(cx)
394 }
395
396 fn save(
397 &mut self,
398 project: ModelHandle<Project>,
399 cx: &mut ViewContext<Self>,
400 ) -> Task<anyhow::Result<()>> {
401 self.results_editor
402 .update(cx, |editor, cx| editor.save(project, cx))
403 }
404
405 fn save_as(
406 &mut self,
407 _: ModelHandle<Project>,
408 _: PathBuf,
409 _: &mut ViewContext<Self>,
410 ) -> Task<anyhow::Result<()>> {
411 unreachable!("save_as should not have been called")
412 }
413
414 fn reload(
415 &mut self,
416 project: ModelHandle<Project>,
417 cx: &mut ViewContext<Self>,
418 ) -> Task<anyhow::Result<()>> {
419 self.results_editor
420 .update(cx, |editor, cx| editor.reload(project, cx))
421 }
422
423 fn clone_on_split(&self, _workspace_id: WorkspaceId, cx: &mut ViewContext<Self>) -> Option<Self>
424 where
425 Self: Sized,
426 {
427 let model = self.model.update(cx, |model, cx| model.clone(cx));
428 Some(Self::new(model, cx))
429 }
430
431 fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
432 self.results_editor
433 .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx));
434 }
435
436 fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
437 self.results_editor.update(cx, |editor, _| {
438 editor.set_nav_history(Some(nav_history));
439 });
440 }
441
442 fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
443 self.results_editor
444 .update(cx, |editor, cx| editor.navigate(data, cx))
445 }
446
447 fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> {
448 match event {
449 ViewEvent::UpdateTab => {
450 smallvec::smallvec![ItemEvent::UpdateBreadcrumbs, ItemEvent::UpdateTab]
451 }
452 ViewEvent::EditorEvent(editor_event) => Editor::to_item_events(editor_event),
453 _ => SmallVec::new(),
454 }
455 }
456
457 fn breadcrumb_location(&self) -> ToolbarItemLocation {
458 if self.has_matches() {
459 ToolbarItemLocation::Secondary
460 } else {
461 ToolbarItemLocation::Hidden
462 }
463 }
464
465 fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
466 self.results_editor.breadcrumbs(theme, cx)
467 }
468
469 fn serialized_item_kind() -> Option<&'static str> {
470 None
471 }
472
473 fn deserialize(
474 _project: ModelHandle<Project>,
475 _workspace: WeakViewHandle<Workspace>,
476 _workspace_id: workspace::WorkspaceId,
477 _item_id: workspace::ItemId,
478 _cx: &mut ViewContext<Pane>,
479 ) -> Task<anyhow::Result<ViewHandle<Self>>> {
480 unimplemented!()
481 }
482}
483
484impl ProjectSearchView {
485 fn new(model: ModelHandle<ProjectSearch>, cx: &mut ViewContext<Self>) -> Self {
486 let project;
487 let excerpts;
488 let mut query_text = String::new();
489 let mut options = SearchOptions::NONE;
490
491 {
492 let model = model.read(cx);
493 project = model.project.clone();
494 excerpts = model.excerpts.clone();
495 if let Some(active_query) = model.active_query.as_ref() {
496 query_text = active_query.as_str().to_string();
497 options = SearchOptions::from_query(active_query);
498 }
499 }
500 cx.observe(&model, |this, _, cx| this.model_changed(cx))
501 .detach();
502
503 let query_editor = cx.add_view(|cx| {
504 let mut editor = Editor::single_line(
505 Some(Arc::new(|theme| theme.search.editor.input.clone())),
506 cx,
507 );
508 editor.set_text(query_text, cx);
509 editor
510 });
511 // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
512 cx.subscribe(&query_editor, |_, _, event, cx| {
513 cx.emit(ViewEvent::EditorEvent(event.clone()))
514 })
515 .detach();
516
517 let results_editor = cx.add_view(|cx| {
518 let mut editor = Editor::for_multibuffer(excerpts, Some(project), cx);
519 editor.set_searchable(false);
520 editor
521 });
522 cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab))
523 .detach();
524
525 cx.subscribe(&results_editor, |this, _, event, cx| {
526 if matches!(event, editor::Event::SelectionsChanged { .. }) {
527 this.update_match_index(cx);
528 }
529 // Reraise editor events for workspace item activation purposes
530 cx.emit(ViewEvent::EditorEvent(event.clone()));
531 })
532 .detach();
533
534 let included_files_editor = cx.add_view(|cx| {
535 let mut editor = Editor::single_line(
536 Some(Arc::new(|theme| {
537 theme.search.include_exclude_editor.input.clone()
538 })),
539 cx,
540 );
541 editor.set_placeholder_text("Include: crates/**/*.toml", cx);
542
543 editor
544 });
545 // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes
546 cx.subscribe(&included_files_editor, |_, _, event, cx| {
547 cx.emit(ViewEvent::EditorEvent(event.clone()))
548 })
549 .detach();
550
551 let excluded_files_editor = cx.add_view(|cx| {
552 let mut editor = Editor::single_line(
553 Some(Arc::new(|theme| {
554 theme.search.include_exclude_editor.input.clone()
555 })),
556 cx,
557 );
558 editor.set_placeholder_text("Exclude: vendor/*, *.lock", cx);
559
560 editor
561 });
562 // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes
563 cx.subscribe(&excluded_files_editor, |_, _, event, cx| {
564 cx.emit(ViewEvent::EditorEvent(event.clone()))
565 })
566 .detach();
567
568 let mut this = ProjectSearchView {
569 search_id: model.read(cx).search_id,
570 model,
571 query_editor,
572 results_editor,
573 semantic: None,
574 search_options: options,
575 panels_with_errors: HashSet::new(),
576 active_match_index: None,
577 query_editor_was_focused: false,
578 included_files_editor,
579 excluded_files_editor,
580 };
581 this.model_changed(cx);
582 this
583 }
584
585 pub fn new_search_in_directory(
586 workspace: &mut Workspace,
587 dir_entry: &Entry,
588 cx: &mut ViewContext<Workspace>,
589 ) {
590 if !dir_entry.is_dir() {
591 return;
592 }
593 let filter_path = dir_entry.path.join("**");
594 let Some(filter_str) = filter_path.to_str() else { return; };
595
596 let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
597 let search = cx.add_view(|cx| ProjectSearchView::new(model, cx));
598 workspace.add_item(Box::new(search.clone()), cx);
599 search.update(cx, |search, cx| {
600 search
601 .included_files_editor
602 .update(cx, |editor, cx| editor.set_text(filter_str, cx));
603 search.focus_query_editor(cx)
604 });
605 }
606
607 // Re-activate the most recently activated search or the most recent if it has been closed.
608 // If no search exists in the workspace, create a new one.
609 fn deploy(
610 workspace: &mut Workspace,
611 _: &workspace::NewSearch,
612 cx: &mut ViewContext<Workspace>,
613 ) {
614 // Clean up entries for dropped projects
615 cx.update_global(|state: &mut ActiveSearches, cx| {
616 state.0.retain(|project, _| project.is_upgradable(cx))
617 });
618
619 let active_search = cx
620 .global::<ActiveSearches>()
621 .0
622 .get(&workspace.project().downgrade());
623
624 let existing = active_search
625 .and_then(|active_search| {
626 workspace
627 .items_of_type::<ProjectSearchView>(cx)
628 .find(|search| search == active_search)
629 })
630 .or_else(|| workspace.item_of_type::<ProjectSearchView>(cx));
631
632 let query = workspace.active_item(cx).and_then(|item| {
633 let editor = item.act_as::<Editor>(cx)?;
634 let query = editor.query_suggestion(cx);
635 if query.is_empty() {
636 None
637 } else {
638 Some(query)
639 }
640 });
641
642 let search = if let Some(existing) = existing {
643 workspace.activate_item(&existing, cx);
644 existing
645 } else {
646 let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
647 let view = cx.add_view(|cx| ProjectSearchView::new(model, cx));
648 workspace.add_item(Box::new(view.clone()), cx);
649 view
650 };
651
652 search.update(cx, |search, cx| {
653 if let Some(query) = query {
654 search.set_query(&query, cx);
655 }
656 search.focus_query_editor(cx)
657 });
658 }
659
660 fn search(&mut self, cx: &mut ViewContext<Self>) {
661 if let Some(semantic) = &mut self.semantic {
662 if semantic.outstanding_file_count > 0 {
663 return;
664 }
665
666 let query = self.query_editor.read(cx).text(cx);
667 if let Some((included_files, exclude_files)) =
668 self.get_included_and_excluded_globsets(cx)
669 {
670 self.model.update(cx, |model, cx| {
671 model.semantic_search(query, included_files, exclude_files, cx)
672 });
673 }
674 return;
675 }
676
677 if let Some(query) = self.build_search_query(cx) {
678 self.model.update(cx, |model, cx| model.search(query, cx));
679 }
680 }
681
682 fn get_included_and_excluded_globsets(
683 &mut self,
684 cx: &mut ViewContext<Self>,
685 ) -> Option<(Vec<GlobMatcher>, Vec<GlobMatcher>)> {
686 let included_files =
687 match Self::load_glob_set(&self.included_files_editor.read(cx).text(cx)) {
688 Ok(included_files) => {
689 self.panels_with_errors.remove(&InputPanel::Include);
690 included_files
691 }
692 Err(_e) => {
693 self.panels_with_errors.insert(InputPanel::Include);
694 cx.notify();
695 return None;
696 }
697 };
698 let excluded_files =
699 match Self::load_glob_set(&self.excluded_files_editor.read(cx).text(cx)) {
700 Ok(excluded_files) => {
701 self.panels_with_errors.remove(&InputPanel::Exclude);
702 excluded_files
703 }
704 Err(_e) => {
705 self.panels_with_errors.insert(InputPanel::Exclude);
706 cx.notify();
707 return None;
708 }
709 };
710
711 Some((included_files, excluded_files))
712 }
713
714 fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
715 let text = self.query_editor.read(cx).text(cx);
716 let included_files =
717 match Self::load_glob_set(&self.included_files_editor.read(cx).text(cx)) {
718 Ok(included_files) => {
719 self.panels_with_errors.remove(&InputPanel::Include);
720 included_files
721 }
722 Err(_e) => {
723 self.panels_with_errors.insert(InputPanel::Include);
724 cx.notify();
725 return None;
726 }
727 };
728 let excluded_files =
729 match Self::load_glob_set(&self.excluded_files_editor.read(cx).text(cx)) {
730 Ok(excluded_files) => {
731 self.panels_with_errors.remove(&InputPanel::Exclude);
732 excluded_files
733 }
734 Err(_e) => {
735 self.panels_with_errors.insert(InputPanel::Exclude);
736 cx.notify();
737 return None;
738 }
739 };
740 if self.search_options.contains(SearchOptions::REGEX) {
741 match SearchQuery::regex(
742 text,
743 self.search_options.contains(SearchOptions::WHOLE_WORD),
744 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
745 included_files,
746 excluded_files,
747 ) {
748 Ok(query) => {
749 self.panels_with_errors.remove(&InputPanel::Query);
750 Some(query)
751 }
752 Err(_e) => {
753 self.panels_with_errors.insert(InputPanel::Query);
754 cx.notify();
755 None
756 }
757 }
758 } else {
759 Some(SearchQuery::text(
760 text,
761 self.search_options.contains(SearchOptions::WHOLE_WORD),
762 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
763 included_files,
764 excluded_files,
765 ))
766 }
767 }
768
769 fn load_glob_set(text: &str) -> Result<Vec<GlobMatcher>> {
770 text.split(',')
771 .map(str::trim)
772 .filter(|glob_str| !glob_str.is_empty())
773 .map(|glob_str| anyhow::Ok(Glob::new(glob_str)?.compile_matcher()))
774 .collect()
775 }
776
777 fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
778 if let Some(index) = self.active_match_index {
779 let match_ranges = self.model.read(cx).match_ranges.clone();
780 let new_index = self.results_editor.update(cx, |editor, cx| {
781 editor.match_index_for_direction(&match_ranges, index, direction, 1, cx)
782 });
783
784 let range_to_select = match_ranges[new_index].clone();
785 self.results_editor.update(cx, |editor, cx| {
786 editor.unfold_ranges([range_to_select.clone()], false, true, cx);
787 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
788 s.select_ranges([range_to_select])
789 });
790 });
791 }
792 }
793
794 fn focus_query_editor(&mut self, cx: &mut ViewContext<Self>) {
795 self.query_editor.update(cx, |query_editor, cx| {
796 query_editor.select_all(&SelectAll, cx);
797 });
798 self.query_editor_was_focused = true;
799 cx.focus(&self.query_editor);
800 }
801
802 fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
803 self.query_editor
804 .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
805 }
806
807 fn focus_results_editor(&mut self, cx: &mut ViewContext<Self>) {
808 self.query_editor.update(cx, |query_editor, cx| {
809 let cursor = query_editor.selections.newest_anchor().head();
810 query_editor.change_selections(None, cx, |s| s.select_ranges([cursor.clone()..cursor]));
811 });
812 self.query_editor_was_focused = false;
813 cx.focus(&self.results_editor);
814 }
815
816 fn model_changed(&mut self, cx: &mut ViewContext<Self>) {
817 let match_ranges = self.model.read(cx).match_ranges.clone();
818 if match_ranges.is_empty() {
819 self.active_match_index = None;
820 } else {
821 self.active_match_index = Some(0);
822 self.update_match_index(cx);
823 let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id);
824 let is_new_search = self.search_id != prev_search_id;
825 self.results_editor.update(cx, |editor, cx| {
826 if is_new_search {
827 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
828 s.select_ranges(match_ranges.first().cloned())
829 });
830 }
831 editor.highlight_background::<Self>(
832 match_ranges,
833 |theme| theme.search.match_background,
834 cx,
835 );
836 });
837 if is_new_search && self.query_editor.is_focused(cx) {
838 self.focus_results_editor(cx);
839 }
840 }
841
842 cx.emit(ViewEvent::UpdateTab);
843 cx.notify();
844 }
845
846 fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
847 let results_editor = self.results_editor.read(cx);
848 let new_index = active_match_index(
849 &self.model.read(cx).match_ranges,
850 &results_editor.selections.newest_anchor().head(),
851 &results_editor.buffer().read(cx).snapshot(cx),
852 );
853 if self.active_match_index != new_index {
854 self.active_match_index = new_index;
855 cx.notify();
856 }
857 }
858
859 pub fn has_matches(&self) -> bool {
860 self.active_match_index.is_some()
861 }
862
863 fn move_focus_to_results(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext<Pane>) {
864 if let Some(search_view) = pane
865 .active_item()
866 .and_then(|item| item.downcast::<ProjectSearchView>())
867 {
868 search_view.update(cx, |search_view, cx| {
869 if !search_view.results_editor.is_focused(cx)
870 && !search_view.model.read(cx).match_ranges.is_empty()
871 {
872 return search_view.focus_results_editor(cx);
873 }
874 });
875 }
876
877 cx.propagate_action();
878 }
879}
880
881impl Default for ProjectSearchBar {
882 fn default() -> Self {
883 Self::new()
884 }
885}
886
887impl ProjectSearchBar {
888 pub fn new() -> Self {
889 Self {
890 active_project_search: Default::default(),
891 subscription: Default::default(),
892 }
893 }
894
895 fn search(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
896 if let Some(search_view) = self.active_project_search.as_ref() {
897 search_view.update(cx, |search_view, cx| search_view.search(cx));
898 }
899 }
900
901 fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
902 if let Some(search_view) = workspace
903 .active_item(cx)
904 .and_then(|item| item.downcast::<ProjectSearchView>())
905 {
906 let new_query = search_view.update(cx, |search_view, cx| {
907 let new_query = search_view.build_search_query(cx);
908 if new_query.is_some() {
909 if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
910 search_view.query_editor.update(cx, |editor, cx| {
911 editor.set_text(old_query.as_str(), cx);
912 });
913 search_view.search_options = SearchOptions::from_query(&old_query);
914 }
915 }
916 new_query
917 });
918 if let Some(new_query) = new_query {
919 let model = cx.add_model(|cx| {
920 let mut model = ProjectSearch::new(workspace.project().clone(), cx);
921 model.search(new_query, cx);
922 model
923 });
924 workspace.add_item(
925 Box::new(cx.add_view(|cx| ProjectSearchView::new(model, cx))),
926 cx,
927 );
928 }
929 }
930 }
931
932 fn select_next_match(pane: &mut Pane, _: &SelectNextMatch, cx: &mut ViewContext<Pane>) {
933 if let Some(search_view) = pane
934 .active_item()
935 .and_then(|item| item.downcast::<ProjectSearchView>())
936 {
937 search_view.update(cx, |view, cx| view.select_match(Direction::Next, cx));
938 } else {
939 cx.propagate_action();
940 }
941 }
942
943 fn select_prev_match(pane: &mut Pane, _: &SelectPrevMatch, cx: &mut ViewContext<Pane>) {
944 if let Some(search_view) = pane
945 .active_item()
946 .and_then(|item| item.downcast::<ProjectSearchView>())
947 {
948 search_view.update(cx, |view, cx| view.select_match(Direction::Prev, cx));
949 } else {
950 cx.propagate_action();
951 }
952 }
953
954 fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
955 self.cycle_field(Direction::Next, cx);
956 }
957
958 fn tab_previous(&mut self, _: &editor::TabPrev, cx: &mut ViewContext<Self>) {
959 self.cycle_field(Direction::Prev, cx);
960 }
961
962 fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
963 let active_project_search = match &self.active_project_search {
964 Some(active_project_search) => active_project_search,
965
966 None => {
967 cx.propagate_action();
968 return;
969 }
970 };
971
972 active_project_search.update(cx, |project_view, cx| {
973 let views = &[
974 &project_view.query_editor,
975 &project_view.included_files_editor,
976 &project_view.excluded_files_editor,
977 ];
978
979 let current_index = match views
980 .iter()
981 .enumerate()
982 .find(|(_, view)| view.is_focused(cx))
983 {
984 Some((index, _)) => index,
985
986 None => {
987 cx.propagate_action();
988 return;
989 }
990 };
991
992 let new_index = match direction {
993 Direction::Next => (current_index + 1) % views.len(),
994 Direction::Prev if current_index == 0 => views.len() - 1,
995 Direction::Prev => (current_index - 1) % views.len(),
996 };
997 cx.focus(views[new_index]);
998 });
999 }
1000
1001 fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) -> bool {
1002 if let Some(search_view) = self.active_project_search.as_ref() {
1003 search_view.update(cx, |search_view, cx| {
1004 search_view.search_options.toggle(option);
1005 search_view.semantic = None;
1006 search_view.search(cx);
1007 });
1008 cx.notify();
1009 true
1010 } else {
1011 false
1012 }
1013 }
1014
1015 fn toggle_semantic_search(&mut self, cx: &mut ViewContext<Self>) -> bool {
1016 if let Some(search_view) = self.active_project_search.as_ref() {
1017 search_view.update(cx, |search_view, cx| {
1018 if search_view.semantic.is_some() {
1019 search_view.semantic = None;
1020 } else if let Some(semantic_index) = SemanticIndex::global(cx) {
1021 // TODO: confirm that it's ok to send this project
1022 search_view.search_options = SearchOptions::none();
1023
1024 let project = search_view.model.read(cx).project.clone();
1025 let index_task = semantic_index.update(cx, |semantic_index, cx| {
1026 semantic_index.index_project(project, cx)
1027 });
1028
1029 cx.spawn(|search_view, mut cx| async move {
1030 let (files_to_index, mut files_remaining_rx) = index_task.await?;
1031
1032 search_view.update(&mut cx, |search_view, cx| {
1033 cx.notify();
1034 search_view.semantic = Some(SemanticSearchState {
1035 file_count: files_to_index,
1036 outstanding_file_count: files_to_index,
1037 _progress_task: cx.spawn(|search_view, mut cx| async move {
1038 while let Some(count) = files_remaining_rx.recv().await {
1039 search_view
1040 .update(&mut cx, |search_view, cx| {
1041 if let Some(semantic_search_state) =
1042 &mut search_view.semantic
1043 {
1044 semantic_search_state.outstanding_file_count =
1045 count;
1046 cx.notify();
1047 if count == 0 {
1048 return;
1049 }
1050 }
1051 })
1052 .ok();
1053 }
1054 }),
1055 });
1056 })?;
1057 anyhow::Ok(())
1058 })
1059 .detach_and_log_err(cx);
1060 }
1061 cx.notify();
1062 });
1063 cx.notify();
1064 true
1065 } else {
1066 false
1067 }
1068 }
1069
1070 fn render_nav_button(
1071 &self,
1072 icon: &'static str,
1073 direction: Direction,
1074 cx: &mut ViewContext<Self>,
1075 ) -> AnyElement<Self> {
1076 let action: Box<dyn Action>;
1077 let tooltip;
1078 match direction {
1079 Direction::Prev => {
1080 action = Box::new(SelectPrevMatch);
1081 tooltip = "Select Previous Match";
1082 }
1083 Direction::Next => {
1084 action = Box::new(SelectNextMatch);
1085 tooltip = "Select Next Match";
1086 }
1087 };
1088 let tooltip_style = theme::current(cx).tooltip.clone();
1089
1090 enum NavButton {}
1091 MouseEventHandler::<NavButton, _>::new(direction as usize, cx, |state, cx| {
1092 let theme = theme::current(cx);
1093 let style = theme.search.option_button.inactive_state().style_for(state);
1094 Label::new(icon, style.text.clone())
1095 .contained()
1096 .with_style(style.container)
1097 })
1098 .on_click(MouseButton::Left, move |_, this, cx| {
1099 if let Some(search) = this.active_project_search.as_ref() {
1100 search.update(cx, |search, cx| search.select_match(direction, cx));
1101 }
1102 })
1103 .with_cursor_style(CursorStyle::PointingHand)
1104 .with_tooltip::<NavButton>(
1105 direction as usize,
1106 tooltip.to_string(),
1107 Some(action),
1108 tooltip_style,
1109 cx,
1110 )
1111 .into_any()
1112 }
1113
1114 fn render_option_button(
1115 &self,
1116 icon: &'static str,
1117 option: SearchOptions,
1118 cx: &mut ViewContext<Self>,
1119 ) -> AnyElement<Self> {
1120 let tooltip_style = theme::current(cx).tooltip.clone();
1121 let is_active = self.is_option_enabled(option, cx);
1122 MouseEventHandler::<Self, _>::new(option.bits as usize, cx, |state, cx| {
1123 let theme = theme::current(cx);
1124 let style = theme
1125 .search
1126 .option_button
1127 .in_state(is_active)
1128 .style_for(state);
1129 Label::new(icon, style.text.clone())
1130 .contained()
1131 .with_style(style.container)
1132 })
1133 .on_click(MouseButton::Left, move |_, this, cx| {
1134 this.toggle_search_option(option, cx);
1135 })
1136 .with_cursor_style(CursorStyle::PointingHand)
1137 .with_tooltip::<Self>(
1138 option.bits as usize,
1139 format!("Toggle {}", option.label()),
1140 Some(option.to_toggle_action()),
1141 tooltip_style,
1142 cx,
1143 )
1144 .into_any()
1145 }
1146
1147 fn render_semantic_search_button(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
1148 let tooltip_style = theme::current(cx).tooltip.clone();
1149 let is_active = if let Some(search) = self.active_project_search.as_ref() {
1150 let search = search.read(cx);
1151 search.semantic.is_some()
1152 } else {
1153 false
1154 };
1155
1156 let region_id = 3;
1157
1158 MouseEventHandler::<Self, _>::new(region_id, cx, |state, cx| {
1159 let theme = theme::current(cx);
1160 let style = theme
1161 .search
1162 .option_button
1163 .in_state(is_active)
1164 .style_for(state);
1165 Label::new("Semantic", style.text.clone())
1166 .contained()
1167 .with_style(style.container)
1168 })
1169 .on_click(MouseButton::Left, move |_, this, cx| {
1170 this.toggle_semantic_search(cx);
1171 })
1172 .with_cursor_style(CursorStyle::PointingHand)
1173 .with_tooltip::<Self>(
1174 region_id,
1175 format!("Toggle Semantic Search"),
1176 Some(Box::new(ToggleSemanticSearch)),
1177 tooltip_style,
1178 cx,
1179 )
1180 .into_any()
1181 }
1182
1183 fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool {
1184 if let Some(search) = self.active_project_search.as_ref() {
1185 search.read(cx).search_options.contains(option)
1186 } else {
1187 false
1188 }
1189 }
1190}
1191
1192impl Entity for ProjectSearchBar {
1193 type Event = ();
1194}
1195
1196impl View for ProjectSearchBar {
1197 fn ui_name() -> &'static str {
1198 "ProjectSearchBar"
1199 }
1200
1201 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
1202 if let Some(search) = self.active_project_search.as_ref() {
1203 let search = search.read(cx);
1204 let theme = theme::current(cx).clone();
1205 let query_container_style = if search.panels_with_errors.contains(&InputPanel::Query) {
1206 theme.search.invalid_editor
1207 } else {
1208 theme.search.editor.input.container
1209 };
1210 let include_container_style =
1211 if search.panels_with_errors.contains(&InputPanel::Include) {
1212 theme.search.invalid_include_exclude_editor
1213 } else {
1214 theme.search.include_exclude_editor.input.container
1215 };
1216 let exclude_container_style =
1217 if search.panels_with_errors.contains(&InputPanel::Exclude) {
1218 theme.search.invalid_include_exclude_editor
1219 } else {
1220 theme.search.include_exclude_editor.input.container
1221 };
1222
1223 let included_files_view = ChildView::new(&search.included_files_editor, cx)
1224 .aligned()
1225 .left()
1226 .flex(1.0, true);
1227 let excluded_files_view = ChildView::new(&search.excluded_files_editor, cx)
1228 .aligned()
1229 .right()
1230 .flex(1.0, true);
1231
1232 let row_spacing = theme.workspace.toolbar.container.padding.bottom;
1233
1234 Flex::column()
1235 .with_child(
1236 Flex::row()
1237 .with_child(
1238 Flex::row()
1239 .with_child(
1240 ChildView::new(&search.query_editor, cx)
1241 .aligned()
1242 .left()
1243 .flex(1., true),
1244 )
1245 .with_children(search.active_match_index.map(|match_ix| {
1246 Label::new(
1247 format!(
1248 "{}/{}",
1249 match_ix + 1,
1250 search.model.read(cx).match_ranges.len()
1251 ),
1252 theme.search.match_index.text.clone(),
1253 )
1254 .contained()
1255 .with_style(theme.search.match_index.container)
1256 .aligned()
1257 }))
1258 .contained()
1259 .with_style(query_container_style)
1260 .aligned()
1261 .constrained()
1262 .with_min_width(theme.search.editor.min_width)
1263 .with_max_width(theme.search.editor.max_width)
1264 .flex(1., false),
1265 )
1266 .with_child(
1267 Flex::row()
1268 .with_child(self.render_nav_button("<", Direction::Prev, cx))
1269 .with_child(self.render_nav_button(">", Direction::Next, cx))
1270 .aligned(),
1271 )
1272 .with_child({
1273 let row = if SemanticIndex::enabled(cx) {
1274 Flex::row().with_child(self.render_semantic_search_button(cx))
1275 } else {
1276 Flex::row()
1277 };
1278
1279 let row = row
1280 .with_child(self.render_option_button(
1281 "Case",
1282 SearchOptions::CASE_SENSITIVE,
1283 cx,
1284 ))
1285 .with_child(self.render_option_button(
1286 "Word",
1287 SearchOptions::WHOLE_WORD,
1288 cx,
1289 ))
1290 .with_child(self.render_option_button(
1291 "Regex",
1292 SearchOptions::REGEX,
1293 cx,
1294 ))
1295 .contained()
1296 .with_style(theme.search.option_button_group)
1297 .aligned();
1298
1299 row
1300 })
1301 .contained()
1302 .with_margin_bottom(row_spacing),
1303 )
1304 .with_child(
1305 Flex::row()
1306 .with_child(
1307 Flex::row()
1308 .with_child(included_files_view)
1309 .contained()
1310 .with_style(include_container_style)
1311 .aligned()
1312 .constrained()
1313 .with_min_width(theme.search.include_exclude_editor.min_width)
1314 .with_max_width(theme.search.include_exclude_editor.max_width)
1315 .flex(1., false),
1316 )
1317 .with_child(
1318 Flex::row()
1319 .with_child(excluded_files_view)
1320 .contained()
1321 .with_style(exclude_container_style)
1322 .aligned()
1323 .constrained()
1324 .with_min_width(theme.search.include_exclude_editor.min_width)
1325 .with_max_width(theme.search.include_exclude_editor.max_width)
1326 .flex(1., false),
1327 ),
1328 )
1329 .contained()
1330 .with_style(theme.search.container)
1331 .aligned()
1332 .left()
1333 .into_any_named("project search")
1334 } else {
1335 Empty::new().into_any()
1336 }
1337 }
1338}
1339
1340impl ToolbarItemView for ProjectSearchBar {
1341 fn set_active_pane_item(
1342 &mut self,
1343 active_pane_item: Option<&dyn ItemHandle>,
1344 cx: &mut ViewContext<Self>,
1345 ) -> ToolbarItemLocation {
1346 cx.notify();
1347 self.subscription = None;
1348 self.active_project_search = None;
1349 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
1350 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
1351 self.active_project_search = Some(search);
1352 ToolbarItemLocation::PrimaryLeft {
1353 flex: Some((1., false)),
1354 }
1355 } else {
1356 ToolbarItemLocation::Hidden
1357 }
1358 }
1359
1360 fn row_count(&self) -> usize {
1361 2
1362 }
1363}
1364
1365#[cfg(test)]
1366pub mod tests {
1367 use super::*;
1368 use editor::DisplayPoint;
1369 use gpui::{color::Color, executor::Deterministic, TestAppContext};
1370 use project::FakeFs;
1371 use serde_json::json;
1372 use settings::SettingsStore;
1373 use std::sync::Arc;
1374 use theme::ThemeSettings;
1375
1376 #[gpui::test]
1377 async fn test_project_search(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
1378 init_test(cx);
1379
1380 let fs = FakeFs::new(cx.background());
1381 fs.insert_tree(
1382 "/dir",
1383 json!({
1384 "one.rs": "const ONE: usize = 1;",
1385 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1386 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1387 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1388 }),
1389 )
1390 .await;
1391 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1392 let search = cx.add_model(|cx| ProjectSearch::new(project, cx));
1393 let (_, search_view) = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx));
1394
1395 search_view.update(cx, |search_view, cx| {
1396 search_view
1397 .query_editor
1398 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1399 search_view.search(cx);
1400 });
1401 deterministic.run_until_parked();
1402 search_view.update(cx, |search_view, cx| {
1403 assert_eq!(
1404 search_view
1405 .results_editor
1406 .update(cx, |editor, cx| editor.display_text(cx)),
1407 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
1408 );
1409 assert_eq!(
1410 search_view
1411 .results_editor
1412 .update(cx, |editor, cx| editor.all_background_highlights(cx)),
1413 &[
1414 (
1415 DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
1416 Color::red()
1417 ),
1418 (
1419 DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
1420 Color::red()
1421 ),
1422 (
1423 DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
1424 Color::red()
1425 )
1426 ]
1427 );
1428 assert_eq!(search_view.active_match_index, Some(0));
1429 assert_eq!(
1430 search_view
1431 .results_editor
1432 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1433 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1434 );
1435
1436 search_view.select_match(Direction::Next, cx);
1437 });
1438
1439 search_view.update(cx, |search_view, cx| {
1440 assert_eq!(search_view.active_match_index, Some(1));
1441 assert_eq!(
1442 search_view
1443 .results_editor
1444 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1445 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1446 );
1447 search_view.select_match(Direction::Next, cx);
1448 });
1449
1450 search_view.update(cx, |search_view, cx| {
1451 assert_eq!(search_view.active_match_index, Some(2));
1452 assert_eq!(
1453 search_view
1454 .results_editor
1455 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1456 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1457 );
1458 search_view.select_match(Direction::Next, cx);
1459 });
1460
1461 search_view.update(cx, |search_view, cx| {
1462 assert_eq!(search_view.active_match_index, Some(0));
1463 assert_eq!(
1464 search_view
1465 .results_editor
1466 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1467 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1468 );
1469 search_view.select_match(Direction::Prev, cx);
1470 });
1471
1472 search_view.update(cx, |search_view, cx| {
1473 assert_eq!(search_view.active_match_index, Some(2));
1474 assert_eq!(
1475 search_view
1476 .results_editor
1477 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1478 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1479 );
1480 search_view.select_match(Direction::Prev, cx);
1481 });
1482
1483 search_view.update(cx, |search_view, cx| {
1484 assert_eq!(search_view.active_match_index, Some(1));
1485 assert_eq!(
1486 search_view
1487 .results_editor
1488 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1489 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1490 );
1491 });
1492 }
1493
1494 #[gpui::test]
1495 async fn test_project_search_focus(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
1496 init_test(cx);
1497
1498 let fs = FakeFs::new(cx.background());
1499 fs.insert_tree(
1500 "/dir",
1501 json!({
1502 "one.rs": "const ONE: usize = 1;",
1503 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1504 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1505 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1506 }),
1507 )
1508 .await;
1509 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1510 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
1511
1512 let active_item = cx.read(|cx| {
1513 workspace
1514 .read(cx)
1515 .active_pane()
1516 .read(cx)
1517 .active_item()
1518 .and_then(|item| item.downcast::<ProjectSearchView>())
1519 });
1520 assert!(
1521 active_item.is_none(),
1522 "Expected no search panel to be active, but got: {active_item:?}"
1523 );
1524
1525 workspace.update(cx, |workspace, cx| {
1526 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
1527 });
1528
1529 let Some(search_view) = cx.read(|cx| {
1530 workspace
1531 .read(cx)
1532 .active_pane()
1533 .read(cx)
1534 .active_item()
1535 .and_then(|item| item.downcast::<ProjectSearchView>())
1536 }) else {
1537 panic!("Search view expected to appear after new search event trigger")
1538 };
1539 let search_view_id = search_view.id();
1540
1541 cx.spawn(
1542 |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1543 )
1544 .detach();
1545 deterministic.run_until_parked();
1546 search_view.update(cx, |search_view, cx| {
1547 assert!(
1548 search_view.query_editor.is_focused(cx),
1549 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
1550 );
1551 });
1552
1553 search_view.update(cx, |search_view, cx| {
1554 let query_editor = &search_view.query_editor;
1555 assert!(
1556 query_editor.is_focused(cx),
1557 "Search view should be focused after the new search view is activated",
1558 );
1559 let query_text = query_editor.read(cx).text(cx);
1560 assert!(
1561 query_text.is_empty(),
1562 "New search query should be empty but got '{query_text}'",
1563 );
1564 let results_text = search_view
1565 .results_editor
1566 .update(cx, |editor, cx| editor.display_text(cx));
1567 assert!(
1568 results_text.is_empty(),
1569 "Empty search view should have no results but got '{results_text}'"
1570 );
1571 });
1572
1573 search_view.update(cx, |search_view, cx| {
1574 search_view.query_editor.update(cx, |query_editor, cx| {
1575 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
1576 });
1577 search_view.search(cx);
1578 });
1579 deterministic.run_until_parked();
1580 search_view.update(cx, |search_view, cx| {
1581 let results_text = search_view
1582 .results_editor
1583 .update(cx, |editor, cx| editor.display_text(cx));
1584 assert!(
1585 results_text.is_empty(),
1586 "Search view for mismatching query should have no results but got '{results_text}'"
1587 );
1588 assert!(
1589 search_view.query_editor.is_focused(cx),
1590 "Search view should be focused after mismatching query had been used in search",
1591 );
1592 });
1593 cx.spawn(
1594 |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1595 )
1596 .detach();
1597 deterministic.run_until_parked();
1598 search_view.update(cx, |search_view, cx| {
1599 assert!(
1600 search_view.query_editor.is_focused(cx),
1601 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
1602 );
1603 });
1604
1605 search_view.update(cx, |search_view, cx| {
1606 search_view
1607 .query_editor
1608 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1609 search_view.search(cx);
1610 });
1611 deterministic.run_until_parked();
1612 search_view.update(cx, |search_view, cx| {
1613 assert_eq!(
1614 search_view
1615 .results_editor
1616 .update(cx, |editor, cx| editor.display_text(cx)),
1617 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1618 "Search view results should match the query"
1619 );
1620 assert!(
1621 search_view.results_editor.is_focused(cx),
1622 "Search view with mismatching query should be focused after search results are available",
1623 );
1624 });
1625 cx.spawn(
1626 |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1627 )
1628 .detach();
1629 deterministic.run_until_parked();
1630 search_view.update(cx, |search_view, cx| {
1631 assert!(
1632 search_view.results_editor.is_focused(cx),
1633 "Search view with matching query should still have its results editor focused after the toggle focus event",
1634 );
1635 });
1636
1637 workspace.update(cx, |workspace, cx| {
1638 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
1639 });
1640 search_view.update(cx, |search_view, cx| {
1641 assert_eq!(search_view.query_editor.read(cx).text(cx), "two", "Query should be updated to first search result after search view 2nd open in a row");
1642 assert_eq!(
1643 search_view
1644 .results_editor
1645 .update(cx, |editor, cx| editor.display_text(cx)),
1646 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1647 "Results should be unchanged after search view 2nd open in a row"
1648 );
1649 assert!(
1650 search_view.query_editor.is_focused(cx),
1651 "Focus should be moved into query editor again after search view 2nd open in a row"
1652 );
1653 });
1654
1655 cx.spawn(
1656 |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1657 )
1658 .detach();
1659 deterministic.run_until_parked();
1660 search_view.update(cx, |search_view, cx| {
1661 assert!(
1662 search_view.results_editor.is_focused(cx),
1663 "Search view with matching query should switch focus to the results editor after the toggle focus event",
1664 );
1665 });
1666 }
1667
1668 #[gpui::test]
1669 async fn test_new_project_search_in_directory(
1670 deterministic: Arc<Deterministic>,
1671 cx: &mut TestAppContext,
1672 ) {
1673 init_test(cx);
1674
1675 let fs = FakeFs::new(cx.background());
1676 fs.insert_tree(
1677 "/dir",
1678 json!({
1679 "a": {
1680 "one.rs": "const ONE: usize = 1;",
1681 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1682 },
1683 "b": {
1684 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1685 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1686 },
1687 }),
1688 )
1689 .await;
1690 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1691 let worktree_id = project.read_with(cx, |project, cx| {
1692 project.worktrees(cx).next().unwrap().read(cx).id()
1693 });
1694 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
1695
1696 let active_item = cx.read(|cx| {
1697 workspace
1698 .read(cx)
1699 .active_pane()
1700 .read(cx)
1701 .active_item()
1702 .and_then(|item| item.downcast::<ProjectSearchView>())
1703 });
1704 assert!(
1705 active_item.is_none(),
1706 "Expected no search panel to be active, but got: {active_item:?}"
1707 );
1708
1709 let one_file_entry = cx.update(|cx| {
1710 workspace
1711 .read(cx)
1712 .project()
1713 .read(cx)
1714 .entry_for_path(&(worktree_id, "a/one.rs").into(), cx)
1715 .expect("no entry for /a/one.rs file")
1716 });
1717 assert!(one_file_entry.is_file());
1718 workspace.update(cx, |workspace, cx| {
1719 ProjectSearchView::new_search_in_directory(workspace, &one_file_entry, cx)
1720 });
1721 let active_search_entry = cx.read(|cx| {
1722 workspace
1723 .read(cx)
1724 .active_pane()
1725 .read(cx)
1726 .active_item()
1727 .and_then(|item| item.downcast::<ProjectSearchView>())
1728 });
1729 assert!(
1730 active_search_entry.is_none(),
1731 "Expected no search panel to be active for file entry"
1732 );
1733
1734 let a_dir_entry = cx.update(|cx| {
1735 workspace
1736 .read(cx)
1737 .project()
1738 .read(cx)
1739 .entry_for_path(&(worktree_id, "a").into(), cx)
1740 .expect("no entry for /a/ directory")
1741 });
1742 assert!(a_dir_entry.is_dir());
1743 workspace.update(cx, |workspace, cx| {
1744 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry, cx)
1745 });
1746
1747 let Some(search_view) = cx.read(|cx| {
1748 workspace
1749 .read(cx)
1750 .active_pane()
1751 .read(cx)
1752 .active_item()
1753 .and_then(|item| item.downcast::<ProjectSearchView>())
1754 }) else {
1755 panic!("Search view expected to appear after new search in directory event trigger")
1756 };
1757 deterministic.run_until_parked();
1758 search_view.update(cx, |search_view, cx| {
1759 assert!(
1760 search_view.query_editor.is_focused(cx),
1761 "On new search in directory, focus should be moved into query editor"
1762 );
1763 search_view.excluded_files_editor.update(cx, |editor, cx| {
1764 assert!(
1765 editor.display_text(cx).is_empty(),
1766 "New search in directory should not have any excluded files"
1767 );
1768 });
1769 search_view.included_files_editor.update(cx, |editor, cx| {
1770 assert_eq!(
1771 editor.display_text(cx),
1772 a_dir_entry.path.join("**").display().to_string(),
1773 "New search in directory should have included dir entry path"
1774 );
1775 });
1776 });
1777
1778 search_view.update(cx, |search_view, cx| {
1779 search_view
1780 .query_editor
1781 .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
1782 search_view.search(cx);
1783 });
1784 deterministic.run_until_parked();
1785 search_view.update(cx, |search_view, cx| {
1786 assert_eq!(
1787 search_view
1788 .results_editor
1789 .update(cx, |editor, cx| editor.display_text(cx)),
1790 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1791 "New search in directory should have a filter that matches a certain directory"
1792 );
1793 });
1794 }
1795
1796 pub fn init_test(cx: &mut TestAppContext) {
1797 cx.foreground().forbid_parking();
1798 let fonts = cx.font_cache();
1799 let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default);
1800 theme.search.match_background = Color::red();
1801
1802 cx.update(|cx| {
1803 cx.set_global(SettingsStore::test(cx));
1804 cx.set_global(ActiveSearches::default());
1805
1806 theme::init((), cx);
1807 cx.update_global::<SettingsStore, _, _>(|store, _| {
1808 let mut settings = store.get::<ThemeSettings>(None).clone();
1809 settings.theme = Arc::new(theme);
1810 store.override_global(settings)
1811 });
1812
1813 language::init(cx);
1814 client::init_settings(cx);
1815 editor::init(cx);
1816 workspace::init_settings(cx);
1817 Project::init_settings(cx);
1818 super::init(cx);
1819 });
1820 }
1821}