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