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