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