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