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