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