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