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