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