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