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).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) -> 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 ))
1253 .on_click(|_event, window, cx| {
1254 window.dispatch_action(ToggleFilters.boxed_clone(), cx)
1255 }),
1256 )
1257 .child(
1258 Button::new("find-replace", "Find and replace")
1259 .icon(IconName::Replace)
1260 .icon_position(IconPosition::Start)
1261 .icon_size(IconSize::Small)
1262 .key_binding(KeyBinding::for_action_in(
1263 &ToggleReplace,
1264 &focus_handle,
1265 window,
1266 ))
1267 .on_click(|_event, window, cx| {
1268 window.dispatch_action(ToggleReplace.boxed_clone(), cx)
1269 }),
1270 )
1271 .child(
1272 Button::new("regex", "Match with regex")
1273 .icon(IconName::Regex)
1274 .icon_position(IconPosition::Start)
1275 .icon_size(IconSize::Small)
1276 .key_binding(KeyBinding::for_action_in(
1277 &ToggleRegex,
1278 &focus_handle,
1279 window,
1280 ))
1281 .on_click(|_event, window, cx| {
1282 window.dispatch_action(ToggleRegex.boxed_clone(), cx)
1283 }),
1284 )
1285 .child(
1286 Button::new("match-case", "Match case")
1287 .icon(IconName::CaseSensitive)
1288 .icon_position(IconPosition::Start)
1289 .icon_size(IconSize::Small)
1290 .key_binding(KeyBinding::for_action_in(
1291 &ToggleCaseSensitive,
1292 &focus_handle,
1293 window,
1294 ))
1295 .on_click(|_event, window, cx| {
1296 window.dispatch_action(ToggleCaseSensitive.boxed_clone(), cx)
1297 }),
1298 )
1299 .child(
1300 Button::new("match-whole-words", "Match whole words")
1301 .icon(IconName::WholeWord)
1302 .icon_position(IconPosition::Start)
1303 .icon_size(IconSize::Small)
1304 .key_binding(KeyBinding::for_action_in(
1305 &ToggleWholeWord,
1306 &focus_handle,
1307 window,
1308 ))
1309 .on_click(|_event, window, cx| {
1310 window.dispatch_action(ToggleWholeWord.boxed_clone(), cx)
1311 }),
1312 )
1313 }
1314
1315 fn border_color_for(&self, panel: InputPanel, cx: &App) -> Hsla {
1316 if self.panels_with_errors.contains(&panel) {
1317 Color::Error.color(cx)
1318 } else {
1319 cx.theme().colors().border
1320 }
1321 }
1322
1323 fn move_focus_to_results(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1324 if !self.results_editor.focus_handle(cx).is_focused(window)
1325 && !self.entity.read(cx).match_ranges.is_empty()
1326 {
1327 cx.stop_propagation();
1328 self.focus_results_editor(window, cx)
1329 }
1330 }
1331
1332 #[cfg(any(test, feature = "test-support"))]
1333 pub fn results_editor(&self) -> &Entity<Editor> {
1334 &self.results_editor
1335 }
1336}
1337
1338fn buffer_search_query(
1339 workspace: &mut Workspace,
1340 item: &dyn ItemHandle,
1341 cx: &mut Context<Workspace>,
1342) -> Option<String> {
1343 let buffer_search_bar = workspace
1344 .pane_for(item)
1345 .and_then(|pane| {
1346 pane.read(cx)
1347 .toolbar()
1348 .read(cx)
1349 .item_of_type::<BufferSearchBar>()
1350 })?
1351 .read(cx);
1352 if buffer_search_bar.query_editor_focused() {
1353 let buffer_search_query = buffer_search_bar.query(cx);
1354 if !buffer_search_query.is_empty() {
1355 return Some(buffer_search_query);
1356 }
1357 }
1358 None
1359}
1360
1361impl Default for ProjectSearchBar {
1362 fn default() -> Self {
1363 Self::new()
1364 }
1365}
1366
1367impl ProjectSearchBar {
1368 pub fn new() -> Self {
1369 Self {
1370 active_project_search: None,
1371 subscription: None,
1372 }
1373 }
1374
1375 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1376 if let Some(search_view) = self.active_project_search.as_ref() {
1377 search_view.update(cx, |search_view, cx| {
1378 if !search_view
1379 .replacement_editor
1380 .focus_handle(cx)
1381 .is_focused(window)
1382 {
1383 cx.stop_propagation();
1384 search_view.search(cx);
1385 }
1386 });
1387 }
1388 }
1389
1390 fn tab(&mut self, _: &editor::actions::Tab, window: &mut Window, cx: &mut Context<Self>) {
1391 self.cycle_field(Direction::Next, window, cx);
1392 }
1393
1394 fn tab_previous(
1395 &mut self,
1396 _: &editor::actions::TabPrev,
1397 window: &mut Window,
1398 cx: &mut Context<Self>,
1399 ) {
1400 self.cycle_field(Direction::Prev, window, cx);
1401 }
1402
1403 fn focus_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1404 if let Some(search_view) = self.active_project_search.as_ref() {
1405 search_view.update(cx, |search_view, cx| {
1406 search_view.query_editor.focus_handle(cx).focus(window);
1407 });
1408 }
1409 }
1410
1411 fn cycle_field(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
1412 let active_project_search = match &self.active_project_search {
1413 Some(active_project_search) => active_project_search,
1414
1415 None => {
1416 return;
1417 }
1418 };
1419
1420 active_project_search.update(cx, |project_view, cx| {
1421 let mut views = vec![&project_view.query_editor];
1422 if project_view.replace_enabled {
1423 views.push(&project_view.replacement_editor);
1424 }
1425 if project_view.filters_enabled {
1426 views.extend([
1427 &project_view.included_files_editor,
1428 &project_view.excluded_files_editor,
1429 ]);
1430 }
1431 let current_index = match views
1432 .iter()
1433 .enumerate()
1434 .find(|(_, editor)| editor.focus_handle(cx).is_focused(window))
1435 {
1436 Some((index, _)) => index,
1437 None => return,
1438 };
1439
1440 let new_index = match direction {
1441 Direction::Next => (current_index + 1) % views.len(),
1442 Direction::Prev if current_index == 0 => views.len() - 1,
1443 Direction::Prev => (current_index - 1) % views.len(),
1444 };
1445 let next_focus_handle = views[new_index].focus_handle(cx);
1446 window.focus(&next_focus_handle);
1447 cx.stop_propagation();
1448 });
1449 }
1450
1451 fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut Context<Self>) -> bool {
1452 if let Some(search_view) = self.active_project_search.as_ref() {
1453 search_view.update(cx, |search_view, cx| {
1454 search_view.toggle_search_option(option, cx);
1455 if search_view.entity.read(cx).active_query.is_some() {
1456 search_view.search(cx);
1457 }
1458 });
1459
1460 cx.notify();
1461 true
1462 } else {
1463 false
1464 }
1465 }
1466
1467 fn toggle_replace(&mut self, _: &ToggleReplace, window: &mut Window, cx: &mut Context<Self>) {
1468 if let Some(search) = &self.active_project_search {
1469 search.update(cx, |this, cx| {
1470 this.replace_enabled = !this.replace_enabled;
1471 let editor_to_focus = if this.replace_enabled {
1472 this.replacement_editor.focus_handle(cx)
1473 } else {
1474 this.query_editor.focus_handle(cx)
1475 };
1476 window.focus(&editor_to_focus);
1477 cx.notify();
1478 });
1479 }
1480 }
1481
1482 fn toggle_filters(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1483 if let Some(search_view) = self.active_project_search.as_ref() {
1484 search_view.update(cx, |search_view, cx| {
1485 search_view.toggle_filters(cx);
1486 search_view
1487 .included_files_editor
1488 .update(cx, |_, cx| cx.notify());
1489 search_view
1490 .excluded_files_editor
1491 .update(cx, |_, cx| cx.notify());
1492 window.refresh();
1493 cx.notify();
1494 });
1495 cx.notify();
1496 true
1497 } else {
1498 false
1499 }
1500 }
1501
1502 fn toggle_opened_only(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1503 if let Some(search_view) = self.active_project_search.as_ref() {
1504 search_view.update(cx, |search_view, cx| {
1505 search_view.toggle_opened_only(window, cx);
1506 if search_view.entity.read(cx).active_query.is_some() {
1507 search_view.search(cx);
1508 }
1509 });
1510
1511 cx.notify();
1512 true
1513 } else {
1514 false
1515 }
1516 }
1517
1518 fn is_opened_only_enabled(&self, cx: &App) -> bool {
1519 if let Some(search_view) = self.active_project_search.as_ref() {
1520 search_view.read(cx).included_opened_only
1521 } else {
1522 false
1523 }
1524 }
1525
1526 fn move_focus_to_results(&self, window: &mut Window, cx: &mut Context<Self>) {
1527 if let Some(search_view) = self.active_project_search.as_ref() {
1528 search_view.update(cx, |search_view, cx| {
1529 search_view.move_focus_to_results(window, cx);
1530 });
1531 cx.notify();
1532 }
1533 }
1534
1535 fn is_option_enabled(&self, option: SearchOptions, cx: &App) -> bool {
1536 if let Some(search) = self.active_project_search.as_ref() {
1537 search.read(cx).search_options.contains(option)
1538 } else {
1539 false
1540 }
1541 }
1542
1543 fn next_history_query(
1544 &mut self,
1545 _: &NextHistoryQuery,
1546 window: &mut Window,
1547 cx: &mut Context<Self>,
1548 ) {
1549 if let Some(search_view) = self.active_project_search.as_ref() {
1550 search_view.update(cx, |search_view, cx| {
1551 for (editor, kind) in [
1552 (search_view.query_editor.clone(), SearchInputKind::Query),
1553 (
1554 search_view.included_files_editor.clone(),
1555 SearchInputKind::Include,
1556 ),
1557 (
1558 search_view.excluded_files_editor.clone(),
1559 SearchInputKind::Exclude,
1560 ),
1561 ] {
1562 if editor.focus_handle(cx).is_focused(window) {
1563 let new_query = search_view.entity.update(cx, |model, cx| {
1564 let project = model.project.clone();
1565
1566 if let Some(new_query) = project.update(cx, |project, _| {
1567 project
1568 .search_history_mut(kind)
1569 .next(model.cursor_mut(kind))
1570 .map(str::to_string)
1571 }) {
1572 new_query
1573 } else {
1574 model.cursor_mut(kind).reset();
1575 String::new()
1576 }
1577 });
1578 search_view.set_search_editor(kind, &new_query, window, cx);
1579 }
1580 }
1581 });
1582 }
1583 }
1584
1585 fn previous_history_query(
1586 &mut self,
1587 _: &PreviousHistoryQuery,
1588 window: &mut Window,
1589 cx: &mut Context<Self>,
1590 ) {
1591 if let Some(search_view) = self.active_project_search.as_ref() {
1592 search_view.update(cx, |search_view, cx| {
1593 for (editor, kind) in [
1594 (search_view.query_editor.clone(), SearchInputKind::Query),
1595 (
1596 search_view.included_files_editor.clone(),
1597 SearchInputKind::Include,
1598 ),
1599 (
1600 search_view.excluded_files_editor.clone(),
1601 SearchInputKind::Exclude,
1602 ),
1603 ] {
1604 if editor.focus_handle(cx).is_focused(window) {
1605 if editor.read(cx).text(cx).is_empty() {
1606 if let Some(new_query) = search_view
1607 .entity
1608 .read(cx)
1609 .project
1610 .read(cx)
1611 .search_history(kind)
1612 .current(search_view.entity.read(cx).cursor(kind))
1613 .map(str::to_string)
1614 {
1615 search_view.set_search_editor(kind, &new_query, window, cx);
1616 return;
1617 }
1618 }
1619
1620 if let Some(new_query) = search_view.entity.update(cx, |model, cx| {
1621 let project = model.project.clone();
1622 project.update(cx, |project, _| {
1623 project
1624 .search_history_mut(kind)
1625 .previous(model.cursor_mut(kind))
1626 .map(str::to_string)
1627 })
1628 }) {
1629 search_view.set_search_editor(kind, &new_query, window, cx);
1630 }
1631 }
1632 }
1633 });
1634 }
1635 }
1636
1637 fn select_next_match(
1638 &mut self,
1639 _: &SelectNextMatch,
1640 window: &mut Window,
1641 cx: &mut Context<Self>,
1642 ) {
1643 if let Some(search) = self.active_project_search.as_ref() {
1644 search.update(cx, |this, cx| {
1645 this.select_match(Direction::Next, window, cx);
1646 })
1647 }
1648 }
1649
1650 fn select_prev_match(
1651 &mut self,
1652 _: &SelectPrevMatch,
1653 window: &mut Window,
1654 cx: &mut Context<Self>,
1655 ) {
1656 if let Some(search) = self.active_project_search.as_ref() {
1657 search.update(cx, |this, cx| {
1658 this.select_match(Direction::Prev, window, cx);
1659 })
1660 }
1661 }
1662
1663 fn render_text_input(&self, editor: &Entity<Editor>, cx: &Context<Self>) -> impl IntoElement {
1664 let settings = ThemeSettings::get_global(cx);
1665 let text_style = TextStyle {
1666 color: if editor.read(cx).read_only(cx) {
1667 cx.theme().colors().text_disabled
1668 } else {
1669 cx.theme().colors().text
1670 },
1671 font_family: settings.buffer_font.family.clone(),
1672 font_features: settings.buffer_font.features.clone(),
1673 font_fallbacks: settings.buffer_font.fallbacks.clone(),
1674 font_size: rems(0.875).into(),
1675 font_weight: settings.buffer_font.weight,
1676 line_height: relative(1.3),
1677 ..Default::default()
1678 };
1679
1680 EditorElement::new(
1681 editor,
1682 EditorStyle {
1683 background: cx.theme().colors().editor_background,
1684 local_player: cx.theme().players().local(),
1685 text: text_style,
1686 ..Default::default()
1687 },
1688 )
1689 }
1690}
1691
1692impl Render for ProjectSearchBar {
1693 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1694 let Some(search) = self.active_project_search.clone() else {
1695 return div();
1696 };
1697 let search = search.read(cx);
1698 let focus_handle = search.focus_handle(cx);
1699
1700 let container_width = window.viewport_size().width;
1701 let input_width = SearchInputWidth::calc_width(container_width);
1702
1703 let input_base_styles = || {
1704 h_flex()
1705 .min_w_32()
1706 .w(input_width)
1707 .h_8()
1708 .pl_2()
1709 .pr_1()
1710 .py_1()
1711 .border_1()
1712 .border_color(search.border_color_for(InputPanel::Query, cx))
1713 .rounded_lg()
1714 };
1715
1716 let query_column = input_base_styles()
1717 .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
1718 .on_action(cx.listener(|this, action, window, cx| {
1719 this.previous_history_query(action, window, cx)
1720 }))
1721 .on_action(
1722 cx.listener(|this, action, window, cx| this.next_history_query(action, window, cx)),
1723 )
1724 .child(self.render_text_input(&search.query_editor, cx))
1725 .child(
1726 h_flex()
1727 .gap_1()
1728 .child(SearchOptions::CASE_SENSITIVE.as_button(
1729 self.is_option_enabled(SearchOptions::CASE_SENSITIVE, cx),
1730 focus_handle.clone(),
1731 cx.listener(|this, _, _, cx| {
1732 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1733 }),
1734 ))
1735 .child(SearchOptions::WHOLE_WORD.as_button(
1736 self.is_option_enabled(SearchOptions::WHOLE_WORD, cx),
1737 focus_handle.clone(),
1738 cx.listener(|this, _, _, cx| {
1739 this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1740 }),
1741 ))
1742 .child(SearchOptions::REGEX.as_button(
1743 self.is_option_enabled(SearchOptions::REGEX, cx),
1744 focus_handle.clone(),
1745 cx.listener(|this, _, _, cx| {
1746 this.toggle_search_option(SearchOptions::REGEX, cx);
1747 }),
1748 )),
1749 );
1750
1751 let mode_column = h_flex()
1752 .gap_1()
1753 .child(
1754 IconButton::new("project-search-filter-button", IconName::Filter)
1755 .shape(IconButtonShape::Square)
1756 .tooltip(|window, cx| {
1757 Tooltip::for_action("Toggle Filters", &ToggleFilters, window, cx)
1758 })
1759 .on_click(cx.listener(|this, _, window, cx| {
1760 this.toggle_filters(window, cx);
1761 }))
1762 .toggle_state(
1763 self.active_project_search
1764 .as_ref()
1765 .map(|search| search.read(cx).filters_enabled)
1766 .unwrap_or_default(),
1767 )
1768 .tooltip({
1769 let focus_handle = focus_handle.clone();
1770 move |window, cx| {
1771 Tooltip::for_action_in(
1772 "Toggle Filters",
1773 &ToggleFilters,
1774 &focus_handle,
1775 window,
1776 cx,
1777 )
1778 }
1779 }),
1780 )
1781 .child(
1782 IconButton::new("project-search-toggle-replace", IconName::Replace)
1783 .shape(IconButtonShape::Square)
1784 .on_click(cx.listener(|this, _, window, cx| {
1785 this.toggle_replace(&ToggleReplace, window, cx);
1786 }))
1787 .toggle_state(
1788 self.active_project_search
1789 .as_ref()
1790 .map(|search| search.read(cx).replace_enabled)
1791 .unwrap_or_default(),
1792 )
1793 .tooltip({
1794 let focus_handle = focus_handle.clone();
1795 move |window, cx| {
1796 Tooltip::for_action_in(
1797 "Toggle Replace",
1798 &ToggleReplace,
1799 &focus_handle,
1800 window,
1801 cx,
1802 )
1803 }
1804 }),
1805 );
1806
1807 let limit_reached = search.entity.read(cx).limit_reached;
1808
1809 let match_text = search
1810 .active_match_index
1811 .and_then(|index| {
1812 let index = index + 1;
1813 let match_quantity = search.entity.read(cx).match_ranges.len();
1814 if match_quantity > 0 {
1815 debug_assert!(match_quantity >= index);
1816 if limit_reached {
1817 Some(format!("{index}/{match_quantity}+").to_string())
1818 } else {
1819 Some(format!("{index}/{match_quantity}").to_string())
1820 }
1821 } else {
1822 None
1823 }
1824 })
1825 .unwrap_or_else(|| "0/0".to_string());
1826
1827 let matches_column = h_flex()
1828 .pl_2()
1829 .ml_2()
1830 .border_l_1()
1831 .border_color(cx.theme().colors().border_variant)
1832 .child(
1833 IconButton::new("project-search-prev-match", IconName::ChevronLeft)
1834 .shape(IconButtonShape::Square)
1835 .disabled(search.active_match_index.is_none())
1836 .on_click(cx.listener(|this, _, window, cx| {
1837 if let Some(search) = this.active_project_search.as_ref() {
1838 search.update(cx, |this, cx| {
1839 this.select_match(Direction::Prev, window, cx);
1840 })
1841 }
1842 }))
1843 .tooltip({
1844 let focus_handle = focus_handle.clone();
1845 move |window, cx| {
1846 Tooltip::for_action_in(
1847 "Go To Previous Match",
1848 &SelectPrevMatch,
1849 &focus_handle,
1850 window,
1851 cx,
1852 )
1853 }
1854 }),
1855 )
1856 .child(
1857 IconButton::new("project-search-next-match", IconName::ChevronRight)
1858 .shape(IconButtonShape::Square)
1859 .disabled(search.active_match_index.is_none())
1860 .on_click(cx.listener(|this, _, window, cx| {
1861 if let Some(search) = this.active_project_search.as_ref() {
1862 search.update(cx, |this, cx| {
1863 this.select_match(Direction::Next, window, cx);
1864 })
1865 }
1866 }))
1867 .tooltip({
1868 let focus_handle = focus_handle.clone();
1869 move |window, cx| {
1870 Tooltip::for_action_in(
1871 "Go To Next Match",
1872 &SelectNextMatch,
1873 &focus_handle,
1874 window,
1875 cx,
1876 )
1877 }
1878 }),
1879 )
1880 .child(
1881 div()
1882 .id("matches")
1883 .ml_1()
1884 .child(Label::new(match_text).size(LabelSize::Small).color(
1885 if search.active_match_index.is_some() {
1886 Color::Default
1887 } else {
1888 Color::Disabled
1889 },
1890 ))
1891 .when(limit_reached, |el| {
1892 el.tooltip(Tooltip::text(
1893 "Search limits reached.\nTry narrowing your search.",
1894 ))
1895 }),
1896 );
1897
1898 let search_line = h_flex()
1899 .w_full()
1900 .gap_2()
1901 .child(query_column)
1902 .child(h_flex().min_w_64().child(mode_column).child(matches_column));
1903
1904 let replace_line = search.replace_enabled.then(|| {
1905 let replace_column =
1906 input_base_styles().child(self.render_text_input(&search.replacement_editor, cx));
1907
1908 let focus_handle = search.replacement_editor.read(cx).focus_handle(cx);
1909
1910 let replace_actions =
1911 h_flex()
1912 .min_w_64()
1913 .gap_1()
1914 .when(search.replace_enabled, |this| {
1915 this.child(
1916 IconButton::new("project-search-replace-next", IconName::ReplaceNext)
1917 .shape(IconButtonShape::Square)
1918 .on_click(cx.listener(|this, _, window, cx| {
1919 if let Some(search) = this.active_project_search.as_ref() {
1920 search.update(cx, |this, cx| {
1921 this.replace_next(&ReplaceNext, window, cx);
1922 })
1923 }
1924 }))
1925 .tooltip({
1926 let focus_handle = focus_handle.clone();
1927 move |window, cx| {
1928 Tooltip::for_action_in(
1929 "Replace Next Match",
1930 &ReplaceNext,
1931 &focus_handle,
1932 window,
1933 cx,
1934 )
1935 }
1936 }),
1937 )
1938 .child(
1939 IconButton::new("project-search-replace-all", IconName::ReplaceAll)
1940 .shape(IconButtonShape::Square)
1941 .on_click(cx.listener(|this, _, window, cx| {
1942 if let Some(search) = this.active_project_search.as_ref() {
1943 search.update(cx, |this, cx| {
1944 this.replace_all(&ReplaceAll, window, cx);
1945 })
1946 }
1947 }))
1948 .tooltip({
1949 let focus_handle = focus_handle.clone();
1950 move |window, cx| {
1951 Tooltip::for_action_in(
1952 "Replace All Matches",
1953 &ReplaceAll,
1954 &focus_handle,
1955 window,
1956 cx,
1957 )
1958 }
1959 }),
1960 )
1961 });
1962
1963 h_flex()
1964 .w_full()
1965 .gap_2()
1966 .child(replace_column)
1967 .child(replace_actions)
1968 });
1969
1970 let filter_line = search.filters_enabled.then(|| {
1971 h_flex()
1972 .w_full()
1973 .gap_2()
1974 .child(
1975 input_base_styles()
1976 .on_action(cx.listener(|this, action, window, cx| {
1977 this.previous_history_query(action, window, cx)
1978 }))
1979 .on_action(cx.listener(|this, action, window, cx| {
1980 this.next_history_query(action, window, cx)
1981 }))
1982 .child(self.render_text_input(&search.included_files_editor, cx)),
1983 )
1984 .child(
1985 input_base_styles()
1986 .on_action(cx.listener(|this, action, window, cx| {
1987 this.previous_history_query(action, window, cx)
1988 }))
1989 .on_action(cx.listener(|this, action, window, cx| {
1990 this.next_history_query(action, window, cx)
1991 }))
1992 .child(self.render_text_input(&search.excluded_files_editor, cx)),
1993 )
1994 .child(
1995 h_flex()
1996 .min_w_64()
1997 .gap_1()
1998 .child(
1999 IconButton::new("project-search-opened-only", IconName::FileSearch)
2000 .shape(IconButtonShape::Square)
2001 .toggle_state(self.is_opened_only_enabled(cx))
2002 .tooltip(Tooltip::text("Only Search Open Files"))
2003 .on_click(cx.listener(|this, _, window, cx| {
2004 this.toggle_opened_only(window, cx);
2005 })),
2006 )
2007 .child(
2008 SearchOptions::INCLUDE_IGNORED.as_button(
2009 search
2010 .search_options
2011 .contains(SearchOptions::INCLUDE_IGNORED),
2012 focus_handle.clone(),
2013 cx.listener(|this, _, _, cx| {
2014 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
2015 }),
2016 ),
2017 ),
2018 )
2019 });
2020
2021 let mut key_context = KeyContext::default();
2022
2023 key_context.add("ProjectSearchBar");
2024
2025 if search
2026 .replacement_editor
2027 .focus_handle(cx)
2028 .is_focused(window)
2029 {
2030 key_context.add("in_replace");
2031 }
2032
2033 v_flex()
2034 .py(px(1.0))
2035 .key_context(key_context)
2036 .on_action(cx.listener(|this, _: &ToggleFocus, window, cx| {
2037 this.move_focus_to_results(window, cx)
2038 }))
2039 .on_action(cx.listener(|this, _: &ToggleFilters, window, cx| {
2040 this.toggle_filters(window, cx);
2041 }))
2042 .capture_action(cx.listener(|this, action, window, cx| {
2043 this.tab(action, window, cx);
2044 cx.stop_propagation();
2045 }))
2046 .capture_action(cx.listener(|this, action, window, cx| {
2047 this.tab_previous(action, window, cx);
2048 cx.stop_propagation();
2049 }))
2050 .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
2051 .on_action(cx.listener(|this, action, window, cx| {
2052 this.toggle_replace(action, window, cx);
2053 }))
2054 .on_action(cx.listener(|this, _: &ToggleWholeWord, _, cx| {
2055 this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
2056 }))
2057 .on_action(cx.listener(|this, _: &ToggleCaseSensitive, _, cx| {
2058 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
2059 }))
2060 .on_action(cx.listener(|this, action, window, cx| {
2061 if let Some(search) = this.active_project_search.as_ref() {
2062 search.update(cx, |this, cx| {
2063 this.replace_next(action, window, cx);
2064 })
2065 }
2066 }))
2067 .on_action(cx.listener(|this, action, window, cx| {
2068 if let Some(search) = this.active_project_search.as_ref() {
2069 search.update(cx, |this, cx| {
2070 this.replace_all(action, window, cx);
2071 })
2072 }
2073 }))
2074 .when(search.filters_enabled, |this| {
2075 this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, _, cx| {
2076 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
2077 }))
2078 })
2079 .on_action(cx.listener(Self::select_next_match))
2080 .on_action(cx.listener(Self::select_prev_match))
2081 .gap_2()
2082 .w_full()
2083 .child(search_line)
2084 .children(replace_line)
2085 .children(filter_line)
2086 }
2087}
2088
2089impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
2090
2091impl ToolbarItemView for ProjectSearchBar {
2092 fn set_active_pane_item(
2093 &mut self,
2094 active_pane_item: Option<&dyn ItemHandle>,
2095 _: &mut Window,
2096 cx: &mut Context<Self>,
2097 ) -> ToolbarItemLocation {
2098 cx.notify();
2099 self.subscription = None;
2100 self.active_project_search = None;
2101 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
2102 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
2103 self.active_project_search = Some(search);
2104 ToolbarItemLocation::PrimaryLeft {}
2105 } else {
2106 ToolbarItemLocation::Hidden
2107 }
2108 }
2109}
2110
2111fn register_workspace_action<A: Action>(
2112 workspace: &mut Workspace,
2113 callback: fn(&mut ProjectSearchBar, &A, &mut Window, &mut Context<ProjectSearchBar>),
2114) {
2115 workspace.register_action(move |workspace, action: &A, window, cx| {
2116 if workspace.has_active_modal(window, cx) {
2117 cx.propagate();
2118 return;
2119 }
2120
2121 workspace.active_pane().update(cx, |pane, cx| {
2122 pane.toolbar().update(cx, move |workspace, cx| {
2123 if let Some(search_bar) = workspace.item_of_type::<ProjectSearchBar>() {
2124 search_bar.update(cx, move |search_bar, cx| {
2125 if search_bar.active_project_search.is_some() {
2126 callback(search_bar, action, window, cx);
2127 cx.notify();
2128 } else {
2129 cx.propagate();
2130 }
2131 });
2132 }
2133 });
2134 })
2135 });
2136}
2137
2138fn register_workspace_action_for_present_search<A: Action>(
2139 workspace: &mut Workspace,
2140 callback: fn(&mut Workspace, &A, &mut Window, &mut Context<Workspace>),
2141) {
2142 workspace.register_action(move |workspace, action: &A, window, cx| {
2143 if workspace.has_active_modal(window, cx) {
2144 cx.propagate();
2145 return;
2146 }
2147
2148 let should_notify = workspace
2149 .active_pane()
2150 .read(cx)
2151 .toolbar()
2152 .read(cx)
2153 .item_of_type::<ProjectSearchBar>()
2154 .map(|search_bar| search_bar.read(cx).active_project_search.is_some())
2155 .unwrap_or(false);
2156 if should_notify {
2157 callback(workspace, action, window, cx);
2158 cx.notify();
2159 } else {
2160 cx.propagate();
2161 }
2162 });
2163}
2164
2165#[cfg(any(test, feature = "test-support"))]
2166pub fn perform_project_search(
2167 search_view: &Entity<ProjectSearchView>,
2168 text: impl Into<std::sync::Arc<str>>,
2169 cx: &mut gpui::VisualTestContext,
2170) {
2171 cx.run_until_parked();
2172 search_view.update_in(cx, |search_view, window, cx| {
2173 search_view.query_editor.update(cx, |query_editor, cx| {
2174 query_editor.set_text(text, window, cx)
2175 });
2176 search_view.search(cx);
2177 });
2178 cx.run_until_parked();
2179}
2180
2181#[cfg(test)]
2182pub mod tests {
2183 use std::{ops::Deref as _, sync::Arc};
2184
2185 use super::*;
2186 use editor::{display_map::DisplayRow, DisplayPoint};
2187 use gpui::{Action, TestAppContext, VisualTestContext, WindowHandle};
2188 use project::FakeFs;
2189 use serde_json::json;
2190 use settings::SettingsStore;
2191 use util::path;
2192 use workspace::DeploySearch;
2193
2194 #[gpui::test]
2195 async fn test_project_search(cx: &mut TestAppContext) {
2196 init_test(cx);
2197
2198 let fs = FakeFs::new(cx.background_executor.clone());
2199 fs.insert_tree(
2200 path!("/dir"),
2201 json!({
2202 "one.rs": "const ONE: usize = 1;",
2203 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2204 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2205 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2206 }),
2207 )
2208 .await;
2209 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2210 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2211 let workspace = window.root(cx).unwrap();
2212 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
2213 let search_view = cx.add_window(|window, cx| {
2214 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
2215 });
2216
2217 perform_search(search_view, "TWO", cx);
2218 search_view.update(cx, |search_view, window, cx| {
2219 assert_eq!(
2220 search_view
2221 .results_editor
2222 .update(cx, |editor, cx| editor.display_text(cx)),
2223 "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n"
2224 );
2225 let match_background_color = cx.theme().colors().search_match_background;
2226 assert_eq!(
2227 search_view
2228 .results_editor
2229 .update(cx, |editor, cx| editor.all_text_background_highlights(window, cx)),
2230 &[
2231 (
2232 DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35),
2233 match_background_color
2234 ),
2235 (
2236 DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40),
2237 match_background_color
2238 ),
2239 (
2240 DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9),
2241 match_background_color
2242 )
2243 ]
2244 );
2245 assert_eq!(search_view.active_match_index, Some(0));
2246 assert_eq!(
2247 search_view
2248 .results_editor
2249 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2250 [DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35)]
2251 );
2252
2253 search_view.select_match(Direction::Next, window, cx);
2254 }).unwrap();
2255
2256 search_view
2257 .update(cx, |search_view, window, cx| {
2258 assert_eq!(search_view.active_match_index, Some(1));
2259 assert_eq!(
2260 search_view
2261 .results_editor
2262 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2263 [DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40)]
2264 );
2265 search_view.select_match(Direction::Next, window, cx);
2266 })
2267 .unwrap();
2268
2269 search_view
2270 .update(cx, |search_view, window, cx| {
2271 assert_eq!(search_view.active_match_index, Some(2));
2272 assert_eq!(
2273 search_view
2274 .results_editor
2275 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2276 [DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9)]
2277 );
2278 search_view.select_match(Direction::Next, window, cx);
2279 })
2280 .unwrap();
2281
2282 search_view
2283 .update(cx, |search_view, window, cx| {
2284 assert_eq!(search_view.active_match_index, Some(0));
2285 assert_eq!(
2286 search_view
2287 .results_editor
2288 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2289 [DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35)]
2290 );
2291 search_view.select_match(Direction::Prev, window, cx);
2292 })
2293 .unwrap();
2294
2295 search_view
2296 .update(cx, |search_view, window, cx| {
2297 assert_eq!(search_view.active_match_index, Some(2));
2298 assert_eq!(
2299 search_view
2300 .results_editor
2301 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2302 [DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9)]
2303 );
2304 search_view.select_match(Direction::Prev, window, cx);
2305 })
2306 .unwrap();
2307
2308 search_view
2309 .update(cx, |search_view, _, cx| {
2310 assert_eq!(search_view.active_match_index, Some(1));
2311 assert_eq!(
2312 search_view
2313 .results_editor
2314 .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2315 [DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40)]
2316 );
2317 })
2318 .unwrap();
2319 }
2320
2321 #[gpui::test]
2322 async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
2323 init_test(cx);
2324
2325 let fs = FakeFs::new(cx.background_executor.clone());
2326 fs.insert_tree(
2327 "/dir",
2328 json!({
2329 "one.rs": "const ONE: usize = 1;",
2330 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2331 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2332 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2333 }),
2334 )
2335 .await;
2336 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2337 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2338 let workspace = window;
2339 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2340
2341 let active_item = cx.read(|cx| {
2342 workspace
2343 .read(cx)
2344 .unwrap()
2345 .active_pane()
2346 .read(cx)
2347 .active_item()
2348 .and_then(|item| item.downcast::<ProjectSearchView>())
2349 });
2350 assert!(
2351 active_item.is_none(),
2352 "Expected no search panel to be active"
2353 );
2354
2355 window
2356 .update(cx, move |workspace, window, cx| {
2357 assert_eq!(workspace.panes().len(), 1);
2358 workspace.panes()[0].update(cx, |pane, cx| {
2359 pane.toolbar()
2360 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2361 });
2362
2363 ProjectSearchView::deploy_search(
2364 workspace,
2365 &workspace::DeploySearch::find(),
2366 window,
2367 cx,
2368 )
2369 })
2370 .unwrap();
2371
2372 let Some(search_view) = cx.read(|cx| {
2373 workspace
2374 .read(cx)
2375 .unwrap()
2376 .active_pane()
2377 .read(cx)
2378 .active_item()
2379 .and_then(|item| item.downcast::<ProjectSearchView>())
2380 }) else {
2381 panic!("Search view expected to appear after new search event trigger")
2382 };
2383
2384 cx.spawn(|mut cx| async move {
2385 window
2386 .update(&mut cx, |_, window, cx| {
2387 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2388 })
2389 .unwrap();
2390 })
2391 .detach();
2392 cx.background_executor.run_until_parked();
2393 window
2394 .update(cx, |_, window, cx| {
2395 search_view.update(cx, |search_view, cx| {
2396 assert!(
2397 search_view.query_editor.focus_handle(cx).is_focused(window),
2398 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2399 );
2400 });
2401 }).unwrap();
2402
2403 window
2404 .update(cx, |_, window, cx| {
2405 search_view.update(cx, |search_view, cx| {
2406 let query_editor = &search_view.query_editor;
2407 assert!(
2408 query_editor.focus_handle(cx).is_focused(window),
2409 "Search view should be focused after the new search view is activated",
2410 );
2411 let query_text = query_editor.read(cx).text(cx);
2412 assert!(
2413 query_text.is_empty(),
2414 "New search query should be empty but got '{query_text}'",
2415 );
2416 let results_text = search_view
2417 .results_editor
2418 .update(cx, |editor, cx| editor.display_text(cx));
2419 assert!(
2420 results_text.is_empty(),
2421 "Empty search view should have no results but got '{results_text}'"
2422 );
2423 });
2424 })
2425 .unwrap();
2426
2427 window
2428 .update(cx, |_, window, cx| {
2429 search_view.update(cx, |search_view, cx| {
2430 search_view.query_editor.update(cx, |query_editor, cx| {
2431 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
2432 });
2433 search_view.search(cx);
2434 });
2435 })
2436 .unwrap();
2437 cx.background_executor.run_until_parked();
2438 window
2439 .update(cx, |_, window, cx| {
2440 search_view.update(cx, |search_view, cx| {
2441 let results_text = search_view
2442 .results_editor
2443 .update(cx, |editor, cx| editor.display_text(cx));
2444 assert!(
2445 results_text.is_empty(),
2446 "Search view for mismatching query should have no results but got '{results_text}'"
2447 );
2448 assert!(
2449 search_view.query_editor.focus_handle(cx).is_focused(window),
2450 "Search view should be focused after mismatching query had been used in search",
2451 );
2452 });
2453 }).unwrap();
2454
2455 cx.spawn(|mut cx| async move {
2456 window.update(&mut cx, |_, window, cx| {
2457 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2458 })
2459 })
2460 .detach();
2461 cx.background_executor.run_until_parked();
2462 window.update(cx, |_, window, cx| {
2463 search_view.update(cx, |search_view, cx| {
2464 assert!(
2465 search_view.query_editor.focus_handle(cx).is_focused(window),
2466 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2467 );
2468 });
2469 }).unwrap();
2470
2471 window
2472 .update(cx, |_, window, cx| {
2473 search_view.update(cx, |search_view, cx| {
2474 search_view.query_editor.update(cx, |query_editor, cx| {
2475 query_editor.set_text("TWO", window, cx)
2476 });
2477 search_view.search(cx);
2478 });
2479 })
2480 .unwrap();
2481 cx.background_executor.run_until_parked();
2482 window.update(cx, |_, window, cx| {
2483 search_view.update(cx, |search_view, cx| {
2484 assert_eq!(
2485 search_view
2486 .results_editor
2487 .update(cx, |editor, cx| editor.display_text(cx)),
2488 "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2489 "Search view results should match the query"
2490 );
2491 assert!(
2492 search_view.results_editor.focus_handle(cx).is_focused(window),
2493 "Search view with mismatching query should be focused after search results are available",
2494 );
2495 });
2496 }).unwrap();
2497 cx.spawn(|mut cx| async move {
2498 window
2499 .update(&mut cx, |_, window, cx| {
2500 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2501 })
2502 .unwrap();
2503 })
2504 .detach();
2505 cx.background_executor.run_until_parked();
2506 window.update(cx, |_, window, cx| {
2507 search_view.update(cx, |search_view, cx| {
2508 assert!(
2509 search_view.results_editor.focus_handle(cx).is_focused(window),
2510 "Search view with matching query should still have its results editor focused after the toggle focus event",
2511 );
2512 });
2513 }).unwrap();
2514
2515 workspace
2516 .update(cx, |workspace, window, cx| {
2517 ProjectSearchView::deploy_search(
2518 workspace,
2519 &workspace::DeploySearch::find(),
2520 window,
2521 cx,
2522 )
2523 })
2524 .unwrap();
2525 window.update(cx, |_, window, cx| {
2526 search_view.update(cx, |search_view, cx| {
2527 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");
2528 assert_eq!(
2529 search_view
2530 .results_editor
2531 .update(cx, |editor, cx| editor.display_text(cx)),
2532 "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2533 "Results should be unchanged after search view 2nd open in a row"
2534 );
2535 assert!(
2536 search_view.query_editor.focus_handle(cx).is_focused(window),
2537 "Focus should be moved into query editor again after search view 2nd open in a row"
2538 );
2539 });
2540 }).unwrap();
2541
2542 cx.spawn(|mut cx| async move {
2543 window
2544 .update(&mut cx, |_, window, cx| {
2545 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2546 })
2547 .unwrap();
2548 })
2549 .detach();
2550 cx.background_executor.run_until_parked();
2551 window.update(cx, |_, window, cx| {
2552 search_view.update(cx, |search_view, cx| {
2553 assert!(
2554 search_view.results_editor.focus_handle(cx).is_focused(window),
2555 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2556 );
2557 });
2558 }).unwrap();
2559 }
2560
2561 #[gpui::test]
2562 async fn test_new_project_search_focus(cx: &mut TestAppContext) {
2563 init_test(cx);
2564
2565 let fs = FakeFs::new(cx.background_executor.clone());
2566 fs.insert_tree(
2567 path!("/dir"),
2568 json!({
2569 "one.rs": "const ONE: usize = 1;",
2570 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2571 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2572 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2573 }),
2574 )
2575 .await;
2576 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2577 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2578 let workspace = window;
2579 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2580
2581 let active_item = cx.read(|cx| {
2582 workspace
2583 .read(cx)
2584 .unwrap()
2585 .active_pane()
2586 .read(cx)
2587 .active_item()
2588 .and_then(|item| item.downcast::<ProjectSearchView>())
2589 });
2590 assert!(
2591 active_item.is_none(),
2592 "Expected no search panel to be active"
2593 );
2594
2595 window
2596 .update(cx, move |workspace, window, cx| {
2597 assert_eq!(workspace.panes().len(), 1);
2598 workspace.panes()[0].update(cx, |pane, cx| {
2599 pane.toolbar()
2600 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2601 });
2602
2603 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
2604 })
2605 .unwrap();
2606
2607 let Some(search_view) = cx.read(|cx| {
2608 workspace
2609 .read(cx)
2610 .unwrap()
2611 .active_pane()
2612 .read(cx)
2613 .active_item()
2614 .and_then(|item| item.downcast::<ProjectSearchView>())
2615 }) else {
2616 panic!("Search view expected to appear after new search event trigger")
2617 };
2618
2619 cx.spawn(|mut cx| async move {
2620 window
2621 .update(&mut cx, |_, window, cx| {
2622 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2623 })
2624 .unwrap();
2625 })
2626 .detach();
2627 cx.background_executor.run_until_parked();
2628
2629 window.update(cx, |_, window, cx| {
2630 search_view.update(cx, |search_view, cx| {
2631 assert!(
2632 search_view.query_editor.focus_handle(cx).is_focused(window),
2633 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2634 );
2635 });
2636 }).unwrap();
2637
2638 window
2639 .update(cx, |_, window, cx| {
2640 search_view.update(cx, |search_view, cx| {
2641 let query_editor = &search_view.query_editor;
2642 assert!(
2643 query_editor.focus_handle(cx).is_focused(window),
2644 "Search view should be focused after the new search view is activated",
2645 );
2646 let query_text = query_editor.read(cx).text(cx);
2647 assert!(
2648 query_text.is_empty(),
2649 "New search query should be empty but got '{query_text}'",
2650 );
2651 let results_text = search_view
2652 .results_editor
2653 .update(cx, |editor, cx| editor.display_text(cx));
2654 assert!(
2655 results_text.is_empty(),
2656 "Empty search view should have no results but got '{results_text}'"
2657 );
2658 });
2659 })
2660 .unwrap();
2661
2662 window
2663 .update(cx, |_, window, cx| {
2664 search_view.update(cx, |search_view, cx| {
2665 search_view.query_editor.update(cx, |query_editor, cx| {
2666 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
2667 });
2668 search_view.search(cx);
2669 });
2670 })
2671 .unwrap();
2672
2673 cx.background_executor.run_until_parked();
2674 window
2675 .update(cx, |_, window, cx| {
2676 search_view.update(cx, |search_view, cx| {
2677 let results_text = search_view
2678 .results_editor
2679 .update(cx, |editor, cx| editor.display_text(cx));
2680 assert!(
2681 results_text.is_empty(),
2682 "Search view for mismatching query should have no results but got '{results_text}'"
2683 );
2684 assert!(
2685 search_view.query_editor.focus_handle(cx).is_focused(window),
2686 "Search view should be focused after mismatching query had been used in search",
2687 );
2688 });
2689 })
2690 .unwrap();
2691 cx.spawn(|mut cx| async move {
2692 window.update(&mut cx, |_, window, cx| {
2693 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2694 })
2695 })
2696 .detach();
2697 cx.background_executor.run_until_parked();
2698 window.update(cx, |_, window, cx| {
2699 search_view.update(cx, |search_view, cx| {
2700 assert!(
2701 search_view.query_editor.focus_handle(cx).is_focused(window),
2702 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2703 );
2704 });
2705 }).unwrap();
2706
2707 window
2708 .update(cx, |_, window, cx| {
2709 search_view.update(cx, |search_view, cx| {
2710 search_view.query_editor.update(cx, |query_editor, cx| {
2711 query_editor.set_text("TWO", window, cx)
2712 });
2713 search_view.search(cx);
2714 })
2715 })
2716 .unwrap();
2717 cx.background_executor.run_until_parked();
2718 window.update(cx, |_, window, cx|
2719 search_view.update(cx, |search_view, cx| {
2720 assert_eq!(
2721 search_view
2722 .results_editor
2723 .update(cx, |editor, cx| editor.display_text(cx)),
2724 "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2725 "Search view results should match the query"
2726 );
2727 assert!(
2728 search_view.results_editor.focus_handle(cx).is_focused(window),
2729 "Search view with mismatching query should be focused after search results are available",
2730 );
2731 })).unwrap();
2732 cx.spawn(|mut cx| async move {
2733 window
2734 .update(&mut cx, |_, window, cx| {
2735 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2736 })
2737 .unwrap();
2738 })
2739 .detach();
2740 cx.background_executor.run_until_parked();
2741 window.update(cx, |_, window, cx| {
2742 search_view.update(cx, |search_view, cx| {
2743 assert!(
2744 search_view.results_editor.focus_handle(cx).is_focused(window),
2745 "Search view with matching query should still have its results editor focused after the toggle focus event",
2746 );
2747 });
2748 }).unwrap();
2749
2750 workspace
2751 .update(cx, |workspace, window, cx| {
2752 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
2753 })
2754 .unwrap();
2755 cx.background_executor.run_until_parked();
2756 let Some(search_view_2) = cx.read(|cx| {
2757 workspace
2758 .read(cx)
2759 .unwrap()
2760 .active_pane()
2761 .read(cx)
2762 .active_item()
2763 .and_then(|item| item.downcast::<ProjectSearchView>())
2764 }) else {
2765 panic!("Search view expected to appear after new search event trigger")
2766 };
2767 assert!(
2768 search_view_2 != search_view,
2769 "New search view should be open after `workspace::NewSearch` event"
2770 );
2771
2772 window.update(cx, |_, window, cx| {
2773 search_view.update(cx, |search_view, cx| {
2774 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
2775 assert_eq!(
2776 search_view
2777 .results_editor
2778 .update(cx, |editor, cx| editor.display_text(cx)),
2779 "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2780 "Results of the first search view should not update too"
2781 );
2782 assert!(
2783 !search_view.query_editor.focus_handle(cx).is_focused(window),
2784 "Focus should be moved away from the first search view"
2785 );
2786 });
2787 }).unwrap();
2788
2789 window.update(cx, |_, window, cx| {
2790 search_view_2.update(cx, |search_view_2, cx| {
2791 assert_eq!(
2792 search_view_2.query_editor.read(cx).text(cx),
2793 "two",
2794 "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
2795 );
2796 assert_eq!(
2797 search_view_2
2798 .results_editor
2799 .update(cx, |editor, cx| editor.display_text(cx)),
2800 "",
2801 "No search results should be in the 2nd view yet, as we did not spawn a search for it"
2802 );
2803 assert!(
2804 search_view_2.query_editor.focus_handle(cx).is_focused(window),
2805 "Focus should be moved into query editor of the new window"
2806 );
2807 });
2808 }).unwrap();
2809
2810 window
2811 .update(cx, |_, window, cx| {
2812 search_view_2.update(cx, |search_view_2, cx| {
2813 search_view_2.query_editor.update(cx, |query_editor, cx| {
2814 query_editor.set_text("FOUR", window, cx)
2815 });
2816 search_view_2.search(cx);
2817 });
2818 })
2819 .unwrap();
2820
2821 cx.background_executor.run_until_parked();
2822 window.update(cx, |_, window, cx| {
2823 search_view_2.update(cx, |search_view_2, cx| {
2824 assert_eq!(
2825 search_view_2
2826 .results_editor
2827 .update(cx, |editor, cx| editor.display_text(cx)),
2828 "\n\n\nconst FOUR: usize = one::ONE + three::THREE;\n",
2829 "New search view with the updated query should have new search results"
2830 );
2831 assert!(
2832 search_view_2.results_editor.focus_handle(cx).is_focused(window),
2833 "Search view with mismatching query should be focused after search results are available",
2834 );
2835 });
2836 }).unwrap();
2837
2838 cx.spawn(|mut cx| async move {
2839 window
2840 .update(&mut cx, |_, window, cx| {
2841 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2842 })
2843 .unwrap();
2844 })
2845 .detach();
2846 cx.background_executor.run_until_parked();
2847 window.update(cx, |_, window, cx| {
2848 search_view_2.update(cx, |search_view_2, cx| {
2849 assert!(
2850 search_view_2.results_editor.focus_handle(cx).is_focused(window),
2851 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2852 );
2853 });}).unwrap();
2854 }
2855
2856 #[gpui::test]
2857 async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
2858 init_test(cx);
2859
2860 let fs = FakeFs::new(cx.background_executor.clone());
2861 fs.insert_tree(
2862 path!("/dir"),
2863 json!({
2864 "a": {
2865 "one.rs": "const ONE: usize = 1;",
2866 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2867 },
2868 "b": {
2869 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2870 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2871 },
2872 }),
2873 )
2874 .await;
2875 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2876 let worktree_id = project.read_with(cx, |project, cx| {
2877 project.worktrees(cx).next().unwrap().read(cx).id()
2878 });
2879 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2880 let workspace = window.root(cx).unwrap();
2881 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2882
2883 let active_item = cx.read(|cx| {
2884 workspace
2885 .read(cx)
2886 .active_pane()
2887 .read(cx)
2888 .active_item()
2889 .and_then(|item| item.downcast::<ProjectSearchView>())
2890 });
2891 assert!(
2892 active_item.is_none(),
2893 "Expected no search panel to be active"
2894 );
2895
2896 window
2897 .update(cx, move |workspace, window, cx| {
2898 assert_eq!(workspace.panes().len(), 1);
2899 workspace.panes()[0].update(cx, move |pane, cx| {
2900 pane.toolbar()
2901 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2902 });
2903 })
2904 .unwrap();
2905
2906 let a_dir_entry = cx.update(|cx| {
2907 workspace
2908 .read(cx)
2909 .project()
2910 .read(cx)
2911 .entry_for_path(&(worktree_id, "a").into(), cx)
2912 .expect("no entry for /a/ directory")
2913 });
2914 assert!(a_dir_entry.is_dir());
2915 window
2916 .update(cx, |workspace, window, cx| {
2917 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, window, cx)
2918 })
2919 .unwrap();
2920
2921 let Some(search_view) = cx.read(|cx| {
2922 workspace
2923 .read(cx)
2924 .active_pane()
2925 .read(cx)
2926 .active_item()
2927 .and_then(|item| item.downcast::<ProjectSearchView>())
2928 }) else {
2929 panic!("Search view expected to appear after new search in directory event trigger")
2930 };
2931 cx.background_executor.run_until_parked();
2932 window
2933 .update(cx, |_, window, cx| {
2934 search_view.update(cx, |search_view, cx| {
2935 assert!(
2936 search_view.query_editor.focus_handle(cx).is_focused(window),
2937 "On new search in directory, focus should be moved into query editor"
2938 );
2939 search_view.excluded_files_editor.update(cx, |editor, cx| {
2940 assert!(
2941 editor.display_text(cx).is_empty(),
2942 "New search in directory should not have any excluded files"
2943 );
2944 });
2945 search_view.included_files_editor.update(cx, |editor, cx| {
2946 assert_eq!(
2947 editor.display_text(cx),
2948 a_dir_entry.path.to_str().unwrap(),
2949 "New search in directory should have included dir entry path"
2950 );
2951 });
2952 });
2953 })
2954 .unwrap();
2955 window
2956 .update(cx, |_, window, cx| {
2957 search_view.update(cx, |search_view, cx| {
2958 search_view.query_editor.update(cx, |query_editor, cx| {
2959 query_editor.set_text("const", window, cx)
2960 });
2961 search_view.search(cx);
2962 });
2963 })
2964 .unwrap();
2965 cx.background_executor.run_until_parked();
2966 window
2967 .update(cx, |_, _, cx| {
2968 search_view.update(cx, |search_view, cx| {
2969 assert_eq!(
2970 search_view
2971 .results_editor
2972 .update(cx, |editor, cx| editor.display_text(cx)),
2973 "\n\n\nconst ONE: usize = 1;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2974 "New search in directory should have a filter that matches a certain directory"
2975 );
2976 })
2977 })
2978 .unwrap();
2979 }
2980
2981 #[gpui::test]
2982 async fn test_search_query_history(cx: &mut TestAppContext) {
2983 init_test(cx);
2984
2985 let fs = FakeFs::new(cx.background_executor.clone());
2986 fs.insert_tree(
2987 path!("/dir"),
2988 json!({
2989 "one.rs": "const ONE: usize = 1;",
2990 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2991 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2992 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2993 }),
2994 )
2995 .await;
2996 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2997 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2998 let workspace = window.root(cx).unwrap();
2999 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3000
3001 window
3002 .update(cx, {
3003 let search_bar = search_bar.clone();
3004 |workspace, window, cx| {
3005 assert_eq!(workspace.panes().len(), 1);
3006 workspace.panes()[0].update(cx, |pane, cx| {
3007 pane.toolbar()
3008 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3009 });
3010
3011 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3012 }
3013 })
3014 .unwrap();
3015
3016 let search_view = cx.read(|cx| {
3017 workspace
3018 .read(cx)
3019 .active_pane()
3020 .read(cx)
3021 .active_item()
3022 .and_then(|item| item.downcast::<ProjectSearchView>())
3023 .expect("Search view expected to appear after new search event trigger")
3024 });
3025
3026 // Add 3 search items into the history + another unsubmitted one.
3027 window
3028 .update(cx, |_, window, cx| {
3029 search_view.update(cx, |search_view, cx| {
3030 search_view.search_options = SearchOptions::CASE_SENSITIVE;
3031 search_view.query_editor.update(cx, |query_editor, cx| {
3032 query_editor.set_text("ONE", window, cx)
3033 });
3034 search_view.search(cx);
3035 });
3036 })
3037 .unwrap();
3038
3039 cx.background_executor.run_until_parked();
3040 window
3041 .update(cx, |_, window, cx| {
3042 search_view.update(cx, |search_view, cx| {
3043 search_view.query_editor.update(cx, |query_editor, cx| {
3044 query_editor.set_text("TWO", window, cx)
3045 });
3046 search_view.search(cx);
3047 });
3048 })
3049 .unwrap();
3050 cx.background_executor.run_until_parked();
3051 window
3052 .update(cx, |_, window, cx| {
3053 search_view.update(cx, |search_view, cx| {
3054 search_view.query_editor.update(cx, |query_editor, cx| {
3055 query_editor.set_text("THREE", window, cx)
3056 });
3057 search_view.search(cx);
3058 })
3059 })
3060 .unwrap();
3061 cx.background_executor.run_until_parked();
3062 window
3063 .update(cx, |_, window, cx| {
3064 search_view.update(cx, |search_view, cx| {
3065 search_view.query_editor.update(cx, |query_editor, cx| {
3066 query_editor.set_text("JUST_TEXT_INPUT", window, cx)
3067 });
3068 })
3069 })
3070 .unwrap();
3071 cx.background_executor.run_until_parked();
3072
3073 // Ensure that the latest input with search settings is active.
3074 window
3075 .update(cx, |_, _, cx| {
3076 search_view.update(cx, |search_view, cx| {
3077 assert_eq!(
3078 search_view.query_editor.read(cx).text(cx),
3079 "JUST_TEXT_INPUT"
3080 );
3081 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3082 });
3083 })
3084 .unwrap();
3085
3086 // Next history query after the latest should set the query to the empty string.
3087 window
3088 .update(cx, |_, window, cx| {
3089 search_bar.update(cx, |search_bar, cx| {
3090 search_bar.focus_search(window, cx);
3091 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3092 })
3093 })
3094 .unwrap();
3095 window
3096 .update(cx, |_, _, cx| {
3097 search_view.update(cx, |search_view, cx| {
3098 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3099 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3100 });
3101 })
3102 .unwrap();
3103 window
3104 .update(cx, |_, window, cx| {
3105 search_bar.update(cx, |search_bar, cx| {
3106 search_bar.focus_search(window, cx);
3107 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3108 })
3109 })
3110 .unwrap();
3111 window
3112 .update(cx, |_, _, cx| {
3113 search_view.update(cx, |search_view, cx| {
3114 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3115 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3116 });
3117 })
3118 .unwrap();
3119
3120 // First previous query for empty current query should set the query to the latest submitted one.
3121 window
3122 .update(cx, |_, window, cx| {
3123 search_bar.update(cx, |search_bar, cx| {
3124 search_bar.focus_search(window, cx);
3125 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3126 });
3127 })
3128 .unwrap();
3129 window
3130 .update(cx, |_, _, cx| {
3131 search_view.update(cx, |search_view, cx| {
3132 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3133 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3134 });
3135 })
3136 .unwrap();
3137
3138 // Further previous items should go over the history in reverse order.
3139 window
3140 .update(cx, |_, window, cx| {
3141 search_bar.update(cx, |search_bar, cx| {
3142 search_bar.focus_search(window, cx);
3143 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3144 });
3145 })
3146 .unwrap();
3147 window
3148 .update(cx, |_, _, cx| {
3149 search_view.update(cx, |search_view, cx| {
3150 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3151 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3152 });
3153 })
3154 .unwrap();
3155
3156 // Previous items should never go behind the first history item.
3157 window
3158 .update(cx, |_, window, cx| {
3159 search_bar.update(cx, |search_bar, cx| {
3160 search_bar.focus_search(window, cx);
3161 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3162 });
3163 })
3164 .unwrap();
3165 window
3166 .update(cx, |_, _, cx| {
3167 search_view.update(cx, |search_view, cx| {
3168 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3169 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3170 });
3171 })
3172 .unwrap();
3173 window
3174 .update(cx, |_, window, cx| {
3175 search_bar.update(cx, |search_bar, cx| {
3176 search_bar.focus_search(window, cx);
3177 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3178 });
3179 })
3180 .unwrap();
3181 window
3182 .update(cx, |_, _, cx| {
3183 search_view.update(cx, |search_view, cx| {
3184 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3185 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3186 });
3187 })
3188 .unwrap();
3189
3190 // Next items should go over the history in the original order.
3191 window
3192 .update(cx, |_, window, cx| {
3193 search_bar.update(cx, |search_bar, cx| {
3194 search_bar.focus_search(window, cx);
3195 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3196 });
3197 })
3198 .unwrap();
3199 window
3200 .update(cx, |_, _, cx| {
3201 search_view.update(cx, |search_view, cx| {
3202 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3203 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3204 });
3205 })
3206 .unwrap();
3207
3208 window
3209 .update(cx, |_, window, cx| {
3210 search_view.update(cx, |search_view, cx| {
3211 search_view.query_editor.update(cx, |query_editor, cx| {
3212 query_editor.set_text("TWO_NEW", window, cx)
3213 });
3214 search_view.search(cx);
3215 });
3216 })
3217 .unwrap();
3218 cx.background_executor.run_until_parked();
3219 window
3220 .update(cx, |_, _, cx| {
3221 search_view.update(cx, |search_view, cx| {
3222 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3223 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3224 });
3225 })
3226 .unwrap();
3227
3228 // New search input should add another entry to history and move the selection to the end of the history.
3229 window
3230 .update(cx, |_, window, cx| {
3231 search_bar.update(cx, |search_bar, cx| {
3232 search_bar.focus_search(window, cx);
3233 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3234 });
3235 })
3236 .unwrap();
3237 window
3238 .update(cx, |_, _, cx| {
3239 search_view.update(cx, |search_view, cx| {
3240 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3241 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3242 });
3243 })
3244 .unwrap();
3245 window
3246 .update(cx, |_, window, cx| {
3247 search_bar.update(cx, |search_bar, cx| {
3248 search_bar.focus_search(window, cx);
3249 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3250 });
3251 })
3252 .unwrap();
3253 window
3254 .update(cx, |_, _, cx| {
3255 search_view.update(cx, |search_view, cx| {
3256 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3257 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3258 });
3259 })
3260 .unwrap();
3261 window
3262 .update(cx, |_, window, cx| {
3263 search_bar.update(cx, |search_bar, cx| {
3264 search_bar.focus_search(window, cx);
3265 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3266 });
3267 })
3268 .unwrap();
3269 window
3270 .update(cx, |_, _, cx| {
3271 search_view.update(cx, |search_view, cx| {
3272 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3273 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3274 });
3275 })
3276 .unwrap();
3277 window
3278 .update(cx, |_, window, cx| {
3279 search_bar.update(cx, |search_bar, cx| {
3280 search_bar.focus_search(window, cx);
3281 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3282 });
3283 })
3284 .unwrap();
3285 window
3286 .update(cx, |_, _, cx| {
3287 search_view.update(cx, |search_view, cx| {
3288 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3289 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3290 });
3291 })
3292 .unwrap();
3293 window
3294 .update(cx, |_, window, cx| {
3295 search_bar.update(cx, |search_bar, cx| {
3296 search_bar.focus_search(window, cx);
3297 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3298 });
3299 })
3300 .unwrap();
3301 window
3302 .update(cx, |_, _, cx| {
3303 search_view.update(cx, |search_view, cx| {
3304 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3305 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3306 });
3307 })
3308 .unwrap();
3309 }
3310
3311 #[gpui::test]
3312 async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) {
3313 init_test(cx);
3314
3315 let fs = FakeFs::new(cx.background_executor.clone());
3316 fs.insert_tree(
3317 path!("/dir"),
3318 json!({
3319 "one.rs": "const ONE: usize = 1;",
3320 }),
3321 )
3322 .await;
3323 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3324 let worktree_id = project.update(cx, |this, cx| {
3325 this.worktrees(cx).next().unwrap().read(cx).id()
3326 });
3327
3328 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3329 let workspace = window.root(cx).unwrap();
3330
3331 let panes: Vec<_> = window
3332 .update(cx, |this, _, _| this.panes().to_owned())
3333 .unwrap();
3334
3335 let search_bar_1 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3336 let search_bar_2 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3337
3338 assert_eq!(panes.len(), 1);
3339 let first_pane = panes.first().cloned().unwrap();
3340 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3341 window
3342 .update(cx, |workspace, window, cx| {
3343 workspace.open_path(
3344 (worktree_id, "one.rs"),
3345 Some(first_pane.downgrade()),
3346 true,
3347 window,
3348 cx,
3349 )
3350 })
3351 .unwrap()
3352 .await
3353 .unwrap();
3354 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3355
3356 // Add a project search item to the first pane
3357 window
3358 .update(cx, {
3359 let search_bar = search_bar_1.clone();
3360 |workspace, window, cx| {
3361 first_pane.update(cx, |pane, cx| {
3362 pane.toolbar()
3363 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3364 });
3365
3366 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3367 }
3368 })
3369 .unwrap();
3370 let search_view_1 = cx.read(|cx| {
3371 workspace
3372 .read(cx)
3373 .active_item(cx)
3374 .and_then(|item| item.downcast::<ProjectSearchView>())
3375 .expect("Search view expected to appear after new search event trigger")
3376 });
3377
3378 let second_pane = window
3379 .update(cx, |workspace, window, cx| {
3380 workspace.split_and_clone(
3381 first_pane.clone(),
3382 workspace::SplitDirection::Right,
3383 window,
3384 cx,
3385 )
3386 })
3387 .unwrap()
3388 .unwrap();
3389 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3390
3391 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3392 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3393
3394 // Add a project search item to the second pane
3395 window
3396 .update(cx, {
3397 let search_bar = search_bar_2.clone();
3398 let pane = second_pane.clone();
3399 move |workspace, window, cx| {
3400 assert_eq!(workspace.panes().len(), 2);
3401 pane.update(cx, |pane, cx| {
3402 pane.toolbar()
3403 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3404 });
3405
3406 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3407 }
3408 })
3409 .unwrap();
3410
3411 let search_view_2 = cx.read(|cx| {
3412 workspace
3413 .read(cx)
3414 .active_item(cx)
3415 .and_then(|item| item.downcast::<ProjectSearchView>())
3416 .expect("Search view expected to appear after new search event trigger")
3417 });
3418
3419 cx.run_until_parked();
3420 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3421 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3422
3423 let update_search_view =
3424 |search_view: &Entity<ProjectSearchView>, query: &str, cx: &mut TestAppContext| {
3425 window
3426 .update(cx, |_, window, cx| {
3427 search_view.update(cx, |search_view, cx| {
3428 search_view.query_editor.update(cx, |query_editor, cx| {
3429 query_editor.set_text(query, window, cx)
3430 });
3431 search_view.search(cx);
3432 });
3433 })
3434 .unwrap();
3435 };
3436
3437 let active_query =
3438 |search_view: &Entity<ProjectSearchView>, cx: &mut TestAppContext| -> String {
3439 window
3440 .update(cx, |_, _, cx| {
3441 search_view.update(cx, |search_view, cx| {
3442 search_view.query_editor.read(cx).text(cx).to_string()
3443 })
3444 })
3445 .unwrap()
3446 };
3447
3448 let select_prev_history_item =
3449 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
3450 window
3451 .update(cx, |_, window, cx| {
3452 search_bar.update(cx, |search_bar, cx| {
3453 search_bar.focus_search(window, cx);
3454 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3455 })
3456 })
3457 .unwrap();
3458 };
3459
3460 let select_next_history_item =
3461 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
3462 window
3463 .update(cx, |_, window, cx| {
3464 search_bar.update(cx, |search_bar, cx| {
3465 search_bar.focus_search(window, cx);
3466 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3467 })
3468 })
3469 .unwrap();
3470 };
3471
3472 update_search_view(&search_view_1, "ONE", cx);
3473 cx.background_executor.run_until_parked();
3474
3475 update_search_view(&search_view_2, "TWO", cx);
3476 cx.background_executor.run_until_parked();
3477
3478 assert_eq!(active_query(&search_view_1, cx), "ONE");
3479 assert_eq!(active_query(&search_view_2, cx), "TWO");
3480
3481 // Selecting previous history item should select the query from search view 1.
3482 select_prev_history_item(&search_bar_2, cx);
3483 assert_eq!(active_query(&search_view_2, cx), "ONE");
3484
3485 // Selecting the previous history item should not change the query as it is already the first item.
3486 select_prev_history_item(&search_bar_2, cx);
3487 assert_eq!(active_query(&search_view_2, cx), "ONE");
3488
3489 // Changing the query in search view 2 should not affect the history of search view 1.
3490 assert_eq!(active_query(&search_view_1, cx), "ONE");
3491
3492 // Deploying a new search in search view 2
3493 update_search_view(&search_view_2, "THREE", cx);
3494 cx.background_executor.run_until_parked();
3495
3496 select_next_history_item(&search_bar_2, cx);
3497 assert_eq!(active_query(&search_view_2, cx), "");
3498
3499 select_prev_history_item(&search_bar_2, cx);
3500 assert_eq!(active_query(&search_view_2, cx), "THREE");
3501
3502 select_prev_history_item(&search_bar_2, cx);
3503 assert_eq!(active_query(&search_view_2, cx), "TWO");
3504
3505 select_prev_history_item(&search_bar_2, cx);
3506 assert_eq!(active_query(&search_view_2, cx), "ONE");
3507
3508 select_prev_history_item(&search_bar_2, cx);
3509 assert_eq!(active_query(&search_view_2, cx), "ONE");
3510
3511 // Search view 1 should now see the query from search view 2.
3512 assert_eq!(active_query(&search_view_1, cx), "ONE");
3513
3514 select_next_history_item(&search_bar_2, cx);
3515 assert_eq!(active_query(&search_view_2, cx), "TWO");
3516
3517 // Here is the new query from search view 2
3518 select_next_history_item(&search_bar_2, cx);
3519 assert_eq!(active_query(&search_view_2, cx), "THREE");
3520
3521 select_next_history_item(&search_bar_2, cx);
3522 assert_eq!(active_query(&search_view_2, cx), "");
3523
3524 select_next_history_item(&search_bar_1, cx);
3525 assert_eq!(active_query(&search_view_1, cx), "TWO");
3526
3527 select_next_history_item(&search_bar_1, cx);
3528 assert_eq!(active_query(&search_view_1, cx), "THREE");
3529
3530 select_next_history_item(&search_bar_1, cx);
3531 assert_eq!(active_query(&search_view_1, cx), "");
3532 }
3533
3534 #[gpui::test]
3535 async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
3536 init_test(cx);
3537
3538 // Setup 2 panes, both with a file open and one with a project search.
3539 let fs = FakeFs::new(cx.background_executor.clone());
3540 fs.insert_tree(
3541 path!("/dir"),
3542 json!({
3543 "one.rs": "const ONE: usize = 1;",
3544 }),
3545 )
3546 .await;
3547 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3548 let worktree_id = project.update(cx, |this, cx| {
3549 this.worktrees(cx).next().unwrap().read(cx).id()
3550 });
3551 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3552 let panes: Vec<_> = window
3553 .update(cx, |this, _, _| this.panes().to_owned())
3554 .unwrap();
3555 assert_eq!(panes.len(), 1);
3556 let first_pane = panes.first().cloned().unwrap();
3557 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3558 window
3559 .update(cx, |workspace, window, cx| {
3560 workspace.open_path(
3561 (worktree_id, "one.rs"),
3562 Some(first_pane.downgrade()),
3563 true,
3564 window,
3565 cx,
3566 )
3567 })
3568 .unwrap()
3569 .await
3570 .unwrap();
3571 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3572 let second_pane = window
3573 .update(cx, |workspace, window, cx| {
3574 workspace.split_and_clone(
3575 first_pane.clone(),
3576 workspace::SplitDirection::Right,
3577 window,
3578 cx,
3579 )
3580 })
3581 .unwrap()
3582 .unwrap();
3583 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3584 assert!(window
3585 .update(cx, |_, window, cx| second_pane
3586 .focus_handle(cx)
3587 .contains_focused(window, cx))
3588 .unwrap());
3589 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3590 window
3591 .update(cx, {
3592 let search_bar = search_bar.clone();
3593 let pane = first_pane.clone();
3594 move |workspace, window, cx| {
3595 assert_eq!(workspace.panes().len(), 2);
3596 pane.update(cx, move |pane, cx| {
3597 pane.toolbar()
3598 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3599 });
3600 }
3601 })
3602 .unwrap();
3603
3604 // Add a project search item to the second pane
3605 window
3606 .update(cx, {
3607 let search_bar = search_bar.clone();
3608 |workspace, window, cx| {
3609 assert_eq!(workspace.panes().len(), 2);
3610 second_pane.update(cx, |pane, cx| {
3611 pane.toolbar()
3612 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3613 });
3614
3615 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3616 }
3617 })
3618 .unwrap();
3619
3620 cx.run_until_parked();
3621 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3622 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3623
3624 // Focus the first pane
3625 window
3626 .update(cx, |workspace, window, cx| {
3627 assert_eq!(workspace.active_pane(), &second_pane);
3628 second_pane.update(cx, |this, cx| {
3629 assert_eq!(this.active_item_index(), 1);
3630 this.activate_prev_item(false, window, cx);
3631 assert_eq!(this.active_item_index(), 0);
3632 });
3633 workspace.activate_pane_in_direction(workspace::SplitDirection::Left, window, cx);
3634 })
3635 .unwrap();
3636 window
3637 .update(cx, |workspace, _, cx| {
3638 assert_eq!(workspace.active_pane(), &first_pane);
3639 assert_eq!(first_pane.read(cx).items_len(), 1);
3640 assert_eq!(second_pane.read(cx).items_len(), 2);
3641 })
3642 .unwrap();
3643
3644 // Deploy a new search
3645 cx.dispatch_action(window.into(), DeploySearch::find());
3646
3647 // Both panes should now have a project search in them
3648 window
3649 .update(cx, |workspace, window, cx| {
3650 assert_eq!(workspace.active_pane(), &first_pane);
3651 first_pane.update(cx, |this, _| {
3652 assert_eq!(this.active_item_index(), 1);
3653 assert_eq!(this.items_len(), 2);
3654 });
3655 second_pane.update(cx, |this, cx| {
3656 assert!(!cx.focus_handle().contains_focused(window, cx));
3657 assert_eq!(this.items_len(), 2);
3658 });
3659 })
3660 .unwrap();
3661
3662 // Focus the second pane's non-search item
3663 window
3664 .update(cx, |_workspace, window, cx| {
3665 second_pane.update(cx, |pane, cx| pane.activate_next_item(true, window, cx));
3666 })
3667 .unwrap();
3668
3669 // Deploy a new search
3670 cx.dispatch_action(window.into(), DeploySearch::find());
3671
3672 // The project search view should now be focused in the second pane
3673 // And the number of items should be unchanged.
3674 window
3675 .update(cx, |_workspace, _, cx| {
3676 second_pane.update(cx, |pane, _cx| {
3677 assert!(pane
3678 .active_item()
3679 .unwrap()
3680 .downcast::<ProjectSearchView>()
3681 .is_some());
3682
3683 assert_eq!(pane.items_len(), 2);
3684 });
3685 })
3686 .unwrap();
3687 }
3688
3689 #[gpui::test]
3690 async fn test_scroll_search_results_to_top(cx: &mut TestAppContext) {
3691 init_test(cx);
3692
3693 // We need many lines in the search results to be able to scroll the window
3694 let fs = FakeFs::new(cx.background_executor.clone());
3695 fs.insert_tree(
3696 path!("/dir"),
3697 json!({
3698 "1.txt": "\n\n\n\n\n A \n\n\n\n\n",
3699 "2.txt": "\n\n\n\n\n A \n\n\n\n\n",
3700 "3.rs": "\n\n\n\n\n A \n\n\n\n\n",
3701 "4.rs": "\n\n\n\n\n A \n\n\n\n\n",
3702 "5.rs": "\n\n\n\n\n A \n\n\n\n\n",
3703 "6.rs": "\n\n\n\n\n A \n\n\n\n\n",
3704 "7.rs": "\n\n\n\n\n A \n\n\n\n\n",
3705 "8.rs": "\n\n\n\n\n A \n\n\n\n\n",
3706 "9.rs": "\n\n\n\n\n A \n\n\n\n\n",
3707 "a.rs": "\n\n\n\n\n A \n\n\n\n\n",
3708 "b.rs": "\n\n\n\n\n B \n\n\n\n\n",
3709 "c.rs": "\n\n\n\n\n B \n\n\n\n\n",
3710 "d.rs": "\n\n\n\n\n B \n\n\n\n\n",
3711 "e.rs": "\n\n\n\n\n B \n\n\n\n\n",
3712 "f.rs": "\n\n\n\n\n B \n\n\n\n\n",
3713 "g.rs": "\n\n\n\n\n B \n\n\n\n\n",
3714 "h.rs": "\n\n\n\n\n B \n\n\n\n\n",
3715 "i.rs": "\n\n\n\n\n B \n\n\n\n\n",
3716 "j.rs": "\n\n\n\n\n B \n\n\n\n\n",
3717 "k.rs": "\n\n\n\n\n B \n\n\n\n\n",
3718 }),
3719 )
3720 .await;
3721 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3722 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3723 let workspace = window.root(cx).unwrap();
3724 let search = cx.new(|cx| ProjectSearch::new(project, cx));
3725 let search_view = cx.add_window(|window, cx| {
3726 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
3727 });
3728
3729 // First search
3730 perform_search(search_view, "A", cx);
3731 search_view
3732 .update(cx, |search_view, window, cx| {
3733 search_view.results_editor.update(cx, |results_editor, cx| {
3734 // Results are correct and scrolled to the top
3735 assert_eq!(
3736 results_editor.display_text(cx).match_indices(" A ").count(),
3737 10
3738 );
3739 assert_eq!(results_editor.scroll_position(cx), Point::default());
3740
3741 // Scroll results all the way down
3742 results_editor.scroll(
3743 Point::new(0., f32::MAX),
3744 Some(Axis::Vertical),
3745 window,
3746 cx,
3747 );
3748 });
3749 })
3750 .expect("unable to update search view");
3751
3752 // Second search
3753 perform_search(search_view, "B", cx);
3754 search_view
3755 .update(cx, |search_view, _, cx| {
3756 search_view.results_editor.update(cx, |results_editor, cx| {
3757 // Results are correct...
3758 assert_eq!(
3759 results_editor.display_text(cx).match_indices(" B ").count(),
3760 10
3761 );
3762 // ...and scrolled back to the top
3763 assert_eq!(results_editor.scroll_position(cx), Point::default());
3764 });
3765 })
3766 .expect("unable to update search view");
3767 }
3768
3769 #[gpui::test]
3770 async fn test_buffer_search_query_reused(cx: &mut TestAppContext) {
3771 init_test(cx);
3772
3773 let fs = FakeFs::new(cx.background_executor.clone());
3774 fs.insert_tree(
3775 path!("/dir"),
3776 json!({
3777 "one.rs": "const ONE: usize = 1;",
3778 }),
3779 )
3780 .await;
3781 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3782 let worktree_id = project.update(cx, |this, cx| {
3783 this.worktrees(cx).next().unwrap().read(cx).id()
3784 });
3785 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3786 let workspace = window.root(cx).unwrap();
3787 let mut cx = VisualTestContext::from_window(*window.deref(), cx);
3788
3789 let editor = workspace
3790 .update_in(&mut cx, |workspace, window, cx| {
3791 workspace.open_path((worktree_id, "one.rs"), None, true, window, cx)
3792 })
3793 .await
3794 .unwrap()
3795 .downcast::<Editor>()
3796 .unwrap();
3797
3798 // Wait for the unstaged changes to be loaded
3799 cx.run_until_parked();
3800
3801 let buffer_search_bar = cx.new_window_entity(|window, cx| {
3802 let mut search_bar = BufferSearchBar::new(window, cx);
3803 search_bar.set_active_pane_item(Some(&editor), window, cx);
3804 search_bar.show(window, cx);
3805 search_bar
3806 });
3807
3808 let panes: Vec<_> = window
3809 .update(&mut cx, |this, _, _| this.panes().to_owned())
3810 .unwrap();
3811 assert_eq!(panes.len(), 1);
3812 let pane = panes.first().cloned().unwrap();
3813 pane.update_in(&mut cx, |pane, window, cx| {
3814 pane.toolbar().update(cx, |toolbar, cx| {
3815 toolbar.add_item(buffer_search_bar.clone(), window, cx);
3816 })
3817 });
3818
3819 let buffer_search_query = "search bar query";
3820 buffer_search_bar
3821 .update_in(&mut cx, |buffer_search_bar, window, cx| {
3822 buffer_search_bar.focus_handle(cx).focus(window);
3823 buffer_search_bar.search(buffer_search_query, None, window, cx)
3824 })
3825 .await
3826 .unwrap();
3827
3828 workspace.update_in(&mut cx, |workspace, window, cx| {
3829 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3830 });
3831 cx.run_until_parked();
3832 let project_search_view = pane
3833 .update(&mut cx, |pane, _| {
3834 pane.active_item()
3835 .and_then(|item| item.downcast::<ProjectSearchView>())
3836 })
3837 .expect("should open a project search view after spawning a new search");
3838 project_search_view.update(&mut cx, |search_view, cx| {
3839 assert_eq!(
3840 search_view.search_query_text(cx),
3841 buffer_search_query,
3842 "Project search should take the query from the buffer search bar since it got focused and had a query inside"
3843 );
3844 });
3845 }
3846
3847 fn init_test(cx: &mut TestAppContext) {
3848 cx.update(|cx| {
3849 let settings = SettingsStore::test(cx);
3850 cx.set_global(settings);
3851
3852 theme::init(theme::LoadThemes::JustBase, cx);
3853
3854 language::init(cx);
3855 client::init_settings(cx);
3856 editor::init(cx);
3857 workspace::init_settings(cx);
3858 Project::init_settings(cx);
3859 crate::init(cx);
3860 });
3861 }
3862
3863 fn perform_search(
3864 search_view: WindowHandle<ProjectSearchView>,
3865 text: impl Into<Arc<str>>,
3866 cx: &mut TestAppContext,
3867 ) {
3868 search_view
3869 .update(cx, |search_view, window, cx| {
3870 search_view.query_editor.update(cx, |query_editor, cx| {
3871 query_editor.set_text(text, window, cx)
3872 });
3873 search_view.search(cx);
3874 })
3875 .unwrap();
3876 cx.background_executor.run_until_parked();
3877 }
3878}