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