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