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