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