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