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