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(
2272 h_flex()
2273 .gap_2()
2274 .w(input_width)
2275 .child(alignment_element())
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), "match"),
2630 // TODO: we should be getting selection highlight here after project search
2631 // but for some reason we are not getting it here
2632 ],
2633 );
2634 select_match(&search_view, cx, Direction::Next);
2635 cx.run_until_parked();
2636
2637 assert_active_match_index(&search_view, cx, 1);
2638 assert_selection_range(&search_view, cx, dp(2, 37)..dp(2, 40));
2639 assert_highlights(
2640 &search_view,
2641 cx,
2642 vec![
2643 (dp(2, 32)..dp(2, 35), "selection"),
2644 (dp(2, 32)..dp(2, 35), "match"),
2645 (dp(2, 37)..dp(2, 40), "active"),
2646 (dp(5, 6)..dp(5, 9), "selection"),
2647 (dp(5, 6)..dp(5, 9), "match"),
2648 ],
2649 );
2650 select_match(&search_view, cx, Direction::Next);
2651 cx.run_until_parked();
2652
2653 assert_active_match_index(&search_view, cx, 2);
2654 assert_selection_range(&search_view, cx, dp(5, 6)..dp(5, 9));
2655 assert_highlights(
2656 &search_view,
2657 cx,
2658 vec![
2659 (dp(2, 32)..dp(2, 35), "selection"),
2660 (dp(2, 32)..dp(2, 35), "match"),
2661 (dp(2, 37)..dp(2, 40), "selection"),
2662 (dp(2, 37)..dp(2, 40), "match"),
2663 (dp(5, 6)..dp(5, 9), "active"),
2664 ],
2665 );
2666 select_match(&search_view, cx, Direction::Next);
2667 cx.run_until_parked();
2668
2669 assert_active_match_index(&search_view, cx, 0);
2670 assert_selection_range(&search_view, cx, dp(2, 32)..dp(2, 35));
2671 assert_highlights(
2672 &search_view,
2673 cx,
2674 vec![
2675 (dp(2, 32)..dp(2, 35), "active"),
2676 (dp(2, 37)..dp(2, 40), "selection"),
2677 (dp(2, 37)..dp(2, 40), "match"),
2678 (dp(5, 6)..dp(5, 9), "selection"),
2679 (dp(5, 6)..dp(5, 9), "match"),
2680 ],
2681 );
2682 select_match(&search_view, cx, Direction::Prev);
2683 cx.run_until_parked();
2684
2685 assert_active_match_index(&search_view, cx, 2);
2686 assert_selection_range(&search_view, cx, dp(5, 6)..dp(5, 9));
2687 assert_highlights(
2688 &search_view,
2689 cx,
2690 vec![
2691 (dp(2, 32)..dp(2, 35), "selection"),
2692 (dp(2, 32)..dp(2, 35), "match"),
2693 (dp(2, 37)..dp(2, 40), "selection"),
2694 (dp(2, 37)..dp(2, 40), "match"),
2695 (dp(5, 6)..dp(5, 9), "active"),
2696 ],
2697 );
2698 select_match(&search_view, cx, Direction::Prev);
2699 cx.run_until_parked();
2700
2701 assert_active_match_index(&search_view, cx, 1);
2702 assert_selection_range(&search_view, cx, dp(2, 37)..dp(2, 40));
2703 assert_highlights(
2704 &search_view,
2705 cx,
2706 vec![
2707 (dp(2, 32)..dp(2, 35), "selection"),
2708 (dp(2, 32)..dp(2, 35), "match"),
2709 (dp(2, 37)..dp(2, 40), "active"),
2710 (dp(5, 6)..dp(5, 9), "selection"),
2711 (dp(5, 6)..dp(5, 9), "match"),
2712 ],
2713 );
2714 search_view
2715 .update(cx, |search_view, window, cx| {
2716 search_view.results_editor.update(cx, |editor, cx| {
2717 editor.fold_all(&FoldAll, window, cx);
2718 })
2719 })
2720 .expect("Should fold fine");
2721
2722 let results_collapsed = search_view
2723 .read_with(cx, |search_view, _| search_view.results_collapsed)
2724 .expect("got results_collapsed");
2725
2726 assert!(results_collapsed);
2727 search_view
2728 .update(cx, |search_view, window, cx| {
2729 search_view.results_editor.update(cx, |editor, cx| {
2730 editor.unfold_all(&UnfoldAll, window, cx);
2731 })
2732 })
2733 .expect("Should unfold fine");
2734
2735 let results_collapsed = search_view
2736 .read_with(cx, |search_view, _| search_view.results_collapsed)
2737 .expect("got results_collapsed");
2738
2739 assert!(!results_collapsed);
2740 }
2741
2742 #[perf]
2743 #[gpui::test]
2744 async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
2745 init_test(cx);
2746
2747 let fs = FakeFs::new(cx.background_executor.clone());
2748 fs.insert_tree(
2749 "/dir",
2750 json!({
2751 "one.rs": "const ONE: usize = 1;",
2752 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2753 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2754 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2755 }),
2756 )
2757 .await;
2758 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2759 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
2760 let workspace = window;
2761 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
2762
2763 let active_item = cx.read(|cx| {
2764 workspace
2765 .read(cx)
2766 .unwrap()
2767 .active_pane()
2768 .read(cx)
2769 .active_item()
2770 .and_then(|item| item.downcast::<ProjectSearchView>())
2771 });
2772 assert!(
2773 active_item.is_none(),
2774 "Expected no search panel to be active"
2775 );
2776
2777 window
2778 .update(cx, move |workspace, window, cx| {
2779 assert_eq!(workspace.panes().len(), 1);
2780 workspace.panes()[0].update(cx, |pane, cx| {
2781 pane.toolbar()
2782 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
2783 });
2784
2785 ProjectSearchView::deploy_search(
2786 workspace,
2787 &workspace::DeploySearch::find(),
2788 window,
2789 cx,
2790 )
2791 })
2792 .unwrap();
2793
2794 let Some(search_view) = cx.read(|cx| {
2795 workspace
2796 .read(cx)
2797 .unwrap()
2798 .active_pane()
2799 .read(cx)
2800 .active_item()
2801 .and_then(|item| item.downcast::<ProjectSearchView>())
2802 }) else {
2803 panic!("Search view expected to appear after new search event trigger")
2804 };
2805
2806 cx.spawn(|mut cx| async move {
2807 window
2808 .update(&mut cx, |_, window, cx| {
2809 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2810 })
2811 .unwrap();
2812 })
2813 .detach();
2814 cx.background_executor.run_until_parked();
2815 window
2816 .update(cx, |_, window, cx| {
2817 search_view.update(cx, |search_view, cx| {
2818 assert!(
2819 search_view.query_editor.focus_handle(cx).is_focused(window),
2820 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2821 );
2822 });
2823 }).unwrap();
2824
2825 window
2826 .update(cx, |_, window, cx| {
2827 search_view.update(cx, |search_view, cx| {
2828 let query_editor = &search_view.query_editor;
2829 assert!(
2830 query_editor.focus_handle(cx).is_focused(window),
2831 "Search view should be focused after the new search view is activated",
2832 );
2833 let query_text = query_editor.read(cx).text(cx);
2834 assert!(
2835 query_text.is_empty(),
2836 "New search query should be empty but got '{query_text}'",
2837 );
2838 let results_text = search_view
2839 .results_editor
2840 .update(cx, |editor, cx| editor.display_text(cx));
2841 assert!(
2842 results_text.is_empty(),
2843 "Empty search view should have no results but got '{results_text}'"
2844 );
2845 });
2846 })
2847 .unwrap();
2848
2849 window
2850 .update(cx, |_, window, cx| {
2851 search_view.update(cx, |search_view, cx| {
2852 search_view.query_editor.update(cx, |query_editor, cx| {
2853 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
2854 });
2855 search_view.search(cx);
2856 });
2857 })
2858 .unwrap();
2859 cx.background_executor.run_until_parked();
2860 window
2861 .update(cx, |_, window, cx| {
2862 search_view.update(cx, |search_view, cx| {
2863 let results_text = search_view
2864 .results_editor
2865 .update(cx, |editor, cx| editor.display_text(cx));
2866 assert!(
2867 results_text.is_empty(),
2868 "Search view for mismatching query should have no results but got '{results_text}'"
2869 );
2870 assert!(
2871 search_view.query_editor.focus_handle(cx).is_focused(window),
2872 "Search view should be focused after mismatching query had been used in search",
2873 );
2874 });
2875 }).unwrap();
2876
2877 cx.spawn(|mut cx| async move {
2878 window.update(&mut cx, |_, window, cx| {
2879 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2880 })
2881 })
2882 .detach();
2883 cx.background_executor.run_until_parked();
2884 window.update(cx, |_, window, cx| {
2885 search_view.update(cx, |search_view, cx| {
2886 assert!(
2887 search_view.query_editor.focus_handle(cx).is_focused(window),
2888 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2889 );
2890 });
2891 }).unwrap();
2892
2893 window
2894 .update(cx, |_, window, cx| {
2895 search_view.update(cx, |search_view, cx| {
2896 search_view.query_editor.update(cx, |query_editor, cx| {
2897 query_editor.set_text("TWO", window, cx)
2898 });
2899 search_view.search(cx);
2900 });
2901 })
2902 .unwrap();
2903 cx.background_executor.run_until_parked();
2904 window.update(cx, |_, window, cx| {
2905 search_view.update(cx, |search_view, cx| {
2906 assert_eq!(
2907 search_view
2908 .results_editor
2909 .update(cx, |editor, cx| editor.display_text(cx)),
2910 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2911 "Search view results should match the query"
2912 );
2913 assert!(
2914 search_view.results_editor.focus_handle(cx).is_focused(window),
2915 "Search view with mismatching query should be focused after search results are available",
2916 );
2917 });
2918 }).unwrap();
2919 cx.spawn(|mut cx| async move {
2920 window
2921 .update(&mut cx, |_, window, cx| {
2922 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2923 })
2924 .unwrap();
2925 })
2926 .detach();
2927 cx.background_executor.run_until_parked();
2928 window.update(cx, |_, window, cx| {
2929 search_view.update(cx, |search_view, cx| {
2930 assert!(
2931 search_view.results_editor.focus_handle(cx).is_focused(window),
2932 "Search view with matching query should still have its results editor focused after the toggle focus event",
2933 );
2934 });
2935 }).unwrap();
2936
2937 workspace
2938 .update(cx, |workspace, window, cx| {
2939 ProjectSearchView::deploy_search(
2940 workspace,
2941 &workspace::DeploySearch::find(),
2942 window,
2943 cx,
2944 )
2945 })
2946 .unwrap();
2947 window.update(cx, |_, window, cx| {
2948 search_view.update(cx, |search_view, cx| {
2949 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");
2950 assert_eq!(
2951 search_view
2952 .results_editor
2953 .update(cx, |editor, cx| editor.display_text(cx)),
2954 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2955 "Results should be unchanged after search view 2nd open in a row"
2956 );
2957 assert!(
2958 search_view.query_editor.focus_handle(cx).is_focused(window),
2959 "Focus should be moved into query editor again after search view 2nd open in a row"
2960 );
2961 });
2962 }).unwrap();
2963
2964 cx.spawn(|mut cx| async move {
2965 window
2966 .update(&mut cx, |_, window, cx| {
2967 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
2968 })
2969 .unwrap();
2970 })
2971 .detach();
2972 cx.background_executor.run_until_parked();
2973 window.update(cx, |_, window, cx| {
2974 search_view.update(cx, |search_view, cx| {
2975 assert!(
2976 search_view.results_editor.focus_handle(cx).is_focused(window),
2977 "Search view with matching query should switch focus to the results editor after the toggle focus event",
2978 );
2979 });
2980 }).unwrap();
2981 }
2982
2983 #[perf]
2984 #[gpui::test]
2985 async fn test_filters_consider_toggle_state(cx: &mut TestAppContext) {
2986 init_test(cx);
2987
2988 let fs = FakeFs::new(cx.background_executor.clone());
2989 fs.insert_tree(
2990 "/dir",
2991 json!({
2992 "one.rs": "const ONE: usize = 1;",
2993 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2994 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2995 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2996 }),
2997 )
2998 .await;
2999 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3000 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3001 let workspace = window;
3002 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3003
3004 window
3005 .update(cx, move |workspace, window, cx| {
3006 workspace.panes()[0].update(cx, |pane, cx| {
3007 pane.toolbar()
3008 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3009 });
3010
3011 ProjectSearchView::deploy_search(
3012 workspace,
3013 &workspace::DeploySearch::find(),
3014 window,
3015 cx,
3016 )
3017 })
3018 .unwrap();
3019
3020 let Some(search_view) = cx.read(|cx| {
3021 workspace
3022 .read(cx)
3023 .unwrap()
3024 .active_pane()
3025 .read(cx)
3026 .active_item()
3027 .and_then(|item| item.downcast::<ProjectSearchView>())
3028 }) else {
3029 panic!("Search view expected to appear after new search event trigger")
3030 };
3031
3032 cx.spawn(|mut cx| async move {
3033 window
3034 .update(&mut cx, |_, window, cx| {
3035 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3036 })
3037 .unwrap();
3038 })
3039 .detach();
3040 cx.background_executor.run_until_parked();
3041
3042 window
3043 .update(cx, |_, window, cx| {
3044 search_view.update(cx, |search_view, cx| {
3045 search_view.query_editor.update(cx, |query_editor, cx| {
3046 query_editor.set_text("const FOUR", window, cx)
3047 });
3048 search_view.toggle_filters(cx);
3049 search_view
3050 .excluded_files_editor
3051 .update(cx, |exclude_editor, cx| {
3052 exclude_editor.set_text("four.rs", window, cx)
3053 });
3054 search_view.search(cx);
3055 });
3056 })
3057 .unwrap();
3058 cx.background_executor.run_until_parked();
3059 window
3060 .update(cx, |_, _, cx| {
3061 search_view.update(cx, |search_view, cx| {
3062 let results_text = search_view
3063 .results_editor
3064 .update(cx, |editor, cx| editor.display_text(cx));
3065 assert!(
3066 results_text.is_empty(),
3067 "Search view for query with the only match in an excluded file should have no results but got '{results_text}'"
3068 );
3069 });
3070 }).unwrap();
3071
3072 cx.spawn(|mut cx| async move {
3073 window.update(&mut cx, |_, window, cx| {
3074 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3075 })
3076 })
3077 .detach();
3078 cx.background_executor.run_until_parked();
3079
3080 window
3081 .update(cx, |_, _, cx| {
3082 search_view.update(cx, |search_view, cx| {
3083 search_view.toggle_filters(cx);
3084 search_view.search(cx);
3085 });
3086 })
3087 .unwrap();
3088 cx.background_executor.run_until_parked();
3089 window
3090 .update(cx, |_, _, cx| {
3091 search_view.update(cx, |search_view, cx| {
3092 assert_eq!(
3093 search_view
3094 .results_editor
3095 .update(cx, |editor, cx| editor.display_text(cx)),
3096 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
3097 "Search view results should contain the queried result in the previously excluded file with filters toggled off"
3098 );
3099 });
3100 })
3101 .unwrap();
3102 }
3103
3104 #[perf]
3105 #[gpui::test]
3106 async fn test_new_project_search_focus(cx: &mut TestAppContext) {
3107 init_test(cx);
3108
3109 let fs = FakeFs::new(cx.background_executor.clone());
3110 fs.insert_tree(
3111 path!("/dir"),
3112 json!({
3113 "one.rs": "const ONE: usize = 1;",
3114 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3115 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3116 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3117 }),
3118 )
3119 .await;
3120 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3121 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3122 let workspace = window;
3123 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3124
3125 let active_item = cx.read(|cx| {
3126 workspace
3127 .read(cx)
3128 .unwrap()
3129 .active_pane()
3130 .read(cx)
3131 .active_item()
3132 .and_then(|item| item.downcast::<ProjectSearchView>())
3133 });
3134 assert!(
3135 active_item.is_none(),
3136 "Expected no search panel to be active"
3137 );
3138
3139 window
3140 .update(cx, move |workspace, window, cx| {
3141 assert_eq!(workspace.panes().len(), 1);
3142 workspace.panes()[0].update(cx, |pane, cx| {
3143 pane.toolbar()
3144 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3145 });
3146
3147 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3148 })
3149 .unwrap();
3150
3151 let Some(search_view) = cx.read(|cx| {
3152 workspace
3153 .read(cx)
3154 .unwrap()
3155 .active_pane()
3156 .read(cx)
3157 .active_item()
3158 .and_then(|item| item.downcast::<ProjectSearchView>())
3159 }) else {
3160 panic!("Search view expected to appear after new search event trigger")
3161 };
3162
3163 cx.spawn(|mut cx| async move {
3164 window
3165 .update(&mut cx, |_, window, cx| {
3166 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3167 })
3168 .unwrap();
3169 })
3170 .detach();
3171 cx.background_executor.run_until_parked();
3172
3173 window.update(cx, |_, window, cx| {
3174 search_view.update(cx, |search_view, cx| {
3175 assert!(
3176 search_view.query_editor.focus_handle(cx).is_focused(window),
3177 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
3178 );
3179 });
3180 }).unwrap();
3181
3182 window
3183 .update(cx, |_, window, cx| {
3184 search_view.update(cx, |search_view, cx| {
3185 let query_editor = &search_view.query_editor;
3186 assert!(
3187 query_editor.focus_handle(cx).is_focused(window),
3188 "Search view should be focused after the new search view is activated",
3189 );
3190 let query_text = query_editor.read(cx).text(cx);
3191 assert!(
3192 query_text.is_empty(),
3193 "New search query should be empty but got '{query_text}'",
3194 );
3195 let results_text = search_view
3196 .results_editor
3197 .update(cx, |editor, cx| editor.display_text(cx));
3198 assert!(
3199 results_text.is_empty(),
3200 "Empty search view should have no results but got '{results_text}'"
3201 );
3202 });
3203 })
3204 .unwrap();
3205
3206 window
3207 .update(cx, |_, window, cx| {
3208 search_view.update(cx, |search_view, cx| {
3209 search_view.query_editor.update(cx, |query_editor, cx| {
3210 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
3211 });
3212 search_view.search(cx);
3213 });
3214 })
3215 .unwrap();
3216
3217 cx.background_executor.run_until_parked();
3218 window
3219 .update(cx, |_, window, cx| {
3220 search_view.update(cx, |search_view, cx| {
3221 let results_text = search_view
3222 .results_editor
3223 .update(cx, |editor, cx| editor.display_text(cx));
3224 assert!(
3225 results_text.is_empty(),
3226 "Search view for mismatching query should have no results but got '{results_text}'"
3227 );
3228 assert!(
3229 search_view.query_editor.focus_handle(cx).is_focused(window),
3230 "Search view should be focused after mismatching query had been used in search",
3231 );
3232 });
3233 })
3234 .unwrap();
3235 cx.spawn(|mut cx| async move {
3236 window.update(&mut cx, |_, window, cx| {
3237 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3238 })
3239 })
3240 .detach();
3241 cx.background_executor.run_until_parked();
3242 window.update(cx, |_, window, cx| {
3243 search_view.update(cx, |search_view, cx| {
3244 assert!(
3245 search_view.query_editor.focus_handle(cx).is_focused(window),
3246 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
3247 );
3248 });
3249 }).unwrap();
3250
3251 window
3252 .update(cx, |_, window, cx| {
3253 search_view.update(cx, |search_view, cx| {
3254 search_view.query_editor.update(cx, |query_editor, cx| {
3255 query_editor.set_text("TWO", window, cx)
3256 });
3257 search_view.search(cx);
3258 })
3259 })
3260 .unwrap();
3261 cx.background_executor.run_until_parked();
3262 window.update(cx, |_, window, cx|
3263 search_view.update(cx, |search_view, cx| {
3264 assert_eq!(
3265 search_view
3266 .results_editor
3267 .update(cx, |editor, cx| editor.display_text(cx)),
3268 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3269 "Search view results should match the query"
3270 );
3271 assert!(
3272 search_view.results_editor.focus_handle(cx).is_focused(window),
3273 "Search view with mismatching query should be focused after search results are available",
3274 );
3275 })).unwrap();
3276 cx.spawn(|mut cx| async move {
3277 window
3278 .update(&mut cx, |_, window, cx| {
3279 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3280 })
3281 .unwrap();
3282 })
3283 .detach();
3284 cx.background_executor.run_until_parked();
3285 window.update(cx, |_, window, cx| {
3286 search_view.update(cx, |search_view, cx| {
3287 assert!(
3288 search_view.results_editor.focus_handle(cx).is_focused(window),
3289 "Search view with matching query should still have its results editor focused after the toggle focus event",
3290 );
3291 });
3292 }).unwrap();
3293
3294 workspace
3295 .update(cx, |workspace, window, cx| {
3296 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3297 })
3298 .unwrap();
3299 cx.background_executor.run_until_parked();
3300 let Some(search_view_2) = cx.read(|cx| {
3301 workspace
3302 .read(cx)
3303 .unwrap()
3304 .active_pane()
3305 .read(cx)
3306 .active_item()
3307 .and_then(|item| item.downcast::<ProjectSearchView>())
3308 }) else {
3309 panic!("Search view expected to appear after new search event trigger")
3310 };
3311 assert!(
3312 search_view_2 != search_view,
3313 "New search view should be open after `workspace::NewSearch` event"
3314 );
3315
3316 window.update(cx, |_, window, cx| {
3317 search_view.update(cx, |search_view, cx| {
3318 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
3319 assert_eq!(
3320 search_view
3321 .results_editor
3322 .update(cx, |editor, cx| editor.display_text(cx)),
3323 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3324 "Results of the first search view should not update too"
3325 );
3326 assert!(
3327 !search_view.query_editor.focus_handle(cx).is_focused(window),
3328 "Focus should be moved away from the first search view"
3329 );
3330 });
3331 }).unwrap();
3332
3333 window.update(cx, |_, window, cx| {
3334 search_view_2.update(cx, |search_view_2, cx| {
3335 assert_eq!(
3336 search_view_2.query_editor.read(cx).text(cx),
3337 "two",
3338 "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
3339 );
3340 assert_eq!(
3341 search_view_2
3342 .results_editor
3343 .update(cx, |editor, cx| editor.display_text(cx)),
3344 "",
3345 "No search results should be in the 2nd view yet, as we did not spawn a search for it"
3346 );
3347 assert!(
3348 search_view_2.query_editor.focus_handle(cx).is_focused(window),
3349 "Focus should be moved into query editor of the new window"
3350 );
3351 });
3352 }).unwrap();
3353
3354 window
3355 .update(cx, |_, window, cx| {
3356 search_view_2.update(cx, |search_view_2, cx| {
3357 search_view_2.query_editor.update(cx, |query_editor, cx| {
3358 query_editor.set_text("FOUR", window, cx)
3359 });
3360 search_view_2.search(cx);
3361 });
3362 })
3363 .unwrap();
3364
3365 cx.background_executor.run_until_parked();
3366 window.update(cx, |_, window, cx| {
3367 search_view_2.update(cx, |search_view_2, cx| {
3368 assert_eq!(
3369 search_view_2
3370 .results_editor
3371 .update(cx, |editor, cx| editor.display_text(cx)),
3372 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
3373 "New search view with the updated query should have new search results"
3374 );
3375 assert!(
3376 search_view_2.results_editor.focus_handle(cx).is_focused(window),
3377 "Search view with mismatching query should be focused after search results are available",
3378 );
3379 });
3380 }).unwrap();
3381
3382 cx.spawn(|mut cx| async move {
3383 window
3384 .update(&mut cx, |_, window, cx| {
3385 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3386 })
3387 .unwrap();
3388 })
3389 .detach();
3390 cx.background_executor.run_until_parked();
3391 window.update(cx, |_, window, cx| {
3392 search_view_2.update(cx, |search_view_2, cx| {
3393 assert!(
3394 search_view_2.results_editor.focus_handle(cx).is_focused(window),
3395 "Search view with matching query should switch focus to the results editor after the toggle focus event",
3396 );
3397 });}).unwrap();
3398 }
3399
3400 #[perf]
3401 #[gpui::test]
3402 async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
3403 init_test(cx);
3404
3405 let fs = FakeFs::new(cx.background_executor.clone());
3406 fs.insert_tree(
3407 path!("/dir"),
3408 json!({
3409 "a": {
3410 "one.rs": "const ONE: usize = 1;",
3411 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3412 },
3413 "b": {
3414 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3415 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3416 },
3417 }),
3418 )
3419 .await;
3420 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3421 let worktree_id = project.read_with(cx, |project, cx| {
3422 project.worktrees(cx).next().unwrap().read(cx).id()
3423 });
3424 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3425 let workspace = window.root(cx).unwrap();
3426 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3427
3428 let active_item = cx.read(|cx| {
3429 workspace
3430 .read(cx)
3431 .active_pane()
3432 .read(cx)
3433 .active_item()
3434 .and_then(|item| item.downcast::<ProjectSearchView>())
3435 });
3436 assert!(
3437 active_item.is_none(),
3438 "Expected no search panel to be active"
3439 );
3440
3441 window
3442 .update(cx, move |workspace, window, cx| {
3443 assert_eq!(workspace.panes().len(), 1);
3444 workspace.panes()[0].update(cx, move |pane, cx| {
3445 pane.toolbar()
3446 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3447 });
3448 })
3449 .unwrap();
3450
3451 let a_dir_entry = cx.update(|cx| {
3452 workspace
3453 .read(cx)
3454 .project()
3455 .read(cx)
3456 .entry_for_path(&(worktree_id, rel_path("a")).into(), cx)
3457 .expect("no entry for /a/ directory")
3458 .clone()
3459 });
3460 assert!(a_dir_entry.is_dir());
3461 window
3462 .update(cx, |workspace, window, cx| {
3463 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, window, cx)
3464 })
3465 .unwrap();
3466
3467 let Some(search_view) = cx.read(|cx| {
3468 workspace
3469 .read(cx)
3470 .active_pane()
3471 .read(cx)
3472 .active_item()
3473 .and_then(|item| item.downcast::<ProjectSearchView>())
3474 }) else {
3475 panic!("Search view expected to appear after new search in directory event trigger")
3476 };
3477 cx.background_executor.run_until_parked();
3478 window
3479 .update(cx, |_, window, cx| {
3480 search_view.update(cx, |search_view, cx| {
3481 assert!(
3482 search_view.query_editor.focus_handle(cx).is_focused(window),
3483 "On new search in directory, focus should be moved into query editor"
3484 );
3485 search_view.excluded_files_editor.update(cx, |editor, cx| {
3486 assert!(
3487 editor.display_text(cx).is_empty(),
3488 "New search in directory should not have any excluded files"
3489 );
3490 });
3491 search_view.included_files_editor.update(cx, |editor, cx| {
3492 assert_eq!(
3493 editor.display_text(cx),
3494 a_dir_entry.path.display(PathStyle::local()),
3495 "New search in directory should have included dir entry path"
3496 );
3497 });
3498 });
3499 })
3500 .unwrap();
3501 window
3502 .update(cx, |_, window, cx| {
3503 search_view.update(cx, |search_view, cx| {
3504 search_view.query_editor.update(cx, |query_editor, cx| {
3505 query_editor.set_text("const", window, cx)
3506 });
3507 search_view.search(cx);
3508 });
3509 })
3510 .unwrap();
3511 cx.background_executor.run_until_parked();
3512 window
3513 .update(cx, |_, _, cx| {
3514 search_view.update(cx, |search_view, cx| {
3515 assert_eq!(
3516 search_view
3517 .results_editor
3518 .update(cx, |editor, cx| editor.display_text(cx)),
3519 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3520 "New search in directory should have a filter that matches a certain directory"
3521 );
3522 })
3523 })
3524 .unwrap();
3525 }
3526
3527 #[perf]
3528 #[gpui::test]
3529 async fn test_search_query_history(cx: &mut TestAppContext) {
3530 init_test(cx);
3531
3532 let fs = FakeFs::new(cx.background_executor.clone());
3533 fs.insert_tree(
3534 path!("/dir"),
3535 json!({
3536 "one.rs": "const ONE: usize = 1;",
3537 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3538 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3539 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3540 }),
3541 )
3542 .await;
3543 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3544 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3545 let workspace = window.root(cx).unwrap();
3546 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3547
3548 window
3549 .update(cx, {
3550 let search_bar = search_bar.clone();
3551 |workspace, window, cx| {
3552 assert_eq!(workspace.panes().len(), 1);
3553 workspace.panes()[0].update(cx, |pane, cx| {
3554 pane.toolbar()
3555 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3556 });
3557
3558 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3559 }
3560 })
3561 .unwrap();
3562
3563 let search_view = cx.read(|cx| {
3564 workspace
3565 .read(cx)
3566 .active_pane()
3567 .read(cx)
3568 .active_item()
3569 .and_then(|item| item.downcast::<ProjectSearchView>())
3570 .expect("Search view expected to appear after new search event trigger")
3571 });
3572
3573 // Add 3 search items into the history + another unsubmitted one.
3574 window
3575 .update(cx, |_, window, cx| {
3576 search_view.update(cx, |search_view, cx| {
3577 search_view.search_options = SearchOptions::CASE_SENSITIVE;
3578 search_view.query_editor.update(cx, |query_editor, cx| {
3579 query_editor.set_text("ONE", window, cx)
3580 });
3581 search_view.search(cx);
3582 });
3583 })
3584 .unwrap();
3585
3586 cx.background_executor.run_until_parked();
3587 window
3588 .update(cx, |_, window, cx| {
3589 search_view.update(cx, |search_view, cx| {
3590 search_view.query_editor.update(cx, |query_editor, cx| {
3591 query_editor.set_text("TWO", window, cx)
3592 });
3593 search_view.search(cx);
3594 });
3595 })
3596 .unwrap();
3597 cx.background_executor.run_until_parked();
3598 window
3599 .update(cx, |_, window, cx| {
3600 search_view.update(cx, |search_view, cx| {
3601 search_view.query_editor.update(cx, |query_editor, cx| {
3602 query_editor.set_text("THREE", window, cx)
3603 });
3604 search_view.search(cx);
3605 })
3606 })
3607 .unwrap();
3608 cx.background_executor.run_until_parked();
3609 window
3610 .update(cx, |_, window, cx| {
3611 search_view.update(cx, |search_view, cx| {
3612 search_view.query_editor.update(cx, |query_editor, cx| {
3613 query_editor.set_text("JUST_TEXT_INPUT", window, cx)
3614 });
3615 })
3616 })
3617 .unwrap();
3618 cx.background_executor.run_until_parked();
3619
3620 // Ensure that the latest input with search settings is active.
3621 window
3622 .update(cx, |_, _, cx| {
3623 search_view.update(cx, |search_view, cx| {
3624 assert_eq!(
3625 search_view.query_editor.read(cx).text(cx),
3626 "JUST_TEXT_INPUT"
3627 );
3628 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3629 });
3630 })
3631 .unwrap();
3632
3633 // Next history query after the latest should set the query to the empty string.
3634 window
3635 .update(cx, |_, window, cx| {
3636 search_bar.update(cx, |search_bar, cx| {
3637 search_bar.focus_search(window, cx);
3638 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3639 })
3640 })
3641 .unwrap();
3642 window
3643 .update(cx, |_, _, cx| {
3644 search_view.update(cx, |search_view, cx| {
3645 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3646 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3647 });
3648 })
3649 .unwrap();
3650 window
3651 .update(cx, |_, window, cx| {
3652 search_bar.update(cx, |search_bar, cx| {
3653 search_bar.focus_search(window, cx);
3654 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3655 })
3656 })
3657 .unwrap();
3658 window
3659 .update(cx, |_, _, cx| {
3660 search_view.update(cx, |search_view, cx| {
3661 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3662 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3663 });
3664 })
3665 .unwrap();
3666
3667 // First previous query for empty current query should set the query to the latest submitted one.
3668 window
3669 .update(cx, |_, window, cx| {
3670 search_bar.update(cx, |search_bar, cx| {
3671 search_bar.focus_search(window, cx);
3672 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3673 });
3674 })
3675 .unwrap();
3676 window
3677 .update(cx, |_, _, cx| {
3678 search_view.update(cx, |search_view, cx| {
3679 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3680 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3681 });
3682 })
3683 .unwrap();
3684
3685 // Further previous items should go over the history in reverse order.
3686 window
3687 .update(cx, |_, window, cx| {
3688 search_bar.update(cx, |search_bar, cx| {
3689 search_bar.focus_search(window, cx);
3690 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3691 });
3692 })
3693 .unwrap();
3694 window
3695 .update(cx, |_, _, cx| {
3696 search_view.update(cx, |search_view, cx| {
3697 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3698 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3699 });
3700 })
3701 .unwrap();
3702
3703 // Previous items should never go behind the first history item.
3704 window
3705 .update(cx, |_, window, cx| {
3706 search_bar.update(cx, |search_bar, cx| {
3707 search_bar.focus_search(window, cx);
3708 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3709 });
3710 })
3711 .unwrap();
3712 window
3713 .update(cx, |_, _, cx| {
3714 search_view.update(cx, |search_view, cx| {
3715 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3716 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3717 });
3718 })
3719 .unwrap();
3720 window
3721 .update(cx, |_, window, cx| {
3722 search_bar.update(cx, |search_bar, cx| {
3723 search_bar.focus_search(window, cx);
3724 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3725 });
3726 })
3727 .unwrap();
3728 window
3729 .update(cx, |_, _, cx| {
3730 search_view.update(cx, |search_view, cx| {
3731 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
3732 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3733 });
3734 })
3735 .unwrap();
3736
3737 // Next items should go over the history in the original order.
3738 window
3739 .update(cx, |_, window, cx| {
3740 search_bar.update(cx, |search_bar, cx| {
3741 search_bar.focus_search(window, cx);
3742 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3743 });
3744 })
3745 .unwrap();
3746 window
3747 .update(cx, |_, _, cx| {
3748 search_view.update(cx, |search_view, cx| {
3749 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3750 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3751 });
3752 })
3753 .unwrap();
3754
3755 window
3756 .update(cx, |_, window, cx| {
3757 search_view.update(cx, |search_view, cx| {
3758 search_view.query_editor.update(cx, |query_editor, cx| {
3759 query_editor.set_text("TWO_NEW", window, cx)
3760 });
3761 search_view.search(cx);
3762 });
3763 })
3764 .unwrap();
3765 cx.background_executor.run_until_parked();
3766 window
3767 .update(cx, |_, _, cx| {
3768 search_view.update(cx, |search_view, cx| {
3769 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3770 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3771 });
3772 })
3773 .unwrap();
3774
3775 // New search input should add another entry to history and move the selection to the end of the history.
3776 window
3777 .update(cx, |_, window, cx| {
3778 search_bar.update(cx, |search_bar, cx| {
3779 search_bar.focus_search(window, cx);
3780 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3781 });
3782 })
3783 .unwrap();
3784 window
3785 .update(cx, |_, _, cx| {
3786 search_view.update(cx, |search_view, cx| {
3787 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3788 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3789 });
3790 })
3791 .unwrap();
3792 window
3793 .update(cx, |_, window, cx| {
3794 search_bar.update(cx, |search_bar, cx| {
3795 search_bar.focus_search(window, cx);
3796 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
3797 });
3798 })
3799 .unwrap();
3800 window
3801 .update(cx, |_, _, cx| {
3802 search_view.update(cx, |search_view, cx| {
3803 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3804 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3805 });
3806 })
3807 .unwrap();
3808 window
3809 .update(cx, |_, window, cx| {
3810 search_bar.update(cx, |search_bar, cx| {
3811 search_bar.focus_search(window, cx);
3812 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3813 });
3814 })
3815 .unwrap();
3816 window
3817 .update(cx, |_, _, cx| {
3818 search_view.update(cx, |search_view, cx| {
3819 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3820 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3821 });
3822 })
3823 .unwrap();
3824 window
3825 .update(cx, |_, window, cx| {
3826 search_bar.update(cx, |search_bar, cx| {
3827 search_bar.focus_search(window, cx);
3828 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3829 });
3830 })
3831 .unwrap();
3832 window
3833 .update(cx, |_, _, cx| {
3834 search_view.update(cx, |search_view, cx| {
3835 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3836 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3837 });
3838 })
3839 .unwrap();
3840 window
3841 .update(cx, |_, window, cx| {
3842 search_bar.update(cx, |search_bar, cx| {
3843 search_bar.focus_search(window, cx);
3844 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3845 });
3846 })
3847 .unwrap();
3848 window
3849 .update(cx, |_, _, cx| {
3850 search_view.update(cx, |search_view, cx| {
3851 assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3852 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3853 });
3854 })
3855 .unwrap();
3856 }
3857
3858 #[perf]
3859 #[gpui::test]
3860 async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) {
3861 init_test(cx);
3862
3863 let fs = FakeFs::new(cx.background_executor.clone());
3864 fs.insert_tree(
3865 path!("/dir"),
3866 json!({
3867 "one.rs": "const ONE: usize = 1;",
3868 }),
3869 )
3870 .await;
3871 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3872 let worktree_id = project.update(cx, |this, cx| {
3873 this.worktrees(cx).next().unwrap().read(cx).id()
3874 });
3875
3876 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
3877 let workspace = window.root(cx).unwrap();
3878
3879 let panes: Vec<_> = window
3880 .update(cx, |this, _, _| this.panes().to_owned())
3881 .unwrap();
3882
3883 let search_bar_1 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3884 let search_bar_2 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3885
3886 assert_eq!(panes.len(), 1);
3887 let first_pane = panes.first().cloned().unwrap();
3888 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3889 window
3890 .update(cx, |workspace, window, cx| {
3891 workspace.open_path(
3892 (worktree_id, rel_path("one.rs")),
3893 Some(first_pane.downgrade()),
3894 true,
3895 window,
3896 cx,
3897 )
3898 })
3899 .unwrap()
3900 .await
3901 .unwrap();
3902 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3903
3904 // Add a project search item to the first pane
3905 window
3906 .update(cx, {
3907 let search_bar = search_bar_1.clone();
3908 |workspace, window, cx| {
3909 first_pane.update(cx, |pane, cx| {
3910 pane.toolbar()
3911 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3912 });
3913
3914 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3915 }
3916 })
3917 .unwrap();
3918 let search_view_1 = cx.read(|cx| {
3919 workspace
3920 .read(cx)
3921 .active_item(cx)
3922 .and_then(|item| item.downcast::<ProjectSearchView>())
3923 .expect("Search view expected to appear after new search event trigger")
3924 });
3925
3926 let second_pane = window
3927 .update(cx, |workspace, window, cx| {
3928 workspace.split_and_clone(
3929 first_pane.clone(),
3930 workspace::SplitDirection::Right,
3931 window,
3932 cx,
3933 )
3934 })
3935 .unwrap()
3936 .await
3937 .unwrap();
3938 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3939
3940 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3941 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3942
3943 // Add a project search item to the second pane
3944 window
3945 .update(cx, {
3946 let search_bar = search_bar_2.clone();
3947 let pane = second_pane.clone();
3948 move |workspace, window, cx| {
3949 assert_eq!(workspace.panes().len(), 2);
3950 pane.update(cx, |pane, cx| {
3951 pane.toolbar()
3952 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3953 });
3954
3955 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3956 }
3957 })
3958 .unwrap();
3959
3960 let search_view_2 = cx.read(|cx| {
3961 workspace
3962 .read(cx)
3963 .active_item(cx)
3964 .and_then(|item| item.downcast::<ProjectSearchView>())
3965 .expect("Search view expected to appear after new search event trigger")
3966 });
3967
3968 cx.run_until_parked();
3969 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3970 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3971
3972 let update_search_view =
3973 |search_view: &Entity<ProjectSearchView>, query: &str, cx: &mut TestAppContext| {
3974 window
3975 .update(cx, |_, window, cx| {
3976 search_view.update(cx, |search_view, cx| {
3977 search_view.query_editor.update(cx, |query_editor, cx| {
3978 query_editor.set_text(query, window, cx)
3979 });
3980 search_view.search(cx);
3981 });
3982 })
3983 .unwrap();
3984 };
3985
3986 let active_query =
3987 |search_view: &Entity<ProjectSearchView>, cx: &mut TestAppContext| -> String {
3988 window
3989 .update(cx, |_, _, cx| {
3990 search_view.update(cx, |search_view, cx| {
3991 search_view.query_editor.read(cx).text(cx)
3992 })
3993 })
3994 .unwrap()
3995 };
3996
3997 let select_prev_history_item =
3998 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
3999 window
4000 .update(cx, |_, window, cx| {
4001 search_bar.update(cx, |search_bar, cx| {
4002 search_bar.focus_search(window, cx);
4003 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4004 })
4005 })
4006 .unwrap();
4007 };
4008
4009 let select_next_history_item =
4010 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
4011 window
4012 .update(cx, |_, window, cx| {
4013 search_bar.update(cx, |search_bar, cx| {
4014 search_bar.focus_search(window, cx);
4015 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4016 })
4017 })
4018 .unwrap();
4019 };
4020
4021 update_search_view(&search_view_1, "ONE", cx);
4022 cx.background_executor.run_until_parked();
4023
4024 update_search_view(&search_view_2, "TWO", cx);
4025 cx.background_executor.run_until_parked();
4026
4027 assert_eq!(active_query(&search_view_1, cx), "ONE");
4028 assert_eq!(active_query(&search_view_2, cx), "TWO");
4029
4030 // Selecting previous history item should select the query from search view 1.
4031 select_prev_history_item(&search_bar_2, cx);
4032 assert_eq!(active_query(&search_view_2, cx), "ONE");
4033
4034 // Selecting the previous history item should not change the query as it is already the first item.
4035 select_prev_history_item(&search_bar_2, cx);
4036 assert_eq!(active_query(&search_view_2, cx), "ONE");
4037
4038 // Changing the query in search view 2 should not affect the history of search view 1.
4039 assert_eq!(active_query(&search_view_1, cx), "ONE");
4040
4041 // Deploying a new search in search view 2
4042 update_search_view(&search_view_2, "THREE", cx);
4043 cx.background_executor.run_until_parked();
4044
4045 select_next_history_item(&search_bar_2, cx);
4046 assert_eq!(active_query(&search_view_2, cx), "");
4047
4048 select_prev_history_item(&search_bar_2, cx);
4049 assert_eq!(active_query(&search_view_2, cx), "THREE");
4050
4051 select_prev_history_item(&search_bar_2, cx);
4052 assert_eq!(active_query(&search_view_2, cx), "TWO");
4053
4054 select_prev_history_item(&search_bar_2, cx);
4055 assert_eq!(active_query(&search_view_2, cx), "ONE");
4056
4057 select_prev_history_item(&search_bar_2, cx);
4058 assert_eq!(active_query(&search_view_2, cx), "ONE");
4059
4060 // Search view 1 should now see the query from search view 2.
4061 assert_eq!(active_query(&search_view_1, cx), "ONE");
4062
4063 select_next_history_item(&search_bar_2, cx);
4064 assert_eq!(active_query(&search_view_2, cx), "TWO");
4065
4066 // Here is the new query from search view 2
4067 select_next_history_item(&search_bar_2, cx);
4068 assert_eq!(active_query(&search_view_2, cx), "THREE");
4069
4070 select_next_history_item(&search_bar_2, cx);
4071 assert_eq!(active_query(&search_view_2, cx), "");
4072
4073 select_next_history_item(&search_bar_1, cx);
4074 assert_eq!(active_query(&search_view_1, cx), "TWO");
4075
4076 select_next_history_item(&search_bar_1, cx);
4077 assert_eq!(active_query(&search_view_1, cx), "THREE");
4078
4079 select_next_history_item(&search_bar_1, cx);
4080 assert_eq!(active_query(&search_view_1, cx), "");
4081 }
4082
4083 #[perf]
4084 #[gpui::test]
4085 async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
4086 init_test(cx);
4087
4088 // Setup 2 panes, both with a file open and one with a project search.
4089 let fs = FakeFs::new(cx.background_executor.clone());
4090 fs.insert_tree(
4091 path!("/dir"),
4092 json!({
4093 "one.rs": "const ONE: usize = 1;",
4094 }),
4095 )
4096 .await;
4097 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4098 let worktree_id = project.update(cx, |this, cx| {
4099 this.worktrees(cx).next().unwrap().read(cx).id()
4100 });
4101 let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
4102 let panes: Vec<_> = window
4103 .update(cx, |this, _, _| this.panes().to_owned())
4104 .unwrap();
4105 assert_eq!(panes.len(), 1);
4106 let first_pane = panes.first().cloned().unwrap();
4107 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
4108 window
4109 .update(cx, |workspace, window, cx| {
4110 workspace.open_path(
4111 (worktree_id, rel_path("one.rs")),
4112 Some(first_pane.downgrade()),
4113 true,
4114 window,
4115 cx,
4116 )
4117 })
4118 .unwrap()
4119 .await
4120 .unwrap();
4121 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
4122 let second_pane = window
4123 .update(cx, |workspace, window, cx| {
4124 workspace.split_and_clone(
4125 first_pane.clone(),
4126 workspace::SplitDirection::Right,
4127 window,
4128 cx,
4129 )
4130 })
4131 .unwrap()
4132 .await
4133 .unwrap();
4134 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
4135 assert!(
4136 window
4137 .update(cx, |_, window, cx| second_pane
4138 .focus_handle(cx)
4139 .contains_focused(window, cx))
4140 .unwrap()
4141 );
4142 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
4143 window
4144 .update(cx, {
4145 let search_bar = search_bar.clone();
4146 let pane = first_pane.clone();
4147 move |workspace, window, cx| {
4148 assert_eq!(workspace.panes().len(), 2);
4149 pane.update(cx, move |pane, cx| {
4150 pane.toolbar()
4151 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4152 });
4153 }
4154 })
4155 .unwrap();
4156
4157 // Add a project search item to the second pane
4158 window
4159 .update(cx, {
4160 |workspace, window, cx| {
4161 assert_eq!(workspace.panes().len(), 2);
4162 second_pane.update(cx, |pane, cx| {
4163 pane.toolbar()
4164 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4165 });
4166
4167 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4168 }
4169 })
4170 .unwrap();
4171
4172 cx.run_until_parked();
4173 assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
4174 assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
4175
4176 // Focus the first pane
4177 window
4178 .update(cx, |workspace, window, cx| {
4179 assert_eq!(workspace.active_pane(), &second_pane);
4180 second_pane.update(cx, |this, cx| {
4181 assert_eq!(this.active_item_index(), 1);
4182 this.activate_previous_item(&Default::default(), window, cx);
4183 assert_eq!(this.active_item_index(), 0);
4184 });
4185 workspace.activate_pane_in_direction(workspace::SplitDirection::Left, window, cx);
4186 })
4187 .unwrap();
4188 window
4189 .update(cx, |workspace, _, cx| {
4190 assert_eq!(workspace.active_pane(), &first_pane);
4191 assert_eq!(first_pane.read(cx).items_len(), 1);
4192 assert_eq!(second_pane.read(cx).items_len(), 2);
4193 })
4194 .unwrap();
4195
4196 // Deploy a new search
4197 cx.dispatch_action(window.into(), DeploySearch::find());
4198
4199 // Both panes should now have a project search in them
4200 window
4201 .update(cx, |workspace, window, cx| {
4202 assert_eq!(workspace.active_pane(), &first_pane);
4203 first_pane.read_with(cx, |this, _| {
4204 assert_eq!(this.active_item_index(), 1);
4205 assert_eq!(this.items_len(), 2);
4206 });
4207 second_pane.update(cx, |this, cx| {
4208 assert!(!cx.focus_handle().contains_focused(window, cx));
4209 assert_eq!(this.items_len(), 2);
4210 });
4211 })
4212 .unwrap();
4213
4214 // Focus the second pane's non-search item
4215 window
4216 .update(cx, |_workspace, window, cx| {
4217 second_pane.update(cx, |pane, cx| {
4218 pane.activate_next_item(&Default::default(), window, cx)
4219 });
4220 })
4221 .unwrap();
4222
4223 // Deploy a new search
4224 cx.dispatch_action(window.into(), DeploySearch::find());
4225
4226 // The project search view should now be focused in the second pane
4227 // And the number of items should be unchanged.
4228 window
4229 .update(cx, |_workspace, _, cx| {
4230 second_pane.update(cx, |pane, _cx| {
4231 assert!(
4232 pane.active_item()
4233 .unwrap()
4234 .downcast::<ProjectSearchView>()
4235 .is_some()
4236 );
4237
4238 assert_eq!(pane.items_len(), 2);
4239 });
4240 })
4241 .unwrap();
4242 }
4243
4244 #[perf]
4245 #[gpui::test]
4246 async fn test_scroll_search_results_to_top(cx: &mut TestAppContext) {
4247 init_test(cx);
4248
4249 // We need many lines in the search results to be able to scroll the window
4250 let fs = FakeFs::new(cx.background_executor.clone());
4251 fs.insert_tree(
4252 path!("/dir"),
4253 json!({
4254 "1.txt": "\n\n\n\n\n A \n\n\n\n\n",
4255 "2.txt": "\n\n\n\n\n A \n\n\n\n\n",
4256 "3.rs": "\n\n\n\n\n A \n\n\n\n\n",
4257 "4.rs": "\n\n\n\n\n A \n\n\n\n\n",
4258 "5.rs": "\n\n\n\n\n A \n\n\n\n\n",
4259 "6.rs": "\n\n\n\n\n A \n\n\n\n\n",
4260 "7.rs": "\n\n\n\n\n A \n\n\n\n\n",
4261 "8.rs": "\n\n\n\n\n A \n\n\n\n\n",
4262 "9.rs": "\n\n\n\n\n A \n\n\n\n\n",
4263 "a.rs": "\n\n\n\n\n A \n\n\n\n\n",
4264 "b.rs": "\n\n\n\n\n B \n\n\n\n\n",
4265 "c.rs": "\n\n\n\n\n B \n\n\n\n\n",
4266 "d.rs": "\n\n\n\n\n B \n\n\n\n\n",
4267 "e.rs": "\n\n\n\n\n B \n\n\n\n\n",
4268 "f.rs": "\n\n\n\n\n B \n\n\n\n\n",
4269 "g.rs": "\n\n\n\n\n B \n\n\n\n\n",
4270 "h.rs": "\n\n\n\n\n B \n\n\n\n\n",
4271 "i.rs": "\n\n\n\n\n B \n\n\n\n\n",
4272 "j.rs": "\n\n\n\n\n B \n\n\n\n\n",
4273 "k.rs": "\n\n\n\n\n B \n\n\n\n\n",
4274 }),
4275 )
4276 .await;
4277 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4278 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4279 let workspace = window.root(cx).unwrap();
4280 let search = cx.new(|cx| ProjectSearch::new(project, cx));
4281 let search_view = cx.add_window(|window, cx| {
4282 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
4283 });
4284
4285 // First search
4286 perform_search(search_view, "A", cx);
4287 search_view
4288 .update(cx, |search_view, window, cx| {
4289 search_view.results_editor.update(cx, |results_editor, cx| {
4290 // Results are correct and scrolled to the top
4291 assert_eq!(
4292 results_editor.display_text(cx).match_indices(" A ").count(),
4293 10
4294 );
4295 assert_eq!(results_editor.scroll_position(cx), Point::default());
4296
4297 // Scroll results all the way down
4298 results_editor.scroll(
4299 Point::new(0., f64::MAX),
4300 Some(Axis::Vertical),
4301 window,
4302 cx,
4303 );
4304 });
4305 })
4306 .expect("unable to update search view");
4307
4308 // Second search
4309 perform_search(search_view, "B", cx);
4310 search_view
4311 .update(cx, |search_view, _, cx| {
4312 search_view.results_editor.update(cx, |results_editor, cx| {
4313 // Results are correct...
4314 assert_eq!(
4315 results_editor.display_text(cx).match_indices(" B ").count(),
4316 10
4317 );
4318 // ...and scrolled back to the top
4319 assert_eq!(results_editor.scroll_position(cx), Point::default());
4320 });
4321 })
4322 .expect("unable to update search view");
4323 }
4324
4325 #[perf]
4326 #[gpui::test]
4327 async fn test_buffer_search_query_reused(cx: &mut TestAppContext) {
4328 init_test(cx);
4329
4330 let fs = FakeFs::new(cx.background_executor.clone());
4331 fs.insert_tree(
4332 path!("/dir"),
4333 json!({
4334 "one.rs": "const ONE: usize = 1;",
4335 }),
4336 )
4337 .await;
4338 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4339 let worktree_id = project.update(cx, |this, cx| {
4340 this.worktrees(cx).next().unwrap().read(cx).id()
4341 });
4342 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4343 let workspace = window.root(cx).unwrap();
4344 let mut cx = VisualTestContext::from_window(*window.deref(), cx);
4345
4346 let editor = workspace
4347 .update_in(&mut cx, |workspace, window, cx| {
4348 workspace.open_path((worktree_id, rel_path("one.rs")), None, true, window, cx)
4349 })
4350 .await
4351 .unwrap()
4352 .downcast::<Editor>()
4353 .unwrap();
4354
4355 // Wait for the unstaged changes to be loaded
4356 cx.run_until_parked();
4357
4358 let buffer_search_bar = cx.new_window_entity(|window, cx| {
4359 let mut search_bar =
4360 BufferSearchBar::new(Some(project.read(cx).languages().clone()), window, cx);
4361 search_bar.set_active_pane_item(Some(&editor), window, cx);
4362 search_bar.show(window, cx);
4363 search_bar
4364 });
4365
4366 let panes: Vec<_> = window
4367 .update(&mut cx, |this, _, _| this.panes().to_owned())
4368 .unwrap();
4369 assert_eq!(panes.len(), 1);
4370 let pane = panes.first().cloned().unwrap();
4371 pane.update_in(&mut cx, |pane, window, cx| {
4372 pane.toolbar().update(cx, |toolbar, cx| {
4373 toolbar.add_item(buffer_search_bar.clone(), window, cx);
4374 })
4375 });
4376
4377 let buffer_search_query = "search bar query";
4378 buffer_search_bar
4379 .update_in(&mut cx, |buffer_search_bar, window, cx| {
4380 buffer_search_bar.focus_handle(cx).focus(window, cx);
4381 buffer_search_bar.search(buffer_search_query, None, true, window, cx)
4382 })
4383 .await
4384 .unwrap();
4385
4386 workspace.update_in(&mut cx, |workspace, window, cx| {
4387 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4388 });
4389 cx.run_until_parked();
4390 let project_search_view = pane
4391 .read_with(&cx, |pane, _| {
4392 pane.active_item()
4393 .and_then(|item| item.downcast::<ProjectSearchView>())
4394 })
4395 .expect("should open a project search view after spawning a new search");
4396 project_search_view.update(&mut cx, |search_view, cx| {
4397 assert_eq!(
4398 search_view.search_query_text(cx),
4399 buffer_search_query,
4400 "Project search should take the query from the buffer search bar since it got focused and had a query inside"
4401 );
4402 });
4403 }
4404
4405 #[gpui::test]
4406 async fn test_search_dismisses_modal(cx: &mut TestAppContext) {
4407 init_test(cx);
4408
4409 let fs = FakeFs::new(cx.background_executor.clone());
4410 fs.insert_tree(
4411 path!("/dir"),
4412 json!({
4413 "one.rs": "const ONE: usize = 1;",
4414 }),
4415 )
4416 .await;
4417 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4418 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4419
4420 struct EmptyModalView {
4421 focus_handle: gpui::FocusHandle,
4422 }
4423 impl EventEmitter<gpui::DismissEvent> for EmptyModalView {}
4424 impl Render for EmptyModalView {
4425 fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement {
4426 div()
4427 }
4428 }
4429 impl Focusable for EmptyModalView {
4430 fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
4431 self.focus_handle.clone()
4432 }
4433 }
4434 impl workspace::ModalView for EmptyModalView {}
4435
4436 window
4437 .update(cx, |workspace, window, cx| {
4438 workspace.toggle_modal(window, cx, |_, cx| EmptyModalView {
4439 focus_handle: cx.focus_handle(),
4440 });
4441 assert!(workspace.has_active_modal(window, cx));
4442 })
4443 .unwrap();
4444
4445 cx.dispatch_action(window.into(), Deploy::find());
4446
4447 window
4448 .update(cx, |workspace, window, cx| {
4449 assert!(!workspace.has_active_modal(window, cx));
4450 workspace.toggle_modal(window, cx, |_, cx| EmptyModalView {
4451 focus_handle: cx.focus_handle(),
4452 });
4453 assert!(workspace.has_active_modal(window, cx));
4454 })
4455 .unwrap();
4456
4457 cx.dispatch_action(window.into(), DeploySearch::find());
4458
4459 window
4460 .update(cx, |workspace, window, cx| {
4461 assert!(!workspace.has_active_modal(window, cx));
4462 })
4463 .unwrap();
4464 }
4465
4466 #[perf]
4467 #[gpui::test]
4468 async fn test_search_with_inlays(cx: &mut TestAppContext) {
4469 init_test(cx);
4470 cx.update(|cx| {
4471 SettingsStore::update_global(cx, |store, cx| {
4472 store.update_user_settings(cx, |settings| {
4473 settings.project.all_languages.defaults.inlay_hints =
4474 Some(InlayHintSettingsContent {
4475 enabled: Some(true),
4476 ..InlayHintSettingsContent::default()
4477 })
4478 });
4479 });
4480 });
4481
4482 let fs = FakeFs::new(cx.background_executor.clone());
4483 fs.insert_tree(
4484 path!("/dir"),
4485 // `\n` , a trailing line on the end, is important for the test case
4486 json!({
4487 "main.rs": "fn main() { let a = 2; }\n",
4488 }),
4489 )
4490 .await;
4491
4492 let requests_count = Arc::new(AtomicUsize::new(0));
4493 let closure_requests_count = requests_count.clone();
4494 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4495 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
4496 let language = rust_lang();
4497 language_registry.add(language);
4498 let mut fake_servers = language_registry.register_fake_lsp(
4499 "Rust",
4500 FakeLspAdapter {
4501 capabilities: lsp::ServerCapabilities {
4502 inlay_hint_provider: Some(lsp::OneOf::Left(true)),
4503 ..lsp::ServerCapabilities::default()
4504 },
4505 initializer: Some(Box::new(move |fake_server| {
4506 let requests_count = closure_requests_count.clone();
4507 fake_server.set_request_handler::<lsp::request::InlayHintRequest, _, _>({
4508 move |_, _| {
4509 let requests_count = requests_count.clone();
4510 async move {
4511 requests_count.fetch_add(1, atomic::Ordering::Release);
4512 Ok(Some(vec![lsp::InlayHint {
4513 position: lsp::Position::new(0, 17),
4514 label: lsp::InlayHintLabel::String(": i32".to_owned()),
4515 kind: Some(lsp::InlayHintKind::TYPE),
4516 text_edits: None,
4517 tooltip: None,
4518 padding_left: None,
4519 padding_right: None,
4520 data: None,
4521 }]))
4522 }
4523 }
4524 });
4525 })),
4526 ..FakeLspAdapter::default()
4527 },
4528 );
4529
4530 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4531 let workspace = window.root(cx).unwrap();
4532 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
4533 let search_view = cx.add_window(|window, cx| {
4534 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
4535 });
4536
4537 perform_search(search_view, "let ", cx);
4538 let fake_server = fake_servers.next().await.unwrap();
4539 cx.executor().advance_clock(Duration::from_secs(1));
4540 cx.executor().run_until_parked();
4541 search_view
4542 .update(cx, |search_view, _, cx| {
4543 assert_eq!(
4544 search_view
4545 .results_editor
4546 .update(cx, |editor, cx| editor.display_text(cx)),
4547 "\n\nfn main() { let a: i32 = 2; }\n"
4548 );
4549 })
4550 .unwrap();
4551 assert_eq!(
4552 requests_count.load(atomic::Ordering::Acquire),
4553 1,
4554 "New hints should have been queried",
4555 );
4556
4557 // Can do the 2nd search without any panics
4558 perform_search(search_view, "let ", cx);
4559 cx.executor().advance_clock(Duration::from_secs(1));
4560 cx.executor().run_until_parked();
4561 search_view
4562 .update(cx, |search_view, _, cx| {
4563 assert_eq!(
4564 search_view
4565 .results_editor
4566 .update(cx, |editor, cx| editor.display_text(cx)),
4567 "\n\nfn main() { let a: i32 = 2; }\n"
4568 );
4569 })
4570 .unwrap();
4571 assert_eq!(
4572 requests_count.load(atomic::Ordering::Acquire),
4573 2,
4574 "We did drop the previous buffer when cleared the old project search results, hence another query was made",
4575 );
4576
4577 let singleton_editor = window
4578 .update(cx, |workspace, window, cx| {
4579 workspace.open_abs_path(
4580 PathBuf::from(path!("/dir/main.rs")),
4581 workspace::OpenOptions::default(),
4582 window,
4583 cx,
4584 )
4585 })
4586 .unwrap()
4587 .await
4588 .unwrap()
4589 .downcast::<Editor>()
4590 .unwrap();
4591 cx.executor().advance_clock(Duration::from_millis(100));
4592 cx.executor().run_until_parked();
4593 singleton_editor.update(cx, |editor, cx| {
4594 assert_eq!(
4595 editor.display_text(cx),
4596 "fn main() { let a: i32 = 2; }\n",
4597 "Newly opened editor should have the correct text with hints",
4598 );
4599 });
4600 assert_eq!(
4601 requests_count.load(atomic::Ordering::Acquire),
4602 2,
4603 "Opening the same buffer again should reuse the cached hints",
4604 );
4605
4606 window
4607 .update(cx, |_, window, cx| {
4608 singleton_editor.update(cx, |editor, cx| {
4609 editor.handle_input("test", window, cx);
4610 });
4611 })
4612 .unwrap();
4613
4614 cx.executor().advance_clock(Duration::from_secs(1));
4615 cx.executor().run_until_parked();
4616 singleton_editor.update(cx, |editor, cx| {
4617 assert_eq!(
4618 editor.display_text(cx),
4619 "testfn main() { l: i32et a = 2; }\n",
4620 "Newly opened editor should have the correct text with hints",
4621 );
4622 });
4623 assert_eq!(
4624 requests_count.load(atomic::Ordering::Acquire),
4625 3,
4626 "We have edited the buffer and should send a new request",
4627 );
4628
4629 window
4630 .update(cx, |_, window, cx| {
4631 singleton_editor.update(cx, |editor, cx| {
4632 editor.undo(&editor::actions::Undo, window, cx);
4633 });
4634 })
4635 .unwrap();
4636 cx.executor().advance_clock(Duration::from_secs(1));
4637 cx.executor().run_until_parked();
4638 assert_eq!(
4639 requests_count.load(atomic::Ordering::Acquire),
4640 4,
4641 "We have edited the buffer again and should send a new request again",
4642 );
4643 singleton_editor.update(cx, |editor, cx| {
4644 assert_eq!(
4645 editor.display_text(cx),
4646 "fn main() { let a: i32 = 2; }\n",
4647 "Newly opened editor should have the correct text with hints",
4648 );
4649 });
4650 project.update(cx, |_, cx| {
4651 cx.emit(project::Event::RefreshInlayHints {
4652 server_id: fake_server.server.server_id(),
4653 request_id: Some(1),
4654 });
4655 });
4656 cx.executor().advance_clock(Duration::from_secs(1));
4657 cx.executor().run_until_parked();
4658 assert_eq!(
4659 requests_count.load(atomic::Ordering::Acquire),
4660 5,
4661 "After a simulated server refresh request, we should have sent another request",
4662 );
4663
4664 perform_search(search_view, "let ", cx);
4665 cx.executor().advance_clock(Duration::from_secs(1));
4666 cx.executor().run_until_parked();
4667 assert_eq!(
4668 requests_count.load(atomic::Ordering::Acquire),
4669 5,
4670 "New project search should reuse the cached hints",
4671 );
4672 search_view
4673 .update(cx, |search_view, _, cx| {
4674 assert_eq!(
4675 search_view
4676 .results_editor
4677 .update(cx, |editor, cx| editor.display_text(cx)),
4678 "\n\nfn main() { let a: i32 = 2; }\n"
4679 );
4680 })
4681 .unwrap();
4682 }
4683
4684 fn init_test(cx: &mut TestAppContext) {
4685 cx.update(|cx| {
4686 let settings = SettingsStore::test(cx);
4687 cx.set_global(settings);
4688
4689 theme::init(theme::LoadThemes::JustBase, cx);
4690
4691 editor::init(cx);
4692 crate::init(cx);
4693 });
4694 }
4695
4696 fn perform_search(
4697 search_view: WindowHandle<ProjectSearchView>,
4698 text: impl Into<Arc<str>>,
4699 cx: &mut TestAppContext,
4700 ) {
4701 search_view
4702 .update(cx, |search_view, window, cx| {
4703 search_view.query_editor.update(cx, |query_editor, cx| {
4704 query_editor.set_text(text, window, cx)
4705 });
4706 search_view.search(cx);
4707 })
4708 .unwrap();
4709 // Ensure editor highlights appear after the search is done
4710 cx.executor().advance_clock(
4711 editor::SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(100),
4712 );
4713 cx.background_executor.run_until_parked();
4714 }
4715}