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