1use crate::{
2 BufferSearchBar, FocusSearch, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext,
3 SearchOptions, SelectNextMatch, SelectPreviousMatch, ToggleCaseSensitive, ToggleIncludeIgnored,
4 ToggleRegex, ToggleReplace, ToggleWholeWord, buffer_search::Deploy,
5};
6use anyhow::Context as _;
7use collections::{HashMap, HashSet};
8use editor::{
9 Anchor, Editor, EditorElement, EditorEvent, EditorSettings, EditorStyle, MAX_TAB_TITLE_LEN,
10 MultiBuffer, actions::SelectAll, items::active_match_index, scroll::Autoscroll,
11};
12use futures::{StreamExt, stream::FuturesOrdered};
13use gpui::{
14 Action, AnyElement, AnyView, App, Axis, Context, Entity, EntityId, EventEmitter, FocusHandle,
15 Focusable, Global, Hsla, InteractiveElement, IntoElement, KeyContext, ParentElement, Point,
16 Render, SharedString, Styled, Subscription, Task, TextStyle, UpdateGlobal, WeakEntity, Window,
17 actions, div,
18};
19use language::{Buffer, Language};
20use menu::Confirm;
21use project::{
22 Project, ProjectPath,
23 search::{SearchInputKind, SearchQuery},
24 search_history::SearchHistoryCursor,
25};
26use settings::Settings;
27use std::{
28 any::{Any, TypeId},
29 mem,
30 ops::{Not, Range},
31 path::Path,
32 pin::pin,
33 sync::Arc,
34};
35use theme::ThemeSettings;
36use ui::{
37 Icon, IconButton, IconButtonShape, IconName, KeyBinding, Label, LabelCommon, LabelSize,
38 Toggleable, Tooltip, h_flex, prelude::*, utils::SearchInputWidth, v_flex,
39};
40use util::{ResultExt as _, paths::PathMatcher};
41use workspace::{
42 DeploySearch, ItemNavHistory, NewSearch, ToolbarItemEvent, ToolbarItemLocation,
43 ToolbarItemView, Workspace, WorkspaceId,
44 item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
45 searchable::{Direction, SearchableItem, SearchableItemHandle},
46};
47
48actions!(
49 project_search,
50 [SearchInNew, ToggleFocus, NextField, ToggleFilters]
51);
52
53#[derive(Default)]
54struct ActiveSettings(HashMap<WeakEntity<Project>, ProjectSearchSettings>);
55
56impl Global for ActiveSettings {}
57
58pub fn init(cx: &mut App) {
59 cx.set_global(ActiveSettings::default());
60 cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
61 register_workspace_action(workspace, move |search_bar, _: &Deploy, window, cx| {
62 search_bar.focus_search(window, cx);
63 });
64 register_workspace_action(workspace, move |search_bar, _: &FocusSearch, window, cx| {
65 search_bar.focus_search(window, cx);
66 });
67 register_workspace_action(
68 workspace,
69 move |search_bar, _: &ToggleFilters, window, cx| {
70 search_bar.toggle_filters(window, cx);
71 },
72 );
73 register_workspace_action(
74 workspace,
75 move |search_bar, _: &ToggleCaseSensitive, window, cx| {
76 search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
77 },
78 );
79 register_workspace_action(
80 workspace,
81 move |search_bar, _: &ToggleWholeWord, window, cx| {
82 search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
83 },
84 );
85 register_workspace_action(workspace, move |search_bar, _: &ToggleRegex, window, cx| {
86 search_bar.toggle_search_option(SearchOptions::REGEX, window, cx);
87 });
88 register_workspace_action(
89 workspace,
90 move |search_bar, action: &ToggleReplace, window, cx| {
91 search_bar.toggle_replace(action, window, cx)
92 },
93 );
94 register_workspace_action(
95 workspace,
96 move |search_bar, action: &SelectPreviousMatch, window, cx| {
97 search_bar.select_prev_match(action, window, cx)
98 },
99 );
100 register_workspace_action(
101 workspace,
102 move |search_bar, action: &SelectNextMatch, window, cx| {
103 search_bar.select_next_match(action, window, cx)
104 },
105 );
106
107 // Only handle search_in_new if there is a search present
108 register_workspace_action_for_present_search(workspace, |workspace, action, window, cx| {
109 ProjectSearchView::search_in_new(workspace, action, window, cx)
110 });
111
112 register_workspace_action_for_present_search(
113 workspace,
114 |workspace, _: &menu::Cancel, window, cx| {
115 if let Some(project_search_bar) = workspace
116 .active_pane()
117 .read(cx)
118 .toolbar()
119 .read(cx)
120 .item_of_type::<ProjectSearchBar>()
121 {
122 project_search_bar.update(cx, |project_search_bar, cx| {
123 let search_is_focused = project_search_bar
124 .active_project_search
125 .as_ref()
126 .is_some_and(|search_view| {
127 search_view
128 .read(cx)
129 .query_editor
130 .read(cx)
131 .focus_handle(cx)
132 .is_focused(window)
133 });
134 if search_is_focused {
135 project_search_bar.move_focus_to_results(window, cx);
136 } else {
137 project_search_bar.focus_search(window, cx)
138 }
139 });
140 } else {
141 cx.propagate();
142 }
143 },
144 );
145
146 // Both on present and dismissed search, we need to unconditionally handle those actions to focus from the editor.
147 workspace.register_action(move |workspace, action: &DeploySearch, window, cx| {
148 if workspace.has_active_modal(window, cx) {
149 cx.propagate();
150 return;
151 }
152 ProjectSearchView::deploy_search(workspace, action, window, cx);
153 cx.notify();
154 });
155 workspace.register_action(move |workspace, action: &NewSearch, window, cx| {
156 if workspace.has_active_modal(window, cx) {
157 cx.propagate();
158 return;
159 }
160 ProjectSearchView::new_search(workspace, action, window, cx);
161 cx.notify();
162 });
163 })
164 .detach();
165}
166
167fn is_contains_uppercase(str: &str) -> bool {
168 str.chars().any(|c| c.is_uppercase())
169}
170
171pub struct ProjectSearch {
172 project: Entity<Project>,
173 excerpts: Entity<MultiBuffer>,
174 pending_search: Option<Task<Option<()>>>,
175 match_ranges: Vec<Range<Anchor>>,
176 active_query: Option<SearchQuery>,
177 last_search_query_text: Option<String>,
178 search_id: usize,
179 no_results: Option<bool>,
180 limit_reached: bool,
181 search_history_cursor: SearchHistoryCursor,
182 search_included_history_cursor: SearchHistoryCursor,
183 search_excluded_history_cursor: SearchHistoryCursor,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
187enum InputPanel {
188 Query,
189 Exclude,
190 Include,
191}
192
193pub struct ProjectSearchView {
194 workspace: WeakEntity<Workspace>,
195 focus_handle: FocusHandle,
196 entity: Entity<ProjectSearch>,
197 query_editor: Entity<Editor>,
198 replacement_editor: Entity<Editor>,
199 results_editor: Entity<Editor>,
200 search_options: SearchOptions,
201 panels_with_errors: HashSet<InputPanel>,
202 active_match_index: Option<usize>,
203 search_id: usize,
204 included_files_editor: Entity<Editor>,
205 excluded_files_editor: Entity<Editor>,
206 filters_enabled: bool,
207 replace_enabled: bool,
208 included_opened_only: bool,
209 regex_language: Option<Arc<Language>>,
210 _subscriptions: Vec<Subscription>,
211}
212
213#[derive(Debug, Clone)]
214pub struct ProjectSearchSettings {
215 search_options: SearchOptions,
216 filters_enabled: bool,
217}
218
219pub struct ProjectSearchBar {
220 active_project_search: Option<Entity<ProjectSearchView>>,
221 subscription: Option<Subscription>,
222}
223
224impl ProjectSearch {
225 pub fn new(project: Entity<Project>, cx: &mut Context<Self>) -> Self {
226 let capability = project.read(cx).capability();
227
228 Self {
229 project,
230 excerpts: cx.new(|_| MultiBuffer::new(capability)),
231 pending_search: Default::default(),
232 match_ranges: Default::default(),
233 active_query: None,
234 last_search_query_text: None,
235 search_id: 0,
236 no_results: None,
237 limit_reached: false,
238 search_history_cursor: Default::default(),
239 search_included_history_cursor: Default::default(),
240 search_excluded_history_cursor: Default::default(),
241 }
242 }
243
244 fn clone(&self, cx: &mut Context<Self>) -> Entity<Self> {
245 cx.new(|cx| Self {
246 project: self.project.clone(),
247 excerpts: self
248 .excerpts
249 .update(cx, |excerpts, cx| cx.new(|cx| excerpts.clone(cx))),
250 pending_search: Default::default(),
251 match_ranges: self.match_ranges.clone(),
252 active_query: self.active_query.clone(),
253 last_search_query_text: self.last_search_query_text.clone(),
254 search_id: self.search_id,
255 no_results: self.no_results,
256 limit_reached: self.limit_reached,
257 search_history_cursor: self.search_history_cursor.clone(),
258 search_included_history_cursor: self.search_included_history_cursor.clone(),
259 search_excluded_history_cursor: self.search_excluded_history_cursor.clone(),
260 })
261 }
262 fn cursor(&self, kind: SearchInputKind) -> &SearchHistoryCursor {
263 match kind {
264 SearchInputKind::Query => &self.search_history_cursor,
265 SearchInputKind::Include => &self.search_included_history_cursor,
266 SearchInputKind::Exclude => &self.search_excluded_history_cursor,
267 }
268 }
269 fn cursor_mut(&mut self, kind: SearchInputKind) -> &mut SearchHistoryCursor {
270 match kind {
271 SearchInputKind::Query => &mut self.search_history_cursor,
272 SearchInputKind::Include => &mut self.search_included_history_cursor,
273 SearchInputKind::Exclude => &mut self.search_excluded_history_cursor,
274 }
275 }
276
277 fn search(&mut self, query: SearchQuery, cx: &mut Context<Self>) {
278 let search = self.project.update(cx, |project, cx| {
279 project
280 .search_history_mut(SearchInputKind::Query)
281 .add(&mut self.search_history_cursor, query.as_str().to_string());
282 let included = query.as_inner().files_to_include().sources().join(",");
283 if !included.is_empty() {
284 project
285 .search_history_mut(SearchInputKind::Include)
286 .add(&mut self.search_included_history_cursor, included);
287 }
288 let excluded = query.as_inner().files_to_exclude().sources().join(",");
289 if !excluded.is_empty() {
290 project
291 .search_history_mut(SearchInputKind::Exclude)
292 .add(&mut self.search_excluded_history_cursor, excluded);
293 }
294 project.search(query.clone(), cx)
295 });
296 self.last_search_query_text = Some(query.as_str().to_string());
297 self.search_id += 1;
298 self.active_query = Some(query);
299 self.match_ranges.clear();
300 self.pending_search = Some(cx.spawn(async move |project_search, cx| {
301 let mut matches = pin!(search.ready_chunks(1024));
302 project_search
303 .update(cx, |project_search, cx| {
304 project_search.match_ranges.clear();
305 project_search
306 .excerpts
307 .update(cx, |excerpts, cx| excerpts.clear(cx));
308 project_search.no_results = Some(true);
309 project_search.limit_reached = false;
310 })
311 .ok()?;
312
313 let mut limit_reached = false;
314 while let Some(results) = matches.next().await {
315 let mut buffers_with_ranges = Vec::with_capacity(results.len());
316 for result in results {
317 match result {
318 project::search::SearchResult::Buffer { buffer, ranges } => {
319 buffers_with_ranges.push((buffer, ranges));
320 }
321 project::search::SearchResult::LimitReached => {
322 limit_reached = true;
323 }
324 }
325 }
326
327 let excerpts = project_search
328 .update(cx, |project_search, _| project_search.excerpts.clone())
329 .ok()?;
330 let mut new_ranges = excerpts
331 .update(cx, |excerpts, cx| {
332 buffers_with_ranges
333 .into_iter()
334 .map(|(buffer, ranges)| {
335 excerpts.set_anchored_excerpts_for_path(
336 buffer,
337 ranges,
338 editor::DEFAULT_MULTIBUFFER_CONTEXT,
339 cx,
340 )
341 })
342 .collect::<FuturesOrdered<_>>()
343 })
344 .ok()?;
345 while let Some(new_ranges) = new_ranges.next().await {
346 project_search
347 .update(cx, |project_search, _| {
348 project_search.match_ranges.extend(new_ranges);
349 })
350 .ok()?;
351 }
352 }
353
354 project_search
355 .update(cx, |project_search, cx| {
356 if !project_search.match_ranges.is_empty() {
357 project_search.no_results = Some(false);
358 }
359 project_search.limit_reached = limit_reached;
360 project_search.pending_search.take();
361 cx.notify();
362 })
363 .ok()?;
364
365 None
366 }));
367 cx.notify();
368 }
369}
370
371#[derive(Clone, Debug, PartialEq, Eq)]
372pub enum ViewEvent {
373 UpdateTab,
374 Activate,
375 EditorEvent(editor::EditorEvent),
376 Dismiss,
377}
378
379impl EventEmitter<ViewEvent> for ProjectSearchView {}
380
381impl Render for ProjectSearchView {
382 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
383 if self.has_matches() {
384 div()
385 .flex_1()
386 .size_full()
387 .track_focus(&self.focus_handle(cx))
388 .child(self.results_editor.clone())
389 } else {
390 let model = self.entity.read(cx);
391 let has_no_results = model.no_results.unwrap_or(false);
392 let is_search_underway = model.pending_search.is_some();
393
394 let heading_text = if is_search_underway {
395 "Searching…"
396 } else if has_no_results {
397 "No Results"
398 } else {
399 "Search All Files"
400 };
401
402 let heading_text = div()
403 .justify_center()
404 .child(Label::new(heading_text).size(LabelSize::Large));
405
406 let page_content: Option<AnyElement> = if let Some(no_results) = model.no_results {
407 if model.pending_search.is_none() && no_results {
408 Some(
409 Label::new("No results found in this project for the provided query")
410 .size(LabelSize::Small)
411 .into_any_element(),
412 )
413 } else {
414 None
415 }
416 } else {
417 Some(self.landing_text_minor(window, cx).into_any_element())
418 };
419
420 let page_content = page_content.map(|text| div().child(text));
421
422 h_flex()
423 .size_full()
424 .items_center()
425 .justify_center()
426 .overflow_hidden()
427 .bg(cx.theme().colors().editor_background)
428 .track_focus(&self.focus_handle(cx))
429 .child(
430 v_flex()
431 .id("project-search-landing-page")
432 .overflow_y_scroll()
433 .gap_1()
434 .child(heading_text)
435 .children(page_content),
436 )
437 }
438 }
439}
440
441impl Focusable for ProjectSearchView {
442 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
443 self.focus_handle.clone()
444 }
445}
446
447impl Item for ProjectSearchView {
448 type Event = ViewEvent;
449 fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
450 let query_text = self.query_editor.read(cx).text(cx);
451
452 query_text
453 .is_empty()
454 .not()
455 .then(|| query_text.into())
456 .or_else(|| Some("Project Search".into()))
457 }
458
459 fn act_as_type<'a>(
460 &'a self,
461 type_id: TypeId,
462 self_handle: &'a Entity<Self>,
463 _: &'a App,
464 ) -> Option<AnyView> {
465 if type_id == TypeId::of::<Self>() {
466 Some(self_handle.clone().into())
467 } else if type_id == TypeId::of::<Editor>() {
468 Some(self.results_editor.clone().into())
469 } else {
470 None
471 }
472 }
473 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
474 Some(Box::new(self.results_editor.clone()))
475 }
476
477 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
478 self.results_editor
479 .update(cx, |editor, cx| editor.deactivated(window, cx));
480 }
481
482 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
483 Some(Icon::new(IconName::MagnifyingGlass))
484 }
485
486 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
487 let last_query: Option<SharedString> = self
488 .entity
489 .read(cx)
490 .last_search_query_text
491 .as_ref()
492 .map(|query| {
493 let query = query.replace('\n', "");
494 let query_text = util::truncate_and_trailoff(&query, MAX_TAB_TITLE_LEN);
495 query_text.into()
496 });
497
498 last_query
499 .filter(|query| !query.is_empty())
500 .unwrap_or_else(|| "Project Search".into())
501 }
502
503 fn telemetry_event_text(&self) -> Option<&'static str> {
504 Some("Project Search Opened")
505 }
506
507 fn for_each_project_item(
508 &self,
509 cx: &App,
510 f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
511 ) {
512 self.results_editor.for_each_project_item(cx, f)
513 }
514
515 fn is_singleton(&self, _: &App) -> bool {
516 false
517 }
518
519 fn can_save(&self, _: &App) -> bool {
520 true
521 }
522
523 fn is_dirty(&self, cx: &App) -> bool {
524 self.results_editor.read(cx).is_dirty(cx)
525 }
526
527 fn has_conflict(&self, cx: &App) -> bool {
528 self.results_editor.read(cx).has_conflict(cx)
529 }
530
531 fn save(
532 &mut self,
533 format: bool,
534 project: Entity<Project>,
535 window: &mut Window,
536 cx: &mut Context<Self>,
537 ) -> Task<anyhow::Result<()>> {
538 self.results_editor
539 .update(cx, |editor, cx| editor.save(format, project, window, cx))
540 }
541
542 fn save_as(
543 &mut self,
544 _: Entity<Project>,
545 _: ProjectPath,
546 _window: &mut Window,
547 _: &mut Context<Self>,
548 ) -> Task<anyhow::Result<()>> {
549 unreachable!("save_as should not have been called")
550 }
551
552 fn reload(
553 &mut self,
554 project: Entity<Project>,
555 window: &mut Window,
556 cx: &mut Context<Self>,
557 ) -> Task<anyhow::Result<()>> {
558 self.results_editor
559 .update(cx, |editor, cx| editor.reload(project, window, cx))
560 }
561
562 fn clone_on_split(
563 &self,
564 _workspace_id: Option<WorkspaceId>,
565 window: &mut Window,
566 cx: &mut Context<Self>,
567 ) -> Option<Entity<Self>>
568 where
569 Self: Sized,
570 {
571 let model = self.entity.update(cx, |model, cx| model.clone(cx));
572 Some(cx.new(|cx| Self::new(self.workspace.clone(), model, window, cx, None)))
573 }
574
575 fn added_to_workspace(
576 &mut self,
577 workspace: &mut Workspace,
578 window: &mut Window,
579 cx: &mut Context<Self>,
580 ) {
581 self.results_editor.update(cx, |editor, cx| {
582 editor.added_to_workspace(workspace, window, cx)
583 });
584 }
585
586 fn set_nav_history(
587 &mut self,
588 nav_history: ItemNavHistory,
589 _: &mut Window,
590 cx: &mut Context<Self>,
591 ) {
592 self.results_editor.update(cx, |editor, _| {
593 editor.set_nav_history(Some(nav_history));
594 });
595 }
596
597 fn navigate(
598 &mut self,
599 data: Box<dyn Any>,
600 window: &mut Window,
601 cx: &mut Context<Self>,
602 ) -> bool {
603 self.results_editor
604 .update(cx, |editor, cx| editor.navigate(data, window, cx))
605 }
606
607 fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
608 match event {
609 ViewEvent::UpdateTab => {
610 f(ItemEvent::UpdateBreadcrumbs);
611 f(ItemEvent::UpdateTab);
612 }
613 ViewEvent::EditorEvent(editor_event) => {
614 Editor::to_item_events(editor_event, f);
615 }
616 ViewEvent::Dismiss => f(ItemEvent::CloseItem),
617 _ => {}
618 }
619 }
620
621 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
622 if self.has_matches() {
623 ToolbarItemLocation::Secondary
624 } else {
625 ToolbarItemLocation::Hidden
626 }
627 }
628
629 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
630 self.results_editor.breadcrumbs(theme, cx)
631 }
632}
633
634impl ProjectSearchView {
635 pub fn get_matches(&self, cx: &App) -> Vec<Range<Anchor>> {
636 self.entity.read(cx).match_ranges.clone()
637 }
638
639 fn toggle_filters(&mut self, cx: &mut Context<Self>) {
640 self.filters_enabled = !self.filters_enabled;
641 ActiveSettings::update_global(cx, |settings, cx| {
642 settings.0.insert(
643 self.entity.read(cx).project.downgrade(),
644 self.current_settings(),
645 );
646 });
647 }
648
649 fn current_settings(&self) -> ProjectSearchSettings {
650 ProjectSearchSettings {
651 search_options: self.search_options,
652 filters_enabled: self.filters_enabled,
653 }
654 }
655
656 fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut Context<Self>) {
657 self.search_options.toggle(option);
658 ActiveSettings::update_global(cx, |settings, cx| {
659 settings.0.insert(
660 self.entity.read(cx).project.downgrade(),
661 self.current_settings(),
662 );
663 });
664 self.adjust_query_regex_language(cx);
665 }
666
667 fn toggle_opened_only(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
668 self.included_opened_only = !self.included_opened_only;
669 }
670
671 fn replace_next(&mut self, _: &ReplaceNext, window: &mut Window, cx: &mut Context<Self>) {
672 if self.entity.read(cx).match_ranges.is_empty() {
673 return;
674 }
675 let Some(active_index) = self.active_match_index else {
676 return;
677 };
678
679 let query = self.entity.read(cx).active_query.clone();
680 if let Some(query) = query {
681 let query = query.with_replacement(self.replacement(cx));
682
683 // TODO: Do we need the clone here?
684 let mat = self.entity.read(cx).match_ranges[active_index].clone();
685 self.results_editor.update(cx, |editor, cx| {
686 editor.replace(&mat, &query, window, cx);
687 });
688 self.select_match(Direction::Next, window, cx)
689 }
690 }
691 pub fn replacement(&self, cx: &App) -> String {
692 self.replacement_editor.read(cx).text(cx)
693 }
694 fn replace_all(&mut self, _: &ReplaceAll, window: &mut Window, cx: &mut Context<Self>) {
695 if self.active_match_index.is_none() {
696 return;
697 }
698
699 let Some(query) = self.entity.read(cx).active_query.as_ref() else {
700 return;
701 };
702 let query = query.clone().with_replacement(self.replacement(cx));
703
704 let match_ranges = self
705 .entity
706 .update(cx, |model, _| mem::take(&mut model.match_ranges));
707 if match_ranges.is_empty() {
708 return;
709 }
710
711 self.results_editor.update(cx, |editor, cx| {
712 editor.replace_all(&mut match_ranges.iter(), &query, window, cx);
713 });
714
715 self.entity.update(cx, |model, _cx| {
716 model.match_ranges = match_ranges;
717 });
718 }
719
720 pub fn new(
721 workspace: WeakEntity<Workspace>,
722 entity: Entity<ProjectSearch>,
723 window: &mut Window,
724 cx: &mut Context<Self>,
725 settings: Option<ProjectSearchSettings>,
726 ) -> Self {
727 let project;
728 let excerpts;
729 let mut replacement_text = None;
730 let mut query_text = String::new();
731 let mut subscriptions = Vec::new();
732
733 // Read in settings if available
734 let (mut options, filters_enabled) = if let Some(settings) = settings {
735 (settings.search_options, settings.filters_enabled)
736 } else {
737 let search_options =
738 SearchOptions::from_settings(&EditorSettings::get_global(cx).search);
739 (search_options, false)
740 };
741
742 {
743 let entity = entity.read(cx);
744 project = entity.project.clone();
745 excerpts = entity.excerpts.clone();
746 if let Some(active_query) = entity.active_query.as_ref() {
747 query_text = active_query.as_str().to_string();
748 replacement_text = active_query.replacement().map(ToOwned::to_owned);
749 options = SearchOptions::from_query(active_query);
750 }
751 }
752 subscriptions.push(cx.observe_in(&entity, window, |this, _, window, cx| {
753 this.entity_changed(window, cx)
754 }));
755
756 let query_editor = cx.new(|cx| {
757 let mut editor = Editor::single_line(window, cx);
758 editor.set_placeholder_text("Search all files…", cx);
759 editor.set_text(query_text, window, cx);
760 editor
761 });
762 // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
763 subscriptions.push(
764 cx.subscribe(&query_editor, |this, _, event: &EditorEvent, cx| {
765 if let EditorEvent::Edited { .. } = event {
766 if EditorSettings::get_global(cx).use_smartcase_search {
767 let query = this.search_query_text(cx);
768 if !query.is_empty()
769 && this.search_options.contains(SearchOptions::CASE_SENSITIVE)
770 != is_contains_uppercase(&query)
771 {
772 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
773 }
774 }
775 }
776 cx.emit(ViewEvent::EditorEvent(event.clone()))
777 }),
778 );
779 let replacement_editor = cx.new(|cx| {
780 let mut editor = Editor::single_line(window, cx);
781 editor.set_placeholder_text("Replace in project…", cx);
782 if let Some(text) = replacement_text {
783 editor.set_text(text, window, cx);
784 }
785 editor
786 });
787 let results_editor = cx.new(|cx| {
788 let mut editor = Editor::for_multibuffer(excerpts, Some(project.clone()), window, cx);
789 editor.set_searchable(false);
790 editor.set_in_project_search(true);
791 editor
792 });
793 subscriptions.push(cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab)));
794
795 subscriptions.push(
796 cx.subscribe(&results_editor, |this, _, event: &EditorEvent, cx| {
797 if matches!(event, editor::EditorEvent::SelectionsChanged { .. }) {
798 this.update_match_index(cx);
799 }
800 // Reraise editor events for workspace item activation purposes
801 cx.emit(ViewEvent::EditorEvent(event.clone()));
802 }),
803 );
804
805 let included_files_editor = cx.new(|cx| {
806 let mut editor = Editor::single_line(window, cx);
807 editor.set_placeholder_text("Include: crates/**/*.toml", cx);
808
809 editor
810 });
811 // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes
812 subscriptions.push(
813 cx.subscribe(&included_files_editor, |_, _, event: &EditorEvent, cx| {
814 cx.emit(ViewEvent::EditorEvent(event.clone()))
815 }),
816 );
817
818 let excluded_files_editor = cx.new(|cx| {
819 let mut editor = Editor::single_line(window, cx);
820 editor.set_placeholder_text("Exclude: vendor/*, *.lock", cx);
821
822 editor
823 });
824 // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes
825 subscriptions.push(
826 cx.subscribe(&excluded_files_editor, |_, _, event: &EditorEvent, cx| {
827 cx.emit(ViewEvent::EditorEvent(event.clone()))
828 }),
829 );
830
831 let focus_handle = cx.focus_handle();
832 subscriptions.push(cx.on_focus(&focus_handle, window, |_, window, cx| {
833 cx.on_next_frame(window, |this, window, cx| {
834 if this.focus_handle.is_focused(window) {
835 if this.has_matches() {
836 this.results_editor.focus_handle(cx).focus(window);
837 } else {
838 this.query_editor.focus_handle(cx).focus(window);
839 }
840 }
841 });
842 }));
843
844 let languages = project.read(cx).languages().clone();
845 cx.spawn(async move |project_search_view, cx| {
846 let regex_language = languages
847 .language_for_name("regex")
848 .await
849 .context("loading regex language")?;
850 project_search_view
851 .update(cx, |project_search_view, cx| {
852 project_search_view.regex_language = Some(regex_language);
853 project_search_view.adjust_query_regex_language(cx);
854 })
855 .ok();
856 anyhow::Ok(())
857 })
858 .detach_and_log_err(cx);
859
860 // Check if Worktrees have all been previously indexed
861 let mut this = ProjectSearchView {
862 workspace,
863 focus_handle,
864 replacement_editor,
865 search_id: entity.read(cx).search_id,
866 entity,
867 query_editor,
868 results_editor,
869 search_options: options,
870 panels_with_errors: HashSet::default(),
871 active_match_index: None,
872 included_files_editor,
873 excluded_files_editor,
874 filters_enabled,
875 replace_enabled: false,
876 included_opened_only: false,
877 regex_language: None,
878 _subscriptions: subscriptions,
879 };
880 this.entity_changed(window, cx);
881 this
882 }
883
884 pub fn new_search_in_directory(
885 workspace: &mut Workspace,
886 dir_path: &Path,
887 window: &mut Window,
888 cx: &mut Context<Workspace>,
889 ) {
890 let Some(filter_str) = dir_path.to_str() else {
891 return;
892 };
893
894 let weak_workspace = cx.entity().downgrade();
895
896 let entity = cx.new(|cx| ProjectSearch::new(workspace.project().clone(), cx));
897 let search = cx.new(|cx| ProjectSearchView::new(weak_workspace, entity, window, cx, None));
898 workspace.add_item_to_active_pane(Box::new(search.clone()), None, true, window, cx);
899 search.update(cx, |search, cx| {
900 search
901 .included_files_editor
902 .update(cx, |editor, cx| editor.set_text(filter_str, window, cx));
903 search.filters_enabled = true;
904 search.focus_query_editor(window, cx)
905 });
906 }
907
908 /// Re-activate the most recently activated search in this pane or the most recent if it has been closed.
909 /// If no search exists in the workspace, create a new one.
910 pub fn deploy_search(
911 workspace: &mut Workspace,
912 action: &workspace::DeploySearch,
913 window: &mut Window,
914 cx: &mut Context<Workspace>,
915 ) {
916 let existing = workspace
917 .active_pane()
918 .read(cx)
919 .items()
920 .find_map(|item| item.downcast::<ProjectSearchView>());
921
922 Self::existing_or_new_search(workspace, existing, action, window, cx);
923 }
924
925 fn search_in_new(
926 workspace: &mut Workspace,
927 _: &SearchInNew,
928 window: &mut Window,
929 cx: &mut Context<Workspace>,
930 ) {
931 if let Some(search_view) = workspace
932 .active_item(cx)
933 .and_then(|item| item.downcast::<ProjectSearchView>())
934 {
935 let new_query = search_view.update(cx, |search_view, cx| {
936 let new_query = search_view.build_search_query(cx);
937 if new_query.is_some() {
938 if let Some(old_query) = search_view.entity.read(cx).active_query.clone() {
939 search_view.query_editor.update(cx, |editor, cx| {
940 editor.set_text(old_query.as_str(), window, cx);
941 });
942 search_view.search_options = SearchOptions::from_query(&old_query);
943 search_view.adjust_query_regex_language(cx);
944 }
945 }
946 new_query
947 });
948 if let Some(new_query) = new_query {
949 let entity = cx.new(|cx| {
950 let mut entity = ProjectSearch::new(workspace.project().clone(), cx);
951 entity.search(new_query, cx);
952 entity
953 });
954 let weak_workspace = cx.entity().downgrade();
955 workspace.add_item_to_active_pane(
956 Box::new(cx.new(|cx| {
957 ProjectSearchView::new(weak_workspace, entity, window, cx, None)
958 })),
959 None,
960 true,
961 window,
962 cx,
963 );
964 }
965 }
966 }
967
968 // Add another search tab to the workspace.
969 fn new_search(
970 workspace: &mut Workspace,
971 _: &workspace::NewSearch,
972 window: &mut Window,
973 cx: &mut Context<Workspace>,
974 ) {
975 Self::existing_or_new_search(workspace, None, &DeploySearch::find(), window, cx)
976 }
977
978 fn existing_or_new_search(
979 workspace: &mut Workspace,
980 existing: Option<Entity<ProjectSearchView>>,
981 action: &workspace::DeploySearch,
982 window: &mut Window,
983 cx: &mut Context<Workspace>,
984 ) {
985 let query = workspace.active_item(cx).and_then(|item| {
986 if let Some(buffer_search_query) = buffer_search_query(workspace, item.as_ref(), cx) {
987 return Some(buffer_search_query);
988 }
989
990 let editor = item.act_as::<Editor>(cx)?;
991 let query = editor.query_suggestion(window, cx);
992 if query.is_empty() { None } else { Some(query) }
993 });
994
995 let search = if let Some(existing) = existing {
996 workspace.activate_item(&existing, true, true, window, cx);
997 existing
998 } else {
999 let settings = cx
1000 .global::<ActiveSettings>()
1001 .0
1002 .get(&workspace.project().downgrade());
1003
1004 let settings = settings.cloned();
1005
1006 let weak_workspace = cx.entity().downgrade();
1007
1008 let project_search = cx.new(|cx| ProjectSearch::new(workspace.project().clone(), cx));
1009 let project_search_view = cx.new(|cx| {
1010 ProjectSearchView::new(weak_workspace, project_search, window, cx, settings)
1011 });
1012
1013 workspace.add_item_to_active_pane(
1014 Box::new(project_search_view.clone()),
1015 None,
1016 true,
1017 window,
1018 cx,
1019 );
1020 project_search_view
1021 };
1022
1023 search.update(cx, |search, cx| {
1024 search.replace_enabled = action.replace_enabled;
1025 if let Some(query) = query {
1026 search.set_query(&query, window, cx);
1027 }
1028 if let Some(included_files) = action.included_files.as_deref() {
1029 search
1030 .included_files_editor
1031 .update(cx, |editor, cx| editor.set_text(included_files, window, cx));
1032 search.filters_enabled = true;
1033 }
1034 if let Some(excluded_files) = action.excluded_files.as_deref() {
1035 search
1036 .excluded_files_editor
1037 .update(cx, |editor, cx| editor.set_text(excluded_files, window, cx));
1038 search.filters_enabled = true;
1039 }
1040 search.focus_query_editor(window, cx)
1041 });
1042 }
1043
1044 fn prompt_to_save_if_dirty_then_search(
1045 &mut self,
1046 window: &mut Window,
1047 cx: &mut Context<Self>,
1048 ) -> Task<anyhow::Result<()>> {
1049 use workspace::AutosaveSetting;
1050
1051 let project = self.entity.read(cx).project.clone();
1052
1053 let can_autosave = self.results_editor.can_autosave(cx);
1054 let autosave_setting = self.results_editor.workspace_settings(cx).autosave;
1055
1056 let will_autosave = can_autosave
1057 && matches!(
1058 autosave_setting,
1059 AutosaveSetting::OnFocusChange | AutosaveSetting::OnWindowChange
1060 );
1061
1062 let is_dirty = self.is_dirty(cx);
1063
1064 let skip_save_on_close = self
1065 .workspace
1066 .read_with(cx, |workspace, cx| {
1067 workspace::Pane::skip_save_on_close(&self.results_editor, workspace, cx)
1068 })
1069 .unwrap_or(false);
1070
1071 let should_prompt_to_save = !skip_save_on_close && !will_autosave && is_dirty;
1072
1073 cx.spawn_in(window, async move |this, cx| {
1074 let should_search = if should_prompt_to_save {
1075 let options = &["Save", "Don't Save", "Cancel"];
1076 let result_channel = this.update_in(cx, |_, window, cx| {
1077 window.prompt(
1078 gpui::PromptLevel::Warning,
1079 "Project search buffer contains unsaved edits. Do you want to save it?",
1080 None,
1081 options,
1082 cx,
1083 )
1084 })?;
1085 let result = result_channel.await?;
1086 let should_save = result == 0;
1087 if should_save {
1088 this.update_in(cx, |this, window, cx| this.save(true, project, window, cx))?
1089 .await
1090 .log_err();
1091 }
1092 let should_search = result != 2;
1093 should_search
1094 } else {
1095 true
1096 };
1097 if should_search {
1098 this.update(cx, |this, cx| {
1099 this.search(cx);
1100 })?;
1101 }
1102 anyhow::Ok(())
1103 })
1104 }
1105
1106 fn search(&mut self, cx: &mut Context<Self>) {
1107 if let Some(query) = self.build_search_query(cx) {
1108 self.entity.update(cx, |model, cx| model.search(query, cx));
1109 }
1110 }
1111
1112 pub fn search_query_text(&self, cx: &App) -> String {
1113 self.query_editor.read(cx).text(cx)
1114 }
1115
1116 fn build_search_query(&mut self, cx: &mut Context<Self>) -> Option<SearchQuery> {
1117 // Do not bail early in this function, as we want to fill out `self.panels_with_errors`.
1118 let text = self.query_editor.read(cx).text(cx);
1119 let open_buffers = if self.included_opened_only {
1120 Some(self.open_buffers(cx))
1121 } else {
1122 None
1123 };
1124 let included_files = self
1125 .filters_enabled
1126 .then(|| {
1127 match Self::parse_path_matches(&self.included_files_editor.read(cx).text(cx)) {
1128 Ok(included_files) => {
1129 let should_unmark_error =
1130 self.panels_with_errors.remove(&InputPanel::Include);
1131 if should_unmark_error {
1132 cx.notify();
1133 }
1134 included_files
1135 }
1136 Err(_e) => {
1137 let should_mark_error = self.panels_with_errors.insert(InputPanel::Include);
1138 if should_mark_error {
1139 cx.notify();
1140 }
1141 PathMatcher::default()
1142 }
1143 }
1144 })
1145 .unwrap_or_default();
1146 let excluded_files = self
1147 .filters_enabled
1148 .then(|| {
1149 match Self::parse_path_matches(&self.excluded_files_editor.read(cx).text(cx)) {
1150 Ok(excluded_files) => {
1151 let should_unmark_error =
1152 self.panels_with_errors.remove(&InputPanel::Exclude);
1153 if should_unmark_error {
1154 cx.notify();
1155 }
1156
1157 excluded_files
1158 }
1159 Err(_e) => {
1160 let should_mark_error = self.panels_with_errors.insert(InputPanel::Exclude);
1161 if should_mark_error {
1162 cx.notify();
1163 }
1164 PathMatcher::default()
1165 }
1166 }
1167 })
1168 .unwrap_or_default();
1169
1170 // If the project contains multiple visible worktrees, we match the
1171 // include/exclude patterns against full paths to allow them to be
1172 // disambiguated. For single worktree projects we use worktree relative
1173 // paths for convenience.
1174 let match_full_paths = self
1175 .entity
1176 .read(cx)
1177 .project
1178 .read(cx)
1179 .visible_worktrees(cx)
1180 .count()
1181 > 1;
1182
1183 let query = if self.search_options.contains(SearchOptions::REGEX) {
1184 match SearchQuery::regex(
1185 text,
1186 self.search_options.contains(SearchOptions::WHOLE_WORD),
1187 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1188 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1189 self.search_options
1190 .contains(SearchOptions::ONE_MATCH_PER_LINE),
1191 included_files,
1192 excluded_files,
1193 match_full_paths,
1194 open_buffers,
1195 ) {
1196 Ok(query) => {
1197 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
1198 if should_unmark_error {
1199 cx.notify();
1200 }
1201
1202 Some(query)
1203 }
1204 Err(_e) => {
1205 let should_mark_error = self.panels_with_errors.insert(InputPanel::Query);
1206 if should_mark_error {
1207 cx.notify();
1208 }
1209
1210 None
1211 }
1212 }
1213 } else {
1214 match SearchQuery::text(
1215 text,
1216 self.search_options.contains(SearchOptions::WHOLE_WORD),
1217 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1218 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1219 included_files,
1220 excluded_files,
1221 match_full_paths,
1222 open_buffers,
1223 ) {
1224 Ok(query) => {
1225 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
1226 if should_unmark_error {
1227 cx.notify();
1228 }
1229
1230 Some(query)
1231 }
1232 Err(_e) => {
1233 let should_mark_error = self.panels_with_errors.insert(InputPanel::Query);
1234 if should_mark_error {
1235 cx.notify();
1236 }
1237
1238 None
1239 }
1240 }
1241 };
1242 if !self.panels_with_errors.is_empty() {
1243 return None;
1244 }
1245 if query.as_ref().is_some_and(|query| query.is_empty()) {
1246 return None;
1247 }
1248 query
1249 }
1250
1251 fn open_buffers(&self, cx: &mut Context<Self>) -> Vec<Entity<Buffer>> {
1252 let mut buffers = Vec::new();
1253 self.workspace
1254 .update(cx, |workspace, cx| {
1255 for editor in workspace.items_of_type::<Editor>(cx) {
1256 if let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() {
1257 buffers.push(buffer);
1258 }
1259 }
1260 })
1261 .ok();
1262 buffers
1263 }
1264
1265 fn parse_path_matches(text: &str) -> anyhow::Result<PathMatcher> {
1266 let queries = text
1267 .split(',')
1268 .map(str::trim)
1269 .filter(|maybe_glob_str| !maybe_glob_str.is_empty())
1270 .map(str::to_owned)
1271 .collect::<Vec<_>>();
1272 Ok(PathMatcher::new(&queries)?)
1273 }
1274
1275 fn select_match(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
1276 if let Some(index) = self.active_match_index {
1277 let match_ranges = self.entity.read(cx).match_ranges.clone();
1278
1279 if !EditorSettings::get_global(cx).search_wrap
1280 && ((direction == Direction::Next && index + 1 >= match_ranges.len())
1281 || (direction == Direction::Prev && index == 0))
1282 {
1283 crate::show_no_more_matches(window, cx);
1284 return;
1285 }
1286
1287 let new_index = self.results_editor.update(cx, |editor, cx| {
1288 editor.match_index_for_direction(&match_ranges, index, direction, 1, window, cx)
1289 });
1290
1291 let range_to_select = match_ranges[new_index].clone();
1292 self.results_editor.update(cx, |editor, cx| {
1293 let range_to_select = editor.range_for_match(&range_to_select);
1294 editor.unfold_ranges(&[range_to_select.clone()], false, true, cx);
1295 editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
1296 s.select_ranges([range_to_select])
1297 });
1298 });
1299 }
1300 }
1301
1302 fn focus_query_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1303 self.query_editor.update(cx, |query_editor, cx| {
1304 query_editor.select_all(&SelectAll, window, cx);
1305 });
1306 let editor_handle = self.query_editor.focus_handle(cx);
1307 window.focus(&editor_handle);
1308 }
1309
1310 fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
1311 self.set_search_editor(SearchInputKind::Query, query, window, cx);
1312 if EditorSettings::get_global(cx).use_smartcase_search
1313 && !query.is_empty()
1314 && self.search_options.contains(SearchOptions::CASE_SENSITIVE)
1315 != is_contains_uppercase(query)
1316 {
1317 self.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx)
1318 }
1319 }
1320
1321 fn set_search_editor(
1322 &mut self,
1323 kind: SearchInputKind,
1324 text: &str,
1325 window: &mut Window,
1326 cx: &mut Context<Self>,
1327 ) {
1328 let editor = match kind {
1329 SearchInputKind::Query => &self.query_editor,
1330 SearchInputKind::Include => &self.included_files_editor,
1331
1332 SearchInputKind::Exclude => &self.excluded_files_editor,
1333 };
1334 editor.update(cx, |included_editor, cx| {
1335 included_editor.set_text(text, window, cx)
1336 });
1337 }
1338
1339 fn focus_results_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1340 self.query_editor.update(cx, |query_editor, cx| {
1341 let cursor = query_editor.selections.newest_anchor().head();
1342 query_editor.change_selections(None, window, cx, |s| s.select_ranges([cursor..cursor]));
1343 });
1344 let results_handle = self.results_editor.focus_handle(cx);
1345 window.focus(&results_handle);
1346 }
1347
1348 fn entity_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1349 let match_ranges = self.entity.read(cx).match_ranges.clone();
1350 if match_ranges.is_empty() {
1351 self.active_match_index = None;
1352 } else {
1353 self.active_match_index = Some(0);
1354 self.update_match_index(cx);
1355 let prev_search_id = mem::replace(&mut self.search_id, self.entity.read(cx).search_id);
1356 let is_new_search = self.search_id != prev_search_id;
1357 self.results_editor.update(cx, |editor, cx| {
1358 if is_new_search {
1359 let range_to_select = match_ranges
1360 .first()
1361 .map(|range| editor.range_for_match(range));
1362 editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
1363 s.select_ranges(range_to_select)
1364 });
1365 editor.scroll(Point::default(), Some(Axis::Vertical), window, cx);
1366 }
1367 editor.highlight_background::<Self>(
1368 &match_ranges,
1369 |theme| theme.search_match_background,
1370 cx,
1371 );
1372 });
1373 if is_new_search && self.query_editor.focus_handle(cx).is_focused(window) {
1374 self.focus_results_editor(window, cx);
1375 }
1376 }
1377
1378 cx.emit(ViewEvent::UpdateTab);
1379 cx.notify();
1380 }
1381
1382 fn update_match_index(&mut self, cx: &mut Context<Self>) {
1383 let results_editor = self.results_editor.read(cx);
1384 let new_index = active_match_index(
1385 Direction::Next,
1386 &self.entity.read(cx).match_ranges,
1387 &results_editor.selections.newest_anchor().head(),
1388 &results_editor.buffer().read(cx).snapshot(cx),
1389 );
1390 if self.active_match_index != new_index {
1391 self.active_match_index = new_index;
1392 cx.notify();
1393 }
1394 }
1395
1396 pub fn has_matches(&self) -> bool {
1397 self.active_match_index.is_some()
1398 }
1399
1400 fn landing_text_minor(&self, window: &mut Window, cx: &App) -> impl IntoElement {
1401 let focus_handle = self.focus_handle.clone();
1402 v_flex()
1403 .gap_1()
1404 .child(
1405 Label::new("Hit enter to search. For more options:")
1406 .color(Color::Muted)
1407 .mb_2(),
1408 )
1409 .child(
1410 Button::new("filter-paths", "Include/exclude specific paths")
1411 .icon(IconName::Filter)
1412 .icon_position(IconPosition::Start)
1413 .icon_size(IconSize::Small)
1414 .key_binding(KeyBinding::for_action_in(
1415 &ToggleFilters,
1416 &focus_handle,
1417 window,
1418 cx,
1419 ))
1420 .on_click(|_event, window, cx| {
1421 window.dispatch_action(ToggleFilters.boxed_clone(), cx)
1422 }),
1423 )
1424 .child(
1425 Button::new("find-replace", "Find and replace")
1426 .icon(IconName::Replace)
1427 .icon_position(IconPosition::Start)
1428 .icon_size(IconSize::Small)
1429 .key_binding(KeyBinding::for_action_in(
1430 &ToggleReplace,
1431 &focus_handle,
1432 window,
1433 cx,
1434 ))
1435 .on_click(|_event, window, cx| {
1436 window.dispatch_action(ToggleReplace.boxed_clone(), cx)
1437 }),
1438 )
1439 .child(
1440 Button::new("regex", "Match with regex")
1441 .icon(IconName::Regex)
1442 .icon_position(IconPosition::Start)
1443 .icon_size(IconSize::Small)
1444 .key_binding(KeyBinding::for_action_in(
1445 &ToggleRegex,
1446 &focus_handle,
1447 window,
1448 cx,
1449 ))
1450 .on_click(|_event, window, cx| {
1451 window.dispatch_action(ToggleRegex.boxed_clone(), cx)
1452 }),
1453 )
1454 .child(
1455 Button::new("match-case", "Match case")
1456 .icon(IconName::CaseSensitive)
1457 .icon_position(IconPosition::Start)
1458 .icon_size(IconSize::Small)
1459 .key_binding(KeyBinding::for_action_in(
1460 &ToggleCaseSensitive,
1461 &focus_handle,
1462 window,
1463 cx,
1464 ))
1465 .on_click(|_event, window, cx| {
1466 window.dispatch_action(ToggleCaseSensitive.boxed_clone(), cx)
1467 }),
1468 )
1469 .child(
1470 Button::new("match-whole-words", "Match whole words")
1471 .icon(IconName::WholeWord)
1472 .icon_position(IconPosition::Start)
1473 .icon_size(IconSize::Small)
1474 .key_binding(KeyBinding::for_action_in(
1475 &ToggleWholeWord,
1476 &focus_handle,
1477 window,
1478 cx,
1479 ))
1480 .on_click(|_event, window, cx| {
1481 window.dispatch_action(ToggleWholeWord.boxed_clone(), cx)
1482 }),
1483 )
1484 }
1485
1486 fn border_color_for(&self, panel: InputPanel, cx: &App) -> Hsla {
1487 if self.panels_with_errors.contains(&panel) {
1488 Color::Error.color(cx)
1489 } else {
1490 cx.theme().colors().border
1491 }
1492 }
1493
1494 fn move_focus_to_results(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1495 if !self.results_editor.focus_handle(cx).is_focused(window)
1496 && !self.entity.read(cx).match_ranges.is_empty()
1497 {
1498 cx.stop_propagation();
1499 self.focus_results_editor(window, cx)
1500 }
1501 }
1502
1503 #[cfg(any(test, feature = "test-support"))]
1504 pub fn results_editor(&self) -> &Entity<Editor> {
1505 &self.results_editor
1506 }
1507
1508 fn adjust_query_regex_language(&self, cx: &mut App) {
1509 let enable = self.search_options.contains(SearchOptions::REGEX);
1510 let query_buffer = self
1511 .query_editor
1512 .read(cx)
1513 .buffer()
1514 .read(cx)
1515 .as_singleton()
1516 .expect("query editor should be backed by a singleton buffer");
1517 if enable {
1518 if let Some(regex_language) = self.regex_language.clone() {
1519 query_buffer.update(cx, |query_buffer, cx| {
1520 query_buffer.set_language(Some(regex_language), cx);
1521 })
1522 }
1523 } else {
1524 query_buffer.update(cx, |query_buffer, cx| {
1525 query_buffer.set_language(None, cx);
1526 })
1527 }
1528 }
1529}
1530
1531fn buffer_search_query(
1532 workspace: &mut Workspace,
1533 item: &dyn ItemHandle,
1534 cx: &mut Context<Workspace>,
1535) -> Option<String> {
1536 let buffer_search_bar = workspace
1537 .pane_for(item)
1538 .and_then(|pane| {
1539 pane.read(cx)
1540 .toolbar()
1541 .read(cx)
1542 .item_of_type::<BufferSearchBar>()
1543 })?
1544 .read(cx);
1545 if buffer_search_bar.query_editor_focused() {
1546 let buffer_search_query = buffer_search_bar.query(cx);
1547 if !buffer_search_query.is_empty() {
1548 return Some(buffer_search_query);
1549 }
1550 }
1551 None
1552}
1553
1554impl Default for ProjectSearchBar {
1555 fn default() -> Self {
1556 Self::new()
1557 }
1558}
1559
1560impl ProjectSearchBar {
1561 pub fn new() -> Self {
1562 Self {
1563 active_project_search: None,
1564 subscription: None,
1565 }
1566 }
1567
1568 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1569 if let Some(search_view) = self.active_project_search.as_ref() {
1570 search_view.update(cx, |search_view, cx| {
1571 if !search_view
1572 .replacement_editor
1573 .focus_handle(cx)
1574 .is_focused(window)
1575 {
1576 cx.stop_propagation();
1577 search_view
1578 .prompt_to_save_if_dirty_then_search(window, cx)
1579 .detach_and_log_err(cx);
1580 }
1581 });
1582 }
1583 }
1584
1585 fn tab(&mut self, _: &editor::actions::Tab, window: &mut Window, cx: &mut Context<Self>) {
1586 self.cycle_field(Direction::Next, window, cx);
1587 }
1588
1589 fn backtab(
1590 &mut self,
1591 _: &editor::actions::Backtab,
1592 window: &mut Window,
1593 cx: &mut Context<Self>,
1594 ) {
1595 self.cycle_field(Direction::Prev, window, cx);
1596 }
1597
1598 fn focus_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1599 if let Some(search_view) = self.active_project_search.as_ref() {
1600 search_view.update(cx, |search_view, cx| {
1601 search_view.query_editor.focus_handle(cx).focus(window);
1602 });
1603 }
1604 }
1605
1606 fn cycle_field(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
1607 let active_project_search = match &self.active_project_search {
1608 Some(active_project_search) => active_project_search,
1609
1610 None => {
1611 return;
1612 }
1613 };
1614
1615 active_project_search.update(cx, |project_view, cx| {
1616 let mut views = vec![&project_view.query_editor];
1617 if project_view.replace_enabled {
1618 views.push(&project_view.replacement_editor);
1619 }
1620 if project_view.filters_enabled {
1621 views.extend([
1622 &project_view.included_files_editor,
1623 &project_view.excluded_files_editor,
1624 ]);
1625 }
1626 let current_index = match views
1627 .iter()
1628 .enumerate()
1629 .find(|(_, editor)| editor.focus_handle(cx).is_focused(window))
1630 {
1631 Some((index, _)) => index,
1632 None => return,
1633 };
1634
1635 let new_index = match direction {
1636 Direction::Next => (current_index + 1) % views.len(),
1637 Direction::Prev if current_index == 0 => views.len() - 1,
1638 Direction::Prev => (current_index - 1) % views.len(),
1639 };
1640 let next_focus_handle = views[new_index].focus_handle(cx);
1641 window.focus(&next_focus_handle);
1642 cx.stop_propagation();
1643 });
1644 }
1645
1646 fn toggle_search_option(
1647 &mut self,
1648 option: SearchOptions,
1649 window: &mut Window,
1650 cx: &mut Context<Self>,
1651 ) -> bool {
1652 if self.active_project_search.is_none() {
1653 return false;
1654 }
1655
1656 cx.spawn_in(window, async move |this, cx| {
1657 let task = this.update_in(cx, |this, window, cx| {
1658 let search_view = this.active_project_search.as_ref()?;
1659 search_view.update(cx, |search_view, cx| {
1660 search_view.toggle_search_option(option, cx);
1661 search_view
1662 .entity
1663 .read(cx)
1664 .active_query
1665 .is_some()
1666 .then(|| search_view.prompt_to_save_if_dirty_then_search(window, cx))
1667 })
1668 })?;
1669 if let Some(task) = task {
1670 task.await?;
1671 }
1672 this.update(cx, |_, cx| {
1673 cx.notify();
1674 })?;
1675 anyhow::Ok(())
1676 })
1677 .detach();
1678 true
1679 }
1680
1681 fn toggle_replace(&mut self, _: &ToggleReplace, window: &mut Window, cx: &mut Context<Self>) {
1682 if let Some(search) = &self.active_project_search {
1683 search.update(cx, |this, cx| {
1684 this.replace_enabled = !this.replace_enabled;
1685 let editor_to_focus = if this.replace_enabled {
1686 this.replacement_editor.focus_handle(cx)
1687 } else {
1688 this.query_editor.focus_handle(cx)
1689 };
1690 window.focus(&editor_to_focus);
1691 cx.notify();
1692 });
1693 }
1694 }
1695
1696 fn toggle_filters(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1697 if let Some(search_view) = self.active_project_search.as_ref() {
1698 search_view.update(cx, |search_view, cx| {
1699 search_view.toggle_filters(cx);
1700 search_view
1701 .included_files_editor
1702 .update(cx, |_, cx| cx.notify());
1703 search_view
1704 .excluded_files_editor
1705 .update(cx, |_, cx| cx.notify());
1706 window.refresh();
1707 cx.notify();
1708 });
1709 cx.notify();
1710 true
1711 } else {
1712 false
1713 }
1714 }
1715
1716 fn toggle_opened_only(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1717 if self.active_project_search.is_none() {
1718 return false;
1719 }
1720
1721 cx.spawn_in(window, async move |this, cx| {
1722 let task = this.update_in(cx, |this, window, cx| {
1723 let search_view = this.active_project_search.as_ref()?;
1724 search_view.update(cx, |search_view, cx| {
1725 search_view.toggle_opened_only(window, cx);
1726 search_view
1727 .entity
1728 .read(cx)
1729 .active_query
1730 .is_some()
1731 .then(|| search_view.prompt_to_save_if_dirty_then_search(window, cx))
1732 })
1733 })?;
1734 if let Some(task) = task {
1735 task.await?;
1736 }
1737 this.update(cx, |_, cx| {
1738 cx.notify();
1739 })?;
1740 anyhow::Ok(())
1741 })
1742 .detach();
1743 true
1744 }
1745
1746 fn is_opened_only_enabled(&self, cx: &App) -> bool {
1747 if let Some(search_view) = self.active_project_search.as_ref() {
1748 search_view.read(cx).included_opened_only
1749 } else {
1750 false
1751 }
1752 }
1753
1754 fn move_focus_to_results(&self, window: &mut Window, cx: &mut Context<Self>) {
1755 if let Some(search_view) = self.active_project_search.as_ref() {
1756 search_view.update(cx, |search_view, cx| {
1757 search_view.move_focus_to_results(window, cx);
1758 });
1759 cx.notify();
1760 }
1761 }
1762
1763 fn is_option_enabled(&self, option: SearchOptions, cx: &App) -> bool {
1764 if let Some(search) = self.active_project_search.as_ref() {
1765 search.read(cx).search_options.contains(option)
1766 } else {
1767 false
1768 }
1769 }
1770
1771 fn next_history_query(
1772 &mut self,
1773 _: &NextHistoryQuery,
1774 window: &mut Window,
1775 cx: &mut Context<Self>,
1776 ) {
1777 if let Some(search_view) = self.active_project_search.as_ref() {
1778 search_view.update(cx, |search_view, cx| {
1779 for (editor, kind) in [
1780 (search_view.query_editor.clone(), SearchInputKind::Query),
1781 (
1782 search_view.included_files_editor.clone(),
1783 SearchInputKind::Include,
1784 ),
1785 (
1786 search_view.excluded_files_editor.clone(),
1787 SearchInputKind::Exclude,
1788 ),
1789 ] {
1790 if editor.focus_handle(cx).is_focused(window) {
1791 let new_query = search_view.entity.update(cx, |model, cx| {
1792 let project = model.project.clone();
1793
1794 if let Some(new_query) = project.update(cx, |project, _| {
1795 project
1796 .search_history_mut(kind)
1797 .next(model.cursor_mut(kind))
1798 .map(str::to_string)
1799 }) {
1800 new_query
1801 } else {
1802 model.cursor_mut(kind).reset();
1803 String::new()
1804 }
1805 });
1806 search_view.set_search_editor(kind, &new_query, window, cx);
1807 }
1808 }
1809 });
1810 }
1811 }
1812
1813 fn previous_history_query(
1814 &mut self,
1815 _: &PreviousHistoryQuery,
1816 window: &mut Window,
1817 cx: &mut Context<Self>,
1818 ) {
1819 if let Some(search_view) = self.active_project_search.as_ref() {
1820 search_view.update(cx, |search_view, cx| {
1821 for (editor, kind) in [
1822 (search_view.query_editor.clone(), SearchInputKind::Query),
1823 (
1824 search_view.included_files_editor.clone(),
1825 SearchInputKind::Include,
1826 ),
1827 (
1828 search_view.excluded_files_editor.clone(),
1829 SearchInputKind::Exclude,
1830 ),
1831 ] {
1832 if editor.focus_handle(cx).is_focused(window) {
1833 if editor.read(cx).text(cx).is_empty() {
1834 if let Some(new_query) = search_view
1835 .entity
1836 .read(cx)
1837 .project
1838 .read(cx)
1839 .search_history(kind)
1840 .current(search_view.entity.read(cx).cursor(kind))
1841 .map(str::to_string)
1842 {
1843 search_view.set_search_editor(kind, &new_query, window, cx);
1844 return;
1845 }
1846 }
1847
1848 if let Some(new_query) = search_view.entity.update(cx, |model, cx| {
1849 let project = model.project.clone();
1850 project.update(cx, |project, _| {
1851 project
1852 .search_history_mut(kind)
1853 .previous(model.cursor_mut(kind))
1854 .map(str::to_string)
1855 })
1856 }) {
1857 search_view.set_search_editor(kind, &new_query, window, cx);
1858 }
1859 }
1860 }
1861 });
1862 }
1863 }
1864
1865 fn select_next_match(
1866 &mut self,
1867 _: &SelectNextMatch,
1868 window: &mut Window,
1869 cx: &mut Context<Self>,
1870 ) {
1871 if let Some(search) = self.active_project_search.as_ref() {
1872 search.update(cx, |this, cx| {
1873 this.select_match(Direction::Next, window, cx);
1874 })
1875 }
1876 }
1877
1878 fn select_prev_match(
1879 &mut self,
1880 _: &SelectPreviousMatch,
1881 window: &mut Window,
1882 cx: &mut Context<Self>,
1883 ) {
1884 if let Some(search) = self.active_project_search.as_ref() {
1885 search.update(cx, |this, cx| {
1886 this.select_match(Direction::Prev, window, cx);
1887 })
1888 }
1889 }
1890
1891 fn render_text_input(&self, editor: &Entity<Editor>, cx: &Context<Self>) -> impl IntoElement {
1892 let (color, use_syntax) = if editor.read(cx).read_only(cx) {
1893 (cx.theme().colors().text_disabled, false)
1894 } else {
1895 (cx.theme().colors().text, true)
1896 };
1897 let settings = ThemeSettings::get_global(cx);
1898 let text_style = TextStyle {
1899 color,
1900 font_family: settings.buffer_font.family.clone(),
1901 font_features: settings.buffer_font.features.clone(),
1902 font_fallbacks: settings.buffer_font.fallbacks.clone(),
1903 font_size: rems(0.875).into(),
1904 font_weight: settings.buffer_font.weight,
1905 line_height: relative(1.3),
1906 ..TextStyle::default()
1907 };
1908
1909 let mut editor_style = EditorStyle {
1910 background: cx.theme().colors().toolbar_background,
1911 local_player: cx.theme().players().local(),
1912 text: text_style,
1913 ..EditorStyle::default()
1914 };
1915 if use_syntax {
1916 editor_style.syntax = cx.theme().syntax().clone();
1917 }
1918
1919 EditorElement::new(editor, editor_style)
1920 }
1921}
1922
1923impl Render for ProjectSearchBar {
1924 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1925 let Some(search) = self.active_project_search.clone() else {
1926 return div();
1927 };
1928 let search = search.read(cx);
1929 let focus_handle = search.focus_handle(cx);
1930
1931 let container_width = window.viewport_size().width;
1932 let input_width = SearchInputWidth::calc_width(container_width);
1933
1934 enum BaseStyle {
1935 SingleInput,
1936 MultipleInputs,
1937 }
1938
1939 let input_base_styles = |base_style: BaseStyle| {
1940 h_flex()
1941 .min_w_32()
1942 .map(|div| match base_style {
1943 BaseStyle::SingleInput => div.w(input_width),
1944 BaseStyle::MultipleInputs => div.flex_grow(),
1945 })
1946 .h_8()
1947 .pl_2()
1948 .pr_1()
1949 .py_1()
1950 .border_1()
1951 .border_color(search.border_color_for(InputPanel::Query, cx))
1952 .rounded_lg()
1953 };
1954
1955 let query_column = input_base_styles(BaseStyle::SingleInput)
1956 .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
1957 .on_action(cx.listener(|this, action, window, cx| {
1958 this.previous_history_query(action, window, cx)
1959 }))
1960 .on_action(
1961 cx.listener(|this, action, window, cx| this.next_history_query(action, window, cx)),
1962 )
1963 .child(self.render_text_input(&search.query_editor, cx))
1964 .child(
1965 h_flex()
1966 .gap_1()
1967 .child(SearchOptions::CASE_SENSITIVE.as_button(
1968 self.is_option_enabled(SearchOptions::CASE_SENSITIVE, cx),
1969 focus_handle.clone(),
1970 cx.listener(|this, _, window, cx| {
1971 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
1972 }),
1973 ))
1974 .child(SearchOptions::WHOLE_WORD.as_button(
1975 self.is_option_enabled(SearchOptions::WHOLE_WORD, cx),
1976 focus_handle.clone(),
1977 cx.listener(|this, _, window, cx| {
1978 this.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
1979 }),
1980 ))
1981 .child(SearchOptions::REGEX.as_button(
1982 self.is_option_enabled(SearchOptions::REGEX, cx),
1983 focus_handle.clone(),
1984 cx.listener(|this, _, window, cx| {
1985 this.toggle_search_option(SearchOptions::REGEX, window, cx);
1986 }),
1987 )),
1988 );
1989
1990 let mode_column = h_flex()
1991 .gap_1()
1992 .child(
1993 IconButton::new("project-search-filter-button", IconName::Filter)
1994 .shape(IconButtonShape::Square)
1995 .tooltip(|window, cx| {
1996 Tooltip::for_action("Toggle Filters", &ToggleFilters, window, cx)
1997 })
1998 .on_click(cx.listener(|this, _, window, cx| {
1999 this.toggle_filters(window, cx);
2000 }))
2001 .toggle_state(
2002 self.active_project_search
2003 .as_ref()
2004 .map(|search| search.read(cx).filters_enabled)
2005 .unwrap_or_default(),
2006 )
2007 .tooltip({
2008 let focus_handle = focus_handle.clone();
2009 move |window, cx| {
2010 Tooltip::for_action_in(
2011 "Toggle Filters",
2012 &ToggleFilters,
2013 &focus_handle,
2014 window,
2015 cx,
2016 )
2017 }
2018 }),
2019 )
2020 .child(
2021 IconButton::new("project-search-toggle-replace", IconName::Replace)
2022 .shape(IconButtonShape::Square)
2023 .on_click(cx.listener(|this, _, window, cx| {
2024 this.toggle_replace(&ToggleReplace, window, cx);
2025 }))
2026 .toggle_state(
2027 self.active_project_search
2028 .as_ref()
2029 .map(|search| search.read(cx).replace_enabled)
2030 .unwrap_or_default(),
2031 )
2032 .tooltip({
2033 let focus_handle = focus_handle.clone();
2034 move |window, cx| {
2035 Tooltip::for_action_in(
2036 "Toggle Replace",
2037 &ToggleReplace,
2038 &focus_handle,
2039 window,
2040 cx,
2041 )
2042 }
2043 }),
2044 );
2045
2046 let limit_reached = search.entity.read(cx).limit_reached;
2047
2048 let match_text = search
2049 .active_match_index
2050 .and_then(|index| {
2051 let index = index + 1;
2052 let match_quantity = search.entity.read(cx).match_ranges.len();
2053 if match_quantity > 0 {
2054 debug_assert!(match_quantity >= index);
2055 if limit_reached {
2056 Some(format!("{index}/{match_quantity}+"))
2057 } else {
2058 Some(format!("{index}/{match_quantity}"))
2059 }
2060 } else {
2061 None
2062 }
2063 })
2064 .unwrap_or_else(|| "0/0".to_string());
2065
2066 let matches_column = h_flex()
2067 .pl_2()
2068 .ml_2()
2069 .border_l_1()
2070 .border_color(cx.theme().colors().border_variant)
2071 .child(
2072 IconButton::new("project-search-prev-match", IconName::ChevronLeft)
2073 .shape(IconButtonShape::Square)
2074 .disabled(search.active_match_index.is_none())
2075 .on_click(cx.listener(|this, _, window, cx| {
2076 if let Some(search) = this.active_project_search.as_ref() {
2077 search.update(cx, |this, cx| {
2078 this.select_match(Direction::Prev, window, cx);
2079 })
2080 }
2081 }))
2082 .tooltip({
2083 let focus_handle = focus_handle.clone();
2084 move |window, cx| {
2085 Tooltip::for_action_in(
2086 "Go To Previous Match",
2087 &SelectPreviousMatch,
2088 &focus_handle,
2089 window,
2090 cx,
2091 )
2092 }
2093 }),
2094 )
2095 .child(
2096 IconButton::new("project-search-next-match", IconName::ChevronRight)
2097 .shape(IconButtonShape::Square)
2098 .disabled(search.active_match_index.is_none())
2099 .on_click(cx.listener(|this, _, window, cx| {
2100 if let Some(search) = this.active_project_search.as_ref() {
2101 search.update(cx, |this, cx| {
2102 this.select_match(Direction::Next, window, cx);
2103 })
2104 }
2105 }))
2106 .tooltip({
2107 let focus_handle = focus_handle.clone();
2108 move |window, cx| {
2109 Tooltip::for_action_in(
2110 "Go To Next Match",
2111 &SelectNextMatch,
2112 &focus_handle,
2113 window,
2114 cx,
2115 )
2116 }
2117 }),
2118 )
2119 .child(
2120 div()
2121 .id("matches")
2122 .ml_1()
2123 .child(Label::new(match_text).size(LabelSize::Small).color(
2124 if search.active_match_index.is_some() {
2125 Color::Default
2126 } else {
2127 Color::Disabled
2128 },
2129 ))
2130 .when(limit_reached, |el| {
2131 el.tooltip(Tooltip::text(
2132 "Search limits reached.\nTry narrowing your search.",
2133 ))
2134 }),
2135 );
2136
2137 let search_line = h_flex()
2138 .w_full()
2139 .gap_2()
2140 .child(query_column)
2141 .child(h_flex().min_w_64().child(mode_column).child(matches_column));
2142
2143 let replace_line = search.replace_enabled.then(|| {
2144 let replace_column = input_base_styles(BaseStyle::SingleInput)
2145 .child(self.render_text_input(&search.replacement_editor, cx));
2146
2147 let focus_handle = search.replacement_editor.read(cx).focus_handle(cx);
2148
2149 let replace_actions =
2150 h_flex()
2151 .min_w_64()
2152 .gap_1()
2153 .when(search.replace_enabled, |this| {
2154 this.child(
2155 IconButton::new("project-search-replace-next", IconName::ReplaceNext)
2156 .shape(IconButtonShape::Square)
2157 .on_click(cx.listener(|this, _, window, cx| {
2158 if let Some(search) = this.active_project_search.as_ref() {
2159 search.update(cx, |this, cx| {
2160 this.replace_next(&ReplaceNext, window, cx);
2161 })
2162 }
2163 }))
2164 .tooltip({
2165 let focus_handle = focus_handle.clone();
2166 move |window, cx| {
2167 Tooltip::for_action_in(
2168 "Replace Next Match",
2169 &ReplaceNext,
2170 &focus_handle,
2171 window,
2172 cx,
2173 )
2174 }
2175 }),
2176 )
2177 .child(
2178 IconButton::new("project-search-replace-all", IconName::ReplaceAll)
2179 .shape(IconButtonShape::Square)
2180 .on_click(cx.listener(|this, _, window, cx| {
2181 if let Some(search) = this.active_project_search.as_ref() {
2182 search.update(cx, |this, cx| {
2183 this.replace_all(&ReplaceAll, window, cx);
2184 })
2185 }
2186 }))
2187 .tooltip({
2188 let focus_handle = focus_handle.clone();
2189 move |window, cx| {
2190 Tooltip::for_action_in(
2191 "Replace All Matches",
2192 &ReplaceAll,
2193 &focus_handle,
2194 window,
2195 cx,
2196 )
2197 }
2198 }),
2199 )
2200 });
2201
2202 h_flex()
2203 .w_full()
2204 .gap_2()
2205 .child(replace_column)
2206 .child(replace_actions)
2207 });
2208
2209 let filter_line = search.filters_enabled.then(|| {
2210 h_flex()
2211 .w_full()
2212 .gap_2()
2213 .child(
2214 h_flex()
2215 .gap_2()
2216 .w(input_width)
2217 .child(
2218 input_base_styles(BaseStyle::MultipleInputs)
2219 .on_action(cx.listener(|this, action, window, cx| {
2220 this.previous_history_query(action, window, cx)
2221 }))
2222 .on_action(cx.listener(|this, action, window, cx| {
2223 this.next_history_query(action, window, cx)
2224 }))
2225 .child(self.render_text_input(&search.included_files_editor, cx)),
2226 )
2227 .child(
2228 input_base_styles(BaseStyle::MultipleInputs)
2229 .on_action(cx.listener(|this, action, window, cx| {
2230 this.previous_history_query(action, window, cx)
2231 }))
2232 .on_action(cx.listener(|this, action, window, cx| {
2233 this.next_history_query(action, window, cx)
2234 }))
2235 .child(self.render_text_input(&search.excluded_files_editor, cx)),
2236 ),
2237 )
2238 .child(
2239 h_flex()
2240 .min_w_64()
2241 .gap_1()
2242 .child(
2243 IconButton::new("project-search-opened-only", IconName::FileSearch)
2244 .shape(IconButtonShape::Square)
2245 .toggle_state(self.is_opened_only_enabled(cx))
2246 .tooltip(Tooltip::text("Only Search Open Files"))
2247 .on_click(cx.listener(|this, _, window, cx| {
2248 this.toggle_opened_only(window, cx);
2249 })),
2250 )
2251 .child(
2252 SearchOptions::INCLUDE_IGNORED.as_button(
2253 search
2254 .search_options
2255 .contains(SearchOptions::INCLUDE_IGNORED),
2256 focus_handle.clone(),
2257 cx.listener(|this, _, window, cx| {
2258 this.toggle_search_option(
2259 SearchOptions::INCLUDE_IGNORED,
2260 window,
2261 cx,
2262 );
2263 }),
2264 ),
2265 ),
2266 )
2267 });
2268
2269 let mut key_context = KeyContext::default();
2270
2271 key_context.add("ProjectSearchBar");
2272
2273 if search
2274 .replacement_editor
2275 .focus_handle(cx)
2276 .is_focused(window)
2277 {
2278 key_context.add("in_replace");
2279 }
2280
2281 v_flex()
2282 .py(px(1.0))
2283 .key_context(key_context)
2284 .on_action(cx.listener(|this, _: &ToggleFocus, window, cx| {
2285 this.move_focus_to_results(window, cx)
2286 }))
2287 .on_action(cx.listener(|this, _: &ToggleFilters, window, cx| {
2288 this.toggle_filters(window, cx);
2289 }))
2290 .capture_action(cx.listener(|this, action, window, cx| {
2291 this.tab(action, window, cx);
2292 cx.stop_propagation();
2293 }))
2294 .capture_action(cx.listener(|this, action, window, cx| {
2295 this.backtab(action, window, cx);
2296 cx.stop_propagation();
2297 }))
2298 .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
2299 .on_action(cx.listener(|this, action, window, cx| {
2300 this.toggle_replace(action, window, cx);
2301 }))
2302 .on_action(cx.listener(|this, _: &ToggleWholeWord, window, cx| {
2303 this.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
2304 }))
2305 .on_action(cx.listener(|this, _: &ToggleCaseSensitive, window, cx| {
2306 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
2307 }))
2308 .on_action(cx.listener(|this, action, window, cx| {
2309 if let Some(search) = this.active_project_search.as_ref() {
2310 search.update(cx, |this, cx| {
2311 this.replace_next(action, window, cx);
2312 })
2313 }
2314 }))
2315 .on_action(cx.listener(|this, action, window, cx| {
2316 if let Some(search) = this.active_project_search.as_ref() {
2317 search.update(cx, |this, cx| {
2318 this.replace_all(action, window, cx);
2319 })
2320 }
2321 }))
2322 .when(search.filters_enabled, |this| {
2323 this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, window, cx| {
2324 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, window, cx);
2325 }))
2326 })
2327 .on_action(cx.listener(Self::select_next_match))
2328 .on_action(cx.listener(Self::select_prev_match))
2329 .gap_2()
2330 .w_full()
2331 .child(search_line)
2332 .children(replace_line)
2333 .children(filter_line)
2334 }
2335}
2336
2337impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
2338
2339impl ToolbarItemView for ProjectSearchBar {
2340 fn set_active_pane_item(
2341 &mut self,
2342 active_pane_item: Option<&dyn ItemHandle>,
2343 _: &mut Window,
2344 cx: &mut Context<Self>,
2345 ) -> ToolbarItemLocation {
2346 cx.notify();
2347 self.subscription = None;
2348 self.active_project_search = None;
2349 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
2350 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
2351 self.active_project_search = Some(search);
2352 ToolbarItemLocation::PrimaryLeft {}
2353 } else {
2354 ToolbarItemLocation::Hidden
2355 }
2356 }
2357}
2358
2359fn register_workspace_action<A: Action>(
2360 workspace: &mut Workspace,
2361 callback: fn(&mut ProjectSearchBar, &A, &mut Window, &mut Context<ProjectSearchBar>),
2362) {
2363 workspace.register_action(move |workspace, action: &A, window, cx| {
2364 if workspace.has_active_modal(window, cx) {
2365 cx.propagate();
2366 return;
2367 }
2368
2369 workspace.active_pane().update(cx, |pane, cx| {
2370 pane.toolbar().update(cx, move |workspace, cx| {
2371 if let Some(search_bar) = workspace.item_of_type::<ProjectSearchBar>() {
2372 search_bar.update(cx, move |search_bar, cx| {
2373 if search_bar.active_project_search.is_some() {
2374 callback(search_bar, action, window, cx);
2375 cx.notify();
2376 } else {
2377 cx.propagate();
2378 }
2379 });
2380 }
2381 });
2382 })
2383 });
2384}
2385
2386fn register_workspace_action_for_present_search<A: Action>(
2387 workspace: &mut Workspace,
2388 callback: fn(&mut Workspace, &A, &mut Window, &mut Context<Workspace>),
2389) {
2390 workspace.register_action(move |workspace, action: &A, window, cx| {
2391 if workspace.has_active_modal(window, cx) {
2392 cx.propagate();
2393 return;
2394 }
2395
2396 let should_notify = workspace
2397 .active_pane()
2398 .read(cx)
2399 .toolbar()
2400 .read(cx)
2401 .item_of_type::<ProjectSearchBar>()
2402 .map(|search_bar| search_bar.read(cx).active_project_search.is_some())
2403 .unwrap_or(false);
2404 if should_notify {
2405 callback(workspace, action, window, cx);
2406 cx.notify();
2407 } else {
2408 cx.propagate();
2409 }
2410 });
2411}
2412
2413#[cfg(any(test, feature = "test-support"))]
2414pub fn perform_project_search(
2415 search_view: &Entity<ProjectSearchView>,
2416 text: impl Into<std::sync::Arc<str>>,
2417 cx: &mut gpui::VisualTestContext,
2418) {
2419 cx.run_until_parked();
2420 search_view.update_in(cx, |search_view, window, cx| {
2421 search_view.query_editor.update(cx, |query_editor, cx| {
2422 query_editor.set_text(text, window, cx)
2423 });
2424 search_view.search(cx);
2425 });
2426 cx.run_until_parked();
2427}
2428
2429#[cfg(test)]
2430pub mod tests {
2431 use std::{ops::Deref as _, sync::Arc};
2432
2433 use super::*;
2434 use editor::{DisplayPoint, display_map::DisplayRow};
2435 use gpui::{Action, TestAppContext, VisualTestContext, WindowHandle};
2436 use project::FakeFs;
2437 use serde_json::json;
2438 use settings::SettingsStore;
2439 use util::path;
2440 use workspace::DeploySearch;
2441
2442 #[gpui::test]
2443 async fn test_project_search(cx: &mut TestAppContext) {
2444 init_test(cx);
2445
2446 let fs = FakeFs::new(cx.background_executor.clone());
2447 fs.insert_tree(
2448 path!("/dir"),
2449 json!({
2450 "one.rs": "const ONE: usize = 1;",
2451 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2452 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2453 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2454 }),
2455 )
2456 .await;
2457 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2458 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2459 let workspace = window.root(cx).unwrap();
2460 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
2461 let search_view = cx.add_window(|window, cx| {
2462 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
2463 });
2464
2465 perform_search(search_view, "TWO", cx);
2466 search_view.update(cx, |search_view, window, cx| {
2467 assert_eq!(
2468 search_view
2469 .results_editor
2470 .update(cx, |editor, cx| editor.display_text(cx)),
2471 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
2472 );
2473 let match_background_color = cx.theme().colors().search_match_background;
2474 assert_eq!(
2475 search_view
2476 .results_editor
2477 .update(cx, |editor, cx| editor.all_text_background_highlights(window, cx)),
2478 &[
2479 (
2480 DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35),
2481 match_background_color
2482 ),
2483 (
2484 DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40),
2485 match_background_color
2486 ),
2487 (
2488 DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9),
2489 match_background_color
2490 )
2491 ]
2492 );
2493 assert_eq!(search_view.active_match_index, Some(0));
2494 assert_eq!(
2495 search_view
2496 .results_editor
2497 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2498 [DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35)]
2499 );
2500
2501 search_view.select_match(Direction::Next, window, cx);
2502 }).unwrap();
2503
2504 search_view
2505 .update(cx, |search_view, window, cx| {
2506 assert_eq!(search_view.active_match_index, Some(1));
2507 assert_eq!(
2508 search_view
2509 .results_editor
2510 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2511 [DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40)]
2512 );
2513 search_view.select_match(Direction::Next, window, cx);
2514 })
2515 .unwrap();
2516
2517 search_view
2518 .update(cx, |search_view, window, cx| {
2519 assert_eq!(search_view.active_match_index, Some(2));
2520 assert_eq!(
2521 search_view
2522 .results_editor
2523 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2524 [DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9)]
2525 );
2526 search_view.select_match(Direction::Next, window, cx);
2527 })
2528 .unwrap();
2529
2530 search_view
2531 .update(cx, |search_view, window, cx| {
2532 assert_eq!(search_view.active_match_index, Some(0));
2533 assert_eq!(
2534 search_view
2535 .results_editor
2536 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2537 [DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35)]
2538 );
2539 search_view.select_match(Direction::Prev, window, cx);
2540 })
2541 .unwrap();
2542
2543 search_view
2544 .update(cx, |search_view, window, cx| {
2545 assert_eq!(search_view.active_match_index, Some(2));
2546 assert_eq!(
2547 search_view
2548 .results_editor
2549 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2550 [DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9)]
2551 );
2552 search_view.select_match(Direction::Prev, window, cx);
2553 })
2554 .unwrap();
2555
2556 search_view
2557 .update(cx, |search_view, _, cx| {
2558 assert_eq!(search_view.active_match_index, Some(1));
2559 assert_eq!(
2560 search_view
2561 .results_editor
2562 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2563 [DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40)]
2564 );
2565 })
2566 .unwrap();
2567 }
2568
2569 #[gpui::test]
2570 async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
2571 init_test(cx);
2572
2573 let fs = FakeFs::new(cx.background_executor.clone());
2574 fs.insert_tree(
2575 "/dir",
2576 json!({
2577 "one.rs": "const ONE: usize = 1;",
2578 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2579 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2580 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2581 }),
2582 )
2583 .await;
2584 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2585 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2586 let workspace = window;
2587 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2588
2589 let active_item = cx.read(|cx| {
2590 workspace
2591 .read(cx)
2592 .unwrap()
2593 .active_pane()
2594 .read(cx)
2595 .active_item()
2596 .and_then(|item| item.downcast::<ProjectSearchView>())
2597 });
2598 assert!(
2599 active_item.is_none(),
2600 "Expected no search panel to be active"
2601 );
2602
2603 window
2604 .update(cx, move |workspace, window, cx| {
2605 assert_eq!(workspace.panes().len(), 1);
2606 workspace.panes()[0].update(cx, |pane, cx| {
2607 pane.toolbar()
2608 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2609 });
2610
2611 ProjectSearchView::deploy_search(
2612 workspace,
2613 &workspace::DeploySearch::find(),
2614 window,
2615 cx,
2616 )
2617 })
2618 .unwrap();
2619
2620 let Some(search_view) = cx.read(|cx| {
2621 workspace
2622 .read(cx)
2623 .unwrap()
2624 .active_pane()
2625 .read(cx)
2626 .active_item()
2627 .and_then(|item| item.downcast::<ProjectSearchView>())
2628 }) else {
2629 panic!("Search view expected to appear after new search event trigger")
2630 };
2631
2632 cx.spawn(|mut cx| async move {
2633 window
2634 .update(&mut cx, |_, window, cx| {
2635 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2636 })
2637 .unwrap();
2638 })
2639 .detach();
2640 cx.background_executor.run_until_parked();
2641 window
2642 .update(cx, |_, window, cx| {
2643 search_view.update(cx, |search_view, cx| {
2644 assert!(
2645 search_view.query_editor.focus_handle(cx).is_focused(window),
2646 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2647 );
2648 });
2649 }).unwrap();
2650
2651 window
2652 .update(cx, |_, window, cx| {
2653 search_view.update(cx, |search_view, cx| {
2654 let query_editor = &search_view.query_editor;
2655 assert!(
2656 query_editor.focus_handle(cx).is_focused(window),
2657 "Search view should be focused after the new search view is activated",
2658 );
2659 let query_text = query_editor.read(cx).text(cx);
2660 assert!(
2661 query_text.is_empty(),
2662 "New search query should be empty but got '{query_text}'",
2663 );
2664 let results_text = search_view
2665 .results_editor
2666 .update(cx, |editor, cx| editor.display_text(cx));
2667 assert!(
2668 results_text.is_empty(),
2669 "Empty search view should have no results but got '{results_text}'"
2670 );
2671 });
2672 })
2673 .unwrap();
2674
2675 window
2676 .update(cx, |_, window, cx| {
2677 search_view.update(cx, |search_view, cx| {
2678 search_view.query_editor.update(cx, |query_editor, cx| {
2679 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
2680 });
2681 search_view.search(cx);
2682 });
2683 })
2684 .unwrap();
2685 cx.background_executor.run_until_parked();
2686 window
2687 .update(cx, |_, window, cx| {
2688 search_view.update(cx, |search_view, cx| {
2689 let results_text = search_view
2690 .results_editor
2691 .update(cx, |editor, cx| editor.display_text(cx));
2692 assert!(
2693 results_text.is_empty(),
2694 "Search view for mismatching query should have no results but got '{results_text}'"
2695 );
2696 assert!(
2697 search_view.query_editor.focus_handle(cx).is_focused(window),
2698 "Search view should be focused after mismatching query had been used in search",
2699 );
2700 });
2701 }).unwrap();
2702
2703 cx.spawn(|mut cx| async move {
2704 window.update(&mut cx, |_, window, cx| {
2705 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2706 })
2707 })
2708 .detach();
2709 cx.background_executor.run_until_parked();
2710 window.update(cx, |_, window, cx| {
2711 search_view.update(cx, |search_view, cx| {
2712 assert!(
2713 search_view.query_editor.focus_handle(cx).is_focused(window),
2714 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2715 );
2716 });
2717 }).unwrap();
2718
2719 window
2720 .update(cx, |_, window, cx| {
2721 search_view.update(cx, |search_view, cx| {
2722 search_view.query_editor.update(cx, |query_editor, cx| {
2723 query_editor.set_text("TWO", window, cx)
2724 });
2725 search_view.search(cx);
2726 });
2727 })
2728 .unwrap();
2729 cx.background_executor.run_until_parked();
2730 window.update(cx, |_, window, cx| {
2731 search_view.update(cx, |search_view, cx| {
2732 assert_eq!(
2733 search_view
2734 .results_editor
2735 .update(cx, |editor, cx| editor.display_text(cx)),
2736 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2737 "Search view results should match the query"
2738 );
2739 assert!(
2740 search_view.results_editor.focus_handle(cx).is_focused(window),
2741 "Search view with mismatching query should be focused after search results are available",
2742 );
2743 });
2744 }).unwrap();
2745 cx.spawn(|mut cx| async move {
2746 window
2747 .update(&mut cx, |_, window, cx| {
2748 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2749 })
2750 .unwrap();
2751 })
2752 .detach();
2753 cx.background_executor.run_until_parked();
2754 window.update(cx, |_, window, cx| {
2755 search_view.update(cx, |search_view, cx| {
2756 assert!(
2757 search_view.results_editor.focus_handle(cx).is_focused(window),
2758 "Search view with matching query should still have its results editor focused after the toggle focus event",
2759 );
2760 });
2761 }).unwrap();
2762
2763 workspace
2764 .update(cx, |workspace, window, cx| {
2765 ProjectSearchView::deploy_search(
2766 workspace,
2767 &workspace::DeploySearch::find(),
2768 window,
2769 cx,
2770 )
2771 })
2772 .unwrap();
2773 window.update(cx, |_, window, cx| {
2774 search_view.update(cx, |search_view, cx| {
2775 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");
2776 assert_eq!(
2777 search_view
2778 .results_editor
2779 .update(cx, |editor, cx| editor.display_text(cx)),
2780 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2781 "Results should be unchanged after search view 2nd open in a row"
2782 );
2783 assert!(
2784 search_view.query_editor.focus_handle(cx).is_focused(window),
2785 "Focus should be moved into query editor again after search view 2nd open in a row"
2786 );
2787 });
2788 }).unwrap();
2789
2790 cx.spawn(|mut cx| async move {
2791 window
2792 .update(&mut cx, |_, window, cx| {
2793 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2794 })
2795 .unwrap();
2796 })
2797 .detach();
2798 cx.background_executor.run_until_parked();
2799 window.update(cx, |_, window, cx| {
2800 search_view.update(cx, |search_view, cx| {
2801 assert!(
2802 search_view.results_editor.focus_handle(cx).is_focused(window),
2803 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2804 );
2805 });
2806 }).unwrap();
2807 }
2808
2809 #[gpui::test]
2810 async fn test_filters_consider_toggle_state(cx: &mut TestAppContext) {
2811 init_test(cx);
2812
2813 let fs = FakeFs::new(cx.background_executor.clone());
2814 fs.insert_tree(
2815 "/dir",
2816 json!({
2817 "one.rs": "const ONE: usize = 1;",
2818 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2819 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2820 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2821 }),
2822 )
2823 .await;
2824 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2825 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2826 let workspace = window;
2827 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2828
2829 window
2830 .update(cx, move |workspace, window, cx| {
2831 workspace.panes()[0].update(cx, |pane, cx| {
2832 pane.toolbar()
2833 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2834 });
2835
2836 ProjectSearchView::deploy_search(
2837 workspace,
2838 &workspace::DeploySearch::find(),
2839 window,
2840 cx,
2841 )
2842 })
2843 .unwrap();
2844
2845 let Some(search_view) = cx.read(|cx| {
2846 workspace
2847 .read(cx)
2848 .unwrap()
2849 .active_pane()
2850 .read(cx)
2851 .active_item()
2852 .and_then(|item| item.downcast::<ProjectSearchView>())
2853 }) else {
2854 panic!("Search view expected to appear after new search event trigger")
2855 };
2856
2857 cx.spawn(|mut cx| async move {
2858 window
2859 .update(&mut cx, |_, window, cx| {
2860 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2861 })
2862 .unwrap();
2863 })
2864 .detach();
2865 cx.background_executor.run_until_parked();
2866
2867 window
2868 .update(cx, |_, window, cx| {
2869 search_view.update(cx, |search_view, cx| {
2870 search_view.query_editor.update(cx, |query_editor, cx| {
2871 query_editor.set_text("const FOUR", window, cx)
2872 });
2873 search_view.toggle_filters(cx);
2874 search_view
2875 .excluded_files_editor
2876 .update(cx, |exclude_editor, cx| {
2877 exclude_editor.set_text("four.rs", window, cx)
2878 });
2879 search_view.search(cx);
2880 });
2881 })
2882 .unwrap();
2883 cx.background_executor.run_until_parked();
2884 window
2885 .update(cx, |_, _, cx| {
2886 search_view.update(cx, |search_view, cx| {
2887 let results_text = search_view
2888 .results_editor
2889 .update(cx, |editor, cx| editor.display_text(cx));
2890 assert!(
2891 results_text.is_empty(),
2892 "Search view for query with the only match in an excluded file should have no results but got '{results_text}'"
2893 );
2894 });
2895 }).unwrap();
2896
2897 cx.spawn(|mut cx| async move {
2898 window.update(&mut cx, |_, window, cx| {
2899 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2900 })
2901 })
2902 .detach();
2903 cx.background_executor.run_until_parked();
2904
2905 window
2906 .update(cx, |_, _, cx| {
2907 search_view.update(cx, |search_view, cx| {
2908 search_view.toggle_filters(cx);
2909 search_view.search(cx);
2910 });
2911 })
2912 .unwrap();
2913 cx.background_executor.run_until_parked();
2914 window
2915 .update(cx, |_, _, cx| {
2916 search_view.update(cx, |search_view, cx| {
2917 assert_eq!(
2918 search_view
2919 .results_editor
2920 .update(cx, |editor, cx| editor.display_text(cx)),
2921 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
2922 "Search view results should contain the queried result in the previously excluded file with filters toggled off"
2923 );
2924 });
2925 })
2926 .unwrap();
2927 }
2928
2929 #[gpui::test]
2930 async fn test_new_project_search_focus(cx: &mut TestAppContext) {
2931 init_test(cx);
2932
2933 let fs = FakeFs::new(cx.background_executor.clone());
2934 fs.insert_tree(
2935 path!("/dir"),
2936 json!({
2937 "one.rs": "const ONE: usize = 1;",
2938 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2939 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2940 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2941 }),
2942 )
2943 .await;
2944 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2945 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2946 let workspace = window;
2947 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2948
2949 let active_item = cx.read(|cx| {
2950 workspace
2951 .read(cx)
2952 .unwrap()
2953 .active_pane()
2954 .read(cx)
2955 .active_item()
2956 .and_then(|item| item.downcast::<ProjectSearchView>())
2957 });
2958 assert!(
2959 active_item.is_none(),
2960 "Expected no search panel to be active"
2961 );
2962
2963 window
2964 .update(cx, move |workspace, window, cx| {
2965 assert_eq!(workspace.panes().len(), 1);
2966 workspace.panes()[0].update(cx, |pane, cx| {
2967 pane.toolbar()
2968 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2969 });
2970
2971 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
2972 })
2973 .unwrap();
2974
2975 let Some(search_view) = cx.read(|cx| {
2976 workspace
2977 .read(cx)
2978 .unwrap()
2979 .active_pane()
2980 .read(cx)
2981 .active_item()
2982 .and_then(|item| item.downcast::<ProjectSearchView>())
2983 }) else {
2984 panic!("Search view expected to appear after new search event trigger")
2985 };
2986
2987 cx.spawn(|mut cx| async move {
2988 window
2989 .update(&mut cx, |_, window, cx| {
2990 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2991 })
2992 .unwrap();
2993 })
2994 .detach();
2995 cx.background_executor.run_until_parked();
2996
2997 window.update(cx, |_, window, cx| {
2998 search_view.update(cx, |search_view, cx| {
2999 assert!(
3000 search_view.query_editor.focus_handle(cx).is_focused(window),
3001 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
3002 );
3003 });
3004 }).unwrap();
3005
3006 window
3007 .update(cx, |_, window, cx| {
3008 search_view.update(cx, |search_view, cx| {
3009 let query_editor = &search_view.query_editor;
3010 assert!(
3011 query_editor.focus_handle(cx).is_focused(window),
3012 "Search view should be focused after the new search view is activated",
3013 );
3014 let query_text = query_editor.read(cx).text(cx);
3015 assert!(
3016 query_text.is_empty(),
3017 "New search query should be empty but got '{query_text}'",
3018 );
3019 let results_text = search_view
3020 .results_editor
3021 .update(cx, |editor, cx| editor.display_text(cx));
3022 assert!(
3023 results_text.is_empty(),
3024 "Empty search view should have no results but got '{results_text}'"
3025 );
3026 });
3027 })
3028 .unwrap();
3029
3030 window
3031 .update(cx, |_, window, cx| {
3032 search_view.update(cx, |search_view, cx| {
3033 search_view.query_editor.update(cx, |query_editor, cx| {
3034 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
3035 });
3036 search_view.search(cx);
3037 });
3038 })
3039 .unwrap();
3040
3041 cx.background_executor.run_until_parked();
3042 window
3043 .update(cx, |_, window, cx| {
3044 search_view.update(cx, |search_view, cx| {
3045 let results_text = search_view
3046 .results_editor
3047 .update(cx, |editor, cx| editor.display_text(cx));
3048 assert!(
3049 results_text.is_empty(),
3050 "Search view for mismatching query should have no results but got '{results_text}'"
3051 );
3052 assert!(
3053 search_view.query_editor.focus_handle(cx).is_focused(window),
3054 "Search view should be focused after mismatching query had been used in search",
3055 );
3056 });
3057 })
3058 .unwrap();
3059 cx.spawn(|mut cx| async move {
3060 window.update(&mut cx, |_, window, cx| {
3061 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3062 })
3063 })
3064 .detach();
3065 cx.background_executor.run_until_parked();
3066 window.update(cx, |_, window, cx| {
3067 search_view.update(cx, |search_view, cx| {
3068 assert!(
3069 search_view.query_editor.focus_handle(cx).is_focused(window),
3070 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
3071 );
3072 });
3073 }).unwrap();
3074
3075 window
3076 .update(cx, |_, window, cx| {
3077 search_view.update(cx, |search_view, cx| {
3078 search_view.query_editor.update(cx, |query_editor, cx| {
3079 query_editor.set_text("TWO", window, cx)
3080 });
3081 search_view.search(cx);
3082 })
3083 })
3084 .unwrap();
3085 cx.background_executor.run_until_parked();
3086 window.update(cx, |_, window, cx|
3087 search_view.update(cx, |search_view, cx| {
3088 assert_eq!(
3089 search_view
3090 .results_editor
3091 .update(cx, |editor, cx| editor.display_text(cx)),
3092 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3093 "Search view results should match the query"
3094 );
3095 assert!(
3096 search_view.results_editor.focus_handle(cx).is_focused(window),
3097 "Search view with mismatching query should be focused after search results are available",
3098 );
3099 })).unwrap();
3100 cx.spawn(|mut cx| async move {
3101 window
3102 .update(&mut cx, |_, window, cx| {
3103 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3104 })
3105 .unwrap();
3106 })
3107 .detach();
3108 cx.background_executor.run_until_parked();
3109 window.update(cx, |_, window, cx| {
3110 search_view.update(cx, |search_view, cx| {
3111 assert!(
3112 search_view.results_editor.focus_handle(cx).is_focused(window),
3113 "Search view with matching query should still have its results editor focused after the toggle focus event",
3114 );
3115 });
3116 }).unwrap();
3117
3118 workspace
3119 .update(cx, |workspace, window, cx| {
3120 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3121 })
3122 .unwrap();
3123 cx.background_executor.run_until_parked();
3124 let Some(search_view_2) = cx.read(|cx| {
3125 workspace
3126 .read(cx)
3127 .unwrap()
3128 .active_pane()
3129 .read(cx)
3130 .active_item()
3131 .and_then(|item| item.downcast::<ProjectSearchView>())
3132 }) else {
3133 panic!("Search view expected to appear after new search event trigger")
3134 };
3135 assert!(
3136 search_view_2 != search_view,
3137 "New search view should be open after `workspace::NewSearch` event"
3138 );
3139
3140 window.update(cx, |_, window, cx| {
3141 search_view.update(cx, |search_view, cx| {
3142 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
3143 assert_eq!(
3144 search_view
3145 .results_editor
3146 .update(cx, |editor, cx| editor.display_text(cx)),
3147 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3148 "Results of the first search view should not update too"
3149 );
3150 assert!(
3151 !search_view.query_editor.focus_handle(cx).is_focused(window),
3152 "Focus should be moved away from the first search view"
3153 );
3154 });
3155 }).unwrap();
3156
3157 window.update(cx, |_, window, cx| {
3158 search_view_2.update(cx, |search_view_2, cx| {
3159 assert_eq!(
3160 search_view_2.query_editor.read(cx).text(cx),
3161 "two",
3162 "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
3163 );
3164 assert_eq!(
3165 search_view_2
3166 .results_editor
3167 .update(cx, |editor, cx| editor.display_text(cx)),
3168 "",
3169 "No search results should be in the 2nd view yet, as we did not spawn a search for it"
3170 );
3171 assert!(
3172 search_view_2.query_editor.focus_handle(cx).is_focused(window),
3173 "Focus should be moved into query editor of the new window"
3174 );
3175 });
3176 }).unwrap();
3177
3178 window
3179 .update(cx, |_, window, cx| {
3180 search_view_2.update(cx, |search_view_2, cx| {
3181 search_view_2.query_editor.update(cx, |query_editor, cx| {
3182 query_editor.set_text("FOUR", window, cx)
3183 });
3184 search_view_2.search(cx);
3185 });
3186 })
3187 .unwrap();
3188
3189 cx.background_executor.run_until_parked();
3190 window.update(cx, |_, window, cx| {
3191 search_view_2.update(cx, |search_view_2, cx| {
3192 assert_eq!(
3193 search_view_2
3194 .results_editor
3195 .update(cx, |editor, cx| editor.display_text(cx)),
3196 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
3197 "New search view with the updated query should have new search results"
3198 );
3199 assert!(
3200 search_view_2.results_editor.focus_handle(cx).is_focused(window),
3201 "Search view with mismatching query should be focused after search results are available",
3202 );
3203 });
3204 }).unwrap();
3205
3206 cx.spawn(|mut cx| async move {
3207 window
3208 .update(&mut cx, |_, window, cx| {
3209 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3210 })
3211 .unwrap();
3212 })
3213 .detach();
3214 cx.background_executor.run_until_parked();
3215 window.update(cx, |_, window, cx| {
3216 search_view_2.update(cx, |search_view_2, cx| {
3217 assert!(
3218 search_view_2.results_editor.focus_handle(cx).is_focused(window),
3219 "Search view with matching query should switch focus to the results editor after the toggle focus event",
3220 );
3221 });}).unwrap();
3222 }
3223
3224 #[gpui::test]
3225 async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
3226 init_test(cx);
3227
3228 let fs = FakeFs::new(cx.background_executor.clone());
3229 fs.insert_tree(
3230 path!("/dir"),
3231 json!({
3232 "a": {
3233 "one.rs": "const ONE: usize = 1;",
3234 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3235 },
3236 "b": {
3237 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3238 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3239 },
3240 }),
3241 )
3242 .await;
3243 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3244 let worktree_id = project.read_with(cx, |project, cx| {
3245 project.worktrees(cx).next().unwrap().read(cx).id()
3246 });
3247 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3248 let workspace = window.root(cx).unwrap();
3249 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3250
3251 let active_item = cx.read(|cx| {
3252 workspace
3253 .read(cx)
3254 .active_pane()
3255 .read(cx)
3256 .active_item()
3257 .and_then(|item| item.downcast::<ProjectSearchView>())
3258 });
3259 assert!(
3260 active_item.is_none(),
3261 "Expected no search panel to be active"
3262 );
3263
3264 window
3265 .update(cx, move |workspace, window, cx| {
3266 assert_eq!(workspace.panes().len(), 1);
3267 workspace.panes()[0].update(cx, move |pane, cx| {
3268 pane.toolbar()
3269 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3270 });
3271 })
3272 .unwrap();
3273
3274 let a_dir_entry = cx.update(|cx| {
3275 workspace
3276 .read(cx)
3277 .project()
3278 .read(cx)
3279 .entry_for_path(&(worktree_id, "a").into(), cx)
3280 .expect("no entry for /a/ directory")
3281 });
3282 assert!(a_dir_entry.is_dir());
3283 window
3284 .update(cx, |workspace, window, cx| {
3285 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, window, cx)
3286 })
3287 .unwrap();
3288
3289 let Some(search_view) = cx.read(|cx| {
3290 workspace
3291 .read(cx)
3292 .active_pane()
3293 .read(cx)
3294 .active_item()
3295 .and_then(|item| item.downcast::<ProjectSearchView>())
3296 }) else {
3297 panic!("Search view expected to appear after new search in directory event trigger")
3298 };
3299 cx.background_executor.run_until_parked();
3300 window
3301 .update(cx, |_, window, cx| {
3302 search_view.update(cx, |search_view, cx| {
3303 assert!(
3304 search_view.query_editor.focus_handle(cx).is_focused(window),
3305 "On new search in directory, focus should be moved into query editor"
3306 );
3307 search_view.excluded_files_editor.update(cx, |editor, cx| {
3308 assert!(
3309 editor.display_text(cx).is_empty(),
3310 "New search in directory should not have any excluded files"
3311 );
3312 });
3313 search_view.included_files_editor.update(cx, |editor, cx| {
3314 assert_eq!(
3315 editor.display_text(cx),
3316 a_dir_entry.path.to_str().unwrap(),
3317 "New search in directory should have included dir entry path"
3318 );
3319 });
3320 });
3321 })
3322 .unwrap();
3323 window
3324 .update(cx, |_, window, cx| {
3325 search_view.update(cx, |search_view, cx| {
3326 search_view.query_editor.update(cx, |query_editor, cx| {
3327 query_editor.set_text("const", window, cx)
3328 });
3329 search_view.search(cx);
3330 });
3331 })
3332 .unwrap();
3333 cx.background_executor.run_until_parked();
3334 window
3335 .update(cx, |_, _, cx| {
3336 search_view.update(cx, |search_view, cx| {
3337 assert_eq!(
3338 search_view
3339 .results_editor
3340 .update(cx, |editor, cx| editor.display_text(cx)),
3341 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3342 "New search in directory should have a filter that matches a certain directory"
3343 );
3344 })
3345 })
3346 .unwrap();
3347 }
3348
3349 #[gpui::test]
3350 async fn test_search_query_history(cx: &mut TestAppContext) {
3351 init_test(cx);
3352
3353 let fs = FakeFs::new(cx.background_executor.clone());
3354 fs.insert_tree(
3355 path!("/dir"),
3356 json!({
3357 "one.rs": "const ONE: usize = 1;",
3358 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3359 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3360 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3361 }),
3362 )
3363 .await;
3364 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3365 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3366 let workspace = window.root(cx).unwrap();
3367 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3368
3369 window
3370 .update(cx, {
3371 let search_bar = search_bar.clone();
3372 |workspace, window, cx| {
3373 assert_eq!(workspace.panes().len(), 1);
3374 workspace.panes()[0].update(cx, |pane, cx| {
3375 pane.toolbar()
3376 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3377 });
3378
3379 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3380 }
3381 })
3382 .unwrap();
3383
3384 let search_view = cx.read(|cx| {
3385 workspace
3386 .read(cx)
3387 .active_pane()
3388 .read(cx)
3389 .active_item()
3390 .and_then(|item| item.downcast::<ProjectSearchView>())
3391 .expect("Search view expected to appear after new search event trigger")
3392 });
3393
3394 // Add 3 search items into the history + another unsubmitted one.
3395 window
3396 .update(cx, |_, window, cx| {
3397 search_view.update(cx, |search_view, cx| {
3398 search_view.search_options = SearchOptions::CASE_SENSITIVE;
3399 search_view.query_editor.update(cx, |query_editor, cx| {
3400 query_editor.set_text("ONE", window, cx)
3401 });
3402 search_view.search(cx);
3403 });
3404 })
3405 .unwrap();
3406
3407 cx.background_executor.run_until_parked();
3408 window
3409 .update(cx, |_, window, cx| {
3410 search_view.update(cx, |search_view, cx| {
3411 search_view.query_editor.update(cx, |query_editor, cx| {
3412 query_editor.set_text("TWO", window, cx)
3413 });
3414 search_view.search(cx);
3415 });
3416 })
3417 .unwrap();
3418 cx.background_executor.run_until_parked();
3419 window
3420 .update(cx, |_, window, cx| {
3421 search_view.update(cx, |search_view, cx| {
3422 search_view.query_editor.update(cx, |query_editor, cx| {
3423 query_editor.set_text("THREE", window, cx)
3424 });
3425 search_view.search(cx);
3426 })
3427 })
3428 .unwrap();
3429 cx.background_executor.run_until_parked();
3430 window
3431 .update(cx, |_, window, cx| {
3432 search_view.update(cx, |search_view, cx| {
3433 search_view.query_editor.update(cx, |query_editor, cx| {
3434 query_editor.set_text("JUST_TEXT_INPUT", window, cx)
3435 });
3436 })
3437 })
3438 .unwrap();
3439 cx.background_executor.run_until_parked();
3440
3441 // Ensure that the latest input with search settings is active.
3442 window
3443 .update(cx, |_, _, cx| {
3444 search_view.update(cx, |search_view, cx| {
3445 assert_eq!(
3446 search_view.query_editor.read(cx).text(cx),
3447 "JUST_TEXT_INPUT"
3448 );
3449 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3450 });
3451 })
3452 .unwrap();
3453
3454 // Next history query after the latest should set the query to the empty string.
3455 window
3456 .update(cx, |_, window, cx| {
3457 search_bar.update(cx, |search_bar, cx| {
3458 search_bar.focus_search(window, cx);
3459 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3460 })
3461 })
3462 .unwrap();
3463 window
3464 .update(cx, |_, _, cx| {
3465 search_view.update(cx, |search_view, cx| {
3466 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3467 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3468 });
3469 })
3470 .unwrap();
3471 window
3472 .update(cx, |_, window, cx| {
3473 search_bar.update(cx, |search_bar, cx| {
3474 search_bar.focus_search(window, cx);
3475 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3476 })
3477 })
3478 .unwrap();
3479 window
3480 .update(cx, |_, _, cx| {
3481 search_view.update(cx, |search_view, cx| {
3482 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3483 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3484 });
3485 })
3486 .unwrap();
3487
3488 // First previous query for empty current query should set the query to the latest submitted one.
3489 window
3490 .update(cx, |_, window, cx| {
3491 search_bar.update(cx, |search_bar, cx| {
3492 search_bar.focus_search(window, cx);
3493 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3494 });
3495 })
3496 .unwrap();
3497 window
3498 .update(cx, |_, _, cx| {
3499 search_view.update(cx, |search_view, cx| {
3500 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3501 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3502 });
3503 })
3504 .unwrap();
3505
3506 // Further previous items should go over the history in reverse order.
3507 window
3508 .update(cx, |_, window, cx| {
3509 search_bar.update(cx, |search_bar, cx| {
3510 search_bar.focus_search(window, cx);
3511 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3512 });
3513 })
3514 .unwrap();
3515 window
3516 .update(cx, |_, _, cx| {
3517 search_view.update(cx, |search_view, cx| {
3518 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3519 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3520 });
3521 })
3522 .unwrap();
3523
3524 // Previous items should never go behind the first history item.
3525 window
3526 .update(cx, |_, window, cx| {
3527 search_bar.update(cx, |search_bar, cx| {
3528 search_bar.focus_search(window, cx);
3529 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3530 });
3531 })
3532 .unwrap();
3533 window
3534 .update(cx, |_, _, cx| {
3535 search_view.update(cx, |search_view, cx| {
3536 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3537 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3538 });
3539 })
3540 .unwrap();
3541 window
3542 .update(cx, |_, window, cx| {
3543 search_bar.update(cx, |search_bar, cx| {
3544 search_bar.focus_search(window, cx);
3545 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3546 });
3547 })
3548 .unwrap();
3549 window
3550 .update(cx, |_, _, cx| {
3551 search_view.update(cx, |search_view, cx| {
3552 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3553 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3554 });
3555 })
3556 .unwrap();
3557
3558 // Next items should go over the history in the original order.
3559 window
3560 .update(cx, |_, window, cx| {
3561 search_bar.update(cx, |search_bar, cx| {
3562 search_bar.focus_search(window, cx);
3563 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3564 });
3565 })
3566 .unwrap();
3567 window
3568 .update(cx, |_, _, cx| {
3569 search_view.update(cx, |search_view, cx| {
3570 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3571 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3572 });
3573 })
3574 .unwrap();
3575
3576 window
3577 .update(cx, |_, window, cx| {
3578 search_view.update(cx, |search_view, cx| {
3579 search_view.query_editor.update(cx, |query_editor, cx| {
3580 query_editor.set_text("TWO_NEW", window, cx)
3581 });
3582 search_view.search(cx);
3583 });
3584 })
3585 .unwrap();
3586 cx.background_executor.run_until_parked();
3587 window
3588 .update(cx, |_, _, cx| {
3589 search_view.update(cx, |search_view, cx| {
3590 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3591 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3592 });
3593 })
3594 .unwrap();
3595
3596 // New search input should add another entry to history and move the selection to the end of the history.
3597 window
3598 .update(cx, |_, window, cx| {
3599 search_bar.update(cx, |search_bar, cx| {
3600 search_bar.focus_search(window, cx);
3601 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3602 });
3603 })
3604 .unwrap();
3605 window
3606 .update(cx, |_, _, cx| {
3607 search_view.update(cx, |search_view, cx| {
3608 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3609 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3610 });
3611 })
3612 .unwrap();
3613 window
3614 .update(cx, |_, window, cx| {
3615 search_bar.update(cx, |search_bar, cx| {
3616 search_bar.focus_search(window, cx);
3617 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3618 });
3619 })
3620 .unwrap();
3621 window
3622 .update(cx, |_, _, cx| {
3623 search_view.update(cx, |search_view, cx| {
3624 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3625 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3626 });
3627 })
3628 .unwrap();
3629 window
3630 .update(cx, |_, window, cx| {
3631 search_bar.update(cx, |search_bar, cx| {
3632 search_bar.focus_search(window, cx);
3633 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3634 });
3635 })
3636 .unwrap();
3637 window
3638 .update(cx, |_, _, cx| {
3639 search_view.update(cx, |search_view, cx| {
3640 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3641 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3642 });
3643 })
3644 .unwrap();
3645 window
3646 .update(cx, |_, window, cx| {
3647 search_bar.update(cx, |search_bar, cx| {
3648 search_bar.focus_search(window, cx);
3649 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3650 });
3651 })
3652 .unwrap();
3653 window
3654 .update(cx, |_, _, cx| {
3655 search_view.update(cx, |search_view, cx| {
3656 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3657 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3658 });
3659 })
3660 .unwrap();
3661 window
3662 .update(cx, |_, window, cx| {
3663 search_bar.update(cx, |search_bar, cx| {
3664 search_bar.focus_search(window, cx);
3665 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3666 });
3667 })
3668 .unwrap();
3669 window
3670 .update(cx, |_, _, cx| {
3671 search_view.update(cx, |search_view, cx| {
3672 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3673 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3674 });
3675 })
3676 .unwrap();
3677 }
3678
3679 #[gpui::test]
3680 async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) {
3681 init_test(cx);
3682
3683 let fs = FakeFs::new(cx.background_executor.clone());
3684 fs.insert_tree(
3685 path!("/dir"),
3686 json!({
3687 "one.rs": "const ONE: usize = 1;",
3688 }),
3689 )
3690 .await;
3691 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3692 let worktree_id = project.update(cx, |this, cx| {
3693 this.worktrees(cx).next().unwrap().read(cx).id()
3694 });
3695
3696 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3697 let workspace = window.root(cx).unwrap();
3698
3699 let panes: Vec<_> = window
3700 .update(cx, |this, _, _| this.panes().to_owned())
3701 .unwrap();
3702
3703 let search_bar_1 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3704 let search_bar_2 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3705
3706 assert_eq!(panes.len(), 1);
3707 let first_pane = panes.first().cloned().unwrap();
3708 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3709 window
3710 .update(cx, |workspace, window, cx| {
3711 workspace.open_path(
3712 (worktree_id, "one.rs"),
3713 Some(first_pane.downgrade()),
3714 true,
3715 window,
3716 cx,
3717 )
3718 })
3719 .unwrap()
3720 .await
3721 .unwrap();
3722 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3723
3724 // Add a project search item to the first pane
3725 window
3726 .update(cx, {
3727 let search_bar = search_bar_1.clone();
3728 |workspace, window, cx| {
3729 first_pane.update(cx, |pane, cx| {
3730 pane.toolbar()
3731 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3732 });
3733
3734 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3735 }
3736 })
3737 .unwrap();
3738 let search_view_1 = cx.read(|cx| {
3739 workspace
3740 .read(cx)
3741 .active_item(cx)
3742 .and_then(|item| item.downcast::<ProjectSearchView>())
3743 .expect("Search view expected to appear after new search event trigger")
3744 });
3745
3746 let second_pane = window
3747 .update(cx, |workspace, window, cx| {
3748 workspace.split_and_clone(
3749 first_pane.clone(),
3750 workspace::SplitDirection::Right,
3751 window,
3752 cx,
3753 )
3754 })
3755 .unwrap()
3756 .unwrap();
3757 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3758
3759 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3760 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3761
3762 // Add a project search item to the second pane
3763 window
3764 .update(cx, {
3765 let search_bar = search_bar_2.clone();
3766 let pane = second_pane.clone();
3767 move |workspace, window, cx| {
3768 assert_eq!(workspace.panes().len(), 2);
3769 pane.update(cx, |pane, cx| {
3770 pane.toolbar()
3771 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3772 });
3773
3774 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3775 }
3776 })
3777 .unwrap();
3778
3779 let search_view_2 = cx.read(|cx| {
3780 workspace
3781 .read(cx)
3782 .active_item(cx)
3783 .and_then(|item| item.downcast::<ProjectSearchView>())
3784 .expect("Search view expected to appear after new search event trigger")
3785 });
3786
3787 cx.run_until_parked();
3788 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3789 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3790
3791 let update_search_view =
3792 |search_view: &Entity<ProjectSearchView>, query: &str, cx: &mut TestAppContext| {
3793 window
3794 .update(cx, |_, window, cx| {
3795 search_view.update(cx, |search_view, cx| {
3796 search_view.query_editor.update(cx, |query_editor, cx| {
3797 query_editor.set_text(query, window, cx)
3798 });
3799 search_view.search(cx);
3800 });
3801 })
3802 .unwrap();
3803 };
3804
3805 let active_query =
3806 |search_view: &Entity<ProjectSearchView>, cx: &mut TestAppContext| -> String {
3807 window
3808 .update(cx, |_, _, cx| {
3809 search_view.update(cx, |search_view, cx| {
3810 search_view.query_editor.read(cx).text(cx).to_string()
3811 })
3812 })
3813 .unwrap()
3814 };
3815
3816 let select_prev_history_item =
3817 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
3818 window
3819 .update(cx, |_, window, cx| {
3820 search_bar.update(cx, |search_bar, cx| {
3821 search_bar.focus_search(window, cx);
3822 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3823 })
3824 })
3825 .unwrap();
3826 };
3827
3828 let select_next_history_item =
3829 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
3830 window
3831 .update(cx, |_, window, cx| {
3832 search_bar.update(cx, |search_bar, cx| {
3833 search_bar.focus_search(window, cx);
3834 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3835 })
3836 })
3837 .unwrap();
3838 };
3839
3840 update_search_view(&search_view_1, "ONE", cx);
3841 cx.background_executor.run_until_parked();
3842
3843 update_search_view(&search_view_2, "TWO", cx);
3844 cx.background_executor.run_until_parked();
3845
3846 assert_eq!(active_query(&search_view_1, cx), "ONE");
3847 assert_eq!(active_query(&search_view_2, cx), "TWO");
3848
3849 // Selecting previous history item should select the query from search view 1.
3850 select_prev_history_item(&search_bar_2, cx);
3851 assert_eq!(active_query(&search_view_2, cx), "ONE");
3852
3853 // Selecting the previous history item should not change the query as it is already the first item.
3854 select_prev_history_item(&search_bar_2, cx);
3855 assert_eq!(active_query(&search_view_2, cx), "ONE");
3856
3857 // Changing the query in search view 2 should not affect the history of search view 1.
3858 assert_eq!(active_query(&search_view_1, cx), "ONE");
3859
3860 // Deploying a new search in search view 2
3861 update_search_view(&search_view_2, "THREE", cx);
3862 cx.background_executor.run_until_parked();
3863
3864 select_next_history_item(&search_bar_2, cx);
3865 assert_eq!(active_query(&search_view_2, cx), "");
3866
3867 select_prev_history_item(&search_bar_2, cx);
3868 assert_eq!(active_query(&search_view_2, cx), "THREE");
3869
3870 select_prev_history_item(&search_bar_2, cx);
3871 assert_eq!(active_query(&search_view_2, cx), "TWO");
3872
3873 select_prev_history_item(&search_bar_2, cx);
3874 assert_eq!(active_query(&search_view_2, cx), "ONE");
3875
3876 select_prev_history_item(&search_bar_2, cx);
3877 assert_eq!(active_query(&search_view_2, cx), "ONE");
3878
3879 // Search view 1 should now see the query from search view 2.
3880 assert_eq!(active_query(&search_view_1, cx), "ONE");
3881
3882 select_next_history_item(&search_bar_2, cx);
3883 assert_eq!(active_query(&search_view_2, cx), "TWO");
3884
3885 // Here is the new query from search view 2
3886 select_next_history_item(&search_bar_2, cx);
3887 assert_eq!(active_query(&search_view_2, cx), "THREE");
3888
3889 select_next_history_item(&search_bar_2, cx);
3890 assert_eq!(active_query(&search_view_2, cx), "");
3891
3892 select_next_history_item(&search_bar_1, cx);
3893 assert_eq!(active_query(&search_view_1, cx), "TWO");
3894
3895 select_next_history_item(&search_bar_1, cx);
3896 assert_eq!(active_query(&search_view_1, cx), "THREE");
3897
3898 select_next_history_item(&search_bar_1, cx);
3899 assert_eq!(active_query(&search_view_1, cx), "");
3900 }
3901
3902 #[gpui::test]
3903 async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
3904 init_test(cx);
3905
3906 // Setup 2 panes, both with a file open and one with a project search.
3907 let fs = FakeFs::new(cx.background_executor.clone());
3908 fs.insert_tree(
3909 path!("/dir"),
3910 json!({
3911 "one.rs": "const ONE: usize = 1;",
3912 }),
3913 )
3914 .await;
3915 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3916 let worktree_id = project.update(cx, |this, cx| {
3917 this.worktrees(cx).next().unwrap().read(cx).id()
3918 });
3919 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3920 let panes: Vec<_> = window
3921 .update(cx, |this, _, _| this.panes().to_owned())
3922 .unwrap();
3923 assert_eq!(panes.len(), 1);
3924 let first_pane = panes.first().cloned().unwrap();
3925 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3926 window
3927 .update(cx, |workspace, window, cx| {
3928 workspace.open_path(
3929 (worktree_id, "one.rs"),
3930 Some(first_pane.downgrade()),
3931 true,
3932 window,
3933 cx,
3934 )
3935 })
3936 .unwrap()
3937 .await
3938 .unwrap();
3939 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3940 let second_pane = window
3941 .update(cx, |workspace, window, cx| {
3942 workspace.split_and_clone(
3943 first_pane.clone(),
3944 workspace::SplitDirection::Right,
3945 window,
3946 cx,
3947 )
3948 })
3949 .unwrap()
3950 .unwrap();
3951 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3952 assert!(
3953 window
3954 .update(cx, |_, window, cx| second_pane
3955 .focus_handle(cx)
3956 .contains_focused(window, cx))
3957 .unwrap()
3958 );
3959 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3960 window
3961 .update(cx, {
3962 let search_bar = search_bar.clone();
3963 let pane = first_pane.clone();
3964 move |workspace, window, cx| {
3965 assert_eq!(workspace.panes().len(), 2);
3966 pane.update(cx, move |pane, cx| {
3967 pane.toolbar()
3968 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3969 });
3970 }
3971 })
3972 .unwrap();
3973
3974 // Add a project search item to the second pane
3975 window
3976 .update(cx, {
3977 let search_bar = search_bar.clone();
3978 |workspace, window, cx| {
3979 assert_eq!(workspace.panes().len(), 2);
3980 second_pane.update(cx, |pane, cx| {
3981 pane.toolbar()
3982 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3983 });
3984
3985 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3986 }
3987 })
3988 .unwrap();
3989
3990 cx.run_until_parked();
3991 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3992 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3993
3994 // Focus the first pane
3995 window
3996 .update(cx, |workspace, window, cx| {
3997 assert_eq!(workspace.active_pane(), &second_pane);
3998 second_pane.update(cx, |this, cx| {
3999 assert_eq!(this.active_item_index(), 1);
4000 this.activate_prev_item(false, window, cx);
4001 assert_eq!(this.active_item_index(), 0);
4002 });
4003 workspace.activate_pane_in_direction(workspace::SplitDirection::Left, window, cx);
4004 })
4005 .unwrap();
4006 window
4007 .update(cx, |workspace, _, cx| {
4008 assert_eq!(workspace.active_pane(), &first_pane);
4009 assert_eq!(first_pane.read(cx).items_len(), 1);
4010 assert_eq!(second_pane.read(cx).items_len(), 2);
4011 })
4012 .unwrap();
4013
4014 // Deploy a new search
4015 cx.dispatch_action(window.into(), DeploySearch::find());
4016
4017 // Both panes should now have a project search in them
4018 window
4019 .update(cx, |workspace, window, cx| {
4020 assert_eq!(workspace.active_pane(), &first_pane);
4021 first_pane.update(cx, |this, _| {
4022 assert_eq!(this.active_item_index(), 1);
4023 assert_eq!(this.items_len(), 2);
4024 });
4025 second_pane.update(cx, |this, cx| {
4026 assert!(!cx.focus_handle().contains_focused(window, cx));
4027 assert_eq!(this.items_len(), 2);
4028 });
4029 })
4030 .unwrap();
4031
4032 // Focus the second pane's non-search item
4033 window
4034 .update(cx, |_workspace, window, cx| {
4035 second_pane.update(cx, |pane, cx| pane.activate_next_item(true, window, cx));
4036 })
4037 .unwrap();
4038
4039 // Deploy a new search
4040 cx.dispatch_action(window.into(), DeploySearch::find());
4041
4042 // The project search view should now be focused in the second pane
4043 // And the number of items should be unchanged.
4044 window
4045 .update(cx, |_workspace, _, cx| {
4046 second_pane.update(cx, |pane, _cx| {
4047 assert!(
4048 pane.active_item()
4049 .unwrap()
4050 .downcast::<ProjectSearchView>()
4051 .is_some()
4052 );
4053
4054 assert_eq!(pane.items_len(), 2);
4055 });
4056 })
4057 .unwrap();
4058 }
4059
4060 #[gpui::test]
4061 async fn test_scroll_search_results_to_top(cx: &mut TestAppContext) {
4062 init_test(cx);
4063
4064 // We need many lines in the search results to be able to scroll the window
4065 let fs = FakeFs::new(cx.background_executor.clone());
4066 fs.insert_tree(
4067 path!("/dir"),
4068 json!({
4069 "1.txt": "\n\n\n\n\n A \n\n\n\n\n",
4070 "2.txt": "\n\n\n\n\n A \n\n\n\n\n",
4071 "3.rs": "\n\n\n\n\n A \n\n\n\n\n",
4072 "4.rs": "\n\n\n\n\n A \n\n\n\n\n",
4073 "5.rs": "\n\n\n\n\n A \n\n\n\n\n",
4074 "6.rs": "\n\n\n\n\n A \n\n\n\n\n",
4075 "7.rs": "\n\n\n\n\n A \n\n\n\n\n",
4076 "8.rs": "\n\n\n\n\n A \n\n\n\n\n",
4077 "9.rs": "\n\n\n\n\n A \n\n\n\n\n",
4078 "a.rs": "\n\n\n\n\n A \n\n\n\n\n",
4079 "b.rs": "\n\n\n\n\n B \n\n\n\n\n",
4080 "c.rs": "\n\n\n\n\n B \n\n\n\n\n",
4081 "d.rs": "\n\n\n\n\n B \n\n\n\n\n",
4082 "e.rs": "\n\n\n\n\n B \n\n\n\n\n",
4083 "f.rs": "\n\n\n\n\n B \n\n\n\n\n",
4084 "g.rs": "\n\n\n\n\n B \n\n\n\n\n",
4085 "h.rs": "\n\n\n\n\n B \n\n\n\n\n",
4086 "i.rs": "\n\n\n\n\n B \n\n\n\n\n",
4087 "j.rs": "\n\n\n\n\n B \n\n\n\n\n",
4088 "k.rs": "\n\n\n\n\n B \n\n\n\n\n",
4089 }),
4090 )
4091 .await;
4092 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4093 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4094 let workspace = window.root(cx).unwrap();
4095 let search = cx.new(|cx| ProjectSearch::new(project, cx));
4096 let search_view = cx.add_window(|window, cx| {
4097 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
4098 });
4099
4100 // First search
4101 perform_search(search_view, "A", cx);
4102 search_view
4103 .update(cx, |search_view, window, cx| {
4104 search_view.results_editor.update(cx, |results_editor, cx| {
4105 // Results are correct and scrolled to the top
4106 assert_eq!(
4107 results_editor.display_text(cx).match_indices(" A ").count(),
4108 10
4109 );
4110 assert_eq!(results_editor.scroll_position(cx), Point::default());
4111
4112 // Scroll results all the way down
4113 results_editor.scroll(
4114 Point::new(0., f32::MAX),
4115 Some(Axis::Vertical),
4116 window,
4117 cx,
4118 );
4119 });
4120 })
4121 .expect("unable to update search view");
4122
4123 // Second search
4124 perform_search(search_view, "B", cx);
4125 search_view
4126 .update(cx, |search_view, _, cx| {
4127 search_view.results_editor.update(cx, |results_editor, cx| {
4128 // Results are correct...
4129 assert_eq!(
4130 results_editor.display_text(cx).match_indices(" B ").count(),
4131 10
4132 );
4133 // ...and scrolled back to the top
4134 assert_eq!(results_editor.scroll_position(cx), Point::default());
4135 });
4136 })
4137 .expect("unable to update search view");
4138 }
4139
4140 #[gpui::test]
4141 async fn test_buffer_search_query_reused(cx: &mut TestAppContext) {
4142 init_test(cx);
4143
4144 let fs = FakeFs::new(cx.background_executor.clone());
4145 fs.insert_tree(
4146 path!("/dir"),
4147 json!({
4148 "one.rs": "const ONE: usize = 1;",
4149 }),
4150 )
4151 .await;
4152 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4153 let worktree_id = project.update(cx, |this, cx| {
4154 this.worktrees(cx).next().unwrap().read(cx).id()
4155 });
4156 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4157 let workspace = window.root(cx).unwrap();
4158 let mut cx = VisualTestContext::from_window(*window.deref(), cx);
4159
4160 let editor = workspace
4161 .update_in(&mut cx, |workspace, window, cx| {
4162 workspace.open_path((worktree_id, "one.rs"), None, true, window, cx)
4163 })
4164 .await
4165 .unwrap()
4166 .downcast::<Editor>()
4167 .unwrap();
4168
4169 // Wait for the unstaged changes to be loaded
4170 cx.run_until_parked();
4171
4172 let buffer_search_bar = cx.new_window_entity(|window, cx| {
4173 let mut search_bar =
4174 BufferSearchBar::new(Some(project.read(cx).languages().clone()), window, cx);
4175 search_bar.set_active_pane_item(Some(&editor), window, cx);
4176 search_bar.show(window, cx);
4177 search_bar
4178 });
4179
4180 let panes: Vec<_> = window
4181 .update(&mut cx, |this, _, _| this.panes().to_owned())
4182 .unwrap();
4183 assert_eq!(panes.len(), 1);
4184 let pane = panes.first().cloned().unwrap();
4185 pane.update_in(&mut cx, |pane, window, cx| {
4186 pane.toolbar().update(cx, |toolbar, cx| {
4187 toolbar.add_item(buffer_search_bar.clone(), window, cx);
4188 })
4189 });
4190
4191 let buffer_search_query = "search bar query";
4192 buffer_search_bar
4193 .update_in(&mut cx, |buffer_search_bar, window, cx| {
4194 buffer_search_bar.focus_handle(cx).focus(window);
4195 buffer_search_bar.search(buffer_search_query, None, window, cx)
4196 })
4197 .await
4198 .unwrap();
4199
4200 workspace.update_in(&mut cx, |workspace, window, cx| {
4201 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4202 });
4203 cx.run_until_parked();
4204 let project_search_view = pane
4205 .update(&mut cx, |pane, _| {
4206 pane.active_item()
4207 .and_then(|item| item.downcast::<ProjectSearchView>())
4208 })
4209 .expect("should open a project search view after spawning a new search");
4210 project_search_view.update(&mut cx, |search_view, cx| {
4211 assert_eq!(
4212 search_view.search_query_text(cx),
4213 buffer_search_query,
4214 "Project search should take the query from the buffer search bar since it got focused and had a query inside"
4215 );
4216 });
4217 }
4218
4219 fn init_test(cx: &mut TestAppContext) {
4220 cx.update(|cx| {
4221 let settings = SettingsStore::test(cx);
4222 cx.set_global(settings);
4223
4224 theme::init(theme::LoadThemes::JustBase, cx);
4225
4226 language::init(cx);
4227 client::init_settings(cx);
4228 editor::init(cx);
4229 workspace::init_settings(cx);
4230 Project::init_settings(cx);
4231 crate::init(cx);
4232 });
4233 }
4234
4235 fn perform_search(
4236 search_view: WindowHandle<ProjectSearchView>,
4237 text: impl Into<Arc<str>>,
4238 cx: &mut TestAppContext,
4239 ) {
4240 search_view
4241 .update(cx, |search_view, window, cx| {
4242 search_view.query_editor.update(cx, |query_editor, cx| {
4243 query_editor.set_text(text, window, cx)
4244 });
4245 search_view.search(cx);
4246 })
4247 .unwrap();
4248 cx.background_executor.run_until_parked();
4249 }
4250}