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