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