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