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