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