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 search.focus_query_editor(window, cx)
992 });
993 }
994
995 fn search(&mut self, cx: &mut Context<Self>) {
996 if let Some(query) = self.build_search_query(cx) {
997 self.entity.update(cx, |model, cx| model.search(query, cx));
998 }
999 }
1000
1001 pub fn search_query_text(&self, cx: &App) -> String {
1002 self.query_editor.read(cx).text(cx)
1003 }
1004
1005 fn build_search_query(&mut self, cx: &mut Context<Self>) -> Option<SearchQuery> {
1006 // Do not bail early in this function, as we want to fill out `self.panels_with_errors`.
1007 let text = self.query_editor.read(cx).text(cx);
1008 let open_buffers = if self.included_opened_only {
1009 Some(self.open_buffers(cx))
1010 } else {
1011 None
1012 };
1013 let included_files = self
1014 .filters_enabled
1015 .then(|| {
1016 match Self::parse_path_matches(&self.included_files_editor.read(cx).text(cx)) {
1017 Ok(included_files) => {
1018 let should_unmark_error =
1019 self.panels_with_errors.remove(&InputPanel::Include);
1020 if should_unmark_error {
1021 cx.notify();
1022 }
1023 included_files
1024 }
1025 Err(_e) => {
1026 let should_mark_error = self.panels_with_errors.insert(InputPanel::Include);
1027 if should_mark_error {
1028 cx.notify();
1029 }
1030 PathMatcher::default()
1031 }
1032 }
1033 })
1034 .unwrap_or_default();
1035 let excluded_files = self
1036 .filters_enabled
1037 .then(|| {
1038 match Self::parse_path_matches(&self.excluded_files_editor.read(cx).text(cx)) {
1039 Ok(excluded_files) => {
1040 let should_unmark_error =
1041 self.panels_with_errors.remove(&InputPanel::Exclude);
1042 if should_unmark_error {
1043 cx.notify();
1044 }
1045
1046 excluded_files
1047 }
1048 Err(_e) => {
1049 let should_mark_error = self.panels_with_errors.insert(InputPanel::Exclude);
1050 if should_mark_error {
1051 cx.notify();
1052 }
1053 PathMatcher::default()
1054 }
1055 }
1056 })
1057 .unwrap_or_default();
1058
1059 // If the project contains multiple visible worktrees, we match the
1060 // include/exclude patterns against full paths to allow them to be
1061 // disambiguated. For single worktree projects we use worktree relative
1062 // paths for convenience.
1063 let match_full_paths = self
1064 .entity
1065 .read(cx)
1066 .project
1067 .read(cx)
1068 .visible_worktrees(cx)
1069 .count()
1070 > 1;
1071
1072 let query = if self.search_options.contains(SearchOptions::REGEX) {
1073 match SearchQuery::regex(
1074 text,
1075 self.search_options.contains(SearchOptions::WHOLE_WORD),
1076 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1077 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1078 self.search_options
1079 .contains(SearchOptions::ONE_MATCH_PER_LINE),
1080 included_files,
1081 excluded_files,
1082 match_full_paths,
1083 open_buffers,
1084 ) {
1085 Ok(query) => {
1086 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
1087 if should_unmark_error {
1088 cx.notify();
1089 }
1090
1091 Some(query)
1092 }
1093 Err(_e) => {
1094 let should_mark_error = self.panels_with_errors.insert(InputPanel::Query);
1095 if should_mark_error {
1096 cx.notify();
1097 }
1098
1099 None
1100 }
1101 }
1102 } else {
1103 match SearchQuery::text(
1104 text,
1105 self.search_options.contains(SearchOptions::WHOLE_WORD),
1106 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1107 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1108 included_files,
1109 excluded_files,
1110 match_full_paths,
1111 open_buffers,
1112 ) {
1113 Ok(query) => {
1114 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
1115 if should_unmark_error {
1116 cx.notify();
1117 }
1118
1119 Some(query)
1120 }
1121 Err(_e) => {
1122 let should_mark_error = self.panels_with_errors.insert(InputPanel::Query);
1123 if should_mark_error {
1124 cx.notify();
1125 }
1126
1127 None
1128 }
1129 }
1130 };
1131 if !self.panels_with_errors.is_empty() {
1132 return None;
1133 }
1134 if query.as_ref().is_some_and(|query| query.is_empty()) {
1135 return None;
1136 }
1137 query
1138 }
1139
1140 fn open_buffers(&self, cx: &mut Context<Self>) -> Vec<Entity<Buffer>> {
1141 let mut buffers = Vec::new();
1142 self.workspace
1143 .update(cx, |workspace, cx| {
1144 for editor in workspace.items_of_type::<Editor>(cx) {
1145 if let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() {
1146 buffers.push(buffer);
1147 }
1148 }
1149 })
1150 .ok();
1151 buffers
1152 }
1153
1154 fn parse_path_matches(text: &str) -> anyhow::Result<PathMatcher> {
1155 let queries = text
1156 .split(',')
1157 .map(str::trim)
1158 .filter(|maybe_glob_str| !maybe_glob_str.is_empty())
1159 .map(str::to_owned)
1160 .collect::<Vec<_>>();
1161 Ok(PathMatcher::new(&queries)?)
1162 }
1163
1164 fn select_match(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
1165 if let Some(index) = self.active_match_index {
1166 let match_ranges = self.entity.read(cx).match_ranges.clone();
1167
1168 if !EditorSettings::get_global(cx).search_wrap
1169 && ((direction == Direction::Next && index + 1 >= match_ranges.len())
1170 || (direction == Direction::Prev && index == 0))
1171 {
1172 crate::show_no_more_matches(window, cx);
1173 return;
1174 }
1175
1176 let new_index = self.results_editor.update(cx, |editor, cx| {
1177 editor.match_index_for_direction(&match_ranges, index, direction, 1, window, cx)
1178 });
1179
1180 let range_to_select = match_ranges[new_index].clone();
1181 self.results_editor.update(cx, |editor, cx| {
1182 let range_to_select = editor.range_for_match(&range_to_select);
1183 editor.unfold_ranges(&[range_to_select.clone()], false, true, cx);
1184 editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
1185 s.select_ranges([range_to_select])
1186 });
1187 });
1188 }
1189 }
1190
1191 fn focus_query_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1192 self.query_editor.update(cx, |query_editor, cx| {
1193 query_editor.select_all(&SelectAll, window, cx);
1194 });
1195 let editor_handle = self.query_editor.focus_handle(cx);
1196 window.focus(&editor_handle);
1197 }
1198
1199 fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
1200 self.set_search_editor(SearchInputKind::Query, query, window, cx);
1201 if EditorSettings::get_global(cx).use_smartcase_search
1202 && !query.is_empty()
1203 && self.search_options.contains(SearchOptions::CASE_SENSITIVE)
1204 != is_contains_uppercase(query)
1205 {
1206 self.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx)
1207 }
1208 }
1209
1210 fn set_search_editor(
1211 &mut self,
1212 kind: SearchInputKind,
1213 text: &str,
1214 window: &mut Window,
1215 cx: &mut Context<Self>,
1216 ) {
1217 let editor = match kind {
1218 SearchInputKind::Query => &self.query_editor,
1219 SearchInputKind::Include => &self.included_files_editor,
1220
1221 SearchInputKind::Exclude => &self.excluded_files_editor,
1222 };
1223 editor.update(cx, |included_editor, cx| {
1224 included_editor.set_text(text, window, cx)
1225 });
1226 }
1227
1228 fn focus_results_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1229 self.query_editor.update(cx, |query_editor, cx| {
1230 let cursor = query_editor.selections.newest_anchor().head();
1231 query_editor.change_selections(None, window, cx, |s| s.select_ranges([cursor..cursor]));
1232 });
1233 let results_handle = self.results_editor.focus_handle(cx);
1234 window.focus(&results_handle);
1235 }
1236
1237 fn entity_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1238 let match_ranges = self.entity.read(cx).match_ranges.clone();
1239 if match_ranges.is_empty() {
1240 self.active_match_index = None;
1241 } else {
1242 self.active_match_index = Some(0);
1243 self.update_match_index(cx);
1244 let prev_search_id = mem::replace(&mut self.search_id, self.entity.read(cx).search_id);
1245 let is_new_search = self.search_id != prev_search_id;
1246 self.results_editor.update(cx, |editor, cx| {
1247 if is_new_search {
1248 let range_to_select = match_ranges
1249 .first()
1250 .map(|range| editor.range_for_match(range));
1251 editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
1252 s.select_ranges(range_to_select)
1253 });
1254 editor.scroll(Point::default(), Some(Axis::Vertical), window, cx);
1255 }
1256 editor.highlight_background::<Self>(
1257 &match_ranges,
1258 |theme| theme.search_match_background,
1259 cx,
1260 );
1261 });
1262 if is_new_search && self.query_editor.focus_handle(cx).is_focused(window) {
1263 self.focus_results_editor(window, cx);
1264 }
1265 }
1266
1267 cx.emit(ViewEvent::UpdateTab);
1268 cx.notify();
1269 }
1270
1271 fn update_match_index(&mut self, cx: &mut Context<Self>) {
1272 let results_editor = self.results_editor.read(cx);
1273 let new_index = active_match_index(
1274 Direction::Next,
1275 &self.entity.read(cx).match_ranges,
1276 &results_editor.selections.newest_anchor().head(),
1277 &results_editor.buffer().read(cx).snapshot(cx),
1278 );
1279 if self.active_match_index != new_index {
1280 self.active_match_index = new_index;
1281 cx.notify();
1282 }
1283 }
1284
1285 pub fn has_matches(&self) -> bool {
1286 self.active_match_index.is_some()
1287 }
1288
1289 fn landing_text_minor(&self, window: &mut Window, cx: &App) -> impl IntoElement {
1290 let focus_handle = self.focus_handle.clone();
1291 v_flex()
1292 .gap_1()
1293 .child(
1294 Label::new("Hit enter to search. For more options:")
1295 .color(Color::Muted)
1296 .mb_2(),
1297 )
1298 .child(
1299 Button::new("filter-paths", "Include/exclude specific paths")
1300 .icon(IconName::Filter)
1301 .icon_position(IconPosition::Start)
1302 .icon_size(IconSize::Small)
1303 .key_binding(KeyBinding::for_action_in(
1304 &ToggleFilters,
1305 &focus_handle,
1306 window,
1307 cx,
1308 ))
1309 .on_click(|_event, window, cx| {
1310 window.dispatch_action(ToggleFilters.boxed_clone(), cx)
1311 }),
1312 )
1313 .child(
1314 Button::new("find-replace", "Find and replace")
1315 .icon(IconName::Replace)
1316 .icon_position(IconPosition::Start)
1317 .icon_size(IconSize::Small)
1318 .key_binding(KeyBinding::for_action_in(
1319 &ToggleReplace,
1320 &focus_handle,
1321 window,
1322 cx,
1323 ))
1324 .on_click(|_event, window, cx| {
1325 window.dispatch_action(ToggleReplace.boxed_clone(), cx)
1326 }),
1327 )
1328 .child(
1329 Button::new("regex", "Match with regex")
1330 .icon(IconName::Regex)
1331 .icon_position(IconPosition::Start)
1332 .icon_size(IconSize::Small)
1333 .key_binding(KeyBinding::for_action_in(
1334 &ToggleRegex,
1335 &focus_handle,
1336 window,
1337 cx,
1338 ))
1339 .on_click(|_event, window, cx| {
1340 window.dispatch_action(ToggleRegex.boxed_clone(), cx)
1341 }),
1342 )
1343 .child(
1344 Button::new("match-case", "Match case")
1345 .icon(IconName::CaseSensitive)
1346 .icon_position(IconPosition::Start)
1347 .icon_size(IconSize::Small)
1348 .key_binding(KeyBinding::for_action_in(
1349 &ToggleCaseSensitive,
1350 &focus_handle,
1351 window,
1352 cx,
1353 ))
1354 .on_click(|_event, window, cx| {
1355 window.dispatch_action(ToggleCaseSensitive.boxed_clone(), cx)
1356 }),
1357 )
1358 .child(
1359 Button::new("match-whole-words", "Match whole words")
1360 .icon(IconName::WholeWord)
1361 .icon_position(IconPosition::Start)
1362 .icon_size(IconSize::Small)
1363 .key_binding(KeyBinding::for_action_in(
1364 &ToggleWholeWord,
1365 &focus_handle,
1366 window,
1367 cx,
1368 ))
1369 .on_click(|_event, window, cx| {
1370 window.dispatch_action(ToggleWholeWord.boxed_clone(), cx)
1371 }),
1372 )
1373 }
1374
1375 fn border_color_for(&self, panel: InputPanel, cx: &App) -> Hsla {
1376 if self.panels_with_errors.contains(&panel) {
1377 Color::Error.color(cx)
1378 } else {
1379 cx.theme().colors().border
1380 }
1381 }
1382
1383 fn move_focus_to_results(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1384 if !self.results_editor.focus_handle(cx).is_focused(window)
1385 && !self.entity.read(cx).match_ranges.is_empty()
1386 {
1387 cx.stop_propagation();
1388 self.focus_results_editor(window, cx)
1389 }
1390 }
1391
1392 #[cfg(any(test, feature = "test-support"))]
1393 pub fn results_editor(&self) -> &Entity<Editor> {
1394 &self.results_editor
1395 }
1396
1397 fn adjust_query_regex_language(&self, cx: &mut App) {
1398 let enable = self.search_options.contains(SearchOptions::REGEX);
1399 let query_buffer = self
1400 .query_editor
1401 .read(cx)
1402 .buffer()
1403 .read(cx)
1404 .as_singleton()
1405 .expect("query editor should be backed by a singleton buffer");
1406 if enable {
1407 if let Some(regex_language) = self.regex_language.clone() {
1408 query_buffer.update(cx, |query_buffer, cx| {
1409 query_buffer.set_language(Some(regex_language), cx);
1410 })
1411 }
1412 } else {
1413 query_buffer.update(cx, |query_buffer, cx| {
1414 query_buffer.set_language(None, cx);
1415 })
1416 }
1417 }
1418}
1419
1420fn buffer_search_query(
1421 workspace: &mut Workspace,
1422 item: &dyn ItemHandle,
1423 cx: &mut Context<Workspace>,
1424) -> Option<String> {
1425 let buffer_search_bar = workspace
1426 .pane_for(item)
1427 .and_then(|pane| {
1428 pane.read(cx)
1429 .toolbar()
1430 .read(cx)
1431 .item_of_type::<BufferSearchBar>()
1432 })?
1433 .read(cx);
1434 if buffer_search_bar.query_editor_focused() {
1435 let buffer_search_query = buffer_search_bar.query(cx);
1436 if !buffer_search_query.is_empty() {
1437 return Some(buffer_search_query);
1438 }
1439 }
1440 None
1441}
1442
1443impl Default for ProjectSearchBar {
1444 fn default() -> Self {
1445 Self::new()
1446 }
1447}
1448
1449impl ProjectSearchBar {
1450 pub fn new() -> Self {
1451 Self {
1452 active_project_search: None,
1453 subscription: None,
1454 }
1455 }
1456
1457 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1458 if let Some(search_view) = self.active_project_search.as_ref() {
1459 search_view.update(cx, |search_view, cx| {
1460 if !search_view
1461 .replacement_editor
1462 .focus_handle(cx)
1463 .is_focused(window)
1464 {
1465 cx.stop_propagation();
1466 search_view.search(cx);
1467 }
1468 });
1469 }
1470 }
1471
1472 fn tab(&mut self, _: &editor::actions::Tab, window: &mut Window, cx: &mut Context<Self>) {
1473 self.cycle_field(Direction::Next, window, cx);
1474 }
1475
1476 fn backtab(
1477 &mut self,
1478 _: &editor::actions::Backtab,
1479 window: &mut Window,
1480 cx: &mut Context<Self>,
1481 ) {
1482 self.cycle_field(Direction::Prev, window, cx);
1483 }
1484
1485 fn focus_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1486 if let Some(search_view) = self.active_project_search.as_ref() {
1487 search_view.update(cx, |search_view, cx| {
1488 search_view.query_editor.focus_handle(cx).focus(window);
1489 });
1490 }
1491 }
1492
1493 fn cycle_field(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
1494 let active_project_search = match &self.active_project_search {
1495 Some(active_project_search) => active_project_search,
1496
1497 None => {
1498 return;
1499 }
1500 };
1501
1502 active_project_search.update(cx, |project_view, cx| {
1503 let mut views = vec![&project_view.query_editor];
1504 if project_view.replace_enabled {
1505 views.push(&project_view.replacement_editor);
1506 }
1507 if project_view.filters_enabled {
1508 views.extend([
1509 &project_view.included_files_editor,
1510 &project_view.excluded_files_editor,
1511 ]);
1512 }
1513 let current_index = match views
1514 .iter()
1515 .enumerate()
1516 .find(|(_, editor)| editor.focus_handle(cx).is_focused(window))
1517 {
1518 Some((index, _)) => index,
1519 None => return,
1520 };
1521
1522 let new_index = match direction {
1523 Direction::Next => (current_index + 1) % views.len(),
1524 Direction::Prev if current_index == 0 => views.len() - 1,
1525 Direction::Prev => (current_index - 1) % views.len(),
1526 };
1527 let next_focus_handle = views[new_index].focus_handle(cx);
1528 window.focus(&next_focus_handle);
1529 cx.stop_propagation();
1530 });
1531 }
1532
1533 fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut Context<Self>) -> bool {
1534 if let Some(search_view) = self.active_project_search.as_ref() {
1535 search_view.update(cx, |search_view, cx| {
1536 search_view.toggle_search_option(option, cx);
1537 if search_view.entity.read(cx).active_query.is_some() {
1538 search_view.search(cx);
1539 }
1540 });
1541 cx.notify();
1542 true
1543 } else {
1544 false
1545 }
1546 }
1547
1548 fn toggle_replace(&mut self, _: &ToggleReplace, window: &mut Window, cx: &mut Context<Self>) {
1549 if let Some(search) = &self.active_project_search {
1550 search.update(cx, |this, cx| {
1551 this.replace_enabled = !this.replace_enabled;
1552 let editor_to_focus = if this.replace_enabled {
1553 this.replacement_editor.focus_handle(cx)
1554 } else {
1555 this.query_editor.focus_handle(cx)
1556 };
1557 window.focus(&editor_to_focus);
1558 cx.notify();
1559 });
1560 }
1561 }
1562
1563 fn toggle_filters(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1564 if let Some(search_view) = self.active_project_search.as_ref() {
1565 search_view.update(cx, |search_view, cx| {
1566 search_view.toggle_filters(cx);
1567 search_view
1568 .included_files_editor
1569 .update(cx, |_, cx| cx.notify());
1570 search_view
1571 .excluded_files_editor
1572 .update(cx, |_, cx| cx.notify());
1573 window.refresh();
1574 cx.notify();
1575 });
1576 cx.notify();
1577 true
1578 } else {
1579 false
1580 }
1581 }
1582
1583 fn toggle_opened_only(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1584 if let Some(search_view) = self.active_project_search.as_ref() {
1585 search_view.update(cx, |search_view, cx| {
1586 search_view.toggle_opened_only(window, cx);
1587 if search_view.entity.read(cx).active_query.is_some() {
1588 search_view.search(cx);
1589 }
1590 });
1591
1592 cx.notify();
1593 true
1594 } else {
1595 false
1596 }
1597 }
1598
1599 fn is_opened_only_enabled(&self, cx: &App) -> bool {
1600 if let Some(search_view) = self.active_project_search.as_ref() {
1601 search_view.read(cx).included_opened_only
1602 } else {
1603 false
1604 }
1605 }
1606
1607 fn move_focus_to_results(&self, window: &mut Window, cx: &mut Context<Self>) {
1608 if let Some(search_view) = self.active_project_search.as_ref() {
1609 search_view.update(cx, |search_view, cx| {
1610 search_view.move_focus_to_results(window, cx);
1611 });
1612 cx.notify();
1613 }
1614 }
1615
1616 fn is_option_enabled(&self, option: SearchOptions, cx: &App) -> bool {
1617 if let Some(search) = self.active_project_search.as_ref() {
1618 search.read(cx).search_options.contains(option)
1619 } else {
1620 false
1621 }
1622 }
1623
1624 fn next_history_query(
1625 &mut self,
1626 _: &NextHistoryQuery,
1627 window: &mut Window,
1628 cx: &mut Context<Self>,
1629 ) {
1630 if let Some(search_view) = self.active_project_search.as_ref() {
1631 search_view.update(cx, |search_view, cx| {
1632 for (editor, kind) in [
1633 (search_view.query_editor.clone(), SearchInputKind::Query),
1634 (
1635 search_view.included_files_editor.clone(),
1636 SearchInputKind::Include,
1637 ),
1638 (
1639 search_view.excluded_files_editor.clone(),
1640 SearchInputKind::Exclude,
1641 ),
1642 ] {
1643 if editor.focus_handle(cx).is_focused(window) {
1644 let new_query = search_view.entity.update(cx, |model, cx| {
1645 let project = model.project.clone();
1646
1647 if let Some(new_query) = project.update(cx, |project, _| {
1648 project
1649 .search_history_mut(kind)
1650 .next(model.cursor_mut(kind))
1651 .map(str::to_string)
1652 }) {
1653 new_query
1654 } else {
1655 model.cursor_mut(kind).reset();
1656 String::new()
1657 }
1658 });
1659 search_view.set_search_editor(kind, &new_query, window, cx);
1660 }
1661 }
1662 });
1663 }
1664 }
1665
1666 fn previous_history_query(
1667 &mut self,
1668 _: &PreviousHistoryQuery,
1669 window: &mut Window,
1670 cx: &mut Context<Self>,
1671 ) {
1672 if let Some(search_view) = self.active_project_search.as_ref() {
1673 search_view.update(cx, |search_view, cx| {
1674 for (editor, kind) in [
1675 (search_view.query_editor.clone(), SearchInputKind::Query),
1676 (
1677 search_view.included_files_editor.clone(),
1678 SearchInputKind::Include,
1679 ),
1680 (
1681 search_view.excluded_files_editor.clone(),
1682 SearchInputKind::Exclude,
1683 ),
1684 ] {
1685 if editor.focus_handle(cx).is_focused(window) {
1686 if editor.read(cx).text(cx).is_empty() {
1687 if let Some(new_query) = search_view
1688 .entity
1689 .read(cx)
1690 .project
1691 .read(cx)
1692 .search_history(kind)
1693 .current(search_view.entity.read(cx).cursor(kind))
1694 .map(str::to_string)
1695 {
1696 search_view.set_search_editor(kind, &new_query, window, cx);
1697 return;
1698 }
1699 }
1700
1701 if let Some(new_query) = search_view.entity.update(cx, |model, cx| {
1702 let project = model.project.clone();
1703 project.update(cx, |project, _| {
1704 project
1705 .search_history_mut(kind)
1706 .previous(model.cursor_mut(kind))
1707 .map(str::to_string)
1708 })
1709 }) {
1710 search_view.set_search_editor(kind, &new_query, window, cx);
1711 }
1712 }
1713 }
1714 });
1715 }
1716 }
1717
1718 fn select_next_match(
1719 &mut self,
1720 _: &SelectNextMatch,
1721 window: &mut Window,
1722 cx: &mut Context<Self>,
1723 ) {
1724 if let Some(search) = self.active_project_search.as_ref() {
1725 search.update(cx, |this, cx| {
1726 this.select_match(Direction::Next, window, cx);
1727 })
1728 }
1729 }
1730
1731 fn select_prev_match(
1732 &mut self,
1733 _: &SelectPreviousMatch,
1734 window: &mut Window,
1735 cx: &mut Context<Self>,
1736 ) {
1737 if let Some(search) = self.active_project_search.as_ref() {
1738 search.update(cx, |this, cx| {
1739 this.select_match(Direction::Prev, window, cx);
1740 })
1741 }
1742 }
1743
1744 fn render_text_input(&self, editor: &Entity<Editor>, cx: &Context<Self>) -> impl IntoElement {
1745 let (color, use_syntax) = if editor.read(cx).read_only(cx) {
1746 (cx.theme().colors().text_disabled, false)
1747 } else {
1748 (cx.theme().colors().text, true)
1749 };
1750 let settings = ThemeSettings::get_global(cx);
1751 let text_style = TextStyle {
1752 color,
1753 font_family: settings.buffer_font.family.clone(),
1754 font_features: settings.buffer_font.features.clone(),
1755 font_fallbacks: settings.buffer_font.fallbacks.clone(),
1756 font_size: rems(0.875).into(),
1757 font_weight: settings.buffer_font.weight,
1758 line_height: relative(1.3),
1759 ..TextStyle::default()
1760 };
1761
1762 let mut editor_style = EditorStyle {
1763 background: cx.theme().colors().toolbar_background,
1764 local_player: cx.theme().players().local(),
1765 text: text_style,
1766 ..EditorStyle::default()
1767 };
1768 if use_syntax {
1769 editor_style.syntax = cx.theme().syntax().clone();
1770 }
1771
1772 EditorElement::new(editor, editor_style)
1773 }
1774}
1775
1776impl Render for ProjectSearchBar {
1777 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1778 let Some(search) = self.active_project_search.clone() else {
1779 return div();
1780 };
1781 let search = search.read(cx);
1782 let focus_handle = search.focus_handle(cx);
1783
1784 let container_width = window.viewport_size().width;
1785 let input_width = SearchInputWidth::calc_width(container_width);
1786
1787 enum BaseStyle {
1788 SingleInput,
1789 MultipleInputs,
1790 }
1791
1792 let input_base_styles = |base_style: BaseStyle| {
1793 h_flex()
1794 .min_w_32()
1795 .map(|div| match base_style {
1796 BaseStyle::SingleInput => div.w(input_width),
1797 BaseStyle::MultipleInputs => div.flex_grow(),
1798 })
1799 .h_8()
1800 .pl_2()
1801 .pr_1()
1802 .py_1()
1803 .border_1()
1804 .border_color(search.border_color_for(InputPanel::Query, cx))
1805 .rounded_lg()
1806 };
1807
1808 let query_column = input_base_styles(BaseStyle::SingleInput)
1809 .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
1810 .on_action(cx.listener(|this, action, window, cx| {
1811 this.previous_history_query(action, window, cx)
1812 }))
1813 .on_action(
1814 cx.listener(|this, action, window, cx| this.next_history_query(action, window, cx)),
1815 )
1816 .child(self.render_text_input(&search.query_editor, cx))
1817 .child(
1818 h_flex()
1819 .gap_1()
1820 .child(SearchOptions::CASE_SENSITIVE.as_button(
1821 self.is_option_enabled(SearchOptions::CASE_SENSITIVE, cx),
1822 focus_handle.clone(),
1823 cx.listener(|this, _, _, cx| {
1824 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1825 }),
1826 ))
1827 .child(SearchOptions::WHOLE_WORD.as_button(
1828 self.is_option_enabled(SearchOptions::WHOLE_WORD, cx),
1829 focus_handle.clone(),
1830 cx.listener(|this, _, _, cx| {
1831 this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1832 }),
1833 ))
1834 .child(SearchOptions::REGEX.as_button(
1835 self.is_option_enabled(SearchOptions::REGEX, cx),
1836 focus_handle.clone(),
1837 cx.listener(|this, _, _, cx| {
1838 this.toggle_search_option(SearchOptions::REGEX, cx);
1839 }),
1840 )),
1841 );
1842
1843 let mode_column = h_flex()
1844 .gap_1()
1845 .child(
1846 IconButton::new("project-search-filter-button", IconName::Filter)
1847 .shape(IconButtonShape::Square)
1848 .tooltip(|window, cx| {
1849 Tooltip::for_action("Toggle Filters", &ToggleFilters, window, cx)
1850 })
1851 .on_click(cx.listener(|this, _, window, cx| {
1852 this.toggle_filters(window, cx);
1853 }))
1854 .toggle_state(
1855 self.active_project_search
1856 .as_ref()
1857 .map(|search| search.read(cx).filters_enabled)
1858 .unwrap_or_default(),
1859 )
1860 .tooltip({
1861 let focus_handle = focus_handle.clone();
1862 move |window, cx| {
1863 Tooltip::for_action_in(
1864 "Toggle Filters",
1865 &ToggleFilters,
1866 &focus_handle,
1867 window,
1868 cx,
1869 )
1870 }
1871 }),
1872 )
1873 .child(
1874 IconButton::new("project-search-toggle-replace", IconName::Replace)
1875 .shape(IconButtonShape::Square)
1876 .on_click(cx.listener(|this, _, window, cx| {
1877 this.toggle_replace(&ToggleReplace, window, cx);
1878 }))
1879 .toggle_state(
1880 self.active_project_search
1881 .as_ref()
1882 .map(|search| search.read(cx).replace_enabled)
1883 .unwrap_or_default(),
1884 )
1885 .tooltip({
1886 let focus_handle = focus_handle.clone();
1887 move |window, cx| {
1888 Tooltip::for_action_in(
1889 "Toggle Replace",
1890 &ToggleReplace,
1891 &focus_handle,
1892 window,
1893 cx,
1894 )
1895 }
1896 }),
1897 );
1898
1899 let limit_reached = search.entity.read(cx).limit_reached;
1900
1901 let match_text = search
1902 .active_match_index
1903 .and_then(|index| {
1904 let index = index + 1;
1905 let match_quantity = search.entity.read(cx).match_ranges.len();
1906 if match_quantity > 0 {
1907 debug_assert!(match_quantity >= index);
1908 if limit_reached {
1909 Some(format!("{index}/{match_quantity}+").to_string())
1910 } else {
1911 Some(format!("{index}/{match_quantity}").to_string())
1912 }
1913 } else {
1914 None
1915 }
1916 })
1917 .unwrap_or_else(|| "0/0".to_string());
1918
1919 let matches_column = h_flex()
1920 .pl_2()
1921 .ml_2()
1922 .border_l_1()
1923 .border_color(cx.theme().colors().border_variant)
1924 .child(
1925 IconButton::new("project-search-prev-match", IconName::ChevronLeft)
1926 .shape(IconButtonShape::Square)
1927 .disabled(search.active_match_index.is_none())
1928 .on_click(cx.listener(|this, _, window, cx| {
1929 if let Some(search) = this.active_project_search.as_ref() {
1930 search.update(cx, |this, cx| {
1931 this.select_match(Direction::Prev, window, cx);
1932 })
1933 }
1934 }))
1935 .tooltip({
1936 let focus_handle = focus_handle.clone();
1937 move |window, cx| {
1938 Tooltip::for_action_in(
1939 "Go To Previous Match",
1940 &SelectPreviousMatch,
1941 &focus_handle,
1942 window,
1943 cx,
1944 )
1945 }
1946 }),
1947 )
1948 .child(
1949 IconButton::new("project-search-next-match", IconName::ChevronRight)
1950 .shape(IconButtonShape::Square)
1951 .disabled(search.active_match_index.is_none())
1952 .on_click(cx.listener(|this, _, window, cx| {
1953 if let Some(search) = this.active_project_search.as_ref() {
1954 search.update(cx, |this, cx| {
1955 this.select_match(Direction::Next, window, cx);
1956 })
1957 }
1958 }))
1959 .tooltip({
1960 let focus_handle = focus_handle.clone();
1961 move |window, cx| {
1962 Tooltip::for_action_in(
1963 "Go To Next Match",
1964 &SelectNextMatch,
1965 &focus_handle,
1966 window,
1967 cx,
1968 )
1969 }
1970 }),
1971 )
1972 .child(
1973 div()
1974 .id("matches")
1975 .ml_1()
1976 .child(Label::new(match_text).size(LabelSize::Small).color(
1977 if search.active_match_index.is_some() {
1978 Color::Default
1979 } else {
1980 Color::Disabled
1981 },
1982 ))
1983 .when(limit_reached, |el| {
1984 el.tooltip(Tooltip::text(
1985 "Search limits reached.\nTry narrowing your search.",
1986 ))
1987 }),
1988 );
1989
1990 let search_line = h_flex()
1991 .w_full()
1992 .gap_2()
1993 .child(query_column)
1994 .child(h_flex().min_w_64().child(mode_column).child(matches_column));
1995
1996 let replace_line = search.replace_enabled.then(|| {
1997 let replace_column = input_base_styles(BaseStyle::SingleInput)
1998 .child(self.render_text_input(&search.replacement_editor, cx));
1999
2000 let focus_handle = search.replacement_editor.read(cx).focus_handle(cx);
2001
2002 let replace_actions =
2003 h_flex()
2004 .min_w_64()
2005 .gap_1()
2006 .when(search.replace_enabled, |this| {
2007 this.child(
2008 IconButton::new("project-search-replace-next", IconName::ReplaceNext)
2009 .shape(IconButtonShape::Square)
2010 .on_click(cx.listener(|this, _, window, cx| {
2011 if let Some(search) = this.active_project_search.as_ref() {
2012 search.update(cx, |this, cx| {
2013 this.replace_next(&ReplaceNext, window, cx);
2014 })
2015 }
2016 }))
2017 .tooltip({
2018 let focus_handle = focus_handle.clone();
2019 move |window, cx| {
2020 Tooltip::for_action_in(
2021 "Replace Next Match",
2022 &ReplaceNext,
2023 &focus_handle,
2024 window,
2025 cx,
2026 )
2027 }
2028 }),
2029 )
2030 .child(
2031 IconButton::new("project-search-replace-all", IconName::ReplaceAll)
2032 .shape(IconButtonShape::Square)
2033 .on_click(cx.listener(|this, _, window, cx| {
2034 if let Some(search) = this.active_project_search.as_ref() {
2035 search.update(cx, |this, cx| {
2036 this.replace_all(&ReplaceAll, window, cx);
2037 })
2038 }
2039 }))
2040 .tooltip({
2041 let focus_handle = focus_handle.clone();
2042 move |window, cx| {
2043 Tooltip::for_action_in(
2044 "Replace All Matches",
2045 &ReplaceAll,
2046 &focus_handle,
2047 window,
2048 cx,
2049 )
2050 }
2051 }),
2052 )
2053 });
2054
2055 h_flex()
2056 .w_full()
2057 .gap_2()
2058 .child(replace_column)
2059 .child(replace_actions)
2060 });
2061
2062 let filter_line = search.filters_enabled.then(|| {
2063 h_flex()
2064 .w_full()
2065 .gap_2()
2066 .child(
2067 h_flex()
2068 .gap_2()
2069 .w(input_width)
2070 .child(
2071 input_base_styles(BaseStyle::MultipleInputs)
2072 .on_action(cx.listener(|this, action, window, cx| {
2073 this.previous_history_query(action, window, cx)
2074 }))
2075 .on_action(cx.listener(|this, action, window, cx| {
2076 this.next_history_query(action, window, cx)
2077 }))
2078 .child(self.render_text_input(&search.included_files_editor, cx)),
2079 )
2080 .child(
2081 input_base_styles(BaseStyle::MultipleInputs)
2082 .on_action(cx.listener(|this, action, window, cx| {
2083 this.previous_history_query(action, window, cx)
2084 }))
2085 .on_action(cx.listener(|this, action, window, cx| {
2086 this.next_history_query(action, window, cx)
2087 }))
2088 .child(self.render_text_input(&search.excluded_files_editor, cx)),
2089 ),
2090 )
2091 .child(
2092 h_flex()
2093 .min_w_64()
2094 .gap_1()
2095 .child(
2096 IconButton::new("project-search-opened-only", IconName::FileSearch)
2097 .shape(IconButtonShape::Square)
2098 .toggle_state(self.is_opened_only_enabled(cx))
2099 .tooltip(Tooltip::text("Only Search Open Files"))
2100 .on_click(cx.listener(|this, _, window, cx| {
2101 this.toggle_opened_only(window, cx);
2102 })),
2103 )
2104 .child(
2105 SearchOptions::INCLUDE_IGNORED.as_button(
2106 search
2107 .search_options
2108 .contains(SearchOptions::INCLUDE_IGNORED),
2109 focus_handle.clone(),
2110 cx.listener(|this, _, _, cx| {
2111 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
2112 }),
2113 ),
2114 ),
2115 )
2116 });
2117
2118 let mut key_context = KeyContext::default();
2119
2120 key_context.add("ProjectSearchBar");
2121
2122 if search
2123 .replacement_editor
2124 .focus_handle(cx)
2125 .is_focused(window)
2126 {
2127 key_context.add("in_replace");
2128 }
2129
2130 v_flex()
2131 .py(px(1.0))
2132 .key_context(key_context)
2133 .on_action(cx.listener(|this, _: &ToggleFocus, window, cx| {
2134 this.move_focus_to_results(window, cx)
2135 }))
2136 .on_action(cx.listener(|this, _: &ToggleFilters, window, cx| {
2137 this.toggle_filters(window, cx);
2138 }))
2139 .capture_action(cx.listener(|this, action, window, cx| {
2140 this.tab(action, window, cx);
2141 cx.stop_propagation();
2142 }))
2143 .capture_action(cx.listener(|this, action, window, cx| {
2144 this.backtab(action, window, cx);
2145 cx.stop_propagation();
2146 }))
2147 .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
2148 .on_action(cx.listener(|this, action, window, cx| {
2149 this.toggle_replace(action, window, cx);
2150 }))
2151 .on_action(cx.listener(|this, _: &ToggleWholeWord, _, cx| {
2152 this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
2153 }))
2154 .on_action(cx.listener(|this, _: &ToggleCaseSensitive, _, cx| {
2155 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
2156 }))
2157 .on_action(cx.listener(|this, action, window, cx| {
2158 if let Some(search) = this.active_project_search.as_ref() {
2159 search.update(cx, |this, cx| {
2160 this.replace_next(action, window, cx);
2161 })
2162 }
2163 }))
2164 .on_action(cx.listener(|this, action, window, cx| {
2165 if let Some(search) = this.active_project_search.as_ref() {
2166 search.update(cx, |this, cx| {
2167 this.replace_all(action, window, cx);
2168 })
2169 }
2170 }))
2171 .when(search.filters_enabled, |this| {
2172 this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, _, cx| {
2173 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
2174 }))
2175 })
2176 .on_action(cx.listener(Self::select_next_match))
2177 .on_action(cx.listener(Self::select_prev_match))
2178 .gap_2()
2179 .w_full()
2180 .child(search_line)
2181 .children(replace_line)
2182 .children(filter_line)
2183 }
2184}
2185
2186impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
2187
2188impl ToolbarItemView for ProjectSearchBar {
2189 fn set_active_pane_item(
2190 &mut self,
2191 active_pane_item: Option<&dyn ItemHandle>,
2192 _: &mut Window,
2193 cx: &mut Context<Self>,
2194 ) -> ToolbarItemLocation {
2195 cx.notify();
2196 self.subscription = None;
2197 self.active_project_search = None;
2198 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
2199 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
2200 self.active_project_search = Some(search);
2201 ToolbarItemLocation::PrimaryLeft {}
2202 } else {
2203 ToolbarItemLocation::Hidden
2204 }
2205 }
2206}
2207
2208fn register_workspace_action<A: Action>(
2209 workspace: &mut Workspace,
2210 callback: fn(&mut ProjectSearchBar, &A, &mut Window, &mut Context<ProjectSearchBar>),
2211) {
2212 workspace.register_action(move |workspace, action: &A, window, cx| {
2213 if workspace.has_active_modal(window, cx) {
2214 cx.propagate();
2215 return;
2216 }
2217
2218 workspace.active_pane().update(cx, |pane, cx| {
2219 pane.toolbar().update(cx, move |workspace, cx| {
2220 if let Some(search_bar) = workspace.item_of_type::<ProjectSearchBar>() {
2221 search_bar.update(cx, move |search_bar, cx| {
2222 if search_bar.active_project_search.is_some() {
2223 callback(search_bar, action, window, cx);
2224 cx.notify();
2225 } else {
2226 cx.propagate();
2227 }
2228 });
2229 }
2230 });
2231 })
2232 });
2233}
2234
2235fn register_workspace_action_for_present_search<A: Action>(
2236 workspace: &mut Workspace,
2237 callback: fn(&mut Workspace, &A, &mut Window, &mut Context<Workspace>),
2238) {
2239 workspace.register_action(move |workspace, action: &A, window, cx| {
2240 if workspace.has_active_modal(window, cx) {
2241 cx.propagate();
2242 return;
2243 }
2244
2245 let should_notify = workspace
2246 .active_pane()
2247 .read(cx)
2248 .toolbar()
2249 .read(cx)
2250 .item_of_type::<ProjectSearchBar>()
2251 .map(|search_bar| search_bar.read(cx).active_project_search.is_some())
2252 .unwrap_or(false);
2253 if should_notify {
2254 callback(workspace, action, window, cx);
2255 cx.notify();
2256 } else {
2257 cx.propagate();
2258 }
2259 });
2260}
2261
2262#[cfg(any(test, feature = "test-support"))]
2263pub fn perform_project_search(
2264 search_view: &Entity<ProjectSearchView>,
2265 text: impl Into<std::sync::Arc<str>>,
2266 cx: &mut gpui::VisualTestContext,
2267) {
2268 cx.run_until_parked();
2269 search_view.update_in(cx, |search_view, window, cx| {
2270 search_view.query_editor.update(cx, |query_editor, cx| {
2271 query_editor.set_text(text, window, cx)
2272 });
2273 search_view.search(cx);
2274 });
2275 cx.run_until_parked();
2276}
2277
2278#[cfg(test)]
2279pub mod tests {
2280 use std::{ops::Deref as _, sync::Arc};
2281
2282 use super::*;
2283 use editor::{DisplayPoint, display_map::DisplayRow};
2284 use gpui::{Action, TestAppContext, VisualTestContext, WindowHandle};
2285 use project::FakeFs;
2286 use serde_json::json;
2287 use settings::SettingsStore;
2288 use util::path;
2289 use workspace::DeploySearch;
2290
2291 #[gpui::test]
2292 async fn test_project_search(cx: &mut TestAppContext) {
2293 init_test(cx);
2294
2295 let fs = FakeFs::new(cx.background_executor.clone());
2296 fs.insert_tree(
2297 path!("/dir"),
2298 json!({
2299 "one.rs": "const ONE: usize = 1;",
2300 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2301 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2302 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2303 }),
2304 )
2305 .await;
2306 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2307 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2308 let workspace = window.root(cx).unwrap();
2309 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
2310 let search_view = cx.add_window(|window, cx| {
2311 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
2312 });
2313
2314 perform_search(search_view, "TWO", cx);
2315 search_view.update(cx, |search_view, window, cx| {
2316 assert_eq!(
2317 search_view
2318 .results_editor
2319 .update(cx, |editor, cx| editor.display_text(cx)),
2320 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
2321 );
2322 let match_background_color = cx.theme().colors().search_match_background;
2323 assert_eq!(
2324 search_view
2325 .results_editor
2326 .update(cx, |editor, cx| editor.all_text_background_highlights(window, cx)),
2327 &[
2328 (
2329 DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35),
2330 match_background_color
2331 ),
2332 (
2333 DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40),
2334 match_background_color
2335 ),
2336 (
2337 DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9),
2338 match_background_color
2339 )
2340 ]
2341 );
2342 assert_eq!(search_view.active_match_index, Some(0));
2343 assert_eq!(
2344 search_view
2345 .results_editor
2346 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2347 [DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35)]
2348 );
2349
2350 search_view.select_match(Direction::Next, window, cx);
2351 }).unwrap();
2352
2353 search_view
2354 .update(cx, |search_view, window, cx| {
2355 assert_eq!(search_view.active_match_index, Some(1));
2356 assert_eq!(
2357 search_view
2358 .results_editor
2359 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2360 [DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40)]
2361 );
2362 search_view.select_match(Direction::Next, window, cx);
2363 })
2364 .unwrap();
2365
2366 search_view
2367 .update(cx, |search_view, window, cx| {
2368 assert_eq!(search_view.active_match_index, Some(2));
2369 assert_eq!(
2370 search_view
2371 .results_editor
2372 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2373 [DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9)]
2374 );
2375 search_view.select_match(Direction::Next, window, cx);
2376 })
2377 .unwrap();
2378
2379 search_view
2380 .update(cx, |search_view, window, cx| {
2381 assert_eq!(search_view.active_match_index, Some(0));
2382 assert_eq!(
2383 search_view
2384 .results_editor
2385 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2386 [DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35)]
2387 );
2388 search_view.select_match(Direction::Prev, window, cx);
2389 })
2390 .unwrap();
2391
2392 search_view
2393 .update(cx, |search_view, window, cx| {
2394 assert_eq!(search_view.active_match_index, Some(2));
2395 assert_eq!(
2396 search_view
2397 .results_editor
2398 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2399 [DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9)]
2400 );
2401 search_view.select_match(Direction::Prev, window, cx);
2402 })
2403 .unwrap();
2404
2405 search_view
2406 .update(cx, |search_view, _, cx| {
2407 assert_eq!(search_view.active_match_index, Some(1));
2408 assert_eq!(
2409 search_view
2410 .results_editor
2411 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2412 [DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40)]
2413 );
2414 })
2415 .unwrap();
2416 }
2417
2418 #[gpui::test]
2419 async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
2420 init_test(cx);
2421
2422 let fs = FakeFs::new(cx.background_executor.clone());
2423 fs.insert_tree(
2424 "/dir",
2425 json!({
2426 "one.rs": "const ONE: usize = 1;",
2427 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2428 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2429 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2430 }),
2431 )
2432 .await;
2433 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2434 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2435 let workspace = window;
2436 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2437
2438 let active_item = cx.read(|cx| {
2439 workspace
2440 .read(cx)
2441 .unwrap()
2442 .active_pane()
2443 .read(cx)
2444 .active_item()
2445 .and_then(|item| item.downcast::<ProjectSearchView>())
2446 });
2447 assert!(
2448 active_item.is_none(),
2449 "Expected no search panel to be active"
2450 );
2451
2452 window
2453 .update(cx, move |workspace, window, cx| {
2454 assert_eq!(workspace.panes().len(), 1);
2455 workspace.panes()[0].update(cx, |pane, cx| {
2456 pane.toolbar()
2457 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2458 });
2459
2460 ProjectSearchView::deploy_search(
2461 workspace,
2462 &workspace::DeploySearch::find(),
2463 window,
2464 cx,
2465 )
2466 })
2467 .unwrap();
2468
2469 let Some(search_view) = cx.read(|cx| {
2470 workspace
2471 .read(cx)
2472 .unwrap()
2473 .active_pane()
2474 .read(cx)
2475 .active_item()
2476 .and_then(|item| item.downcast::<ProjectSearchView>())
2477 }) else {
2478 panic!("Search view expected to appear after new search event trigger")
2479 };
2480
2481 cx.spawn(|mut cx| async move {
2482 window
2483 .update(&mut cx, |_, window, cx| {
2484 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2485 })
2486 .unwrap();
2487 })
2488 .detach();
2489 cx.background_executor.run_until_parked();
2490 window
2491 .update(cx, |_, window, cx| {
2492 search_view.update(cx, |search_view, cx| {
2493 assert!(
2494 search_view.query_editor.focus_handle(cx).is_focused(window),
2495 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2496 );
2497 });
2498 }).unwrap();
2499
2500 window
2501 .update(cx, |_, window, cx| {
2502 search_view.update(cx, |search_view, cx| {
2503 let query_editor = &search_view.query_editor;
2504 assert!(
2505 query_editor.focus_handle(cx).is_focused(window),
2506 "Search view should be focused after the new search view is activated",
2507 );
2508 let query_text = query_editor.read(cx).text(cx);
2509 assert!(
2510 query_text.is_empty(),
2511 "New search query should be empty but got '{query_text}'",
2512 );
2513 let results_text = search_view
2514 .results_editor
2515 .update(cx, |editor, cx| editor.display_text(cx));
2516 assert!(
2517 results_text.is_empty(),
2518 "Empty search view should have no results but got '{results_text}'"
2519 );
2520 });
2521 })
2522 .unwrap();
2523
2524 window
2525 .update(cx, |_, window, cx| {
2526 search_view.update(cx, |search_view, cx| {
2527 search_view.query_editor.update(cx, |query_editor, cx| {
2528 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
2529 });
2530 search_view.search(cx);
2531 });
2532 })
2533 .unwrap();
2534 cx.background_executor.run_until_parked();
2535 window
2536 .update(cx, |_, window, cx| {
2537 search_view.update(cx, |search_view, cx| {
2538 let results_text = search_view
2539 .results_editor
2540 .update(cx, |editor, cx| editor.display_text(cx));
2541 assert!(
2542 results_text.is_empty(),
2543 "Search view for mismatching query should have no results but got '{results_text}'"
2544 );
2545 assert!(
2546 search_view.query_editor.focus_handle(cx).is_focused(window),
2547 "Search view should be focused after mismatching query had been used in search",
2548 );
2549 });
2550 }).unwrap();
2551
2552 cx.spawn(|mut cx| async move {
2553 window.update(&mut cx, |_, window, cx| {
2554 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2555 })
2556 })
2557 .detach();
2558 cx.background_executor.run_until_parked();
2559 window.update(cx, |_, window, cx| {
2560 search_view.update(cx, |search_view, cx| {
2561 assert!(
2562 search_view.query_editor.focus_handle(cx).is_focused(window),
2563 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2564 );
2565 });
2566 }).unwrap();
2567
2568 window
2569 .update(cx, |_, window, cx| {
2570 search_view.update(cx, |search_view, cx| {
2571 search_view.query_editor.update(cx, |query_editor, cx| {
2572 query_editor.set_text("TWO", window, cx)
2573 });
2574 search_view.search(cx);
2575 });
2576 })
2577 .unwrap();
2578 cx.background_executor.run_until_parked();
2579 window.update(cx, |_, window, cx| {
2580 search_view.update(cx, |search_view, cx| {
2581 assert_eq!(
2582 search_view
2583 .results_editor
2584 .update(cx, |editor, cx| editor.display_text(cx)),
2585 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2586 "Search view results should match the query"
2587 );
2588 assert!(
2589 search_view.results_editor.focus_handle(cx).is_focused(window),
2590 "Search view with mismatching query should be focused after search results are available",
2591 );
2592 });
2593 }).unwrap();
2594 cx.spawn(|mut cx| async move {
2595 window
2596 .update(&mut cx, |_, window, cx| {
2597 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2598 })
2599 .unwrap();
2600 })
2601 .detach();
2602 cx.background_executor.run_until_parked();
2603 window.update(cx, |_, window, cx| {
2604 search_view.update(cx, |search_view, cx| {
2605 assert!(
2606 search_view.results_editor.focus_handle(cx).is_focused(window),
2607 "Search view with matching query should still have its results editor focused after the toggle focus event",
2608 );
2609 });
2610 }).unwrap();
2611
2612 workspace
2613 .update(cx, |workspace, window, cx| {
2614 ProjectSearchView::deploy_search(
2615 workspace,
2616 &workspace::DeploySearch::find(),
2617 window,
2618 cx,
2619 )
2620 })
2621 .unwrap();
2622 window.update(cx, |_, window, cx| {
2623 search_view.update(cx, |search_view, cx| {
2624 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");
2625 assert_eq!(
2626 search_view
2627 .results_editor
2628 .update(cx, |editor, cx| editor.display_text(cx)),
2629 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2630 "Results should be unchanged after search view 2nd open in a row"
2631 );
2632 assert!(
2633 search_view.query_editor.focus_handle(cx).is_focused(window),
2634 "Focus should be moved into query editor again after search view 2nd open in a row"
2635 );
2636 });
2637 }).unwrap();
2638
2639 cx.spawn(|mut cx| async move {
2640 window
2641 .update(&mut cx, |_, window, cx| {
2642 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2643 })
2644 .unwrap();
2645 })
2646 .detach();
2647 cx.background_executor.run_until_parked();
2648 window.update(cx, |_, window, cx| {
2649 search_view.update(cx, |search_view, cx| {
2650 assert!(
2651 search_view.results_editor.focus_handle(cx).is_focused(window),
2652 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2653 );
2654 });
2655 }).unwrap();
2656 }
2657
2658 #[gpui::test]
2659 async fn test_filters_consider_toggle_state(cx: &mut TestAppContext) {
2660 init_test(cx);
2661
2662 let fs = FakeFs::new(cx.background_executor.clone());
2663 fs.insert_tree(
2664 "/dir",
2665 json!({
2666 "one.rs": "const ONE: usize = 1;",
2667 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2668 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2669 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2670 }),
2671 )
2672 .await;
2673 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2674 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2675 let workspace = window;
2676 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2677
2678 window
2679 .update(cx, move |workspace, window, cx| {
2680 workspace.panes()[0].update(cx, |pane, cx| {
2681 pane.toolbar()
2682 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2683 });
2684
2685 ProjectSearchView::deploy_search(
2686 workspace,
2687 &workspace::DeploySearch::find(),
2688 window,
2689 cx,
2690 )
2691 })
2692 .unwrap();
2693
2694 let Some(search_view) = cx.read(|cx| {
2695 workspace
2696 .read(cx)
2697 .unwrap()
2698 .active_pane()
2699 .read(cx)
2700 .active_item()
2701 .and_then(|item| item.downcast::<ProjectSearchView>())
2702 }) else {
2703 panic!("Search view expected to appear after new search event trigger")
2704 };
2705
2706 cx.spawn(|mut cx| async move {
2707 window
2708 .update(&mut cx, |_, window, cx| {
2709 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2710 })
2711 .unwrap();
2712 })
2713 .detach();
2714 cx.background_executor.run_until_parked();
2715
2716 window
2717 .update(cx, |_, window, cx| {
2718 search_view.update(cx, |search_view, cx| {
2719 search_view.query_editor.update(cx, |query_editor, cx| {
2720 query_editor.set_text("const FOUR", window, cx)
2721 });
2722 search_view.toggle_filters(cx);
2723 search_view
2724 .excluded_files_editor
2725 .update(cx, |exclude_editor, cx| {
2726 exclude_editor.set_text("four.rs", window, cx)
2727 });
2728 search_view.search(cx);
2729 });
2730 })
2731 .unwrap();
2732 cx.background_executor.run_until_parked();
2733 window
2734 .update(cx, |_, _, cx| {
2735 search_view.update(cx, |search_view, cx| {
2736 let results_text = search_view
2737 .results_editor
2738 .update(cx, |editor, cx| editor.display_text(cx));
2739 assert!(
2740 results_text.is_empty(),
2741 "Search view for query with the only match in an excluded file should have no results but got '{results_text}'"
2742 );
2743 });
2744 }).unwrap();
2745
2746 cx.spawn(|mut cx| async move {
2747 window.update(&mut cx, |_, window, cx| {
2748 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2749 })
2750 })
2751 .detach();
2752 cx.background_executor.run_until_parked();
2753
2754 window
2755 .update(cx, |_, _, cx| {
2756 search_view.update(cx, |search_view, cx| {
2757 search_view.toggle_filters(cx);
2758 search_view.search(cx);
2759 });
2760 })
2761 .unwrap();
2762 cx.background_executor.run_until_parked();
2763 window
2764 .update(cx, |_, _, cx| {
2765 search_view.update(cx, |search_view, cx| {
2766 assert_eq!(
2767 search_view
2768 .results_editor
2769 .update(cx, |editor, cx| editor.display_text(cx)),
2770 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
2771 "Search view results should contain the queried result in the previously excluded file with filters toggled off"
2772 );
2773 });
2774 })
2775 .unwrap();
2776 }
2777
2778 #[gpui::test]
2779 async fn test_new_project_search_focus(cx: &mut TestAppContext) {
2780 init_test(cx);
2781
2782 let fs = FakeFs::new(cx.background_executor.clone());
2783 fs.insert_tree(
2784 path!("/dir"),
2785 json!({
2786 "one.rs": "const ONE: usize = 1;",
2787 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2788 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2789 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2790 }),
2791 )
2792 .await;
2793 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2794 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2795 let workspace = window;
2796 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2797
2798 let active_item = cx.read(|cx| {
2799 workspace
2800 .read(cx)
2801 .unwrap()
2802 .active_pane()
2803 .read(cx)
2804 .active_item()
2805 .and_then(|item| item.downcast::<ProjectSearchView>())
2806 });
2807 assert!(
2808 active_item.is_none(),
2809 "Expected no search panel to be active"
2810 );
2811
2812 window
2813 .update(cx, move |workspace, window, cx| {
2814 assert_eq!(workspace.panes().len(), 1);
2815 workspace.panes()[0].update(cx, |pane, cx| {
2816 pane.toolbar()
2817 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2818 });
2819
2820 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
2821 })
2822 .unwrap();
2823
2824 let Some(search_view) = cx.read(|cx| {
2825 workspace
2826 .read(cx)
2827 .unwrap()
2828 .active_pane()
2829 .read(cx)
2830 .active_item()
2831 .and_then(|item| item.downcast::<ProjectSearchView>())
2832 }) else {
2833 panic!("Search view expected to appear after new search event trigger")
2834 };
2835
2836 cx.spawn(|mut cx| async move {
2837 window
2838 .update(&mut cx, |_, window, cx| {
2839 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2840 })
2841 .unwrap();
2842 })
2843 .detach();
2844 cx.background_executor.run_until_parked();
2845
2846 window.update(cx, |_, window, cx| {
2847 search_view.update(cx, |search_view, cx| {
2848 assert!(
2849 search_view.query_editor.focus_handle(cx).is_focused(window),
2850 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2851 );
2852 });
2853 }).unwrap();
2854
2855 window
2856 .update(cx, |_, window, cx| {
2857 search_view.update(cx, |search_view, cx| {
2858 let query_editor = &search_view.query_editor;
2859 assert!(
2860 query_editor.focus_handle(cx).is_focused(window),
2861 "Search view should be focused after the new search view is activated",
2862 );
2863 let query_text = query_editor.read(cx).text(cx);
2864 assert!(
2865 query_text.is_empty(),
2866 "New search query should be empty but got '{query_text}'",
2867 );
2868 let results_text = search_view
2869 .results_editor
2870 .update(cx, |editor, cx| editor.display_text(cx));
2871 assert!(
2872 results_text.is_empty(),
2873 "Empty search view should have no results but got '{results_text}'"
2874 );
2875 });
2876 })
2877 .unwrap();
2878
2879 window
2880 .update(cx, |_, window, cx| {
2881 search_view.update(cx, |search_view, cx| {
2882 search_view.query_editor.update(cx, |query_editor, cx| {
2883 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
2884 });
2885 search_view.search(cx);
2886 });
2887 })
2888 .unwrap();
2889
2890 cx.background_executor.run_until_parked();
2891 window
2892 .update(cx, |_, window, cx| {
2893 search_view.update(cx, |search_view, cx| {
2894 let results_text = search_view
2895 .results_editor
2896 .update(cx, |editor, cx| editor.display_text(cx));
2897 assert!(
2898 results_text.is_empty(),
2899 "Search view for mismatching query should have no results but got '{results_text}'"
2900 );
2901 assert!(
2902 search_view.query_editor.focus_handle(cx).is_focused(window),
2903 "Search view should be focused after mismatching query had been used in search",
2904 );
2905 });
2906 })
2907 .unwrap();
2908 cx.spawn(|mut cx| async move {
2909 window.update(&mut cx, |_, window, cx| {
2910 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2911 })
2912 })
2913 .detach();
2914 cx.background_executor.run_until_parked();
2915 window.update(cx, |_, window, cx| {
2916 search_view.update(cx, |search_view, cx| {
2917 assert!(
2918 search_view.query_editor.focus_handle(cx).is_focused(window),
2919 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2920 );
2921 });
2922 }).unwrap();
2923
2924 window
2925 .update(cx, |_, window, cx| {
2926 search_view.update(cx, |search_view, cx| {
2927 search_view.query_editor.update(cx, |query_editor, cx| {
2928 query_editor.set_text("TWO", window, cx)
2929 });
2930 search_view.search(cx);
2931 })
2932 })
2933 .unwrap();
2934 cx.background_executor.run_until_parked();
2935 window.update(cx, |_, window, cx|
2936 search_view.update(cx, |search_view, cx| {
2937 assert_eq!(
2938 search_view
2939 .results_editor
2940 .update(cx, |editor, cx| editor.display_text(cx)),
2941 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2942 "Search view results should match the query"
2943 );
2944 assert!(
2945 search_view.results_editor.focus_handle(cx).is_focused(window),
2946 "Search view with mismatching query should be focused after search results are available",
2947 );
2948 })).unwrap();
2949 cx.spawn(|mut cx| async move {
2950 window
2951 .update(&mut cx, |_, window, cx| {
2952 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2953 })
2954 .unwrap();
2955 })
2956 .detach();
2957 cx.background_executor.run_until_parked();
2958 window.update(cx, |_, window, cx| {
2959 search_view.update(cx, |search_view, cx| {
2960 assert!(
2961 search_view.results_editor.focus_handle(cx).is_focused(window),
2962 "Search view with matching query should still have its results editor focused after the toggle focus event",
2963 );
2964 });
2965 }).unwrap();
2966
2967 workspace
2968 .update(cx, |workspace, window, cx| {
2969 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
2970 })
2971 .unwrap();
2972 cx.background_executor.run_until_parked();
2973 let Some(search_view_2) = cx.read(|cx| {
2974 workspace
2975 .read(cx)
2976 .unwrap()
2977 .active_pane()
2978 .read(cx)
2979 .active_item()
2980 .and_then(|item| item.downcast::<ProjectSearchView>())
2981 }) else {
2982 panic!("Search view expected to appear after new search event trigger")
2983 };
2984 assert!(
2985 search_view_2 != search_view,
2986 "New search view should be open after `workspace::NewSearch` event"
2987 );
2988
2989 window.update(cx, |_, window, cx| {
2990 search_view.update(cx, |search_view, cx| {
2991 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
2992 assert_eq!(
2993 search_view
2994 .results_editor
2995 .update(cx, |editor, cx| editor.display_text(cx)),
2996 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2997 "Results of the first search view should not update too"
2998 );
2999 assert!(
3000 !search_view.query_editor.focus_handle(cx).is_focused(window),
3001 "Focus should be moved away from the first search view"
3002 );
3003 });
3004 }).unwrap();
3005
3006 window.update(cx, |_, window, cx| {
3007 search_view_2.update(cx, |search_view_2, cx| {
3008 assert_eq!(
3009 search_view_2.query_editor.read(cx).text(cx),
3010 "two",
3011 "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
3012 );
3013 assert_eq!(
3014 search_view_2
3015 .results_editor
3016 .update(cx, |editor, cx| editor.display_text(cx)),
3017 "",
3018 "No search results should be in the 2nd view yet, as we did not spawn a search for it"
3019 );
3020 assert!(
3021 search_view_2.query_editor.focus_handle(cx).is_focused(window),
3022 "Focus should be moved into query editor of the new window"
3023 );
3024 });
3025 }).unwrap();
3026
3027 window
3028 .update(cx, |_, window, cx| {
3029 search_view_2.update(cx, |search_view_2, cx| {
3030 search_view_2.query_editor.update(cx, |query_editor, cx| {
3031 query_editor.set_text("FOUR", window, cx)
3032 });
3033 search_view_2.search(cx);
3034 });
3035 })
3036 .unwrap();
3037
3038 cx.background_executor.run_until_parked();
3039 window.update(cx, |_, window, cx| {
3040 search_view_2.update(cx, |search_view_2, cx| {
3041 assert_eq!(
3042 search_view_2
3043 .results_editor
3044 .update(cx, |editor, cx| editor.display_text(cx)),
3045 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
3046 "New search view with the updated query should have new search results"
3047 );
3048 assert!(
3049 search_view_2.results_editor.focus_handle(cx).is_focused(window),
3050 "Search view with mismatching query should be focused after search results are available",
3051 );
3052 });
3053 }).unwrap();
3054
3055 cx.spawn(|mut cx| async move {
3056 window
3057 .update(&mut cx, |_, window, cx| {
3058 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3059 })
3060 .unwrap();
3061 })
3062 .detach();
3063 cx.background_executor.run_until_parked();
3064 window.update(cx, |_, window, cx| {
3065 search_view_2.update(cx, |search_view_2, cx| {
3066 assert!(
3067 search_view_2.results_editor.focus_handle(cx).is_focused(window),
3068 "Search view with matching query should switch focus to the results editor after the toggle focus event",
3069 );
3070 });}).unwrap();
3071 }
3072
3073 #[gpui::test]
3074 async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
3075 init_test(cx);
3076
3077 let fs = FakeFs::new(cx.background_executor.clone());
3078 fs.insert_tree(
3079 path!("/dir"),
3080 json!({
3081 "a": {
3082 "one.rs": "const ONE: usize = 1;",
3083 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3084 },
3085 "b": {
3086 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3087 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3088 },
3089 }),
3090 )
3091 .await;
3092 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3093 let worktree_id = project.read_with(cx, |project, cx| {
3094 project.worktrees(cx).next().unwrap().read(cx).id()
3095 });
3096 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3097 let workspace = window.root(cx).unwrap();
3098 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3099
3100 let active_item = cx.read(|cx| {
3101 workspace
3102 .read(cx)
3103 .active_pane()
3104 .read(cx)
3105 .active_item()
3106 .and_then(|item| item.downcast::<ProjectSearchView>())
3107 });
3108 assert!(
3109 active_item.is_none(),
3110 "Expected no search panel to be active"
3111 );
3112
3113 window
3114 .update(cx, move |workspace, window, cx| {
3115 assert_eq!(workspace.panes().len(), 1);
3116 workspace.panes()[0].update(cx, move |pane, cx| {
3117 pane.toolbar()
3118 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3119 });
3120 })
3121 .unwrap();
3122
3123 let a_dir_entry = cx.update(|cx| {
3124 workspace
3125 .read(cx)
3126 .project()
3127 .read(cx)
3128 .entry_for_path(&(worktree_id, "a").into(), cx)
3129 .expect("no entry for /a/ directory")
3130 });
3131 assert!(a_dir_entry.is_dir());
3132 window
3133 .update(cx, |workspace, window, cx| {
3134 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, window, cx)
3135 })
3136 .unwrap();
3137
3138 let Some(search_view) = cx.read(|cx| {
3139 workspace
3140 .read(cx)
3141 .active_pane()
3142 .read(cx)
3143 .active_item()
3144 .and_then(|item| item.downcast::<ProjectSearchView>())
3145 }) else {
3146 panic!("Search view expected to appear after new search in directory event trigger")
3147 };
3148 cx.background_executor.run_until_parked();
3149 window
3150 .update(cx, |_, window, cx| {
3151 search_view.update(cx, |search_view, cx| {
3152 assert!(
3153 search_view.query_editor.focus_handle(cx).is_focused(window),
3154 "On new search in directory, focus should be moved into query editor"
3155 );
3156 search_view.excluded_files_editor.update(cx, |editor, cx| {
3157 assert!(
3158 editor.display_text(cx).is_empty(),
3159 "New search in directory should not have any excluded files"
3160 );
3161 });
3162 search_view.included_files_editor.update(cx, |editor, cx| {
3163 assert_eq!(
3164 editor.display_text(cx),
3165 a_dir_entry.path.to_str().unwrap(),
3166 "New search in directory should have included dir entry path"
3167 );
3168 });
3169 });
3170 })
3171 .unwrap();
3172 window
3173 .update(cx, |_, window, cx| {
3174 search_view.update(cx, |search_view, cx| {
3175 search_view.query_editor.update(cx, |query_editor, cx| {
3176 query_editor.set_text("const", window, cx)
3177 });
3178 search_view.search(cx);
3179 });
3180 })
3181 .unwrap();
3182 cx.background_executor.run_until_parked();
3183 window
3184 .update(cx, |_, _, cx| {
3185 search_view.update(cx, |search_view, cx| {
3186 assert_eq!(
3187 search_view
3188 .results_editor
3189 .update(cx, |editor, cx| editor.display_text(cx)),
3190 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3191 "New search in directory should have a filter that matches a certain directory"
3192 );
3193 })
3194 })
3195 .unwrap();
3196 }
3197
3198 #[gpui::test]
3199 async fn test_search_query_history(cx: &mut TestAppContext) {
3200 init_test(cx);
3201
3202 let fs = FakeFs::new(cx.background_executor.clone());
3203 fs.insert_tree(
3204 path!("/dir"),
3205 json!({
3206 "one.rs": "const ONE: usize = 1;",
3207 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3208 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3209 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3210 }),
3211 )
3212 .await;
3213 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3214 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3215 let workspace = window.root(cx).unwrap();
3216 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3217
3218 window
3219 .update(cx, {
3220 let search_bar = search_bar.clone();
3221 |workspace, window, cx| {
3222 assert_eq!(workspace.panes().len(), 1);
3223 workspace.panes()[0].update(cx, |pane, cx| {
3224 pane.toolbar()
3225 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3226 });
3227
3228 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3229 }
3230 })
3231 .unwrap();
3232
3233 let search_view = cx.read(|cx| {
3234 workspace
3235 .read(cx)
3236 .active_pane()
3237 .read(cx)
3238 .active_item()
3239 .and_then(|item| item.downcast::<ProjectSearchView>())
3240 .expect("Search view expected to appear after new search event trigger")
3241 });
3242
3243 // Add 3 search items into the history + another unsubmitted one.
3244 window
3245 .update(cx, |_, window, cx| {
3246 search_view.update(cx, |search_view, cx| {
3247 search_view.search_options = SearchOptions::CASE_SENSITIVE;
3248 search_view.query_editor.update(cx, |query_editor, cx| {
3249 query_editor.set_text("ONE", window, cx)
3250 });
3251 search_view.search(cx);
3252 });
3253 })
3254 .unwrap();
3255
3256 cx.background_executor.run_until_parked();
3257 window
3258 .update(cx, |_, window, cx| {
3259 search_view.update(cx, |search_view, cx| {
3260 search_view.query_editor.update(cx, |query_editor, cx| {
3261 query_editor.set_text("TWO", window, cx)
3262 });
3263 search_view.search(cx);
3264 });
3265 })
3266 .unwrap();
3267 cx.background_executor.run_until_parked();
3268 window
3269 .update(cx, |_, window, cx| {
3270 search_view.update(cx, |search_view, cx| {
3271 search_view.query_editor.update(cx, |query_editor, cx| {
3272 query_editor.set_text("THREE", window, cx)
3273 });
3274 search_view.search(cx);
3275 })
3276 })
3277 .unwrap();
3278 cx.background_executor.run_until_parked();
3279 window
3280 .update(cx, |_, window, cx| {
3281 search_view.update(cx, |search_view, cx| {
3282 search_view.query_editor.update(cx, |query_editor, cx| {
3283 query_editor.set_text("JUST_TEXT_INPUT", window, cx)
3284 });
3285 })
3286 })
3287 .unwrap();
3288 cx.background_executor.run_until_parked();
3289
3290 // Ensure that the latest input with search settings is active.
3291 window
3292 .update(cx, |_, _, cx| {
3293 search_view.update(cx, |search_view, cx| {
3294 assert_eq!(
3295 search_view.query_editor.read(cx).text(cx),
3296 "JUST_TEXT_INPUT"
3297 );
3298 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3299 });
3300 })
3301 .unwrap();
3302
3303 // Next history query after the latest should set the query to the empty string.
3304 window
3305 .update(cx, |_, window, cx| {
3306 search_bar.update(cx, |search_bar, cx| {
3307 search_bar.focus_search(window, cx);
3308 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3309 })
3310 })
3311 .unwrap();
3312 window
3313 .update(cx, |_, _, cx| {
3314 search_view.update(cx, |search_view, cx| {
3315 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3316 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3317 });
3318 })
3319 .unwrap();
3320 window
3321 .update(cx, |_, window, cx| {
3322 search_bar.update(cx, |search_bar, cx| {
3323 search_bar.focus_search(window, cx);
3324 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3325 })
3326 })
3327 .unwrap();
3328 window
3329 .update(cx, |_, _, cx| {
3330 search_view.update(cx, |search_view, cx| {
3331 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3332 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3333 });
3334 })
3335 .unwrap();
3336
3337 // First previous query for empty current query should set the query to the latest submitted one.
3338 window
3339 .update(cx, |_, window, cx| {
3340 search_bar.update(cx, |search_bar, cx| {
3341 search_bar.focus_search(window, cx);
3342 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3343 });
3344 })
3345 .unwrap();
3346 window
3347 .update(cx, |_, _, cx| {
3348 search_view.update(cx, |search_view, cx| {
3349 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3350 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3351 });
3352 })
3353 .unwrap();
3354
3355 // Further previous items should go over the history in reverse order.
3356 window
3357 .update(cx, |_, window, cx| {
3358 search_bar.update(cx, |search_bar, cx| {
3359 search_bar.focus_search(window, cx);
3360 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3361 });
3362 })
3363 .unwrap();
3364 window
3365 .update(cx, |_, _, cx| {
3366 search_view.update(cx, |search_view, cx| {
3367 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3368 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3369 });
3370 })
3371 .unwrap();
3372
3373 // Previous items should never go behind the first history item.
3374 window
3375 .update(cx, |_, window, cx| {
3376 search_bar.update(cx, |search_bar, cx| {
3377 search_bar.focus_search(window, cx);
3378 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3379 });
3380 })
3381 .unwrap();
3382 window
3383 .update(cx, |_, _, cx| {
3384 search_view.update(cx, |search_view, cx| {
3385 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3386 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3387 });
3388 })
3389 .unwrap();
3390 window
3391 .update(cx, |_, window, cx| {
3392 search_bar.update(cx, |search_bar, cx| {
3393 search_bar.focus_search(window, cx);
3394 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3395 });
3396 })
3397 .unwrap();
3398 window
3399 .update(cx, |_, _, cx| {
3400 search_view.update(cx, |search_view, cx| {
3401 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3402 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3403 });
3404 })
3405 .unwrap();
3406
3407 // Next items should go over the history in the original order.
3408 window
3409 .update(cx, |_, window, cx| {
3410 search_bar.update(cx, |search_bar, cx| {
3411 search_bar.focus_search(window, cx);
3412 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3413 });
3414 })
3415 .unwrap();
3416 window
3417 .update(cx, |_, _, cx| {
3418 search_view.update(cx, |search_view, cx| {
3419 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3420 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3421 });
3422 })
3423 .unwrap();
3424
3425 window
3426 .update(cx, |_, window, cx| {
3427 search_view.update(cx, |search_view, cx| {
3428 search_view.query_editor.update(cx, |query_editor, cx| {
3429 query_editor.set_text("TWO_NEW", window, cx)
3430 });
3431 search_view.search(cx);
3432 });
3433 })
3434 .unwrap();
3435 cx.background_executor.run_until_parked();
3436 window
3437 .update(cx, |_, _, cx| {
3438 search_view.update(cx, |search_view, cx| {
3439 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3440 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3441 });
3442 })
3443 .unwrap();
3444
3445 // New search input should add another entry to history and move the selection to the end of the history.
3446 window
3447 .update(cx, |_, window, cx| {
3448 search_bar.update(cx, |search_bar, cx| {
3449 search_bar.focus_search(window, cx);
3450 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3451 });
3452 })
3453 .unwrap();
3454 window
3455 .update(cx, |_, _, cx| {
3456 search_view.update(cx, |search_view, cx| {
3457 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3458 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3459 });
3460 })
3461 .unwrap();
3462 window
3463 .update(cx, |_, window, cx| {
3464 search_bar.update(cx, |search_bar, cx| {
3465 search_bar.focus_search(window, cx);
3466 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3467 });
3468 })
3469 .unwrap();
3470 window
3471 .update(cx, |_, _, cx| {
3472 search_view.update(cx, |search_view, cx| {
3473 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3474 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3475 });
3476 })
3477 .unwrap();
3478 window
3479 .update(cx, |_, window, cx| {
3480 search_bar.update(cx, |search_bar, cx| {
3481 search_bar.focus_search(window, cx);
3482 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3483 });
3484 })
3485 .unwrap();
3486 window
3487 .update(cx, |_, _, cx| {
3488 search_view.update(cx, |search_view, cx| {
3489 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3490 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3491 });
3492 })
3493 .unwrap();
3494 window
3495 .update(cx, |_, window, cx| {
3496 search_bar.update(cx, |search_bar, cx| {
3497 search_bar.focus_search(window, cx);
3498 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3499 });
3500 })
3501 .unwrap();
3502 window
3503 .update(cx, |_, _, cx| {
3504 search_view.update(cx, |search_view, cx| {
3505 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3506 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3507 });
3508 })
3509 .unwrap();
3510 window
3511 .update(cx, |_, window, cx| {
3512 search_bar.update(cx, |search_bar, cx| {
3513 search_bar.focus_search(window, cx);
3514 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3515 });
3516 })
3517 .unwrap();
3518 window
3519 .update(cx, |_, _, cx| {
3520 search_view.update(cx, |search_view, cx| {
3521 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3522 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3523 });
3524 })
3525 .unwrap();
3526 }
3527
3528 #[gpui::test]
3529 async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) {
3530 init_test(cx);
3531
3532 let fs = FakeFs::new(cx.background_executor.clone());
3533 fs.insert_tree(
3534 path!("/dir"),
3535 json!({
3536 "one.rs": "const ONE: usize = 1;",
3537 }),
3538 )
3539 .await;
3540 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3541 let worktree_id = project.update(cx, |this, cx| {
3542 this.worktrees(cx).next().unwrap().read(cx).id()
3543 });
3544
3545 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3546 let workspace = window.root(cx).unwrap();
3547
3548 let panes: Vec<_> = window
3549 .update(cx, |this, _, _| this.panes().to_owned())
3550 .unwrap();
3551
3552 let search_bar_1 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3553 let search_bar_2 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3554
3555 assert_eq!(panes.len(), 1);
3556 let first_pane = panes.first().cloned().unwrap();
3557 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3558 window
3559 .update(cx, |workspace, window, cx| {
3560 workspace.open_path(
3561 (worktree_id, "one.rs"),
3562 Some(first_pane.downgrade()),
3563 true,
3564 window,
3565 cx,
3566 )
3567 })
3568 .unwrap()
3569 .await
3570 .unwrap();
3571 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3572
3573 // Add a project search item to the first pane
3574 window
3575 .update(cx, {
3576 let search_bar = search_bar_1.clone();
3577 |workspace, window, cx| {
3578 first_pane.update(cx, |pane, cx| {
3579 pane.toolbar()
3580 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3581 });
3582
3583 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3584 }
3585 })
3586 .unwrap();
3587 let search_view_1 = cx.read(|cx| {
3588 workspace
3589 .read(cx)
3590 .active_item(cx)
3591 .and_then(|item| item.downcast::<ProjectSearchView>())
3592 .expect("Search view expected to appear after new search event trigger")
3593 });
3594
3595 let second_pane = window
3596 .update(cx, |workspace, window, cx| {
3597 workspace.split_and_clone(
3598 first_pane.clone(),
3599 workspace::SplitDirection::Right,
3600 window,
3601 cx,
3602 )
3603 })
3604 .unwrap()
3605 .unwrap();
3606 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3607
3608 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3609 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3610
3611 // Add a project search item to the second pane
3612 window
3613 .update(cx, {
3614 let search_bar = search_bar_2.clone();
3615 let pane = second_pane.clone();
3616 move |workspace, window, cx| {
3617 assert_eq!(workspace.panes().len(), 2);
3618 pane.update(cx, |pane, cx| {
3619 pane.toolbar()
3620 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3621 });
3622
3623 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3624 }
3625 })
3626 .unwrap();
3627
3628 let search_view_2 = cx.read(|cx| {
3629 workspace
3630 .read(cx)
3631 .active_item(cx)
3632 .and_then(|item| item.downcast::<ProjectSearchView>())
3633 .expect("Search view expected to appear after new search event trigger")
3634 });
3635
3636 cx.run_until_parked();
3637 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3638 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3639
3640 let update_search_view =
3641 |search_view: &Entity<ProjectSearchView>, query: &str, cx: &mut TestAppContext| {
3642 window
3643 .update(cx, |_, window, cx| {
3644 search_view.update(cx, |search_view, cx| {
3645 search_view.query_editor.update(cx, |query_editor, cx| {
3646 query_editor.set_text(query, window, cx)
3647 });
3648 search_view.search(cx);
3649 });
3650 })
3651 .unwrap();
3652 };
3653
3654 let active_query =
3655 |search_view: &Entity<ProjectSearchView>, cx: &mut TestAppContext| -> String {
3656 window
3657 .update(cx, |_, _, cx| {
3658 search_view.update(cx, |search_view, cx| {
3659 search_view.query_editor.read(cx).text(cx).to_string()
3660 })
3661 })
3662 .unwrap()
3663 };
3664
3665 let select_prev_history_item =
3666 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
3667 window
3668 .update(cx, |_, window, cx| {
3669 search_bar.update(cx, |search_bar, cx| {
3670 search_bar.focus_search(window, cx);
3671 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3672 })
3673 })
3674 .unwrap();
3675 };
3676
3677 let select_next_history_item =
3678 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
3679 window
3680 .update(cx, |_, window, cx| {
3681 search_bar.update(cx, |search_bar, cx| {
3682 search_bar.focus_search(window, cx);
3683 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3684 })
3685 })
3686 .unwrap();
3687 };
3688
3689 update_search_view(&search_view_1, "ONE", cx);
3690 cx.background_executor.run_until_parked();
3691
3692 update_search_view(&search_view_2, "TWO", cx);
3693 cx.background_executor.run_until_parked();
3694
3695 assert_eq!(active_query(&search_view_1, cx), "ONE");
3696 assert_eq!(active_query(&search_view_2, cx), "TWO");
3697
3698 // Selecting previous history item should select the query from search view 1.
3699 select_prev_history_item(&search_bar_2, cx);
3700 assert_eq!(active_query(&search_view_2, cx), "ONE");
3701
3702 // Selecting the previous history item should not change the query as it is already the first item.
3703 select_prev_history_item(&search_bar_2, cx);
3704 assert_eq!(active_query(&search_view_2, cx), "ONE");
3705
3706 // Changing the query in search view 2 should not affect the history of search view 1.
3707 assert_eq!(active_query(&search_view_1, cx), "ONE");
3708
3709 // Deploying a new search in search view 2
3710 update_search_view(&search_view_2, "THREE", cx);
3711 cx.background_executor.run_until_parked();
3712
3713 select_next_history_item(&search_bar_2, cx);
3714 assert_eq!(active_query(&search_view_2, cx), "");
3715
3716 select_prev_history_item(&search_bar_2, cx);
3717 assert_eq!(active_query(&search_view_2, cx), "THREE");
3718
3719 select_prev_history_item(&search_bar_2, cx);
3720 assert_eq!(active_query(&search_view_2, cx), "TWO");
3721
3722 select_prev_history_item(&search_bar_2, cx);
3723 assert_eq!(active_query(&search_view_2, cx), "ONE");
3724
3725 select_prev_history_item(&search_bar_2, cx);
3726 assert_eq!(active_query(&search_view_2, cx), "ONE");
3727
3728 // Search view 1 should now see the query from search view 2.
3729 assert_eq!(active_query(&search_view_1, cx), "ONE");
3730
3731 select_next_history_item(&search_bar_2, cx);
3732 assert_eq!(active_query(&search_view_2, cx), "TWO");
3733
3734 // Here is the new query from search view 2
3735 select_next_history_item(&search_bar_2, cx);
3736 assert_eq!(active_query(&search_view_2, cx), "THREE");
3737
3738 select_next_history_item(&search_bar_2, cx);
3739 assert_eq!(active_query(&search_view_2, cx), "");
3740
3741 select_next_history_item(&search_bar_1, cx);
3742 assert_eq!(active_query(&search_view_1, cx), "TWO");
3743
3744 select_next_history_item(&search_bar_1, cx);
3745 assert_eq!(active_query(&search_view_1, cx), "THREE");
3746
3747 select_next_history_item(&search_bar_1, cx);
3748 assert_eq!(active_query(&search_view_1, cx), "");
3749 }
3750
3751 #[gpui::test]
3752 async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
3753 init_test(cx);
3754
3755 // Setup 2 panes, both with a file open and one with a project search.
3756 let fs = FakeFs::new(cx.background_executor.clone());
3757 fs.insert_tree(
3758 path!("/dir"),
3759 json!({
3760 "one.rs": "const ONE: usize = 1;",
3761 }),
3762 )
3763 .await;
3764 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3765 let worktree_id = project.update(cx, |this, cx| {
3766 this.worktrees(cx).next().unwrap().read(cx).id()
3767 });
3768 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3769 let panes: Vec<_> = window
3770 .update(cx, |this, _, _| this.panes().to_owned())
3771 .unwrap();
3772 assert_eq!(panes.len(), 1);
3773 let first_pane = panes.first().cloned().unwrap();
3774 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3775 window
3776 .update(cx, |workspace, window, cx| {
3777 workspace.open_path(
3778 (worktree_id, "one.rs"),
3779 Some(first_pane.downgrade()),
3780 true,
3781 window,
3782 cx,
3783 )
3784 })
3785 .unwrap()
3786 .await
3787 .unwrap();
3788 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3789 let second_pane = window
3790 .update(cx, |workspace, window, cx| {
3791 workspace.split_and_clone(
3792 first_pane.clone(),
3793 workspace::SplitDirection::Right,
3794 window,
3795 cx,
3796 )
3797 })
3798 .unwrap()
3799 .unwrap();
3800 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3801 assert!(
3802 window
3803 .update(cx, |_, window, cx| second_pane
3804 .focus_handle(cx)
3805 .contains_focused(window, cx))
3806 .unwrap()
3807 );
3808 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3809 window
3810 .update(cx, {
3811 let search_bar = search_bar.clone();
3812 let pane = first_pane.clone();
3813 move |workspace, window, cx| {
3814 assert_eq!(workspace.panes().len(), 2);
3815 pane.update(cx, move |pane, cx| {
3816 pane.toolbar()
3817 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3818 });
3819 }
3820 })
3821 .unwrap();
3822
3823 // Add a project search item to the second pane
3824 window
3825 .update(cx, {
3826 let search_bar = search_bar.clone();
3827 |workspace, window, cx| {
3828 assert_eq!(workspace.panes().len(), 2);
3829 second_pane.update(cx, |pane, cx| {
3830 pane.toolbar()
3831 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3832 });
3833
3834 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3835 }
3836 })
3837 .unwrap();
3838
3839 cx.run_until_parked();
3840 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3841 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3842
3843 // Focus the first pane
3844 window
3845 .update(cx, |workspace, window, cx| {
3846 assert_eq!(workspace.active_pane(), &second_pane);
3847 second_pane.update(cx, |this, cx| {
3848 assert_eq!(this.active_item_index(), 1);
3849 this.activate_prev_item(false, window, cx);
3850 assert_eq!(this.active_item_index(), 0);
3851 });
3852 workspace.activate_pane_in_direction(workspace::SplitDirection::Left, window, cx);
3853 })
3854 .unwrap();
3855 window
3856 .update(cx, |workspace, _, cx| {
3857 assert_eq!(workspace.active_pane(), &first_pane);
3858 assert_eq!(first_pane.read(cx).items_len(), 1);
3859 assert_eq!(second_pane.read(cx).items_len(), 2);
3860 })
3861 .unwrap();
3862
3863 // Deploy a new search
3864 cx.dispatch_action(window.into(), DeploySearch::find());
3865
3866 // Both panes should now have a project search in them
3867 window
3868 .update(cx, |workspace, window, cx| {
3869 assert_eq!(workspace.active_pane(), &first_pane);
3870 first_pane.update(cx, |this, _| {
3871 assert_eq!(this.active_item_index(), 1);
3872 assert_eq!(this.items_len(), 2);
3873 });
3874 second_pane.update(cx, |this, cx| {
3875 assert!(!cx.focus_handle().contains_focused(window, cx));
3876 assert_eq!(this.items_len(), 2);
3877 });
3878 })
3879 .unwrap();
3880
3881 // Focus the second pane's non-search item
3882 window
3883 .update(cx, |_workspace, window, cx| {
3884 second_pane.update(cx, |pane, cx| pane.activate_next_item(true, window, cx));
3885 })
3886 .unwrap();
3887
3888 // Deploy a new search
3889 cx.dispatch_action(window.into(), DeploySearch::find());
3890
3891 // The project search view should now be focused in the second pane
3892 // And the number of items should be unchanged.
3893 window
3894 .update(cx, |_workspace, _, cx| {
3895 second_pane.update(cx, |pane, _cx| {
3896 assert!(
3897 pane.active_item()
3898 .unwrap()
3899 .downcast::<ProjectSearchView>()
3900 .is_some()
3901 );
3902
3903 assert_eq!(pane.items_len(), 2);
3904 });
3905 })
3906 .unwrap();
3907 }
3908
3909 #[gpui::test]
3910 async fn test_scroll_search_results_to_top(cx: &mut TestAppContext) {
3911 init_test(cx);
3912
3913 // We need many lines in the search results to be able to scroll the window
3914 let fs = FakeFs::new(cx.background_executor.clone());
3915 fs.insert_tree(
3916 path!("/dir"),
3917 json!({
3918 "1.txt": "\n\n\n\n\n A \n\n\n\n\n",
3919 "2.txt": "\n\n\n\n\n A \n\n\n\n\n",
3920 "3.rs": "\n\n\n\n\n A \n\n\n\n\n",
3921 "4.rs": "\n\n\n\n\n A \n\n\n\n\n",
3922 "5.rs": "\n\n\n\n\n A \n\n\n\n\n",
3923 "6.rs": "\n\n\n\n\n A \n\n\n\n\n",
3924 "7.rs": "\n\n\n\n\n A \n\n\n\n\n",
3925 "8.rs": "\n\n\n\n\n A \n\n\n\n\n",
3926 "9.rs": "\n\n\n\n\n A \n\n\n\n\n",
3927 "a.rs": "\n\n\n\n\n A \n\n\n\n\n",
3928 "b.rs": "\n\n\n\n\n B \n\n\n\n\n",
3929 "c.rs": "\n\n\n\n\n B \n\n\n\n\n",
3930 "d.rs": "\n\n\n\n\n B \n\n\n\n\n",
3931 "e.rs": "\n\n\n\n\n B \n\n\n\n\n",
3932 "f.rs": "\n\n\n\n\n B \n\n\n\n\n",
3933 "g.rs": "\n\n\n\n\n B \n\n\n\n\n",
3934 "h.rs": "\n\n\n\n\n B \n\n\n\n\n",
3935 "i.rs": "\n\n\n\n\n B \n\n\n\n\n",
3936 "j.rs": "\n\n\n\n\n B \n\n\n\n\n",
3937 "k.rs": "\n\n\n\n\n B \n\n\n\n\n",
3938 }),
3939 )
3940 .await;
3941 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3942 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3943 let workspace = window.root(cx).unwrap();
3944 let search = cx.new(|cx| ProjectSearch::new(project, cx));
3945 let search_view = cx.add_window(|window, cx| {
3946 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
3947 });
3948
3949 // First search
3950 perform_search(search_view, "A", cx);
3951 search_view
3952 .update(cx, |search_view, window, cx| {
3953 search_view.results_editor.update(cx, |results_editor, cx| {
3954 // Results are correct and scrolled to the top
3955 assert_eq!(
3956 results_editor.display_text(cx).match_indices(" A ").count(),
3957 10
3958 );
3959 assert_eq!(results_editor.scroll_position(cx), Point::default());
3960
3961 // Scroll results all the way down
3962 results_editor.scroll(
3963 Point::new(0., f32::MAX),
3964 Some(Axis::Vertical),
3965 window,
3966 cx,
3967 );
3968 });
3969 })
3970 .expect("unable to update search view");
3971
3972 // Second search
3973 perform_search(search_view, "B", cx);
3974 search_view
3975 .update(cx, |search_view, _, cx| {
3976 search_view.results_editor.update(cx, |results_editor, cx| {
3977 // Results are correct...
3978 assert_eq!(
3979 results_editor.display_text(cx).match_indices(" B ").count(),
3980 10
3981 );
3982 // ...and scrolled back to the top
3983 assert_eq!(results_editor.scroll_position(cx), Point::default());
3984 });
3985 })
3986 .expect("unable to update search view");
3987 }
3988
3989 #[gpui::test]
3990 async fn test_buffer_search_query_reused(cx: &mut TestAppContext) {
3991 init_test(cx);
3992
3993 let fs = FakeFs::new(cx.background_executor.clone());
3994 fs.insert_tree(
3995 path!("/dir"),
3996 json!({
3997 "one.rs": "const ONE: usize = 1;",
3998 }),
3999 )
4000 .await;
4001 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4002 let worktree_id = project.update(cx, |this, cx| {
4003 this.worktrees(cx).next().unwrap().read(cx).id()
4004 });
4005 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4006 let workspace = window.root(cx).unwrap();
4007 let mut cx = VisualTestContext::from_window(*window.deref(), cx);
4008
4009 let editor = workspace
4010 .update_in(&mut cx, |workspace, window, cx| {
4011 workspace.open_path((worktree_id, "one.rs"), None, true, window, cx)
4012 })
4013 .await
4014 .unwrap()
4015 .downcast::<Editor>()
4016 .unwrap();
4017
4018 // Wait for the unstaged changes to be loaded
4019 cx.run_until_parked();
4020
4021 let buffer_search_bar = cx.new_window_entity(|window, cx| {
4022 let mut search_bar =
4023 BufferSearchBar::new(Some(project.read(cx).languages().clone()), window, cx);
4024 search_bar.set_active_pane_item(Some(&editor), window, cx);
4025 search_bar.show(window, cx);
4026 search_bar
4027 });
4028
4029 let panes: Vec<_> = window
4030 .update(&mut cx, |this, _, _| this.panes().to_owned())
4031 .unwrap();
4032 assert_eq!(panes.len(), 1);
4033 let pane = panes.first().cloned().unwrap();
4034 pane.update_in(&mut cx, |pane, window, cx| {
4035 pane.toolbar().update(cx, |toolbar, cx| {
4036 toolbar.add_item(buffer_search_bar.clone(), window, cx);
4037 })
4038 });
4039
4040 let buffer_search_query = "search bar query";
4041 buffer_search_bar
4042 .update_in(&mut cx, |buffer_search_bar, window, cx| {
4043 buffer_search_bar.focus_handle(cx).focus(window);
4044 buffer_search_bar.search(buffer_search_query, None, window, cx)
4045 })
4046 .await
4047 .unwrap();
4048
4049 workspace.update_in(&mut cx, |workspace, window, cx| {
4050 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4051 });
4052 cx.run_until_parked();
4053 let project_search_view = pane
4054 .update(&mut cx, |pane, _| {
4055 pane.active_item()
4056 .and_then(|item| item.downcast::<ProjectSearchView>())
4057 })
4058 .expect("should open a project search view after spawning a new search");
4059 project_search_view.update(&mut cx, |search_view, cx| {
4060 assert_eq!(
4061 search_view.search_query_text(cx),
4062 buffer_search_query,
4063 "Project search should take the query from the buffer search bar since it got focused and had a query inside"
4064 );
4065 });
4066 }
4067
4068 fn init_test(cx: &mut TestAppContext) {
4069 cx.update(|cx| {
4070 let settings = SettingsStore::test(cx);
4071 cx.set_global(settings);
4072
4073 theme::init(theme::LoadThemes::JustBase, cx);
4074
4075 language::init(cx);
4076 client::init_settings(cx);
4077 editor::init(cx);
4078 workspace::init_settings(cx);
4079 Project::init_settings(cx);
4080 crate::init(cx);
4081 });
4082 }
4083
4084 fn perform_search(
4085 search_view: WindowHandle<ProjectSearchView>,
4086 text: impl Into<Arc<str>>,
4087 cx: &mut TestAppContext,
4088 ) {
4089 search_view
4090 .update(cx, |search_view, window, cx| {
4091 search_view.query_editor.update(cx, |query_editor, cx| {
4092 query_editor.set_text(text, window, cx)
4093 });
4094 search_view.search(cx);
4095 })
4096 .unwrap();
4097 cx.background_executor.run_until_parked();
4098 }
4099}