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