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 let range_to_select = editor.range_for_match(&range_to_select);
787 editor.unfold_ranges([range_to_select.clone()], false, true, cx);
788 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
789 s.select_ranges([range_to_select])
790 });
791 });
792 }
793 }
794
795 fn focus_query_editor(&mut self, cx: &mut ViewContext<Self>) {
796 self.query_editor.update(cx, |query_editor, cx| {
797 query_editor.select_all(&SelectAll, cx);
798 });
799 self.query_editor_was_focused = true;
800 cx.focus(&self.query_editor);
801 }
802
803 fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
804 self.query_editor
805 .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
806 }
807
808 fn focus_results_editor(&mut self, cx: &mut ViewContext<Self>) {
809 self.query_editor.update(cx, |query_editor, cx| {
810 let cursor = query_editor.selections.newest_anchor().head();
811 query_editor.change_selections(None, cx, |s| s.select_ranges([cursor.clone()..cursor]));
812 });
813 self.query_editor_was_focused = false;
814 cx.focus(&self.results_editor);
815 }
816
817 fn model_changed(&mut self, cx: &mut ViewContext<Self>) {
818 let match_ranges = self.model.read(cx).match_ranges.clone();
819 if match_ranges.is_empty() {
820 self.active_match_index = None;
821 } else {
822 self.active_match_index = Some(0);
823 self.update_match_index(cx);
824 let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id);
825 let is_new_search = self.search_id != prev_search_id;
826 self.results_editor.update(cx, |editor, cx| {
827 if is_new_search {
828 let range_to_select = match_ranges
829 .first()
830 .clone()
831 .map(|range| editor.range_for_match(range));
832 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
833 s.select_ranges(range_to_select)
834 });
835 }
836 editor.highlight_background::<Self>(
837 match_ranges,
838 |theme| theme.search.match_background,
839 cx,
840 );
841 });
842 if is_new_search && self.query_editor.is_focused(cx) {
843 self.focus_results_editor(cx);
844 }
845 }
846
847 cx.emit(ViewEvent::UpdateTab);
848 cx.notify();
849 }
850
851 fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
852 let results_editor = self.results_editor.read(cx);
853 let new_index = active_match_index(
854 &self.model.read(cx).match_ranges,
855 &results_editor.selections.newest_anchor().head(),
856 &results_editor.buffer().read(cx).snapshot(cx),
857 );
858 if self.active_match_index != new_index {
859 self.active_match_index = new_index;
860 cx.notify();
861 }
862 }
863
864 pub fn has_matches(&self) -> bool {
865 self.active_match_index.is_some()
866 }
867
868 fn move_focus_to_results(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext<Pane>) {
869 if let Some(search_view) = pane
870 .active_item()
871 .and_then(|item| item.downcast::<ProjectSearchView>())
872 {
873 search_view.update(cx, |search_view, cx| {
874 if !search_view.results_editor.is_focused(cx)
875 && !search_view.model.read(cx).match_ranges.is_empty()
876 {
877 return search_view.focus_results_editor(cx);
878 }
879 });
880 }
881
882 cx.propagate_action();
883 }
884}
885
886impl Default for ProjectSearchBar {
887 fn default() -> Self {
888 Self::new()
889 }
890}
891
892impl ProjectSearchBar {
893 pub fn new() -> Self {
894 Self {
895 active_project_search: Default::default(),
896 subscription: Default::default(),
897 }
898 }
899
900 fn search(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
901 if let Some(search_view) = self.active_project_search.as_ref() {
902 search_view.update(cx, |search_view, cx| search_view.search(cx));
903 }
904 }
905
906 fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
907 if let Some(search_view) = workspace
908 .active_item(cx)
909 .and_then(|item| item.downcast::<ProjectSearchView>())
910 {
911 let new_query = search_view.update(cx, |search_view, cx| {
912 let new_query = search_view.build_search_query(cx);
913 if new_query.is_some() {
914 if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
915 search_view.query_editor.update(cx, |editor, cx| {
916 editor.set_text(old_query.as_str(), cx);
917 });
918 search_view.search_options = SearchOptions::from_query(&old_query);
919 }
920 }
921 new_query
922 });
923 if let Some(new_query) = new_query {
924 let model = cx.add_model(|cx| {
925 let mut model = ProjectSearch::new(workspace.project().clone(), cx);
926 model.search(new_query, cx);
927 model
928 });
929 workspace.add_item(
930 Box::new(cx.add_view(|cx| ProjectSearchView::new(model, cx))),
931 cx,
932 );
933 }
934 }
935 }
936
937 fn select_next_match(pane: &mut Pane, _: &SelectNextMatch, cx: &mut ViewContext<Pane>) {
938 if let Some(search_view) = pane
939 .active_item()
940 .and_then(|item| item.downcast::<ProjectSearchView>())
941 {
942 search_view.update(cx, |view, cx| view.select_match(Direction::Next, cx));
943 } else {
944 cx.propagate_action();
945 }
946 }
947
948 fn select_prev_match(pane: &mut Pane, _: &SelectPrevMatch, cx: &mut ViewContext<Pane>) {
949 if let Some(search_view) = pane
950 .active_item()
951 .and_then(|item| item.downcast::<ProjectSearchView>())
952 {
953 search_view.update(cx, |view, cx| view.select_match(Direction::Prev, cx));
954 } else {
955 cx.propagate_action();
956 }
957 }
958
959 fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
960 self.cycle_field(Direction::Next, cx);
961 }
962
963 fn tab_previous(&mut self, _: &editor::TabPrev, cx: &mut ViewContext<Self>) {
964 self.cycle_field(Direction::Prev, cx);
965 }
966
967 fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
968 let active_project_search = match &self.active_project_search {
969 Some(active_project_search) => active_project_search,
970
971 None => {
972 cx.propagate_action();
973 return;
974 }
975 };
976
977 active_project_search.update(cx, |project_view, cx| {
978 let views = &[
979 &project_view.query_editor,
980 &project_view.included_files_editor,
981 &project_view.excluded_files_editor,
982 ];
983
984 let current_index = match views
985 .iter()
986 .enumerate()
987 .find(|(_, view)| view.is_focused(cx))
988 {
989 Some((index, _)) => index,
990
991 None => {
992 cx.propagate_action();
993 return;
994 }
995 };
996
997 let new_index = match direction {
998 Direction::Next => (current_index + 1) % views.len(),
999 Direction::Prev if current_index == 0 => views.len() - 1,
1000 Direction::Prev => (current_index - 1) % views.len(),
1001 };
1002 cx.focus(views[new_index]);
1003 });
1004 }
1005
1006 fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) -> bool {
1007 if let Some(search_view) = self.active_project_search.as_ref() {
1008 search_view.update(cx, |search_view, cx| {
1009 search_view.search_options.toggle(option);
1010 search_view.semantic = None;
1011 search_view.search(cx);
1012 });
1013 cx.notify();
1014 true
1015 } else {
1016 false
1017 }
1018 }
1019
1020 fn toggle_semantic_search(&mut self, cx: &mut ViewContext<Self>) -> bool {
1021 if let Some(search_view) = self.active_project_search.as_ref() {
1022 search_view.update(cx, |search_view, cx| {
1023 if search_view.semantic.is_some() {
1024 search_view.semantic = None;
1025 } else if let Some(semantic_index) = SemanticIndex::global(cx) {
1026 // TODO: confirm that it's ok to send this project
1027 search_view.search_options = SearchOptions::none();
1028
1029 let project = search_view.model.read(cx).project.clone();
1030 let index_task = semantic_index.update(cx, |semantic_index, cx| {
1031 semantic_index.index_project(project, cx)
1032 });
1033
1034 cx.spawn(|search_view, mut cx| async move {
1035 let (files_to_index, mut files_remaining_rx) = index_task.await?;
1036
1037 search_view.update(&mut cx, |search_view, cx| {
1038 cx.notify();
1039 search_view.semantic = Some(SemanticSearchState {
1040 file_count: files_to_index,
1041 outstanding_file_count: files_to_index,
1042 _progress_task: cx.spawn(|search_view, mut cx| async move {
1043 while let Some(count) = files_remaining_rx.recv().await {
1044 search_view
1045 .update(&mut cx, |search_view, cx| {
1046 if let Some(semantic_search_state) =
1047 &mut search_view.semantic
1048 {
1049 semantic_search_state.outstanding_file_count =
1050 count;
1051 cx.notify();
1052 if count == 0 {
1053 return;
1054 }
1055 }
1056 })
1057 .ok();
1058 }
1059 }),
1060 });
1061 })?;
1062 anyhow::Ok(())
1063 })
1064 .detach_and_log_err(cx);
1065 }
1066 cx.notify();
1067 });
1068 cx.notify();
1069 true
1070 } else {
1071 false
1072 }
1073 }
1074
1075 fn render_nav_button(
1076 &self,
1077 icon: &'static str,
1078 direction: Direction,
1079 cx: &mut ViewContext<Self>,
1080 ) -> AnyElement<Self> {
1081 let action: Box<dyn Action>;
1082 let tooltip;
1083 match direction {
1084 Direction::Prev => {
1085 action = Box::new(SelectPrevMatch);
1086 tooltip = "Select Previous Match";
1087 }
1088 Direction::Next => {
1089 action = Box::new(SelectNextMatch);
1090 tooltip = "Select Next Match";
1091 }
1092 };
1093 let tooltip_style = theme::current(cx).tooltip.clone();
1094
1095 enum NavButton {}
1096 MouseEventHandler::<NavButton, _>::new(direction as usize, cx, |state, cx| {
1097 let theme = theme::current(cx);
1098 let style = theme.search.option_button.inactive_state().style_for(state);
1099 Label::new(icon, style.text.clone())
1100 .contained()
1101 .with_style(style.container)
1102 })
1103 .on_click(MouseButton::Left, move |_, this, cx| {
1104 if let Some(search) = this.active_project_search.as_ref() {
1105 search.update(cx, |search, cx| search.select_match(direction, cx));
1106 }
1107 })
1108 .with_cursor_style(CursorStyle::PointingHand)
1109 .with_tooltip::<NavButton>(
1110 direction as usize,
1111 tooltip.to_string(),
1112 Some(action),
1113 tooltip_style,
1114 cx,
1115 )
1116 .into_any()
1117 }
1118
1119 fn render_option_button(
1120 &self,
1121 icon: &'static str,
1122 option: SearchOptions,
1123 cx: &mut ViewContext<Self>,
1124 ) -> AnyElement<Self> {
1125 let tooltip_style = theme::current(cx).tooltip.clone();
1126 let is_active = self.is_option_enabled(option, cx);
1127 MouseEventHandler::<Self, _>::new(option.bits as usize, cx, |state, cx| {
1128 let theme = theme::current(cx);
1129 let style = theme
1130 .search
1131 .option_button
1132 .in_state(is_active)
1133 .style_for(state);
1134 Label::new(icon, style.text.clone())
1135 .contained()
1136 .with_style(style.container)
1137 })
1138 .on_click(MouseButton::Left, move |_, this, cx| {
1139 this.toggle_search_option(option, cx);
1140 })
1141 .with_cursor_style(CursorStyle::PointingHand)
1142 .with_tooltip::<Self>(
1143 option.bits as usize,
1144 format!("Toggle {}", option.label()),
1145 Some(option.to_toggle_action()),
1146 tooltip_style,
1147 cx,
1148 )
1149 .into_any()
1150 }
1151
1152 fn render_semantic_search_button(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
1153 let tooltip_style = theme::current(cx).tooltip.clone();
1154 let is_active = if let Some(search) = self.active_project_search.as_ref() {
1155 let search = search.read(cx);
1156 search.semantic.is_some()
1157 } else {
1158 false
1159 };
1160
1161 let region_id = 3;
1162
1163 MouseEventHandler::<Self, _>::new(region_id, cx, |state, cx| {
1164 let theme = theme::current(cx);
1165 let style = theme
1166 .search
1167 .option_button
1168 .in_state(is_active)
1169 .style_for(state);
1170 Label::new("Semantic", style.text.clone())
1171 .contained()
1172 .with_style(style.container)
1173 })
1174 .on_click(MouseButton::Left, move |_, this, cx| {
1175 this.toggle_semantic_search(cx);
1176 })
1177 .with_cursor_style(CursorStyle::PointingHand)
1178 .with_tooltip::<Self>(
1179 region_id,
1180 format!("Toggle Semantic Search"),
1181 Some(Box::new(ToggleSemanticSearch)),
1182 tooltip_style,
1183 cx,
1184 )
1185 .into_any()
1186 }
1187
1188 fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool {
1189 if let Some(search) = self.active_project_search.as_ref() {
1190 search.read(cx).search_options.contains(option)
1191 } else {
1192 false
1193 }
1194 }
1195}
1196
1197impl Entity for ProjectSearchBar {
1198 type Event = ();
1199}
1200
1201impl View for ProjectSearchBar {
1202 fn ui_name() -> &'static str {
1203 "ProjectSearchBar"
1204 }
1205
1206 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
1207 if let Some(search) = self.active_project_search.as_ref() {
1208 let search = search.read(cx);
1209 let theme = theme::current(cx).clone();
1210 let query_container_style = if search.panels_with_errors.contains(&InputPanel::Query) {
1211 theme.search.invalid_editor
1212 } else {
1213 theme.search.editor.input.container
1214 };
1215 let include_container_style =
1216 if search.panels_with_errors.contains(&InputPanel::Include) {
1217 theme.search.invalid_include_exclude_editor
1218 } else {
1219 theme.search.include_exclude_editor.input.container
1220 };
1221 let exclude_container_style =
1222 if search.panels_with_errors.contains(&InputPanel::Exclude) {
1223 theme.search.invalid_include_exclude_editor
1224 } else {
1225 theme.search.include_exclude_editor.input.container
1226 };
1227
1228 let included_files_view = ChildView::new(&search.included_files_editor, cx)
1229 .aligned()
1230 .left()
1231 .flex(1.0, true);
1232 let excluded_files_view = ChildView::new(&search.excluded_files_editor, cx)
1233 .aligned()
1234 .right()
1235 .flex(1.0, true);
1236
1237 let row_spacing = theme.workspace.toolbar.container.padding.bottom;
1238
1239 Flex::column()
1240 .with_child(
1241 Flex::row()
1242 .with_child(
1243 Flex::row()
1244 .with_child(
1245 ChildView::new(&search.query_editor, cx)
1246 .aligned()
1247 .left()
1248 .flex(1., true),
1249 )
1250 .with_children(search.active_match_index.map(|match_ix| {
1251 Label::new(
1252 format!(
1253 "{}/{}",
1254 match_ix + 1,
1255 search.model.read(cx).match_ranges.len()
1256 ),
1257 theme.search.match_index.text.clone(),
1258 )
1259 .contained()
1260 .with_style(theme.search.match_index.container)
1261 .aligned()
1262 }))
1263 .contained()
1264 .with_style(query_container_style)
1265 .aligned()
1266 .constrained()
1267 .with_min_width(theme.search.editor.min_width)
1268 .with_max_width(theme.search.editor.max_width)
1269 .flex(1., false),
1270 )
1271 .with_child(
1272 Flex::row()
1273 .with_child(self.render_nav_button("<", Direction::Prev, cx))
1274 .with_child(self.render_nav_button(">", Direction::Next, cx))
1275 .aligned(),
1276 )
1277 .with_child({
1278 let row = if SemanticIndex::enabled(cx) {
1279 Flex::row().with_child(self.render_semantic_search_button(cx))
1280 } else {
1281 Flex::row()
1282 };
1283
1284 let row = row
1285 .with_child(self.render_option_button(
1286 "Case",
1287 SearchOptions::CASE_SENSITIVE,
1288 cx,
1289 ))
1290 .with_child(self.render_option_button(
1291 "Word",
1292 SearchOptions::WHOLE_WORD,
1293 cx,
1294 ))
1295 .with_child(self.render_option_button(
1296 "Regex",
1297 SearchOptions::REGEX,
1298 cx,
1299 ))
1300 .contained()
1301 .with_style(theme.search.option_button_group)
1302 .aligned();
1303
1304 row
1305 })
1306 .contained()
1307 .with_margin_bottom(row_spacing),
1308 )
1309 .with_child(
1310 Flex::row()
1311 .with_child(
1312 Flex::row()
1313 .with_child(included_files_view)
1314 .contained()
1315 .with_style(include_container_style)
1316 .aligned()
1317 .constrained()
1318 .with_min_width(theme.search.include_exclude_editor.min_width)
1319 .with_max_width(theme.search.include_exclude_editor.max_width)
1320 .flex(1., false),
1321 )
1322 .with_child(
1323 Flex::row()
1324 .with_child(excluded_files_view)
1325 .contained()
1326 .with_style(exclude_container_style)
1327 .aligned()
1328 .constrained()
1329 .with_min_width(theme.search.include_exclude_editor.min_width)
1330 .with_max_width(theme.search.include_exclude_editor.max_width)
1331 .flex(1., false),
1332 ),
1333 )
1334 .contained()
1335 .with_style(theme.search.container)
1336 .aligned()
1337 .left()
1338 .into_any_named("project search")
1339 } else {
1340 Empty::new().into_any()
1341 }
1342 }
1343}
1344
1345impl ToolbarItemView for ProjectSearchBar {
1346 fn set_active_pane_item(
1347 &mut self,
1348 active_pane_item: Option<&dyn ItemHandle>,
1349 cx: &mut ViewContext<Self>,
1350 ) -> ToolbarItemLocation {
1351 cx.notify();
1352 self.subscription = None;
1353 self.active_project_search = None;
1354 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
1355 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
1356 self.active_project_search = Some(search);
1357 ToolbarItemLocation::PrimaryLeft {
1358 flex: Some((1., false)),
1359 }
1360 } else {
1361 ToolbarItemLocation::Hidden
1362 }
1363 }
1364
1365 fn row_count(&self) -> usize {
1366 2
1367 }
1368}
1369
1370#[cfg(test)]
1371pub mod tests {
1372 use super::*;
1373 use editor::DisplayPoint;
1374 use gpui::{color::Color, executor::Deterministic, TestAppContext};
1375 use project::FakeFs;
1376 use serde_json::json;
1377 use settings::SettingsStore;
1378 use std::sync::Arc;
1379 use theme::ThemeSettings;
1380
1381 #[gpui::test]
1382 async fn test_project_search(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
1383 init_test(cx);
1384
1385 let fs = FakeFs::new(cx.background());
1386 fs.insert_tree(
1387 "/dir",
1388 json!({
1389 "one.rs": "const ONE: usize = 1;",
1390 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1391 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1392 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1393 }),
1394 )
1395 .await;
1396 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1397 let search = cx.add_model(|cx| ProjectSearch::new(project, cx));
1398 let (_, search_view) = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx));
1399
1400 search_view.update(cx, |search_view, cx| {
1401 search_view
1402 .query_editor
1403 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1404 search_view.search(cx);
1405 });
1406 deterministic.run_until_parked();
1407 search_view.update(cx, |search_view, cx| {
1408 assert_eq!(
1409 search_view
1410 .results_editor
1411 .update(cx, |editor, cx| editor.display_text(cx)),
1412 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
1413 );
1414 assert_eq!(
1415 search_view
1416 .results_editor
1417 .update(cx, |editor, cx| editor.all_background_highlights(cx)),
1418 &[
1419 (
1420 DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
1421 Color::red()
1422 ),
1423 (
1424 DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
1425 Color::red()
1426 ),
1427 (
1428 DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
1429 Color::red()
1430 )
1431 ]
1432 );
1433 assert_eq!(search_view.active_match_index, Some(0));
1434 assert_eq!(
1435 search_view
1436 .results_editor
1437 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1438 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1439 );
1440
1441 search_view.select_match(Direction::Next, cx);
1442 });
1443
1444 search_view.update(cx, |search_view, cx| {
1445 assert_eq!(search_view.active_match_index, Some(1));
1446 assert_eq!(
1447 search_view
1448 .results_editor
1449 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1450 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1451 );
1452 search_view.select_match(Direction::Next, cx);
1453 });
1454
1455 search_view.update(cx, |search_view, cx| {
1456 assert_eq!(search_view.active_match_index, Some(2));
1457 assert_eq!(
1458 search_view
1459 .results_editor
1460 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1461 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1462 );
1463 search_view.select_match(Direction::Next, cx);
1464 });
1465
1466 search_view.update(cx, |search_view, cx| {
1467 assert_eq!(search_view.active_match_index, Some(0));
1468 assert_eq!(
1469 search_view
1470 .results_editor
1471 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1472 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1473 );
1474 search_view.select_match(Direction::Prev, cx);
1475 });
1476
1477 search_view.update(cx, |search_view, cx| {
1478 assert_eq!(search_view.active_match_index, Some(2));
1479 assert_eq!(
1480 search_view
1481 .results_editor
1482 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1483 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1484 );
1485 search_view.select_match(Direction::Prev, cx);
1486 });
1487
1488 search_view.update(cx, |search_view, cx| {
1489 assert_eq!(search_view.active_match_index, Some(1));
1490 assert_eq!(
1491 search_view
1492 .results_editor
1493 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1494 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1495 );
1496 });
1497 }
1498
1499 #[gpui::test]
1500 async fn test_project_search_focus(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
1501 init_test(cx);
1502
1503 let fs = FakeFs::new(cx.background());
1504 fs.insert_tree(
1505 "/dir",
1506 json!({
1507 "one.rs": "const ONE: usize = 1;",
1508 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1509 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1510 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1511 }),
1512 )
1513 .await;
1514 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1515 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
1516
1517 let active_item = cx.read(|cx| {
1518 workspace
1519 .read(cx)
1520 .active_pane()
1521 .read(cx)
1522 .active_item()
1523 .and_then(|item| item.downcast::<ProjectSearchView>())
1524 });
1525 assert!(
1526 active_item.is_none(),
1527 "Expected no search panel to be active, but got: {active_item:?}"
1528 );
1529
1530 workspace.update(cx, |workspace, cx| {
1531 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
1532 });
1533
1534 let Some(search_view) = cx.read(|cx| {
1535 workspace
1536 .read(cx)
1537 .active_pane()
1538 .read(cx)
1539 .active_item()
1540 .and_then(|item| item.downcast::<ProjectSearchView>())
1541 }) else {
1542 panic!("Search view expected to appear after new search event trigger")
1543 };
1544 let search_view_id = search_view.id();
1545
1546 cx.spawn(
1547 |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1548 )
1549 .detach();
1550 deterministic.run_until_parked();
1551 search_view.update(cx, |search_view, cx| {
1552 assert!(
1553 search_view.query_editor.is_focused(cx),
1554 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
1555 );
1556 });
1557
1558 search_view.update(cx, |search_view, cx| {
1559 let query_editor = &search_view.query_editor;
1560 assert!(
1561 query_editor.is_focused(cx),
1562 "Search view should be focused after the new search view is activated",
1563 );
1564 let query_text = query_editor.read(cx).text(cx);
1565 assert!(
1566 query_text.is_empty(),
1567 "New search query should be empty but got '{query_text}'",
1568 );
1569 let results_text = search_view
1570 .results_editor
1571 .update(cx, |editor, cx| editor.display_text(cx));
1572 assert!(
1573 results_text.is_empty(),
1574 "Empty search view should have no results but got '{results_text}'"
1575 );
1576 });
1577
1578 search_view.update(cx, |search_view, cx| {
1579 search_view.query_editor.update(cx, |query_editor, cx| {
1580 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
1581 });
1582 search_view.search(cx);
1583 });
1584 deterministic.run_until_parked();
1585 search_view.update(cx, |search_view, cx| {
1586 let results_text = search_view
1587 .results_editor
1588 .update(cx, |editor, cx| editor.display_text(cx));
1589 assert!(
1590 results_text.is_empty(),
1591 "Search view for mismatching query should have no results but got '{results_text}'"
1592 );
1593 assert!(
1594 search_view.query_editor.is_focused(cx),
1595 "Search view should be focused after mismatching query had been used in search",
1596 );
1597 });
1598 cx.spawn(
1599 |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1600 )
1601 .detach();
1602 deterministic.run_until_parked();
1603 search_view.update(cx, |search_view, cx| {
1604 assert!(
1605 search_view.query_editor.is_focused(cx),
1606 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
1607 );
1608 });
1609
1610 search_view.update(cx, |search_view, cx| {
1611 search_view
1612 .query_editor
1613 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1614 search_view.search(cx);
1615 });
1616 deterministic.run_until_parked();
1617 search_view.update(cx, |search_view, cx| {
1618 assert_eq!(
1619 search_view
1620 .results_editor
1621 .update(cx, |editor, cx| editor.display_text(cx)),
1622 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1623 "Search view results should match the query"
1624 );
1625 assert!(
1626 search_view.results_editor.is_focused(cx),
1627 "Search view with mismatching query should be focused after search results are available",
1628 );
1629 });
1630 cx.spawn(
1631 |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1632 )
1633 .detach();
1634 deterministic.run_until_parked();
1635 search_view.update(cx, |search_view, cx| {
1636 assert!(
1637 search_view.results_editor.is_focused(cx),
1638 "Search view with matching query should still have its results editor focused after the toggle focus event",
1639 );
1640 });
1641
1642 workspace.update(cx, |workspace, cx| {
1643 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
1644 });
1645 search_view.update(cx, |search_view, cx| {
1646 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");
1647 assert_eq!(
1648 search_view
1649 .results_editor
1650 .update(cx, |editor, cx| editor.display_text(cx)),
1651 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1652 "Results should be unchanged after search view 2nd open in a row"
1653 );
1654 assert!(
1655 search_view.query_editor.is_focused(cx),
1656 "Focus should be moved into query editor again after search view 2nd open in a row"
1657 );
1658 });
1659
1660 cx.spawn(
1661 |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1662 )
1663 .detach();
1664 deterministic.run_until_parked();
1665 search_view.update(cx, |search_view, cx| {
1666 assert!(
1667 search_view.results_editor.is_focused(cx),
1668 "Search view with matching query should switch focus to the results editor after the toggle focus event",
1669 );
1670 });
1671 }
1672
1673 #[gpui::test]
1674 async fn test_new_project_search_in_directory(
1675 deterministic: Arc<Deterministic>,
1676 cx: &mut TestAppContext,
1677 ) {
1678 init_test(cx);
1679
1680 let fs = FakeFs::new(cx.background());
1681 fs.insert_tree(
1682 "/dir",
1683 json!({
1684 "a": {
1685 "one.rs": "const ONE: usize = 1;",
1686 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1687 },
1688 "b": {
1689 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1690 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1691 },
1692 }),
1693 )
1694 .await;
1695 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1696 let worktree_id = project.read_with(cx, |project, cx| {
1697 project.worktrees(cx).next().unwrap().read(cx).id()
1698 });
1699 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
1700
1701 let active_item = cx.read(|cx| {
1702 workspace
1703 .read(cx)
1704 .active_pane()
1705 .read(cx)
1706 .active_item()
1707 .and_then(|item| item.downcast::<ProjectSearchView>())
1708 });
1709 assert!(
1710 active_item.is_none(),
1711 "Expected no search panel to be active, but got: {active_item:?}"
1712 );
1713
1714 let one_file_entry = cx.update(|cx| {
1715 workspace
1716 .read(cx)
1717 .project()
1718 .read(cx)
1719 .entry_for_path(&(worktree_id, "a/one.rs").into(), cx)
1720 .expect("no entry for /a/one.rs file")
1721 });
1722 assert!(one_file_entry.is_file());
1723 workspace.update(cx, |workspace, cx| {
1724 ProjectSearchView::new_search_in_directory(workspace, &one_file_entry, cx)
1725 });
1726 let active_search_entry = cx.read(|cx| {
1727 workspace
1728 .read(cx)
1729 .active_pane()
1730 .read(cx)
1731 .active_item()
1732 .and_then(|item| item.downcast::<ProjectSearchView>())
1733 });
1734 assert!(
1735 active_search_entry.is_none(),
1736 "Expected no search panel to be active for file entry"
1737 );
1738
1739 let a_dir_entry = cx.update(|cx| {
1740 workspace
1741 .read(cx)
1742 .project()
1743 .read(cx)
1744 .entry_for_path(&(worktree_id, "a").into(), cx)
1745 .expect("no entry for /a/ directory")
1746 });
1747 assert!(a_dir_entry.is_dir());
1748 workspace.update(cx, |workspace, cx| {
1749 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry, cx)
1750 });
1751
1752 let Some(search_view) = cx.read(|cx| {
1753 workspace
1754 .read(cx)
1755 .active_pane()
1756 .read(cx)
1757 .active_item()
1758 .and_then(|item| item.downcast::<ProjectSearchView>())
1759 }) else {
1760 panic!("Search view expected to appear after new search in directory event trigger")
1761 };
1762 deterministic.run_until_parked();
1763 search_view.update(cx, |search_view, cx| {
1764 assert!(
1765 search_view.query_editor.is_focused(cx),
1766 "On new search in directory, focus should be moved into query editor"
1767 );
1768 search_view.excluded_files_editor.update(cx, |editor, cx| {
1769 assert!(
1770 editor.display_text(cx).is_empty(),
1771 "New search in directory should not have any excluded files"
1772 );
1773 });
1774 search_view.included_files_editor.update(cx, |editor, cx| {
1775 assert_eq!(
1776 editor.display_text(cx),
1777 a_dir_entry.path.join("**").display().to_string(),
1778 "New search in directory should have included dir entry path"
1779 );
1780 });
1781 });
1782
1783 search_view.update(cx, |search_view, cx| {
1784 search_view
1785 .query_editor
1786 .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
1787 search_view.search(cx);
1788 });
1789 deterministic.run_until_parked();
1790 search_view.update(cx, |search_view, cx| {
1791 assert_eq!(
1792 search_view
1793 .results_editor
1794 .update(cx, |editor, cx| editor.display_text(cx)),
1795 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1796 "New search in directory should have a filter that matches a certain directory"
1797 );
1798 });
1799 }
1800
1801 pub fn init_test(cx: &mut TestAppContext) {
1802 cx.foreground().forbid_parking();
1803 let fonts = cx.font_cache();
1804 let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default);
1805 theme.search.match_background = Color::red();
1806
1807 cx.update(|cx| {
1808 cx.set_global(SettingsStore::test(cx));
1809 cx.set_global(ActiveSearches::default());
1810
1811 theme::init((), cx);
1812 cx.update_global::<SettingsStore, _, _>(|store, _| {
1813 let mut settings = store.get::<ThemeSettings>(None).clone();
1814 settings.theme = Arc::new(theme);
1815 store.override_global(settings)
1816 });
1817
1818 language::init(cx);
1819 client::init_settings(cx);
1820 editor::init(cx);
1821 workspace::init_settings(cx);
1822 Project::init_settings(cx);
1823 super::init(cx);
1824 });
1825 }
1826}