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