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 enum BaseStyle {
1778 SingleInput,
1779 MultipleInputs,
1780 }
1781
1782 let input_base_styles = |base_style: BaseStyle| {
1783 h_flex()
1784 .min_w_32()
1785 .map(|div| match base_style {
1786 BaseStyle::SingleInput => div.w(input_width),
1787 BaseStyle::MultipleInputs => div.flex_grow(),
1788 })
1789 .h_8()
1790 .pl_2()
1791 .pr_1()
1792 .py_1()
1793 .border_1()
1794 .border_color(search.border_color_for(InputPanel::Query, cx))
1795 .rounded_lg()
1796 };
1797
1798 let query_column = input_base_styles(BaseStyle::SingleInput)
1799 .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
1800 .on_action(cx.listener(|this, action, window, cx| {
1801 this.previous_history_query(action, window, cx)
1802 }))
1803 .on_action(
1804 cx.listener(|this, action, window, cx| this.next_history_query(action, window, cx)),
1805 )
1806 .child(self.render_text_input(&search.query_editor, cx))
1807 .child(
1808 h_flex()
1809 .gap_1()
1810 .child(SearchOptions::CASE_SENSITIVE.as_button(
1811 self.is_option_enabled(SearchOptions::CASE_SENSITIVE, cx),
1812 focus_handle.clone(),
1813 cx.listener(|this, _, _, cx| {
1814 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1815 }),
1816 ))
1817 .child(SearchOptions::WHOLE_WORD.as_button(
1818 self.is_option_enabled(SearchOptions::WHOLE_WORD, cx),
1819 focus_handle.clone(),
1820 cx.listener(|this, _, _, cx| {
1821 this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1822 }),
1823 ))
1824 .child(SearchOptions::REGEX.as_button(
1825 self.is_option_enabled(SearchOptions::REGEX, cx),
1826 focus_handle.clone(),
1827 cx.listener(|this, _, _, cx| {
1828 this.toggle_search_option(SearchOptions::REGEX, cx);
1829 }),
1830 )),
1831 );
1832
1833 let mode_column = h_flex()
1834 .gap_1()
1835 .child(
1836 IconButton::new("project-search-filter-button", IconName::Filter)
1837 .shape(IconButtonShape::Square)
1838 .tooltip(|window, cx| {
1839 Tooltip::for_action("Toggle Filters", &ToggleFilters, window, cx)
1840 })
1841 .on_click(cx.listener(|this, _, window, cx| {
1842 this.toggle_filters(window, cx);
1843 }))
1844 .toggle_state(
1845 self.active_project_search
1846 .as_ref()
1847 .map(|search| search.read(cx).filters_enabled)
1848 .unwrap_or_default(),
1849 )
1850 .tooltip({
1851 let focus_handle = focus_handle.clone();
1852 move |window, cx| {
1853 Tooltip::for_action_in(
1854 "Toggle Filters",
1855 &ToggleFilters,
1856 &focus_handle,
1857 window,
1858 cx,
1859 )
1860 }
1861 }),
1862 )
1863 .child(
1864 IconButton::new("project-search-toggle-replace", IconName::Replace)
1865 .shape(IconButtonShape::Square)
1866 .on_click(cx.listener(|this, _, window, cx| {
1867 this.toggle_replace(&ToggleReplace, window, cx);
1868 }))
1869 .toggle_state(
1870 self.active_project_search
1871 .as_ref()
1872 .map(|search| search.read(cx).replace_enabled)
1873 .unwrap_or_default(),
1874 )
1875 .tooltip({
1876 let focus_handle = focus_handle.clone();
1877 move |window, cx| {
1878 Tooltip::for_action_in(
1879 "Toggle Replace",
1880 &ToggleReplace,
1881 &focus_handle,
1882 window,
1883 cx,
1884 )
1885 }
1886 }),
1887 );
1888
1889 let limit_reached = search.entity.read(cx).limit_reached;
1890
1891 let match_text = search
1892 .active_match_index
1893 .and_then(|index| {
1894 let index = index + 1;
1895 let match_quantity = search.entity.read(cx).match_ranges.len();
1896 if match_quantity > 0 {
1897 debug_assert!(match_quantity >= index);
1898 if limit_reached {
1899 Some(format!("{index}/{match_quantity}+").to_string())
1900 } else {
1901 Some(format!("{index}/{match_quantity}").to_string())
1902 }
1903 } else {
1904 None
1905 }
1906 })
1907 .unwrap_or_else(|| "0/0".to_string());
1908
1909 let matches_column = h_flex()
1910 .pl_2()
1911 .ml_2()
1912 .border_l_1()
1913 .border_color(cx.theme().colors().border_variant)
1914 .child(
1915 IconButton::new("project-search-prev-match", IconName::ChevronLeft)
1916 .shape(IconButtonShape::Square)
1917 .disabled(search.active_match_index.is_none())
1918 .on_click(cx.listener(|this, _, window, cx| {
1919 if let Some(search) = this.active_project_search.as_ref() {
1920 search.update(cx, |this, cx| {
1921 this.select_match(Direction::Prev, window, cx);
1922 })
1923 }
1924 }))
1925 .tooltip({
1926 let focus_handle = focus_handle.clone();
1927 move |window, cx| {
1928 Tooltip::for_action_in(
1929 "Go To Previous Match",
1930 &SelectPreviousMatch,
1931 &focus_handle,
1932 window,
1933 cx,
1934 )
1935 }
1936 }),
1937 )
1938 .child(
1939 IconButton::new("project-search-next-match", IconName::ChevronRight)
1940 .shape(IconButtonShape::Square)
1941 .disabled(search.active_match_index.is_none())
1942 .on_click(cx.listener(|this, _, window, cx| {
1943 if let Some(search) = this.active_project_search.as_ref() {
1944 search.update(cx, |this, cx| {
1945 this.select_match(Direction::Next, window, cx);
1946 })
1947 }
1948 }))
1949 .tooltip({
1950 let focus_handle = focus_handle.clone();
1951 move |window, cx| {
1952 Tooltip::for_action_in(
1953 "Go To Next Match",
1954 &SelectNextMatch,
1955 &focus_handle,
1956 window,
1957 cx,
1958 )
1959 }
1960 }),
1961 )
1962 .child(
1963 div()
1964 .id("matches")
1965 .ml_1()
1966 .child(Label::new(match_text).size(LabelSize::Small).color(
1967 if search.active_match_index.is_some() {
1968 Color::Default
1969 } else {
1970 Color::Disabled
1971 },
1972 ))
1973 .when(limit_reached, |el| {
1974 el.tooltip(Tooltip::text(
1975 "Search limits reached.\nTry narrowing your search.",
1976 ))
1977 }),
1978 );
1979
1980 let search_line = h_flex()
1981 .w_full()
1982 .gap_2()
1983 .child(query_column)
1984 .child(h_flex().min_w_64().child(mode_column).child(matches_column));
1985
1986 let replace_line = search.replace_enabled.then(|| {
1987 let replace_column = input_base_styles(BaseStyle::SingleInput)
1988 .child(self.render_text_input(&search.replacement_editor, cx));
1989
1990 let focus_handle = search.replacement_editor.read(cx).focus_handle(cx);
1991
1992 let replace_actions =
1993 h_flex()
1994 .min_w_64()
1995 .gap_1()
1996 .when(search.replace_enabled, |this| {
1997 this.child(
1998 IconButton::new("project-search-replace-next", IconName::ReplaceNext)
1999 .shape(IconButtonShape::Square)
2000 .on_click(cx.listener(|this, _, window, cx| {
2001 if let Some(search) = this.active_project_search.as_ref() {
2002 search.update(cx, |this, cx| {
2003 this.replace_next(&ReplaceNext, window, cx);
2004 })
2005 }
2006 }))
2007 .tooltip({
2008 let focus_handle = focus_handle.clone();
2009 move |window, cx| {
2010 Tooltip::for_action_in(
2011 "Replace Next Match",
2012 &ReplaceNext,
2013 &focus_handle,
2014 window,
2015 cx,
2016 )
2017 }
2018 }),
2019 )
2020 .child(
2021 IconButton::new("project-search-replace-all", IconName::ReplaceAll)
2022 .shape(IconButtonShape::Square)
2023 .on_click(cx.listener(|this, _, window, cx| {
2024 if let Some(search) = this.active_project_search.as_ref() {
2025 search.update(cx, |this, cx| {
2026 this.replace_all(&ReplaceAll, window, cx);
2027 })
2028 }
2029 }))
2030 .tooltip({
2031 let focus_handle = focus_handle.clone();
2032 move |window, cx| {
2033 Tooltip::for_action_in(
2034 "Replace All Matches",
2035 &ReplaceAll,
2036 &focus_handle,
2037 window,
2038 cx,
2039 )
2040 }
2041 }),
2042 )
2043 });
2044
2045 h_flex()
2046 .w_full()
2047 .gap_2()
2048 .child(replace_column)
2049 .child(replace_actions)
2050 });
2051
2052 let filter_line = search.filters_enabled.then(|| {
2053 h_flex()
2054 .w_full()
2055 .gap_2()
2056 .child(
2057 h_flex()
2058 .gap_2()
2059 .w(input_width)
2060 .child(
2061 input_base_styles(BaseStyle::MultipleInputs)
2062 .on_action(cx.listener(|this, action, window, cx| {
2063 this.previous_history_query(action, window, cx)
2064 }))
2065 .on_action(cx.listener(|this, action, window, cx| {
2066 this.next_history_query(action, window, cx)
2067 }))
2068 .child(self.render_text_input(&search.included_files_editor, cx)),
2069 )
2070 .child(
2071 input_base_styles(BaseStyle::MultipleInputs)
2072 .on_action(cx.listener(|this, action, window, cx| {
2073 this.previous_history_query(action, window, cx)
2074 }))
2075 .on_action(cx.listener(|this, action, window, cx| {
2076 this.next_history_query(action, window, cx)
2077 }))
2078 .child(self.render_text_input(&search.excluded_files_editor, cx)),
2079 ),
2080 )
2081 .child(
2082 h_flex()
2083 .min_w_64()
2084 .gap_1()
2085 .child(
2086 IconButton::new("project-search-opened-only", IconName::FileSearch)
2087 .shape(IconButtonShape::Square)
2088 .toggle_state(self.is_opened_only_enabled(cx))
2089 .tooltip(Tooltip::text("Only Search Open Files"))
2090 .on_click(cx.listener(|this, _, window, cx| {
2091 this.toggle_opened_only(window, cx);
2092 })),
2093 )
2094 .child(
2095 SearchOptions::INCLUDE_IGNORED.as_button(
2096 search
2097 .search_options
2098 .contains(SearchOptions::INCLUDE_IGNORED),
2099 focus_handle.clone(),
2100 cx.listener(|this, _, _, cx| {
2101 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
2102 }),
2103 ),
2104 ),
2105 )
2106 });
2107
2108 let mut key_context = KeyContext::default();
2109
2110 key_context.add("ProjectSearchBar");
2111
2112 if search
2113 .replacement_editor
2114 .focus_handle(cx)
2115 .is_focused(window)
2116 {
2117 key_context.add("in_replace");
2118 }
2119
2120 v_flex()
2121 .py(px(1.0))
2122 .key_context(key_context)
2123 .on_action(cx.listener(|this, _: &ToggleFocus, window, cx| {
2124 this.move_focus_to_results(window, cx)
2125 }))
2126 .on_action(cx.listener(|this, _: &ToggleFilters, window, cx| {
2127 this.toggle_filters(window, cx);
2128 }))
2129 .capture_action(cx.listener(|this, action, window, cx| {
2130 this.tab(action, window, cx);
2131 cx.stop_propagation();
2132 }))
2133 .capture_action(cx.listener(|this, action, window, cx| {
2134 this.backtab(action, window, cx);
2135 cx.stop_propagation();
2136 }))
2137 .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
2138 .on_action(cx.listener(|this, action, window, cx| {
2139 this.toggle_replace(action, window, cx);
2140 }))
2141 .on_action(cx.listener(|this, _: &ToggleWholeWord, _, cx| {
2142 this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
2143 }))
2144 .on_action(cx.listener(|this, _: &ToggleCaseSensitive, _, cx| {
2145 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
2146 }))
2147 .on_action(cx.listener(|this, action, window, cx| {
2148 if let Some(search) = this.active_project_search.as_ref() {
2149 search.update(cx, |this, cx| {
2150 this.replace_next(action, window, cx);
2151 })
2152 }
2153 }))
2154 .on_action(cx.listener(|this, action, window, cx| {
2155 if let Some(search) = this.active_project_search.as_ref() {
2156 search.update(cx, |this, cx| {
2157 this.replace_all(action, window, cx);
2158 })
2159 }
2160 }))
2161 .when(search.filters_enabled, |this| {
2162 this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, _, cx| {
2163 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
2164 }))
2165 })
2166 .on_action(cx.listener(Self::select_next_match))
2167 .on_action(cx.listener(Self::select_prev_match))
2168 .gap_2()
2169 .w_full()
2170 .child(search_line)
2171 .children(replace_line)
2172 .children(filter_line)
2173 }
2174}
2175
2176impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
2177
2178impl ToolbarItemView for ProjectSearchBar {
2179 fn set_active_pane_item(
2180 &mut self,
2181 active_pane_item: Option<&dyn ItemHandle>,
2182 _: &mut Window,
2183 cx: &mut Context<Self>,
2184 ) -> ToolbarItemLocation {
2185 cx.notify();
2186 self.subscription = None;
2187 self.active_project_search = None;
2188 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
2189 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
2190 self.active_project_search = Some(search);
2191 ToolbarItemLocation::PrimaryLeft {}
2192 } else {
2193 ToolbarItemLocation::Hidden
2194 }
2195 }
2196}
2197
2198fn register_workspace_action<A: Action>(
2199 workspace: &mut Workspace,
2200 callback: fn(&mut ProjectSearchBar, &A, &mut Window, &mut Context<ProjectSearchBar>),
2201) {
2202 workspace.register_action(move |workspace, action: &A, window, cx| {
2203 if workspace.has_active_modal(window, cx) {
2204 cx.propagate();
2205 return;
2206 }
2207
2208 workspace.active_pane().update(cx, |pane, cx| {
2209 pane.toolbar().update(cx, move |workspace, cx| {
2210 if let Some(search_bar) = workspace.item_of_type::<ProjectSearchBar>() {
2211 search_bar.update(cx, move |search_bar, cx| {
2212 if search_bar.active_project_search.is_some() {
2213 callback(search_bar, action, window, cx);
2214 cx.notify();
2215 } else {
2216 cx.propagate();
2217 }
2218 });
2219 }
2220 });
2221 })
2222 });
2223}
2224
2225fn register_workspace_action_for_present_search<A: Action>(
2226 workspace: &mut Workspace,
2227 callback: fn(&mut Workspace, &A, &mut Window, &mut Context<Workspace>),
2228) {
2229 workspace.register_action(move |workspace, action: &A, window, cx| {
2230 if workspace.has_active_modal(window, cx) {
2231 cx.propagate();
2232 return;
2233 }
2234
2235 let should_notify = workspace
2236 .active_pane()
2237 .read(cx)
2238 .toolbar()
2239 .read(cx)
2240 .item_of_type::<ProjectSearchBar>()
2241 .map(|search_bar| search_bar.read(cx).active_project_search.is_some())
2242 .unwrap_or(false);
2243 if should_notify {
2244 callback(workspace, action, window, cx);
2245 cx.notify();
2246 } else {
2247 cx.propagate();
2248 }
2249 });
2250}
2251
2252#[cfg(any(test, feature = "test-support"))]
2253pub fn perform_project_search(
2254 search_view: &Entity<ProjectSearchView>,
2255 text: impl Into<std::sync::Arc<str>>,
2256 cx: &mut gpui::VisualTestContext,
2257) {
2258 cx.run_until_parked();
2259 search_view.update_in(cx, |search_view, window, cx| {
2260 search_view.query_editor.update(cx, |query_editor, cx| {
2261 query_editor.set_text(text, window, cx)
2262 });
2263 search_view.search(cx);
2264 });
2265 cx.run_until_parked();
2266}
2267
2268#[cfg(test)]
2269pub mod tests {
2270 use std::{ops::Deref as _, sync::Arc};
2271
2272 use super::*;
2273 use editor::{DisplayPoint, display_map::DisplayRow};
2274 use gpui::{Action, TestAppContext, VisualTestContext, WindowHandle};
2275 use project::FakeFs;
2276 use serde_json::json;
2277 use settings::SettingsStore;
2278 use util::path;
2279 use workspace::DeploySearch;
2280
2281 #[gpui::test]
2282 async fn test_project_search(cx: &mut TestAppContext) {
2283 init_test(cx);
2284
2285 let fs = FakeFs::new(cx.background_executor.clone());
2286 fs.insert_tree(
2287 path!("/dir"),
2288 json!({
2289 "one.rs": "const ONE: usize = 1;",
2290 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2291 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2292 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2293 }),
2294 )
2295 .await;
2296 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2297 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2298 let workspace = window.root(cx).unwrap();
2299 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
2300 let search_view = cx.add_window(|window, cx| {
2301 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
2302 });
2303
2304 perform_search(search_view, "TWO", cx);
2305 search_view.update(cx, |search_view, window, cx| {
2306 assert_eq!(
2307 search_view
2308 .results_editor
2309 .update(cx, |editor, cx| editor.display_text(cx)),
2310 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
2311 );
2312 let match_background_color = cx.theme().colors().search_match_background;
2313 assert_eq!(
2314 search_view
2315 .results_editor
2316 .update(cx, |editor, cx| editor.all_text_background_highlights(window, cx)),
2317 &[
2318 (
2319 DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35),
2320 match_background_color
2321 ),
2322 (
2323 DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40),
2324 match_background_color
2325 ),
2326 (
2327 DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9),
2328 match_background_color
2329 )
2330 ]
2331 );
2332 assert_eq!(search_view.active_match_index, Some(0));
2333 assert_eq!(
2334 search_view
2335 .results_editor
2336 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2337 [DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35)]
2338 );
2339
2340 search_view.select_match(Direction::Next, window, cx);
2341 }).unwrap();
2342
2343 search_view
2344 .update(cx, |search_view, window, cx| {
2345 assert_eq!(search_view.active_match_index, Some(1));
2346 assert_eq!(
2347 search_view
2348 .results_editor
2349 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2350 [DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40)]
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(2));
2359 assert_eq!(
2360 search_view
2361 .results_editor
2362 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2363 [DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9)]
2364 );
2365 search_view.select_match(Direction::Next, 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(0));
2372 assert_eq!(
2373 search_view
2374 .results_editor
2375 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2376 [DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35)]
2377 );
2378 search_view.select_match(Direction::Prev, window, cx);
2379 })
2380 .unwrap();
2381
2382 search_view
2383 .update(cx, |search_view, window, cx| {
2384 assert_eq!(search_view.active_match_index, Some(2));
2385 assert_eq!(
2386 search_view
2387 .results_editor
2388 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2389 [DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9)]
2390 );
2391 search_view.select_match(Direction::Prev, window, cx);
2392 })
2393 .unwrap();
2394
2395 search_view
2396 .update(cx, |search_view, _, cx| {
2397 assert_eq!(search_view.active_match_index, Some(1));
2398 assert_eq!(
2399 search_view
2400 .results_editor
2401 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2402 [DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40)]
2403 );
2404 })
2405 .unwrap();
2406 }
2407
2408 #[gpui::test]
2409 async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
2410 init_test(cx);
2411
2412 let fs = FakeFs::new(cx.background_executor.clone());
2413 fs.insert_tree(
2414 "/dir",
2415 json!({
2416 "one.rs": "const ONE: usize = 1;",
2417 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2418 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2419 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2420 }),
2421 )
2422 .await;
2423 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2424 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2425 let workspace = window;
2426 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2427
2428 let active_item = cx.read(|cx| {
2429 workspace
2430 .read(cx)
2431 .unwrap()
2432 .active_pane()
2433 .read(cx)
2434 .active_item()
2435 .and_then(|item| item.downcast::<ProjectSearchView>())
2436 });
2437 assert!(
2438 active_item.is_none(),
2439 "Expected no search panel to be active"
2440 );
2441
2442 window
2443 .update(cx, move |workspace, window, cx| {
2444 assert_eq!(workspace.panes().len(), 1);
2445 workspace.panes()[0].update(cx, |pane, cx| {
2446 pane.toolbar()
2447 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2448 });
2449
2450 ProjectSearchView::deploy_search(
2451 workspace,
2452 &workspace::DeploySearch::find(),
2453 window,
2454 cx,
2455 )
2456 })
2457 .unwrap();
2458
2459 let Some(search_view) = cx.read(|cx| {
2460 workspace
2461 .read(cx)
2462 .unwrap()
2463 .active_pane()
2464 .read(cx)
2465 .active_item()
2466 .and_then(|item| item.downcast::<ProjectSearchView>())
2467 }) else {
2468 panic!("Search view expected to appear after new search event trigger")
2469 };
2470
2471 cx.spawn(|mut cx| async move {
2472 window
2473 .update(&mut cx, |_, window, cx| {
2474 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2475 })
2476 .unwrap();
2477 })
2478 .detach();
2479 cx.background_executor.run_until_parked();
2480 window
2481 .update(cx, |_, window, cx| {
2482 search_view.update(cx, |search_view, cx| {
2483 assert!(
2484 search_view.query_editor.focus_handle(cx).is_focused(window),
2485 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2486 );
2487 });
2488 }).unwrap();
2489
2490 window
2491 .update(cx, |_, window, cx| {
2492 search_view.update(cx, |search_view, cx| {
2493 let query_editor = &search_view.query_editor;
2494 assert!(
2495 query_editor.focus_handle(cx).is_focused(window),
2496 "Search view should be focused after the new search view is activated",
2497 );
2498 let query_text = query_editor.read(cx).text(cx);
2499 assert!(
2500 query_text.is_empty(),
2501 "New search query should be empty but got '{query_text}'",
2502 );
2503 let results_text = search_view
2504 .results_editor
2505 .update(cx, |editor, cx| editor.display_text(cx));
2506 assert!(
2507 results_text.is_empty(),
2508 "Empty search view should have no results but got '{results_text}'"
2509 );
2510 });
2511 })
2512 .unwrap();
2513
2514 window
2515 .update(cx, |_, window, cx| {
2516 search_view.update(cx, |search_view, cx| {
2517 search_view.query_editor.update(cx, |query_editor, cx| {
2518 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
2519 });
2520 search_view.search(cx);
2521 });
2522 })
2523 .unwrap();
2524 cx.background_executor.run_until_parked();
2525 window
2526 .update(cx, |_, window, cx| {
2527 search_view.update(cx, |search_view, cx| {
2528 let results_text = search_view
2529 .results_editor
2530 .update(cx, |editor, cx| editor.display_text(cx));
2531 assert!(
2532 results_text.is_empty(),
2533 "Search view for mismatching query should have no results but got '{results_text}'"
2534 );
2535 assert!(
2536 search_view.query_editor.focus_handle(cx).is_focused(window),
2537 "Search view should be focused after mismatching query had been used in search",
2538 );
2539 });
2540 }).unwrap();
2541
2542 cx.spawn(|mut cx| async move {
2543 window.update(&mut cx, |_, window, cx| {
2544 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2545 })
2546 })
2547 .detach();
2548 cx.background_executor.run_until_parked();
2549 window.update(cx, |_, window, cx| {
2550 search_view.update(cx, |search_view, cx| {
2551 assert!(
2552 search_view.query_editor.focus_handle(cx).is_focused(window),
2553 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2554 );
2555 });
2556 }).unwrap();
2557
2558 window
2559 .update(cx, |_, window, cx| {
2560 search_view.update(cx, |search_view, cx| {
2561 search_view.query_editor.update(cx, |query_editor, cx| {
2562 query_editor.set_text("TWO", window, cx)
2563 });
2564 search_view.search(cx);
2565 });
2566 })
2567 .unwrap();
2568 cx.background_executor.run_until_parked();
2569 window.update(cx, |_, window, cx| {
2570 search_view.update(cx, |search_view, cx| {
2571 assert_eq!(
2572 search_view
2573 .results_editor
2574 .update(cx, |editor, cx| editor.display_text(cx)),
2575 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2576 "Search view results should match the query"
2577 );
2578 assert!(
2579 search_view.results_editor.focus_handle(cx).is_focused(window),
2580 "Search view with mismatching query should be focused after search results are available",
2581 );
2582 });
2583 }).unwrap();
2584 cx.spawn(|mut cx| async move {
2585 window
2586 .update(&mut cx, |_, window, cx| {
2587 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2588 })
2589 .unwrap();
2590 })
2591 .detach();
2592 cx.background_executor.run_until_parked();
2593 window.update(cx, |_, window, cx| {
2594 search_view.update(cx, |search_view, cx| {
2595 assert!(
2596 search_view.results_editor.focus_handle(cx).is_focused(window),
2597 "Search view with matching query should still have its results editor focused after the toggle focus event",
2598 );
2599 });
2600 }).unwrap();
2601
2602 workspace
2603 .update(cx, |workspace, window, cx| {
2604 ProjectSearchView::deploy_search(
2605 workspace,
2606 &workspace::DeploySearch::find(),
2607 window,
2608 cx,
2609 )
2610 })
2611 .unwrap();
2612 window.update(cx, |_, window, cx| {
2613 search_view.update(cx, |search_view, cx| {
2614 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");
2615 assert_eq!(
2616 search_view
2617 .results_editor
2618 .update(cx, |editor, cx| editor.display_text(cx)),
2619 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2620 "Results should be unchanged after search view 2nd open in a row"
2621 );
2622 assert!(
2623 search_view.query_editor.focus_handle(cx).is_focused(window),
2624 "Focus should be moved into query editor again after search view 2nd open in a row"
2625 );
2626 });
2627 }).unwrap();
2628
2629 cx.spawn(|mut cx| async move {
2630 window
2631 .update(&mut cx, |_, window, cx| {
2632 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2633 })
2634 .unwrap();
2635 })
2636 .detach();
2637 cx.background_executor.run_until_parked();
2638 window.update(cx, |_, window, cx| {
2639 search_view.update(cx, |search_view, cx| {
2640 assert!(
2641 search_view.results_editor.focus_handle(cx).is_focused(window),
2642 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2643 );
2644 });
2645 }).unwrap();
2646 }
2647
2648 #[gpui::test]
2649 async fn test_new_project_search_focus(cx: &mut TestAppContext) {
2650 init_test(cx);
2651
2652 let fs = FakeFs::new(cx.background_executor.clone());
2653 fs.insert_tree(
2654 path!("/dir"),
2655 json!({
2656 "one.rs": "const ONE: usize = 1;",
2657 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2658 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2659 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2660 }),
2661 )
2662 .await;
2663 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2664 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2665 let workspace = window;
2666 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2667
2668 let active_item = cx.read(|cx| {
2669 workspace
2670 .read(cx)
2671 .unwrap()
2672 .active_pane()
2673 .read(cx)
2674 .active_item()
2675 .and_then(|item| item.downcast::<ProjectSearchView>())
2676 });
2677 assert!(
2678 active_item.is_none(),
2679 "Expected no search panel to be active"
2680 );
2681
2682 window
2683 .update(cx, move |workspace, window, cx| {
2684 assert_eq!(workspace.panes().len(), 1);
2685 workspace.panes()[0].update(cx, |pane, cx| {
2686 pane.toolbar()
2687 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2688 });
2689
2690 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
2691 })
2692 .unwrap();
2693
2694 let Some(search_view) = cx.read(|cx| {
2695 workspace
2696 .read(cx)
2697 .unwrap()
2698 .active_pane()
2699 .read(cx)
2700 .active_item()
2701 .and_then(|item| item.downcast::<ProjectSearchView>())
2702 }) else {
2703 panic!("Search view expected to appear after new search event trigger")
2704 };
2705
2706 cx.spawn(|mut cx| async move {
2707 window
2708 .update(&mut cx, |_, window, cx| {
2709 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2710 })
2711 .unwrap();
2712 })
2713 .detach();
2714 cx.background_executor.run_until_parked();
2715
2716 window.update(cx, |_, window, cx| {
2717 search_view.update(cx, |search_view, cx| {
2718 assert!(
2719 search_view.query_editor.focus_handle(cx).is_focused(window),
2720 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2721 );
2722 });
2723 }).unwrap();
2724
2725 window
2726 .update(cx, |_, window, cx| {
2727 search_view.update(cx, |search_view, cx| {
2728 let query_editor = &search_view.query_editor;
2729 assert!(
2730 query_editor.focus_handle(cx).is_focused(window),
2731 "Search view should be focused after the new search view is activated",
2732 );
2733 let query_text = query_editor.read(cx).text(cx);
2734 assert!(
2735 query_text.is_empty(),
2736 "New search query should be empty but got '{query_text}'",
2737 );
2738 let results_text = search_view
2739 .results_editor
2740 .update(cx, |editor, cx| editor.display_text(cx));
2741 assert!(
2742 results_text.is_empty(),
2743 "Empty search view should have no results but got '{results_text}'"
2744 );
2745 });
2746 })
2747 .unwrap();
2748
2749 window
2750 .update(cx, |_, window, cx| {
2751 search_view.update(cx, |search_view, cx| {
2752 search_view.query_editor.update(cx, |query_editor, cx| {
2753 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
2754 });
2755 search_view.search(cx);
2756 });
2757 })
2758 .unwrap();
2759
2760 cx.background_executor.run_until_parked();
2761 window
2762 .update(cx, |_, window, cx| {
2763 search_view.update(cx, |search_view, cx| {
2764 let results_text = search_view
2765 .results_editor
2766 .update(cx, |editor, cx| editor.display_text(cx));
2767 assert!(
2768 results_text.is_empty(),
2769 "Search view for mismatching query should have no results but got '{results_text}'"
2770 );
2771 assert!(
2772 search_view.query_editor.focus_handle(cx).is_focused(window),
2773 "Search view should be focused after mismatching query had been used in search",
2774 );
2775 });
2776 })
2777 .unwrap();
2778 cx.spawn(|mut cx| async move {
2779 window.update(&mut cx, |_, window, cx| {
2780 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2781 })
2782 })
2783 .detach();
2784 cx.background_executor.run_until_parked();
2785 window.update(cx, |_, window, cx| {
2786 search_view.update(cx, |search_view, cx| {
2787 assert!(
2788 search_view.query_editor.focus_handle(cx).is_focused(window),
2789 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2790 );
2791 });
2792 }).unwrap();
2793
2794 window
2795 .update(cx, |_, window, cx| {
2796 search_view.update(cx, |search_view, cx| {
2797 search_view.query_editor.update(cx, |query_editor, cx| {
2798 query_editor.set_text("TWO", window, cx)
2799 });
2800 search_view.search(cx);
2801 })
2802 })
2803 .unwrap();
2804 cx.background_executor.run_until_parked();
2805 window.update(cx, |_, window, cx|
2806 search_view.update(cx, |search_view, cx| {
2807 assert_eq!(
2808 search_view
2809 .results_editor
2810 .update(cx, |editor, cx| editor.display_text(cx)),
2811 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2812 "Search view results should match the query"
2813 );
2814 assert!(
2815 search_view.results_editor.focus_handle(cx).is_focused(window),
2816 "Search view with mismatching query should be focused after search results are available",
2817 );
2818 })).unwrap();
2819 cx.spawn(|mut cx| async move {
2820 window
2821 .update(&mut cx, |_, window, cx| {
2822 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2823 })
2824 .unwrap();
2825 })
2826 .detach();
2827 cx.background_executor.run_until_parked();
2828 window.update(cx, |_, window, cx| {
2829 search_view.update(cx, |search_view, cx| {
2830 assert!(
2831 search_view.results_editor.focus_handle(cx).is_focused(window),
2832 "Search view with matching query should still have its results editor focused after the toggle focus event",
2833 );
2834 });
2835 }).unwrap();
2836
2837 workspace
2838 .update(cx, |workspace, window, cx| {
2839 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
2840 })
2841 .unwrap();
2842 cx.background_executor.run_until_parked();
2843 let Some(search_view_2) = cx.read(|cx| {
2844 workspace
2845 .read(cx)
2846 .unwrap()
2847 .active_pane()
2848 .read(cx)
2849 .active_item()
2850 .and_then(|item| item.downcast::<ProjectSearchView>())
2851 }) else {
2852 panic!("Search view expected to appear after new search event trigger")
2853 };
2854 assert!(
2855 search_view_2 != search_view,
2856 "New search view should be open after `workspace::NewSearch` event"
2857 );
2858
2859 window.update(cx, |_, window, cx| {
2860 search_view.update(cx, |search_view, cx| {
2861 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
2862 assert_eq!(
2863 search_view
2864 .results_editor
2865 .update(cx, |editor, cx| editor.display_text(cx)),
2866 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2867 "Results of the first search view should not update too"
2868 );
2869 assert!(
2870 !search_view.query_editor.focus_handle(cx).is_focused(window),
2871 "Focus should be moved away from the first search view"
2872 );
2873 });
2874 }).unwrap();
2875
2876 window.update(cx, |_, window, cx| {
2877 search_view_2.update(cx, |search_view_2, cx| {
2878 assert_eq!(
2879 search_view_2.query_editor.read(cx).text(cx),
2880 "two",
2881 "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
2882 );
2883 assert_eq!(
2884 search_view_2
2885 .results_editor
2886 .update(cx, |editor, cx| editor.display_text(cx)),
2887 "",
2888 "No search results should be in the 2nd view yet, as we did not spawn a search for it"
2889 );
2890 assert!(
2891 search_view_2.query_editor.focus_handle(cx).is_focused(window),
2892 "Focus should be moved into query editor of the new window"
2893 );
2894 });
2895 }).unwrap();
2896
2897 window
2898 .update(cx, |_, window, cx| {
2899 search_view_2.update(cx, |search_view_2, cx| {
2900 search_view_2.query_editor.update(cx, |query_editor, cx| {
2901 query_editor.set_text("FOUR", window, cx)
2902 });
2903 search_view_2.search(cx);
2904 });
2905 })
2906 .unwrap();
2907
2908 cx.background_executor.run_until_parked();
2909 window.update(cx, |_, window, cx| {
2910 search_view_2.update(cx, |search_view_2, cx| {
2911 assert_eq!(
2912 search_view_2
2913 .results_editor
2914 .update(cx, |editor, cx| editor.display_text(cx)),
2915 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
2916 "New search view with the updated query should have new search results"
2917 );
2918 assert!(
2919 search_view_2.results_editor.focus_handle(cx).is_focused(window),
2920 "Search view with mismatching query should be focused after search results are available",
2921 );
2922 });
2923 }).unwrap();
2924
2925 cx.spawn(|mut cx| async move {
2926 window
2927 .update(&mut cx, |_, window, cx| {
2928 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2929 })
2930 .unwrap();
2931 })
2932 .detach();
2933 cx.background_executor.run_until_parked();
2934 window.update(cx, |_, window, cx| {
2935 search_view_2.update(cx, |search_view_2, cx| {
2936 assert!(
2937 search_view_2.results_editor.focus_handle(cx).is_focused(window),
2938 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2939 );
2940 });}).unwrap();
2941 }
2942
2943 #[gpui::test]
2944 async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
2945 init_test(cx);
2946
2947 let fs = FakeFs::new(cx.background_executor.clone());
2948 fs.insert_tree(
2949 path!("/dir"),
2950 json!({
2951 "a": {
2952 "one.rs": "const ONE: usize = 1;",
2953 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2954 },
2955 "b": {
2956 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2957 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2958 },
2959 }),
2960 )
2961 .await;
2962 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2963 let worktree_id = project.read_with(cx, |project, cx| {
2964 project.worktrees(cx).next().unwrap().read(cx).id()
2965 });
2966 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2967 let workspace = window.root(cx).unwrap();
2968 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2969
2970 let active_item = cx.read(|cx| {
2971 workspace
2972 .read(cx)
2973 .active_pane()
2974 .read(cx)
2975 .active_item()
2976 .and_then(|item| item.downcast::<ProjectSearchView>())
2977 });
2978 assert!(
2979 active_item.is_none(),
2980 "Expected no search panel to be active"
2981 );
2982
2983 window
2984 .update(cx, move |workspace, window, cx| {
2985 assert_eq!(workspace.panes().len(), 1);
2986 workspace.panes()[0].update(cx, move |pane, cx| {
2987 pane.toolbar()
2988 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2989 });
2990 })
2991 .unwrap();
2992
2993 let a_dir_entry = cx.update(|cx| {
2994 workspace
2995 .read(cx)
2996 .project()
2997 .read(cx)
2998 .entry_for_path(&(worktree_id, "a").into(), cx)
2999 .expect("no entry for /a/ directory")
3000 });
3001 assert!(a_dir_entry.is_dir());
3002 window
3003 .update(cx, |workspace, window, cx| {
3004 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, window, cx)
3005 })
3006 .unwrap();
3007
3008 let Some(search_view) = cx.read(|cx| {
3009 workspace
3010 .read(cx)
3011 .active_pane()
3012 .read(cx)
3013 .active_item()
3014 .and_then(|item| item.downcast::<ProjectSearchView>())
3015 }) else {
3016 panic!("Search view expected to appear after new search in directory event trigger")
3017 };
3018 cx.background_executor.run_until_parked();
3019 window
3020 .update(cx, |_, window, cx| {
3021 search_view.update(cx, |search_view, cx| {
3022 assert!(
3023 search_view.query_editor.focus_handle(cx).is_focused(window),
3024 "On new search in directory, focus should be moved into query editor"
3025 );
3026 search_view.excluded_files_editor.update(cx, |editor, cx| {
3027 assert!(
3028 editor.display_text(cx).is_empty(),
3029 "New search in directory should not have any excluded files"
3030 );
3031 });
3032 search_view.included_files_editor.update(cx, |editor, cx| {
3033 assert_eq!(
3034 editor.display_text(cx),
3035 a_dir_entry.path.to_str().unwrap(),
3036 "New search in directory should have included dir entry path"
3037 );
3038 });
3039 });
3040 })
3041 .unwrap();
3042 window
3043 .update(cx, |_, window, cx| {
3044 search_view.update(cx, |search_view, cx| {
3045 search_view.query_editor.update(cx, |query_editor, cx| {
3046 query_editor.set_text("const", window, cx)
3047 });
3048 search_view.search(cx);
3049 });
3050 })
3051 .unwrap();
3052 cx.background_executor.run_until_parked();
3053 window
3054 .update(cx, |_, _, cx| {
3055 search_view.update(cx, |search_view, cx| {
3056 assert_eq!(
3057 search_view
3058 .results_editor
3059 .update(cx, |editor, cx| editor.display_text(cx)),
3060 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3061 "New search in directory should have a filter that matches a certain directory"
3062 );
3063 })
3064 })
3065 .unwrap();
3066 }
3067
3068 #[gpui::test]
3069 async fn test_search_query_history(cx: &mut TestAppContext) {
3070 init_test(cx);
3071
3072 let fs = FakeFs::new(cx.background_executor.clone());
3073 fs.insert_tree(
3074 path!("/dir"),
3075 json!({
3076 "one.rs": "const ONE: usize = 1;",
3077 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3078 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3079 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3080 }),
3081 )
3082 .await;
3083 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3084 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3085 let workspace = window.root(cx).unwrap();
3086 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3087
3088 window
3089 .update(cx, {
3090 let search_bar = search_bar.clone();
3091 |workspace, window, cx| {
3092 assert_eq!(workspace.panes().len(), 1);
3093 workspace.panes()[0].update(cx, |pane, cx| {
3094 pane.toolbar()
3095 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3096 });
3097
3098 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3099 }
3100 })
3101 .unwrap();
3102
3103 let search_view = cx.read(|cx| {
3104 workspace
3105 .read(cx)
3106 .active_pane()
3107 .read(cx)
3108 .active_item()
3109 .and_then(|item| item.downcast::<ProjectSearchView>())
3110 .expect("Search view expected to appear after new search event trigger")
3111 });
3112
3113 // Add 3 search items into the history + another unsubmitted one.
3114 window
3115 .update(cx, |_, window, cx| {
3116 search_view.update(cx, |search_view, cx| {
3117 search_view.search_options = SearchOptions::CASE_SENSITIVE;
3118 search_view.query_editor.update(cx, |query_editor, cx| {
3119 query_editor.set_text("ONE", window, cx)
3120 });
3121 search_view.search(cx);
3122 });
3123 })
3124 .unwrap();
3125
3126 cx.background_executor.run_until_parked();
3127 window
3128 .update(cx, |_, window, cx| {
3129 search_view.update(cx, |search_view, cx| {
3130 search_view.query_editor.update(cx, |query_editor, cx| {
3131 query_editor.set_text("TWO", window, cx)
3132 });
3133 search_view.search(cx);
3134 });
3135 })
3136 .unwrap();
3137 cx.background_executor.run_until_parked();
3138 window
3139 .update(cx, |_, window, cx| {
3140 search_view.update(cx, |search_view, cx| {
3141 search_view.query_editor.update(cx, |query_editor, cx| {
3142 query_editor.set_text("THREE", window, cx)
3143 });
3144 search_view.search(cx);
3145 })
3146 })
3147 .unwrap();
3148 cx.background_executor.run_until_parked();
3149 window
3150 .update(cx, |_, window, cx| {
3151 search_view.update(cx, |search_view, cx| {
3152 search_view.query_editor.update(cx, |query_editor, cx| {
3153 query_editor.set_text("JUST_TEXT_INPUT", window, cx)
3154 });
3155 })
3156 })
3157 .unwrap();
3158 cx.background_executor.run_until_parked();
3159
3160 // Ensure that the latest input with search settings is active.
3161 window
3162 .update(cx, |_, _, cx| {
3163 search_view.update(cx, |search_view, cx| {
3164 assert_eq!(
3165 search_view.query_editor.read(cx).text(cx),
3166 "JUST_TEXT_INPUT"
3167 );
3168 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3169 });
3170 })
3171 .unwrap();
3172
3173 // Next history query after the latest should set the query to the empty string.
3174 window
3175 .update(cx, |_, window, cx| {
3176 search_bar.update(cx, |search_bar, cx| {
3177 search_bar.focus_search(window, cx);
3178 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3179 })
3180 })
3181 .unwrap();
3182 window
3183 .update(cx, |_, _, cx| {
3184 search_view.update(cx, |search_view, cx| {
3185 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3186 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3187 });
3188 })
3189 .unwrap();
3190 window
3191 .update(cx, |_, window, cx| {
3192 search_bar.update(cx, |search_bar, cx| {
3193 search_bar.focus_search(window, cx);
3194 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3195 })
3196 })
3197 .unwrap();
3198 window
3199 .update(cx, |_, _, cx| {
3200 search_view.update(cx, |search_view, cx| {
3201 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3202 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3203 });
3204 })
3205 .unwrap();
3206
3207 // First previous query for empty current query should set the query to the latest submitted one.
3208 window
3209 .update(cx, |_, window, cx| {
3210 search_bar.update(cx, |search_bar, cx| {
3211 search_bar.focus_search(window, cx);
3212 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3213 });
3214 })
3215 .unwrap();
3216 window
3217 .update(cx, |_, _, cx| {
3218 search_view.update(cx, |search_view, cx| {
3219 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3220 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3221 });
3222 })
3223 .unwrap();
3224
3225 // Further previous items should go over the history in reverse order.
3226 window
3227 .update(cx, |_, window, cx| {
3228 search_bar.update(cx, |search_bar, cx| {
3229 search_bar.focus_search(window, cx);
3230 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3231 });
3232 })
3233 .unwrap();
3234 window
3235 .update(cx, |_, _, cx| {
3236 search_view.update(cx, |search_view, cx| {
3237 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3238 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3239 });
3240 })
3241 .unwrap();
3242
3243 // Previous items should never go behind the first history item.
3244 window
3245 .update(cx, |_, window, cx| {
3246 search_bar.update(cx, |search_bar, cx| {
3247 search_bar.focus_search(window, cx);
3248 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3249 });
3250 })
3251 .unwrap();
3252 window
3253 .update(cx, |_, _, cx| {
3254 search_view.update(cx, |search_view, cx| {
3255 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3256 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3257 });
3258 })
3259 .unwrap();
3260 window
3261 .update(cx, |_, window, cx| {
3262 search_bar.update(cx, |search_bar, cx| {
3263 search_bar.focus_search(window, cx);
3264 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3265 });
3266 })
3267 .unwrap();
3268 window
3269 .update(cx, |_, _, cx| {
3270 search_view.update(cx, |search_view, cx| {
3271 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3272 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3273 });
3274 })
3275 .unwrap();
3276
3277 // Next items should go over the history in the original order.
3278 window
3279 .update(cx, |_, window, cx| {
3280 search_bar.update(cx, |search_bar, cx| {
3281 search_bar.focus_search(window, cx);
3282 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3283 });
3284 })
3285 .unwrap();
3286 window
3287 .update(cx, |_, _, cx| {
3288 search_view.update(cx, |search_view, cx| {
3289 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3290 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3291 });
3292 })
3293 .unwrap();
3294
3295 window
3296 .update(cx, |_, window, cx| {
3297 search_view.update(cx, |search_view, cx| {
3298 search_view.query_editor.update(cx, |query_editor, cx| {
3299 query_editor.set_text("TWO_NEW", window, cx)
3300 });
3301 search_view.search(cx);
3302 });
3303 })
3304 .unwrap();
3305 cx.background_executor.run_until_parked();
3306 window
3307 .update(cx, |_, _, cx| {
3308 search_view.update(cx, |search_view, cx| {
3309 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3310 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3311 });
3312 })
3313 .unwrap();
3314
3315 // New search input should add another entry to history and move the selection to the end of the history.
3316 window
3317 .update(cx, |_, window, cx| {
3318 search_bar.update(cx, |search_bar, cx| {
3319 search_bar.focus_search(window, cx);
3320 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3321 });
3322 })
3323 .unwrap();
3324 window
3325 .update(cx, |_, _, cx| {
3326 search_view.update(cx, |search_view, cx| {
3327 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3328 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3329 });
3330 })
3331 .unwrap();
3332 window
3333 .update(cx, |_, window, cx| {
3334 search_bar.update(cx, |search_bar, cx| {
3335 search_bar.focus_search(window, cx);
3336 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3337 });
3338 })
3339 .unwrap();
3340 window
3341 .update(cx, |_, _, cx| {
3342 search_view.update(cx, |search_view, cx| {
3343 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3344 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3345 });
3346 })
3347 .unwrap();
3348 window
3349 .update(cx, |_, window, cx| {
3350 search_bar.update(cx, |search_bar, cx| {
3351 search_bar.focus_search(window, cx);
3352 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3353 });
3354 })
3355 .unwrap();
3356 window
3357 .update(cx, |_, _, cx| {
3358 search_view.update(cx, |search_view, cx| {
3359 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3360 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3361 });
3362 })
3363 .unwrap();
3364 window
3365 .update(cx, |_, window, cx| {
3366 search_bar.update(cx, |search_bar, cx| {
3367 search_bar.focus_search(window, cx);
3368 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3369 });
3370 })
3371 .unwrap();
3372 window
3373 .update(cx, |_, _, cx| {
3374 search_view.update(cx, |search_view, cx| {
3375 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3376 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3377 });
3378 })
3379 .unwrap();
3380 window
3381 .update(cx, |_, window, cx| {
3382 search_bar.update(cx, |search_bar, cx| {
3383 search_bar.focus_search(window, cx);
3384 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3385 });
3386 })
3387 .unwrap();
3388 window
3389 .update(cx, |_, _, cx| {
3390 search_view.update(cx, |search_view, cx| {
3391 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3392 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3393 });
3394 })
3395 .unwrap();
3396 }
3397
3398 #[gpui::test]
3399 async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) {
3400 init_test(cx);
3401
3402 let fs = FakeFs::new(cx.background_executor.clone());
3403 fs.insert_tree(
3404 path!("/dir"),
3405 json!({
3406 "one.rs": "const ONE: usize = 1;",
3407 }),
3408 )
3409 .await;
3410 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3411 let worktree_id = project.update(cx, |this, cx| {
3412 this.worktrees(cx).next().unwrap().read(cx).id()
3413 });
3414
3415 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3416 let workspace = window.root(cx).unwrap();
3417
3418 let panes: Vec<_> = window
3419 .update(cx, |this, _, _| this.panes().to_owned())
3420 .unwrap();
3421
3422 let search_bar_1 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3423 let search_bar_2 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3424
3425 assert_eq!(panes.len(), 1);
3426 let first_pane = panes.first().cloned().unwrap();
3427 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3428 window
3429 .update(cx, |workspace, window, cx| {
3430 workspace.open_path(
3431 (worktree_id, "one.rs"),
3432 Some(first_pane.downgrade()),
3433 true,
3434 window,
3435 cx,
3436 )
3437 })
3438 .unwrap()
3439 .await
3440 .unwrap();
3441 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3442
3443 // Add a project search item to the first pane
3444 window
3445 .update(cx, {
3446 let search_bar = search_bar_1.clone();
3447 |workspace, window, cx| {
3448 first_pane.update(cx, |pane, cx| {
3449 pane.toolbar()
3450 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3451 });
3452
3453 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3454 }
3455 })
3456 .unwrap();
3457 let search_view_1 = cx.read(|cx| {
3458 workspace
3459 .read(cx)
3460 .active_item(cx)
3461 .and_then(|item| item.downcast::<ProjectSearchView>())
3462 .expect("Search view expected to appear after new search event trigger")
3463 });
3464
3465 let second_pane = window
3466 .update(cx, |workspace, window, cx| {
3467 workspace.split_and_clone(
3468 first_pane.clone(),
3469 workspace::SplitDirection::Right,
3470 window,
3471 cx,
3472 )
3473 })
3474 .unwrap()
3475 .unwrap();
3476 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3477
3478 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3479 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3480
3481 // Add a project search item to the second pane
3482 window
3483 .update(cx, {
3484 let search_bar = search_bar_2.clone();
3485 let pane = second_pane.clone();
3486 move |workspace, window, cx| {
3487 assert_eq!(workspace.panes().len(), 2);
3488 pane.update(cx, |pane, cx| {
3489 pane.toolbar()
3490 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3491 });
3492
3493 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3494 }
3495 })
3496 .unwrap();
3497
3498 let search_view_2 = cx.read(|cx| {
3499 workspace
3500 .read(cx)
3501 .active_item(cx)
3502 .and_then(|item| item.downcast::<ProjectSearchView>())
3503 .expect("Search view expected to appear after new search event trigger")
3504 });
3505
3506 cx.run_until_parked();
3507 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3508 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3509
3510 let update_search_view =
3511 |search_view: &Entity<ProjectSearchView>, query: &str, cx: &mut TestAppContext| {
3512 window
3513 .update(cx, |_, window, cx| {
3514 search_view.update(cx, |search_view, cx| {
3515 search_view.query_editor.update(cx, |query_editor, cx| {
3516 query_editor.set_text(query, window, cx)
3517 });
3518 search_view.search(cx);
3519 });
3520 })
3521 .unwrap();
3522 };
3523
3524 let active_query =
3525 |search_view: &Entity<ProjectSearchView>, cx: &mut TestAppContext| -> String {
3526 window
3527 .update(cx, |_, _, cx| {
3528 search_view.update(cx, |search_view, cx| {
3529 search_view.query_editor.read(cx).text(cx).to_string()
3530 })
3531 })
3532 .unwrap()
3533 };
3534
3535 let select_prev_history_item =
3536 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
3537 window
3538 .update(cx, |_, window, cx| {
3539 search_bar.update(cx, |search_bar, cx| {
3540 search_bar.focus_search(window, cx);
3541 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3542 })
3543 })
3544 .unwrap();
3545 };
3546
3547 let select_next_history_item =
3548 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
3549 window
3550 .update(cx, |_, window, cx| {
3551 search_bar.update(cx, |search_bar, cx| {
3552 search_bar.focus_search(window, cx);
3553 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3554 })
3555 })
3556 .unwrap();
3557 };
3558
3559 update_search_view(&search_view_1, "ONE", cx);
3560 cx.background_executor.run_until_parked();
3561
3562 update_search_view(&search_view_2, "TWO", cx);
3563 cx.background_executor.run_until_parked();
3564
3565 assert_eq!(active_query(&search_view_1, cx), "ONE");
3566 assert_eq!(active_query(&search_view_2, cx), "TWO");
3567
3568 // Selecting previous history item should select the query from search view 1.
3569 select_prev_history_item(&search_bar_2, cx);
3570 assert_eq!(active_query(&search_view_2, cx), "ONE");
3571
3572 // Selecting the previous history item should not change the query as it is already the first item.
3573 select_prev_history_item(&search_bar_2, cx);
3574 assert_eq!(active_query(&search_view_2, cx), "ONE");
3575
3576 // Changing the query in search view 2 should not affect the history of search view 1.
3577 assert_eq!(active_query(&search_view_1, cx), "ONE");
3578
3579 // Deploying a new search in search view 2
3580 update_search_view(&search_view_2, "THREE", cx);
3581 cx.background_executor.run_until_parked();
3582
3583 select_next_history_item(&search_bar_2, cx);
3584 assert_eq!(active_query(&search_view_2, cx), "");
3585
3586 select_prev_history_item(&search_bar_2, cx);
3587 assert_eq!(active_query(&search_view_2, cx), "THREE");
3588
3589 select_prev_history_item(&search_bar_2, cx);
3590 assert_eq!(active_query(&search_view_2, cx), "TWO");
3591
3592 select_prev_history_item(&search_bar_2, cx);
3593 assert_eq!(active_query(&search_view_2, cx), "ONE");
3594
3595 select_prev_history_item(&search_bar_2, cx);
3596 assert_eq!(active_query(&search_view_2, cx), "ONE");
3597
3598 // Search view 1 should now see the query from search view 2.
3599 assert_eq!(active_query(&search_view_1, cx), "ONE");
3600
3601 select_next_history_item(&search_bar_2, cx);
3602 assert_eq!(active_query(&search_view_2, cx), "TWO");
3603
3604 // Here is the new query from search view 2
3605 select_next_history_item(&search_bar_2, cx);
3606 assert_eq!(active_query(&search_view_2, cx), "THREE");
3607
3608 select_next_history_item(&search_bar_2, cx);
3609 assert_eq!(active_query(&search_view_2, cx), "");
3610
3611 select_next_history_item(&search_bar_1, cx);
3612 assert_eq!(active_query(&search_view_1, cx), "TWO");
3613
3614 select_next_history_item(&search_bar_1, cx);
3615 assert_eq!(active_query(&search_view_1, cx), "THREE");
3616
3617 select_next_history_item(&search_bar_1, cx);
3618 assert_eq!(active_query(&search_view_1, cx), "");
3619 }
3620
3621 #[gpui::test]
3622 async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
3623 init_test(cx);
3624
3625 // Setup 2 panes, both with a file open and one with a project search.
3626 let fs = FakeFs::new(cx.background_executor.clone());
3627 fs.insert_tree(
3628 path!("/dir"),
3629 json!({
3630 "one.rs": "const ONE: usize = 1;",
3631 }),
3632 )
3633 .await;
3634 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3635 let worktree_id = project.update(cx, |this, cx| {
3636 this.worktrees(cx).next().unwrap().read(cx).id()
3637 });
3638 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3639 let panes: Vec<_> = window
3640 .update(cx, |this, _, _| this.panes().to_owned())
3641 .unwrap();
3642 assert_eq!(panes.len(), 1);
3643 let first_pane = panes.first().cloned().unwrap();
3644 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3645 window
3646 .update(cx, |workspace, window, cx| {
3647 workspace.open_path(
3648 (worktree_id, "one.rs"),
3649 Some(first_pane.downgrade()),
3650 true,
3651 window,
3652 cx,
3653 )
3654 })
3655 .unwrap()
3656 .await
3657 .unwrap();
3658 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3659 let second_pane = window
3660 .update(cx, |workspace, window, cx| {
3661 workspace.split_and_clone(
3662 first_pane.clone(),
3663 workspace::SplitDirection::Right,
3664 window,
3665 cx,
3666 )
3667 })
3668 .unwrap()
3669 .unwrap();
3670 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3671 assert!(
3672 window
3673 .update(cx, |_, window, cx| second_pane
3674 .focus_handle(cx)
3675 .contains_focused(window, cx))
3676 .unwrap()
3677 );
3678 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3679 window
3680 .update(cx, {
3681 let search_bar = search_bar.clone();
3682 let pane = first_pane.clone();
3683 move |workspace, window, cx| {
3684 assert_eq!(workspace.panes().len(), 2);
3685 pane.update(cx, move |pane, cx| {
3686 pane.toolbar()
3687 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3688 });
3689 }
3690 })
3691 .unwrap();
3692
3693 // Add a project search item to the second pane
3694 window
3695 .update(cx, {
3696 let search_bar = search_bar.clone();
3697 |workspace, window, cx| {
3698 assert_eq!(workspace.panes().len(), 2);
3699 second_pane.update(cx, |pane, cx| {
3700 pane.toolbar()
3701 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3702 });
3703
3704 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3705 }
3706 })
3707 .unwrap();
3708
3709 cx.run_until_parked();
3710 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3711 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3712
3713 // Focus the first pane
3714 window
3715 .update(cx, |workspace, window, cx| {
3716 assert_eq!(workspace.active_pane(), &second_pane);
3717 second_pane.update(cx, |this, cx| {
3718 assert_eq!(this.active_item_index(), 1);
3719 this.activate_prev_item(false, window, cx);
3720 assert_eq!(this.active_item_index(), 0);
3721 });
3722 workspace.activate_pane_in_direction(workspace::SplitDirection::Left, window, cx);
3723 })
3724 .unwrap();
3725 window
3726 .update(cx, |workspace, _, cx| {
3727 assert_eq!(workspace.active_pane(), &first_pane);
3728 assert_eq!(first_pane.read(cx).items_len(), 1);
3729 assert_eq!(second_pane.read(cx).items_len(), 2);
3730 })
3731 .unwrap();
3732
3733 // Deploy a new search
3734 cx.dispatch_action(window.into(), DeploySearch::find());
3735
3736 // Both panes should now have a project search in them
3737 window
3738 .update(cx, |workspace, window, cx| {
3739 assert_eq!(workspace.active_pane(), &first_pane);
3740 first_pane.update(cx, |this, _| {
3741 assert_eq!(this.active_item_index(), 1);
3742 assert_eq!(this.items_len(), 2);
3743 });
3744 second_pane.update(cx, |this, cx| {
3745 assert!(!cx.focus_handle().contains_focused(window, cx));
3746 assert_eq!(this.items_len(), 2);
3747 });
3748 })
3749 .unwrap();
3750
3751 // Focus the second pane's non-search item
3752 window
3753 .update(cx, |_workspace, window, cx| {
3754 second_pane.update(cx, |pane, cx| pane.activate_next_item(true, window, cx));
3755 })
3756 .unwrap();
3757
3758 // Deploy a new search
3759 cx.dispatch_action(window.into(), DeploySearch::find());
3760
3761 // The project search view should now be focused in the second pane
3762 // And the number of items should be unchanged.
3763 window
3764 .update(cx, |_workspace, _, cx| {
3765 second_pane.update(cx, |pane, _cx| {
3766 assert!(
3767 pane.active_item()
3768 .unwrap()
3769 .downcast::<ProjectSearchView>()
3770 .is_some()
3771 );
3772
3773 assert_eq!(pane.items_len(), 2);
3774 });
3775 })
3776 .unwrap();
3777 }
3778
3779 #[gpui::test]
3780 async fn test_scroll_search_results_to_top(cx: &mut TestAppContext) {
3781 init_test(cx);
3782
3783 // We need many lines in the search results to be able to scroll the window
3784 let fs = FakeFs::new(cx.background_executor.clone());
3785 fs.insert_tree(
3786 path!("/dir"),
3787 json!({
3788 "1.txt": "\n\n\n\n\n A \n\n\n\n\n",
3789 "2.txt": "\n\n\n\n\n A \n\n\n\n\n",
3790 "3.rs": "\n\n\n\n\n A \n\n\n\n\n",
3791 "4.rs": "\n\n\n\n\n A \n\n\n\n\n",
3792 "5.rs": "\n\n\n\n\n A \n\n\n\n\n",
3793 "6.rs": "\n\n\n\n\n A \n\n\n\n\n",
3794 "7.rs": "\n\n\n\n\n A \n\n\n\n\n",
3795 "8.rs": "\n\n\n\n\n A \n\n\n\n\n",
3796 "9.rs": "\n\n\n\n\n A \n\n\n\n\n",
3797 "a.rs": "\n\n\n\n\n A \n\n\n\n\n",
3798 "b.rs": "\n\n\n\n\n B \n\n\n\n\n",
3799 "c.rs": "\n\n\n\n\n B \n\n\n\n\n",
3800 "d.rs": "\n\n\n\n\n B \n\n\n\n\n",
3801 "e.rs": "\n\n\n\n\n B \n\n\n\n\n",
3802 "f.rs": "\n\n\n\n\n B \n\n\n\n\n",
3803 "g.rs": "\n\n\n\n\n B \n\n\n\n\n",
3804 "h.rs": "\n\n\n\n\n B \n\n\n\n\n",
3805 "i.rs": "\n\n\n\n\n B \n\n\n\n\n",
3806 "j.rs": "\n\n\n\n\n B \n\n\n\n\n",
3807 "k.rs": "\n\n\n\n\n B \n\n\n\n\n",
3808 }),
3809 )
3810 .await;
3811 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3812 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3813 let workspace = window.root(cx).unwrap();
3814 let search = cx.new(|cx| ProjectSearch::new(project, cx));
3815 let search_view = cx.add_window(|window, cx| {
3816 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
3817 });
3818
3819 // First search
3820 perform_search(search_view, "A", cx);
3821 search_view
3822 .update(cx, |search_view, window, cx| {
3823 search_view.results_editor.update(cx, |results_editor, cx| {
3824 // Results are correct and scrolled to the top
3825 assert_eq!(
3826 results_editor.display_text(cx).match_indices(" A ").count(),
3827 10
3828 );
3829 assert_eq!(results_editor.scroll_position(cx), Point::default());
3830
3831 // Scroll results all the way down
3832 results_editor.scroll(
3833 Point::new(0., f32::MAX),
3834 Some(Axis::Vertical),
3835 window,
3836 cx,
3837 );
3838 });
3839 })
3840 .expect("unable to update search view");
3841
3842 // Second search
3843 perform_search(search_view, "B", cx);
3844 search_view
3845 .update(cx, |search_view, _, cx| {
3846 search_view.results_editor.update(cx, |results_editor, cx| {
3847 // Results are correct...
3848 assert_eq!(
3849 results_editor.display_text(cx).match_indices(" B ").count(),
3850 10
3851 );
3852 // ...and scrolled back to the top
3853 assert_eq!(results_editor.scroll_position(cx), Point::default());
3854 });
3855 })
3856 .expect("unable to update search view");
3857 }
3858
3859 #[gpui::test]
3860 async fn test_buffer_search_query_reused(cx: &mut TestAppContext) {
3861 init_test(cx);
3862
3863 let fs = FakeFs::new(cx.background_executor.clone());
3864 fs.insert_tree(
3865 path!("/dir"),
3866 json!({
3867 "one.rs": "const ONE: usize = 1;",
3868 }),
3869 )
3870 .await;
3871 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3872 let worktree_id = project.update(cx, |this, cx| {
3873 this.worktrees(cx).next().unwrap().read(cx).id()
3874 });
3875 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3876 let workspace = window.root(cx).unwrap();
3877 let mut cx = VisualTestContext::from_window(*window.deref(), cx);
3878
3879 let editor = workspace
3880 .update_in(&mut cx, |workspace, window, cx| {
3881 workspace.open_path((worktree_id, "one.rs"), None, true, window, cx)
3882 })
3883 .await
3884 .unwrap()
3885 .downcast::<Editor>()
3886 .unwrap();
3887
3888 // Wait for the unstaged changes to be loaded
3889 cx.run_until_parked();
3890
3891 let buffer_search_bar = cx.new_window_entity(|window, cx| {
3892 let mut search_bar =
3893 BufferSearchBar::new(Some(project.read(cx).languages().clone()), window, cx);
3894 search_bar.set_active_pane_item(Some(&editor), window, cx);
3895 search_bar.show(window, cx);
3896 search_bar
3897 });
3898
3899 let panes: Vec<_> = window
3900 .update(&mut cx, |this, _, _| this.panes().to_owned())
3901 .unwrap();
3902 assert_eq!(panes.len(), 1);
3903 let pane = panes.first().cloned().unwrap();
3904 pane.update_in(&mut cx, |pane, window, cx| {
3905 pane.toolbar().update(cx, |toolbar, cx| {
3906 toolbar.add_item(buffer_search_bar.clone(), window, cx);
3907 })
3908 });
3909
3910 let buffer_search_query = "search bar query";
3911 buffer_search_bar
3912 .update_in(&mut cx, |buffer_search_bar, window, cx| {
3913 buffer_search_bar.focus_handle(cx).focus(window);
3914 buffer_search_bar.search(buffer_search_query, None, window, cx)
3915 })
3916 .await
3917 .unwrap();
3918
3919 workspace.update_in(&mut cx, |workspace, window, cx| {
3920 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3921 });
3922 cx.run_until_parked();
3923 let project_search_view = pane
3924 .update(&mut cx, |pane, _| {
3925 pane.active_item()
3926 .and_then(|item| item.downcast::<ProjectSearchView>())
3927 })
3928 .expect("should open a project search view after spawning a new search");
3929 project_search_view.update(&mut cx, |search_view, cx| {
3930 assert_eq!(
3931 search_view.search_query_text(cx),
3932 buffer_search_query,
3933 "Project search should take the query from the buffer search bar since it got focused and had a query inside"
3934 );
3935 });
3936 }
3937
3938 fn init_test(cx: &mut TestAppContext) {
3939 cx.update(|cx| {
3940 let settings = SettingsStore::test(cx);
3941 cx.set_global(settings);
3942
3943 theme::init(theme::LoadThemes::JustBase, cx);
3944
3945 language::init(cx);
3946 client::init_settings(cx);
3947 editor::init(cx);
3948 workspace::init_settings(cx);
3949 Project::init_settings(cx);
3950 crate::init(cx);
3951 });
3952 }
3953
3954 fn perform_search(
3955 search_view: WindowHandle<ProjectSearchView>,
3956 text: impl Into<Arc<str>>,
3957 cx: &mut TestAppContext,
3958 ) {
3959 search_view
3960 .update(cx, |search_view, window, cx| {
3961 search_view.query_editor.update(cx, |query_editor, cx| {
3962 query_editor.set_text(text, window, cx)
3963 });
3964 search_view.search(cx);
3965 })
3966 .unwrap();
3967 cx.background_executor.run_until_parked();
3968 }
3969}