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