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