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