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 let skip_save_on_close = self
1059 .workspace
1060 .read_with(cx, |workspace, cx| {
1061 workspace::Pane::skip_save_on_close(&self.results_editor, workspace, cx)
1062 })
1063 .unwrap_or(false);
1064
1065 let should_prompt_to_save = !skip_save_on_close && !will_autosave && is_dirty;
1066
1067 cx.spawn_in(window, async move |this, cx| {
1068 let should_search = if should_prompt_to_save {
1069 let options = &["Save", "Don't Save", "Cancel"];
1070 let result_channel = this.update_in(cx, |_, window, cx| {
1071 window.prompt(
1072 gpui::PromptLevel::Warning,
1073 "Project search buffer contains unsaved edits. Do you want to save it?",
1074 None,
1075 options,
1076 cx,
1077 )
1078 })?;
1079 let result = result_channel.await?;
1080 let should_save = result == 0;
1081 if should_save {
1082 this.update_in(cx, |this, window, cx| this.save(true, project, window, cx))?
1083 .await
1084 .log_err();
1085 }
1086 let should_search = result != 2;
1087 should_search
1088 } else {
1089 true
1090 };
1091 if should_search {
1092 this.update(cx, |this, cx| {
1093 this.search(cx);
1094 })?;
1095 }
1096 anyhow::Ok(())
1097 })
1098 }
1099
1100 fn search(&mut self, cx: &mut Context<Self>) {
1101 if let Some(query) = self.build_search_query(cx) {
1102 self.entity.update(cx, |model, cx| model.search(query, cx));
1103 }
1104 }
1105
1106 pub fn search_query_text(&self, cx: &App) -> String {
1107 self.query_editor.read(cx).text(cx)
1108 }
1109
1110 fn build_search_query(&mut self, cx: &mut Context<Self>) -> Option<SearchQuery> {
1111 // Do not bail early in this function, as we want to fill out `self.panels_with_errors`.
1112 let text = self.query_editor.read(cx).text(cx);
1113 let open_buffers = if self.included_opened_only {
1114 Some(self.open_buffers(cx))
1115 } else {
1116 None
1117 };
1118 let included_files = self
1119 .filters_enabled
1120 .then(|| {
1121 match Self::parse_path_matches(&self.included_files_editor.read(cx).text(cx)) {
1122 Ok(included_files) => {
1123 let should_unmark_error =
1124 self.panels_with_errors.remove(&InputPanel::Include);
1125 if should_unmark_error {
1126 cx.notify();
1127 }
1128 included_files
1129 }
1130 Err(_e) => {
1131 let should_mark_error = self.panels_with_errors.insert(InputPanel::Include);
1132 if should_mark_error {
1133 cx.notify();
1134 }
1135 PathMatcher::default()
1136 }
1137 }
1138 })
1139 .unwrap_or_default();
1140 let excluded_files = self
1141 .filters_enabled
1142 .then(|| {
1143 match Self::parse_path_matches(&self.excluded_files_editor.read(cx).text(cx)) {
1144 Ok(excluded_files) => {
1145 let should_unmark_error =
1146 self.panels_with_errors.remove(&InputPanel::Exclude);
1147 if should_unmark_error {
1148 cx.notify();
1149 }
1150
1151 excluded_files
1152 }
1153 Err(_e) => {
1154 let should_mark_error = self.panels_with_errors.insert(InputPanel::Exclude);
1155 if should_mark_error {
1156 cx.notify();
1157 }
1158 PathMatcher::default()
1159 }
1160 }
1161 })
1162 .unwrap_or_default();
1163
1164 // If the project contains multiple visible worktrees, we match the
1165 // include/exclude patterns against full paths to allow them to be
1166 // disambiguated. For single worktree projects we use worktree relative
1167 // paths for convenience.
1168 let match_full_paths = self
1169 .entity
1170 .read(cx)
1171 .project
1172 .read(cx)
1173 .visible_worktrees(cx)
1174 .count()
1175 > 1;
1176
1177 let query = if self.search_options.contains(SearchOptions::REGEX) {
1178 match SearchQuery::regex(
1179 text,
1180 self.search_options.contains(SearchOptions::WHOLE_WORD),
1181 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1182 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1183 self.search_options
1184 .contains(SearchOptions::ONE_MATCH_PER_LINE),
1185 included_files,
1186 excluded_files,
1187 match_full_paths,
1188 open_buffers,
1189 ) {
1190 Ok(query) => {
1191 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
1192 if should_unmark_error {
1193 cx.notify();
1194 }
1195
1196 Some(query)
1197 }
1198 Err(_e) => {
1199 let should_mark_error = self.panels_with_errors.insert(InputPanel::Query);
1200 if should_mark_error {
1201 cx.notify();
1202 }
1203
1204 None
1205 }
1206 }
1207 } else {
1208 match SearchQuery::text(
1209 text,
1210 self.search_options.contains(SearchOptions::WHOLE_WORD),
1211 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1212 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1213 included_files,
1214 excluded_files,
1215 match_full_paths,
1216 open_buffers,
1217 ) {
1218 Ok(query) => {
1219 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
1220 if should_unmark_error {
1221 cx.notify();
1222 }
1223
1224 Some(query)
1225 }
1226 Err(_e) => {
1227 let should_mark_error = self.panels_with_errors.insert(InputPanel::Query);
1228 if should_mark_error {
1229 cx.notify();
1230 }
1231
1232 None
1233 }
1234 }
1235 };
1236 if !self.panels_with_errors.is_empty() {
1237 return None;
1238 }
1239 if query.as_ref().is_some_and(|query| query.is_empty()) {
1240 return None;
1241 }
1242 query
1243 }
1244
1245 fn open_buffers(&self, cx: &mut Context<Self>) -> Vec<Entity<Buffer>> {
1246 let mut buffers = Vec::new();
1247 self.workspace
1248 .update(cx, |workspace, cx| {
1249 for editor in workspace.items_of_type::<Editor>(cx) {
1250 if let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() {
1251 buffers.push(buffer);
1252 }
1253 }
1254 })
1255 .ok();
1256 buffers
1257 }
1258
1259 fn parse_path_matches(text: &str) -> anyhow::Result<PathMatcher> {
1260 let queries = text
1261 .split(',')
1262 .map(str::trim)
1263 .filter(|maybe_glob_str| !maybe_glob_str.is_empty())
1264 .map(str::to_owned)
1265 .collect::<Vec<_>>();
1266 Ok(PathMatcher::new(&queries)?)
1267 }
1268
1269 fn select_match(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
1270 if let Some(index) = self.active_match_index {
1271 let match_ranges = self.entity.read(cx).match_ranges.clone();
1272
1273 if !EditorSettings::get_global(cx).search_wrap
1274 && ((direction == Direction::Next && index + 1 >= match_ranges.len())
1275 || (direction == Direction::Prev && index == 0))
1276 {
1277 crate::show_no_more_matches(window, cx);
1278 return;
1279 }
1280
1281 let new_index = self.results_editor.update(cx, |editor, cx| {
1282 editor.match_index_for_direction(&match_ranges, index, direction, 1, window, cx)
1283 });
1284
1285 let range_to_select = match_ranges[new_index].clone();
1286 self.results_editor.update(cx, |editor, cx| {
1287 let range_to_select = editor.range_for_match(&range_to_select);
1288 editor.unfold_ranges(&[range_to_select.clone()], false, true, cx);
1289 editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
1290 s.select_ranges([range_to_select])
1291 });
1292 });
1293 }
1294 }
1295
1296 fn focus_query_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1297 self.query_editor.update(cx, |query_editor, cx| {
1298 query_editor.select_all(&SelectAll, window, cx);
1299 });
1300 let editor_handle = self.query_editor.focus_handle(cx);
1301 window.focus(&editor_handle);
1302 }
1303
1304 fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
1305 self.set_search_editor(SearchInputKind::Query, query, window, cx);
1306 if EditorSettings::get_global(cx).use_smartcase_search
1307 && !query.is_empty()
1308 && self.search_options.contains(SearchOptions::CASE_SENSITIVE)
1309 != is_contains_uppercase(query)
1310 {
1311 self.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx)
1312 }
1313 }
1314
1315 fn set_search_editor(
1316 &mut self,
1317 kind: SearchInputKind,
1318 text: &str,
1319 window: &mut Window,
1320 cx: &mut Context<Self>,
1321 ) {
1322 let editor = match kind {
1323 SearchInputKind::Query => &self.query_editor,
1324 SearchInputKind::Include => &self.included_files_editor,
1325
1326 SearchInputKind::Exclude => &self.excluded_files_editor,
1327 };
1328 editor.update(cx, |included_editor, cx| {
1329 included_editor.set_text(text, window, cx)
1330 });
1331 }
1332
1333 fn focus_results_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1334 self.query_editor.update(cx, |query_editor, cx| {
1335 let cursor = query_editor.selections.newest_anchor().head();
1336 query_editor.change_selections(None, window, cx, |s| s.select_ranges([cursor..cursor]));
1337 });
1338 let results_handle = self.results_editor.focus_handle(cx);
1339 window.focus(&results_handle);
1340 }
1341
1342 fn entity_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1343 let match_ranges = self.entity.read(cx).match_ranges.clone();
1344 if match_ranges.is_empty() {
1345 self.active_match_index = None;
1346 } else {
1347 self.active_match_index = Some(0);
1348 self.update_match_index(cx);
1349 let prev_search_id = mem::replace(&mut self.search_id, self.entity.read(cx).search_id);
1350 let is_new_search = self.search_id != prev_search_id;
1351 self.results_editor.update(cx, |editor, cx| {
1352 if is_new_search {
1353 let range_to_select = match_ranges
1354 .first()
1355 .map(|range| editor.range_for_match(range));
1356 editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
1357 s.select_ranges(range_to_select)
1358 });
1359 editor.scroll(Point::default(), Some(Axis::Vertical), window, cx);
1360 }
1361 editor.highlight_background::<Self>(
1362 &match_ranges,
1363 |theme| theme.search_match_background,
1364 cx,
1365 );
1366 });
1367 if is_new_search && self.query_editor.focus_handle(cx).is_focused(window) {
1368 self.focus_results_editor(window, cx);
1369 }
1370 }
1371
1372 cx.emit(ViewEvent::UpdateTab);
1373 cx.notify();
1374 }
1375
1376 fn update_match_index(&mut self, cx: &mut Context<Self>) {
1377 let results_editor = self.results_editor.read(cx);
1378 let new_index = active_match_index(
1379 Direction::Next,
1380 &self.entity.read(cx).match_ranges,
1381 &results_editor.selections.newest_anchor().head(),
1382 &results_editor.buffer().read(cx).snapshot(cx),
1383 );
1384 if self.active_match_index != new_index {
1385 self.active_match_index = new_index;
1386 cx.notify();
1387 }
1388 }
1389
1390 pub fn has_matches(&self) -> bool {
1391 self.active_match_index.is_some()
1392 }
1393
1394 fn landing_text_minor(&self, window: &mut Window, cx: &App) -> impl IntoElement {
1395 let focus_handle = self.focus_handle.clone();
1396 v_flex()
1397 .gap_1()
1398 .child(
1399 Label::new("Hit enter to search. For more options:")
1400 .color(Color::Muted)
1401 .mb_2(),
1402 )
1403 .child(
1404 Button::new("filter-paths", "Include/exclude specific paths")
1405 .icon(IconName::Filter)
1406 .icon_position(IconPosition::Start)
1407 .icon_size(IconSize::Small)
1408 .key_binding(KeyBinding::for_action_in(
1409 &ToggleFilters,
1410 &focus_handle,
1411 window,
1412 cx,
1413 ))
1414 .on_click(|_event, window, cx| {
1415 window.dispatch_action(ToggleFilters.boxed_clone(), cx)
1416 }),
1417 )
1418 .child(
1419 Button::new("find-replace", "Find and replace")
1420 .icon(IconName::Replace)
1421 .icon_position(IconPosition::Start)
1422 .icon_size(IconSize::Small)
1423 .key_binding(KeyBinding::for_action_in(
1424 &ToggleReplace,
1425 &focus_handle,
1426 window,
1427 cx,
1428 ))
1429 .on_click(|_event, window, cx| {
1430 window.dispatch_action(ToggleReplace.boxed_clone(), cx)
1431 }),
1432 )
1433 .child(
1434 Button::new("regex", "Match with regex")
1435 .icon(IconName::Regex)
1436 .icon_position(IconPosition::Start)
1437 .icon_size(IconSize::Small)
1438 .key_binding(KeyBinding::for_action_in(
1439 &ToggleRegex,
1440 &focus_handle,
1441 window,
1442 cx,
1443 ))
1444 .on_click(|_event, window, cx| {
1445 window.dispatch_action(ToggleRegex.boxed_clone(), cx)
1446 }),
1447 )
1448 .child(
1449 Button::new("match-case", "Match case")
1450 .icon(IconName::CaseSensitive)
1451 .icon_position(IconPosition::Start)
1452 .icon_size(IconSize::Small)
1453 .key_binding(KeyBinding::for_action_in(
1454 &ToggleCaseSensitive,
1455 &focus_handle,
1456 window,
1457 cx,
1458 ))
1459 .on_click(|_event, window, cx| {
1460 window.dispatch_action(ToggleCaseSensitive.boxed_clone(), cx)
1461 }),
1462 )
1463 .child(
1464 Button::new("match-whole-words", "Match whole words")
1465 .icon(IconName::WholeWord)
1466 .icon_position(IconPosition::Start)
1467 .icon_size(IconSize::Small)
1468 .key_binding(KeyBinding::for_action_in(
1469 &ToggleWholeWord,
1470 &focus_handle,
1471 window,
1472 cx,
1473 ))
1474 .on_click(|_event, window, cx| {
1475 window.dispatch_action(ToggleWholeWord.boxed_clone(), cx)
1476 }),
1477 )
1478 }
1479
1480 fn border_color_for(&self, panel: InputPanel, cx: &App) -> Hsla {
1481 if self.panels_with_errors.contains(&panel) {
1482 Color::Error.color(cx)
1483 } else {
1484 cx.theme().colors().border
1485 }
1486 }
1487
1488 fn move_focus_to_results(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1489 if !self.results_editor.focus_handle(cx).is_focused(window)
1490 && !self.entity.read(cx).match_ranges.is_empty()
1491 {
1492 cx.stop_propagation();
1493 self.focus_results_editor(window, cx)
1494 }
1495 }
1496
1497 #[cfg(any(test, feature = "test-support"))]
1498 pub fn results_editor(&self) -> &Entity<Editor> {
1499 &self.results_editor
1500 }
1501
1502 fn adjust_query_regex_language(&self, cx: &mut App) {
1503 let enable = self.search_options.contains(SearchOptions::REGEX);
1504 let query_buffer = self
1505 .query_editor
1506 .read(cx)
1507 .buffer()
1508 .read(cx)
1509 .as_singleton()
1510 .expect("query editor should be backed by a singleton buffer");
1511 if enable {
1512 if let Some(regex_language) = self.regex_language.clone() {
1513 query_buffer.update(cx, |query_buffer, cx| {
1514 query_buffer.set_language(Some(regex_language), cx);
1515 })
1516 }
1517 } else {
1518 query_buffer.update(cx, |query_buffer, cx| {
1519 query_buffer.set_language(None, cx);
1520 })
1521 }
1522 }
1523}
1524
1525fn buffer_search_query(
1526 workspace: &mut Workspace,
1527 item: &dyn ItemHandle,
1528 cx: &mut Context<Workspace>,
1529) -> Option<String> {
1530 let buffer_search_bar = workspace
1531 .pane_for(item)
1532 .and_then(|pane| {
1533 pane.read(cx)
1534 .toolbar()
1535 .read(cx)
1536 .item_of_type::<BufferSearchBar>()
1537 })?
1538 .read(cx);
1539 if buffer_search_bar.query_editor_focused() {
1540 let buffer_search_query = buffer_search_bar.query(cx);
1541 if !buffer_search_query.is_empty() {
1542 return Some(buffer_search_query);
1543 }
1544 }
1545 None
1546}
1547
1548impl Default for ProjectSearchBar {
1549 fn default() -> Self {
1550 Self::new()
1551 }
1552}
1553
1554impl ProjectSearchBar {
1555 pub fn new() -> Self {
1556 Self {
1557 active_project_search: None,
1558 subscription: None,
1559 }
1560 }
1561
1562 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1563 if let Some(search_view) = self.active_project_search.as_ref() {
1564 search_view.update(cx, |search_view, cx| {
1565 if !search_view
1566 .replacement_editor
1567 .focus_handle(cx)
1568 .is_focused(window)
1569 {
1570 cx.stop_propagation();
1571 search_view
1572 .prompt_to_save_if_dirty_then_search(window, cx)
1573 .detach_and_log_err(cx);
1574 }
1575 });
1576 }
1577 }
1578
1579 fn tab(&mut self, _: &editor::actions::Tab, window: &mut Window, cx: &mut Context<Self>) {
1580 self.cycle_field(Direction::Next, window, cx);
1581 }
1582
1583 fn backtab(
1584 &mut self,
1585 _: &editor::actions::Backtab,
1586 window: &mut Window,
1587 cx: &mut Context<Self>,
1588 ) {
1589 self.cycle_field(Direction::Prev, window, cx);
1590 }
1591
1592 fn focus_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1593 if let Some(search_view) = self.active_project_search.as_ref() {
1594 search_view.update(cx, |search_view, cx| {
1595 search_view.query_editor.focus_handle(cx).focus(window);
1596 });
1597 }
1598 }
1599
1600 fn cycle_field(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
1601 let active_project_search = match &self.active_project_search {
1602 Some(active_project_search) => active_project_search,
1603
1604 None => {
1605 return;
1606 }
1607 };
1608
1609 active_project_search.update(cx, |project_view, cx| {
1610 let mut views = vec![&project_view.query_editor];
1611 if project_view.replace_enabled {
1612 views.push(&project_view.replacement_editor);
1613 }
1614 if project_view.filters_enabled {
1615 views.extend([
1616 &project_view.included_files_editor,
1617 &project_view.excluded_files_editor,
1618 ]);
1619 }
1620 let current_index = match views
1621 .iter()
1622 .enumerate()
1623 .find(|(_, editor)| editor.focus_handle(cx).is_focused(window))
1624 {
1625 Some((index, _)) => index,
1626 None => return,
1627 };
1628
1629 let new_index = match direction {
1630 Direction::Next => (current_index + 1) % views.len(),
1631 Direction::Prev if current_index == 0 => views.len() - 1,
1632 Direction::Prev => (current_index - 1) % views.len(),
1633 };
1634 let next_focus_handle = views[new_index].focus_handle(cx);
1635 window.focus(&next_focus_handle);
1636 cx.stop_propagation();
1637 });
1638 }
1639
1640 fn toggle_search_option(
1641 &mut self,
1642 option: SearchOptions,
1643 window: &mut Window,
1644 cx: &mut Context<Self>,
1645 ) -> bool {
1646 if self.active_project_search.is_none() {
1647 return false;
1648 }
1649
1650 cx.spawn_in(window, async move |this, cx| {
1651 let task = this.update_in(cx, |this, window, cx| {
1652 let search_view = this.active_project_search.as_ref()?;
1653 search_view.update(cx, |search_view, cx| {
1654 search_view.toggle_search_option(option, cx);
1655 search_view
1656 .entity
1657 .read(cx)
1658 .active_query
1659 .is_some()
1660 .then(|| search_view.prompt_to_save_if_dirty_then_search(window, cx))
1661 })
1662 })?;
1663 if let Some(task) = task {
1664 task.await?;
1665 }
1666 this.update(cx, |_, cx| {
1667 cx.notify();
1668 })?;
1669 anyhow::Ok(())
1670 })
1671 .detach();
1672 true
1673 }
1674
1675 fn toggle_replace(&mut self, _: &ToggleReplace, window: &mut Window, cx: &mut Context<Self>) {
1676 if let Some(search) = &self.active_project_search {
1677 search.update(cx, |this, cx| {
1678 this.replace_enabled = !this.replace_enabled;
1679 let editor_to_focus = if this.replace_enabled {
1680 this.replacement_editor.focus_handle(cx)
1681 } else {
1682 this.query_editor.focus_handle(cx)
1683 };
1684 window.focus(&editor_to_focus);
1685 cx.notify();
1686 });
1687 }
1688 }
1689
1690 fn toggle_filters(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1691 if let Some(search_view) = self.active_project_search.as_ref() {
1692 search_view.update(cx, |search_view, cx| {
1693 search_view.toggle_filters(cx);
1694 search_view
1695 .included_files_editor
1696 .update(cx, |_, cx| cx.notify());
1697 search_view
1698 .excluded_files_editor
1699 .update(cx, |_, cx| cx.notify());
1700 window.refresh();
1701 cx.notify();
1702 });
1703 cx.notify();
1704 true
1705 } else {
1706 false
1707 }
1708 }
1709
1710 fn toggle_opened_only(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1711 if self.active_project_search.is_none() {
1712 return false;
1713 }
1714
1715 cx.spawn_in(window, async move |this, cx| {
1716 let task = this.update_in(cx, |this, window, cx| {
1717 let search_view = this.active_project_search.as_ref()?;
1718 search_view.update(cx, |search_view, cx| {
1719 search_view.toggle_opened_only(window, cx);
1720 search_view
1721 .entity
1722 .read(cx)
1723 .active_query
1724 .is_some()
1725 .then(|| search_view.prompt_to_save_if_dirty_then_search(window, cx))
1726 })
1727 })?;
1728 if let Some(task) = task {
1729 task.await?;
1730 }
1731 this.update(cx, |_, cx| {
1732 cx.notify();
1733 })?;
1734 anyhow::Ok(())
1735 })
1736 .detach();
1737 true
1738 }
1739
1740 fn is_opened_only_enabled(&self, cx: &App) -> bool {
1741 if let Some(search_view) = self.active_project_search.as_ref() {
1742 search_view.read(cx).included_opened_only
1743 } else {
1744 false
1745 }
1746 }
1747
1748 fn move_focus_to_results(&self, window: &mut Window, cx: &mut Context<Self>) {
1749 if let Some(search_view) = self.active_project_search.as_ref() {
1750 search_view.update(cx, |search_view, cx| {
1751 search_view.move_focus_to_results(window, cx);
1752 });
1753 cx.notify();
1754 }
1755 }
1756
1757 fn is_option_enabled(&self, option: SearchOptions, cx: &App) -> bool {
1758 if let Some(search) = self.active_project_search.as_ref() {
1759 search.read(cx).search_options.contains(option)
1760 } else {
1761 false
1762 }
1763 }
1764
1765 fn next_history_query(
1766 &mut self,
1767 _: &NextHistoryQuery,
1768 window: &mut Window,
1769 cx: &mut Context<Self>,
1770 ) {
1771 if let Some(search_view) = self.active_project_search.as_ref() {
1772 search_view.update(cx, |search_view, cx| {
1773 for (editor, kind) in [
1774 (search_view.query_editor.clone(), SearchInputKind::Query),
1775 (
1776 search_view.included_files_editor.clone(),
1777 SearchInputKind::Include,
1778 ),
1779 (
1780 search_view.excluded_files_editor.clone(),
1781 SearchInputKind::Exclude,
1782 ),
1783 ] {
1784 if editor.focus_handle(cx).is_focused(window) {
1785 let new_query = search_view.entity.update(cx, |model, cx| {
1786 let project = model.project.clone();
1787
1788 if let Some(new_query) = project.update(cx, |project, _| {
1789 project
1790 .search_history_mut(kind)
1791 .next(model.cursor_mut(kind))
1792 .map(str::to_string)
1793 }) {
1794 new_query
1795 } else {
1796 model.cursor_mut(kind).reset();
1797 String::new()
1798 }
1799 });
1800 search_view.set_search_editor(kind, &new_query, window, cx);
1801 }
1802 }
1803 });
1804 }
1805 }
1806
1807 fn previous_history_query(
1808 &mut self,
1809 _: &PreviousHistoryQuery,
1810 window: &mut Window,
1811 cx: &mut Context<Self>,
1812 ) {
1813 if let Some(search_view) = self.active_project_search.as_ref() {
1814 search_view.update(cx, |search_view, cx| {
1815 for (editor, kind) in [
1816 (search_view.query_editor.clone(), SearchInputKind::Query),
1817 (
1818 search_view.included_files_editor.clone(),
1819 SearchInputKind::Include,
1820 ),
1821 (
1822 search_view.excluded_files_editor.clone(),
1823 SearchInputKind::Exclude,
1824 ),
1825 ] {
1826 if editor.focus_handle(cx).is_focused(window) {
1827 if editor.read(cx).text(cx).is_empty() {
1828 if let Some(new_query) = search_view
1829 .entity
1830 .read(cx)
1831 .project
1832 .read(cx)
1833 .search_history(kind)
1834 .current(search_view.entity.read(cx).cursor(kind))
1835 .map(str::to_string)
1836 {
1837 search_view.set_search_editor(kind, &new_query, window, cx);
1838 return;
1839 }
1840 }
1841
1842 if let Some(new_query) = search_view.entity.update(cx, |model, cx| {
1843 let project = model.project.clone();
1844 project.update(cx, |project, _| {
1845 project
1846 .search_history_mut(kind)
1847 .previous(model.cursor_mut(kind))
1848 .map(str::to_string)
1849 })
1850 }) {
1851 search_view.set_search_editor(kind, &new_query, window, cx);
1852 }
1853 }
1854 }
1855 });
1856 }
1857 }
1858
1859 fn select_next_match(
1860 &mut self,
1861 _: &SelectNextMatch,
1862 window: &mut Window,
1863 cx: &mut Context<Self>,
1864 ) {
1865 if let Some(search) = self.active_project_search.as_ref() {
1866 search.update(cx, |this, cx| {
1867 this.select_match(Direction::Next, window, cx);
1868 })
1869 }
1870 }
1871
1872 fn select_prev_match(
1873 &mut self,
1874 _: &SelectPreviousMatch,
1875 window: &mut Window,
1876 cx: &mut Context<Self>,
1877 ) {
1878 if let Some(search) = self.active_project_search.as_ref() {
1879 search.update(cx, |this, cx| {
1880 this.select_match(Direction::Prev, window, cx);
1881 })
1882 }
1883 }
1884
1885 fn render_text_input(&self, editor: &Entity<Editor>, cx: &Context<Self>) -> impl IntoElement {
1886 let (color, use_syntax) = if editor.read(cx).read_only(cx) {
1887 (cx.theme().colors().text_disabled, false)
1888 } else {
1889 (cx.theme().colors().text, true)
1890 };
1891 let settings = ThemeSettings::get_global(cx);
1892 let text_style = TextStyle {
1893 color,
1894 font_family: settings.buffer_font.family.clone(),
1895 font_features: settings.buffer_font.features.clone(),
1896 font_fallbacks: settings.buffer_font.fallbacks.clone(),
1897 font_size: rems(0.875).into(),
1898 font_weight: settings.buffer_font.weight,
1899 line_height: relative(1.3),
1900 ..TextStyle::default()
1901 };
1902
1903 let mut editor_style = EditorStyle {
1904 background: cx.theme().colors().toolbar_background,
1905 local_player: cx.theme().players().local(),
1906 text: text_style,
1907 ..EditorStyle::default()
1908 };
1909 if use_syntax {
1910 editor_style.syntax = cx.theme().syntax().clone();
1911 }
1912
1913 EditorElement::new(editor, editor_style)
1914 }
1915}
1916
1917impl Render for ProjectSearchBar {
1918 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1919 let Some(search) = self.active_project_search.clone() else {
1920 return div();
1921 };
1922 let search = search.read(cx);
1923 let focus_handle = search.focus_handle(cx);
1924
1925 let container_width = window.viewport_size().width;
1926 let input_width = SearchInputWidth::calc_width(container_width);
1927
1928 enum BaseStyle {
1929 SingleInput,
1930 MultipleInputs,
1931 }
1932
1933 let input_base_styles = |base_style: BaseStyle| {
1934 h_flex()
1935 .min_w_32()
1936 .map(|div| match base_style {
1937 BaseStyle::SingleInput => div.w(input_width),
1938 BaseStyle::MultipleInputs => div.flex_grow(),
1939 })
1940 .h_8()
1941 .pl_2()
1942 .pr_1()
1943 .py_1()
1944 .border_1()
1945 .border_color(search.border_color_for(InputPanel::Query, cx))
1946 .rounded_lg()
1947 };
1948
1949 let query_column = input_base_styles(BaseStyle::SingleInput)
1950 .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
1951 .on_action(cx.listener(|this, action, window, cx| {
1952 this.previous_history_query(action, window, cx)
1953 }))
1954 .on_action(
1955 cx.listener(|this, action, window, cx| this.next_history_query(action, window, cx)),
1956 )
1957 .child(self.render_text_input(&search.query_editor, cx))
1958 .child(
1959 h_flex()
1960 .gap_1()
1961 .child(SearchOptions::CASE_SENSITIVE.as_button(
1962 self.is_option_enabled(SearchOptions::CASE_SENSITIVE, cx),
1963 focus_handle.clone(),
1964 cx.listener(|this, _, window, cx| {
1965 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
1966 }),
1967 ))
1968 .child(SearchOptions::WHOLE_WORD.as_button(
1969 self.is_option_enabled(SearchOptions::WHOLE_WORD, cx),
1970 focus_handle.clone(),
1971 cx.listener(|this, _, window, cx| {
1972 this.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
1973 }),
1974 ))
1975 .child(SearchOptions::REGEX.as_button(
1976 self.is_option_enabled(SearchOptions::REGEX, cx),
1977 focus_handle.clone(),
1978 cx.listener(|this, _, window, cx| {
1979 this.toggle_search_option(SearchOptions::REGEX, window, cx);
1980 }),
1981 )),
1982 );
1983
1984 let mode_column = h_flex()
1985 .gap_1()
1986 .child(
1987 IconButton::new("project-search-filter-button", IconName::Filter)
1988 .shape(IconButtonShape::Square)
1989 .tooltip(|window, cx| {
1990 Tooltip::for_action("Toggle Filters", &ToggleFilters, window, cx)
1991 })
1992 .on_click(cx.listener(|this, _, window, cx| {
1993 this.toggle_filters(window, cx);
1994 }))
1995 .toggle_state(
1996 self.active_project_search
1997 .as_ref()
1998 .map(|search| search.read(cx).filters_enabled)
1999 .unwrap_or_default(),
2000 )
2001 .tooltip({
2002 let focus_handle = focus_handle.clone();
2003 move |window, cx| {
2004 Tooltip::for_action_in(
2005 "Toggle Filters",
2006 &ToggleFilters,
2007 &focus_handle,
2008 window,
2009 cx,
2010 )
2011 }
2012 }),
2013 )
2014 .child(
2015 IconButton::new("project-search-toggle-replace", IconName::Replace)
2016 .shape(IconButtonShape::Square)
2017 .on_click(cx.listener(|this, _, window, cx| {
2018 this.toggle_replace(&ToggleReplace, window, cx);
2019 }))
2020 .toggle_state(
2021 self.active_project_search
2022 .as_ref()
2023 .map(|search| search.read(cx).replace_enabled)
2024 .unwrap_or_default(),
2025 )
2026 .tooltip({
2027 let focus_handle = focus_handle.clone();
2028 move |window, cx| {
2029 Tooltip::for_action_in(
2030 "Toggle Replace",
2031 &ToggleReplace,
2032 &focus_handle,
2033 window,
2034 cx,
2035 )
2036 }
2037 }),
2038 );
2039
2040 let limit_reached = search.entity.read(cx).limit_reached;
2041
2042 let match_text = search
2043 .active_match_index
2044 .and_then(|index| {
2045 let index = index + 1;
2046 let match_quantity = search.entity.read(cx).match_ranges.len();
2047 if match_quantity > 0 {
2048 debug_assert!(match_quantity >= index);
2049 if limit_reached {
2050 Some(format!("{index}/{match_quantity}+"))
2051 } else {
2052 Some(format!("{index}/{match_quantity}"))
2053 }
2054 } else {
2055 None
2056 }
2057 })
2058 .unwrap_or_else(|| "0/0".to_string());
2059
2060 let matches_column = h_flex()
2061 .pl_2()
2062 .ml_2()
2063 .border_l_1()
2064 .border_color(cx.theme().colors().border_variant)
2065 .child(
2066 IconButton::new("project-search-prev-match", IconName::ChevronLeft)
2067 .shape(IconButtonShape::Square)
2068 .disabled(search.active_match_index.is_none())
2069 .on_click(cx.listener(|this, _, window, cx| {
2070 if let Some(search) = this.active_project_search.as_ref() {
2071 search.update(cx, |this, cx| {
2072 this.select_match(Direction::Prev, window, cx);
2073 })
2074 }
2075 }))
2076 .tooltip({
2077 let focus_handle = focus_handle.clone();
2078 move |window, cx| {
2079 Tooltip::for_action_in(
2080 "Go To Previous Match",
2081 &SelectPreviousMatch,
2082 &focus_handle,
2083 window,
2084 cx,
2085 )
2086 }
2087 }),
2088 )
2089 .child(
2090 IconButton::new("project-search-next-match", IconName::ChevronRight)
2091 .shape(IconButtonShape::Square)
2092 .disabled(search.active_match_index.is_none())
2093 .on_click(cx.listener(|this, _, window, cx| {
2094 if let Some(search) = this.active_project_search.as_ref() {
2095 search.update(cx, |this, cx| {
2096 this.select_match(Direction::Next, window, cx);
2097 })
2098 }
2099 }))
2100 .tooltip({
2101 let focus_handle = focus_handle.clone();
2102 move |window, cx| {
2103 Tooltip::for_action_in(
2104 "Go To Next Match",
2105 &SelectNextMatch,
2106 &focus_handle,
2107 window,
2108 cx,
2109 )
2110 }
2111 }),
2112 )
2113 .child(
2114 div()
2115 .id("matches")
2116 .ml_1()
2117 .child(Label::new(match_text).size(LabelSize::Small).color(
2118 if search.active_match_index.is_some() {
2119 Color::Default
2120 } else {
2121 Color::Disabled
2122 },
2123 ))
2124 .when(limit_reached, |el| {
2125 el.tooltip(Tooltip::text(
2126 "Search limits reached.\nTry narrowing your search.",
2127 ))
2128 }),
2129 );
2130
2131 let search_line = h_flex()
2132 .w_full()
2133 .gap_2()
2134 .child(query_column)
2135 .child(h_flex().min_w_64().child(mode_column).child(matches_column));
2136
2137 let replace_line = search.replace_enabled.then(|| {
2138 let replace_column = input_base_styles(BaseStyle::SingleInput)
2139 .child(self.render_text_input(&search.replacement_editor, cx));
2140
2141 let focus_handle = search.replacement_editor.read(cx).focus_handle(cx);
2142
2143 let replace_actions =
2144 h_flex()
2145 .min_w_64()
2146 .gap_1()
2147 .when(search.replace_enabled, |this| {
2148 this.child(
2149 IconButton::new("project-search-replace-next", IconName::ReplaceNext)
2150 .shape(IconButtonShape::Square)
2151 .on_click(cx.listener(|this, _, window, cx| {
2152 if let Some(search) = this.active_project_search.as_ref() {
2153 search.update(cx, |this, cx| {
2154 this.replace_next(&ReplaceNext, window, cx);
2155 })
2156 }
2157 }))
2158 .tooltip({
2159 let focus_handle = focus_handle.clone();
2160 move |window, cx| {
2161 Tooltip::for_action_in(
2162 "Replace Next Match",
2163 &ReplaceNext,
2164 &focus_handle,
2165 window,
2166 cx,
2167 )
2168 }
2169 }),
2170 )
2171 .child(
2172 IconButton::new("project-search-replace-all", IconName::ReplaceAll)
2173 .shape(IconButtonShape::Square)
2174 .on_click(cx.listener(|this, _, window, cx| {
2175 if let Some(search) = this.active_project_search.as_ref() {
2176 search.update(cx, |this, cx| {
2177 this.replace_all(&ReplaceAll, window, cx);
2178 })
2179 }
2180 }))
2181 .tooltip({
2182 let focus_handle = focus_handle.clone();
2183 move |window, cx| {
2184 Tooltip::for_action_in(
2185 "Replace All Matches",
2186 &ReplaceAll,
2187 &focus_handle,
2188 window,
2189 cx,
2190 )
2191 }
2192 }),
2193 )
2194 });
2195
2196 h_flex()
2197 .w_full()
2198 .gap_2()
2199 .child(replace_column)
2200 .child(replace_actions)
2201 });
2202
2203 let filter_line = search.filters_enabled.then(|| {
2204 h_flex()
2205 .w_full()
2206 .gap_2()
2207 .child(
2208 h_flex()
2209 .gap_2()
2210 .w(input_width)
2211 .child(
2212 input_base_styles(BaseStyle::MultipleInputs)
2213 .on_action(cx.listener(|this, action, window, cx| {
2214 this.previous_history_query(action, window, cx)
2215 }))
2216 .on_action(cx.listener(|this, action, window, cx| {
2217 this.next_history_query(action, window, cx)
2218 }))
2219 .child(self.render_text_input(&search.included_files_editor, cx)),
2220 )
2221 .child(
2222 input_base_styles(BaseStyle::MultipleInputs)
2223 .on_action(cx.listener(|this, action, window, cx| {
2224 this.previous_history_query(action, window, cx)
2225 }))
2226 .on_action(cx.listener(|this, action, window, cx| {
2227 this.next_history_query(action, window, cx)
2228 }))
2229 .child(self.render_text_input(&search.excluded_files_editor, cx)),
2230 ),
2231 )
2232 .child(
2233 h_flex()
2234 .min_w_64()
2235 .gap_1()
2236 .child(
2237 IconButton::new("project-search-opened-only", IconName::FileSearch)
2238 .shape(IconButtonShape::Square)
2239 .toggle_state(self.is_opened_only_enabled(cx))
2240 .tooltip(Tooltip::text("Only Search Open Files"))
2241 .on_click(cx.listener(|this, _, window, cx| {
2242 this.toggle_opened_only(window, cx);
2243 })),
2244 )
2245 .child(
2246 SearchOptions::INCLUDE_IGNORED.as_button(
2247 search
2248 .search_options
2249 .contains(SearchOptions::INCLUDE_IGNORED),
2250 focus_handle.clone(),
2251 cx.listener(|this, _, window, cx| {
2252 this.toggle_search_option(
2253 SearchOptions::INCLUDE_IGNORED,
2254 window,
2255 cx,
2256 );
2257 }),
2258 ),
2259 ),
2260 )
2261 });
2262
2263 let mut key_context = KeyContext::default();
2264
2265 key_context.add("ProjectSearchBar");
2266
2267 if search
2268 .replacement_editor
2269 .focus_handle(cx)
2270 .is_focused(window)
2271 {
2272 key_context.add("in_replace");
2273 }
2274
2275 v_flex()
2276 .py(px(1.0))
2277 .key_context(key_context)
2278 .on_action(cx.listener(|this, _: &ToggleFocus, window, cx| {
2279 this.move_focus_to_results(window, cx)
2280 }))
2281 .on_action(cx.listener(|this, _: &ToggleFilters, window, cx| {
2282 this.toggle_filters(window, cx);
2283 }))
2284 .capture_action(cx.listener(|this, action, window, cx| {
2285 this.tab(action, window, cx);
2286 cx.stop_propagation();
2287 }))
2288 .capture_action(cx.listener(|this, action, window, cx| {
2289 this.backtab(action, window, cx);
2290 cx.stop_propagation();
2291 }))
2292 .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
2293 .on_action(cx.listener(|this, action, window, cx| {
2294 this.toggle_replace(action, window, cx);
2295 }))
2296 .on_action(cx.listener(|this, _: &ToggleWholeWord, window, cx| {
2297 this.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
2298 }))
2299 .on_action(cx.listener(|this, _: &ToggleCaseSensitive, window, cx| {
2300 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
2301 }))
2302 .on_action(cx.listener(|this, action, window, cx| {
2303 if let Some(search) = this.active_project_search.as_ref() {
2304 search.update(cx, |this, cx| {
2305 this.replace_next(action, window, cx);
2306 })
2307 }
2308 }))
2309 .on_action(cx.listener(|this, action, window, cx| {
2310 if let Some(search) = this.active_project_search.as_ref() {
2311 search.update(cx, |this, cx| {
2312 this.replace_all(action, window, cx);
2313 })
2314 }
2315 }))
2316 .when(search.filters_enabled, |this| {
2317 this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, window, cx| {
2318 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, window, cx);
2319 }))
2320 })
2321 .on_action(cx.listener(Self::select_next_match))
2322 .on_action(cx.listener(Self::select_prev_match))
2323 .gap_2()
2324 .w_full()
2325 .child(search_line)
2326 .children(replace_line)
2327 .children(filter_line)
2328 }
2329}
2330
2331impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
2332
2333impl ToolbarItemView for ProjectSearchBar {
2334 fn set_active_pane_item(
2335 &mut self,
2336 active_pane_item: Option<&dyn ItemHandle>,
2337 _: &mut Window,
2338 cx: &mut Context<Self>,
2339 ) -> ToolbarItemLocation {
2340 cx.notify();
2341 self.subscription = None;
2342 self.active_project_search = None;
2343 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
2344 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
2345 self.active_project_search = Some(search);
2346 ToolbarItemLocation::PrimaryLeft {}
2347 } else {
2348 ToolbarItemLocation::Hidden
2349 }
2350 }
2351}
2352
2353fn register_workspace_action<A: Action>(
2354 workspace: &mut Workspace,
2355 callback: fn(&mut ProjectSearchBar, &A, &mut Window, &mut Context<ProjectSearchBar>),
2356) {
2357 workspace.register_action(move |workspace, action: &A, window, cx| {
2358 if workspace.has_active_modal(window, cx) {
2359 cx.propagate();
2360 return;
2361 }
2362
2363 workspace.active_pane().update(cx, |pane, cx| {
2364 pane.toolbar().update(cx, move |workspace, cx| {
2365 if let Some(search_bar) = workspace.item_of_type::<ProjectSearchBar>() {
2366 search_bar.update(cx, move |search_bar, cx| {
2367 if search_bar.active_project_search.is_some() {
2368 callback(search_bar, action, window, cx);
2369 cx.notify();
2370 } else {
2371 cx.propagate();
2372 }
2373 });
2374 }
2375 });
2376 })
2377 });
2378}
2379
2380fn register_workspace_action_for_present_search<A: Action>(
2381 workspace: &mut Workspace,
2382 callback: fn(&mut Workspace, &A, &mut Window, &mut Context<Workspace>),
2383) {
2384 workspace.register_action(move |workspace, action: &A, window, cx| {
2385 if workspace.has_active_modal(window, cx) {
2386 cx.propagate();
2387 return;
2388 }
2389
2390 let should_notify = workspace
2391 .active_pane()
2392 .read(cx)
2393 .toolbar()
2394 .read(cx)
2395 .item_of_type::<ProjectSearchBar>()
2396 .map(|search_bar| search_bar.read(cx).active_project_search.is_some())
2397 .unwrap_or(false);
2398 if should_notify {
2399 callback(workspace, action, window, cx);
2400 cx.notify();
2401 } else {
2402 cx.propagate();
2403 }
2404 });
2405}
2406
2407#[cfg(any(test, feature = "test-support"))]
2408pub fn perform_project_search(
2409 search_view: &Entity<ProjectSearchView>,
2410 text: impl Into<std::sync::Arc<str>>,
2411 cx: &mut gpui::VisualTestContext,
2412) {
2413 cx.run_until_parked();
2414 search_view.update_in(cx, |search_view, window, cx| {
2415 search_view.query_editor.update(cx, |query_editor, cx| {
2416 query_editor.set_text(text, window, cx)
2417 });
2418 search_view.search(cx);
2419 });
2420 cx.run_until_parked();
2421}
2422
2423#[cfg(test)]
2424pub mod tests {
2425 use std::{ops::Deref as _, sync::Arc};
2426
2427 use super::*;
2428 use editor::{DisplayPoint, display_map::DisplayRow};
2429 use gpui::{Action, TestAppContext, VisualTestContext, WindowHandle};
2430 use project::FakeFs;
2431 use serde_json::json;
2432 use settings::SettingsStore;
2433 use util::path;
2434 use workspace::DeploySearch;
2435
2436 #[gpui::test]
2437 async fn test_project_search(cx: &mut TestAppContext) {
2438 init_test(cx);
2439
2440 let fs = FakeFs::new(cx.background_executor.clone());
2441 fs.insert_tree(
2442 path!("/dir"),
2443 json!({
2444 "one.rs": "const ONE: usize = 1;",
2445 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2446 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2447 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2448 }),
2449 )
2450 .await;
2451 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2452 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2453 let workspace = window.root(cx).unwrap();
2454 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
2455 let search_view = cx.add_window(|window, cx| {
2456 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
2457 });
2458
2459 perform_search(search_view, "TWO", cx);
2460 search_view.update(cx, |search_view, window, cx| {
2461 assert_eq!(
2462 search_view
2463 .results_editor
2464 .update(cx, |editor, cx| editor.display_text(cx)),
2465 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
2466 );
2467 let match_background_color = cx.theme().colors().search_match_background;
2468 assert_eq!(
2469 search_view
2470 .results_editor
2471 .update(cx, |editor, cx| editor.all_text_background_highlights(window, cx)),
2472 &[
2473 (
2474 DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35),
2475 match_background_color
2476 ),
2477 (
2478 DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40),
2479 match_background_color
2480 ),
2481 (
2482 DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9),
2483 match_background_color
2484 )
2485 ]
2486 );
2487 assert_eq!(search_view.active_match_index, Some(0));
2488 assert_eq!(
2489 search_view
2490 .results_editor
2491 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2492 [DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35)]
2493 );
2494
2495 search_view.select_match(Direction::Next, window, cx);
2496 }).unwrap();
2497
2498 search_view
2499 .update(cx, |search_view, window, cx| {
2500 assert_eq!(search_view.active_match_index, Some(1));
2501 assert_eq!(
2502 search_view
2503 .results_editor
2504 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2505 [DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40)]
2506 );
2507 search_view.select_match(Direction::Next, window, cx);
2508 })
2509 .unwrap();
2510
2511 search_view
2512 .update(cx, |search_view, window, cx| {
2513 assert_eq!(search_view.active_match_index, Some(2));
2514 assert_eq!(
2515 search_view
2516 .results_editor
2517 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2518 [DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9)]
2519 );
2520 search_view.select_match(Direction::Next, window, cx);
2521 })
2522 .unwrap();
2523
2524 search_view
2525 .update(cx, |search_view, window, cx| {
2526 assert_eq!(search_view.active_match_index, Some(0));
2527 assert_eq!(
2528 search_view
2529 .results_editor
2530 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2531 [DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35)]
2532 );
2533 search_view.select_match(Direction::Prev, window, cx);
2534 })
2535 .unwrap();
2536
2537 search_view
2538 .update(cx, |search_view, window, cx| {
2539 assert_eq!(search_view.active_match_index, Some(2));
2540 assert_eq!(
2541 search_view
2542 .results_editor
2543 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2544 [DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9)]
2545 );
2546 search_view.select_match(Direction::Prev, window, cx);
2547 })
2548 .unwrap();
2549
2550 search_view
2551 .update(cx, |search_view, _, cx| {
2552 assert_eq!(search_view.active_match_index, Some(1));
2553 assert_eq!(
2554 search_view
2555 .results_editor
2556 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2557 [DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40)]
2558 );
2559 })
2560 .unwrap();
2561 }
2562
2563 #[gpui::test]
2564 async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
2565 init_test(cx);
2566
2567 let fs = FakeFs::new(cx.background_executor.clone());
2568 fs.insert_tree(
2569 "/dir",
2570 json!({
2571 "one.rs": "const ONE: usize = 1;",
2572 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2573 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2574 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2575 }),
2576 )
2577 .await;
2578 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2579 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2580 let workspace = window;
2581 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2582
2583 let active_item = cx.read(|cx| {
2584 workspace
2585 .read(cx)
2586 .unwrap()
2587 .active_pane()
2588 .read(cx)
2589 .active_item()
2590 .and_then(|item| item.downcast::<ProjectSearchView>())
2591 });
2592 assert!(
2593 active_item.is_none(),
2594 "Expected no search panel to be active"
2595 );
2596
2597 window
2598 .update(cx, move |workspace, window, cx| {
2599 assert_eq!(workspace.panes().len(), 1);
2600 workspace.panes()[0].update(cx, |pane, cx| {
2601 pane.toolbar()
2602 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2603 });
2604
2605 ProjectSearchView::deploy_search(
2606 workspace,
2607 &workspace::DeploySearch::find(),
2608 window,
2609 cx,
2610 )
2611 })
2612 .unwrap();
2613
2614 let Some(search_view) = cx.read(|cx| {
2615 workspace
2616 .read(cx)
2617 .unwrap()
2618 .active_pane()
2619 .read(cx)
2620 .active_item()
2621 .and_then(|item| item.downcast::<ProjectSearchView>())
2622 }) else {
2623 panic!("Search view expected to appear after new search event trigger")
2624 };
2625
2626 cx.spawn(|mut cx| async move {
2627 window
2628 .update(&mut cx, |_, window, cx| {
2629 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2630 })
2631 .unwrap();
2632 })
2633 .detach();
2634 cx.background_executor.run_until_parked();
2635 window
2636 .update(cx, |_, window, cx| {
2637 search_view.update(cx, |search_view, cx| {
2638 assert!(
2639 search_view.query_editor.focus_handle(cx).is_focused(window),
2640 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2641 );
2642 });
2643 }).unwrap();
2644
2645 window
2646 .update(cx, |_, window, cx| {
2647 search_view.update(cx, |search_view, cx| {
2648 let query_editor = &search_view.query_editor;
2649 assert!(
2650 query_editor.focus_handle(cx).is_focused(window),
2651 "Search view should be focused after the new search view is activated",
2652 );
2653 let query_text = query_editor.read(cx).text(cx);
2654 assert!(
2655 query_text.is_empty(),
2656 "New search query should be empty but got '{query_text}'",
2657 );
2658 let results_text = search_view
2659 .results_editor
2660 .update(cx, |editor, cx| editor.display_text(cx));
2661 assert!(
2662 results_text.is_empty(),
2663 "Empty search view should have no results but got '{results_text}'"
2664 );
2665 });
2666 })
2667 .unwrap();
2668
2669 window
2670 .update(cx, |_, window, cx| {
2671 search_view.update(cx, |search_view, cx| {
2672 search_view.query_editor.update(cx, |query_editor, cx| {
2673 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
2674 });
2675 search_view.search(cx);
2676 });
2677 })
2678 .unwrap();
2679 cx.background_executor.run_until_parked();
2680 window
2681 .update(cx, |_, window, cx| {
2682 search_view.update(cx, |search_view, cx| {
2683 let results_text = search_view
2684 .results_editor
2685 .update(cx, |editor, cx| editor.display_text(cx));
2686 assert!(
2687 results_text.is_empty(),
2688 "Search view for mismatching query should have no results but got '{results_text}'"
2689 );
2690 assert!(
2691 search_view.query_editor.focus_handle(cx).is_focused(window),
2692 "Search view should be focused after mismatching query had been used in search",
2693 );
2694 });
2695 }).unwrap();
2696
2697 cx.spawn(|mut cx| async move {
2698 window.update(&mut cx, |_, window, cx| {
2699 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2700 })
2701 })
2702 .detach();
2703 cx.background_executor.run_until_parked();
2704 window.update(cx, |_, window, cx| {
2705 search_view.update(cx, |search_view, cx| {
2706 assert!(
2707 search_view.query_editor.focus_handle(cx).is_focused(window),
2708 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2709 );
2710 });
2711 }).unwrap();
2712
2713 window
2714 .update(cx, |_, window, cx| {
2715 search_view.update(cx, |search_view, cx| {
2716 search_view.query_editor.update(cx, |query_editor, cx| {
2717 query_editor.set_text("TWO", window, cx)
2718 });
2719 search_view.search(cx);
2720 });
2721 })
2722 .unwrap();
2723 cx.background_executor.run_until_parked();
2724 window.update(cx, |_, window, cx| {
2725 search_view.update(cx, |search_view, cx| {
2726 assert_eq!(
2727 search_view
2728 .results_editor
2729 .update(cx, |editor, cx| editor.display_text(cx)),
2730 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2731 "Search view results should match the query"
2732 );
2733 assert!(
2734 search_view.results_editor.focus_handle(cx).is_focused(window),
2735 "Search view with mismatching query should be focused after search results are available",
2736 );
2737 });
2738 }).unwrap();
2739 cx.spawn(|mut cx| async move {
2740 window
2741 .update(&mut cx, |_, window, cx| {
2742 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2743 })
2744 .unwrap();
2745 })
2746 .detach();
2747 cx.background_executor.run_until_parked();
2748 window.update(cx, |_, window, cx| {
2749 search_view.update(cx, |search_view, cx| {
2750 assert!(
2751 search_view.results_editor.focus_handle(cx).is_focused(window),
2752 "Search view with matching query should still have its results editor focused after the toggle focus event",
2753 );
2754 });
2755 }).unwrap();
2756
2757 workspace
2758 .update(cx, |workspace, window, cx| {
2759 ProjectSearchView::deploy_search(
2760 workspace,
2761 &workspace::DeploySearch::find(),
2762 window,
2763 cx,
2764 )
2765 })
2766 .unwrap();
2767 window.update(cx, |_, window, cx| {
2768 search_view.update(cx, |search_view, cx| {
2769 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");
2770 assert_eq!(
2771 search_view
2772 .results_editor
2773 .update(cx, |editor, cx| editor.display_text(cx)),
2774 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2775 "Results should be unchanged after search view 2nd open in a row"
2776 );
2777 assert!(
2778 search_view.query_editor.focus_handle(cx).is_focused(window),
2779 "Focus should be moved into query editor again after search view 2nd open in a row"
2780 );
2781 });
2782 }).unwrap();
2783
2784 cx.spawn(|mut cx| async move {
2785 window
2786 .update(&mut cx, |_, window, cx| {
2787 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2788 })
2789 .unwrap();
2790 })
2791 .detach();
2792 cx.background_executor.run_until_parked();
2793 window.update(cx, |_, window, cx| {
2794 search_view.update(cx, |search_view, cx| {
2795 assert!(
2796 search_view.results_editor.focus_handle(cx).is_focused(window),
2797 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2798 );
2799 });
2800 }).unwrap();
2801 }
2802
2803 #[gpui::test]
2804 async fn test_filters_consider_toggle_state(cx: &mut TestAppContext) {
2805 init_test(cx);
2806
2807 let fs = FakeFs::new(cx.background_executor.clone());
2808 fs.insert_tree(
2809 "/dir",
2810 json!({
2811 "one.rs": "const ONE: usize = 1;",
2812 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2813 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2814 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2815 }),
2816 )
2817 .await;
2818 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2819 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2820 let workspace = window;
2821 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2822
2823 window
2824 .update(cx, move |workspace, window, cx| {
2825 workspace.panes()[0].update(cx, |pane, cx| {
2826 pane.toolbar()
2827 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2828 });
2829
2830 ProjectSearchView::deploy_search(
2831 workspace,
2832 &workspace::DeploySearch::find(),
2833 window,
2834 cx,
2835 )
2836 })
2837 .unwrap();
2838
2839 let Some(search_view) = cx.read(|cx| {
2840 workspace
2841 .read(cx)
2842 .unwrap()
2843 .active_pane()
2844 .read(cx)
2845 .active_item()
2846 .and_then(|item| item.downcast::<ProjectSearchView>())
2847 }) else {
2848 panic!("Search view expected to appear after new search event trigger")
2849 };
2850
2851 cx.spawn(|mut cx| async move {
2852 window
2853 .update(&mut cx, |_, window, cx| {
2854 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2855 })
2856 .unwrap();
2857 })
2858 .detach();
2859 cx.background_executor.run_until_parked();
2860
2861 window
2862 .update(cx, |_, window, cx| {
2863 search_view.update(cx, |search_view, cx| {
2864 search_view.query_editor.update(cx, |query_editor, cx| {
2865 query_editor.set_text("const FOUR", window, cx)
2866 });
2867 search_view.toggle_filters(cx);
2868 search_view
2869 .excluded_files_editor
2870 .update(cx, |exclude_editor, cx| {
2871 exclude_editor.set_text("four.rs", window, cx)
2872 });
2873 search_view.search(cx);
2874 });
2875 })
2876 .unwrap();
2877 cx.background_executor.run_until_parked();
2878 window
2879 .update(cx, |_, _, cx| {
2880 search_view.update(cx, |search_view, cx| {
2881 let results_text = search_view
2882 .results_editor
2883 .update(cx, |editor, cx| editor.display_text(cx));
2884 assert!(
2885 results_text.is_empty(),
2886 "Search view for query with the only match in an excluded file should have no results but got '{results_text}'"
2887 );
2888 });
2889 }).unwrap();
2890
2891 cx.spawn(|mut cx| async move {
2892 window.update(&mut cx, |_, window, cx| {
2893 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2894 })
2895 })
2896 .detach();
2897 cx.background_executor.run_until_parked();
2898
2899 window
2900 .update(cx, |_, _, cx| {
2901 search_view.update(cx, |search_view, cx| {
2902 search_view.toggle_filters(cx);
2903 search_view.search(cx);
2904 });
2905 })
2906 .unwrap();
2907 cx.background_executor.run_until_parked();
2908 window
2909 .update(cx, |_, _, cx| {
2910 search_view.update(cx, |search_view, cx| {
2911 assert_eq!(
2912 search_view
2913 .results_editor
2914 .update(cx, |editor, cx| editor.display_text(cx)),
2915 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
2916 "Search view results should contain the queried result in the previously excluded file with filters toggled off"
2917 );
2918 });
2919 })
2920 .unwrap();
2921 }
2922
2923 #[gpui::test]
2924 async fn test_new_project_search_focus(cx: &mut TestAppContext) {
2925 init_test(cx);
2926
2927 let fs = FakeFs::new(cx.background_executor.clone());
2928 fs.insert_tree(
2929 path!("/dir"),
2930 json!({
2931 "one.rs": "const ONE: usize = 1;",
2932 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2933 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2934 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2935 }),
2936 )
2937 .await;
2938 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2939 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2940 let workspace = window;
2941 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2942
2943 let active_item = cx.read(|cx| {
2944 workspace
2945 .read(cx)
2946 .unwrap()
2947 .active_pane()
2948 .read(cx)
2949 .active_item()
2950 .and_then(|item| item.downcast::<ProjectSearchView>())
2951 });
2952 assert!(
2953 active_item.is_none(),
2954 "Expected no search panel to be active"
2955 );
2956
2957 window
2958 .update(cx, move |workspace, window, cx| {
2959 assert_eq!(workspace.panes().len(), 1);
2960 workspace.panes()[0].update(cx, |pane, cx| {
2961 pane.toolbar()
2962 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2963 });
2964
2965 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
2966 })
2967 .unwrap();
2968
2969 let Some(search_view) = cx.read(|cx| {
2970 workspace
2971 .read(cx)
2972 .unwrap()
2973 .active_pane()
2974 .read(cx)
2975 .active_item()
2976 .and_then(|item| item.downcast::<ProjectSearchView>())
2977 }) else {
2978 panic!("Search view expected to appear after new search event trigger")
2979 };
2980
2981 cx.spawn(|mut cx| async move {
2982 window
2983 .update(&mut cx, |_, window, cx| {
2984 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2985 })
2986 .unwrap();
2987 })
2988 .detach();
2989 cx.background_executor.run_until_parked();
2990
2991 window.update(cx, |_, window, cx| {
2992 search_view.update(cx, |search_view, cx| {
2993 assert!(
2994 search_view.query_editor.focus_handle(cx).is_focused(window),
2995 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2996 );
2997 });
2998 }).unwrap();
2999
3000 window
3001 .update(cx, |_, window, cx| {
3002 search_view.update(cx, |search_view, cx| {
3003 let query_editor = &search_view.query_editor;
3004 assert!(
3005 query_editor.focus_handle(cx).is_focused(window),
3006 "Search view should be focused after the new search view is activated",
3007 );
3008 let query_text = query_editor.read(cx).text(cx);
3009 assert!(
3010 query_text.is_empty(),
3011 "New search query should be empty but got '{query_text}'",
3012 );
3013 let results_text = search_view
3014 .results_editor
3015 .update(cx, |editor, cx| editor.display_text(cx));
3016 assert!(
3017 results_text.is_empty(),
3018 "Empty search view should have no results but got '{results_text}'"
3019 );
3020 });
3021 })
3022 .unwrap();
3023
3024 window
3025 .update(cx, |_, window, cx| {
3026 search_view.update(cx, |search_view, cx| {
3027 search_view.query_editor.update(cx, |query_editor, cx| {
3028 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
3029 });
3030 search_view.search(cx);
3031 });
3032 })
3033 .unwrap();
3034
3035 cx.background_executor.run_until_parked();
3036 window
3037 .update(cx, |_, window, cx| {
3038 search_view.update(cx, |search_view, cx| {
3039 let results_text = search_view
3040 .results_editor
3041 .update(cx, |editor, cx| editor.display_text(cx));
3042 assert!(
3043 results_text.is_empty(),
3044 "Search view for mismatching query should have no results but got '{results_text}'"
3045 );
3046 assert!(
3047 search_view.query_editor.focus_handle(cx).is_focused(window),
3048 "Search view should be focused after mismatching query had been used in search",
3049 );
3050 });
3051 })
3052 .unwrap();
3053 cx.spawn(|mut cx| async move {
3054 window.update(&mut cx, |_, window, cx| {
3055 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3056 })
3057 })
3058 .detach();
3059 cx.background_executor.run_until_parked();
3060 window.update(cx, |_, window, cx| {
3061 search_view.update(cx, |search_view, cx| {
3062 assert!(
3063 search_view.query_editor.focus_handle(cx).is_focused(window),
3064 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
3065 );
3066 });
3067 }).unwrap();
3068
3069 window
3070 .update(cx, |_, window, cx| {
3071 search_view.update(cx, |search_view, cx| {
3072 search_view.query_editor.update(cx, |query_editor, cx| {
3073 query_editor.set_text("TWO", window, cx)
3074 });
3075 search_view.search(cx);
3076 })
3077 })
3078 .unwrap();
3079 cx.background_executor.run_until_parked();
3080 window.update(cx, |_, window, cx|
3081 search_view.update(cx, |search_view, cx| {
3082 assert_eq!(
3083 search_view
3084 .results_editor
3085 .update(cx, |editor, cx| editor.display_text(cx)),
3086 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3087 "Search view results should match the query"
3088 );
3089 assert!(
3090 search_view.results_editor.focus_handle(cx).is_focused(window),
3091 "Search view with mismatching query should be focused after search results are available",
3092 );
3093 })).unwrap();
3094 cx.spawn(|mut cx| async move {
3095 window
3096 .update(&mut cx, |_, window, cx| {
3097 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3098 })
3099 .unwrap();
3100 })
3101 .detach();
3102 cx.background_executor.run_until_parked();
3103 window.update(cx, |_, window, cx| {
3104 search_view.update(cx, |search_view, cx| {
3105 assert!(
3106 search_view.results_editor.focus_handle(cx).is_focused(window),
3107 "Search view with matching query should still have its results editor focused after the toggle focus event",
3108 );
3109 });
3110 }).unwrap();
3111
3112 workspace
3113 .update(cx, |workspace, window, cx| {
3114 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3115 })
3116 .unwrap();
3117 cx.background_executor.run_until_parked();
3118 let Some(search_view_2) = cx.read(|cx| {
3119 workspace
3120 .read(cx)
3121 .unwrap()
3122 .active_pane()
3123 .read(cx)
3124 .active_item()
3125 .and_then(|item| item.downcast::<ProjectSearchView>())
3126 }) else {
3127 panic!("Search view expected to appear after new search event trigger")
3128 };
3129 assert!(
3130 search_view_2 != search_view,
3131 "New search view should be open after `workspace::NewSearch` event"
3132 );
3133
3134 window.update(cx, |_, window, cx| {
3135 search_view.update(cx, |search_view, cx| {
3136 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
3137 assert_eq!(
3138 search_view
3139 .results_editor
3140 .update(cx, |editor, cx| editor.display_text(cx)),
3141 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3142 "Results of the first search view should not update too"
3143 );
3144 assert!(
3145 !search_view.query_editor.focus_handle(cx).is_focused(window),
3146 "Focus should be moved away from the first search view"
3147 );
3148 });
3149 }).unwrap();
3150
3151 window.update(cx, |_, window, cx| {
3152 search_view_2.update(cx, |search_view_2, cx| {
3153 assert_eq!(
3154 search_view_2.query_editor.read(cx).text(cx),
3155 "two",
3156 "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
3157 );
3158 assert_eq!(
3159 search_view_2
3160 .results_editor
3161 .update(cx, |editor, cx| editor.display_text(cx)),
3162 "",
3163 "No search results should be in the 2nd view yet, as we did not spawn a search for it"
3164 );
3165 assert!(
3166 search_view_2.query_editor.focus_handle(cx).is_focused(window),
3167 "Focus should be moved into query editor of the new window"
3168 );
3169 });
3170 }).unwrap();
3171
3172 window
3173 .update(cx, |_, window, cx| {
3174 search_view_2.update(cx, |search_view_2, cx| {
3175 search_view_2.query_editor.update(cx, |query_editor, cx| {
3176 query_editor.set_text("FOUR", window, cx)
3177 });
3178 search_view_2.search(cx);
3179 });
3180 })
3181 .unwrap();
3182
3183 cx.background_executor.run_until_parked();
3184 window.update(cx, |_, window, cx| {
3185 search_view_2.update(cx, |search_view_2, cx| {
3186 assert_eq!(
3187 search_view_2
3188 .results_editor
3189 .update(cx, |editor, cx| editor.display_text(cx)),
3190 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
3191 "New search view with the updated query should have new search results"
3192 );
3193 assert!(
3194 search_view_2.results_editor.focus_handle(cx).is_focused(window),
3195 "Search view with mismatching query should be focused after search results are available",
3196 );
3197 });
3198 }).unwrap();
3199
3200 cx.spawn(|mut cx| async move {
3201 window
3202 .update(&mut cx, |_, window, cx| {
3203 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3204 })
3205 .unwrap();
3206 })
3207 .detach();
3208 cx.background_executor.run_until_parked();
3209 window.update(cx, |_, window, cx| {
3210 search_view_2.update(cx, |search_view_2, cx| {
3211 assert!(
3212 search_view_2.results_editor.focus_handle(cx).is_focused(window),
3213 "Search view with matching query should switch focus to the results editor after the toggle focus event",
3214 );
3215 });}).unwrap();
3216 }
3217
3218 #[gpui::test]
3219 async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
3220 init_test(cx);
3221
3222 let fs = FakeFs::new(cx.background_executor.clone());
3223 fs.insert_tree(
3224 path!("/dir"),
3225 json!({
3226 "a": {
3227 "one.rs": "const ONE: usize = 1;",
3228 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3229 },
3230 "b": {
3231 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3232 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3233 },
3234 }),
3235 )
3236 .await;
3237 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3238 let worktree_id = project.read_with(cx, |project, cx| {
3239 project.worktrees(cx).next().unwrap().read(cx).id()
3240 });
3241 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3242 let workspace = window.root(cx).unwrap();
3243 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3244
3245 let active_item = cx.read(|cx| {
3246 workspace
3247 .read(cx)
3248 .active_pane()
3249 .read(cx)
3250 .active_item()
3251 .and_then(|item| item.downcast::<ProjectSearchView>())
3252 });
3253 assert!(
3254 active_item.is_none(),
3255 "Expected no search panel to be active"
3256 );
3257
3258 window
3259 .update(cx, move |workspace, window, cx| {
3260 assert_eq!(workspace.panes().len(), 1);
3261 workspace.panes()[0].update(cx, move |pane, cx| {
3262 pane.toolbar()
3263 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3264 });
3265 })
3266 .unwrap();
3267
3268 let a_dir_entry = cx.update(|cx| {
3269 workspace
3270 .read(cx)
3271 .project()
3272 .read(cx)
3273 .entry_for_path(&(worktree_id, "a").into(), cx)
3274 .expect("no entry for /a/ directory")
3275 });
3276 assert!(a_dir_entry.is_dir());
3277 window
3278 .update(cx, |workspace, window, cx| {
3279 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, window, cx)
3280 })
3281 .unwrap();
3282
3283 let Some(search_view) = cx.read(|cx| {
3284 workspace
3285 .read(cx)
3286 .active_pane()
3287 .read(cx)
3288 .active_item()
3289 .and_then(|item| item.downcast::<ProjectSearchView>())
3290 }) else {
3291 panic!("Search view expected to appear after new search in directory event trigger")
3292 };
3293 cx.background_executor.run_until_parked();
3294 window
3295 .update(cx, |_, window, cx| {
3296 search_view.update(cx, |search_view, cx| {
3297 assert!(
3298 search_view.query_editor.focus_handle(cx).is_focused(window),
3299 "On new search in directory, focus should be moved into query editor"
3300 );
3301 search_view.excluded_files_editor.update(cx, |editor, cx| {
3302 assert!(
3303 editor.display_text(cx).is_empty(),
3304 "New search in directory should not have any excluded files"
3305 );
3306 });
3307 search_view.included_files_editor.update(cx, |editor, cx| {
3308 assert_eq!(
3309 editor.display_text(cx),
3310 a_dir_entry.path.to_str().unwrap(),
3311 "New search in directory should have included dir entry path"
3312 );
3313 });
3314 });
3315 })
3316 .unwrap();
3317 window
3318 .update(cx, |_, window, cx| {
3319 search_view.update(cx, |search_view, cx| {
3320 search_view.query_editor.update(cx, |query_editor, cx| {
3321 query_editor.set_text("const", window, cx)
3322 });
3323 search_view.search(cx);
3324 });
3325 })
3326 .unwrap();
3327 cx.background_executor.run_until_parked();
3328 window
3329 .update(cx, |_, _, cx| {
3330 search_view.update(cx, |search_view, cx| {
3331 assert_eq!(
3332 search_view
3333 .results_editor
3334 .update(cx, |editor, cx| editor.display_text(cx)),
3335 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3336 "New search in directory should have a filter that matches a certain directory"
3337 );
3338 })
3339 })
3340 .unwrap();
3341 }
3342
3343 #[gpui::test]
3344 async fn test_search_query_history(cx: &mut TestAppContext) {
3345 init_test(cx);
3346
3347 let fs = FakeFs::new(cx.background_executor.clone());
3348 fs.insert_tree(
3349 path!("/dir"),
3350 json!({
3351 "one.rs": "const ONE: usize = 1;",
3352 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3353 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3354 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3355 }),
3356 )
3357 .await;
3358 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3359 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3360 let workspace = window.root(cx).unwrap();
3361 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3362
3363 window
3364 .update(cx, {
3365 let search_bar = search_bar.clone();
3366 |workspace, window, cx| {
3367 assert_eq!(workspace.panes().len(), 1);
3368 workspace.panes()[0].update(cx, |pane, cx| {
3369 pane.toolbar()
3370 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3371 });
3372
3373 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3374 }
3375 })
3376 .unwrap();
3377
3378 let search_view = cx.read(|cx| {
3379 workspace
3380 .read(cx)
3381 .active_pane()
3382 .read(cx)
3383 .active_item()
3384 .and_then(|item| item.downcast::<ProjectSearchView>())
3385 .expect("Search view expected to appear after new search event trigger")
3386 });
3387
3388 // Add 3 search items into the history + another unsubmitted one.
3389 window
3390 .update(cx, |_, window, cx| {
3391 search_view.update(cx, |search_view, cx| {
3392 search_view.search_options = SearchOptions::CASE_SENSITIVE;
3393 search_view.query_editor.update(cx, |query_editor, cx| {
3394 query_editor.set_text("ONE", window, cx)
3395 });
3396 search_view.search(cx);
3397 });
3398 })
3399 .unwrap();
3400
3401 cx.background_executor.run_until_parked();
3402 window
3403 .update(cx, |_, window, cx| {
3404 search_view.update(cx, |search_view, cx| {
3405 search_view.query_editor.update(cx, |query_editor, cx| {
3406 query_editor.set_text("TWO", window, cx)
3407 });
3408 search_view.search(cx);
3409 });
3410 })
3411 .unwrap();
3412 cx.background_executor.run_until_parked();
3413 window
3414 .update(cx, |_, window, cx| {
3415 search_view.update(cx, |search_view, cx| {
3416 search_view.query_editor.update(cx, |query_editor, cx| {
3417 query_editor.set_text("THREE", window, cx)
3418 });
3419 search_view.search(cx);
3420 })
3421 })
3422 .unwrap();
3423 cx.background_executor.run_until_parked();
3424 window
3425 .update(cx, |_, window, cx| {
3426 search_view.update(cx, |search_view, cx| {
3427 search_view.query_editor.update(cx, |query_editor, cx| {
3428 query_editor.set_text("JUST_TEXT_INPUT", window, cx)
3429 });
3430 })
3431 })
3432 .unwrap();
3433 cx.background_executor.run_until_parked();
3434
3435 // Ensure that the latest input with search settings is active.
3436 window
3437 .update(cx, |_, _, cx| {
3438 search_view.update(cx, |search_view, cx| {
3439 assert_eq!(
3440 search_view.query_editor.read(cx).text(cx),
3441 "JUST_TEXT_INPUT"
3442 );
3443 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3444 });
3445 })
3446 .unwrap();
3447
3448 // Next history query after the latest should set the query to the empty string.
3449 window
3450 .update(cx, |_, window, cx| {
3451 search_bar.update(cx, |search_bar, cx| {
3452 search_bar.focus_search(window, cx);
3453 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3454 })
3455 })
3456 .unwrap();
3457 window
3458 .update(cx, |_, _, cx| {
3459 search_view.update(cx, |search_view, cx| {
3460 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3461 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3462 });
3463 })
3464 .unwrap();
3465 window
3466 .update(cx, |_, window, cx| {
3467 search_bar.update(cx, |search_bar, cx| {
3468 search_bar.focus_search(window, cx);
3469 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3470 })
3471 })
3472 .unwrap();
3473 window
3474 .update(cx, |_, _, cx| {
3475 search_view.update(cx, |search_view, cx| {
3476 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3477 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3478 });
3479 })
3480 .unwrap();
3481
3482 // First previous query for empty current query should set the query to the latest submitted one.
3483 window
3484 .update(cx, |_, window, cx| {
3485 search_bar.update(cx, |search_bar, cx| {
3486 search_bar.focus_search(window, cx);
3487 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3488 });
3489 })
3490 .unwrap();
3491 window
3492 .update(cx, |_, _, cx| {
3493 search_view.update(cx, |search_view, cx| {
3494 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3495 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3496 });
3497 })
3498 .unwrap();
3499
3500 // Further previous items should go over the history in reverse order.
3501 window
3502 .update(cx, |_, window, cx| {
3503 search_bar.update(cx, |search_bar, cx| {
3504 search_bar.focus_search(window, cx);
3505 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3506 });
3507 })
3508 .unwrap();
3509 window
3510 .update(cx, |_, _, cx| {
3511 search_view.update(cx, |search_view, cx| {
3512 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3513 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3514 });
3515 })
3516 .unwrap();
3517
3518 // Previous items should never go behind the first history item.
3519 window
3520 .update(cx, |_, window, cx| {
3521 search_bar.update(cx, |search_bar, cx| {
3522 search_bar.focus_search(window, cx);
3523 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3524 });
3525 })
3526 .unwrap();
3527 window
3528 .update(cx, |_, _, cx| {
3529 search_view.update(cx, |search_view, cx| {
3530 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3531 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3532 });
3533 })
3534 .unwrap();
3535 window
3536 .update(cx, |_, window, cx| {
3537 search_bar.update(cx, |search_bar, cx| {
3538 search_bar.focus_search(window, cx);
3539 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3540 });
3541 })
3542 .unwrap();
3543 window
3544 .update(cx, |_, _, cx| {
3545 search_view.update(cx, |search_view, cx| {
3546 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3547 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3548 });
3549 })
3550 .unwrap();
3551
3552 // Next items should go over the history in the original order.
3553 window
3554 .update(cx, |_, window, cx| {
3555 search_bar.update(cx, |search_bar, cx| {
3556 search_bar.focus_search(window, cx);
3557 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3558 });
3559 })
3560 .unwrap();
3561 window
3562 .update(cx, |_, _, cx| {
3563 search_view.update(cx, |search_view, cx| {
3564 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3565 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3566 });
3567 })
3568 .unwrap();
3569
3570 window
3571 .update(cx, |_, window, cx| {
3572 search_view.update(cx, |search_view, cx| {
3573 search_view.query_editor.update(cx, |query_editor, cx| {
3574 query_editor.set_text("TWO_NEW", window, cx)
3575 });
3576 search_view.search(cx);
3577 });
3578 })
3579 .unwrap();
3580 cx.background_executor.run_until_parked();
3581 window
3582 .update(cx, |_, _, cx| {
3583 search_view.update(cx, |search_view, cx| {
3584 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3585 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3586 });
3587 })
3588 .unwrap();
3589
3590 // New search input should add another entry to history and move the selection to the end of the history.
3591 window
3592 .update(cx, |_, window, cx| {
3593 search_bar.update(cx, |search_bar, cx| {
3594 search_bar.focus_search(window, cx);
3595 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3596 });
3597 })
3598 .unwrap();
3599 window
3600 .update(cx, |_, _, cx| {
3601 search_view.update(cx, |search_view, cx| {
3602 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3603 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3604 });
3605 })
3606 .unwrap();
3607 window
3608 .update(cx, |_, window, cx| {
3609 search_bar.update(cx, |search_bar, cx| {
3610 search_bar.focus_search(window, cx);
3611 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3612 });
3613 })
3614 .unwrap();
3615 window
3616 .update(cx, |_, _, cx| {
3617 search_view.update(cx, |search_view, cx| {
3618 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3619 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3620 });
3621 })
3622 .unwrap();
3623 window
3624 .update(cx, |_, window, cx| {
3625 search_bar.update(cx, |search_bar, cx| {
3626 search_bar.focus_search(window, cx);
3627 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3628 });
3629 })
3630 .unwrap();
3631 window
3632 .update(cx, |_, _, cx| {
3633 search_view.update(cx, |search_view, cx| {
3634 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3635 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3636 });
3637 })
3638 .unwrap();
3639 window
3640 .update(cx, |_, window, cx| {
3641 search_bar.update(cx, |search_bar, cx| {
3642 search_bar.focus_search(window, cx);
3643 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3644 });
3645 })
3646 .unwrap();
3647 window
3648 .update(cx, |_, _, cx| {
3649 search_view.update(cx, |search_view, cx| {
3650 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3651 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3652 });
3653 })
3654 .unwrap();
3655 window
3656 .update(cx, |_, window, cx| {
3657 search_bar.update(cx, |search_bar, cx| {
3658 search_bar.focus_search(window, cx);
3659 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3660 });
3661 })
3662 .unwrap();
3663 window
3664 .update(cx, |_, _, cx| {
3665 search_view.update(cx, |search_view, cx| {
3666 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3667 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3668 });
3669 })
3670 .unwrap();
3671 }
3672
3673 #[gpui::test]
3674 async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) {
3675 init_test(cx);
3676
3677 let fs = FakeFs::new(cx.background_executor.clone());
3678 fs.insert_tree(
3679 path!("/dir"),
3680 json!({
3681 "one.rs": "const ONE: usize = 1;",
3682 }),
3683 )
3684 .await;
3685 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3686 let worktree_id = project.update(cx, |this, cx| {
3687 this.worktrees(cx).next().unwrap().read(cx).id()
3688 });
3689
3690 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3691 let workspace = window.root(cx).unwrap();
3692
3693 let panes: Vec<_> = window
3694 .update(cx, |this, _, _| this.panes().to_owned())
3695 .unwrap();
3696
3697 let search_bar_1 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3698 let search_bar_2 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3699
3700 assert_eq!(panes.len(), 1);
3701 let first_pane = panes.first().cloned().unwrap();
3702 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3703 window
3704 .update(cx, |workspace, window, cx| {
3705 workspace.open_path(
3706 (worktree_id, "one.rs"),
3707 Some(first_pane.downgrade()),
3708 true,
3709 window,
3710 cx,
3711 )
3712 })
3713 .unwrap()
3714 .await
3715 .unwrap();
3716 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3717
3718 // Add a project search item to the first pane
3719 window
3720 .update(cx, {
3721 let search_bar = search_bar_1.clone();
3722 |workspace, window, cx| {
3723 first_pane.update(cx, |pane, cx| {
3724 pane.toolbar()
3725 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3726 });
3727
3728 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3729 }
3730 })
3731 .unwrap();
3732 let search_view_1 = cx.read(|cx| {
3733 workspace
3734 .read(cx)
3735 .active_item(cx)
3736 .and_then(|item| item.downcast::<ProjectSearchView>())
3737 .expect("Search view expected to appear after new search event trigger")
3738 });
3739
3740 let second_pane = window
3741 .update(cx, |workspace, window, cx| {
3742 workspace.split_and_clone(
3743 first_pane.clone(),
3744 workspace::SplitDirection::Right,
3745 window,
3746 cx,
3747 )
3748 })
3749 .unwrap()
3750 .unwrap();
3751 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3752
3753 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3754 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3755
3756 // Add a project search item to the second pane
3757 window
3758 .update(cx, {
3759 let search_bar = search_bar_2.clone();
3760 let pane = second_pane.clone();
3761 move |workspace, window, cx| {
3762 assert_eq!(workspace.panes().len(), 2);
3763 pane.update(cx, |pane, cx| {
3764 pane.toolbar()
3765 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3766 });
3767
3768 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3769 }
3770 })
3771 .unwrap();
3772
3773 let search_view_2 = cx.read(|cx| {
3774 workspace
3775 .read(cx)
3776 .active_item(cx)
3777 .and_then(|item| item.downcast::<ProjectSearchView>())
3778 .expect("Search view expected to appear after new search event trigger")
3779 });
3780
3781 cx.run_until_parked();
3782 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3783 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3784
3785 let update_search_view =
3786 |search_view: &Entity<ProjectSearchView>, query: &str, cx: &mut TestAppContext| {
3787 window
3788 .update(cx, |_, window, cx| {
3789 search_view.update(cx, |search_view, cx| {
3790 search_view.query_editor.update(cx, |query_editor, cx| {
3791 query_editor.set_text(query, window, cx)
3792 });
3793 search_view.search(cx);
3794 });
3795 })
3796 .unwrap();
3797 };
3798
3799 let active_query =
3800 |search_view: &Entity<ProjectSearchView>, cx: &mut TestAppContext| -> String {
3801 window
3802 .update(cx, |_, _, cx| {
3803 search_view.update(cx, |search_view, cx| {
3804 search_view.query_editor.read(cx).text(cx).to_string()
3805 })
3806 })
3807 .unwrap()
3808 };
3809
3810 let select_prev_history_item =
3811 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
3812 window
3813 .update(cx, |_, window, cx| {
3814 search_bar.update(cx, |search_bar, cx| {
3815 search_bar.focus_search(window, cx);
3816 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3817 })
3818 })
3819 .unwrap();
3820 };
3821
3822 let select_next_history_item =
3823 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
3824 window
3825 .update(cx, |_, window, cx| {
3826 search_bar.update(cx, |search_bar, cx| {
3827 search_bar.focus_search(window, cx);
3828 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3829 })
3830 })
3831 .unwrap();
3832 };
3833
3834 update_search_view(&search_view_1, "ONE", cx);
3835 cx.background_executor.run_until_parked();
3836
3837 update_search_view(&search_view_2, "TWO", cx);
3838 cx.background_executor.run_until_parked();
3839
3840 assert_eq!(active_query(&search_view_1, cx), "ONE");
3841 assert_eq!(active_query(&search_view_2, cx), "TWO");
3842
3843 // Selecting previous history item should select the query from search view 1.
3844 select_prev_history_item(&search_bar_2, cx);
3845 assert_eq!(active_query(&search_view_2, cx), "ONE");
3846
3847 // Selecting the previous history item should not change the query as it is already the first item.
3848 select_prev_history_item(&search_bar_2, cx);
3849 assert_eq!(active_query(&search_view_2, cx), "ONE");
3850
3851 // Changing the query in search view 2 should not affect the history of search view 1.
3852 assert_eq!(active_query(&search_view_1, cx), "ONE");
3853
3854 // Deploying a new search in search view 2
3855 update_search_view(&search_view_2, "THREE", cx);
3856 cx.background_executor.run_until_parked();
3857
3858 select_next_history_item(&search_bar_2, cx);
3859 assert_eq!(active_query(&search_view_2, cx), "");
3860
3861 select_prev_history_item(&search_bar_2, cx);
3862 assert_eq!(active_query(&search_view_2, cx), "THREE");
3863
3864 select_prev_history_item(&search_bar_2, cx);
3865 assert_eq!(active_query(&search_view_2, cx), "TWO");
3866
3867 select_prev_history_item(&search_bar_2, cx);
3868 assert_eq!(active_query(&search_view_2, cx), "ONE");
3869
3870 select_prev_history_item(&search_bar_2, cx);
3871 assert_eq!(active_query(&search_view_2, cx), "ONE");
3872
3873 // Search view 1 should now see the query from search view 2.
3874 assert_eq!(active_query(&search_view_1, cx), "ONE");
3875
3876 select_next_history_item(&search_bar_2, cx);
3877 assert_eq!(active_query(&search_view_2, cx), "TWO");
3878
3879 // Here is the new query from search view 2
3880 select_next_history_item(&search_bar_2, cx);
3881 assert_eq!(active_query(&search_view_2, cx), "THREE");
3882
3883 select_next_history_item(&search_bar_2, cx);
3884 assert_eq!(active_query(&search_view_2, cx), "");
3885
3886 select_next_history_item(&search_bar_1, cx);
3887 assert_eq!(active_query(&search_view_1, cx), "TWO");
3888
3889 select_next_history_item(&search_bar_1, cx);
3890 assert_eq!(active_query(&search_view_1, cx), "THREE");
3891
3892 select_next_history_item(&search_bar_1, cx);
3893 assert_eq!(active_query(&search_view_1, cx), "");
3894 }
3895
3896 #[gpui::test]
3897 async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
3898 init_test(cx);
3899
3900 // Setup 2 panes, both with a file open and one with a project search.
3901 let fs = FakeFs::new(cx.background_executor.clone());
3902 fs.insert_tree(
3903 path!("/dir"),
3904 json!({
3905 "one.rs": "const ONE: usize = 1;",
3906 }),
3907 )
3908 .await;
3909 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3910 let worktree_id = project.update(cx, |this, cx| {
3911 this.worktrees(cx).next().unwrap().read(cx).id()
3912 });
3913 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3914 let panes: Vec<_> = window
3915 .update(cx, |this, _, _| this.panes().to_owned())
3916 .unwrap();
3917 assert_eq!(panes.len(), 1);
3918 let first_pane = panes.first().cloned().unwrap();
3919 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3920 window
3921 .update(cx, |workspace, window, cx| {
3922 workspace.open_path(
3923 (worktree_id, "one.rs"),
3924 Some(first_pane.downgrade()),
3925 true,
3926 window,
3927 cx,
3928 )
3929 })
3930 .unwrap()
3931 .await
3932 .unwrap();
3933 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3934 let second_pane = window
3935 .update(cx, |workspace, window, cx| {
3936 workspace.split_and_clone(
3937 first_pane.clone(),
3938 workspace::SplitDirection::Right,
3939 window,
3940 cx,
3941 )
3942 })
3943 .unwrap()
3944 .unwrap();
3945 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3946 assert!(
3947 window
3948 .update(cx, |_, window, cx| second_pane
3949 .focus_handle(cx)
3950 .contains_focused(window, cx))
3951 .unwrap()
3952 );
3953 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3954 window
3955 .update(cx, {
3956 let search_bar = search_bar.clone();
3957 let pane = first_pane.clone();
3958 move |workspace, window, cx| {
3959 assert_eq!(workspace.panes().len(), 2);
3960 pane.update(cx, move |pane, cx| {
3961 pane.toolbar()
3962 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3963 });
3964 }
3965 })
3966 .unwrap();
3967
3968 // Add a project search item to the second pane
3969 window
3970 .update(cx, {
3971 let search_bar = search_bar.clone();
3972 |workspace, window, cx| {
3973 assert_eq!(workspace.panes().len(), 2);
3974 second_pane.update(cx, |pane, cx| {
3975 pane.toolbar()
3976 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3977 });
3978
3979 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3980 }
3981 })
3982 .unwrap();
3983
3984 cx.run_until_parked();
3985 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3986 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3987
3988 // Focus the first pane
3989 window
3990 .update(cx, |workspace, window, cx| {
3991 assert_eq!(workspace.active_pane(), &second_pane);
3992 second_pane.update(cx, |this, cx| {
3993 assert_eq!(this.active_item_index(), 1);
3994 this.activate_prev_item(false, window, cx);
3995 assert_eq!(this.active_item_index(), 0);
3996 });
3997 workspace.activate_pane_in_direction(workspace::SplitDirection::Left, window, cx);
3998 })
3999 .unwrap();
4000 window
4001 .update(cx, |workspace, _, cx| {
4002 assert_eq!(workspace.active_pane(), &first_pane);
4003 assert_eq!(first_pane.read(cx).items_len(), 1);
4004 assert_eq!(second_pane.read(cx).items_len(), 2);
4005 })
4006 .unwrap();
4007
4008 // Deploy a new search
4009 cx.dispatch_action(window.into(), DeploySearch::find());
4010
4011 // Both panes should now have a project search in them
4012 window
4013 .update(cx, |workspace, window, cx| {
4014 assert_eq!(workspace.active_pane(), &first_pane);
4015 first_pane.update(cx, |this, _| {
4016 assert_eq!(this.active_item_index(), 1);
4017 assert_eq!(this.items_len(), 2);
4018 });
4019 second_pane.update(cx, |this, cx| {
4020 assert!(!cx.focus_handle().contains_focused(window, cx));
4021 assert_eq!(this.items_len(), 2);
4022 });
4023 })
4024 .unwrap();
4025
4026 // Focus the second pane's non-search item
4027 window
4028 .update(cx, |_workspace, window, cx| {
4029 second_pane.update(cx, |pane, cx| pane.activate_next_item(true, window, cx));
4030 })
4031 .unwrap();
4032
4033 // Deploy a new search
4034 cx.dispatch_action(window.into(), DeploySearch::find());
4035
4036 // The project search view should now be focused in the second pane
4037 // And the number of items should be unchanged.
4038 window
4039 .update(cx, |_workspace, _, cx| {
4040 second_pane.update(cx, |pane, _cx| {
4041 assert!(
4042 pane.active_item()
4043 .unwrap()
4044 .downcast::<ProjectSearchView>()
4045 .is_some()
4046 );
4047
4048 assert_eq!(pane.items_len(), 2);
4049 });
4050 })
4051 .unwrap();
4052 }
4053
4054 #[gpui::test]
4055 async fn test_scroll_search_results_to_top(cx: &mut TestAppContext) {
4056 init_test(cx);
4057
4058 // We need many lines in the search results to be able to scroll the window
4059 let fs = FakeFs::new(cx.background_executor.clone());
4060 fs.insert_tree(
4061 path!("/dir"),
4062 json!({
4063 "1.txt": "\n\n\n\n\n A \n\n\n\n\n",
4064 "2.txt": "\n\n\n\n\n A \n\n\n\n\n",
4065 "3.rs": "\n\n\n\n\n A \n\n\n\n\n",
4066 "4.rs": "\n\n\n\n\n A \n\n\n\n\n",
4067 "5.rs": "\n\n\n\n\n A \n\n\n\n\n",
4068 "6.rs": "\n\n\n\n\n A \n\n\n\n\n",
4069 "7.rs": "\n\n\n\n\n A \n\n\n\n\n",
4070 "8.rs": "\n\n\n\n\n A \n\n\n\n\n",
4071 "9.rs": "\n\n\n\n\n A \n\n\n\n\n",
4072 "a.rs": "\n\n\n\n\n A \n\n\n\n\n",
4073 "b.rs": "\n\n\n\n\n B \n\n\n\n\n",
4074 "c.rs": "\n\n\n\n\n B \n\n\n\n\n",
4075 "d.rs": "\n\n\n\n\n B \n\n\n\n\n",
4076 "e.rs": "\n\n\n\n\n B \n\n\n\n\n",
4077 "f.rs": "\n\n\n\n\n B \n\n\n\n\n",
4078 "g.rs": "\n\n\n\n\n B \n\n\n\n\n",
4079 "h.rs": "\n\n\n\n\n B \n\n\n\n\n",
4080 "i.rs": "\n\n\n\n\n B \n\n\n\n\n",
4081 "j.rs": "\n\n\n\n\n B \n\n\n\n\n",
4082 "k.rs": "\n\n\n\n\n B \n\n\n\n\n",
4083 }),
4084 )
4085 .await;
4086 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4087 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4088 let workspace = window.root(cx).unwrap();
4089 let search = cx.new(|cx| ProjectSearch::new(project, cx));
4090 let search_view = cx.add_window(|window, cx| {
4091 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
4092 });
4093
4094 // First search
4095 perform_search(search_view, "A", cx);
4096 search_view
4097 .update(cx, |search_view, window, cx| {
4098 search_view.results_editor.update(cx, |results_editor, cx| {
4099 // Results are correct and scrolled to the top
4100 assert_eq!(
4101 results_editor.display_text(cx).match_indices(" A ").count(),
4102 10
4103 );
4104 assert_eq!(results_editor.scroll_position(cx), Point::default());
4105
4106 // Scroll results all the way down
4107 results_editor.scroll(
4108 Point::new(0., f32::MAX),
4109 Some(Axis::Vertical),
4110 window,
4111 cx,
4112 );
4113 });
4114 })
4115 .expect("unable to update search view");
4116
4117 // Second search
4118 perform_search(search_view, "B", cx);
4119 search_view
4120 .update(cx, |search_view, _, cx| {
4121 search_view.results_editor.update(cx, |results_editor, cx| {
4122 // Results are correct...
4123 assert_eq!(
4124 results_editor.display_text(cx).match_indices(" B ").count(),
4125 10
4126 );
4127 // ...and scrolled back to the top
4128 assert_eq!(results_editor.scroll_position(cx), Point::default());
4129 });
4130 })
4131 .expect("unable to update search view");
4132 }
4133
4134 #[gpui::test]
4135 async fn test_buffer_search_query_reused(cx: &mut TestAppContext) {
4136 init_test(cx);
4137
4138 let fs = FakeFs::new(cx.background_executor.clone());
4139 fs.insert_tree(
4140 path!("/dir"),
4141 json!({
4142 "one.rs": "const ONE: usize = 1;",
4143 }),
4144 )
4145 .await;
4146 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4147 let worktree_id = project.update(cx, |this, cx| {
4148 this.worktrees(cx).next().unwrap().read(cx).id()
4149 });
4150 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4151 let workspace = window.root(cx).unwrap();
4152 let mut cx = VisualTestContext::from_window(*window.deref(), cx);
4153
4154 let editor = workspace
4155 .update_in(&mut cx, |workspace, window, cx| {
4156 workspace.open_path((worktree_id, "one.rs"), None, true, window, cx)
4157 })
4158 .await
4159 .unwrap()
4160 .downcast::<Editor>()
4161 .unwrap();
4162
4163 // Wait for the unstaged changes to be loaded
4164 cx.run_until_parked();
4165
4166 let buffer_search_bar = cx.new_window_entity(|window, cx| {
4167 let mut search_bar =
4168 BufferSearchBar::new(Some(project.read(cx).languages().clone()), window, cx);
4169 search_bar.set_active_pane_item(Some(&editor), window, cx);
4170 search_bar.show(window, cx);
4171 search_bar
4172 });
4173
4174 let panes: Vec<_> = window
4175 .update(&mut cx, |this, _, _| this.panes().to_owned())
4176 .unwrap();
4177 assert_eq!(panes.len(), 1);
4178 let pane = panes.first().cloned().unwrap();
4179 pane.update_in(&mut cx, |pane, window, cx| {
4180 pane.toolbar().update(cx, |toolbar, cx| {
4181 toolbar.add_item(buffer_search_bar.clone(), window, cx);
4182 })
4183 });
4184
4185 let buffer_search_query = "search bar query";
4186 buffer_search_bar
4187 .update_in(&mut cx, |buffer_search_bar, window, cx| {
4188 buffer_search_bar.focus_handle(cx).focus(window);
4189 buffer_search_bar.search(buffer_search_query, None, window, cx)
4190 })
4191 .await
4192 .unwrap();
4193
4194 workspace.update_in(&mut cx, |workspace, window, cx| {
4195 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4196 });
4197 cx.run_until_parked();
4198 let project_search_view = pane
4199 .update(&mut cx, |pane, _| {
4200 pane.active_item()
4201 .and_then(|item| item.downcast::<ProjectSearchView>())
4202 })
4203 .expect("should open a project search view after spawning a new search");
4204 project_search_view.update(&mut cx, |search_view, cx| {
4205 assert_eq!(
4206 search_view.search_query_text(cx),
4207 buffer_search_query,
4208 "Project search should take the query from the buffer search bar since it got focused and had a query inside"
4209 );
4210 });
4211 }
4212
4213 fn init_test(cx: &mut TestAppContext) {
4214 cx.update(|cx| {
4215 let settings = SettingsStore::test(cx);
4216 cx.set_global(settings);
4217
4218 theme::init(theme::LoadThemes::JustBase, cx);
4219
4220 language::init(cx);
4221 client::init_settings(cx);
4222 editor::init(cx);
4223 workspace::init_settings(cx);
4224 Project::init_settings(cx);
4225 crate::init(cx);
4226 });
4227 }
4228
4229 fn perform_search(
4230 search_view: WindowHandle<ProjectSearchView>,
4231 text: impl Into<Arc<str>>,
4232 cx: &mut TestAppContext,
4233 ) {
4234 search_view
4235 .update(cx, |search_view, window, cx| {
4236 search_view.query_editor.update(cx, |query_editor, cx| {
4237 query_editor.set_text(text, window, cx)
4238 });
4239 search_view.search(cx);
4240 })
4241 .unwrap();
4242 cx.background_executor.run_until_parked();
4243 }
4244}