1use crate::{
2 BufferSearchBar, FocusSearch, HighlightKey, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll,
3 ReplaceNext, SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch,
4 ToggleCaseSensitive, ToggleIncludeIgnored, ToggleRegex, ToggleReplace, ToggleWholeWord,
5 buffer_search::Deploy,
6 search_bar::{
7 ActionButtonState, HistoryNavigationDirection, alignment_element, input_base_styles,
8 render_action_button, render_text_input, should_navigate_history,
9 },
10};
11use anyhow::Context as _;
12use collections::HashMap;
13use editor::{
14 Anchor, Editor, EditorEvent, EditorSettings, MAX_TAB_TITLE_LEN, MultiBuffer, PathKey,
15 SelectionEffects,
16 actions::{Backtab, FoldAll, SelectAll, Tab, UnfoldAll},
17 items::active_match_index,
18 multibuffer_context_lines,
19 scroll::Autoscroll,
20};
21use futures::{StreamExt, stream::FuturesOrdered};
22use gpui::{
23 Action, AnyElement, App, Axis, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
24 Global, Hsla, InteractiveElement, IntoElement, KeyContext, ParentElement, Point, Render,
25 SharedString, Styled, Subscription, Task, UpdateGlobal, WeakEntity, Window, actions, div,
26};
27use itertools::Itertools;
28use language::{Buffer, Language};
29use menu::Confirm;
30use multi_buffer;
31use project::{
32 Project, ProjectPath, SearchResults,
33 search::{SearchInputKind, SearchQuery},
34 search_history::SearchHistoryCursor,
35};
36use settings::Settings;
37use std::{
38 any::{Any, TypeId},
39 mem,
40 ops::{Not, Range},
41 pin::pin,
42 sync::Arc,
43};
44use ui::{
45 CommonAnimationExt, IconButtonShape, KeyBinding, Toggleable, Tooltip, prelude::*,
46 utils::SearchInputWidth,
47};
48use util::{ResultExt as _, paths::PathMatcher, rel_path::RelPath};
49use workspace::{
50 DeploySearch, ItemNavHistory, NewSearch, ToolbarItemEvent, ToolbarItemLocation,
51 ToolbarItemView, Workspace, WorkspaceId,
52 item::{Item, ItemEvent, ItemHandle, SaveOptions},
53 searchable::{Direction, SearchEvent, SearchToken, SearchableItem, SearchableItemHandle},
54};
55
56actions!(
57 project_search,
58 [
59 /// Searches in a new project search tab.
60 SearchInNew,
61 /// Toggles focus between the search bar and the search results.
62 ToggleFocus,
63 /// Moves to the next input field.
64 NextField,
65 /// Toggles the search filters panel.
66 ToggleFilters,
67 /// Toggles collapse/expand state of all search result excerpts.
68 ToggleAllSearchResults
69 ]
70);
71
72fn split_glob_patterns(text: &str) -> Vec<&str> {
73 let mut patterns = Vec::new();
74 let mut pattern_start = 0;
75 let mut brace_depth: usize = 0;
76 let mut escaped = false;
77
78 for (index, character) in text.char_indices() {
79 if escaped {
80 escaped = false;
81 continue;
82 }
83 match character {
84 '\\' => escaped = true,
85 '{' => brace_depth += 1,
86 '}' => brace_depth = brace_depth.saturating_sub(1),
87 ',' if brace_depth == 0 => {
88 patterns.push(&text[pattern_start..index]);
89 pattern_start = index + 1;
90 }
91 _ => {}
92 }
93 }
94 patterns.push(&text[pattern_start..]);
95 patterns
96}
97
98#[derive(Default)]
99struct ActiveSettings(HashMap<WeakEntity<Project>, ProjectSearchSettings>);
100
101impl Global for ActiveSettings {}
102
103pub fn init(cx: &mut App) {
104 cx.set_global(ActiveSettings::default());
105 cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
106 register_workspace_action(workspace, move |search_bar, _: &Deploy, window, cx| {
107 search_bar.focus_search(window, cx);
108 });
109 register_workspace_action(workspace, move |search_bar, _: &FocusSearch, window, cx| {
110 search_bar.focus_search(window, cx);
111 });
112 register_workspace_action(
113 workspace,
114 move |search_bar, _: &ToggleFilters, window, cx| {
115 search_bar.toggle_filters(window, cx);
116 },
117 );
118 register_workspace_action(
119 workspace,
120 move |search_bar, _: &ToggleCaseSensitive, window, cx| {
121 search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
122 },
123 );
124 register_workspace_action(
125 workspace,
126 move |search_bar, _: &ToggleWholeWord, window, cx| {
127 search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
128 },
129 );
130 register_workspace_action(workspace, move |search_bar, _: &ToggleRegex, window, cx| {
131 search_bar.toggle_search_option(SearchOptions::REGEX, window, cx);
132 });
133 register_workspace_action(
134 workspace,
135 move |search_bar, action: &ToggleReplace, window, cx| {
136 search_bar.toggle_replace(action, window, cx)
137 },
138 );
139 register_workspace_action(
140 workspace,
141 move |search_bar, action: &SelectPreviousMatch, window, cx| {
142 search_bar.select_prev_match(action, window, cx)
143 },
144 );
145 register_workspace_action(
146 workspace,
147 move |search_bar, action: &SelectNextMatch, window, cx| {
148 search_bar.select_next_match(action, window, cx)
149 },
150 );
151
152 // Only handle search_in_new if there is a search present
153 register_workspace_action_for_present_search(workspace, |workspace, action, window, cx| {
154 ProjectSearchView::search_in_new(workspace, action, window, cx)
155 });
156
157 register_workspace_action_for_present_search(
158 workspace,
159 |workspace, action: &ToggleAllSearchResults, window, cx| {
160 if let Some(search_view) = workspace
161 .active_item(cx)
162 .and_then(|item| item.downcast::<ProjectSearchView>())
163 {
164 search_view.update(cx, |search_view, cx| {
165 search_view.toggle_all_search_results(action, window, cx);
166 });
167 }
168 },
169 );
170
171 register_workspace_action_for_present_search(
172 workspace,
173 |workspace, _: &menu::Cancel, window, cx| {
174 if let Some(project_search_bar) = workspace
175 .active_pane()
176 .read(cx)
177 .toolbar()
178 .read(cx)
179 .item_of_type::<ProjectSearchBar>()
180 {
181 project_search_bar.update(cx, |project_search_bar, cx| {
182 let search_is_focused = project_search_bar
183 .active_project_search
184 .as_ref()
185 .is_some_and(|search_view| {
186 search_view
187 .read(cx)
188 .query_editor
189 .read(cx)
190 .focus_handle(cx)
191 .is_focused(window)
192 });
193 if search_is_focused {
194 project_search_bar.move_focus_to_results(window, cx);
195 } else {
196 project_search_bar.focus_search(window, cx)
197 }
198 });
199 } else {
200 cx.propagate();
201 }
202 },
203 );
204
205 // Both on present and dismissed search, we need to unconditionally handle those actions to focus from the editor.
206 workspace.register_action(move |workspace, action: &DeploySearch, window, cx| {
207 if workspace.has_active_modal(window, cx) && !workspace.hide_modal(window, cx) {
208 cx.propagate();
209 return;
210 }
211 ProjectSearchView::deploy_search(workspace, action, window, cx);
212 cx.notify();
213 });
214 workspace.register_action(move |workspace, action: &NewSearch, window, cx| {
215 if workspace.has_active_modal(window, cx) && !workspace.hide_modal(window, cx) {
216 cx.propagate();
217 return;
218 }
219 ProjectSearchView::new_search(workspace, action, window, cx);
220 cx.notify();
221 });
222 })
223 .detach();
224}
225
226fn contains_uppercase(str: &str) -> bool {
227 str.chars().any(|c| c.is_uppercase())
228}
229
230pub struct ProjectSearch {
231 project: Entity<Project>,
232 excerpts: Entity<MultiBuffer>,
233 pending_search: Option<Task<Option<()>>>,
234 match_ranges: Vec<Range<Anchor>>,
235 active_query: Option<SearchQuery>,
236 last_search_query_text: Option<String>,
237 search_id: usize,
238 no_results: Option<bool>,
239 limit_reached: bool,
240 search_history_cursor: SearchHistoryCursor,
241 search_included_history_cursor: SearchHistoryCursor,
242 search_excluded_history_cursor: SearchHistoryCursor,
243 _excerpts_subscription: Subscription,
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
247enum InputPanel {
248 Query,
249 Replacement,
250 Exclude,
251 Include,
252}
253
254pub struct ProjectSearchView {
255 workspace: WeakEntity<Workspace>,
256 focus_handle: FocusHandle,
257 entity: Entity<ProjectSearch>,
258 query_editor: Entity<Editor>,
259 replacement_editor: Entity<Editor>,
260 results_editor: Entity<Editor>,
261 search_options: SearchOptions,
262 panels_with_errors: HashMap<InputPanel, String>,
263 active_match_index: Option<usize>,
264 search_id: usize,
265 included_files_editor: Entity<Editor>,
266 excluded_files_editor: Entity<Editor>,
267 filters_enabled: bool,
268 replace_enabled: bool,
269 pending_replace_all: bool,
270 included_opened_only: bool,
271 regex_language: Option<Arc<Language>>,
272 _subscriptions: Vec<Subscription>,
273}
274
275#[derive(Debug, Clone)]
276pub struct ProjectSearchSettings {
277 search_options: SearchOptions,
278 filters_enabled: bool,
279}
280
281pub struct ProjectSearchBar {
282 active_project_search: Option<Entity<ProjectSearchView>>,
283 subscription: Option<Subscription>,
284}
285
286impl ProjectSearch {
287 pub fn new(project: Entity<Project>, cx: &mut Context<Self>) -> Self {
288 let capability = project.read(cx).capability();
289 let excerpts = cx.new(|_| MultiBuffer::new(capability));
290 let subscription = Self::subscribe_to_excerpts(&excerpts, cx);
291
292 Self {
293 project,
294 excerpts,
295 pending_search: Default::default(),
296 match_ranges: Default::default(),
297 active_query: None,
298 last_search_query_text: None,
299 search_id: 0,
300 no_results: None,
301 limit_reached: false,
302 search_history_cursor: Default::default(),
303 search_included_history_cursor: Default::default(),
304 search_excluded_history_cursor: Default::default(),
305 _excerpts_subscription: subscription,
306 }
307 }
308
309 fn clone(&self, cx: &mut Context<Self>) -> Entity<Self> {
310 cx.new(|cx| {
311 let excerpts = self
312 .excerpts
313 .update(cx, |excerpts, cx| cx.new(|cx| excerpts.clone(cx)));
314 let subscription = Self::subscribe_to_excerpts(&excerpts, cx);
315
316 Self {
317 project: self.project.clone(),
318 excerpts,
319 pending_search: Default::default(),
320 match_ranges: self.match_ranges.clone(),
321 active_query: self.active_query.clone(),
322 last_search_query_text: self.last_search_query_text.clone(),
323 search_id: self.search_id,
324 no_results: self.no_results,
325 limit_reached: self.limit_reached,
326 search_history_cursor: self.search_history_cursor.clone(),
327 search_included_history_cursor: self.search_included_history_cursor.clone(),
328 search_excluded_history_cursor: self.search_excluded_history_cursor.clone(),
329 _excerpts_subscription: subscription,
330 }
331 })
332 }
333 fn subscribe_to_excerpts(
334 excerpts: &Entity<MultiBuffer>,
335 cx: &mut Context<Self>,
336 ) -> Subscription {
337 cx.subscribe(excerpts, |this, _, event, cx| {
338 if matches!(event, multi_buffer::Event::FileHandleChanged) {
339 this.remove_deleted_buffers(cx);
340 }
341 })
342 }
343
344 fn remove_deleted_buffers(&mut self, cx: &mut Context<Self>) {
345 let deleted_buffer_ids = self
346 .excerpts
347 .read(cx)
348 .all_buffers_iter()
349 .filter(|buffer| {
350 buffer
351 .read(cx)
352 .file()
353 .is_some_and(|file| file.disk_state().is_deleted())
354 })
355 .map(|buffer| buffer.read(cx).remote_id())
356 .collect::<Vec<_>>();
357
358 if deleted_buffer_ids.is_empty() {
359 return;
360 }
361
362 let snapshot = self.excerpts.update(cx, |excerpts, cx| {
363 for buffer_id in deleted_buffer_ids {
364 excerpts.remove_excerpts_for_buffer(buffer_id, cx);
365 }
366 excerpts.snapshot(cx)
367 });
368
369 self.match_ranges
370 .retain(|range| snapshot.anchor_to_buffer_anchor(range.start).is_some());
371
372 cx.notify();
373 }
374
375 fn cursor(&self, kind: SearchInputKind) -> &SearchHistoryCursor {
376 match kind {
377 SearchInputKind::Query => &self.search_history_cursor,
378 SearchInputKind::Include => &self.search_included_history_cursor,
379 SearchInputKind::Exclude => &self.search_excluded_history_cursor,
380 }
381 }
382 fn cursor_mut(&mut self, kind: SearchInputKind) -> &mut SearchHistoryCursor {
383 match kind {
384 SearchInputKind::Query => &mut self.search_history_cursor,
385 SearchInputKind::Include => &mut self.search_included_history_cursor,
386 SearchInputKind::Exclude => &mut self.search_excluded_history_cursor,
387 }
388 }
389
390 fn search(&mut self, query: SearchQuery, cx: &mut Context<Self>) {
391 let search = self.project.update(cx, |project, cx| {
392 project
393 .search_history_mut(SearchInputKind::Query)
394 .add(&mut self.search_history_cursor, query.as_str().to_string());
395 let included = query.as_inner().files_to_include().sources().join(",");
396 if !included.is_empty() {
397 project
398 .search_history_mut(SearchInputKind::Include)
399 .add(&mut self.search_included_history_cursor, included);
400 }
401 let excluded = query.as_inner().files_to_exclude().sources().join(",");
402 if !excluded.is_empty() {
403 project
404 .search_history_mut(SearchInputKind::Exclude)
405 .add(&mut self.search_excluded_history_cursor, excluded);
406 }
407 project.search(query.clone(), cx)
408 });
409 self.last_search_query_text = Some(query.as_str().to_string());
410 self.search_id += 1;
411 self.active_query = Some(query);
412 self.match_ranges.clear();
413 self.pending_search = Some(cx.spawn(async move |project_search, cx| {
414 let SearchResults { rx, _task_handle } = search;
415
416 let mut matches = pin!(rx.ready_chunks(1024));
417 project_search
418 .update(cx, |project_search, cx| {
419 project_search.match_ranges.clear();
420 project_search
421 .excerpts
422 .update(cx, |excerpts, cx| excerpts.clear(cx));
423 project_search.no_results = Some(true);
424 project_search.limit_reached = false;
425 })
426 .ok()?;
427
428 let mut limit_reached = false;
429 while let Some(results) = matches.next().await {
430 let (buffers_with_ranges, has_reached_limit) = cx
431 .background_executor()
432 .spawn(async move {
433 let mut limit_reached = false;
434 let mut buffers_with_ranges = Vec::with_capacity(results.len());
435 for result in results {
436 match result {
437 project::search::SearchResult::Buffer { buffer, ranges } => {
438 buffers_with_ranges.push((buffer, ranges));
439 }
440 project::search::SearchResult::LimitReached => {
441 limit_reached = true;
442 }
443 }
444 }
445 (buffers_with_ranges, limit_reached)
446 })
447 .await;
448 limit_reached |= has_reached_limit;
449 let mut new_ranges = project_search
450 .update(cx, |project_search, cx| {
451 project_search.excerpts.update(cx, |excerpts, cx| {
452 buffers_with_ranges
453 .into_iter()
454 .map(|(buffer, ranges)| {
455 excerpts.set_anchored_excerpts_for_path(
456 PathKey::for_buffer(&buffer, cx),
457 buffer,
458 ranges,
459 multibuffer_context_lines(cx),
460 cx,
461 )
462 })
463 .collect::<FuturesOrdered<_>>()
464 })
465 })
466 .ok()?;
467 while let Some(new_ranges) = new_ranges.next().await {
468 // `new_ranges.next().await` likely never gets hit while still pending so `async_task`
469 // will not reschedule, starving other front end tasks, insert a yield point for that here
470 smol::future::yield_now().await;
471 project_search
472 .update(cx, |project_search, cx| {
473 project_search.match_ranges.extend(new_ranges);
474 cx.notify();
475 })
476 .ok()?;
477 }
478 }
479
480 project_search
481 .update(cx, |project_search, cx| {
482 if !project_search.match_ranges.is_empty() {
483 project_search.no_results = Some(false);
484 }
485 project_search.limit_reached = limit_reached;
486 project_search.pending_search.take();
487 cx.notify();
488 })
489 .ok()?;
490
491 None
492 }));
493 cx.notify();
494 }
495}
496
497#[derive(Clone, Debug, PartialEq, Eq)]
498pub enum ViewEvent {
499 UpdateTab,
500 Activate,
501 EditorEvent(editor::EditorEvent),
502 Dismiss,
503}
504
505impl EventEmitter<ViewEvent> for ProjectSearchView {}
506
507impl Render for ProjectSearchView {
508 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
509 if self.has_matches() {
510 div()
511 .flex_1()
512 .size_full()
513 .track_focus(&self.focus_handle(cx))
514 .child(self.results_editor.clone())
515 } else {
516 let model = self.entity.read(cx);
517 let has_no_results = model.no_results.unwrap_or(false);
518 let is_search_underway = model.pending_search.is_some();
519
520 let heading_text = if is_search_underway {
521 "Searching…"
522 } else if has_no_results {
523 "No Results"
524 } else {
525 "Search All Files"
526 };
527
528 let heading_text = div()
529 .justify_center()
530 .child(Label::new(heading_text).size(LabelSize::Large));
531
532 let page_content: Option<AnyElement> = if let Some(no_results) = model.no_results {
533 if model.pending_search.is_none() && no_results {
534 Some(
535 Label::new("No results found in this project for the provided query")
536 .size(LabelSize::Small)
537 .into_any_element(),
538 )
539 } else {
540 None
541 }
542 } else {
543 Some(self.landing_text_minor(cx).into_any_element())
544 };
545
546 let page_content = page_content.map(|text| div().child(text));
547
548 h_flex()
549 .size_full()
550 .items_center()
551 .justify_center()
552 .overflow_hidden()
553 .bg(cx.theme().colors().editor_background)
554 .track_focus(&self.focus_handle(cx))
555 .child(
556 v_flex()
557 .id("project-search-landing-page")
558 .overflow_y_scroll()
559 .gap_1()
560 .child(heading_text)
561 .children(page_content),
562 )
563 }
564 }
565}
566
567impl Focusable for ProjectSearchView {
568 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
569 self.focus_handle.clone()
570 }
571}
572
573impl Item for ProjectSearchView {
574 type Event = ViewEvent;
575 fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
576 let query_text = self.query_editor.read(cx).text(cx);
577
578 query_text
579 .is_empty()
580 .not()
581 .then(|| query_text.into())
582 .or_else(|| Some("Project Search".into()))
583 }
584
585 fn act_as_type<'a>(
586 &'a self,
587 type_id: TypeId,
588 self_handle: &'a Entity<Self>,
589 _: &'a App,
590 ) -> Option<gpui::AnyEntity> {
591 if type_id == TypeId::of::<Self>() {
592 Some(self_handle.clone().into())
593 } else if type_id == TypeId::of::<Editor>() {
594 Some(self.results_editor.clone().into())
595 } else {
596 None
597 }
598 }
599 fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
600 Some(Box::new(self.results_editor.clone()))
601 }
602
603 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
604 self.results_editor
605 .update(cx, |editor, cx| editor.deactivated(window, cx));
606 }
607
608 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
609 Some(Icon::new(IconName::MagnifyingGlass))
610 }
611
612 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
613 let last_query: Option<SharedString> = self
614 .entity
615 .read(cx)
616 .last_search_query_text
617 .as_ref()
618 .map(|query| {
619 let query = query.replace('\n', "");
620 let query_text = util::truncate_and_trailoff(&query, MAX_TAB_TITLE_LEN);
621 query_text.into()
622 });
623
624 last_query
625 .filter(|query| !query.is_empty())
626 .unwrap_or_else(|| "Project Search".into())
627 }
628
629 fn telemetry_event_text(&self) -> Option<&'static str> {
630 Some("Project Search Opened")
631 }
632
633 fn for_each_project_item(
634 &self,
635 cx: &App,
636 f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
637 ) {
638 self.results_editor.for_each_project_item(cx, f)
639 }
640
641 fn can_save(&self, _: &App) -> bool {
642 true
643 }
644
645 fn is_dirty(&self, cx: &App) -> bool {
646 self.results_editor.read(cx).is_dirty(cx)
647 }
648
649 fn has_conflict(&self, cx: &App) -> bool {
650 self.results_editor.read(cx).has_conflict(cx)
651 }
652
653 fn save(
654 &mut self,
655 options: SaveOptions,
656 project: Entity<Project>,
657 window: &mut Window,
658 cx: &mut Context<Self>,
659 ) -> Task<anyhow::Result<()>> {
660 self.results_editor
661 .update(cx, |editor, cx| editor.save(options, project, window, cx))
662 }
663
664 fn save_as(
665 &mut self,
666 _: Entity<Project>,
667 _: ProjectPath,
668 _window: &mut Window,
669 _: &mut Context<Self>,
670 ) -> Task<anyhow::Result<()>> {
671 unreachable!("save_as should not have been called")
672 }
673
674 fn reload(
675 &mut self,
676 project: Entity<Project>,
677 window: &mut Window,
678 cx: &mut Context<Self>,
679 ) -> Task<anyhow::Result<()>> {
680 self.results_editor
681 .update(cx, |editor, cx| editor.reload(project, window, cx))
682 }
683
684 fn can_split(&self) -> bool {
685 true
686 }
687
688 fn clone_on_split(
689 &self,
690 _workspace_id: Option<WorkspaceId>,
691 window: &mut Window,
692 cx: &mut Context<Self>,
693 ) -> Task<Option<Entity<Self>>>
694 where
695 Self: Sized,
696 {
697 let model = self.entity.update(cx, |model, cx| model.clone(cx));
698 Task::ready(Some(cx.new(|cx| {
699 Self::new(self.workspace.clone(), model, window, cx, None)
700 })))
701 }
702
703 fn added_to_workspace(
704 &mut self,
705 workspace: &mut Workspace,
706 window: &mut Window,
707 cx: &mut Context<Self>,
708 ) {
709 self.results_editor.update(cx, |editor, cx| {
710 editor.added_to_workspace(workspace, window, cx)
711 });
712 }
713
714 fn set_nav_history(
715 &mut self,
716 nav_history: ItemNavHistory,
717 _: &mut Window,
718 cx: &mut Context<Self>,
719 ) {
720 self.results_editor.update(cx, |editor, _| {
721 editor.set_nav_history(Some(nav_history));
722 });
723 }
724
725 fn navigate(
726 &mut self,
727 data: Arc<dyn Any + Send>,
728 window: &mut Window,
729 cx: &mut Context<Self>,
730 ) -> bool {
731 self.results_editor
732 .update(cx, |editor, cx| editor.navigate(data, window, cx))
733 }
734
735 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
736 match event {
737 ViewEvent::UpdateTab => {
738 f(ItemEvent::UpdateBreadcrumbs);
739 f(ItemEvent::UpdateTab);
740 }
741 ViewEvent::EditorEvent(editor_event) => {
742 Editor::to_item_events(editor_event, f);
743 }
744 ViewEvent::Dismiss => f(ItemEvent::CloseItem),
745 _ => {}
746 }
747 }
748}
749
750impl ProjectSearchView {
751 pub fn get_matches(&self, cx: &App) -> Vec<Range<Anchor>> {
752 self.entity.read(cx).match_ranges.clone()
753 }
754
755 fn toggle_filters(&mut self, cx: &mut Context<Self>) {
756 self.filters_enabled = !self.filters_enabled;
757 ActiveSettings::update_global(cx, |settings, cx| {
758 settings.0.insert(
759 self.entity.read(cx).project.downgrade(),
760 self.current_settings(),
761 );
762 });
763 }
764
765 fn current_settings(&self) -> ProjectSearchSettings {
766 ProjectSearchSettings {
767 search_options: self.search_options,
768 filters_enabled: self.filters_enabled,
769 }
770 }
771
772 fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut Context<Self>) {
773 self.search_options.toggle(option);
774 ActiveSettings::update_global(cx, |settings, cx| {
775 settings.0.insert(
776 self.entity.read(cx).project.downgrade(),
777 self.current_settings(),
778 );
779 });
780 self.adjust_query_regex_language(cx);
781 }
782
783 fn toggle_opened_only(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
784 self.included_opened_only = !self.included_opened_only;
785 }
786
787 pub fn replacement(&self, cx: &App) -> String {
788 self.replacement_editor.read(cx).text(cx)
789 }
790
791 fn replace_next(&mut self, _: &ReplaceNext, window: &mut Window, cx: &mut Context<Self>) {
792 if self.entity.read(cx).pending_search.is_some() {
793 return;
794 }
795 if let Some(last_search_query_text) = &self.entity.read(cx).last_search_query_text
796 && self.query_editor.read(cx).text(cx) != *last_search_query_text
797 {
798 // search query has changed, restart search and bail
799 self.search(cx);
800 return;
801 }
802 if self.entity.read(cx).match_ranges.is_empty() {
803 return;
804 }
805 let Some(active_index) = self.active_match_index else {
806 return;
807 };
808
809 let query = self.entity.read(cx).active_query.clone();
810 if let Some(query) = query {
811 let query = query.with_replacement(self.replacement(cx));
812
813 let mat = self.entity.read(cx).match_ranges.get(active_index).cloned();
814 self.results_editor.update(cx, |editor, cx| {
815 if let Some(mat) = mat.as_ref() {
816 editor.replace(mat, &query, SearchToken::default(), window, cx);
817 }
818 });
819 self.select_match(Direction::Next, window, cx)
820 }
821 }
822
823 fn replace_all(&mut self, _: &ReplaceAll, window: &mut Window, cx: &mut Context<Self>) {
824 if self.entity.read(cx).pending_search.is_some() {
825 self.pending_replace_all = true;
826 return;
827 }
828 let query_text = self.query_editor.read(cx).text(cx);
829 let query_is_stale =
830 self.entity.read(cx).last_search_query_text.as_deref() != Some(query_text.as_str());
831 if query_is_stale {
832 self.pending_replace_all = true;
833 self.search(cx);
834 if self.entity.read(cx).pending_search.is_none() {
835 self.pending_replace_all = false;
836 }
837 return;
838 }
839 self.pending_replace_all = false;
840 if self.active_match_index.is_none() {
841 return;
842 }
843 let Some(query) = self.entity.read(cx).active_query.as_ref() else {
844 return;
845 };
846 let query = query.clone().with_replacement(self.replacement(cx));
847
848 let match_ranges = self
849 .entity
850 .update(cx, |model, _| mem::take(&mut model.match_ranges));
851 if match_ranges.is_empty() {
852 return;
853 }
854
855 self.results_editor.update(cx, |editor, cx| {
856 editor.replace_all(
857 &mut match_ranges.iter(),
858 &query,
859 SearchToken::default(),
860 window,
861 cx,
862 );
863 });
864
865 self.entity.update(cx, |model, _cx| {
866 model.match_ranges = match_ranges;
867 });
868 }
869
870 fn toggle_all_search_results(
871 &mut self,
872 _: &ToggleAllSearchResults,
873 window: &mut Window,
874 cx: &mut Context<Self>,
875 ) {
876 self.update_results_visibility(window, cx);
877 }
878
879 fn update_results_visibility(&mut self, window: &mut Window, cx: &mut Context<Self>) {
880 let has_any_folded = self.results_editor.read(cx).has_any_buffer_folded(cx);
881 self.results_editor.update(cx, |editor, cx| {
882 if has_any_folded {
883 editor.unfold_all(&UnfoldAll, window, cx);
884 } else {
885 editor.fold_all(&FoldAll, window, cx);
886 }
887 });
888 cx.notify();
889 }
890
891 pub fn new(
892 workspace: WeakEntity<Workspace>,
893 entity: Entity<ProjectSearch>,
894 window: &mut Window,
895 cx: &mut Context<Self>,
896 settings: Option<ProjectSearchSettings>,
897 ) -> Self {
898 let project;
899 let excerpts;
900 let mut replacement_text = None;
901 let mut query_text = String::new();
902 let mut subscriptions = Vec::new();
903
904 // Read in settings if available
905 let (mut options, filters_enabled) = if let Some(settings) = settings {
906 (settings.search_options, settings.filters_enabled)
907 } else {
908 let search_options =
909 SearchOptions::from_settings(&EditorSettings::get_global(cx).search);
910 (search_options, false)
911 };
912
913 {
914 let entity = entity.read(cx);
915 project = entity.project.clone();
916 excerpts = entity.excerpts.clone();
917 if let Some(active_query) = entity.active_query.as_ref() {
918 query_text = active_query.as_str().to_string();
919 replacement_text = active_query.replacement().map(ToOwned::to_owned);
920 options = SearchOptions::from_query(active_query);
921 }
922 }
923 subscriptions.push(cx.observe_in(&entity, window, |this, _, window, cx| {
924 this.entity_changed(window, cx)
925 }));
926
927 let query_editor = cx.new(|cx| {
928 let mut editor = Editor::auto_height(1, 4, window, cx);
929 editor.set_placeholder_text("Search all files…", window, cx);
930 editor.set_use_autoclose(false);
931 editor.set_text(query_text, window, cx);
932 editor
933 });
934 // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
935 subscriptions.push(
936 cx.subscribe(&query_editor, |this, _, event: &EditorEvent, cx| {
937 if let EditorEvent::Edited { .. } = event
938 && EditorSettings::get_global(cx).use_smartcase_search
939 {
940 let query = this.search_query_text(cx);
941 if !query.is_empty()
942 && this.search_options.contains(SearchOptions::CASE_SENSITIVE)
943 != contains_uppercase(&query)
944 {
945 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
946 }
947 }
948 cx.emit(ViewEvent::EditorEvent(event.clone()))
949 }),
950 );
951 let replacement_editor = cx.new(|cx| {
952 let mut editor = Editor::auto_height(1, 4, window, cx);
953 editor.set_placeholder_text("Replace in project…", window, cx);
954 if let Some(text) = replacement_text {
955 editor.set_text(text, window, cx);
956 }
957 editor
958 });
959 let results_editor = cx.new(|cx| {
960 let mut editor = Editor::for_multibuffer(excerpts, Some(project.clone()), window, cx);
961 editor.set_searchable(false);
962 editor.set_in_project_search(true);
963 editor
964 });
965 subscriptions.push(cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab)));
966
967 subscriptions.push(
968 cx.subscribe(&results_editor, |this, _, event: &EditorEvent, cx| {
969 if matches!(event, editor::EditorEvent::SelectionsChanged { .. }) {
970 this.update_match_index(cx);
971 }
972 // Reraise editor events for workspace item activation purposes
973 cx.emit(ViewEvent::EditorEvent(event.clone()));
974 }),
975 );
976 subscriptions.push(cx.subscribe(
977 &results_editor,
978 |_this, _editor, _event: &SearchEvent, cx| cx.notify(),
979 ));
980
981 let included_files_editor = cx.new(|cx| {
982 let mut editor = Editor::single_line(window, cx);
983 editor.set_placeholder_text("Include: crates/**/*.toml", window, cx);
984
985 editor
986 });
987 // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes
988 subscriptions.push(
989 cx.subscribe(&included_files_editor, |_, _, event: &EditorEvent, cx| {
990 cx.emit(ViewEvent::EditorEvent(event.clone()))
991 }),
992 );
993
994 let excluded_files_editor = cx.new(|cx| {
995 let mut editor = Editor::single_line(window, cx);
996 editor.set_placeholder_text("Exclude: vendor/*, *.lock", window, cx);
997
998 editor
999 });
1000 // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes
1001 subscriptions.push(
1002 cx.subscribe(&excluded_files_editor, |_, _, event: &EditorEvent, cx| {
1003 cx.emit(ViewEvent::EditorEvent(event.clone()))
1004 }),
1005 );
1006
1007 let focus_handle = cx.focus_handle();
1008 subscriptions.push(cx.on_focus(&focus_handle, window, |_, window, cx| {
1009 cx.on_next_frame(window, |this, window, cx| {
1010 if this.focus_handle.is_focused(window) {
1011 if this.has_matches() {
1012 this.results_editor.focus_handle(cx).focus(window, cx);
1013 } else {
1014 this.query_editor.focus_handle(cx).focus(window, cx);
1015 }
1016 }
1017 });
1018 }));
1019
1020 let languages = project.read(cx).languages().clone();
1021 cx.spawn(async move |project_search_view, cx| {
1022 let regex_language = languages
1023 .language_for_name("regex")
1024 .await
1025 .context("loading regex language")?;
1026 project_search_view
1027 .update(cx, |project_search_view, cx| {
1028 project_search_view.regex_language = Some(regex_language);
1029 project_search_view.adjust_query_regex_language(cx);
1030 })
1031 .ok();
1032 anyhow::Ok(())
1033 })
1034 .detach_and_log_err(cx);
1035
1036 // Check if Worktrees have all been previously indexed
1037 let mut this = ProjectSearchView {
1038 workspace,
1039 focus_handle,
1040 replacement_editor,
1041 search_id: entity.read(cx).search_id,
1042 entity,
1043 query_editor,
1044 results_editor,
1045 search_options: options,
1046 panels_with_errors: HashMap::default(),
1047 active_match_index: None,
1048 included_files_editor,
1049 excluded_files_editor,
1050 filters_enabled,
1051 replace_enabled: false,
1052 pending_replace_all: false,
1053 included_opened_only: false,
1054 regex_language: None,
1055 _subscriptions: subscriptions,
1056 };
1057
1058 this.entity_changed(window, cx);
1059 this
1060 }
1061
1062 pub fn new_search_in_directory(
1063 workspace: &mut Workspace,
1064 dir_path: &RelPath,
1065 window: &mut Window,
1066 cx: &mut Context<Workspace>,
1067 ) {
1068 let filter_str = dir_path.display(workspace.path_style(cx));
1069
1070 let weak_workspace = cx.entity().downgrade();
1071
1072 let entity = cx.new(|cx| ProjectSearch::new(workspace.project().clone(), cx));
1073 let search = cx.new(|cx| ProjectSearchView::new(weak_workspace, entity, window, cx, None));
1074 workspace.add_item_to_active_pane(Box::new(search.clone()), None, true, window, cx);
1075 search.update(cx, |search, cx| {
1076 search
1077 .included_files_editor
1078 .update(cx, |editor, cx| editor.set_text(filter_str, window, cx));
1079 search.filters_enabled = true;
1080 search.focus_query_editor(window, cx)
1081 });
1082 }
1083
1084 /// Re-activate the most recently activated search in this pane or the most recent if it has been closed.
1085 /// If no search exists in the workspace, create a new one.
1086 pub fn deploy_search(
1087 workspace: &mut Workspace,
1088 action: &workspace::DeploySearch,
1089 window: &mut Window,
1090 cx: &mut Context<Workspace>,
1091 ) {
1092 let existing = workspace
1093 .active_pane()
1094 .read(cx)
1095 .items()
1096 .find_map(|item| item.downcast::<ProjectSearchView>());
1097
1098 Self::existing_or_new_search(workspace, existing, action, window, cx);
1099 }
1100
1101 fn search_in_new(
1102 workspace: &mut Workspace,
1103 _: &SearchInNew,
1104 window: &mut Window,
1105 cx: &mut Context<Workspace>,
1106 ) {
1107 if let Some(search_view) = workspace
1108 .active_item(cx)
1109 .and_then(|item| item.downcast::<ProjectSearchView>())
1110 {
1111 let new_query = search_view.update(cx, |search_view, cx| {
1112 let open_buffers = if search_view.included_opened_only {
1113 Some(search_view.open_buffers(cx, workspace))
1114 } else {
1115 None
1116 };
1117 let new_query = search_view.build_search_query(cx, open_buffers);
1118 if new_query.is_some()
1119 && let Some(old_query) = search_view.entity.read(cx).active_query.clone()
1120 {
1121 search_view.query_editor.update(cx, |editor, cx| {
1122 editor.set_text(old_query.as_str(), window, cx);
1123 });
1124 search_view.search_options = SearchOptions::from_query(&old_query);
1125 search_view.adjust_query_regex_language(cx);
1126 }
1127 new_query
1128 });
1129 if let Some(new_query) = new_query {
1130 let entity = cx.new(|cx| {
1131 let mut entity = ProjectSearch::new(workspace.project().clone(), cx);
1132 entity.search(new_query, cx);
1133 entity
1134 });
1135 let weak_workspace = cx.entity().downgrade();
1136 workspace.add_item_to_active_pane(
1137 Box::new(cx.new(|cx| {
1138 ProjectSearchView::new(weak_workspace, entity, window, cx, None)
1139 })),
1140 None,
1141 true,
1142 window,
1143 cx,
1144 );
1145 }
1146 }
1147 }
1148
1149 // Add another search tab to the workspace.
1150 fn new_search(
1151 workspace: &mut Workspace,
1152 _: &workspace::NewSearch,
1153 window: &mut Window,
1154 cx: &mut Context<Workspace>,
1155 ) {
1156 Self::existing_or_new_search(workspace, None, &DeploySearch::find(), window, cx)
1157 }
1158
1159 fn existing_or_new_search(
1160 workspace: &mut Workspace,
1161 existing: Option<Entity<ProjectSearchView>>,
1162 action: &workspace::DeploySearch,
1163 window: &mut Window,
1164 cx: &mut Context<Workspace>,
1165 ) {
1166 let query = workspace.active_item(cx).and_then(|item| {
1167 if let Some(buffer_search_query) = buffer_search_query(workspace, item.as_ref(), cx) {
1168 return Some(buffer_search_query);
1169 }
1170
1171 let editor = item.act_as::<Editor>(cx)?;
1172 let query = editor.query_suggestion(window, cx);
1173 if query.is_empty() { None } else { Some(query) }
1174 });
1175
1176 let search = if let Some(existing) = existing {
1177 workspace.activate_item(&existing, true, true, window, cx);
1178 existing
1179 } else {
1180 let settings = cx
1181 .global::<ActiveSettings>()
1182 .0
1183 .get(&workspace.project().downgrade());
1184
1185 let settings = settings.cloned();
1186
1187 let weak_workspace = cx.entity().downgrade();
1188
1189 let project_search = cx.new(|cx| ProjectSearch::new(workspace.project().clone(), cx));
1190 let project_search_view = cx.new(|cx| {
1191 ProjectSearchView::new(weak_workspace, project_search, window, cx, settings)
1192 });
1193
1194 workspace.add_item_to_active_pane(
1195 Box::new(project_search_view.clone()),
1196 None,
1197 true,
1198 window,
1199 cx,
1200 );
1201 project_search_view
1202 };
1203
1204 search.update(cx, |search, cx| {
1205 search.replace_enabled |= action.replace_enabled;
1206 if let Some(query) = query {
1207 search.set_query(&query, window, cx);
1208 }
1209 if let Some(included_files) = action.included_files.as_deref() {
1210 search
1211 .included_files_editor
1212 .update(cx, |editor, cx| editor.set_text(included_files, window, cx));
1213 search.filters_enabled = true;
1214 }
1215 if let Some(excluded_files) = action.excluded_files.as_deref() {
1216 search
1217 .excluded_files_editor
1218 .update(cx, |editor, cx| editor.set_text(excluded_files, window, cx));
1219 search.filters_enabled = true;
1220 }
1221 search.focus_query_editor(window, cx)
1222 });
1223 }
1224
1225 fn prompt_to_save_if_dirty_then_search(
1226 &mut self,
1227 window: &mut Window,
1228 cx: &mut Context<Self>,
1229 ) -> Task<anyhow::Result<()>> {
1230 let project = self.entity.read(cx).project.clone();
1231
1232 let can_autosave = self.results_editor.can_autosave(cx);
1233 let autosave_setting = self.results_editor.workspace_settings(cx).autosave;
1234
1235 let will_autosave = can_autosave && autosave_setting.should_save_on_close();
1236
1237 let is_dirty = self.is_dirty(cx);
1238
1239 cx.spawn_in(window, async move |this, cx| {
1240 let skip_save_on_close = this
1241 .read_with(cx, |this, cx| {
1242 this.workspace.read_with(cx, |workspace, cx| {
1243 workspace::Pane::skip_save_on_close(&this.results_editor, workspace, cx)
1244 })
1245 })?
1246 .unwrap_or(false);
1247
1248 let should_prompt_to_save = !skip_save_on_close && !will_autosave && is_dirty;
1249
1250 let should_search = if should_prompt_to_save {
1251 let options = &["Save", "Don't Save", "Cancel"];
1252 let result_channel = this.update_in(cx, |_, window, cx| {
1253 window.prompt(
1254 gpui::PromptLevel::Warning,
1255 "Project search buffer contains unsaved edits. Do you want to save it?",
1256 None,
1257 options,
1258 cx,
1259 )
1260 })?;
1261 let result = result_channel.await?;
1262 let should_save = result == 0;
1263 if should_save {
1264 this.update_in(cx, |this, window, cx| {
1265 this.save(
1266 SaveOptions {
1267 format: true,
1268 autosave: false,
1269 },
1270 project,
1271 window,
1272 cx,
1273 )
1274 })?
1275 .await
1276 .log_err();
1277 }
1278
1279 result != 2
1280 } else {
1281 true
1282 };
1283 if should_search {
1284 this.update(cx, |this, cx| {
1285 this.search(cx);
1286 })?;
1287 }
1288 anyhow::Ok(())
1289 })
1290 }
1291
1292 fn search(&mut self, cx: &mut Context<Self>) {
1293 let open_buffers = if self.included_opened_only {
1294 self.workspace
1295 .update(cx, |workspace, cx| self.open_buffers(cx, workspace))
1296 .ok()
1297 } else {
1298 None
1299 };
1300 if let Some(query) = self.build_search_query(cx, open_buffers) {
1301 self.entity.update(cx, |model, cx| model.search(query, cx));
1302 }
1303 }
1304
1305 pub fn search_query_text(&self, cx: &App) -> String {
1306 self.query_editor.read(cx).text(cx)
1307 }
1308
1309 fn build_search_query(
1310 &mut self,
1311 cx: &mut Context<Self>,
1312 open_buffers: Option<Vec<Entity<Buffer>>>,
1313 ) -> Option<SearchQuery> {
1314 // Do not bail early in this function, as we want to fill out `self.panels_with_errors`.
1315
1316 let text = self.search_query_text(cx);
1317 let included_files = self
1318 .filters_enabled
1319 .then(|| {
1320 match self.parse_path_matches(self.included_files_editor.read(cx).text(cx), cx) {
1321 Ok(included_files) => {
1322 let should_unmark_error =
1323 self.panels_with_errors.remove(&InputPanel::Include);
1324 if should_unmark_error.is_some() {
1325 cx.notify();
1326 }
1327 included_files
1328 }
1329 Err(e) => {
1330 let should_mark_error = self
1331 .panels_with_errors
1332 .insert(InputPanel::Include, e.to_string());
1333 if should_mark_error.is_none() {
1334 cx.notify();
1335 }
1336 PathMatcher::default()
1337 }
1338 }
1339 })
1340 .unwrap_or(PathMatcher::default());
1341 let excluded_files = self
1342 .filters_enabled
1343 .then(|| {
1344 match self.parse_path_matches(self.excluded_files_editor.read(cx).text(cx), cx) {
1345 Ok(excluded_files) => {
1346 let should_unmark_error =
1347 self.panels_with_errors.remove(&InputPanel::Exclude);
1348 if should_unmark_error.is_some() {
1349 cx.notify();
1350 }
1351
1352 excluded_files
1353 }
1354 Err(e) => {
1355 let should_mark_error = self
1356 .panels_with_errors
1357 .insert(InputPanel::Exclude, e.to_string());
1358 if should_mark_error.is_none() {
1359 cx.notify();
1360 }
1361 PathMatcher::default()
1362 }
1363 }
1364 })
1365 .unwrap_or(PathMatcher::default());
1366
1367 // If the project contains multiple visible worktrees, we match the
1368 // include/exclude patterns against full paths to allow them to be
1369 // disambiguated. For single worktree projects we use worktree relative
1370 // paths for convenience.
1371 let match_full_paths = self
1372 .entity
1373 .read(cx)
1374 .project
1375 .read(cx)
1376 .visible_worktrees(cx)
1377 .count()
1378 > 1;
1379
1380 let query = if self.search_options.contains(SearchOptions::REGEX) {
1381 match SearchQuery::regex(
1382 text,
1383 self.search_options.contains(SearchOptions::WHOLE_WORD),
1384 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1385 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1386 self.search_options
1387 .contains(SearchOptions::ONE_MATCH_PER_LINE),
1388 included_files,
1389 excluded_files,
1390 match_full_paths,
1391 open_buffers,
1392 ) {
1393 Ok(query) => {
1394 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
1395 if should_unmark_error.is_some() {
1396 cx.notify();
1397 }
1398
1399 Some(query)
1400 }
1401 Err(e) => {
1402 let should_mark_error = self
1403 .panels_with_errors
1404 .insert(InputPanel::Query, e.to_string());
1405 if should_mark_error.is_none() {
1406 cx.notify();
1407 }
1408
1409 None
1410 }
1411 }
1412 } else {
1413 match SearchQuery::text(
1414 text,
1415 self.search_options.contains(SearchOptions::WHOLE_WORD),
1416 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1417 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1418 included_files,
1419 excluded_files,
1420 match_full_paths,
1421 open_buffers,
1422 ) {
1423 Ok(query) => {
1424 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
1425 if should_unmark_error.is_some() {
1426 cx.notify();
1427 }
1428
1429 Some(query)
1430 }
1431 Err(e) => {
1432 let should_mark_error = self
1433 .panels_with_errors
1434 .insert(InputPanel::Query, e.to_string());
1435 if should_mark_error.is_none() {
1436 cx.notify();
1437 }
1438
1439 None
1440 }
1441 }
1442 };
1443 if !self.panels_with_errors.is_empty() {
1444 return None;
1445 }
1446 if query.as_ref().is_some_and(|query| query.is_empty()) {
1447 return None;
1448 }
1449 query
1450 }
1451
1452 fn open_buffers(&self, cx: &App, workspace: &Workspace) -> Vec<Entity<Buffer>> {
1453 let mut buffers = Vec::new();
1454 for editor in workspace.items_of_type::<Editor>(cx) {
1455 if let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() {
1456 buffers.push(buffer);
1457 }
1458 }
1459 buffers
1460 }
1461
1462 fn parse_path_matches(&self, text: String, cx: &App) -> anyhow::Result<PathMatcher> {
1463 let path_style = self.entity.read(cx).project.read(cx).path_style(cx);
1464 let queries = split_glob_patterns(&text)
1465 .into_iter()
1466 .map(str::trim)
1467 .filter(|maybe_glob_str| !maybe_glob_str.is_empty())
1468 .map(str::to_owned)
1469 .collect::<Vec<_>>();
1470 Ok(PathMatcher::new(&queries, path_style)?)
1471 }
1472
1473 fn select_match(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
1474 if let Some(index) = self.active_match_index {
1475 let match_ranges = self.entity.read(cx).match_ranges.clone();
1476
1477 if !EditorSettings::get_global(cx).search_wrap
1478 && ((direction == Direction::Next && index + 1 >= match_ranges.len())
1479 || (direction == Direction::Prev && index == 0))
1480 {
1481 crate::show_no_more_matches(window, cx);
1482 return;
1483 }
1484
1485 let new_index = self.results_editor.update(cx, |editor, cx| {
1486 editor.match_index_for_direction(
1487 &match_ranges,
1488 index,
1489 direction,
1490 1,
1491 SearchToken::default(),
1492 window,
1493 cx,
1494 )
1495 });
1496
1497 let range_to_select = match_ranges[new_index].clone();
1498 self.results_editor.update(cx, |editor, cx| {
1499 let range_to_select = editor.range_for_match(&range_to_select);
1500 let autoscroll = if EditorSettings::get_global(cx).search.center_on_match {
1501 Autoscroll::center()
1502 } else {
1503 Autoscroll::fit()
1504 };
1505 editor.unfold_ranges(std::slice::from_ref(&range_to_select), false, true, cx);
1506 editor.change_selections(SelectionEffects::scroll(autoscroll), window, cx, |s| {
1507 s.select_ranges([range_to_select])
1508 });
1509 });
1510 self.highlight_matches(&match_ranges, Some(new_index), cx);
1511 }
1512 }
1513
1514 fn focus_query_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1515 self.query_editor.update(cx, |query_editor, cx| {
1516 query_editor.select_all(&SelectAll, window, cx);
1517 });
1518 let editor_handle = self.query_editor.focus_handle(cx);
1519 window.focus(&editor_handle, cx);
1520 }
1521
1522 fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
1523 self.set_search_editor(SearchInputKind::Query, query, window, cx);
1524 if EditorSettings::get_global(cx).use_smartcase_search
1525 && !query.is_empty()
1526 && self.search_options.contains(SearchOptions::CASE_SENSITIVE)
1527 != contains_uppercase(query)
1528 {
1529 self.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx)
1530 }
1531 }
1532
1533 fn set_search_editor(
1534 &mut self,
1535 kind: SearchInputKind,
1536 text: &str,
1537 window: &mut Window,
1538 cx: &mut Context<Self>,
1539 ) {
1540 let editor = match kind {
1541 SearchInputKind::Query => &self.query_editor,
1542 SearchInputKind::Include => &self.included_files_editor,
1543
1544 SearchInputKind::Exclude => &self.excluded_files_editor,
1545 };
1546 editor.update(cx, |editor, cx| {
1547 editor.set_text(text, window, cx);
1548 editor.request_autoscroll(Autoscroll::fit(), cx);
1549 });
1550 }
1551
1552 fn focus_results_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1553 self.query_editor.update(cx, |query_editor, cx| {
1554 let cursor = query_editor.selections.newest_anchor().head();
1555 query_editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
1556 s.select_ranges([cursor..cursor])
1557 });
1558 });
1559 let results_handle = self.results_editor.focus_handle(cx);
1560 window.focus(&results_handle, cx);
1561 }
1562
1563 fn entity_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1564 let match_ranges = self.entity.read(cx).match_ranges.clone();
1565
1566 if match_ranges.is_empty() {
1567 self.active_match_index = None;
1568 self.results_editor.update(cx, |editor, cx| {
1569 editor.clear_background_highlights(HighlightKey::ProjectSearchView, cx);
1570 });
1571 } else {
1572 self.active_match_index = Some(0);
1573 self.update_match_index(cx);
1574 let prev_search_id = mem::replace(&mut self.search_id, self.entity.read(cx).search_id);
1575 let is_new_search = self.search_id != prev_search_id;
1576 self.results_editor.update(cx, |editor, cx| {
1577 if is_new_search {
1578 let range_to_select = match_ranges
1579 .first()
1580 .map(|range| editor.range_for_match(range));
1581 editor.change_selections(Default::default(), window, cx, |s| {
1582 s.select_ranges(range_to_select)
1583 });
1584 editor.scroll(Point::default(), Some(Axis::Vertical), window, cx);
1585 }
1586 });
1587 if is_new_search && self.query_editor.focus_handle(cx).is_focused(window) {
1588 self.focus_results_editor(window, cx);
1589 }
1590 }
1591
1592 cx.emit(ViewEvent::UpdateTab);
1593 cx.notify();
1594
1595 if self.pending_replace_all && self.entity.read(cx).pending_search.is_none() {
1596 self.replace_all(&ReplaceAll, window, cx);
1597 }
1598 }
1599
1600 fn update_match_index(&mut self, cx: &mut Context<Self>) {
1601 let results_editor = self.results_editor.read(cx);
1602 let newest_anchor = results_editor.selections.newest_anchor().head();
1603 let buffer_snapshot = results_editor.buffer().read(cx).snapshot(cx);
1604 let new_index = self.entity.update(cx, |this, cx| {
1605 let new_index = active_match_index(
1606 Direction::Next,
1607 &this.match_ranges,
1608 &newest_anchor,
1609 &buffer_snapshot,
1610 );
1611
1612 self.highlight_matches(&this.match_ranges, new_index, cx);
1613 new_index
1614 });
1615
1616 if self.active_match_index != new_index {
1617 self.active_match_index = new_index;
1618 cx.notify();
1619 }
1620 }
1621
1622 #[ztracing::instrument(skip_all)]
1623 fn highlight_matches(
1624 &self,
1625 match_ranges: &[Range<Anchor>],
1626 active_index: Option<usize>,
1627 cx: &mut App,
1628 ) {
1629 self.results_editor.update(cx, |editor, cx| {
1630 editor.highlight_background(
1631 HighlightKey::ProjectSearchView,
1632 match_ranges,
1633 move |index, theme| {
1634 if active_index == Some(*index) {
1635 theme.colors().search_active_match_background
1636 } else {
1637 theme.colors().search_match_background
1638 }
1639 },
1640 cx,
1641 );
1642 });
1643 }
1644
1645 pub fn has_matches(&self) -> bool {
1646 self.active_match_index.is_some()
1647 }
1648
1649 fn landing_text_minor(&self, cx: &App) -> impl IntoElement {
1650 let focus_handle = self.focus_handle.clone();
1651 v_flex()
1652 .gap_1()
1653 .child(
1654 Label::new("Hit enter to search. For more options:")
1655 .color(Color::Muted)
1656 .mb_2(),
1657 )
1658 .child(
1659 Button::new("filter-paths", "Include/exclude specific paths")
1660 .start_icon(Icon::new(IconName::Filter).size(IconSize::Small))
1661 .key_binding(KeyBinding::for_action_in(&ToggleFilters, &focus_handle, cx))
1662 .on_click(|_event, window, cx| {
1663 window.dispatch_action(ToggleFilters.boxed_clone(), cx)
1664 }),
1665 )
1666 .child(
1667 Button::new("find-replace", "Find and replace")
1668 .start_icon(Icon::new(IconName::Replace).size(IconSize::Small))
1669 .key_binding(KeyBinding::for_action_in(&ToggleReplace, &focus_handle, cx))
1670 .on_click(|_event, window, cx| {
1671 window.dispatch_action(ToggleReplace.boxed_clone(), cx)
1672 }),
1673 )
1674 .child(
1675 Button::new("regex", "Match with regex")
1676 .start_icon(Icon::new(IconName::Regex).size(IconSize::Small))
1677 .key_binding(KeyBinding::for_action_in(&ToggleRegex, &focus_handle, cx))
1678 .on_click(|_event, window, cx| {
1679 window.dispatch_action(ToggleRegex.boxed_clone(), cx)
1680 }),
1681 )
1682 .child(
1683 Button::new("match-case", "Match case")
1684 .start_icon(Icon::new(IconName::CaseSensitive).size(IconSize::Small))
1685 .key_binding(KeyBinding::for_action_in(
1686 &ToggleCaseSensitive,
1687 &focus_handle,
1688 cx,
1689 ))
1690 .on_click(|_event, window, cx| {
1691 window.dispatch_action(ToggleCaseSensitive.boxed_clone(), cx)
1692 }),
1693 )
1694 .child(
1695 Button::new("match-whole-words", "Match whole words")
1696 .start_icon(Icon::new(IconName::WholeWord).size(IconSize::Small))
1697 .key_binding(KeyBinding::for_action_in(
1698 &ToggleWholeWord,
1699 &focus_handle,
1700 cx,
1701 ))
1702 .on_click(|_event, window, cx| {
1703 window.dispatch_action(ToggleWholeWord.boxed_clone(), cx)
1704 }),
1705 )
1706 }
1707
1708 fn border_color_for(&self, panel: InputPanel, cx: &App) -> Hsla {
1709 if self.panels_with_errors.contains_key(&panel) {
1710 Color::Error.color(cx)
1711 } else {
1712 cx.theme().colors().border
1713 }
1714 }
1715
1716 fn move_focus_to_results(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1717 if !self.results_editor.focus_handle(cx).is_focused(window)
1718 && !self.entity.read(cx).match_ranges.is_empty()
1719 {
1720 cx.stop_propagation();
1721 self.focus_results_editor(window, cx)
1722 }
1723 }
1724
1725 #[cfg(any(test, feature = "test-support"))]
1726 pub fn results_editor(&self) -> &Entity<Editor> {
1727 &self.results_editor
1728 }
1729
1730 fn adjust_query_regex_language(&self, cx: &mut App) {
1731 let enable = self.search_options.contains(SearchOptions::REGEX);
1732 let query_buffer = self
1733 .query_editor
1734 .read(cx)
1735 .buffer()
1736 .read(cx)
1737 .as_singleton()
1738 .expect("query editor should be backed by a singleton buffer");
1739 if enable {
1740 if let Some(regex_language) = self.regex_language.clone() {
1741 query_buffer.update(cx, |query_buffer, cx| {
1742 query_buffer.set_language(Some(regex_language), cx);
1743 })
1744 }
1745 } else {
1746 query_buffer.update(cx, |query_buffer, cx| {
1747 query_buffer.set_language(None, cx);
1748 })
1749 }
1750 }
1751}
1752
1753fn buffer_search_query(
1754 workspace: &mut Workspace,
1755 item: &dyn ItemHandle,
1756 cx: &mut Context<Workspace>,
1757) -> Option<String> {
1758 let buffer_search_bar = workspace
1759 .pane_for(item)
1760 .and_then(|pane| {
1761 pane.read(cx)
1762 .toolbar()
1763 .read(cx)
1764 .item_of_type::<BufferSearchBar>()
1765 })?
1766 .read(cx);
1767 if buffer_search_bar.query_editor_focused() {
1768 let buffer_search_query = buffer_search_bar.query(cx);
1769 if !buffer_search_query.is_empty() {
1770 return Some(buffer_search_query);
1771 }
1772 }
1773 None
1774}
1775
1776impl Default for ProjectSearchBar {
1777 fn default() -> Self {
1778 Self::new()
1779 }
1780}
1781
1782impl ProjectSearchBar {
1783 pub fn new() -> Self {
1784 Self {
1785 active_project_search: None,
1786 subscription: None,
1787 }
1788 }
1789
1790 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1791 if let Some(search_view) = self.active_project_search.as_ref() {
1792 search_view.update(cx, |search_view, cx| {
1793 if !search_view
1794 .replacement_editor
1795 .focus_handle(cx)
1796 .is_focused(window)
1797 {
1798 cx.stop_propagation();
1799 search_view
1800 .prompt_to_save_if_dirty_then_search(window, cx)
1801 .detach_and_log_err(cx);
1802 }
1803 });
1804 }
1805 }
1806
1807 fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
1808 self.cycle_field(Direction::Next, window, cx);
1809 }
1810
1811 fn backtab(&mut self, _: &Backtab, window: &mut Window, cx: &mut Context<Self>) {
1812 self.cycle_field(Direction::Prev, window, cx);
1813 }
1814
1815 fn focus_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1816 if let Some(search_view) = self.active_project_search.as_ref() {
1817 search_view.update(cx, |search_view, cx| {
1818 search_view.query_editor.focus_handle(cx).focus(window, cx);
1819 });
1820 }
1821 }
1822
1823 fn cycle_field(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
1824 let active_project_search = match &self.active_project_search {
1825 Some(active_project_search) => active_project_search,
1826 None => return,
1827 };
1828
1829 active_project_search.update(cx, |project_view, cx| {
1830 let mut views = vec![project_view.query_editor.focus_handle(cx)];
1831 if project_view.replace_enabled {
1832 views.push(project_view.replacement_editor.focus_handle(cx));
1833 }
1834 if project_view.filters_enabled {
1835 views.extend([
1836 project_view.included_files_editor.focus_handle(cx),
1837 project_view.excluded_files_editor.focus_handle(cx),
1838 ]);
1839 }
1840 let current_index = match views.iter().position(|focus| focus.is_focused(window)) {
1841 Some(index) => index,
1842 None => return,
1843 };
1844
1845 let new_index = match direction {
1846 Direction::Next => (current_index + 1) % views.len(),
1847 Direction::Prev if current_index == 0 => views.len() - 1,
1848 Direction::Prev => (current_index - 1) % views.len(),
1849 };
1850 let next_focus_handle = &views[new_index];
1851 window.focus(next_focus_handle, cx);
1852 cx.stop_propagation();
1853 });
1854 }
1855
1856 pub(crate) fn toggle_search_option(
1857 &mut self,
1858 option: SearchOptions,
1859 window: &mut Window,
1860 cx: &mut Context<Self>,
1861 ) -> bool {
1862 if self.active_project_search.is_none() {
1863 return false;
1864 }
1865
1866 cx.spawn_in(window, async move |this, cx| {
1867 let task = this.update_in(cx, |this, window, cx| {
1868 let search_view = this.active_project_search.as_ref()?;
1869 search_view.update(cx, |search_view, cx| {
1870 search_view.toggle_search_option(option, cx);
1871 search_view
1872 .entity
1873 .read(cx)
1874 .active_query
1875 .is_some()
1876 .then(|| search_view.prompt_to_save_if_dirty_then_search(window, cx))
1877 })
1878 })?;
1879 if let Some(task) = task {
1880 task.await?;
1881 }
1882 this.update(cx, |_, cx| {
1883 cx.notify();
1884 })?;
1885 anyhow::Ok(())
1886 })
1887 .detach();
1888 true
1889 }
1890
1891 fn toggle_replace(&mut self, _: &ToggleReplace, window: &mut Window, cx: &mut Context<Self>) {
1892 if let Some(search) = &self.active_project_search {
1893 search.update(cx, |this, cx| {
1894 this.replace_enabled = !this.replace_enabled;
1895 let editor_to_focus = if this.replace_enabled {
1896 this.replacement_editor.focus_handle(cx)
1897 } else {
1898 this.query_editor.focus_handle(cx)
1899 };
1900 window.focus(&editor_to_focus, cx);
1901 cx.notify();
1902 });
1903 }
1904 }
1905
1906 fn toggle_filters(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1907 if let Some(search_view) = self.active_project_search.as_ref() {
1908 search_view.update(cx, |search_view, cx| {
1909 search_view.toggle_filters(cx);
1910 search_view
1911 .included_files_editor
1912 .update(cx, |_, cx| cx.notify());
1913 search_view
1914 .excluded_files_editor
1915 .update(cx, |_, cx| cx.notify());
1916 window.refresh();
1917 cx.notify();
1918 });
1919 cx.notify();
1920 true
1921 } else {
1922 false
1923 }
1924 }
1925
1926 fn toggle_opened_only(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1927 if self.active_project_search.is_none() {
1928 return false;
1929 }
1930
1931 cx.spawn_in(window, async move |this, cx| {
1932 let task = this.update_in(cx, |this, window, cx| {
1933 let search_view = this.active_project_search.as_ref()?;
1934 search_view.update(cx, |search_view, cx| {
1935 search_view.toggle_opened_only(window, cx);
1936 search_view
1937 .entity
1938 .read(cx)
1939 .active_query
1940 .is_some()
1941 .then(|| search_view.prompt_to_save_if_dirty_then_search(window, cx))
1942 })
1943 })?;
1944 if let Some(task) = task {
1945 task.await?;
1946 }
1947 this.update(cx, |_, cx| {
1948 cx.notify();
1949 })?;
1950 anyhow::Ok(())
1951 })
1952 .detach();
1953 true
1954 }
1955
1956 fn is_opened_only_enabled(&self, cx: &App) -> bool {
1957 if let Some(search_view) = self.active_project_search.as_ref() {
1958 search_view.read(cx).included_opened_only
1959 } else {
1960 false
1961 }
1962 }
1963
1964 fn move_focus_to_results(&self, window: &mut Window, cx: &mut Context<Self>) {
1965 if let Some(search_view) = self.active_project_search.as_ref() {
1966 search_view.update(cx, |search_view, cx| {
1967 search_view.move_focus_to_results(window, cx);
1968 });
1969 cx.notify();
1970 }
1971 }
1972
1973 fn next_history_query(
1974 &mut self,
1975 _: &NextHistoryQuery,
1976 window: &mut Window,
1977 cx: &mut Context<Self>,
1978 ) {
1979 if let Some(search_view) = self.active_project_search.as_ref() {
1980 search_view.update(cx, |search_view, cx| {
1981 for (editor, kind) in [
1982 (search_view.query_editor.clone(), SearchInputKind::Query),
1983 (
1984 search_view.included_files_editor.clone(),
1985 SearchInputKind::Include,
1986 ),
1987 (
1988 search_view.excluded_files_editor.clone(),
1989 SearchInputKind::Exclude,
1990 ),
1991 ] {
1992 if editor.focus_handle(cx).is_focused(window) {
1993 if !should_navigate_history(&editor, HistoryNavigationDirection::Next, cx) {
1994 cx.propagate();
1995 return;
1996 }
1997
1998 let new_query = search_view.entity.update(cx, |model, cx| {
1999 let project = model.project.clone();
2000
2001 if let Some(new_query) = project.update(cx, |project, _| {
2002 project
2003 .search_history_mut(kind)
2004 .next(model.cursor_mut(kind))
2005 .map(str::to_string)
2006 }) {
2007 Some(new_query)
2008 } else {
2009 model.cursor_mut(kind).take_draft()
2010 }
2011 });
2012 if let Some(new_query) = new_query {
2013 search_view.set_search_editor(kind, &new_query, window, cx);
2014 }
2015 }
2016 }
2017 });
2018 }
2019 }
2020
2021 fn previous_history_query(
2022 &mut self,
2023 _: &PreviousHistoryQuery,
2024 window: &mut Window,
2025 cx: &mut Context<Self>,
2026 ) {
2027 if let Some(search_view) = self.active_project_search.as_ref() {
2028 search_view.update(cx, |search_view, cx| {
2029 for (editor, kind) in [
2030 (search_view.query_editor.clone(), SearchInputKind::Query),
2031 (
2032 search_view.included_files_editor.clone(),
2033 SearchInputKind::Include,
2034 ),
2035 (
2036 search_view.excluded_files_editor.clone(),
2037 SearchInputKind::Exclude,
2038 ),
2039 ] {
2040 if editor.focus_handle(cx).is_focused(window) {
2041 if !should_navigate_history(
2042 &editor,
2043 HistoryNavigationDirection::Previous,
2044 cx,
2045 ) {
2046 cx.propagate();
2047 return;
2048 }
2049
2050 if editor.read(cx).text(cx).is_empty()
2051 && let Some(new_query) = search_view
2052 .entity
2053 .read(cx)
2054 .project
2055 .read(cx)
2056 .search_history(kind)
2057 .current(search_view.entity.read(cx).cursor(kind))
2058 .map(str::to_string)
2059 {
2060 search_view.set_search_editor(kind, &new_query, window, cx);
2061 return;
2062 }
2063
2064 let current_query = editor.read(cx).text(cx);
2065 if let Some(new_query) = search_view.entity.update(cx, |model, cx| {
2066 let project = model.project.clone();
2067 project.update(cx, |project, _| {
2068 project
2069 .search_history_mut(kind)
2070 .previous(model.cursor_mut(kind), ¤t_query)
2071 .map(str::to_string)
2072 })
2073 }) {
2074 search_view.set_search_editor(kind, &new_query, window, cx);
2075 }
2076 }
2077 }
2078 });
2079 }
2080 }
2081
2082 fn select_next_match(
2083 &mut self,
2084 _: &SelectNextMatch,
2085 window: &mut Window,
2086 cx: &mut Context<Self>,
2087 ) {
2088 if let Some(search) = self.active_project_search.as_ref() {
2089 search.update(cx, |this, cx| {
2090 this.select_match(Direction::Next, window, cx);
2091 })
2092 }
2093 }
2094
2095 fn select_prev_match(
2096 &mut self,
2097 _: &SelectPreviousMatch,
2098 window: &mut Window,
2099 cx: &mut Context<Self>,
2100 ) {
2101 if let Some(search) = self.active_project_search.as_ref() {
2102 search.update(cx, |this, cx| {
2103 this.select_match(Direction::Prev, window, cx);
2104 })
2105 }
2106 }
2107}
2108
2109impl Render for ProjectSearchBar {
2110 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2111 let Some(search) = self.active_project_search.clone() else {
2112 return div().into_any_element();
2113 };
2114 let search = search.read(cx);
2115 let focus_handle = search.focus_handle(cx);
2116
2117 let container_width = window.viewport_size().width;
2118 let input_width = SearchInputWidth::calc_width(container_width);
2119
2120 let input_base_styles = |panel: InputPanel| {
2121 input_base_styles(search.border_color_for(panel, cx), |div| match panel {
2122 InputPanel::Query | InputPanel::Replacement => div.w(input_width),
2123 InputPanel::Include | InputPanel::Exclude => div.flex_grow(),
2124 })
2125 };
2126 let theme_colors = cx.theme().colors();
2127 let project_search = search.entity.read(cx);
2128 let limit_reached = project_search.limit_reached;
2129 let is_search_underway = project_search.pending_search.is_some();
2130
2131 let color_override = match (
2132 &project_search.pending_search,
2133 project_search.no_results,
2134 &project_search.active_query,
2135 &project_search.last_search_query_text,
2136 ) {
2137 (None, Some(true), Some(q), Some(p)) if q.as_str() == p => Some(Color::Error),
2138 _ => None,
2139 };
2140
2141 let match_text = search
2142 .active_match_index
2143 .and_then(|index| {
2144 let index = index + 1;
2145 let match_quantity = project_search.match_ranges.len();
2146 if match_quantity > 0 {
2147 debug_assert!(match_quantity >= index);
2148 if limit_reached {
2149 Some(format!("{index}/{match_quantity}+"))
2150 } else {
2151 Some(format!("{index}/{match_quantity}"))
2152 }
2153 } else {
2154 None
2155 }
2156 })
2157 .unwrap_or_else(|| "0/0".to_string());
2158
2159 let query_focus = search.query_editor.focus_handle(cx);
2160
2161 let query_column = input_base_styles(InputPanel::Query)
2162 .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
2163 .on_action(cx.listener(|this, action, window, cx| {
2164 this.previous_history_query(action, window, cx)
2165 }))
2166 .on_action(
2167 cx.listener(|this, action, window, cx| this.next_history_query(action, window, cx)),
2168 )
2169 .child(div().flex_1().py_1().child(render_text_input(
2170 &search.query_editor,
2171 color_override,
2172 cx,
2173 )))
2174 .child(
2175 h_flex()
2176 .gap_1()
2177 .child(SearchOption::CaseSensitive.as_button(
2178 search.search_options,
2179 SearchSource::Project(cx),
2180 focus_handle.clone(),
2181 ))
2182 .child(SearchOption::WholeWord.as_button(
2183 search.search_options,
2184 SearchSource::Project(cx),
2185 focus_handle.clone(),
2186 ))
2187 .child(SearchOption::Regex.as_button(
2188 search.search_options,
2189 SearchSource::Project(cx),
2190 focus_handle.clone(),
2191 )),
2192 );
2193
2194 let matches_column = h_flex()
2195 .ml_1()
2196 .pl_1p5()
2197 .border_l_1()
2198 .border_color(theme_colors.border_variant)
2199 .child(render_action_button(
2200 "project-search-nav-button",
2201 IconName::ChevronLeft,
2202 search
2203 .active_match_index
2204 .is_none()
2205 .then_some(ActionButtonState::Disabled),
2206 "Select Previous Match",
2207 &SelectPreviousMatch,
2208 query_focus.clone(),
2209 ))
2210 .child(render_action_button(
2211 "project-search-nav-button",
2212 IconName::ChevronRight,
2213 search
2214 .active_match_index
2215 .is_none()
2216 .then_some(ActionButtonState::Disabled),
2217 "Select Next Match",
2218 &SelectNextMatch,
2219 query_focus.clone(),
2220 ))
2221 .child(
2222 div()
2223 .id("matches")
2224 .ml_2()
2225 .min_w(rems_from_px(40.))
2226 .child(
2227 h_flex()
2228 .gap_1p5()
2229 .child(
2230 Label::new(match_text)
2231 .size(LabelSize::Small)
2232 .when(search.active_match_index.is_some(), |this| {
2233 this.color(Color::Disabled)
2234 }),
2235 )
2236 .when(is_search_underway, |this| {
2237 this.child(
2238 Icon::new(IconName::ArrowCircle)
2239 .color(Color::Accent)
2240 .size(IconSize::Small)
2241 .with_rotate_animation(2)
2242 .into_any_element(),
2243 )
2244 }),
2245 )
2246 .when(limit_reached, |this| {
2247 this.tooltip(Tooltip::text(
2248 "Search Limits Reached\nTry narrowing your search",
2249 ))
2250 }),
2251 );
2252
2253 let mode_column = h_flex()
2254 .gap_1()
2255 .min_w_64()
2256 .child(
2257 IconButton::new("project-search-filter-button", IconName::Filter)
2258 .shape(IconButtonShape::Square)
2259 .tooltip(|_window, cx| {
2260 Tooltip::for_action("Toggle Filters", &ToggleFilters, cx)
2261 })
2262 .on_click(cx.listener(|this, _, window, cx| {
2263 this.toggle_filters(window, cx);
2264 }))
2265 .toggle_state(
2266 self.active_project_search
2267 .as_ref()
2268 .map(|search| search.read(cx).filters_enabled)
2269 .unwrap_or_default(),
2270 )
2271 .tooltip({
2272 let focus_handle = focus_handle.clone();
2273 move |_window, cx| {
2274 Tooltip::for_action_in(
2275 "Toggle Filters",
2276 &ToggleFilters,
2277 &focus_handle,
2278 cx,
2279 )
2280 }
2281 }),
2282 )
2283 .child(render_action_button(
2284 "project-search",
2285 IconName::Replace,
2286 self.active_project_search
2287 .as_ref()
2288 .map(|search| search.read(cx).replace_enabled)
2289 .and_then(|enabled| enabled.then_some(ActionButtonState::Toggled)),
2290 "Toggle Replace",
2291 &ToggleReplace,
2292 focus_handle.clone(),
2293 ))
2294 .child(matches_column);
2295
2296 let is_collapsed = search.results_editor.read(cx).has_any_buffer_folded(cx);
2297
2298 let (icon, tooltip_label) = if is_collapsed {
2299 (IconName::ChevronUpDown, "Expand All Search Results")
2300 } else {
2301 (IconName::ChevronDownUp, "Collapse All Search Results")
2302 };
2303
2304 let expand_button = IconButton::new("project-search-collapse-expand", icon)
2305 .shape(IconButtonShape::Square)
2306 .tooltip(move |_, cx| {
2307 Tooltip::for_action_in(
2308 tooltip_label,
2309 &ToggleAllSearchResults,
2310 &query_focus.clone(),
2311 cx,
2312 )
2313 })
2314 .on_click(cx.listener(|this, _, window, cx| {
2315 if let Some(active_view) = &this.active_project_search {
2316 active_view.update(cx, |active_view, cx| {
2317 active_view.toggle_all_search_results(&ToggleAllSearchResults, window, cx);
2318 })
2319 }
2320 }));
2321
2322 let search_line = h_flex()
2323 .pl_0p5()
2324 .w_full()
2325 .gap_2()
2326 .child(expand_button)
2327 .child(query_column)
2328 .child(mode_column);
2329
2330 let replace_line = search.replace_enabled.then(|| {
2331 let replace_column = input_base_styles(InputPanel::Replacement).child(
2332 div().flex_1().py_1().child(render_text_input(
2333 &search.replacement_editor,
2334 None,
2335 cx,
2336 )),
2337 );
2338
2339 let focus_handle = search.replacement_editor.read(cx).focus_handle(cx);
2340 let replace_actions = h_flex()
2341 .min_w_64()
2342 .gap_1()
2343 .child(render_action_button(
2344 "project-search-replace-button",
2345 IconName::ReplaceNext,
2346 is_search_underway.then_some(ActionButtonState::Disabled),
2347 "Replace Next Match",
2348 &ReplaceNext,
2349 focus_handle.clone(),
2350 ))
2351 .child(render_action_button(
2352 "project-search-replace-button",
2353 IconName::ReplaceAll,
2354 Default::default(),
2355 "Replace All Matches",
2356 &ReplaceAll,
2357 focus_handle,
2358 ));
2359
2360 h_flex()
2361 .w_full()
2362 .gap_2()
2363 .child(alignment_element())
2364 .child(replace_column)
2365 .child(replace_actions)
2366 });
2367
2368 let filter_line = search.filters_enabled.then(|| {
2369 let include = input_base_styles(InputPanel::Include)
2370 .on_action(cx.listener(|this, action, window, cx| {
2371 this.previous_history_query(action, window, cx)
2372 }))
2373 .on_action(cx.listener(|this, action, window, cx| {
2374 this.next_history_query(action, window, cx)
2375 }))
2376 .child(render_text_input(&search.included_files_editor, None, cx));
2377 let exclude = input_base_styles(InputPanel::Exclude)
2378 .on_action(cx.listener(|this, action, window, cx| {
2379 this.previous_history_query(action, window, cx)
2380 }))
2381 .on_action(cx.listener(|this, action, window, cx| {
2382 this.next_history_query(action, window, cx)
2383 }))
2384 .child(render_text_input(&search.excluded_files_editor, None, cx));
2385 let mode_column = h_flex()
2386 .gap_1()
2387 .min_w_64()
2388 .child(
2389 IconButton::new("project-search-opened-only", IconName::FolderSearch)
2390 .shape(IconButtonShape::Square)
2391 .toggle_state(self.is_opened_only_enabled(cx))
2392 .tooltip(Tooltip::text("Only Search Open Files"))
2393 .on_click(cx.listener(|this, _, window, cx| {
2394 this.toggle_opened_only(window, cx);
2395 })),
2396 )
2397 .child(SearchOption::IncludeIgnored.as_button(
2398 search.search_options,
2399 SearchSource::Project(cx),
2400 focus_handle,
2401 ));
2402
2403 h_flex()
2404 .w_full()
2405 .gap_2()
2406 .child(alignment_element())
2407 .child(
2408 h_flex()
2409 .w(input_width)
2410 .gap_2()
2411 .child(include)
2412 .child(exclude),
2413 )
2414 .child(mode_column)
2415 });
2416
2417 let mut key_context = KeyContext::default();
2418 key_context.add("ProjectSearchBar");
2419 if search
2420 .replacement_editor
2421 .focus_handle(cx)
2422 .is_focused(window)
2423 {
2424 key_context.add("in_replace");
2425 }
2426
2427 let query_error_line = search
2428 .panels_with_errors
2429 .get(&InputPanel::Query)
2430 .map(|error| {
2431 Label::new(error)
2432 .size(LabelSize::Small)
2433 .color(Color::Error)
2434 .mt_neg_1()
2435 .ml_2()
2436 });
2437
2438 let filter_error_line = search
2439 .panels_with_errors
2440 .get(&InputPanel::Include)
2441 .or_else(|| search.panels_with_errors.get(&InputPanel::Exclude))
2442 .map(|error| {
2443 Label::new(error)
2444 .size(LabelSize::Small)
2445 .color(Color::Error)
2446 .mt_neg_1()
2447 .ml_2()
2448 });
2449
2450 v_flex()
2451 .gap_2()
2452 .w_full()
2453 .key_context(key_context)
2454 .on_action(cx.listener(|this, _: &ToggleFocus, window, cx| {
2455 this.move_focus_to_results(window, cx)
2456 }))
2457 .on_action(cx.listener(|this, _: &ToggleFilters, window, cx| {
2458 this.toggle_filters(window, cx);
2459 }))
2460 .capture_action(cx.listener(Self::tab))
2461 .capture_action(cx.listener(Self::backtab))
2462 .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
2463 .on_action(cx.listener(|this, action, window, cx| {
2464 this.toggle_replace(action, window, cx);
2465 }))
2466 .on_action(cx.listener(|this, _: &ToggleWholeWord, window, cx| {
2467 this.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
2468 }))
2469 .on_action(cx.listener(|this, _: &ToggleCaseSensitive, window, cx| {
2470 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
2471 }))
2472 .on_action(cx.listener(|this, action, window, cx| {
2473 if let Some(search) = this.active_project_search.as_ref() {
2474 search.update(cx, |this, cx| {
2475 this.replace_next(action, window, cx);
2476 })
2477 }
2478 }))
2479 .on_action(cx.listener(|this, action, window, cx| {
2480 if let Some(search) = this.active_project_search.as_ref() {
2481 search.update(cx, |this, cx| {
2482 this.replace_all(action, window, cx);
2483 })
2484 }
2485 }))
2486 .when(search.filters_enabled, |this| {
2487 this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, window, cx| {
2488 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, window, cx);
2489 }))
2490 })
2491 .on_action(cx.listener(Self::select_next_match))
2492 .on_action(cx.listener(Self::select_prev_match))
2493 .child(search_line)
2494 .children(query_error_line)
2495 .children(replace_line)
2496 .children(filter_line)
2497 .children(filter_error_line)
2498 .into_any_element()
2499 }
2500}
2501
2502impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
2503
2504impl ToolbarItemView for ProjectSearchBar {
2505 fn set_active_pane_item(
2506 &mut self,
2507 active_pane_item: Option<&dyn ItemHandle>,
2508 _: &mut Window,
2509 cx: &mut Context<Self>,
2510 ) -> ToolbarItemLocation {
2511 cx.notify();
2512 self.subscription = None;
2513 self.active_project_search = None;
2514 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
2515 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
2516 self.active_project_search = Some(search);
2517 ToolbarItemLocation::PrimaryLeft {}
2518 } else {
2519 ToolbarItemLocation::Hidden
2520 }
2521 }
2522}
2523
2524fn register_workspace_action<A: Action>(
2525 workspace: &mut Workspace,
2526 callback: fn(&mut ProjectSearchBar, &A, &mut Window, &mut Context<ProjectSearchBar>),
2527) {
2528 workspace.register_action(move |workspace, action: &A, window, cx| {
2529 if workspace.has_active_modal(window, cx) && !workspace.hide_modal(window, cx) {
2530 cx.propagate();
2531 return;
2532 }
2533
2534 workspace.active_pane().update(cx, |pane, cx| {
2535 pane.toolbar().update(cx, move |workspace, cx| {
2536 if let Some(search_bar) = workspace.item_of_type::<ProjectSearchBar>() {
2537 search_bar.update(cx, move |search_bar, cx| {
2538 if search_bar.active_project_search.is_some() {
2539 callback(search_bar, action, window, cx);
2540 cx.notify();
2541 } else {
2542 cx.propagate();
2543 }
2544 });
2545 }
2546 });
2547 })
2548 });
2549}
2550
2551fn register_workspace_action_for_present_search<A: Action>(
2552 workspace: &mut Workspace,
2553 callback: fn(&mut Workspace, &A, &mut Window, &mut Context<Workspace>),
2554) {
2555 workspace.register_action(move |workspace, action: &A, window, cx| {
2556 if workspace.has_active_modal(window, cx) && !workspace.hide_modal(window, cx) {
2557 cx.propagate();
2558 return;
2559 }
2560
2561 let should_notify = workspace
2562 .active_pane()
2563 .read(cx)
2564 .toolbar()
2565 .read(cx)
2566 .item_of_type::<ProjectSearchBar>()
2567 .map(|search_bar| search_bar.read(cx).active_project_search.is_some())
2568 .unwrap_or(false);
2569 if should_notify {
2570 callback(workspace, action, window, cx);
2571 cx.notify();
2572 } else {
2573 cx.propagate();
2574 }
2575 });
2576}
2577
2578#[cfg(any(test, feature = "test-support"))]
2579pub fn perform_project_search(
2580 search_view: &Entity<ProjectSearchView>,
2581 text: impl Into<std::sync::Arc<str>>,
2582 cx: &mut gpui::VisualTestContext,
2583) {
2584 cx.run_until_parked();
2585 search_view.update_in(cx, |search_view, window, cx| {
2586 search_view.query_editor.update(cx, |query_editor, cx| {
2587 query_editor.set_text(text, window, cx)
2588 });
2589 search_view.search(cx);
2590 });
2591 cx.run_until_parked();
2592}
2593
2594#[cfg(test)]
2595pub mod tests {
2596 use std::{
2597 path::PathBuf,
2598 sync::{
2599 Arc,
2600 atomic::{self, AtomicUsize},
2601 },
2602 time::Duration,
2603 };
2604
2605 use super::*;
2606 use editor::{DisplayPoint, display_map::DisplayRow};
2607 use gpui::{Action, TestAppContext, VisualTestContext, WindowHandle};
2608 use language::{FakeLspAdapter, rust_lang};
2609 use pretty_assertions::assert_eq;
2610 use project::{FakeFs, Fs};
2611 use serde_json::json;
2612 use settings::{
2613 InlayHintSettingsContent, SettingsStore, ThemeColorsContent, ThemeStyleContent,
2614 };
2615 use util::{path, paths::PathStyle, rel_path::rel_path};
2616 use util_macros::perf;
2617 use workspace::{DeploySearch, MultiWorkspace};
2618
2619 #[test]
2620 fn test_split_glob_patterns() {
2621 assert_eq!(split_glob_patterns("a,b,c"), vec!["a", "b", "c"]);
2622 assert_eq!(split_glob_patterns("a, b, c"), vec!["a", " b", " c"]);
2623 assert_eq!(
2624 split_glob_patterns("src/{a,b}/**/*.rs"),
2625 vec!["src/{a,b}/**/*.rs"]
2626 );
2627 assert_eq!(
2628 split_glob_patterns("src/{a,b}/*.rs, tests/**/*.rs"),
2629 vec!["src/{a,b}/*.rs", " tests/**/*.rs"]
2630 );
2631 assert_eq!(split_glob_patterns("{a,b},{c,d}"), vec!["{a,b}", "{c,d}"]);
2632 assert_eq!(split_glob_patterns("{{a,b},{c,d}}"), vec!["{{a,b},{c,d}}"]);
2633 assert_eq!(split_glob_patterns(""), vec![""]);
2634 assert_eq!(split_glob_patterns("a"), vec!["a"]);
2635 // Escaped characters should not be treated as special
2636 assert_eq!(split_glob_patterns(r"a\,b,c"), vec![r"a\,b", "c"]);
2637 assert_eq!(split_glob_patterns(r"\{a,b\}"), vec![r"\{a", r"b\}"]);
2638 assert_eq!(split_glob_patterns(r"a\\,b"), vec![r"a\\", "b"]);
2639 assert_eq!(split_glob_patterns(r"a\\\,b"), vec![r"a\\\,b"]);
2640 }
2641
2642 #[perf]
2643 #[gpui::test]
2644 async fn test_project_search(cx: &mut TestAppContext) {
2645 fn dp(row: u32, col: u32) -> DisplayPoint {
2646 DisplayPoint::new(DisplayRow(row), col)
2647 }
2648
2649 fn assert_active_match_index(
2650 search_view: &WindowHandle<ProjectSearchView>,
2651 cx: &mut TestAppContext,
2652 expected_index: usize,
2653 ) {
2654 search_view
2655 .update(cx, |search_view, _window, _cx| {
2656 assert_eq!(search_view.active_match_index, Some(expected_index));
2657 })
2658 .unwrap();
2659 }
2660
2661 fn assert_selection_range(
2662 search_view: &WindowHandle<ProjectSearchView>,
2663 cx: &mut TestAppContext,
2664 expected_range: Range<DisplayPoint>,
2665 ) {
2666 search_view
2667 .update(cx, |search_view, _window, cx| {
2668 assert_eq!(
2669 search_view.results_editor.update(cx, |editor, cx| editor
2670 .selections
2671 .display_ranges(&editor.display_snapshot(cx))),
2672 [expected_range]
2673 );
2674 })
2675 .unwrap();
2676 }
2677
2678 fn assert_highlights(
2679 search_view: &WindowHandle<ProjectSearchView>,
2680 cx: &mut TestAppContext,
2681 expected_highlights: Vec<(Range<DisplayPoint>, &str)>,
2682 ) {
2683 search_view
2684 .update(cx, |search_view, window, cx| {
2685 let match_bg = cx.theme().colors().search_match_background;
2686 let active_match_bg = cx.theme().colors().search_active_match_background;
2687 let selection_bg = cx
2688 .theme()
2689 .colors()
2690 .editor_document_highlight_bracket_background;
2691
2692 let highlights: Vec<_> = expected_highlights
2693 .into_iter()
2694 .map(|(range, color_type)| {
2695 let color = match color_type {
2696 "active" => active_match_bg,
2697 "match" => match_bg,
2698 "selection" => selection_bg,
2699 _ => panic!("Unknown color type"),
2700 };
2701 (range, color)
2702 })
2703 .collect();
2704
2705 assert_eq!(
2706 search_view.results_editor.update(cx, |editor, cx| editor
2707 .all_text_background_highlights(window, cx)),
2708 highlights.as_slice()
2709 );
2710 })
2711 .unwrap();
2712 }
2713
2714 fn select_match(
2715 search_view: &WindowHandle<ProjectSearchView>,
2716 cx: &mut TestAppContext,
2717 direction: Direction,
2718 ) {
2719 search_view
2720 .update(cx, |search_view, window, cx| {
2721 search_view.select_match(direction, window, cx);
2722 })
2723 .unwrap();
2724 }
2725
2726 init_test(cx);
2727
2728 // Override active search match color since the fallback theme uses the same color
2729 // for normal search match and active one, which can make this test less robust.
2730 cx.update(|cx| {
2731 SettingsStore::update_global(cx, |settings, cx| {
2732 settings.update_user_settings(cx, |settings| {
2733 settings.theme.experimental_theme_overrides = Some(ThemeStyleContent {
2734 colors: ThemeColorsContent {
2735 search_active_match_background: Some("#ff0000ff".to_string()),
2736 ..Default::default()
2737 },
2738 ..Default::default()
2739 });
2740 });
2741 });
2742 });
2743
2744 let fs = FakeFs::new(cx.background_executor.clone());
2745 fs.insert_tree(
2746 path!("/dir"),
2747 json!({
2748 "one.rs": "const ONE: usize = 1;",
2749 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2750 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2751 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2752 }),
2753 )
2754 .await;
2755 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2756 let window =
2757 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2758 let workspace = window
2759 .read_with(cx, |mw, _| mw.workspace().clone())
2760 .unwrap();
2761 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
2762 let search_view = cx.add_window(|window, cx| {
2763 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
2764 });
2765
2766 perform_search(search_view, "TWO", cx);
2767 cx.run_until_parked();
2768
2769 search_view
2770 .update(cx, |search_view, _window, cx| {
2771 assert_eq!(
2772 search_view
2773 .results_editor
2774 .update(cx, |editor, cx| editor.display_text(cx)),
2775 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
2776 );
2777 })
2778 .unwrap();
2779
2780 assert_active_match_index(&search_view, cx, 0);
2781 assert_selection_range(&search_view, cx, dp(2, 32)..dp(2, 35));
2782 assert_highlights(
2783 &search_view,
2784 cx,
2785 vec![
2786 (dp(2, 32)..dp(2, 35), "active"),
2787 (dp(2, 37)..dp(2, 40), "selection"),
2788 (dp(2, 37)..dp(2, 40), "match"),
2789 (dp(5, 6)..dp(5, 9), "selection"),
2790 (dp(5, 6)..dp(5, 9), "match"),
2791 ],
2792 );
2793 select_match(&search_view, cx, Direction::Next);
2794 cx.run_until_parked();
2795
2796 assert_active_match_index(&search_view, cx, 1);
2797 assert_selection_range(&search_view, cx, dp(2, 37)..dp(2, 40));
2798 assert_highlights(
2799 &search_view,
2800 cx,
2801 vec![
2802 (dp(2, 32)..dp(2, 35), "selection"),
2803 (dp(2, 32)..dp(2, 35), "match"),
2804 (dp(2, 37)..dp(2, 40), "active"),
2805 (dp(5, 6)..dp(5, 9), "selection"),
2806 (dp(5, 6)..dp(5, 9), "match"),
2807 ],
2808 );
2809 select_match(&search_view, cx, Direction::Next);
2810 cx.run_until_parked();
2811
2812 assert_active_match_index(&search_view, cx, 2);
2813 assert_selection_range(&search_view, cx, dp(5, 6)..dp(5, 9));
2814 assert_highlights(
2815 &search_view,
2816 cx,
2817 vec![
2818 (dp(2, 32)..dp(2, 35), "selection"),
2819 (dp(2, 32)..dp(2, 35), "match"),
2820 (dp(2, 37)..dp(2, 40), "selection"),
2821 (dp(2, 37)..dp(2, 40), "match"),
2822 (dp(5, 6)..dp(5, 9), "active"),
2823 ],
2824 );
2825 select_match(&search_view, cx, Direction::Next);
2826 cx.run_until_parked();
2827
2828 assert_active_match_index(&search_view, cx, 0);
2829 assert_selection_range(&search_view, cx, dp(2, 32)..dp(2, 35));
2830 assert_highlights(
2831 &search_view,
2832 cx,
2833 vec![
2834 (dp(2, 32)..dp(2, 35), "active"),
2835 (dp(2, 37)..dp(2, 40), "selection"),
2836 (dp(2, 37)..dp(2, 40), "match"),
2837 (dp(5, 6)..dp(5, 9), "selection"),
2838 (dp(5, 6)..dp(5, 9), "match"),
2839 ],
2840 );
2841 select_match(&search_view, cx, Direction::Prev);
2842 cx.run_until_parked();
2843
2844 assert_active_match_index(&search_view, cx, 2);
2845 assert_selection_range(&search_view, cx, dp(5, 6)..dp(5, 9));
2846 assert_highlights(
2847 &search_view,
2848 cx,
2849 vec![
2850 (dp(2, 32)..dp(2, 35), "selection"),
2851 (dp(2, 32)..dp(2, 35), "match"),
2852 (dp(2, 37)..dp(2, 40), "selection"),
2853 (dp(2, 37)..dp(2, 40), "match"),
2854 (dp(5, 6)..dp(5, 9), "active"),
2855 ],
2856 );
2857 select_match(&search_view, cx, Direction::Prev);
2858 cx.run_until_parked();
2859
2860 assert_active_match_index(&search_view, cx, 1);
2861 assert_selection_range(&search_view, cx, dp(2, 37)..dp(2, 40));
2862 assert_highlights(
2863 &search_view,
2864 cx,
2865 vec![
2866 (dp(2, 32)..dp(2, 35), "selection"),
2867 (dp(2, 32)..dp(2, 35), "match"),
2868 (dp(2, 37)..dp(2, 40), "active"),
2869 (dp(5, 6)..dp(5, 9), "selection"),
2870 (dp(5, 6)..dp(5, 9), "match"),
2871 ],
2872 );
2873 search_view
2874 .update(cx, |search_view, window, cx| {
2875 search_view.results_editor.update(cx, |editor, cx| {
2876 editor.fold_all(&FoldAll, window, cx);
2877 })
2878 })
2879 .expect("Should fold fine");
2880 cx.run_until_parked();
2881
2882 let results_collapsed = search_view
2883 .read_with(cx, |search_view, cx| {
2884 search_view
2885 .results_editor
2886 .read(cx)
2887 .has_any_buffer_folded(cx)
2888 })
2889 .expect("got results_collapsed");
2890
2891 assert!(results_collapsed);
2892 search_view
2893 .update(cx, |search_view, window, cx| {
2894 search_view.results_editor.update(cx, |editor, cx| {
2895 editor.unfold_all(&UnfoldAll, window, cx);
2896 })
2897 })
2898 .expect("Should unfold fine");
2899 cx.run_until_parked();
2900
2901 let results_collapsed = search_view
2902 .read_with(cx, |search_view, cx| {
2903 search_view
2904 .results_editor
2905 .read(cx)
2906 .has_any_buffer_folded(cx)
2907 })
2908 .expect("got results_collapsed");
2909
2910 assert!(!results_collapsed);
2911 }
2912
2913 #[perf]
2914 #[gpui::test]
2915 async fn test_collapse_state_syncs_after_manual_buffer_fold(cx: &mut TestAppContext) {
2916 init_test(cx);
2917
2918 let fs = FakeFs::new(cx.background_executor.clone());
2919 fs.insert_tree(
2920 path!("/dir"),
2921 json!({
2922 "one.rs": "const ONE: usize = 1;",
2923 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2924 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2925 }),
2926 )
2927 .await;
2928 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2929 let window =
2930 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2931 let workspace = window
2932 .read_with(cx, |mw, _| mw.workspace().clone())
2933 .unwrap();
2934 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
2935 let search_view = cx.add_window(|window, cx| {
2936 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
2937 });
2938
2939 // Search for "ONE" which appears in all 3 files
2940 perform_search(search_view, "ONE", cx);
2941
2942 // Verify initial state: no folds
2943 let has_any_folded = search_view
2944 .read_with(cx, |search_view, cx| {
2945 search_view
2946 .results_editor
2947 .read(cx)
2948 .has_any_buffer_folded(cx)
2949 })
2950 .expect("should read state");
2951 assert!(!has_any_folded, "No buffers should be folded initially");
2952
2953 // Fold all via fold_all
2954 search_view
2955 .update(cx, |search_view, window, cx| {
2956 search_view.results_editor.update(cx, |editor, cx| {
2957 editor.fold_all(&FoldAll, window, cx);
2958 })
2959 })
2960 .expect("Should fold fine");
2961 cx.run_until_parked();
2962
2963 let has_any_folded = search_view
2964 .read_with(cx, |search_view, cx| {
2965 search_view
2966 .results_editor
2967 .read(cx)
2968 .has_any_buffer_folded(cx)
2969 })
2970 .expect("should read state");
2971 assert!(
2972 has_any_folded,
2973 "All buffers should be folded after fold_all"
2974 );
2975
2976 // Manually unfold one buffer (simulating a chevron click)
2977 let first_buffer_id = search_view
2978 .read_with(cx, |search_view, cx| {
2979 search_view
2980 .results_editor
2981 .read(cx)
2982 .buffer()
2983 .read(cx)
2984 .snapshot(cx)
2985 .excerpts()
2986 .next()
2987 .unwrap()
2988 .context
2989 .start
2990 .buffer_id
2991 })
2992 .expect("should read buffer ids");
2993
2994 search_view
2995 .update(cx, |search_view, _window, cx| {
2996 search_view.results_editor.update(cx, |editor, cx| {
2997 editor.unfold_buffer(first_buffer_id, cx);
2998 })
2999 })
3000 .expect("Should unfold one buffer");
3001
3002 let has_any_folded = search_view
3003 .read_with(cx, |search_view, cx| {
3004 search_view
3005 .results_editor
3006 .read(cx)
3007 .has_any_buffer_folded(cx)
3008 })
3009 .expect("should read state");
3010 assert!(
3011 has_any_folded,
3012 "Should still report folds when only one buffer is unfolded"
3013 );
3014
3015 // Unfold all via unfold_all
3016 search_view
3017 .update(cx, |search_view, window, cx| {
3018 search_view.results_editor.update(cx, |editor, cx| {
3019 editor.unfold_all(&UnfoldAll, window, cx);
3020 })
3021 })
3022 .expect("Should unfold fine");
3023 cx.run_until_parked();
3024
3025 let has_any_folded = search_view
3026 .read_with(cx, |search_view, cx| {
3027 search_view
3028 .results_editor
3029 .read(cx)
3030 .has_any_buffer_folded(cx)
3031 })
3032 .expect("should read state");
3033 assert!(!has_any_folded, "No folds should remain after unfold_all");
3034
3035 // Manually fold one buffer back (simulating a chevron click)
3036 search_view
3037 .update(cx, |search_view, _window, cx| {
3038 search_view.results_editor.update(cx, |editor, cx| {
3039 editor.fold_buffer(first_buffer_id, cx);
3040 })
3041 })
3042 .expect("Should fold one buffer");
3043
3044 let has_any_folded = search_view
3045 .read_with(cx, |search_view, cx| {
3046 search_view
3047 .results_editor
3048 .read(cx)
3049 .has_any_buffer_folded(cx)
3050 })
3051 .expect("should read state");
3052 assert!(
3053 has_any_folded,
3054 "Should report folds after manually folding one buffer"
3055 );
3056 }
3057
3058 #[perf]
3059 #[gpui::test]
3060 async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
3061 init_test(cx);
3062
3063 let fs = FakeFs::new(cx.background_executor.clone());
3064 fs.insert_tree(
3065 "/dir",
3066 json!({
3067 "one.rs": "const ONE: usize = 1;",
3068 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3069 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3070 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3071 }),
3072 )
3073 .await;
3074 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3075 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3076 let workspace = window
3077 .read_with(cx, |mw, _| mw.workspace().clone())
3078 .unwrap();
3079 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3080 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3081
3082 let active_item = cx.read(|cx| {
3083 workspace
3084 .read(cx)
3085 .active_pane()
3086 .read(cx)
3087 .active_item()
3088 .and_then(|item| item.downcast::<ProjectSearchView>())
3089 });
3090 assert!(
3091 active_item.is_none(),
3092 "Expected no search panel to be active"
3093 );
3094
3095 workspace.update_in(cx, move |workspace, window, cx| {
3096 assert_eq!(workspace.panes().len(), 1);
3097 workspace.panes()[0].update(cx, |pane, cx| {
3098 pane.toolbar()
3099 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3100 });
3101
3102 ProjectSearchView::deploy_search(
3103 workspace,
3104 &workspace::DeploySearch::find(),
3105 window,
3106 cx,
3107 )
3108 });
3109
3110 let Some(search_view) = cx.read(|cx| {
3111 workspace
3112 .read(cx)
3113 .active_pane()
3114 .read(cx)
3115 .active_item()
3116 .and_then(|item| item.downcast::<ProjectSearchView>())
3117 }) else {
3118 panic!("Search view expected to appear after new search event trigger")
3119 };
3120
3121 cx.spawn(|mut cx| async move {
3122 window
3123 .update(&mut cx, |_, window, cx| {
3124 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3125 })
3126 .unwrap();
3127 })
3128 .detach();
3129 cx.background_executor.run_until_parked();
3130 window
3131 .update(cx, |_, window, cx| {
3132 search_view.update(cx, |search_view, cx| {
3133 assert!(
3134 search_view.query_editor.focus_handle(cx).is_focused(window),
3135 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
3136 );
3137 });
3138 }).unwrap();
3139
3140 window
3141 .update(cx, |_, window, cx| {
3142 search_view.update(cx, |search_view, cx| {
3143 let query_editor = &search_view.query_editor;
3144 assert!(
3145 query_editor.focus_handle(cx).is_focused(window),
3146 "Search view should be focused after the new search view is activated",
3147 );
3148 let query_text = query_editor.read(cx).text(cx);
3149 assert!(
3150 query_text.is_empty(),
3151 "New search query should be empty but got '{query_text}'",
3152 );
3153 let results_text = search_view
3154 .results_editor
3155 .update(cx, |editor, cx| editor.display_text(cx));
3156 assert!(
3157 results_text.is_empty(),
3158 "Empty search view should have no results but got '{results_text}'"
3159 );
3160 });
3161 })
3162 .unwrap();
3163
3164 window
3165 .update(cx, |_, window, cx| {
3166 search_view.update(cx, |search_view, cx| {
3167 search_view.query_editor.update(cx, |query_editor, cx| {
3168 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
3169 });
3170 search_view.search(cx);
3171 });
3172 })
3173 .unwrap();
3174 cx.background_executor.run_until_parked();
3175 window
3176 .update(cx, |_, window, cx| {
3177 search_view.update(cx, |search_view, cx| {
3178 let results_text = search_view
3179 .results_editor
3180 .update(cx, |editor, cx| editor.display_text(cx));
3181 assert!(
3182 results_text.is_empty(),
3183 "Search view for mismatching query should have no results but got '{results_text}'"
3184 );
3185 assert!(
3186 search_view.query_editor.focus_handle(cx).is_focused(window),
3187 "Search view should be focused after mismatching query had been used in search",
3188 );
3189 });
3190 }).unwrap();
3191
3192 cx.spawn(|mut cx| async move {
3193 window.update(&mut cx, |_, window, cx| {
3194 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3195 })
3196 })
3197 .detach();
3198 cx.background_executor.run_until_parked();
3199 window.update(cx, |_, window, cx| {
3200 search_view.update(cx, |search_view, cx| {
3201 assert!(
3202 search_view.query_editor.focus_handle(cx).is_focused(window),
3203 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
3204 );
3205 });
3206 }).unwrap();
3207
3208 window
3209 .update(cx, |_, window, cx| {
3210 search_view.update(cx, |search_view, cx| {
3211 search_view.query_editor.update(cx, |query_editor, cx| {
3212 query_editor.set_text("TWO", window, cx)
3213 });
3214 search_view.search(cx);
3215 });
3216 })
3217 .unwrap();
3218 cx.background_executor.run_until_parked();
3219 window.update(cx, |_, window, cx| {
3220 search_view.update(cx, |search_view, cx| {
3221 assert_eq!(
3222 search_view
3223 .results_editor
3224 .update(cx, |editor, cx| editor.display_text(cx)),
3225 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3226 "Search view results should match the query"
3227 );
3228 assert!(
3229 search_view.results_editor.focus_handle(cx).is_focused(window),
3230 "Search view with mismatching query should be focused after search results are available",
3231 );
3232 });
3233 }).unwrap();
3234 cx.spawn(|mut cx| async move {
3235 window
3236 .update(&mut cx, |_, window, cx| {
3237 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3238 })
3239 .unwrap();
3240 })
3241 .detach();
3242 cx.background_executor.run_until_parked();
3243 window.update(cx, |_, window, cx| {
3244 search_view.update(cx, |search_view, cx| {
3245 assert!(
3246 search_view.results_editor.focus_handle(cx).is_focused(window),
3247 "Search view with matching query should still have its results editor focused after the toggle focus event",
3248 );
3249 });
3250 }).unwrap();
3251
3252 workspace.update_in(cx, |workspace, window, cx| {
3253 ProjectSearchView::deploy_search(
3254 workspace,
3255 &workspace::DeploySearch::find(),
3256 window,
3257 cx,
3258 )
3259 });
3260 window.update(cx, |_, window, cx| {
3261 search_view.update(cx, |search_view, cx| {
3262 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");
3263 assert_eq!(
3264 search_view
3265 .results_editor
3266 .update(cx, |editor, cx| editor.display_text(cx)),
3267 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3268 "Results should be unchanged after search view 2nd open in a row"
3269 );
3270 assert!(
3271 search_view.query_editor.focus_handle(cx).is_focused(window),
3272 "Focus should be moved into query editor again after search view 2nd open in a row"
3273 );
3274 });
3275 }).unwrap();
3276
3277 cx.spawn(|mut cx| async move {
3278 window
3279 .update(&mut cx, |_, window, cx| {
3280 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3281 })
3282 .unwrap();
3283 })
3284 .detach();
3285 cx.background_executor.run_until_parked();
3286 window.update(cx, |_, window, cx| {
3287 search_view.update(cx, |search_view, cx| {
3288 assert!(
3289 search_view.results_editor.focus_handle(cx).is_focused(window),
3290 "Search view with matching query should switch focus to the results editor after the toggle focus event",
3291 );
3292 });
3293 }).unwrap();
3294 }
3295
3296 #[perf]
3297 #[gpui::test]
3298 async fn test_filters_consider_toggle_state(cx: &mut TestAppContext) {
3299 init_test(cx);
3300
3301 let fs = FakeFs::new(cx.background_executor.clone());
3302 fs.insert_tree(
3303 "/dir",
3304 json!({
3305 "one.rs": "const ONE: usize = 1;",
3306 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3307 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3308 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3309 }),
3310 )
3311 .await;
3312 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3313 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3314 let workspace = window
3315 .read_with(cx, |mw, _| mw.workspace().clone())
3316 .unwrap();
3317 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3318 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3319
3320 workspace.update_in(cx, move |workspace, window, cx| {
3321 workspace.panes()[0].update(cx, |pane, cx| {
3322 pane.toolbar()
3323 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3324 });
3325
3326 ProjectSearchView::deploy_search(
3327 workspace,
3328 &workspace::DeploySearch::find(),
3329 window,
3330 cx,
3331 )
3332 });
3333
3334 let Some(search_view) = cx.read(|cx| {
3335 workspace
3336 .read(cx)
3337 .active_pane()
3338 .read(cx)
3339 .active_item()
3340 .and_then(|item| item.downcast::<ProjectSearchView>())
3341 }) else {
3342 panic!("Search view expected to appear after new search event trigger")
3343 };
3344
3345 cx.spawn(|mut cx| async move {
3346 window
3347 .update(&mut cx, |_, window, cx| {
3348 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3349 })
3350 .unwrap();
3351 })
3352 .detach();
3353 cx.background_executor.run_until_parked();
3354
3355 window
3356 .update(cx, |_, window, cx| {
3357 search_view.update(cx, |search_view, cx| {
3358 search_view.query_editor.update(cx, |query_editor, cx| {
3359 query_editor.set_text("const FOUR", window, cx)
3360 });
3361 search_view.toggle_filters(cx);
3362 search_view
3363 .excluded_files_editor
3364 .update(cx, |exclude_editor, cx| {
3365 exclude_editor.set_text("four.rs", window, cx)
3366 });
3367 search_view.search(cx);
3368 });
3369 })
3370 .unwrap();
3371 cx.background_executor.run_until_parked();
3372 window
3373 .update(cx, |_, _, cx| {
3374 search_view.update(cx, |search_view, cx| {
3375 let results_text = search_view
3376 .results_editor
3377 .update(cx, |editor, cx| editor.display_text(cx));
3378 assert!(
3379 results_text.is_empty(),
3380 "Search view for query with the only match in an excluded file should have no results but got '{results_text}'"
3381 );
3382 });
3383 }).unwrap();
3384
3385 cx.spawn(|mut cx| async move {
3386 window.update(&mut cx, |_, window, cx| {
3387 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3388 })
3389 })
3390 .detach();
3391 cx.background_executor.run_until_parked();
3392
3393 window
3394 .update(cx, |_, _, cx| {
3395 search_view.update(cx, |search_view, cx| {
3396 search_view.toggle_filters(cx);
3397 search_view.search(cx);
3398 });
3399 })
3400 .unwrap();
3401 cx.background_executor.run_until_parked();
3402 window
3403 .update(cx, |_, _, cx| {
3404 search_view.update(cx, |search_view, cx| {
3405 assert_eq!(
3406 search_view
3407 .results_editor
3408 .update(cx, |editor, cx| editor.display_text(cx)),
3409 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
3410 "Search view results should contain the queried result in the previously excluded file with filters toggled off"
3411 );
3412 });
3413 })
3414 .unwrap();
3415 }
3416
3417 #[perf]
3418 #[gpui::test]
3419 async fn test_new_project_search_focus(cx: &mut TestAppContext) {
3420 init_test(cx);
3421
3422 let fs = FakeFs::new(cx.background_executor.clone());
3423 fs.insert_tree(
3424 path!("/dir"),
3425 json!({
3426 "one.rs": "const ONE: usize = 1;",
3427 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3428 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3429 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3430 }),
3431 )
3432 .await;
3433 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3434 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3435 let workspace = window
3436 .read_with(cx, |mw, _| mw.workspace().clone())
3437 .unwrap();
3438 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3439 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3440
3441 let active_item = cx.read(|cx| {
3442 workspace
3443 .read(cx)
3444 .active_pane()
3445 .read(cx)
3446 .active_item()
3447 .and_then(|item| item.downcast::<ProjectSearchView>())
3448 });
3449 assert!(
3450 active_item.is_none(),
3451 "Expected no search panel to be active"
3452 );
3453
3454 workspace.update_in(cx, move |workspace, window, cx| {
3455 assert_eq!(workspace.panes().len(), 1);
3456 workspace.panes()[0].update(cx, |pane, cx| {
3457 pane.toolbar()
3458 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3459 });
3460
3461 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3462 });
3463
3464 let Some(search_view) = cx.read(|cx| {
3465 workspace
3466 .read(cx)
3467 .active_pane()
3468 .read(cx)
3469 .active_item()
3470 .and_then(|item| item.downcast::<ProjectSearchView>())
3471 }) else {
3472 panic!("Search view expected to appear after new search event trigger")
3473 };
3474
3475 cx.spawn(|mut cx| async move {
3476 window
3477 .update(&mut cx, |_, window, cx| {
3478 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3479 })
3480 .unwrap();
3481 })
3482 .detach();
3483 cx.background_executor.run_until_parked();
3484
3485 window.update(cx, |_, window, cx| {
3486 search_view.update(cx, |search_view, cx| {
3487 assert!(
3488 search_view.query_editor.focus_handle(cx).is_focused(window),
3489 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
3490 );
3491 });
3492 }).unwrap();
3493
3494 window
3495 .update(cx, |_, window, cx| {
3496 search_view.update(cx, |search_view, cx| {
3497 let query_editor = &search_view.query_editor;
3498 assert!(
3499 query_editor.focus_handle(cx).is_focused(window),
3500 "Search view should be focused after the new search view is activated",
3501 );
3502 let query_text = query_editor.read(cx).text(cx);
3503 assert!(
3504 query_text.is_empty(),
3505 "New search query should be empty but got '{query_text}'",
3506 );
3507 let results_text = search_view
3508 .results_editor
3509 .update(cx, |editor, cx| editor.display_text(cx));
3510 assert!(
3511 results_text.is_empty(),
3512 "Empty search view should have no results but got '{results_text}'"
3513 );
3514 });
3515 })
3516 .unwrap();
3517
3518 window
3519 .update(cx, |_, window, cx| {
3520 search_view.update(cx, |search_view, cx| {
3521 search_view.query_editor.update(cx, |query_editor, cx| {
3522 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
3523 });
3524 search_view.search(cx);
3525 });
3526 })
3527 .unwrap();
3528
3529 cx.background_executor.run_until_parked();
3530 window
3531 .update(cx, |_, window, cx| {
3532 search_view.update(cx, |search_view, cx| {
3533 let results_text = search_view
3534 .results_editor
3535 .update(cx, |editor, cx| editor.display_text(cx));
3536 assert!(
3537 results_text.is_empty(),
3538 "Search view for mismatching query should have no results but got '{results_text}'"
3539 );
3540 assert!(
3541 search_view.query_editor.focus_handle(cx).is_focused(window),
3542 "Search view should be focused after mismatching query had been used in search",
3543 );
3544 });
3545 })
3546 .unwrap();
3547 cx.spawn(|mut cx| async move {
3548 window.update(&mut cx, |_, window, cx| {
3549 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3550 })
3551 })
3552 .detach();
3553 cx.background_executor.run_until_parked();
3554 window.update(cx, |_, window, cx| {
3555 search_view.update(cx, |search_view, cx| {
3556 assert!(
3557 search_view.query_editor.focus_handle(cx).is_focused(window),
3558 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
3559 );
3560 });
3561 }).unwrap();
3562
3563 window
3564 .update(cx, |_, window, cx| {
3565 search_view.update(cx, |search_view, cx| {
3566 search_view.query_editor.update(cx, |query_editor, cx| {
3567 query_editor.set_text("TWO", window, cx)
3568 });
3569 search_view.search(cx);
3570 })
3571 })
3572 .unwrap();
3573 cx.background_executor.run_until_parked();
3574 window.update(cx, |_, window, cx|
3575 search_view.update(cx, |search_view, cx| {
3576 assert_eq!(
3577 search_view
3578 .results_editor
3579 .update(cx, |editor, cx| editor.display_text(cx)),
3580 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3581 "Search view results should match the query"
3582 );
3583 assert!(
3584 search_view.results_editor.focus_handle(cx).is_focused(window),
3585 "Search view with mismatching query should be focused after search results are available",
3586 );
3587 })).unwrap();
3588 cx.spawn(|mut cx| async move {
3589 window
3590 .update(&mut cx, |_, window, cx| {
3591 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3592 })
3593 .unwrap();
3594 })
3595 .detach();
3596 cx.background_executor.run_until_parked();
3597 window.update(cx, |_, window, cx| {
3598 search_view.update(cx, |search_view, cx| {
3599 assert!(
3600 search_view.results_editor.focus_handle(cx).is_focused(window),
3601 "Search view with matching query should still have its results editor focused after the toggle focus event",
3602 );
3603 });
3604 }).unwrap();
3605
3606 workspace.update_in(cx, |workspace, window, cx| {
3607 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3608 });
3609 cx.background_executor.run_until_parked();
3610 let Some(search_view_2) = cx.read(|cx| {
3611 workspace
3612 .read(cx)
3613 .active_pane()
3614 .read(cx)
3615 .active_item()
3616 .and_then(|item| item.downcast::<ProjectSearchView>())
3617 }) else {
3618 panic!("Search view expected to appear after new search event trigger")
3619 };
3620 assert!(
3621 search_view_2 != search_view,
3622 "New search view should be open after `workspace::NewSearch` event"
3623 );
3624
3625 window.update(cx, |_, window, cx| {
3626 search_view.update(cx, |search_view, cx| {
3627 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
3628 assert_eq!(
3629 search_view
3630 .results_editor
3631 .update(cx, |editor, cx| editor.display_text(cx)),
3632 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3633 "Results of the first search view should not update too"
3634 );
3635 assert!(
3636 !search_view.query_editor.focus_handle(cx).is_focused(window),
3637 "Focus should be moved away from the first search view"
3638 );
3639 });
3640 }).unwrap();
3641
3642 window.update(cx, |_, window, cx| {
3643 search_view_2.update(cx, |search_view_2, cx| {
3644 assert_eq!(
3645 search_view_2.query_editor.read(cx).text(cx),
3646 "two",
3647 "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
3648 );
3649 assert_eq!(
3650 search_view_2
3651 .results_editor
3652 .update(cx, |editor, cx| editor.display_text(cx)),
3653 "",
3654 "No search results should be in the 2nd view yet, as we did not spawn a search for it"
3655 );
3656 assert!(
3657 search_view_2.query_editor.focus_handle(cx).is_focused(window),
3658 "Focus should be moved into query editor of the new window"
3659 );
3660 });
3661 }).unwrap();
3662
3663 window
3664 .update(cx, |_, window, cx| {
3665 search_view_2.update(cx, |search_view_2, cx| {
3666 search_view_2.query_editor.update(cx, |query_editor, cx| {
3667 query_editor.set_text("FOUR", window, cx)
3668 });
3669 search_view_2.search(cx);
3670 });
3671 })
3672 .unwrap();
3673
3674 cx.background_executor.run_until_parked();
3675 window.update(cx, |_, window, cx| {
3676 search_view_2.update(cx, |search_view_2, cx| {
3677 assert_eq!(
3678 search_view_2
3679 .results_editor
3680 .update(cx, |editor, cx| editor.display_text(cx)),
3681 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
3682 "New search view with the updated query should have new search results"
3683 );
3684 assert!(
3685 search_view_2.results_editor.focus_handle(cx).is_focused(window),
3686 "Search view with mismatching query should be focused after search results are available",
3687 );
3688 });
3689 }).unwrap();
3690
3691 cx.spawn(|mut cx| async move {
3692 window
3693 .update(&mut cx, |_, window, cx| {
3694 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3695 })
3696 .unwrap();
3697 })
3698 .detach();
3699 cx.background_executor.run_until_parked();
3700 window.update(cx, |_, window, cx| {
3701 search_view_2.update(cx, |search_view_2, cx| {
3702 assert!(
3703 search_view_2.results_editor.focus_handle(cx).is_focused(window),
3704 "Search view with matching query should switch focus to the results editor after the toggle focus event",
3705 );
3706 });}).unwrap();
3707 }
3708
3709 #[perf]
3710 #[gpui::test]
3711 async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
3712 init_test(cx);
3713
3714 let fs = FakeFs::new(cx.background_executor.clone());
3715 fs.insert_tree(
3716 path!("/dir"),
3717 json!({
3718 "a": {
3719 "one.rs": "const ONE: usize = 1;",
3720 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3721 },
3722 "b": {
3723 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3724 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3725 },
3726 }),
3727 )
3728 .await;
3729 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3730 let worktree_id = project.read_with(cx, |project, cx| {
3731 project.worktrees(cx).next().unwrap().read(cx).id()
3732 });
3733 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3734 let workspace = window
3735 .read_with(cx, |mw, _| mw.workspace().clone())
3736 .unwrap();
3737 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3738 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3739
3740 let active_item = cx.read(|cx| {
3741 workspace
3742 .read(cx)
3743 .active_pane()
3744 .read(cx)
3745 .active_item()
3746 .and_then(|item| item.downcast::<ProjectSearchView>())
3747 });
3748 assert!(
3749 active_item.is_none(),
3750 "Expected no search panel to be active"
3751 );
3752
3753 workspace.update_in(cx, move |workspace, window, cx| {
3754 assert_eq!(workspace.panes().len(), 1);
3755 workspace.panes()[0].update(cx, move |pane, cx| {
3756 pane.toolbar()
3757 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3758 });
3759 });
3760
3761 let a_dir_entry = cx.update(|_, cx| {
3762 workspace
3763 .read(cx)
3764 .project()
3765 .read(cx)
3766 .entry_for_path(&(worktree_id, rel_path("a")).into(), cx)
3767 .expect("no entry for /a/ directory")
3768 .clone()
3769 });
3770 assert!(a_dir_entry.is_dir());
3771 workspace.update_in(cx, |workspace, window, cx| {
3772 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, window, cx)
3773 });
3774
3775 let Some(search_view) = cx.read(|cx| {
3776 workspace
3777 .read(cx)
3778 .active_pane()
3779 .read(cx)
3780 .active_item()
3781 .and_then(|item| item.downcast::<ProjectSearchView>())
3782 }) else {
3783 panic!("Search view expected to appear after new search in directory event trigger")
3784 };
3785 cx.background_executor.run_until_parked();
3786 window
3787 .update(cx, |_, window, cx| {
3788 search_view.update(cx, |search_view, cx| {
3789 assert!(
3790 search_view.query_editor.focus_handle(cx).is_focused(window),
3791 "On new search in directory, focus should be moved into query editor"
3792 );
3793 search_view.excluded_files_editor.update(cx, |editor, cx| {
3794 assert!(
3795 editor.display_text(cx).is_empty(),
3796 "New search in directory should not have any excluded files"
3797 );
3798 });
3799 search_view.included_files_editor.update(cx, |editor, cx| {
3800 assert_eq!(
3801 editor.display_text(cx),
3802 a_dir_entry.path.display(PathStyle::local()),
3803 "New search in directory should have included dir entry path"
3804 );
3805 });
3806 });
3807 })
3808 .unwrap();
3809 window
3810 .update(cx, |_, window, cx| {
3811 search_view.update(cx, |search_view, cx| {
3812 search_view.query_editor.update(cx, |query_editor, cx| {
3813 query_editor.set_text("const", window, cx)
3814 });
3815 search_view.search(cx);
3816 });
3817 })
3818 .unwrap();
3819 cx.background_executor.run_until_parked();
3820 window
3821 .update(cx, |_, _, cx| {
3822 search_view.update(cx, |search_view, cx| {
3823 assert_eq!(
3824 search_view
3825 .results_editor
3826 .update(cx, |editor, cx| editor.display_text(cx)),
3827 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3828 "New search in directory should have a filter that matches a certain directory"
3829 );
3830 })
3831 })
3832 .unwrap();
3833 }
3834
3835 #[perf]
3836 #[gpui::test]
3837 async fn test_search_query_history(cx: &mut TestAppContext) {
3838 init_test(cx);
3839
3840 let fs = FakeFs::new(cx.background_executor.clone());
3841 fs.insert_tree(
3842 path!("/dir"),
3843 json!({
3844 "one.rs": "const ONE: usize = 1;",
3845 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3846 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3847 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3848 }),
3849 )
3850 .await;
3851 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3852 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3853 let workspace = window
3854 .read_with(cx, |mw, _| mw.workspace().clone())
3855 .unwrap();
3856 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3857 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3858
3859 workspace.update_in(cx, {
3860 let search_bar = search_bar.clone();
3861 |workspace, window, cx| {
3862 assert_eq!(workspace.panes().len(), 1);
3863 workspace.panes()[0].update(cx, |pane, cx| {
3864 pane.toolbar()
3865 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3866 });
3867
3868 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3869 }
3870 });
3871
3872 let search_view = cx.read(|cx| {
3873 workspace
3874 .read(cx)
3875 .active_pane()
3876 .read(cx)
3877 .active_item()
3878 .and_then(|item| item.downcast::<ProjectSearchView>())
3879 .expect("Search view expected to appear after new search event trigger")
3880 });
3881
3882 // Add 3 search items into the history + another unsubmitted one.
3883 window
3884 .update(cx, |_, window, cx| {
3885 search_view.update(cx, |search_view, cx| {
3886 search_view.search_options = SearchOptions::CASE_SENSITIVE;
3887 search_view.query_editor.update(cx, |query_editor, cx| {
3888 query_editor.set_text("ONE", window, cx)
3889 });
3890 search_view.search(cx);
3891 });
3892 })
3893 .unwrap();
3894
3895 cx.background_executor.run_until_parked();
3896 window
3897 .update(cx, |_, window, cx| {
3898 search_view.update(cx, |search_view, cx| {
3899 search_view.query_editor.update(cx, |query_editor, cx| {
3900 query_editor.set_text("TWO", window, cx)
3901 });
3902 search_view.search(cx);
3903 });
3904 })
3905 .unwrap();
3906 cx.background_executor.run_until_parked();
3907 window
3908 .update(cx, |_, window, cx| {
3909 search_view.update(cx, |search_view, cx| {
3910 search_view.query_editor.update(cx, |query_editor, cx| {
3911 query_editor.set_text("THREE", window, cx)
3912 });
3913 search_view.search(cx);
3914 })
3915 })
3916 .unwrap();
3917 cx.background_executor.run_until_parked();
3918 window
3919 .update(cx, |_, window, cx| {
3920 search_view.update(cx, |search_view, cx| {
3921 search_view.query_editor.update(cx, |query_editor, cx| {
3922 query_editor.set_text("JUST_TEXT_INPUT", window, cx)
3923 });
3924 })
3925 })
3926 .unwrap();
3927 cx.background_executor.run_until_parked();
3928
3929 // Ensure that the latest input with search settings is active.
3930 window
3931 .update(cx, |_, _, cx| {
3932 search_view.update(cx, |search_view, cx| {
3933 assert_eq!(
3934 search_view.query_editor.read(cx).text(cx),
3935 "JUST_TEXT_INPUT"
3936 );
3937 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3938 });
3939 })
3940 .unwrap();
3941
3942 // Next history query after the latest should preserve the current query.
3943 window
3944 .update(cx, |_, window, cx| {
3945 search_bar.update(cx, |search_bar, cx| {
3946 search_bar.focus_search(window, cx);
3947 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3948 })
3949 })
3950 .unwrap();
3951 window
3952 .update(cx, |_, _, cx| {
3953 search_view.update(cx, |search_view, cx| {
3954 assert_eq!(
3955 search_view.query_editor.read(cx).text(cx),
3956 "JUST_TEXT_INPUT"
3957 );
3958 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3959 });
3960 })
3961 .unwrap();
3962 window
3963 .update(cx, |_, window, cx| {
3964 search_bar.update(cx, |search_bar, cx| {
3965 search_bar.focus_search(window, cx);
3966 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3967 })
3968 })
3969 .unwrap();
3970 window
3971 .update(cx, |_, _, cx| {
3972 search_view.update(cx, |search_view, cx| {
3973 assert_eq!(
3974 search_view.query_editor.read(cx).text(cx),
3975 "JUST_TEXT_INPUT"
3976 );
3977 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3978 });
3979 })
3980 .unwrap();
3981
3982 // Previous query should navigate backwards through history.
3983 window
3984 .update(cx, |_, window, cx| {
3985 search_bar.update(cx, |search_bar, cx| {
3986 search_bar.focus_search(window, cx);
3987 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3988 });
3989 })
3990 .unwrap();
3991 window
3992 .update(cx, |_, _, cx| {
3993 search_view.update(cx, |search_view, cx| {
3994 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3995 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3996 });
3997 })
3998 .unwrap();
3999
4000 // Further previous items should go over the history in reverse order.
4001 window
4002 .update(cx, |_, window, cx| {
4003 search_bar.update(cx, |search_bar, cx| {
4004 search_bar.focus_search(window, cx);
4005 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4006 });
4007 })
4008 .unwrap();
4009 window
4010 .update(cx, |_, _, cx| {
4011 search_view.update(cx, |search_view, cx| {
4012 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
4013 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4014 });
4015 })
4016 .unwrap();
4017
4018 // Previous items should never go behind the first history item.
4019 window
4020 .update(cx, |_, window, cx| {
4021 search_bar.update(cx, |search_bar, cx| {
4022 search_bar.focus_search(window, cx);
4023 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4024 });
4025 })
4026 .unwrap();
4027 window
4028 .update(cx, |_, _, cx| {
4029 search_view.update(cx, |search_view, cx| {
4030 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
4031 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4032 });
4033 })
4034 .unwrap();
4035 window
4036 .update(cx, |_, window, cx| {
4037 search_bar.update(cx, |search_bar, cx| {
4038 search_bar.focus_search(window, cx);
4039 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4040 });
4041 })
4042 .unwrap();
4043 window
4044 .update(cx, |_, _, cx| {
4045 search_view.update(cx, |search_view, cx| {
4046 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
4047 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4048 });
4049 })
4050 .unwrap();
4051
4052 // Next items should go over the history in the original order.
4053 window
4054 .update(cx, |_, window, cx| {
4055 search_bar.update(cx, |search_bar, cx| {
4056 search_bar.focus_search(window, cx);
4057 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4058 });
4059 })
4060 .unwrap();
4061 window
4062 .update(cx, |_, _, cx| {
4063 search_view.update(cx, |search_view, cx| {
4064 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
4065 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4066 });
4067 })
4068 .unwrap();
4069
4070 window
4071 .update(cx, |_, window, cx| {
4072 search_view.update(cx, |search_view, cx| {
4073 search_view.query_editor.update(cx, |query_editor, cx| {
4074 query_editor.set_text("TWO_NEW", window, cx)
4075 });
4076 search_view.search(cx);
4077 });
4078 })
4079 .unwrap();
4080 cx.background_executor.run_until_parked();
4081 window
4082 .update(cx, |_, _, cx| {
4083 search_view.update(cx, |search_view, cx| {
4084 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
4085 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4086 });
4087 })
4088 .unwrap();
4089
4090 // New search input should add another entry to history and move the selection to the end of the history.
4091 window
4092 .update(cx, |_, window, cx| {
4093 search_bar.update(cx, |search_bar, cx| {
4094 search_bar.focus_search(window, cx);
4095 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4096 });
4097 })
4098 .unwrap();
4099 window
4100 .update(cx, |_, _, cx| {
4101 search_view.update(cx, |search_view, cx| {
4102 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
4103 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4104 });
4105 })
4106 .unwrap();
4107 window
4108 .update(cx, |_, window, cx| {
4109 search_bar.update(cx, |search_bar, cx| {
4110 search_bar.focus_search(window, cx);
4111 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4112 });
4113 })
4114 .unwrap();
4115 window
4116 .update(cx, |_, _, cx| {
4117 search_view.update(cx, |search_view, cx| {
4118 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
4119 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4120 });
4121 })
4122 .unwrap();
4123 window
4124 .update(cx, |_, window, cx| {
4125 search_bar.update(cx, |search_bar, cx| {
4126 search_bar.focus_search(window, cx);
4127 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4128 });
4129 })
4130 .unwrap();
4131 window
4132 .update(cx, |_, _, cx| {
4133 search_view.update(cx, |search_view, cx| {
4134 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
4135 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4136 });
4137 })
4138 .unwrap();
4139 window
4140 .update(cx, |_, window, cx| {
4141 search_bar.update(cx, |search_bar, cx| {
4142 search_bar.focus_search(window, cx);
4143 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4144 });
4145 })
4146 .unwrap();
4147 window
4148 .update(cx, |_, _, cx| {
4149 search_view.update(cx, |search_view, cx| {
4150 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
4151 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4152 });
4153 })
4154 .unwrap();
4155 window
4156 .update(cx, |_, window, cx| {
4157 search_bar.update(cx, |search_bar, cx| {
4158 search_bar.focus_search(window, cx);
4159 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4160 });
4161 })
4162 .unwrap();
4163 window
4164 .update(cx, |_, _, cx| {
4165 search_view.update(cx, |search_view, cx| {
4166 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
4167 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4168 });
4169 })
4170 .unwrap();
4171
4172 // Typing text without running a search, then navigating history, should allow
4173 // restoring the draft when pressing next past the end.
4174 window
4175 .update(cx, |_, window, cx| {
4176 search_view.update(cx, |search_view, cx| {
4177 search_view.query_editor.update(cx, |query_editor, cx| {
4178 query_editor.set_text("unsaved draft", window, cx)
4179 });
4180 })
4181 })
4182 .unwrap();
4183 cx.background_executor.run_until_parked();
4184
4185 // Navigate up into history — the draft should be stashed.
4186 window
4187 .update(cx, |_, window, cx| {
4188 search_bar.update(cx, |search_bar, cx| {
4189 search_bar.focus_search(window, cx);
4190 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4191 });
4192 })
4193 .unwrap();
4194 window
4195 .update(cx, |_, _, cx| {
4196 search_view.update(cx, |search_view, cx| {
4197 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
4198 });
4199 })
4200 .unwrap();
4201
4202 // Navigate forward through history.
4203 window
4204 .update(cx, |_, window, cx| {
4205 search_bar.update(cx, |search_bar, cx| {
4206 search_bar.focus_search(window, cx);
4207 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4208 });
4209 })
4210 .unwrap();
4211 window
4212 .update(cx, |_, _, cx| {
4213 search_view.update(cx, |search_view, cx| {
4214 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
4215 });
4216 })
4217 .unwrap();
4218
4219 // Navigate past the end — the draft should be restored.
4220 window
4221 .update(cx, |_, window, cx| {
4222 search_bar.update(cx, |search_bar, cx| {
4223 search_bar.focus_search(window, cx);
4224 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4225 });
4226 })
4227 .unwrap();
4228 window
4229 .update(cx, |_, _, cx| {
4230 search_view.update(cx, |search_view, cx| {
4231 assert_eq!(search_view.query_editor.read(cx).text(cx), "unsaved draft");
4232 });
4233 })
4234 .unwrap();
4235 }
4236
4237 #[perf]
4238 #[gpui::test]
4239 async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) {
4240 init_test(cx);
4241
4242 let fs = FakeFs::new(cx.background_executor.clone());
4243 fs.insert_tree(
4244 path!("/dir"),
4245 json!({
4246 "one.rs": "const ONE: usize = 1;",
4247 }),
4248 )
4249 .await;
4250 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4251 let worktree_id = project.update(cx, |this, cx| {
4252 this.worktrees(cx).next().unwrap().read(cx).id()
4253 });
4254
4255 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
4256 let workspace = window
4257 .read_with(cx, |mw, _| mw.workspace().clone())
4258 .unwrap();
4259 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4260
4261 let panes: Vec<_> = workspace.update_in(cx, |this, _, _| this.panes().to_owned());
4262
4263 let search_bar_1 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
4264 let search_bar_2 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
4265
4266 assert_eq!(panes.len(), 1);
4267 let first_pane = panes.first().cloned().unwrap();
4268 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 0);
4269 workspace
4270 .update_in(cx, |workspace, window, cx| {
4271 workspace.open_path(
4272 (worktree_id, rel_path("one.rs")),
4273 Some(first_pane.downgrade()),
4274 true,
4275 window,
4276 cx,
4277 )
4278 })
4279 .await
4280 .unwrap();
4281 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 1);
4282
4283 // Add a project search item to the first pane
4284 workspace.update_in(cx, {
4285 let search_bar = search_bar_1.clone();
4286 |workspace, window, cx| {
4287 first_pane.update(cx, |pane, cx| {
4288 pane.toolbar()
4289 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4290 });
4291
4292 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4293 }
4294 });
4295 let search_view_1 = cx.read(|cx| {
4296 workspace
4297 .read(cx)
4298 .active_item(cx)
4299 .and_then(|item| item.downcast::<ProjectSearchView>())
4300 .expect("Search view expected to appear after new search event trigger")
4301 });
4302
4303 let second_pane = workspace
4304 .update_in(cx, |workspace, window, cx| {
4305 workspace.split_and_clone(
4306 first_pane.clone(),
4307 workspace::SplitDirection::Right,
4308 window,
4309 cx,
4310 )
4311 })
4312 .await
4313 .unwrap();
4314 assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 1);
4315
4316 assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 1);
4317 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 2);
4318
4319 // Add a project search item to the second pane
4320 workspace.update_in(cx, {
4321 let search_bar = search_bar_2.clone();
4322 let pane = second_pane.clone();
4323 move |workspace, window, cx| {
4324 assert_eq!(workspace.panes().len(), 2);
4325 pane.update(cx, |pane, cx| {
4326 pane.toolbar()
4327 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4328 });
4329
4330 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4331 }
4332 });
4333
4334 let search_view_2 = cx.read(|cx| {
4335 workspace
4336 .read(cx)
4337 .active_item(cx)
4338 .and_then(|item| item.downcast::<ProjectSearchView>())
4339 .expect("Search view expected to appear after new search event trigger")
4340 });
4341
4342 cx.run_until_parked();
4343 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 2);
4344 assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 2);
4345
4346 let update_search_view =
4347 |search_view: &Entity<ProjectSearchView>, query: &str, cx: &mut TestAppContext| {
4348 window
4349 .update(cx, |_, window, cx| {
4350 search_view.update(cx, |search_view, cx| {
4351 search_view.query_editor.update(cx, |query_editor, cx| {
4352 query_editor.set_text(query, window, cx)
4353 });
4354 search_view.search(cx);
4355 });
4356 })
4357 .unwrap();
4358 };
4359
4360 let active_query =
4361 |search_view: &Entity<ProjectSearchView>, cx: &mut TestAppContext| -> String {
4362 window
4363 .update(cx, |_, _, cx| {
4364 search_view.update(cx, |search_view, cx| {
4365 search_view.query_editor.read(cx).text(cx)
4366 })
4367 })
4368 .unwrap()
4369 };
4370
4371 let select_prev_history_item =
4372 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
4373 window
4374 .update(cx, |_, window, cx| {
4375 search_bar.update(cx, |search_bar, cx| {
4376 search_bar.focus_search(window, cx);
4377 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4378 })
4379 })
4380 .unwrap();
4381 };
4382
4383 let select_next_history_item =
4384 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
4385 window
4386 .update(cx, |_, window, cx| {
4387 search_bar.update(cx, |search_bar, cx| {
4388 search_bar.focus_search(window, cx);
4389 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4390 })
4391 })
4392 .unwrap();
4393 };
4394
4395 update_search_view(&search_view_1, "ONE", cx);
4396 cx.background_executor.run_until_parked();
4397
4398 update_search_view(&search_view_2, "TWO", cx);
4399 cx.background_executor.run_until_parked();
4400
4401 assert_eq!(active_query(&search_view_1, cx), "ONE");
4402 assert_eq!(active_query(&search_view_2, cx), "TWO");
4403
4404 // Selecting previous history item should select the query from search view 1.
4405 select_prev_history_item(&search_bar_2, cx);
4406 assert_eq!(active_query(&search_view_2, cx), "ONE");
4407
4408 // Selecting the previous history item should not change the query as it is already the first item.
4409 select_prev_history_item(&search_bar_2, cx);
4410 assert_eq!(active_query(&search_view_2, cx), "ONE");
4411
4412 // Changing the query in search view 2 should not affect the history of search view 1.
4413 assert_eq!(active_query(&search_view_1, cx), "ONE");
4414
4415 // Deploying a new search in search view 2
4416 update_search_view(&search_view_2, "THREE", cx);
4417 cx.background_executor.run_until_parked();
4418
4419 select_next_history_item(&search_bar_2, cx);
4420 assert_eq!(active_query(&search_view_2, cx), "THREE");
4421
4422 select_prev_history_item(&search_bar_2, cx);
4423 assert_eq!(active_query(&search_view_2, cx), "TWO");
4424
4425 select_prev_history_item(&search_bar_2, cx);
4426 assert_eq!(active_query(&search_view_2, cx), "ONE");
4427
4428 select_prev_history_item(&search_bar_2, cx);
4429 assert_eq!(active_query(&search_view_2, cx), "ONE");
4430
4431 select_prev_history_item(&search_bar_2, cx);
4432 assert_eq!(active_query(&search_view_2, cx), "ONE");
4433
4434 // Search view 1 should now see the query from search view 2.
4435 assert_eq!(active_query(&search_view_1, cx), "ONE");
4436
4437 select_next_history_item(&search_bar_2, cx);
4438 assert_eq!(active_query(&search_view_2, cx), "TWO");
4439
4440 // Here is the new query from search view 2
4441 select_next_history_item(&search_bar_2, cx);
4442 assert_eq!(active_query(&search_view_2, cx), "THREE");
4443
4444 select_next_history_item(&search_bar_2, cx);
4445 assert_eq!(active_query(&search_view_2, cx), "THREE");
4446
4447 select_next_history_item(&search_bar_1, cx);
4448 assert_eq!(active_query(&search_view_1, cx), "TWO");
4449
4450 select_next_history_item(&search_bar_1, cx);
4451 assert_eq!(active_query(&search_view_1, cx), "THREE");
4452
4453 select_next_history_item(&search_bar_1, cx);
4454 assert_eq!(active_query(&search_view_1, cx), "THREE");
4455 }
4456
4457 #[perf]
4458 #[gpui::test]
4459 async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
4460 init_test(cx);
4461
4462 // Setup 2 panes, both with a file open and one with a project search.
4463 let fs = FakeFs::new(cx.background_executor.clone());
4464 fs.insert_tree(
4465 path!("/dir"),
4466 json!({
4467 "one.rs": "const ONE: usize = 1;",
4468 }),
4469 )
4470 .await;
4471 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4472 let worktree_id = project.update(cx, |this, cx| {
4473 this.worktrees(cx).next().unwrap().read(cx).id()
4474 });
4475 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
4476 let workspace = window
4477 .read_with(cx, |mw, _| mw.workspace().clone())
4478 .unwrap();
4479 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4480 let panes: Vec<_> = workspace.update_in(cx, |this, _, _| this.panes().to_owned());
4481 assert_eq!(panes.len(), 1);
4482 let first_pane = panes.first().cloned().unwrap();
4483 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 0);
4484 workspace
4485 .update_in(cx, |workspace, window, cx| {
4486 workspace.open_path(
4487 (worktree_id, rel_path("one.rs")),
4488 Some(first_pane.downgrade()),
4489 true,
4490 window,
4491 cx,
4492 )
4493 })
4494 .await
4495 .unwrap();
4496 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 1);
4497 let second_pane = workspace
4498 .update_in(cx, |workspace, window, cx| {
4499 workspace.split_and_clone(
4500 first_pane.clone(),
4501 workspace::SplitDirection::Right,
4502 window,
4503 cx,
4504 )
4505 })
4506 .await
4507 .unwrap();
4508 assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 1);
4509 assert!(
4510 window
4511 .update(cx, |_, window, cx| second_pane
4512 .focus_handle(cx)
4513 .contains_focused(window, cx))
4514 .unwrap()
4515 );
4516 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
4517 workspace.update_in(cx, {
4518 let search_bar = search_bar.clone();
4519 let pane = first_pane.clone();
4520 move |workspace, window, cx| {
4521 assert_eq!(workspace.panes().len(), 2);
4522 pane.update(cx, move |pane, cx| {
4523 pane.toolbar()
4524 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4525 });
4526 }
4527 });
4528
4529 // Add a project search item to the second pane
4530 workspace.update_in(cx, {
4531 |workspace, window, cx| {
4532 assert_eq!(workspace.panes().len(), 2);
4533 second_pane.update(cx, |pane, cx| {
4534 pane.toolbar()
4535 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4536 });
4537
4538 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4539 }
4540 });
4541
4542 cx.run_until_parked();
4543 assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 2);
4544 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 1);
4545
4546 // Focus the first pane
4547 workspace.update_in(cx, |workspace, window, cx| {
4548 assert_eq!(workspace.active_pane(), &second_pane);
4549 second_pane.update(cx, |this, cx| {
4550 assert_eq!(this.active_item_index(), 1);
4551 this.activate_previous_item(&Default::default(), window, cx);
4552 assert_eq!(this.active_item_index(), 0);
4553 });
4554 workspace.activate_pane_in_direction(workspace::SplitDirection::Left, window, cx);
4555 });
4556 workspace.update_in(cx, |workspace, _, cx| {
4557 assert_eq!(workspace.active_pane(), &first_pane);
4558 assert_eq!(first_pane.read(cx).items_len(), 1);
4559 assert_eq!(second_pane.read(cx).items_len(), 2);
4560 });
4561
4562 // Deploy a new search
4563 cx.dispatch_action(DeploySearch::find());
4564
4565 // Both panes should now have a project search in them
4566 workspace.update_in(cx, |workspace, window, cx| {
4567 assert_eq!(workspace.active_pane(), &first_pane);
4568 first_pane.read_with(cx, |this, _| {
4569 assert_eq!(this.active_item_index(), 1);
4570 assert_eq!(this.items_len(), 2);
4571 });
4572 second_pane.update(cx, |this, cx| {
4573 assert!(!cx.focus_handle().contains_focused(window, cx));
4574 assert_eq!(this.items_len(), 2);
4575 });
4576 });
4577
4578 // Focus the second pane's non-search item
4579 window
4580 .update(cx, |_workspace, window, cx| {
4581 second_pane.update(cx, |pane, cx| {
4582 pane.activate_next_item(&Default::default(), window, cx)
4583 });
4584 })
4585 .unwrap();
4586
4587 // Deploy a new search
4588 cx.dispatch_action(DeploySearch::find());
4589
4590 // The project search view should now be focused in the second pane
4591 // And the number of items should be unchanged.
4592 window
4593 .update(cx, |_workspace, _, cx| {
4594 second_pane.update(cx, |pane, _cx| {
4595 assert!(
4596 pane.active_item()
4597 .unwrap()
4598 .downcast::<ProjectSearchView>()
4599 .is_some()
4600 );
4601
4602 assert_eq!(pane.items_len(), 2);
4603 });
4604 })
4605 .unwrap();
4606 }
4607
4608 #[perf]
4609 #[gpui::test]
4610 async fn test_scroll_search_results_to_top(cx: &mut TestAppContext) {
4611 init_test(cx);
4612
4613 // We need many lines in the search results to be able to scroll the window
4614 let fs = FakeFs::new(cx.background_executor.clone());
4615 fs.insert_tree(
4616 path!("/dir"),
4617 json!({
4618 "1.txt": "\n\n\n\n\n A \n\n\n\n\n",
4619 "2.txt": "\n\n\n\n\n A \n\n\n\n\n",
4620 "3.rs": "\n\n\n\n\n A \n\n\n\n\n",
4621 "4.rs": "\n\n\n\n\n A \n\n\n\n\n",
4622 "5.rs": "\n\n\n\n\n A \n\n\n\n\n",
4623 "6.rs": "\n\n\n\n\n A \n\n\n\n\n",
4624 "7.rs": "\n\n\n\n\n A \n\n\n\n\n",
4625 "8.rs": "\n\n\n\n\n A \n\n\n\n\n",
4626 "9.rs": "\n\n\n\n\n A \n\n\n\n\n",
4627 "a.rs": "\n\n\n\n\n A \n\n\n\n\n",
4628 "b.rs": "\n\n\n\n\n B \n\n\n\n\n",
4629 "c.rs": "\n\n\n\n\n B \n\n\n\n\n",
4630 "d.rs": "\n\n\n\n\n B \n\n\n\n\n",
4631 "e.rs": "\n\n\n\n\n B \n\n\n\n\n",
4632 "f.rs": "\n\n\n\n\n B \n\n\n\n\n",
4633 "g.rs": "\n\n\n\n\n B \n\n\n\n\n",
4634 "h.rs": "\n\n\n\n\n B \n\n\n\n\n",
4635 "i.rs": "\n\n\n\n\n B \n\n\n\n\n",
4636 "j.rs": "\n\n\n\n\n B \n\n\n\n\n",
4637 "k.rs": "\n\n\n\n\n B \n\n\n\n\n",
4638 }),
4639 )
4640 .await;
4641 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4642 let window =
4643 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4644 let workspace = window
4645 .read_with(cx, |mw, _| mw.workspace().clone())
4646 .unwrap();
4647 let search = cx.new(|cx| ProjectSearch::new(project, cx));
4648 let search_view = cx.add_window(|window, cx| {
4649 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
4650 });
4651
4652 // First search
4653 perform_search(search_view, "A", cx);
4654 search_view
4655 .update(cx, |search_view, window, cx| {
4656 search_view.results_editor.update(cx, |results_editor, cx| {
4657 // Results are correct and scrolled to the top
4658 assert_eq!(
4659 results_editor.display_text(cx).match_indices(" A ").count(),
4660 10
4661 );
4662 assert_eq!(results_editor.scroll_position(cx), Point::default());
4663
4664 // Scroll results all the way down
4665 results_editor.scroll(
4666 Point::new(0., f64::MAX),
4667 Some(Axis::Vertical),
4668 window,
4669 cx,
4670 );
4671 });
4672 })
4673 .expect("unable to update search view");
4674
4675 // Second search
4676 perform_search(search_view, "B", cx);
4677 search_view
4678 .update(cx, |search_view, _, cx| {
4679 search_view.results_editor.update(cx, |results_editor, cx| {
4680 // Results are correct...
4681 assert_eq!(
4682 results_editor.display_text(cx).match_indices(" B ").count(),
4683 10
4684 );
4685 // ...and scrolled back to the top
4686 assert_eq!(results_editor.scroll_position(cx), Point::default());
4687 });
4688 })
4689 .expect("unable to update search view");
4690 }
4691
4692 #[perf]
4693 #[gpui::test]
4694 async fn test_buffer_search_query_reused(cx: &mut TestAppContext) {
4695 init_test(cx);
4696
4697 let fs = FakeFs::new(cx.background_executor.clone());
4698 fs.insert_tree(
4699 path!("/dir"),
4700 json!({
4701 "one.rs": "const ONE: usize = 1;",
4702 }),
4703 )
4704 .await;
4705 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4706 let worktree_id = project.update(cx, |this, cx| {
4707 this.worktrees(cx).next().unwrap().read(cx).id()
4708 });
4709 let window =
4710 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4711 let workspace = window
4712 .read_with(cx, |mw, _| mw.workspace().clone())
4713 .unwrap();
4714 let mut cx = VisualTestContext::from_window(window.into(), cx);
4715
4716 let editor = workspace
4717 .update_in(&mut cx, |workspace, window, cx| {
4718 workspace.open_path((worktree_id, rel_path("one.rs")), None, true, window, cx)
4719 })
4720 .await
4721 .unwrap()
4722 .downcast::<Editor>()
4723 .unwrap();
4724
4725 // Wait for the unstaged changes to be loaded
4726 cx.run_until_parked();
4727
4728 let buffer_search_bar = cx.new_window_entity(|window, cx| {
4729 let mut search_bar =
4730 BufferSearchBar::new(Some(project.read(cx).languages().clone()), window, cx);
4731 search_bar.set_active_pane_item(Some(&editor), window, cx);
4732 search_bar.show(window, cx);
4733 search_bar
4734 });
4735
4736 let panes: Vec<_> = workspace.update_in(&mut cx, |this, _, _| this.panes().to_owned());
4737 assert_eq!(panes.len(), 1);
4738 let pane = panes.first().cloned().unwrap();
4739 pane.update_in(&mut cx, |pane, window, cx| {
4740 pane.toolbar().update(cx, |toolbar, cx| {
4741 toolbar.add_item(buffer_search_bar.clone(), window, cx);
4742 })
4743 });
4744
4745 let buffer_search_query = "search bar query";
4746 buffer_search_bar
4747 .update_in(&mut cx, |buffer_search_bar, window, cx| {
4748 buffer_search_bar.focus_handle(cx).focus(window, cx);
4749 buffer_search_bar.search(buffer_search_query, None, true, window, cx)
4750 })
4751 .await
4752 .unwrap();
4753
4754 workspace.update_in(&mut cx, |workspace, window, cx| {
4755 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4756 });
4757 cx.run_until_parked();
4758 let project_search_view = pane
4759 .read_with(&cx, |pane, _| {
4760 pane.active_item()
4761 .and_then(|item| item.downcast::<ProjectSearchView>())
4762 })
4763 .expect("should open a project search view after spawning a new search");
4764 project_search_view.update(&mut cx, |search_view, cx| {
4765 assert_eq!(
4766 search_view.search_query_text(cx),
4767 buffer_search_query,
4768 "Project search should take the query from the buffer search bar since it got focused and had a query inside"
4769 );
4770 });
4771 }
4772
4773 #[gpui::test]
4774 async fn test_search_dismisses_modal(cx: &mut TestAppContext) {
4775 init_test(cx);
4776
4777 let fs = FakeFs::new(cx.background_executor.clone());
4778 fs.insert_tree(
4779 path!("/dir"),
4780 json!({
4781 "one.rs": "const ONE: usize = 1;",
4782 }),
4783 )
4784 .await;
4785 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4786 let window =
4787 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4788 let workspace = window
4789 .read_with(cx, |mw, _| mw.workspace().clone())
4790 .unwrap();
4791 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4792
4793 struct EmptyModalView {
4794 focus_handle: gpui::FocusHandle,
4795 }
4796 impl EventEmitter<gpui::DismissEvent> for EmptyModalView {}
4797 impl Render for EmptyModalView {
4798 fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement {
4799 div()
4800 }
4801 }
4802 impl Focusable for EmptyModalView {
4803 fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
4804 self.focus_handle.clone()
4805 }
4806 }
4807 impl workspace::ModalView for EmptyModalView {}
4808
4809 workspace.update_in(cx, |workspace, window, cx| {
4810 workspace.toggle_modal(window, cx, |_, cx| EmptyModalView {
4811 focus_handle: cx.focus_handle(),
4812 });
4813 assert!(workspace.has_active_modal(window, cx));
4814 });
4815
4816 cx.dispatch_action(Deploy::find());
4817
4818 workspace.update_in(cx, |workspace, window, cx| {
4819 assert!(!workspace.has_active_modal(window, cx));
4820 workspace.toggle_modal(window, cx, |_, cx| EmptyModalView {
4821 focus_handle: cx.focus_handle(),
4822 });
4823 assert!(workspace.has_active_modal(window, cx));
4824 });
4825
4826 cx.dispatch_action(DeploySearch::find());
4827
4828 workspace.update_in(cx, |workspace, window, cx| {
4829 assert!(!workspace.has_active_modal(window, cx));
4830 });
4831 }
4832
4833 #[perf]
4834 #[gpui::test]
4835 async fn test_search_with_inlays(cx: &mut TestAppContext) {
4836 init_test(cx);
4837 cx.update(|cx| {
4838 SettingsStore::update_global(cx, |store, cx| {
4839 store.update_user_settings(cx, |settings| {
4840 settings.project.all_languages.defaults.inlay_hints =
4841 Some(InlayHintSettingsContent {
4842 enabled: Some(true),
4843 ..InlayHintSettingsContent::default()
4844 })
4845 });
4846 });
4847 });
4848
4849 let fs = FakeFs::new(cx.background_executor.clone());
4850 fs.insert_tree(
4851 path!("/dir"),
4852 // `\n` , a trailing line on the end, is important for the test case
4853 json!({
4854 "main.rs": "fn main() { let a = 2; }\n",
4855 }),
4856 )
4857 .await;
4858
4859 let requests_count = Arc::new(AtomicUsize::new(0));
4860 let closure_requests_count = requests_count.clone();
4861 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4862 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
4863 let language = rust_lang();
4864 language_registry.add(language);
4865 let mut fake_servers = language_registry.register_fake_lsp(
4866 "Rust",
4867 FakeLspAdapter {
4868 capabilities: lsp::ServerCapabilities {
4869 inlay_hint_provider: Some(lsp::OneOf::Left(true)),
4870 ..lsp::ServerCapabilities::default()
4871 },
4872 initializer: Some(Box::new(move |fake_server| {
4873 let requests_count = closure_requests_count.clone();
4874 fake_server.set_request_handler::<lsp::request::InlayHintRequest, _, _>({
4875 move |_, _| {
4876 let requests_count = requests_count.clone();
4877 async move {
4878 requests_count.fetch_add(1, atomic::Ordering::Release);
4879 Ok(Some(vec![lsp::InlayHint {
4880 position: lsp::Position::new(0, 17),
4881 label: lsp::InlayHintLabel::String(": i32".to_owned()),
4882 kind: Some(lsp::InlayHintKind::TYPE),
4883 text_edits: None,
4884 tooltip: None,
4885 padding_left: None,
4886 padding_right: None,
4887 data: None,
4888 }]))
4889 }
4890 }
4891 });
4892 })),
4893 ..FakeLspAdapter::default()
4894 },
4895 );
4896
4897 let window =
4898 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4899 let workspace = window
4900 .read_with(cx, |mw, _| mw.workspace().clone())
4901 .unwrap();
4902 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4903 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
4904 let search_view = cx.add_window(|window, cx| {
4905 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
4906 });
4907
4908 perform_search(search_view, "let ", cx);
4909 let fake_server = fake_servers.next().await.unwrap();
4910 cx.executor().advance_clock(Duration::from_secs(1));
4911 cx.executor().run_until_parked();
4912 search_view
4913 .update(cx, |search_view, _, cx| {
4914 assert_eq!(
4915 search_view
4916 .results_editor
4917 .update(cx, |editor, cx| editor.display_text(cx)),
4918 "\n\nfn main() { let a: i32 = 2; }\n"
4919 );
4920 })
4921 .unwrap();
4922 assert_eq!(
4923 requests_count.load(atomic::Ordering::Acquire),
4924 1,
4925 "New hints should have been queried",
4926 );
4927
4928 // Can do the 2nd search without any panics
4929 perform_search(search_view, "let ", cx);
4930 cx.executor().advance_clock(Duration::from_secs(1));
4931 cx.executor().run_until_parked();
4932 search_view
4933 .update(cx, |search_view, _, cx| {
4934 assert_eq!(
4935 search_view
4936 .results_editor
4937 .update(cx, |editor, cx| editor.display_text(cx)),
4938 "\n\nfn main() { let a: i32 = 2; }\n"
4939 );
4940 })
4941 .unwrap();
4942 assert_eq!(
4943 requests_count.load(atomic::Ordering::Acquire),
4944 2,
4945 "We did drop the previous buffer when cleared the old project search results, hence another query was made",
4946 );
4947
4948 let singleton_editor = workspace
4949 .update_in(cx, |workspace, window, cx| {
4950 workspace.open_abs_path(
4951 PathBuf::from(path!("/dir/main.rs")),
4952 workspace::OpenOptions::default(),
4953 window,
4954 cx,
4955 )
4956 })
4957 .await
4958 .unwrap()
4959 .downcast::<Editor>()
4960 .unwrap();
4961 cx.executor().advance_clock(Duration::from_millis(100));
4962 cx.executor().run_until_parked();
4963 singleton_editor.update(cx, |editor, cx| {
4964 assert_eq!(
4965 editor.display_text(cx),
4966 "fn main() { let a: i32 = 2; }\n",
4967 "Newly opened editor should have the correct text with hints",
4968 );
4969 });
4970 assert_eq!(
4971 requests_count.load(atomic::Ordering::Acquire),
4972 2,
4973 "Opening the same buffer again should reuse the cached hints",
4974 );
4975
4976 window
4977 .update(cx, |_, window, cx| {
4978 singleton_editor.update(cx, |editor, cx| {
4979 editor.handle_input("test", window, cx);
4980 });
4981 })
4982 .unwrap();
4983
4984 cx.executor().advance_clock(Duration::from_secs(1));
4985 cx.executor().run_until_parked();
4986 singleton_editor.update(cx, |editor, cx| {
4987 assert_eq!(
4988 editor.display_text(cx),
4989 "testfn main() { l: i32et a = 2; }\n",
4990 "Newly opened editor should have the correct text with hints",
4991 );
4992 });
4993 assert_eq!(
4994 requests_count.load(atomic::Ordering::Acquire),
4995 3,
4996 "We have edited the buffer and should send a new request",
4997 );
4998
4999 window
5000 .update(cx, |_, window, cx| {
5001 singleton_editor.update(cx, |editor, cx| {
5002 editor.undo(&editor::actions::Undo, window, cx);
5003 });
5004 })
5005 .unwrap();
5006 cx.executor().advance_clock(Duration::from_secs(1));
5007 cx.executor().run_until_parked();
5008 assert_eq!(
5009 requests_count.load(atomic::Ordering::Acquire),
5010 4,
5011 "We have edited the buffer again and should send a new request again",
5012 );
5013 singleton_editor.update(cx, |editor, cx| {
5014 assert_eq!(
5015 editor.display_text(cx),
5016 "fn main() { let a: i32 = 2; }\n",
5017 "Newly opened editor should have the correct text with hints",
5018 );
5019 });
5020 project.update(cx, |_, cx| {
5021 cx.emit(project::Event::RefreshInlayHints {
5022 server_id: fake_server.server.server_id(),
5023 request_id: Some(1),
5024 });
5025 });
5026 cx.executor().advance_clock(Duration::from_secs(1));
5027 cx.executor().run_until_parked();
5028 assert_eq!(
5029 requests_count.load(atomic::Ordering::Acquire),
5030 5,
5031 "After a simulated server refresh request, we should have sent another request",
5032 );
5033
5034 perform_search(search_view, "let ", cx);
5035 cx.executor().advance_clock(Duration::from_secs(1));
5036 cx.executor().run_until_parked();
5037 assert_eq!(
5038 requests_count.load(atomic::Ordering::Acquire),
5039 5,
5040 "New project search should reuse the cached hints",
5041 );
5042 search_view
5043 .update(cx, |search_view, _, cx| {
5044 assert_eq!(
5045 search_view
5046 .results_editor
5047 .update(cx, |editor, cx| editor.display_text(cx)),
5048 "\n\nfn main() { let a: i32 = 2; }\n"
5049 );
5050 })
5051 .unwrap();
5052 }
5053
5054 #[gpui::test]
5055 async fn test_deleted_file_removed_from_search_results(cx: &mut TestAppContext) {
5056 init_test(cx);
5057
5058 let fs = FakeFs::new(cx.background_executor.clone());
5059 fs.insert_tree(
5060 path!("/dir"),
5061 json!({
5062 "file_a.txt": "hello world",
5063 "file_b.txt": "hello universe",
5064 }),
5065 )
5066 .await;
5067
5068 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5069 let window =
5070 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5071 let workspace = window
5072 .read_with(cx, |mw, _| mw.workspace().clone())
5073 .unwrap();
5074 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
5075 let search_view = cx.add_window(|window, cx| {
5076 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
5077 });
5078
5079 perform_search(search_view, "hello", cx);
5080
5081 search_view
5082 .update(cx, |search_view, _window, cx| {
5083 let match_count = search_view.entity.read(cx).match_ranges.len();
5084 assert_eq!(match_count, 2, "Should have matches from both files");
5085 })
5086 .unwrap();
5087
5088 // Delete file_b.txt
5089 fs.remove_file(
5090 path!("/dir/file_b.txt").as_ref(),
5091 fs::RemoveOptions::default(),
5092 )
5093 .await
5094 .unwrap();
5095 cx.run_until_parked();
5096
5097 // Verify deleted file's results are removed proactively
5098 search_view
5099 .update(cx, |search_view, _window, cx| {
5100 let results_text = search_view
5101 .results_editor
5102 .update(cx, |editor, cx| editor.display_text(cx));
5103 assert!(
5104 !results_text.contains("universe"),
5105 "Deleted file's content should be removed from results, got: {results_text}"
5106 );
5107 assert!(
5108 results_text.contains("world"),
5109 "Remaining file's content should still be present, got: {results_text}"
5110 );
5111 })
5112 .unwrap();
5113
5114 // Re-run the search and verify deleted file stays gone
5115 perform_search(search_view, "hello", cx);
5116
5117 search_view
5118 .update(cx, |search_view, _window, cx| {
5119 let results_text = search_view
5120 .results_editor
5121 .update(cx, |editor, cx| editor.display_text(cx));
5122 assert!(
5123 !results_text.contains("universe"),
5124 "Deleted file should not reappear after re-search, got: {results_text}"
5125 );
5126 assert!(
5127 results_text.contains("world"),
5128 "Remaining file should still be found, got: {results_text}"
5129 );
5130 assert_eq!(
5131 search_view.entity.read(cx).match_ranges.len(),
5132 1,
5133 "Should only have match from the remaining file"
5134 );
5135 })
5136 .unwrap();
5137 }
5138
5139 fn init_test(cx: &mut TestAppContext) {
5140 cx.update(|cx| {
5141 let settings = SettingsStore::test(cx);
5142 cx.set_global(settings);
5143
5144 theme_settings::init(theme::LoadThemes::JustBase, cx);
5145
5146 editor::init(cx);
5147 crate::init(cx);
5148 });
5149 }
5150
5151 fn perform_search(
5152 search_view: WindowHandle<ProjectSearchView>,
5153 text: impl Into<Arc<str>>,
5154 cx: &mut TestAppContext,
5155 ) {
5156 search_view
5157 .update(cx, |search_view, window, cx| {
5158 search_view.query_editor.update(cx, |query_editor, cx| {
5159 query_editor.set_text(text, window, cx)
5160 });
5161 search_view.search(cx);
5162 })
5163 .unwrap();
5164 // Ensure editor highlights appear after the search is done
5165 cx.executor().advance_clock(
5166 editor::SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(100),
5167 );
5168 cx.background_executor.run_until_parked();
5169 }
5170}