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