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