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