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