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