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