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