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