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