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