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::{
2348 ops::Deref as _,
2349 path::PathBuf,
2350 sync::{
2351 Arc,
2352 atomic::{self, AtomicUsize},
2353 },
2354 time::Duration,
2355 };
2356
2357 use super::*;
2358 use editor::{DisplayPoint, display_map::DisplayRow};
2359 use gpui::{Action, TestAppContext, VisualTestContext, WindowHandle};
2360 use language::{FakeLspAdapter, rust_lang};
2361 use project::FakeFs;
2362 use serde_json::json;
2363 use settings::{InlayHintSettingsContent, SettingsStore};
2364 use util::{path, paths::PathStyle, rel_path::rel_path};
2365 use util_macros::perf;
2366 use workspace::DeploySearch;
2367
2368 #[perf]
2369 #[gpui::test]
2370 async fn test_project_search(cx: &mut TestAppContext) {
2371 init_test(cx);
2372
2373 let fs = FakeFs::new(cx.background_executor.clone());
2374 fs.insert_tree(
2375 path!("/dir"),
2376 json!({
2377 "one.rs": "const ONE: usize = 1;",
2378 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2379 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2380 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2381 }),
2382 )
2383 .await;
2384 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2385 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2386 let workspace = window.root(cx).unwrap();
2387 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
2388 let search_view = cx.add_window(|window, cx| {
2389 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
2390 });
2391
2392 perform_search(search_view, "TWO", cx);
2393 search_view.update(cx, |search_view, window, cx| {
2394 assert_eq!(
2395 search_view
2396 .results_editor
2397 .update(cx, |editor, cx| editor.display_text(cx)),
2398 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
2399 );
2400 let match_background_color = cx.theme().colors().search_match_background;
2401 let selection_background_color = cx.theme().colors().editor_document_highlight_bracket_background;
2402 assert_eq!(
2403 search_view
2404 .results_editor
2405 .update(cx, |editor, cx| editor.all_text_background_highlights(window, cx)),
2406 &[
2407 (
2408 DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35),
2409 match_background_color
2410 ),
2411 (
2412 DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40),
2413 selection_background_color
2414 ),
2415 (
2416 DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40),
2417 match_background_color
2418 ),
2419 (
2420 DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9),
2421 selection_background_color
2422 ),
2423 (
2424 DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9),
2425 match_background_color
2426 ),
2427
2428 ]
2429 );
2430 assert_eq!(search_view.active_match_index, Some(0));
2431 assert_eq!(
2432 search_view
2433 .results_editor
2434 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2435 [DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35)]
2436 );
2437
2438 search_view.select_match(Direction::Next, window, cx);
2439 }).unwrap();
2440
2441 search_view
2442 .update(cx, |search_view, window, cx| {
2443 assert_eq!(search_view.active_match_index, Some(1));
2444 assert_eq!(
2445 search_view
2446 .results_editor
2447 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2448 [DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40)]
2449 );
2450 search_view.select_match(Direction::Next, window, cx);
2451 })
2452 .unwrap();
2453
2454 search_view
2455 .update(cx, |search_view, window, cx| {
2456 assert_eq!(search_view.active_match_index, Some(2));
2457 assert_eq!(
2458 search_view
2459 .results_editor
2460 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2461 [DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9)]
2462 );
2463 search_view.select_match(Direction::Next, window, cx);
2464 })
2465 .unwrap();
2466
2467 search_view
2468 .update(cx, |search_view, window, cx| {
2469 assert_eq!(search_view.active_match_index, Some(0));
2470 assert_eq!(
2471 search_view
2472 .results_editor
2473 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2474 [DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35)]
2475 );
2476 search_view.select_match(Direction::Prev, window, cx);
2477 })
2478 .unwrap();
2479
2480 search_view
2481 .update(cx, |search_view, window, cx| {
2482 assert_eq!(search_view.active_match_index, Some(2));
2483 assert_eq!(
2484 search_view
2485 .results_editor
2486 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2487 [DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9)]
2488 );
2489 search_view.select_match(Direction::Prev, window, cx);
2490 })
2491 .unwrap();
2492
2493 search_view
2494 .update(cx, |search_view, _, cx| {
2495 assert_eq!(search_view.active_match_index, Some(1));
2496 assert_eq!(
2497 search_view
2498 .results_editor
2499 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2500 [DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40)]
2501 );
2502 })
2503 .unwrap();
2504 }
2505
2506 #[perf]
2507 #[gpui::test]
2508 async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
2509 init_test(cx);
2510
2511 let fs = FakeFs::new(cx.background_executor.clone());
2512 fs.insert_tree(
2513 "/dir",
2514 json!({
2515 "one.rs": "const ONE: usize = 1;",
2516 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2517 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2518 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2519 }),
2520 )
2521 .await;
2522 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2523 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2524 let workspace = window;
2525 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2526
2527 let active_item = cx.read(|cx| {
2528 workspace
2529 .read(cx)
2530 .unwrap()
2531 .active_pane()
2532 .read(cx)
2533 .active_item()
2534 .and_then(|item| item.downcast::<ProjectSearchView>())
2535 });
2536 assert!(
2537 active_item.is_none(),
2538 "Expected no search panel to be active"
2539 );
2540
2541 window
2542 .update(cx, move |workspace, window, cx| {
2543 assert_eq!(workspace.panes().len(), 1);
2544 workspace.panes()[0].update(cx, |pane, cx| {
2545 pane.toolbar()
2546 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2547 });
2548
2549 ProjectSearchView::deploy_search(
2550 workspace,
2551 &workspace::DeploySearch::find(),
2552 window,
2553 cx,
2554 )
2555 })
2556 .unwrap();
2557
2558 let Some(search_view) = cx.read(|cx| {
2559 workspace
2560 .read(cx)
2561 .unwrap()
2562 .active_pane()
2563 .read(cx)
2564 .active_item()
2565 .and_then(|item| item.downcast::<ProjectSearchView>())
2566 }) else {
2567 panic!("Search view expected to appear after new search event trigger")
2568 };
2569
2570 cx.spawn(|mut cx| async move {
2571 window
2572 .update(&mut cx, |_, window, cx| {
2573 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2574 })
2575 .unwrap();
2576 })
2577 .detach();
2578 cx.background_executor.run_until_parked();
2579 window
2580 .update(cx, |_, window, cx| {
2581 search_view.update(cx, |search_view, cx| {
2582 assert!(
2583 search_view.query_editor.focus_handle(cx).is_focused(window),
2584 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2585 );
2586 });
2587 }).unwrap();
2588
2589 window
2590 .update(cx, |_, window, cx| {
2591 search_view.update(cx, |search_view, cx| {
2592 let query_editor = &search_view.query_editor;
2593 assert!(
2594 query_editor.focus_handle(cx).is_focused(window),
2595 "Search view should be focused after the new search view is activated",
2596 );
2597 let query_text = query_editor.read(cx).text(cx);
2598 assert!(
2599 query_text.is_empty(),
2600 "New search query should be empty but got '{query_text}'",
2601 );
2602 let results_text = search_view
2603 .results_editor
2604 .update(cx, |editor, cx| editor.display_text(cx));
2605 assert!(
2606 results_text.is_empty(),
2607 "Empty search view should have no results but got '{results_text}'"
2608 );
2609 });
2610 })
2611 .unwrap();
2612
2613 window
2614 .update(cx, |_, window, cx| {
2615 search_view.update(cx, |search_view, cx| {
2616 search_view.query_editor.update(cx, |query_editor, cx| {
2617 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
2618 });
2619 search_view.search(cx);
2620 });
2621 })
2622 .unwrap();
2623 cx.background_executor.run_until_parked();
2624 window
2625 .update(cx, |_, window, cx| {
2626 search_view.update(cx, |search_view, cx| {
2627 let results_text = search_view
2628 .results_editor
2629 .update(cx, |editor, cx| editor.display_text(cx));
2630 assert!(
2631 results_text.is_empty(),
2632 "Search view for mismatching query should have no results but got '{results_text}'"
2633 );
2634 assert!(
2635 search_view.query_editor.focus_handle(cx).is_focused(window),
2636 "Search view should be focused after mismatching query had been used in search",
2637 );
2638 });
2639 }).unwrap();
2640
2641 cx.spawn(|mut cx| async move {
2642 window.update(&mut cx, |_, window, cx| {
2643 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2644 })
2645 })
2646 .detach();
2647 cx.background_executor.run_until_parked();
2648 window.update(cx, |_, window, cx| {
2649 search_view.update(cx, |search_view, cx| {
2650 assert!(
2651 search_view.query_editor.focus_handle(cx).is_focused(window),
2652 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2653 );
2654 });
2655 }).unwrap();
2656
2657 window
2658 .update(cx, |_, window, cx| {
2659 search_view.update(cx, |search_view, cx| {
2660 search_view.query_editor.update(cx, |query_editor, cx| {
2661 query_editor.set_text("TWO", window, cx)
2662 });
2663 search_view.search(cx);
2664 });
2665 })
2666 .unwrap();
2667 cx.background_executor.run_until_parked();
2668 window.update(cx, |_, window, cx| {
2669 search_view.update(cx, |search_view, cx| {
2670 assert_eq!(
2671 search_view
2672 .results_editor
2673 .update(cx, |editor, cx| editor.display_text(cx)),
2674 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2675 "Search view results should match the query"
2676 );
2677 assert!(
2678 search_view.results_editor.focus_handle(cx).is_focused(window),
2679 "Search view with mismatching query should be focused after search results are available",
2680 );
2681 });
2682 }).unwrap();
2683 cx.spawn(|mut cx| async move {
2684 window
2685 .update(&mut cx, |_, window, cx| {
2686 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2687 })
2688 .unwrap();
2689 })
2690 .detach();
2691 cx.background_executor.run_until_parked();
2692 window.update(cx, |_, window, cx| {
2693 search_view.update(cx, |search_view, cx| {
2694 assert!(
2695 search_view.results_editor.focus_handle(cx).is_focused(window),
2696 "Search view with matching query should still have its results editor focused after the toggle focus event",
2697 );
2698 });
2699 }).unwrap();
2700
2701 workspace
2702 .update(cx, |workspace, window, cx| {
2703 ProjectSearchView::deploy_search(
2704 workspace,
2705 &workspace::DeploySearch::find(),
2706 window,
2707 cx,
2708 )
2709 })
2710 .unwrap();
2711 window.update(cx, |_, window, cx| {
2712 search_view.update(cx, |search_view, cx| {
2713 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");
2714 assert_eq!(
2715 search_view
2716 .results_editor
2717 .update(cx, |editor, cx| editor.display_text(cx)),
2718 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2719 "Results should be unchanged after search view 2nd open in a row"
2720 );
2721 assert!(
2722 search_view.query_editor.focus_handle(cx).is_focused(window),
2723 "Focus should be moved into query editor again after search view 2nd open in a row"
2724 );
2725 });
2726 }).unwrap();
2727
2728 cx.spawn(|mut cx| async move {
2729 window
2730 .update(&mut cx, |_, window, cx| {
2731 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2732 })
2733 .unwrap();
2734 })
2735 .detach();
2736 cx.background_executor.run_until_parked();
2737 window.update(cx, |_, window, cx| {
2738 search_view.update(cx, |search_view, cx| {
2739 assert!(
2740 search_view.results_editor.focus_handle(cx).is_focused(window),
2741 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2742 );
2743 });
2744 }).unwrap();
2745 }
2746
2747 #[perf]
2748 #[gpui::test]
2749 async fn test_filters_consider_toggle_state(cx: &mut TestAppContext) {
2750 init_test(cx);
2751
2752 let fs = FakeFs::new(cx.background_executor.clone());
2753 fs.insert_tree(
2754 "/dir",
2755 json!({
2756 "one.rs": "const ONE: usize = 1;",
2757 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2758 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2759 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2760 }),
2761 )
2762 .await;
2763 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2764 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2765 let workspace = window;
2766 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2767
2768 window
2769 .update(cx, move |workspace, window, cx| {
2770 workspace.panes()[0].update(cx, |pane, cx| {
2771 pane.toolbar()
2772 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2773 });
2774
2775 ProjectSearchView::deploy_search(
2776 workspace,
2777 &workspace::DeploySearch::find(),
2778 window,
2779 cx,
2780 )
2781 })
2782 .unwrap();
2783
2784 let Some(search_view) = cx.read(|cx| {
2785 workspace
2786 .read(cx)
2787 .unwrap()
2788 .active_pane()
2789 .read(cx)
2790 .active_item()
2791 .and_then(|item| item.downcast::<ProjectSearchView>())
2792 }) else {
2793 panic!("Search view expected to appear after new search event trigger")
2794 };
2795
2796 cx.spawn(|mut cx| async move {
2797 window
2798 .update(&mut cx, |_, window, cx| {
2799 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2800 })
2801 .unwrap();
2802 })
2803 .detach();
2804 cx.background_executor.run_until_parked();
2805
2806 window
2807 .update(cx, |_, window, cx| {
2808 search_view.update(cx, |search_view, cx| {
2809 search_view.query_editor.update(cx, |query_editor, cx| {
2810 query_editor.set_text("const FOUR", window, cx)
2811 });
2812 search_view.toggle_filters(cx);
2813 search_view
2814 .excluded_files_editor
2815 .update(cx, |exclude_editor, cx| {
2816 exclude_editor.set_text("four.rs", window, cx)
2817 });
2818 search_view.search(cx);
2819 });
2820 })
2821 .unwrap();
2822 cx.background_executor.run_until_parked();
2823 window
2824 .update(cx, |_, _, cx| {
2825 search_view.update(cx, |search_view, cx| {
2826 let results_text = search_view
2827 .results_editor
2828 .update(cx, |editor, cx| editor.display_text(cx));
2829 assert!(
2830 results_text.is_empty(),
2831 "Search view for query with the only match in an excluded file should have no results but got '{results_text}'"
2832 );
2833 });
2834 }).unwrap();
2835
2836 cx.spawn(|mut cx| async move {
2837 window.update(&mut cx, |_, window, cx| {
2838 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2839 })
2840 })
2841 .detach();
2842 cx.background_executor.run_until_parked();
2843
2844 window
2845 .update(cx, |_, _, cx| {
2846 search_view.update(cx, |search_view, cx| {
2847 search_view.toggle_filters(cx);
2848 search_view.search(cx);
2849 });
2850 })
2851 .unwrap();
2852 cx.background_executor.run_until_parked();
2853 window
2854 .update(cx, |_, _, cx| {
2855 search_view.update(cx, |search_view, cx| {
2856 assert_eq!(
2857 search_view
2858 .results_editor
2859 .update(cx, |editor, cx| editor.display_text(cx)),
2860 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
2861 "Search view results should contain the queried result in the previously excluded file with filters toggled off"
2862 );
2863 });
2864 })
2865 .unwrap();
2866 }
2867
2868 #[perf]
2869 #[gpui::test]
2870 async fn test_new_project_search_focus(cx: &mut TestAppContext) {
2871 init_test(cx);
2872
2873 let fs = FakeFs::new(cx.background_executor.clone());
2874 fs.insert_tree(
2875 path!("/dir"),
2876 json!({
2877 "one.rs": "const ONE: usize = 1;",
2878 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2879 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2880 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2881 }),
2882 )
2883 .await;
2884 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2885 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2886 let workspace = window;
2887 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2888
2889 let active_item = cx.read(|cx| {
2890 workspace
2891 .read(cx)
2892 .unwrap()
2893 .active_pane()
2894 .read(cx)
2895 .active_item()
2896 .and_then(|item| item.downcast::<ProjectSearchView>())
2897 });
2898 assert!(
2899 active_item.is_none(),
2900 "Expected no search panel to be active"
2901 );
2902
2903 window
2904 .update(cx, move |workspace, window, cx| {
2905 assert_eq!(workspace.panes().len(), 1);
2906 workspace.panes()[0].update(cx, |pane, cx| {
2907 pane.toolbar()
2908 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2909 });
2910
2911 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
2912 })
2913 .unwrap();
2914
2915 let Some(search_view) = cx.read(|cx| {
2916 workspace
2917 .read(cx)
2918 .unwrap()
2919 .active_pane()
2920 .read(cx)
2921 .active_item()
2922 .and_then(|item| item.downcast::<ProjectSearchView>())
2923 }) else {
2924 panic!("Search view expected to appear after new search event trigger")
2925 };
2926
2927 cx.spawn(|mut cx| async move {
2928 window
2929 .update(&mut cx, |_, window, cx| {
2930 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2931 })
2932 .unwrap();
2933 })
2934 .detach();
2935 cx.background_executor.run_until_parked();
2936
2937 window.update(cx, |_, window, cx| {
2938 search_view.update(cx, |search_view, cx| {
2939 assert!(
2940 search_view.query_editor.focus_handle(cx).is_focused(window),
2941 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2942 );
2943 });
2944 }).unwrap();
2945
2946 window
2947 .update(cx, |_, window, cx| {
2948 search_view.update(cx, |search_view, cx| {
2949 let query_editor = &search_view.query_editor;
2950 assert!(
2951 query_editor.focus_handle(cx).is_focused(window),
2952 "Search view should be focused after the new search view is activated",
2953 );
2954 let query_text = query_editor.read(cx).text(cx);
2955 assert!(
2956 query_text.is_empty(),
2957 "New search query should be empty but got '{query_text}'",
2958 );
2959 let results_text = search_view
2960 .results_editor
2961 .update(cx, |editor, cx| editor.display_text(cx));
2962 assert!(
2963 results_text.is_empty(),
2964 "Empty search view should have no results but got '{results_text}'"
2965 );
2966 });
2967 })
2968 .unwrap();
2969
2970 window
2971 .update(cx, |_, window, cx| {
2972 search_view.update(cx, |search_view, cx| {
2973 search_view.query_editor.update(cx, |query_editor, cx| {
2974 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
2975 });
2976 search_view.search(cx);
2977 });
2978 })
2979 .unwrap();
2980
2981 cx.background_executor.run_until_parked();
2982 window
2983 .update(cx, |_, window, cx| {
2984 search_view.update(cx, |search_view, cx| {
2985 let results_text = search_view
2986 .results_editor
2987 .update(cx, |editor, cx| editor.display_text(cx));
2988 assert!(
2989 results_text.is_empty(),
2990 "Search view for mismatching query should have no results but got '{results_text}'"
2991 );
2992 assert!(
2993 search_view.query_editor.focus_handle(cx).is_focused(window),
2994 "Search view should be focused after mismatching query had been used in search",
2995 );
2996 });
2997 })
2998 .unwrap();
2999 cx.spawn(|mut cx| async move {
3000 window.update(&mut cx, |_, window, cx| {
3001 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3002 })
3003 })
3004 .detach();
3005 cx.background_executor.run_until_parked();
3006 window.update(cx, |_, window, cx| {
3007 search_view.update(cx, |search_view, cx| {
3008 assert!(
3009 search_view.query_editor.focus_handle(cx).is_focused(window),
3010 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
3011 );
3012 });
3013 }).unwrap();
3014
3015 window
3016 .update(cx, |_, window, cx| {
3017 search_view.update(cx, |search_view, cx| {
3018 search_view.query_editor.update(cx, |query_editor, cx| {
3019 query_editor.set_text("TWO", window, cx)
3020 });
3021 search_view.search(cx);
3022 })
3023 })
3024 .unwrap();
3025 cx.background_executor.run_until_parked();
3026 window.update(cx, |_, window, cx|
3027 search_view.update(cx, |search_view, cx| {
3028 assert_eq!(
3029 search_view
3030 .results_editor
3031 .update(cx, |editor, cx| editor.display_text(cx)),
3032 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3033 "Search view results should match the query"
3034 );
3035 assert!(
3036 search_view.results_editor.focus_handle(cx).is_focused(window),
3037 "Search view with mismatching query should be focused after search results are available",
3038 );
3039 })).unwrap();
3040 cx.spawn(|mut cx| async move {
3041 window
3042 .update(&mut cx, |_, window, cx| {
3043 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3044 })
3045 .unwrap();
3046 })
3047 .detach();
3048 cx.background_executor.run_until_parked();
3049 window.update(cx, |_, window, cx| {
3050 search_view.update(cx, |search_view, cx| {
3051 assert!(
3052 search_view.results_editor.focus_handle(cx).is_focused(window),
3053 "Search view with matching query should still have its results editor focused after the toggle focus event",
3054 );
3055 });
3056 }).unwrap();
3057
3058 workspace
3059 .update(cx, |workspace, window, cx| {
3060 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3061 })
3062 .unwrap();
3063 cx.background_executor.run_until_parked();
3064 let Some(search_view_2) = cx.read(|cx| {
3065 workspace
3066 .read(cx)
3067 .unwrap()
3068 .active_pane()
3069 .read(cx)
3070 .active_item()
3071 .and_then(|item| item.downcast::<ProjectSearchView>())
3072 }) else {
3073 panic!("Search view expected to appear after new search event trigger")
3074 };
3075 assert!(
3076 search_view_2 != search_view,
3077 "New search view should be open after `workspace::NewSearch` event"
3078 );
3079
3080 window.update(cx, |_, window, cx| {
3081 search_view.update(cx, |search_view, cx| {
3082 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
3083 assert_eq!(
3084 search_view
3085 .results_editor
3086 .update(cx, |editor, cx| editor.display_text(cx)),
3087 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3088 "Results of the first search view should not update too"
3089 );
3090 assert!(
3091 !search_view.query_editor.focus_handle(cx).is_focused(window),
3092 "Focus should be moved away from the first search view"
3093 );
3094 });
3095 }).unwrap();
3096
3097 window.update(cx, |_, window, cx| {
3098 search_view_2.update(cx, |search_view_2, cx| {
3099 assert_eq!(
3100 search_view_2.query_editor.read(cx).text(cx),
3101 "two",
3102 "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
3103 );
3104 assert_eq!(
3105 search_view_2
3106 .results_editor
3107 .update(cx, |editor, cx| editor.display_text(cx)),
3108 "",
3109 "No search results should be in the 2nd view yet, as we did not spawn a search for it"
3110 );
3111 assert!(
3112 search_view_2.query_editor.focus_handle(cx).is_focused(window),
3113 "Focus should be moved into query editor of the new window"
3114 );
3115 });
3116 }).unwrap();
3117
3118 window
3119 .update(cx, |_, window, cx| {
3120 search_view_2.update(cx, |search_view_2, cx| {
3121 search_view_2.query_editor.update(cx, |query_editor, cx| {
3122 query_editor.set_text("FOUR", window, cx)
3123 });
3124 search_view_2.search(cx);
3125 });
3126 })
3127 .unwrap();
3128
3129 cx.background_executor.run_until_parked();
3130 window.update(cx, |_, window, cx| {
3131 search_view_2.update(cx, |search_view_2, cx| {
3132 assert_eq!(
3133 search_view_2
3134 .results_editor
3135 .update(cx, |editor, cx| editor.display_text(cx)),
3136 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
3137 "New search view with the updated query should have new search results"
3138 );
3139 assert!(
3140 search_view_2.results_editor.focus_handle(cx).is_focused(window),
3141 "Search view with mismatching query should be focused after search results are available",
3142 );
3143 });
3144 }).unwrap();
3145
3146 cx.spawn(|mut cx| async move {
3147 window
3148 .update(&mut cx, |_, window, cx| {
3149 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3150 })
3151 .unwrap();
3152 })
3153 .detach();
3154 cx.background_executor.run_until_parked();
3155 window.update(cx, |_, window, cx| {
3156 search_view_2.update(cx, |search_view_2, cx| {
3157 assert!(
3158 search_view_2.results_editor.focus_handle(cx).is_focused(window),
3159 "Search view with matching query should switch focus to the results editor after the toggle focus event",
3160 );
3161 });}).unwrap();
3162 }
3163
3164 #[perf]
3165 #[gpui::test]
3166 async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
3167 init_test(cx);
3168
3169 let fs = FakeFs::new(cx.background_executor.clone());
3170 fs.insert_tree(
3171 path!("/dir"),
3172 json!({
3173 "a": {
3174 "one.rs": "const ONE: usize = 1;",
3175 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3176 },
3177 "b": {
3178 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3179 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3180 },
3181 }),
3182 )
3183 .await;
3184 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3185 let worktree_id = project.read_with(cx, |project, cx| {
3186 project.worktrees(cx).next().unwrap().read(cx).id()
3187 });
3188 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3189 let workspace = window.root(cx).unwrap();
3190 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3191
3192 let active_item = cx.read(|cx| {
3193 workspace
3194 .read(cx)
3195 .active_pane()
3196 .read(cx)
3197 .active_item()
3198 .and_then(|item| item.downcast::<ProjectSearchView>())
3199 });
3200 assert!(
3201 active_item.is_none(),
3202 "Expected no search panel to be active"
3203 );
3204
3205 window
3206 .update(cx, move |workspace, window, cx| {
3207 assert_eq!(workspace.panes().len(), 1);
3208 workspace.panes()[0].update(cx, move |pane, cx| {
3209 pane.toolbar()
3210 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3211 });
3212 })
3213 .unwrap();
3214
3215 let a_dir_entry = cx.update(|cx| {
3216 workspace
3217 .read(cx)
3218 .project()
3219 .read(cx)
3220 .entry_for_path(&(worktree_id, rel_path("a")).into(), cx)
3221 .expect("no entry for /a/ directory")
3222 .clone()
3223 });
3224 assert!(a_dir_entry.is_dir());
3225 window
3226 .update(cx, |workspace, window, cx| {
3227 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, window, cx)
3228 })
3229 .unwrap();
3230
3231 let Some(search_view) = cx.read(|cx| {
3232 workspace
3233 .read(cx)
3234 .active_pane()
3235 .read(cx)
3236 .active_item()
3237 .and_then(|item| item.downcast::<ProjectSearchView>())
3238 }) else {
3239 panic!("Search view expected to appear after new search in directory event trigger")
3240 };
3241 cx.background_executor.run_until_parked();
3242 window
3243 .update(cx, |_, window, cx| {
3244 search_view.update(cx, |search_view, cx| {
3245 assert!(
3246 search_view.query_editor.focus_handle(cx).is_focused(window),
3247 "On new search in directory, focus should be moved into query editor"
3248 );
3249 search_view.excluded_files_editor.update(cx, |editor, cx| {
3250 assert!(
3251 editor.display_text(cx).is_empty(),
3252 "New search in directory should not have any excluded files"
3253 );
3254 });
3255 search_view.included_files_editor.update(cx, |editor, cx| {
3256 assert_eq!(
3257 editor.display_text(cx),
3258 a_dir_entry.path.display(PathStyle::local()),
3259 "New search in directory should have included dir entry path"
3260 );
3261 });
3262 });
3263 })
3264 .unwrap();
3265 window
3266 .update(cx, |_, window, cx| {
3267 search_view.update(cx, |search_view, cx| {
3268 search_view.query_editor.update(cx, |query_editor, cx| {
3269 query_editor.set_text("const", window, cx)
3270 });
3271 search_view.search(cx);
3272 });
3273 })
3274 .unwrap();
3275 cx.background_executor.run_until_parked();
3276 window
3277 .update(cx, |_, _, cx| {
3278 search_view.update(cx, |search_view, cx| {
3279 assert_eq!(
3280 search_view
3281 .results_editor
3282 .update(cx, |editor, cx| editor.display_text(cx)),
3283 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3284 "New search in directory should have a filter that matches a certain directory"
3285 );
3286 })
3287 })
3288 .unwrap();
3289 }
3290
3291 #[perf]
3292 #[gpui::test]
3293 async fn test_search_query_history(cx: &mut TestAppContext) {
3294 init_test(cx);
3295
3296 let fs = FakeFs::new(cx.background_executor.clone());
3297 fs.insert_tree(
3298 path!("/dir"),
3299 json!({
3300 "one.rs": "const ONE: usize = 1;",
3301 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3302 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3303 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3304 }),
3305 )
3306 .await;
3307 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3308 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3309 let workspace = window.root(cx).unwrap();
3310 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3311
3312 window
3313 .update(cx, {
3314 let search_bar = search_bar.clone();
3315 |workspace, window, cx| {
3316 assert_eq!(workspace.panes().len(), 1);
3317 workspace.panes()[0].update(cx, |pane, cx| {
3318 pane.toolbar()
3319 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3320 });
3321
3322 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3323 }
3324 })
3325 .unwrap();
3326
3327 let search_view = cx.read(|cx| {
3328 workspace
3329 .read(cx)
3330 .active_pane()
3331 .read(cx)
3332 .active_item()
3333 .and_then(|item| item.downcast::<ProjectSearchView>())
3334 .expect("Search view expected to appear after new search event trigger")
3335 });
3336
3337 // Add 3 search items into the history + another unsubmitted one.
3338 window
3339 .update(cx, |_, window, cx| {
3340 search_view.update(cx, |search_view, cx| {
3341 search_view.search_options = SearchOptions::CASE_SENSITIVE;
3342 search_view.query_editor.update(cx, |query_editor, cx| {
3343 query_editor.set_text("ONE", window, cx)
3344 });
3345 search_view.search(cx);
3346 });
3347 })
3348 .unwrap();
3349
3350 cx.background_executor.run_until_parked();
3351 window
3352 .update(cx, |_, window, cx| {
3353 search_view.update(cx, |search_view, cx| {
3354 search_view.query_editor.update(cx, |query_editor, cx| {
3355 query_editor.set_text("TWO", window, cx)
3356 });
3357 search_view.search(cx);
3358 });
3359 })
3360 .unwrap();
3361 cx.background_executor.run_until_parked();
3362 window
3363 .update(cx, |_, window, cx| {
3364 search_view.update(cx, |search_view, cx| {
3365 search_view.query_editor.update(cx, |query_editor, cx| {
3366 query_editor.set_text("THREE", window, cx)
3367 });
3368 search_view.search(cx);
3369 })
3370 })
3371 .unwrap();
3372 cx.background_executor.run_until_parked();
3373 window
3374 .update(cx, |_, window, cx| {
3375 search_view.update(cx, |search_view, cx| {
3376 search_view.query_editor.update(cx, |query_editor, cx| {
3377 query_editor.set_text("JUST_TEXT_INPUT", window, cx)
3378 });
3379 })
3380 })
3381 .unwrap();
3382 cx.background_executor.run_until_parked();
3383
3384 // Ensure that the latest input with search settings is active.
3385 window
3386 .update(cx, |_, _, cx| {
3387 search_view.update(cx, |search_view, cx| {
3388 assert_eq!(
3389 search_view.query_editor.read(cx).text(cx),
3390 "JUST_TEXT_INPUT"
3391 );
3392 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3393 });
3394 })
3395 .unwrap();
3396
3397 // Next history query after the latest should set the query to the empty string.
3398 window
3399 .update(cx, |_, window, cx| {
3400 search_bar.update(cx, |search_bar, cx| {
3401 search_bar.focus_search(window, cx);
3402 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3403 })
3404 })
3405 .unwrap();
3406 window
3407 .update(cx, |_, _, cx| {
3408 search_view.update(cx, |search_view, cx| {
3409 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3410 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3411 });
3412 })
3413 .unwrap();
3414 window
3415 .update(cx, |_, window, cx| {
3416 search_bar.update(cx, |search_bar, cx| {
3417 search_bar.focus_search(window, cx);
3418 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3419 })
3420 })
3421 .unwrap();
3422 window
3423 .update(cx, |_, _, cx| {
3424 search_view.update(cx, |search_view, cx| {
3425 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3426 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3427 });
3428 })
3429 .unwrap();
3430
3431 // First previous query for empty current query should set the query to the latest submitted one.
3432 window
3433 .update(cx, |_, window, cx| {
3434 search_bar.update(cx, |search_bar, cx| {
3435 search_bar.focus_search(window, cx);
3436 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3437 });
3438 })
3439 .unwrap();
3440 window
3441 .update(cx, |_, _, cx| {
3442 search_view.update(cx, |search_view, cx| {
3443 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3444 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3445 });
3446 })
3447 .unwrap();
3448
3449 // Further previous items should go over the history in reverse order.
3450 window
3451 .update(cx, |_, window, cx| {
3452 search_bar.update(cx, |search_bar, cx| {
3453 search_bar.focus_search(window, cx);
3454 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3455 });
3456 })
3457 .unwrap();
3458 window
3459 .update(cx, |_, _, cx| {
3460 search_view.update(cx, |search_view, cx| {
3461 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3462 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3463 });
3464 })
3465 .unwrap();
3466
3467 // Previous items should never go behind the first history item.
3468 window
3469 .update(cx, |_, window, cx| {
3470 search_bar.update(cx, |search_bar, cx| {
3471 search_bar.focus_search(window, cx);
3472 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3473 });
3474 })
3475 .unwrap();
3476 window
3477 .update(cx, |_, _, cx| {
3478 search_view.update(cx, |search_view, cx| {
3479 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3480 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3481 });
3482 })
3483 .unwrap();
3484 window
3485 .update(cx, |_, window, cx| {
3486 search_bar.update(cx, |search_bar, cx| {
3487 search_bar.focus_search(window, cx);
3488 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3489 });
3490 })
3491 .unwrap();
3492 window
3493 .update(cx, |_, _, cx| {
3494 search_view.update(cx, |search_view, cx| {
3495 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3496 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3497 });
3498 })
3499 .unwrap();
3500
3501 // Next items should go over the history in the original order.
3502 window
3503 .update(cx, |_, window, cx| {
3504 search_bar.update(cx, |search_bar, cx| {
3505 search_bar.focus_search(window, cx);
3506 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3507 });
3508 })
3509 .unwrap();
3510 window
3511 .update(cx, |_, _, cx| {
3512 search_view.update(cx, |search_view, cx| {
3513 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3514 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3515 });
3516 })
3517 .unwrap();
3518
3519 window
3520 .update(cx, |_, window, cx| {
3521 search_view.update(cx, |search_view, cx| {
3522 search_view.query_editor.update(cx, |query_editor, cx| {
3523 query_editor.set_text("TWO_NEW", window, cx)
3524 });
3525 search_view.search(cx);
3526 });
3527 })
3528 .unwrap();
3529 cx.background_executor.run_until_parked();
3530 window
3531 .update(cx, |_, _, cx| {
3532 search_view.update(cx, |search_view, cx| {
3533 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3534 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3535 });
3536 })
3537 .unwrap();
3538
3539 // New search input should add another entry to history and move the selection to the end of the history.
3540 window
3541 .update(cx, |_, window, cx| {
3542 search_bar.update(cx, |search_bar, cx| {
3543 search_bar.focus_search(window, cx);
3544 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3545 });
3546 })
3547 .unwrap();
3548 window
3549 .update(cx, |_, _, cx| {
3550 search_view.update(cx, |search_view, cx| {
3551 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3552 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3553 });
3554 })
3555 .unwrap();
3556 window
3557 .update(cx, |_, window, cx| {
3558 search_bar.update(cx, |search_bar, cx| {
3559 search_bar.focus_search(window, cx);
3560 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3561 });
3562 })
3563 .unwrap();
3564 window
3565 .update(cx, |_, _, cx| {
3566 search_view.update(cx, |search_view, cx| {
3567 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3568 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3569 });
3570 })
3571 .unwrap();
3572 window
3573 .update(cx, |_, window, cx| {
3574 search_bar.update(cx, |search_bar, cx| {
3575 search_bar.focus_search(window, cx);
3576 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3577 });
3578 })
3579 .unwrap();
3580 window
3581 .update(cx, |_, _, cx| {
3582 search_view.update(cx, |search_view, cx| {
3583 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3584 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3585 });
3586 })
3587 .unwrap();
3588 window
3589 .update(cx, |_, window, cx| {
3590 search_bar.update(cx, |search_bar, cx| {
3591 search_bar.focus_search(window, cx);
3592 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3593 });
3594 })
3595 .unwrap();
3596 window
3597 .update(cx, |_, _, cx| {
3598 search_view.update(cx, |search_view, cx| {
3599 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3600 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3601 });
3602 })
3603 .unwrap();
3604 window
3605 .update(cx, |_, window, cx| {
3606 search_bar.update(cx, |search_bar, cx| {
3607 search_bar.focus_search(window, cx);
3608 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3609 });
3610 })
3611 .unwrap();
3612 window
3613 .update(cx, |_, _, cx| {
3614 search_view.update(cx, |search_view, cx| {
3615 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3616 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3617 });
3618 })
3619 .unwrap();
3620 }
3621
3622 #[perf]
3623 #[gpui::test]
3624 async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) {
3625 init_test(cx);
3626
3627 let fs = FakeFs::new(cx.background_executor.clone());
3628 fs.insert_tree(
3629 path!("/dir"),
3630 json!({
3631 "one.rs": "const ONE: usize = 1;",
3632 }),
3633 )
3634 .await;
3635 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3636 let worktree_id = project.update(cx, |this, cx| {
3637 this.worktrees(cx).next().unwrap().read(cx).id()
3638 });
3639
3640 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3641 let workspace = window.root(cx).unwrap();
3642
3643 let panes: Vec<_> = window
3644 .update(cx, |this, _, _| this.panes().to_owned())
3645 .unwrap();
3646
3647 let search_bar_1 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3648 let search_bar_2 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3649
3650 assert_eq!(panes.len(), 1);
3651 let first_pane = panes.first().cloned().unwrap();
3652 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3653 window
3654 .update(cx, |workspace, window, cx| {
3655 workspace.open_path(
3656 (worktree_id, rel_path("one.rs")),
3657 Some(first_pane.downgrade()),
3658 true,
3659 window,
3660 cx,
3661 )
3662 })
3663 .unwrap()
3664 .await
3665 .unwrap();
3666 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3667
3668 // Add a project search item to the first pane
3669 window
3670 .update(cx, {
3671 let search_bar = search_bar_1.clone();
3672 |workspace, window, cx| {
3673 first_pane.update(cx, |pane, cx| {
3674 pane.toolbar()
3675 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3676 });
3677
3678 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3679 }
3680 })
3681 .unwrap();
3682 let search_view_1 = cx.read(|cx| {
3683 workspace
3684 .read(cx)
3685 .active_item(cx)
3686 .and_then(|item| item.downcast::<ProjectSearchView>())
3687 .expect("Search view expected to appear after new search event trigger")
3688 });
3689
3690 let second_pane = window
3691 .update(cx, |workspace, window, cx| {
3692 workspace.split_and_clone(
3693 first_pane.clone(),
3694 workspace::SplitDirection::Right,
3695 window,
3696 cx,
3697 )
3698 })
3699 .unwrap()
3700 .await
3701 .unwrap();
3702 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3703
3704 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3705 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3706
3707 // Add a project search item to the second pane
3708 window
3709 .update(cx, {
3710 let search_bar = search_bar_2.clone();
3711 let pane = second_pane.clone();
3712 move |workspace, window, cx| {
3713 assert_eq!(workspace.panes().len(), 2);
3714 pane.update(cx, |pane, cx| {
3715 pane.toolbar()
3716 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3717 });
3718
3719 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3720 }
3721 })
3722 .unwrap();
3723
3724 let search_view_2 = cx.read(|cx| {
3725 workspace
3726 .read(cx)
3727 .active_item(cx)
3728 .and_then(|item| item.downcast::<ProjectSearchView>())
3729 .expect("Search view expected to appear after new search event trigger")
3730 });
3731
3732 cx.run_until_parked();
3733 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3734 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3735
3736 let update_search_view =
3737 |search_view: &Entity<ProjectSearchView>, query: &str, cx: &mut TestAppContext| {
3738 window
3739 .update(cx, |_, window, cx| {
3740 search_view.update(cx, |search_view, cx| {
3741 search_view.query_editor.update(cx, |query_editor, cx| {
3742 query_editor.set_text(query, window, cx)
3743 });
3744 search_view.search(cx);
3745 });
3746 })
3747 .unwrap();
3748 };
3749
3750 let active_query =
3751 |search_view: &Entity<ProjectSearchView>, cx: &mut TestAppContext| -> String {
3752 window
3753 .update(cx, |_, _, cx| {
3754 search_view.update(cx, |search_view, cx| {
3755 search_view.query_editor.read(cx).text(cx)
3756 })
3757 })
3758 .unwrap()
3759 };
3760
3761 let select_prev_history_item =
3762 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
3763 window
3764 .update(cx, |_, window, cx| {
3765 search_bar.update(cx, |search_bar, cx| {
3766 search_bar.focus_search(window, cx);
3767 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3768 })
3769 })
3770 .unwrap();
3771 };
3772
3773 let select_next_history_item =
3774 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
3775 window
3776 .update(cx, |_, window, cx| {
3777 search_bar.update(cx, |search_bar, cx| {
3778 search_bar.focus_search(window, cx);
3779 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3780 })
3781 })
3782 .unwrap();
3783 };
3784
3785 update_search_view(&search_view_1, "ONE", cx);
3786 cx.background_executor.run_until_parked();
3787
3788 update_search_view(&search_view_2, "TWO", cx);
3789 cx.background_executor.run_until_parked();
3790
3791 assert_eq!(active_query(&search_view_1, cx), "ONE");
3792 assert_eq!(active_query(&search_view_2, cx), "TWO");
3793
3794 // Selecting previous history item should select the query from search view 1.
3795 select_prev_history_item(&search_bar_2, cx);
3796 assert_eq!(active_query(&search_view_2, cx), "ONE");
3797
3798 // Selecting the previous history item should not change the query as it is already the first item.
3799 select_prev_history_item(&search_bar_2, cx);
3800 assert_eq!(active_query(&search_view_2, cx), "ONE");
3801
3802 // Changing the query in search view 2 should not affect the history of search view 1.
3803 assert_eq!(active_query(&search_view_1, cx), "ONE");
3804
3805 // Deploying a new search in search view 2
3806 update_search_view(&search_view_2, "THREE", cx);
3807 cx.background_executor.run_until_parked();
3808
3809 select_next_history_item(&search_bar_2, cx);
3810 assert_eq!(active_query(&search_view_2, cx), "");
3811
3812 select_prev_history_item(&search_bar_2, cx);
3813 assert_eq!(active_query(&search_view_2, cx), "THREE");
3814
3815 select_prev_history_item(&search_bar_2, cx);
3816 assert_eq!(active_query(&search_view_2, cx), "TWO");
3817
3818 select_prev_history_item(&search_bar_2, cx);
3819 assert_eq!(active_query(&search_view_2, cx), "ONE");
3820
3821 select_prev_history_item(&search_bar_2, cx);
3822 assert_eq!(active_query(&search_view_2, cx), "ONE");
3823
3824 // Search view 1 should now see the query from search view 2.
3825 assert_eq!(active_query(&search_view_1, cx), "ONE");
3826
3827 select_next_history_item(&search_bar_2, cx);
3828 assert_eq!(active_query(&search_view_2, cx), "TWO");
3829
3830 // Here is the new query from search view 2
3831 select_next_history_item(&search_bar_2, cx);
3832 assert_eq!(active_query(&search_view_2, cx), "THREE");
3833
3834 select_next_history_item(&search_bar_2, cx);
3835 assert_eq!(active_query(&search_view_2, cx), "");
3836
3837 select_next_history_item(&search_bar_1, cx);
3838 assert_eq!(active_query(&search_view_1, cx), "TWO");
3839
3840 select_next_history_item(&search_bar_1, cx);
3841 assert_eq!(active_query(&search_view_1, cx), "THREE");
3842
3843 select_next_history_item(&search_bar_1, cx);
3844 assert_eq!(active_query(&search_view_1, cx), "");
3845 }
3846
3847 #[perf]
3848 #[gpui::test]
3849 async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
3850 init_test(cx);
3851
3852 // Setup 2 panes, both with a file open and one with a project search.
3853 let fs = FakeFs::new(cx.background_executor.clone());
3854 fs.insert_tree(
3855 path!("/dir"),
3856 json!({
3857 "one.rs": "const ONE: usize = 1;",
3858 }),
3859 )
3860 .await;
3861 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3862 let worktree_id = project.update(cx, |this, cx| {
3863 this.worktrees(cx).next().unwrap().read(cx).id()
3864 });
3865 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3866 let panes: Vec<_> = window
3867 .update(cx, |this, _, _| this.panes().to_owned())
3868 .unwrap();
3869 assert_eq!(panes.len(), 1);
3870 let first_pane = panes.first().cloned().unwrap();
3871 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3872 window
3873 .update(cx, |workspace, window, cx| {
3874 workspace.open_path(
3875 (worktree_id, rel_path("one.rs")),
3876 Some(first_pane.downgrade()),
3877 true,
3878 window,
3879 cx,
3880 )
3881 })
3882 .unwrap()
3883 .await
3884 .unwrap();
3885 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3886 let second_pane = window
3887 .update(cx, |workspace, window, cx| {
3888 workspace.split_and_clone(
3889 first_pane.clone(),
3890 workspace::SplitDirection::Right,
3891 window,
3892 cx,
3893 )
3894 })
3895 .unwrap()
3896 .await
3897 .unwrap();
3898 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3899 assert!(
3900 window
3901 .update(cx, |_, window, cx| second_pane
3902 .focus_handle(cx)
3903 .contains_focused(window, cx))
3904 .unwrap()
3905 );
3906 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3907 window
3908 .update(cx, {
3909 let search_bar = search_bar.clone();
3910 let pane = first_pane.clone();
3911 move |workspace, window, cx| {
3912 assert_eq!(workspace.panes().len(), 2);
3913 pane.update(cx, move |pane, cx| {
3914 pane.toolbar()
3915 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3916 });
3917 }
3918 })
3919 .unwrap();
3920
3921 // Add a project search item to the second pane
3922 window
3923 .update(cx, {
3924 |workspace, window, cx| {
3925 assert_eq!(workspace.panes().len(), 2);
3926 second_pane.update(cx, |pane, cx| {
3927 pane.toolbar()
3928 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3929 });
3930
3931 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3932 }
3933 })
3934 .unwrap();
3935
3936 cx.run_until_parked();
3937 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3938 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3939
3940 // Focus the first pane
3941 window
3942 .update(cx, |workspace, window, cx| {
3943 assert_eq!(workspace.active_pane(), &second_pane);
3944 second_pane.update(cx, |this, cx| {
3945 assert_eq!(this.active_item_index(), 1);
3946 this.activate_previous_item(&Default::default(), window, cx);
3947 assert_eq!(this.active_item_index(), 0);
3948 });
3949 workspace.activate_pane_in_direction(workspace::SplitDirection::Left, window, cx);
3950 })
3951 .unwrap();
3952 window
3953 .update(cx, |workspace, _, cx| {
3954 assert_eq!(workspace.active_pane(), &first_pane);
3955 assert_eq!(first_pane.read(cx).items_len(), 1);
3956 assert_eq!(second_pane.read(cx).items_len(), 2);
3957 })
3958 .unwrap();
3959
3960 // Deploy a new search
3961 cx.dispatch_action(window.into(), DeploySearch::find());
3962
3963 // Both panes should now have a project search in them
3964 window
3965 .update(cx, |workspace, window, cx| {
3966 assert_eq!(workspace.active_pane(), &first_pane);
3967 first_pane.read_with(cx, |this, _| {
3968 assert_eq!(this.active_item_index(), 1);
3969 assert_eq!(this.items_len(), 2);
3970 });
3971 second_pane.update(cx, |this, cx| {
3972 assert!(!cx.focus_handle().contains_focused(window, cx));
3973 assert_eq!(this.items_len(), 2);
3974 });
3975 })
3976 .unwrap();
3977
3978 // Focus the second pane's non-search item
3979 window
3980 .update(cx, |_workspace, window, cx| {
3981 second_pane.update(cx, |pane, cx| {
3982 pane.activate_next_item(&Default::default(), window, cx)
3983 });
3984 })
3985 .unwrap();
3986
3987 // Deploy a new search
3988 cx.dispatch_action(window.into(), DeploySearch::find());
3989
3990 // The project search view should now be focused in the second pane
3991 // And the number of items should be unchanged.
3992 window
3993 .update(cx, |_workspace, _, cx| {
3994 second_pane.update(cx, |pane, _cx| {
3995 assert!(
3996 pane.active_item()
3997 .unwrap()
3998 .downcast::<ProjectSearchView>()
3999 .is_some()
4000 );
4001
4002 assert_eq!(pane.items_len(), 2);
4003 });
4004 })
4005 .unwrap();
4006 }
4007
4008 #[perf]
4009 #[gpui::test]
4010 async fn test_scroll_search_results_to_top(cx: &mut TestAppContext) {
4011 init_test(cx);
4012
4013 // We need many lines in the search results to be able to scroll the window
4014 let fs = FakeFs::new(cx.background_executor.clone());
4015 fs.insert_tree(
4016 path!("/dir"),
4017 json!({
4018 "1.txt": "\n\n\n\n\n A \n\n\n\n\n",
4019 "2.txt": "\n\n\n\n\n A \n\n\n\n\n",
4020 "3.rs": "\n\n\n\n\n A \n\n\n\n\n",
4021 "4.rs": "\n\n\n\n\n A \n\n\n\n\n",
4022 "5.rs": "\n\n\n\n\n A \n\n\n\n\n",
4023 "6.rs": "\n\n\n\n\n A \n\n\n\n\n",
4024 "7.rs": "\n\n\n\n\n A \n\n\n\n\n",
4025 "8.rs": "\n\n\n\n\n A \n\n\n\n\n",
4026 "9.rs": "\n\n\n\n\n A \n\n\n\n\n",
4027 "a.rs": "\n\n\n\n\n A \n\n\n\n\n",
4028 "b.rs": "\n\n\n\n\n B \n\n\n\n\n",
4029 "c.rs": "\n\n\n\n\n B \n\n\n\n\n",
4030 "d.rs": "\n\n\n\n\n B \n\n\n\n\n",
4031 "e.rs": "\n\n\n\n\n B \n\n\n\n\n",
4032 "f.rs": "\n\n\n\n\n B \n\n\n\n\n",
4033 "g.rs": "\n\n\n\n\n B \n\n\n\n\n",
4034 "h.rs": "\n\n\n\n\n B \n\n\n\n\n",
4035 "i.rs": "\n\n\n\n\n B \n\n\n\n\n",
4036 "j.rs": "\n\n\n\n\n B \n\n\n\n\n",
4037 "k.rs": "\n\n\n\n\n B \n\n\n\n\n",
4038 }),
4039 )
4040 .await;
4041 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4042 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4043 let workspace = window.root(cx).unwrap();
4044 let search = cx.new(|cx| ProjectSearch::new(project, cx));
4045 let search_view = cx.add_window(|window, cx| {
4046 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
4047 });
4048
4049 // First search
4050 perform_search(search_view, "A", cx);
4051 search_view
4052 .update(cx, |search_view, window, cx| {
4053 search_view.results_editor.update(cx, |results_editor, cx| {
4054 // Results are correct and scrolled to the top
4055 assert_eq!(
4056 results_editor.display_text(cx).match_indices(" A ").count(),
4057 10
4058 );
4059 assert_eq!(results_editor.scroll_position(cx), Point::default());
4060
4061 // Scroll results all the way down
4062 results_editor.scroll(
4063 Point::new(0., f64::MAX),
4064 Some(Axis::Vertical),
4065 window,
4066 cx,
4067 );
4068 });
4069 })
4070 .expect("unable to update search view");
4071
4072 // Second search
4073 perform_search(search_view, "B", cx);
4074 search_view
4075 .update(cx, |search_view, _, cx| {
4076 search_view.results_editor.update(cx, |results_editor, cx| {
4077 // Results are correct...
4078 assert_eq!(
4079 results_editor.display_text(cx).match_indices(" B ").count(),
4080 10
4081 );
4082 // ...and scrolled back to the top
4083 assert_eq!(results_editor.scroll_position(cx), Point::default());
4084 });
4085 })
4086 .expect("unable to update search view");
4087 }
4088
4089 #[perf]
4090 #[gpui::test]
4091 async fn test_buffer_search_query_reused(cx: &mut TestAppContext) {
4092 init_test(cx);
4093
4094 let fs = FakeFs::new(cx.background_executor.clone());
4095 fs.insert_tree(
4096 path!("/dir"),
4097 json!({
4098 "one.rs": "const ONE: usize = 1;",
4099 }),
4100 )
4101 .await;
4102 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4103 let worktree_id = project.update(cx, |this, cx| {
4104 this.worktrees(cx).next().unwrap().read(cx).id()
4105 });
4106 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4107 let workspace = window.root(cx).unwrap();
4108 let mut cx = VisualTestContext::from_window(*window.deref(), cx);
4109
4110 let editor = workspace
4111 .update_in(&mut cx, |workspace, window, cx| {
4112 workspace.open_path((worktree_id, rel_path("one.rs")), None, true, window, cx)
4113 })
4114 .await
4115 .unwrap()
4116 .downcast::<Editor>()
4117 .unwrap();
4118
4119 // Wait for the unstaged changes to be loaded
4120 cx.run_until_parked();
4121
4122 let buffer_search_bar = cx.new_window_entity(|window, cx| {
4123 let mut search_bar =
4124 BufferSearchBar::new(Some(project.read(cx).languages().clone()), window, cx);
4125 search_bar.set_active_pane_item(Some(&editor), window, cx);
4126 search_bar.show(window, cx);
4127 search_bar
4128 });
4129
4130 let panes: Vec<_> = window
4131 .update(&mut cx, |this, _, _| this.panes().to_owned())
4132 .unwrap();
4133 assert_eq!(panes.len(), 1);
4134 let pane = panes.first().cloned().unwrap();
4135 pane.update_in(&mut cx, |pane, window, cx| {
4136 pane.toolbar().update(cx, |toolbar, cx| {
4137 toolbar.add_item(buffer_search_bar.clone(), window, cx);
4138 })
4139 });
4140
4141 let buffer_search_query = "search bar query";
4142 buffer_search_bar
4143 .update_in(&mut cx, |buffer_search_bar, window, cx| {
4144 buffer_search_bar.focus_handle(cx).focus(window);
4145 buffer_search_bar.search(buffer_search_query, None, true, window, cx)
4146 })
4147 .await
4148 .unwrap();
4149
4150 workspace.update_in(&mut cx, |workspace, window, cx| {
4151 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4152 });
4153 cx.run_until_parked();
4154 let project_search_view = pane
4155 .read_with(&cx, |pane, _| {
4156 pane.active_item()
4157 .and_then(|item| item.downcast::<ProjectSearchView>())
4158 })
4159 .expect("should open a project search view after spawning a new search");
4160 project_search_view.update(&mut cx, |search_view, cx| {
4161 assert_eq!(
4162 search_view.search_query_text(cx),
4163 buffer_search_query,
4164 "Project search should take the query from the buffer search bar since it got focused and had a query inside"
4165 );
4166 });
4167 }
4168
4169 #[gpui::test]
4170 async fn test_search_dismisses_modal(cx: &mut TestAppContext) {
4171 init_test(cx);
4172
4173 let fs = FakeFs::new(cx.background_executor.clone());
4174 fs.insert_tree(
4175 path!("/dir"),
4176 json!({
4177 "one.rs": "const ONE: usize = 1;",
4178 }),
4179 )
4180 .await;
4181 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4182 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4183
4184 struct EmptyModalView {
4185 focus_handle: gpui::FocusHandle,
4186 }
4187 impl EventEmitter<gpui::DismissEvent> for EmptyModalView {}
4188 impl Render for EmptyModalView {
4189 fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement {
4190 div()
4191 }
4192 }
4193 impl Focusable for EmptyModalView {
4194 fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
4195 self.focus_handle.clone()
4196 }
4197 }
4198 impl workspace::ModalView for EmptyModalView {}
4199
4200 window
4201 .update(cx, |workspace, window, cx| {
4202 workspace.toggle_modal(window, cx, |_, cx| EmptyModalView {
4203 focus_handle: cx.focus_handle(),
4204 });
4205 assert!(workspace.has_active_modal(window, cx));
4206 })
4207 .unwrap();
4208
4209 cx.dispatch_action(window.into(), Deploy::find());
4210
4211 window
4212 .update(cx, |workspace, window, cx| {
4213 assert!(!workspace.has_active_modal(window, cx));
4214 workspace.toggle_modal(window, cx, |_, cx| EmptyModalView {
4215 focus_handle: cx.focus_handle(),
4216 });
4217 assert!(workspace.has_active_modal(window, cx));
4218 })
4219 .unwrap();
4220
4221 cx.dispatch_action(window.into(), DeploySearch::find());
4222
4223 window
4224 .update(cx, |workspace, window, cx| {
4225 assert!(!workspace.has_active_modal(window, cx));
4226 })
4227 .unwrap();
4228 }
4229
4230 #[perf]
4231 #[gpui::test]
4232 async fn test_search_with_inlays(cx: &mut TestAppContext) {
4233 init_test(cx);
4234 cx.update(|cx| {
4235 SettingsStore::update_global(cx, |store, cx| {
4236 store.update_user_settings(cx, |settings| {
4237 settings.project.all_languages.defaults.inlay_hints =
4238 Some(InlayHintSettingsContent {
4239 enabled: Some(true),
4240 ..InlayHintSettingsContent::default()
4241 })
4242 });
4243 });
4244 });
4245
4246 let fs = FakeFs::new(cx.background_executor.clone());
4247 fs.insert_tree(
4248 path!("/dir"),
4249 // `\n` , a trailing line on the end, is important for the test case
4250 json!({
4251 "main.rs": "fn main() { let a = 2; }\n",
4252 }),
4253 )
4254 .await;
4255
4256 let requests_count = Arc::new(AtomicUsize::new(0));
4257 let closure_requests_count = requests_count.clone();
4258 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4259 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
4260 let language = rust_lang();
4261 language_registry.add(language);
4262 let mut fake_servers = language_registry.register_fake_lsp(
4263 "Rust",
4264 FakeLspAdapter {
4265 capabilities: lsp::ServerCapabilities {
4266 inlay_hint_provider: Some(lsp::OneOf::Left(true)),
4267 ..lsp::ServerCapabilities::default()
4268 },
4269 initializer: Some(Box::new(move |fake_server| {
4270 let requests_count = closure_requests_count.clone();
4271 fake_server.set_request_handler::<lsp::request::InlayHintRequest, _, _>({
4272 move |_, _| {
4273 let requests_count = requests_count.clone();
4274 async move {
4275 requests_count.fetch_add(1, atomic::Ordering::Release);
4276 Ok(Some(vec![lsp::InlayHint {
4277 position: lsp::Position::new(0, 17),
4278 label: lsp::InlayHintLabel::String(": i32".to_owned()),
4279 kind: Some(lsp::InlayHintKind::TYPE),
4280 text_edits: None,
4281 tooltip: None,
4282 padding_left: None,
4283 padding_right: None,
4284 data: None,
4285 }]))
4286 }
4287 }
4288 });
4289 })),
4290 ..FakeLspAdapter::default()
4291 },
4292 );
4293
4294 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4295 let workspace = window.root(cx).unwrap();
4296 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
4297 let search_view = cx.add_window(|window, cx| {
4298 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
4299 });
4300
4301 perform_search(search_view, "let ", cx);
4302 let _fake_server = fake_servers.next().await.unwrap();
4303 cx.executor().advance_clock(Duration::from_secs(1));
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 assert_eq!(
4316 requests_count.load(atomic::Ordering::Acquire),
4317 1,
4318 "New hints should have been queried",
4319 );
4320
4321 // Can do the 2nd search without any panics
4322 perform_search(search_view, "let ", cx);
4323 cx.executor().advance_clock(Duration::from_millis(100));
4324 cx.executor().run_until_parked();
4325 search_view
4326 .update(cx, |search_view, _, cx| {
4327 assert_eq!(
4328 search_view
4329 .results_editor
4330 .update(cx, |editor, cx| editor.display_text(cx)),
4331 "\n\nfn main() { let a: i32 = 2; }\n"
4332 );
4333 })
4334 .unwrap();
4335 assert_eq!(
4336 requests_count.load(atomic::Ordering::Acquire),
4337 2,
4338 "We did drop the previous buffer when cleared the old project search results, hence another query was made",
4339 );
4340
4341 let singleton_editor = window
4342 .update(cx, |workspace, window, cx| {
4343 workspace.open_abs_path(
4344 PathBuf::from(path!("/dir/main.rs")),
4345 workspace::OpenOptions::default(),
4346 window,
4347 cx,
4348 )
4349 })
4350 .unwrap()
4351 .await
4352 .unwrap()
4353 .downcast::<Editor>()
4354 .unwrap();
4355 cx.executor().advance_clock(Duration::from_millis(100));
4356 cx.executor().run_until_parked();
4357 singleton_editor.update(cx, |editor, cx| {
4358 assert_eq!(
4359 editor.display_text(cx),
4360 "fn main() { let a: i32 = 2; }\n",
4361 "Newly opened editor should have the correct text with hints",
4362 );
4363 });
4364 assert_eq!(
4365 requests_count.load(atomic::Ordering::Acquire),
4366 2,
4367 "Opening the same buffer again should reuse the cached hints",
4368 );
4369
4370 window
4371 .update(cx, |_, window, cx| {
4372 singleton_editor.update(cx, |editor, cx| {
4373 editor.handle_input("test", window, cx);
4374 });
4375 })
4376 .unwrap();
4377
4378 cx.executor().advance_clock(Duration::from_secs(1));
4379 cx.executor().run_until_parked();
4380 singleton_editor.update(cx, |editor, cx| {
4381 assert_eq!(
4382 editor.display_text(cx),
4383 "testfn main() { l: i32et a = 2; }\n",
4384 "Newly opened editor should have the correct text with hints",
4385 );
4386 });
4387 assert_eq!(
4388 requests_count.load(atomic::Ordering::Acquire),
4389 // TODO kb this is wrong, should be 3
4390 4,
4391 "We have edited the buffer and should send a new request",
4392 );
4393
4394 window
4395 .update(cx, |_, window, cx| {
4396 singleton_editor.update(cx, |editor, cx| {
4397 editor.undo(&editor::actions::Undo, window, cx);
4398 });
4399 })
4400 .unwrap();
4401 cx.executor().advance_clock(Duration::from_secs(1));
4402 cx.executor().run_until_parked();
4403 assert_eq!(
4404 requests_count.load(atomic::Ordering::Acquire),
4405 // TODO kb this is wrong, should be 4
4406 6,
4407 "We have edited the buffer again and should send a new request again",
4408 );
4409 singleton_editor.update(cx, |editor, cx| {
4410 assert_eq!(
4411 editor.display_text(cx),
4412 "fn main() { let a: i32 = 2; }\n",
4413 "Newly opened editor should have the correct text with hints",
4414 );
4415 });
4416
4417 perform_search(search_view, "let ", cx);
4418 cx.executor().advance_clock(Duration::from_secs(1));
4419 cx.executor().run_until_parked();
4420 assert_eq!(
4421 requests_count.load(atomic::Ordering::Acquire),
4422 6,
4423 "New project search should reuse the cached hints",
4424 );
4425 search_view
4426 .update(cx, |search_view, _, cx| {
4427 assert_eq!(
4428 search_view
4429 .results_editor
4430 .update(cx, |editor, cx| editor.display_text(cx)),
4431 "\n\nfn main() { let a: i32 = 2; }\n"
4432 );
4433 })
4434 .unwrap();
4435 }
4436
4437 fn init_test(cx: &mut TestAppContext) {
4438 cx.update(|cx| {
4439 let settings = SettingsStore::test(cx);
4440 cx.set_global(settings);
4441
4442 theme::init(theme::LoadThemes::JustBase, cx);
4443
4444 language::init(cx);
4445 client::init_settings(cx);
4446 editor::init(cx);
4447 workspace::init_settings(cx);
4448 Project::init_settings(cx);
4449 crate::init(cx);
4450 });
4451 }
4452
4453 fn perform_search(
4454 search_view: WindowHandle<ProjectSearchView>,
4455 text: impl Into<Arc<str>>,
4456 cx: &mut TestAppContext,
4457 ) {
4458 search_view
4459 .update(cx, |search_view, window, cx| {
4460 search_view.query_editor.update(cx, |query_editor, cx| {
4461 query_editor.set_text(text, window, cx)
4462 });
4463 search_view.search(cx);
4464 })
4465 .unwrap();
4466 // Ensure editor highlights appear after the search is done
4467 cx.executor().advance_clock(
4468 editor::SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(100),
4469 );
4470 cx.background_executor.run_until_parked();
4471 }
4472}