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