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::new::<Status, _>(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::new::<NavButton, _>(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::new::<Self, _>(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::new::<Self, _>(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
1451 .add_window(|cx| ProjectSearchView::new(search.clone(), cx))
1452 .root(cx);
1453
1454 search_view.update(cx, |search_view, cx| {
1455 search_view
1456 .query_editor
1457 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1458 search_view.search(cx);
1459 });
1460 deterministic.run_until_parked();
1461 search_view.update(cx, |search_view, cx| {
1462 assert_eq!(
1463 search_view
1464 .results_editor
1465 .update(cx, |editor, cx| editor.display_text(cx)),
1466 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
1467 );
1468 assert_eq!(
1469 search_view
1470 .results_editor
1471 .update(cx, |editor, cx| editor.all_background_highlights(cx)),
1472 &[
1473 (
1474 DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
1475 Color::red()
1476 ),
1477 (
1478 DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
1479 Color::red()
1480 ),
1481 (
1482 DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
1483 Color::red()
1484 )
1485 ]
1486 );
1487 assert_eq!(search_view.active_match_index, Some(0));
1488 assert_eq!(
1489 search_view
1490 .results_editor
1491 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1492 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1493 );
1494
1495 search_view.select_match(Direction::Next, cx);
1496 });
1497
1498 search_view.update(cx, |search_view, cx| {
1499 assert_eq!(search_view.active_match_index, Some(1));
1500 assert_eq!(
1501 search_view
1502 .results_editor
1503 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1504 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1505 );
1506 search_view.select_match(Direction::Next, cx);
1507 });
1508
1509 search_view.update(cx, |search_view, cx| {
1510 assert_eq!(search_view.active_match_index, Some(2));
1511 assert_eq!(
1512 search_view
1513 .results_editor
1514 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1515 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1516 );
1517 search_view.select_match(Direction::Next, cx);
1518 });
1519
1520 search_view.update(cx, |search_view, cx| {
1521 assert_eq!(search_view.active_match_index, Some(0));
1522 assert_eq!(
1523 search_view
1524 .results_editor
1525 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1526 [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1527 );
1528 search_view.select_match(Direction::Prev, cx);
1529 });
1530
1531 search_view.update(cx, |search_view, cx| {
1532 assert_eq!(search_view.active_match_index, Some(2));
1533 assert_eq!(
1534 search_view
1535 .results_editor
1536 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1537 [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1538 );
1539 search_view.select_match(Direction::Prev, cx);
1540 });
1541
1542 search_view.update(cx, |search_view, cx| {
1543 assert_eq!(search_view.active_match_index, Some(1));
1544 assert_eq!(
1545 search_view
1546 .results_editor
1547 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1548 [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1549 );
1550 });
1551 }
1552
1553 #[gpui::test]
1554 async fn test_project_search_focus(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
1555 init_test(cx);
1556
1557 let fs = FakeFs::new(cx.background());
1558 fs.insert_tree(
1559 "/dir",
1560 json!({
1561 "one.rs": "const ONE: usize = 1;",
1562 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1563 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1564 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1565 }),
1566 )
1567 .await;
1568 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1569 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
1570 let workspace = window.root(cx);
1571
1572 let active_item = cx.read(|cx| {
1573 workspace
1574 .read(cx)
1575 .active_pane()
1576 .read(cx)
1577 .active_item()
1578 .and_then(|item| item.downcast::<ProjectSearchView>())
1579 });
1580 assert!(
1581 active_item.is_none(),
1582 "Expected no search panel to be active, but got: {active_item:?}"
1583 );
1584
1585 workspace.update(cx, |workspace, cx| {
1586 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
1587 });
1588
1589 let Some(search_view) = cx.read(|cx| {
1590 workspace
1591 .read(cx)
1592 .active_pane()
1593 .read(cx)
1594 .active_item()
1595 .and_then(|item| item.downcast::<ProjectSearchView>())
1596 }) else {
1597 panic!("Search view expected to appear after new search event trigger")
1598 };
1599 let search_view_id = search_view.id();
1600
1601 cx.spawn(|mut cx| async move {
1602 window.dispatch_action(search_view_id, &ToggleFocus, &mut cx);
1603 })
1604 .detach();
1605 deterministic.run_until_parked();
1606 search_view.update(cx, |search_view, cx| {
1607 assert!(
1608 search_view.query_editor.is_focused(cx),
1609 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
1610 );
1611 });
1612
1613 search_view.update(cx, |search_view, cx| {
1614 let query_editor = &search_view.query_editor;
1615 assert!(
1616 query_editor.is_focused(cx),
1617 "Search view should be focused after the new search view is activated",
1618 );
1619 let query_text = query_editor.read(cx).text(cx);
1620 assert!(
1621 query_text.is_empty(),
1622 "New search query should be empty but got '{query_text}'",
1623 );
1624 let results_text = search_view
1625 .results_editor
1626 .update(cx, |editor, cx| editor.display_text(cx));
1627 assert!(
1628 results_text.is_empty(),
1629 "Empty search view should have no results but got '{results_text}'"
1630 );
1631 });
1632
1633 search_view.update(cx, |search_view, cx| {
1634 search_view.query_editor.update(cx, |query_editor, cx| {
1635 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
1636 });
1637 search_view.search(cx);
1638 });
1639 deterministic.run_until_parked();
1640 search_view.update(cx, |search_view, cx| {
1641 let results_text = search_view
1642 .results_editor
1643 .update(cx, |editor, cx| editor.display_text(cx));
1644 assert!(
1645 results_text.is_empty(),
1646 "Search view for mismatching query should have no results but got '{results_text}'"
1647 );
1648 assert!(
1649 search_view.query_editor.is_focused(cx),
1650 "Search view should be focused after mismatching query had been used in search",
1651 );
1652 });
1653 cx.spawn(
1654 |mut cx| async move { window.dispatch_action(search_view_id, &ToggleFocus, &mut cx) },
1655 )
1656 .detach();
1657 deterministic.run_until_parked();
1658 search_view.update(cx, |search_view, cx| {
1659 assert!(
1660 search_view.query_editor.is_focused(cx),
1661 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
1662 );
1663 });
1664
1665 search_view.update(cx, |search_view, cx| {
1666 search_view
1667 .query_editor
1668 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1669 search_view.search(cx);
1670 });
1671 deterministic.run_until_parked();
1672 search_view.update(cx, |search_view, cx| {
1673 assert_eq!(
1674 search_view
1675 .results_editor
1676 .update(cx, |editor, cx| editor.display_text(cx)),
1677 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1678 "Search view results should match the query"
1679 );
1680 assert!(
1681 search_view.results_editor.is_focused(cx),
1682 "Search view with mismatching query should be focused after search results are available",
1683 );
1684 });
1685 cx.spawn(|mut cx| async move {
1686 window.dispatch_action(search_view_id, &ToggleFocus, &mut cx);
1687 })
1688 .detach();
1689 deterministic.run_until_parked();
1690 search_view.update(cx, |search_view, cx| {
1691 assert!(
1692 search_view.results_editor.is_focused(cx),
1693 "Search view with matching query should still have its results editor focused after the toggle focus event",
1694 );
1695 });
1696
1697 workspace.update(cx, |workspace, cx| {
1698 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
1699 });
1700 search_view.update(cx, |search_view, cx| {
1701 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");
1702 assert_eq!(
1703 search_view
1704 .results_editor
1705 .update(cx, |editor, cx| editor.display_text(cx)),
1706 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1707 "Results should be unchanged after search view 2nd open in a row"
1708 );
1709 assert!(
1710 search_view.query_editor.is_focused(cx),
1711 "Focus should be moved into query editor again after search view 2nd open in a row"
1712 );
1713 });
1714
1715 cx.spawn(|mut cx| async move {
1716 window.dispatch_action(search_view_id, &ToggleFocus, &mut cx);
1717 })
1718 .detach();
1719 deterministic.run_until_parked();
1720 search_view.update(cx, |search_view, cx| {
1721 assert!(
1722 search_view.results_editor.is_focused(cx),
1723 "Search view with matching query should switch focus to the results editor after the toggle focus event",
1724 );
1725 });
1726 }
1727
1728 #[gpui::test]
1729 async fn test_new_project_search_in_directory(
1730 deterministic: Arc<Deterministic>,
1731 cx: &mut TestAppContext,
1732 ) {
1733 init_test(cx);
1734
1735 let fs = FakeFs::new(cx.background());
1736 fs.insert_tree(
1737 "/dir",
1738 json!({
1739 "a": {
1740 "one.rs": "const ONE: usize = 1;",
1741 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1742 },
1743 "b": {
1744 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1745 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1746 },
1747 }),
1748 )
1749 .await;
1750 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1751 let worktree_id = project.read_with(cx, |project, cx| {
1752 project.worktrees(cx).next().unwrap().read(cx).id()
1753 });
1754 let workspace = cx
1755 .add_window(|cx| Workspace::test_new(project, cx))
1756 .root(cx);
1757
1758 let active_item = cx.read(|cx| {
1759 workspace
1760 .read(cx)
1761 .active_pane()
1762 .read(cx)
1763 .active_item()
1764 .and_then(|item| item.downcast::<ProjectSearchView>())
1765 });
1766 assert!(
1767 active_item.is_none(),
1768 "Expected no search panel to be active, but got: {active_item:?}"
1769 );
1770
1771 let one_file_entry = cx.update(|cx| {
1772 workspace
1773 .read(cx)
1774 .project()
1775 .read(cx)
1776 .entry_for_path(&(worktree_id, "a/one.rs").into(), cx)
1777 .expect("no entry for /a/one.rs file")
1778 });
1779 assert!(one_file_entry.is_file());
1780 workspace.update(cx, |workspace, cx| {
1781 ProjectSearchView::new_search_in_directory(workspace, &one_file_entry, cx)
1782 });
1783 let active_search_entry = cx.read(|cx| {
1784 workspace
1785 .read(cx)
1786 .active_pane()
1787 .read(cx)
1788 .active_item()
1789 .and_then(|item| item.downcast::<ProjectSearchView>())
1790 });
1791 assert!(
1792 active_search_entry.is_none(),
1793 "Expected no search panel to be active for file entry"
1794 );
1795
1796 let a_dir_entry = cx.update(|cx| {
1797 workspace
1798 .read(cx)
1799 .project()
1800 .read(cx)
1801 .entry_for_path(&(worktree_id, "a").into(), cx)
1802 .expect("no entry for /a/ directory")
1803 });
1804 assert!(a_dir_entry.is_dir());
1805 workspace.update(cx, |workspace, cx| {
1806 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry, cx)
1807 });
1808
1809 let Some(search_view) = cx.read(|cx| {
1810 workspace
1811 .read(cx)
1812 .active_pane()
1813 .read(cx)
1814 .active_item()
1815 .and_then(|item| item.downcast::<ProjectSearchView>())
1816 }) else {
1817 panic!("Search view expected to appear after new search in directory event trigger")
1818 };
1819 deterministic.run_until_parked();
1820 search_view.update(cx, |search_view, cx| {
1821 assert!(
1822 search_view.query_editor.is_focused(cx),
1823 "On new search in directory, focus should be moved into query editor"
1824 );
1825 search_view.excluded_files_editor.update(cx, |editor, cx| {
1826 assert!(
1827 editor.display_text(cx).is_empty(),
1828 "New search in directory should not have any excluded files"
1829 );
1830 });
1831 search_view.included_files_editor.update(cx, |editor, cx| {
1832 assert_eq!(
1833 editor.display_text(cx),
1834 a_dir_entry.path.to_str().unwrap(),
1835 "New search in directory should have included dir entry path"
1836 );
1837 });
1838 });
1839
1840 search_view.update(cx, |search_view, cx| {
1841 search_view
1842 .query_editor
1843 .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
1844 search_view.search(cx);
1845 });
1846 deterministic.run_until_parked();
1847 search_view.update(cx, |search_view, cx| {
1848 assert_eq!(
1849 search_view
1850 .results_editor
1851 .update(cx, |editor, cx| editor.display_text(cx)),
1852 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1853 "New search in directory should have a filter that matches a certain directory"
1854 );
1855 });
1856 }
1857
1858 #[gpui::test]
1859 async fn test_search_query_history(cx: &mut TestAppContext) {
1860 init_test(cx);
1861
1862 let fs = FakeFs::new(cx.background());
1863 fs.insert_tree(
1864 "/dir",
1865 json!({
1866 "one.rs": "const ONE: usize = 1;",
1867 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1868 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1869 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1870 }),
1871 )
1872 .await;
1873 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1874 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
1875 let workspace = window.root(cx);
1876 workspace.update(cx, |workspace, cx| {
1877 ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
1878 });
1879
1880 let search_view = cx.read(|cx| {
1881 workspace
1882 .read(cx)
1883 .active_pane()
1884 .read(cx)
1885 .active_item()
1886 .and_then(|item| item.downcast::<ProjectSearchView>())
1887 .expect("Search view expected to appear after new search event trigger")
1888 });
1889
1890 let search_bar = window.add_view(cx, |cx| {
1891 let mut search_bar = ProjectSearchBar::new();
1892 search_bar.set_active_pane_item(Some(&search_view), cx);
1893 // search_bar.show(cx);
1894 search_bar
1895 });
1896
1897 // Add 3 search items into the history + another unsubmitted one.
1898 search_view.update(cx, |search_view, cx| {
1899 search_view.search_options = SearchOptions::CASE_SENSITIVE;
1900 search_view
1901 .query_editor
1902 .update(cx, |query_editor, cx| query_editor.set_text("ONE", cx));
1903 search_view.search(cx);
1904 });
1905 cx.foreground().run_until_parked();
1906 search_view.update(cx, |search_view, cx| {
1907 search_view
1908 .query_editor
1909 .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1910 search_view.search(cx);
1911 });
1912 cx.foreground().run_until_parked();
1913 search_view.update(cx, |search_view, cx| {
1914 search_view
1915 .query_editor
1916 .update(cx, |query_editor, cx| query_editor.set_text("THREE", cx));
1917 search_view.search(cx);
1918 });
1919 cx.foreground().run_until_parked();
1920 search_view.update(cx, |search_view, cx| {
1921 search_view.query_editor.update(cx, |query_editor, cx| {
1922 query_editor.set_text("JUST_TEXT_INPUT", cx)
1923 });
1924 });
1925 cx.foreground().run_until_parked();
1926
1927 // Ensure that the latest input with search settings is active.
1928 search_view.update(cx, |search_view, cx| {
1929 assert_eq!(
1930 search_view.query_editor.read(cx).text(cx),
1931 "JUST_TEXT_INPUT"
1932 );
1933 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
1934 });
1935
1936 // Next history query after the latest should set the query to the empty string.
1937 search_bar.update(cx, |search_bar, cx| {
1938 search_bar.next_history_query(&NextHistoryQuery, cx);
1939 });
1940 search_view.update(cx, |search_view, cx| {
1941 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
1942 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
1943 });
1944 search_bar.update(cx, |search_bar, cx| {
1945 search_bar.next_history_query(&NextHistoryQuery, cx);
1946 });
1947 search_view.update(cx, |search_view, cx| {
1948 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
1949 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
1950 });
1951
1952 // First previous query for empty current query should set the query to the latest submitted one.
1953 search_bar.update(cx, |search_bar, cx| {
1954 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
1955 });
1956 search_view.update(cx, |search_view, cx| {
1957 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
1958 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
1959 });
1960
1961 // Further previous items should go over the history in reverse order.
1962 search_bar.update(cx, |search_bar, cx| {
1963 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
1964 });
1965 search_view.update(cx, |search_view, cx| {
1966 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
1967 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
1968 });
1969
1970 // Previous items should never go behind the first history item.
1971 search_bar.update(cx, |search_bar, cx| {
1972 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
1973 });
1974 search_view.update(cx, |search_view, cx| {
1975 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
1976 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
1977 });
1978 search_bar.update(cx, |search_bar, cx| {
1979 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
1980 });
1981 search_view.update(cx, |search_view, cx| {
1982 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
1983 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
1984 });
1985
1986 // Next items should go over the history in the original order.
1987 search_bar.update(cx, |search_bar, cx| {
1988 search_bar.next_history_query(&NextHistoryQuery, cx);
1989 });
1990 search_view.update(cx, |search_view, cx| {
1991 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
1992 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
1993 });
1994
1995 search_view.update(cx, |search_view, cx| {
1996 search_view
1997 .query_editor
1998 .update(cx, |query_editor, cx| query_editor.set_text("TWO_NEW", cx));
1999 search_view.search(cx);
2000 });
2001 cx.foreground().run_until_parked();
2002 search_view.update(cx, |search_view, cx| {
2003 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2004 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2005 });
2006
2007 // New search input should add another entry to history and move the selection to the end of the history.
2008 search_bar.update(cx, |search_bar, cx| {
2009 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2010 });
2011 search_view.update(cx, |search_view, cx| {
2012 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2013 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2014 });
2015 search_bar.update(cx, |search_bar, cx| {
2016 search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2017 });
2018 search_view.update(cx, |search_view, cx| {
2019 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2020 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2021 });
2022 search_bar.update(cx, |search_bar, cx| {
2023 search_bar.next_history_query(&NextHistoryQuery, cx);
2024 });
2025 search_view.update(cx, |search_view, cx| {
2026 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2027 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2028 });
2029 search_bar.update(cx, |search_bar, cx| {
2030 search_bar.next_history_query(&NextHistoryQuery, cx);
2031 });
2032 search_view.update(cx, |search_view, cx| {
2033 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2034 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2035 });
2036 search_bar.update(cx, |search_bar, cx| {
2037 search_bar.next_history_query(&NextHistoryQuery, cx);
2038 });
2039 search_view.update(cx, |search_view, cx| {
2040 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2041 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2042 });
2043 }
2044
2045 pub fn init_test(cx: &mut TestAppContext) {
2046 cx.foreground().forbid_parking();
2047 let fonts = cx.font_cache();
2048 let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default);
2049 theme.search.match_background = Color::red();
2050
2051 cx.update(|cx| {
2052 cx.set_global(SettingsStore::test(cx));
2053 cx.set_global(ActiveSearches::default());
2054 settings::register::<SemanticIndexSettings>(cx);
2055
2056 theme::init((), cx);
2057 cx.update_global::<SettingsStore, _, _>(|store, _| {
2058 let mut settings = store.get::<ThemeSettings>(None).clone();
2059 settings.theme = Arc::new(theme);
2060 store.override_global(settings)
2061 });
2062
2063 language::init(cx);
2064 client::init_settings(cx);
2065 editor::init(cx);
2066 workspace::init_settings(cx);
2067 Project::init_settings(cx);
2068 super::init(cx);
2069 });
2070 }
2071}