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