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