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