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