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 autosave: false,
1302 },
1303 project,
1304 window,
1305 cx,
1306 )
1307 })?
1308 .await
1309 .log_err();
1310 }
1311
1312 result != 2
1313 } else {
1314 true
1315 };
1316 if should_search {
1317 this.update(cx, |this, cx| {
1318 this.search(cx);
1319 })?;
1320 }
1321 anyhow::Ok(())
1322 })
1323 }
1324
1325 fn search(&mut self, cx: &mut Context<Self>) {
1326 let open_buffers = if self.included_opened_only {
1327 self.workspace
1328 .update(cx, |workspace, cx| self.open_buffers(cx, workspace))
1329 .ok()
1330 } else {
1331 None
1332 };
1333 if let Some(query) = self.build_search_query(cx, open_buffers) {
1334 self.entity.update(cx, |model, cx| model.search(query, cx));
1335 }
1336 }
1337
1338 pub fn search_query_text(&self, cx: &App) -> String {
1339 self.query_editor.read(cx).text(cx)
1340 }
1341
1342 fn build_search_query(
1343 &mut self,
1344 cx: &mut Context<Self>,
1345 open_buffers: Option<Vec<Entity<Buffer>>>,
1346 ) -> Option<SearchQuery> {
1347 // Do not bail early in this function, as we want to fill out `self.panels_with_errors`.
1348
1349 let text = self.search_query_text(cx);
1350 let included_files = self
1351 .filters_enabled
1352 .then(|| {
1353 match self.parse_path_matches(self.included_files_editor.read(cx).text(cx), cx) {
1354 Ok(included_files) => {
1355 let should_unmark_error =
1356 self.panels_with_errors.remove(&InputPanel::Include);
1357 if should_unmark_error.is_some() {
1358 cx.notify();
1359 }
1360 included_files
1361 }
1362 Err(e) => {
1363 let should_mark_error = self
1364 .panels_with_errors
1365 .insert(InputPanel::Include, e.to_string());
1366 if should_mark_error.is_none() {
1367 cx.notify();
1368 }
1369 PathMatcher::default()
1370 }
1371 }
1372 })
1373 .unwrap_or(PathMatcher::default());
1374 let excluded_files = self
1375 .filters_enabled
1376 .then(|| {
1377 match self.parse_path_matches(self.excluded_files_editor.read(cx).text(cx), cx) {
1378 Ok(excluded_files) => {
1379 let should_unmark_error =
1380 self.panels_with_errors.remove(&InputPanel::Exclude);
1381 if should_unmark_error.is_some() {
1382 cx.notify();
1383 }
1384
1385 excluded_files
1386 }
1387 Err(e) => {
1388 let should_mark_error = self
1389 .panels_with_errors
1390 .insert(InputPanel::Exclude, e.to_string());
1391 if should_mark_error.is_none() {
1392 cx.notify();
1393 }
1394 PathMatcher::default()
1395 }
1396 }
1397 })
1398 .unwrap_or(PathMatcher::default());
1399
1400 // If the project contains multiple visible worktrees, we match the
1401 // include/exclude patterns against full paths to allow them to be
1402 // disambiguated. For single worktree projects we use worktree relative
1403 // paths for convenience.
1404 let match_full_paths = self
1405 .entity
1406 .read(cx)
1407 .project
1408 .read(cx)
1409 .visible_worktrees(cx)
1410 .count()
1411 > 1;
1412
1413 let query = if self.search_options.contains(SearchOptions::REGEX) {
1414 match SearchQuery::regex(
1415 text,
1416 self.search_options.contains(SearchOptions::WHOLE_WORD),
1417 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1418 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1419 self.search_options
1420 .contains(SearchOptions::ONE_MATCH_PER_LINE),
1421 included_files,
1422 excluded_files,
1423 match_full_paths,
1424 open_buffers,
1425 ) {
1426 Ok(query) => {
1427 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
1428 if should_unmark_error.is_some() {
1429 cx.notify();
1430 }
1431
1432 Some(query)
1433 }
1434 Err(e) => {
1435 let should_mark_error = self
1436 .panels_with_errors
1437 .insert(InputPanel::Query, e.to_string());
1438 if should_mark_error.is_none() {
1439 cx.notify();
1440 }
1441
1442 None
1443 }
1444 }
1445 } else {
1446 match SearchQuery::text(
1447 text,
1448 self.search_options.contains(SearchOptions::WHOLE_WORD),
1449 self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1450 self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1451 included_files,
1452 excluded_files,
1453 match_full_paths,
1454 open_buffers,
1455 ) {
1456 Ok(query) => {
1457 let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
1458 if should_unmark_error.is_some() {
1459 cx.notify();
1460 }
1461
1462 Some(query)
1463 }
1464 Err(e) => {
1465 let should_mark_error = self
1466 .panels_with_errors
1467 .insert(InputPanel::Query, e.to_string());
1468 if should_mark_error.is_none() {
1469 cx.notify();
1470 }
1471
1472 None
1473 }
1474 }
1475 };
1476 if !self.panels_with_errors.is_empty() {
1477 return None;
1478 }
1479 if query.as_ref().is_some_and(|query| query.is_empty()) {
1480 return None;
1481 }
1482 query
1483 }
1484
1485 fn open_buffers(&self, cx: &App, workspace: &Workspace) -> Vec<Entity<Buffer>> {
1486 let mut buffers = Vec::new();
1487 for editor in workspace.items_of_type::<Editor>(cx) {
1488 if let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() {
1489 buffers.push(buffer);
1490 }
1491 }
1492 buffers
1493 }
1494
1495 fn parse_path_matches(&self, text: String, cx: &App) -> anyhow::Result<PathMatcher> {
1496 let path_style = self.entity.read(cx).project.read(cx).path_style(cx);
1497 let queries = split_glob_patterns(&text)
1498 .into_iter()
1499 .map(str::trim)
1500 .filter(|maybe_glob_str| !maybe_glob_str.is_empty())
1501 .map(str::to_owned)
1502 .collect::<Vec<_>>();
1503 Ok(PathMatcher::new(&queries, path_style)?)
1504 }
1505
1506 fn select_match(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
1507 if let Some(index) = self.active_match_index {
1508 let match_ranges = self.entity.read(cx).match_ranges.clone();
1509
1510 if !EditorSettings::get_global(cx).search_wrap
1511 && ((direction == Direction::Next && index + 1 >= match_ranges.len())
1512 || (direction == Direction::Prev && index == 0))
1513 {
1514 crate::show_no_more_matches(window, cx);
1515 return;
1516 }
1517
1518 let new_index = self.results_editor.update(cx, |editor, cx| {
1519 editor.match_index_for_direction(
1520 &match_ranges,
1521 index,
1522 direction,
1523 1,
1524 SearchToken::default(),
1525 window,
1526 cx,
1527 )
1528 });
1529
1530 let range_to_select = match_ranges[new_index].clone();
1531 self.results_editor.update(cx, |editor, cx| {
1532 let range_to_select = editor.range_for_match(&range_to_select);
1533 let autoscroll = if EditorSettings::get_global(cx).search.center_on_match {
1534 Autoscroll::center()
1535 } else {
1536 Autoscroll::fit()
1537 };
1538 editor.unfold_ranges(std::slice::from_ref(&range_to_select), false, true, cx);
1539 editor.change_selections(SelectionEffects::scroll(autoscroll), window, cx, |s| {
1540 s.select_ranges([range_to_select])
1541 });
1542 });
1543 self.highlight_matches(&match_ranges, Some(new_index), cx);
1544 }
1545 }
1546
1547 fn focus_query_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1548 self.query_editor.update(cx, |query_editor, cx| {
1549 query_editor.select_all(&SelectAll, window, cx);
1550 });
1551 let editor_handle = self.query_editor.focus_handle(cx);
1552 window.focus(&editor_handle, cx);
1553 }
1554
1555 fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
1556 self.set_search_editor(SearchInputKind::Query, query, window, cx);
1557 if EditorSettings::get_global(cx).use_smartcase_search
1558 && !query.is_empty()
1559 && self.search_options.contains(SearchOptions::CASE_SENSITIVE)
1560 != contains_uppercase(query)
1561 {
1562 self.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx)
1563 }
1564 }
1565
1566 fn set_search_editor(
1567 &mut self,
1568 kind: SearchInputKind,
1569 text: &str,
1570 window: &mut Window,
1571 cx: &mut Context<Self>,
1572 ) {
1573 let editor = match kind {
1574 SearchInputKind::Query => &self.query_editor,
1575 SearchInputKind::Include => &self.included_files_editor,
1576
1577 SearchInputKind::Exclude => &self.excluded_files_editor,
1578 };
1579 editor.update(cx, |editor, cx| {
1580 editor.set_text(text, window, cx);
1581 editor.request_autoscroll(Autoscroll::fit(), cx);
1582 });
1583 }
1584
1585 fn focus_results_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1586 self.query_editor.update(cx, |query_editor, cx| {
1587 let cursor = query_editor.selections.newest_anchor().head();
1588 query_editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
1589 s.select_ranges([cursor..cursor])
1590 });
1591 });
1592 let results_handle = self.results_editor.focus_handle(cx);
1593 window.focus(&results_handle, cx);
1594 }
1595
1596 fn entity_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1597 let match_ranges = self.entity.read(cx).match_ranges.clone();
1598
1599 if match_ranges.is_empty() {
1600 self.active_match_index = None;
1601 self.results_editor.update(cx, |editor, cx| {
1602 editor.clear_background_highlights(HighlightKey::ProjectSearchView, cx);
1603 });
1604 } else {
1605 self.active_match_index = Some(0);
1606 self.update_match_index(cx);
1607 let prev_search_id = mem::replace(&mut self.search_id, self.entity.read(cx).search_id);
1608 let is_new_search = self.search_id != prev_search_id;
1609 self.results_editor.update(cx, |editor, cx| {
1610 if is_new_search {
1611 let range_to_select = match_ranges
1612 .first()
1613 .map(|range| editor.range_for_match(range));
1614 editor.change_selections(Default::default(), window, cx, |s| {
1615 s.select_ranges(range_to_select)
1616 });
1617 editor.scroll(Point::default(), Some(Axis::Vertical), window, cx);
1618 }
1619 });
1620 if is_new_search && self.query_editor.focus_handle(cx).is_focused(window) {
1621 self.focus_results_editor(window, cx);
1622 }
1623 }
1624
1625 cx.emit(ViewEvent::UpdateTab);
1626 cx.notify();
1627
1628 if self.pending_replace_all && self.entity.read(cx).pending_search.is_none() {
1629 self.replace_all(&ReplaceAll, window, cx);
1630 }
1631 }
1632
1633 fn update_match_index(&mut self, cx: &mut Context<Self>) {
1634 let results_editor = self.results_editor.read(cx);
1635 let newest_anchor = results_editor.selections.newest_anchor().head();
1636 let buffer_snapshot = results_editor.buffer().read(cx).snapshot(cx);
1637 let new_index = self.entity.update(cx, |this, cx| {
1638 let new_index = active_match_index(
1639 Direction::Next,
1640 &this.match_ranges,
1641 &newest_anchor,
1642 &buffer_snapshot,
1643 );
1644
1645 self.highlight_matches(&this.match_ranges, new_index, cx);
1646 new_index
1647 });
1648
1649 if self.active_match_index != new_index {
1650 self.active_match_index = new_index;
1651 cx.notify();
1652 }
1653 }
1654
1655 #[ztracing::instrument(skip_all)]
1656 fn highlight_matches(
1657 &self,
1658 match_ranges: &[Range<Anchor>],
1659 active_index: Option<usize>,
1660 cx: &mut App,
1661 ) {
1662 self.results_editor.update(cx, |editor, cx| {
1663 editor.highlight_background(
1664 HighlightKey::ProjectSearchView,
1665 match_ranges,
1666 move |index, theme| {
1667 if active_index == Some(*index) {
1668 theme.colors().search_active_match_background
1669 } else {
1670 theme.colors().search_match_background
1671 }
1672 },
1673 cx,
1674 );
1675 });
1676 }
1677
1678 pub fn has_matches(&self) -> bool {
1679 self.active_match_index.is_some()
1680 }
1681
1682 fn landing_text_minor(&self, cx: &App) -> impl IntoElement {
1683 let focus_handle = self.focus_handle.clone();
1684 v_flex()
1685 .gap_1()
1686 .child(
1687 Label::new("Hit enter to search. For more options:")
1688 .color(Color::Muted)
1689 .mb_2(),
1690 )
1691 .child(
1692 Button::new("filter-paths", "Include/exclude specific paths")
1693 .start_icon(Icon::new(IconName::Filter).size(IconSize::Small))
1694 .key_binding(KeyBinding::for_action_in(&ToggleFilters, &focus_handle, cx))
1695 .on_click(|_event, window, cx| {
1696 window.dispatch_action(ToggleFilters.boxed_clone(), cx)
1697 }),
1698 )
1699 .child(
1700 Button::new("find-replace", "Find and replace")
1701 .start_icon(Icon::new(IconName::Replace).size(IconSize::Small))
1702 .key_binding(KeyBinding::for_action_in(&ToggleReplace, &focus_handle, cx))
1703 .on_click(|_event, window, cx| {
1704 window.dispatch_action(ToggleReplace.boxed_clone(), cx)
1705 }),
1706 )
1707 .child(
1708 Button::new("regex", "Match with regex")
1709 .start_icon(Icon::new(IconName::Regex).size(IconSize::Small))
1710 .key_binding(KeyBinding::for_action_in(&ToggleRegex, &focus_handle, cx))
1711 .on_click(|_event, window, cx| {
1712 window.dispatch_action(ToggleRegex.boxed_clone(), cx)
1713 }),
1714 )
1715 .child(
1716 Button::new("match-case", "Match case")
1717 .start_icon(Icon::new(IconName::CaseSensitive).size(IconSize::Small))
1718 .key_binding(KeyBinding::for_action_in(
1719 &ToggleCaseSensitive,
1720 &focus_handle,
1721 cx,
1722 ))
1723 .on_click(|_event, window, cx| {
1724 window.dispatch_action(ToggleCaseSensitive.boxed_clone(), cx)
1725 }),
1726 )
1727 .child(
1728 Button::new("match-whole-words", "Match whole words")
1729 .start_icon(Icon::new(IconName::WholeWord).size(IconSize::Small))
1730 .key_binding(KeyBinding::for_action_in(
1731 &ToggleWholeWord,
1732 &focus_handle,
1733 cx,
1734 ))
1735 .on_click(|_event, window, cx| {
1736 window.dispatch_action(ToggleWholeWord.boxed_clone(), cx)
1737 }),
1738 )
1739 }
1740
1741 fn border_color_for(&self, panel: InputPanel, cx: &App) -> Hsla {
1742 if self.panels_with_errors.contains_key(&panel) {
1743 Color::Error.color(cx)
1744 } else {
1745 cx.theme().colors().border
1746 }
1747 }
1748
1749 fn move_focus_to_results(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1750 if !self.results_editor.focus_handle(cx).is_focused(window)
1751 && !self.entity.read(cx).match_ranges.is_empty()
1752 {
1753 cx.stop_propagation();
1754 self.focus_results_editor(window, cx)
1755 }
1756 }
1757
1758 #[cfg(any(test, feature = "test-support"))]
1759 pub fn results_editor(&self) -> &Entity<Editor> {
1760 &self.results_editor
1761 }
1762
1763 fn adjust_query_regex_language(&self, cx: &mut App) {
1764 let enable = self.search_options.contains(SearchOptions::REGEX);
1765 let query_buffer = self
1766 .query_editor
1767 .read(cx)
1768 .buffer()
1769 .read(cx)
1770 .as_singleton()
1771 .expect("query editor should be backed by a singleton buffer");
1772 if enable {
1773 if let Some(regex_language) = self.regex_language.clone() {
1774 query_buffer.update(cx, |query_buffer, cx| {
1775 query_buffer.set_language(Some(regex_language), cx);
1776 })
1777 }
1778 } else {
1779 query_buffer.update(cx, |query_buffer, cx| {
1780 query_buffer.set_language(None, cx);
1781 })
1782 }
1783 }
1784}
1785
1786fn buffer_search_query(
1787 workspace: &mut Workspace,
1788 item: &dyn ItemHandle,
1789 cx: &mut Context<Workspace>,
1790) -> Option<String> {
1791 let buffer_search_bar = workspace
1792 .pane_for(item)
1793 .and_then(|pane| {
1794 pane.read(cx)
1795 .toolbar()
1796 .read(cx)
1797 .item_of_type::<BufferSearchBar>()
1798 })?
1799 .read(cx);
1800 if buffer_search_bar.query_editor_focused() {
1801 let buffer_search_query = buffer_search_bar.query(cx);
1802 if !buffer_search_query.is_empty() {
1803 return Some(buffer_search_query);
1804 }
1805 }
1806 None
1807}
1808
1809impl Default for ProjectSearchBar {
1810 fn default() -> Self {
1811 Self::new()
1812 }
1813}
1814
1815impl ProjectSearchBar {
1816 pub fn new() -> Self {
1817 Self {
1818 active_project_search: None,
1819 subscription: None,
1820 }
1821 }
1822
1823 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1824 if let Some(search_view) = self.active_project_search.as_ref() {
1825 search_view.update(cx, |search_view, cx| {
1826 if !search_view
1827 .replacement_editor
1828 .focus_handle(cx)
1829 .is_focused(window)
1830 {
1831 cx.stop_propagation();
1832 search_view
1833 .prompt_to_save_if_dirty_then_search(window, cx)
1834 .detach_and_log_err(cx);
1835 }
1836 });
1837 }
1838 }
1839
1840 fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
1841 self.cycle_field(Direction::Next, window, cx);
1842 }
1843
1844 fn backtab(&mut self, _: &Backtab, window: &mut Window, cx: &mut Context<Self>) {
1845 self.cycle_field(Direction::Prev, window, cx);
1846 }
1847
1848 fn focus_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1849 if let Some(search_view) = self.active_project_search.as_ref() {
1850 search_view.update(cx, |search_view, cx| {
1851 search_view.query_editor.focus_handle(cx).focus(window, cx);
1852 });
1853 }
1854 }
1855
1856 fn cycle_field(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
1857 let active_project_search = match &self.active_project_search {
1858 Some(active_project_search) => active_project_search,
1859 None => return,
1860 };
1861
1862 active_project_search.update(cx, |project_view, cx| {
1863 let mut views = vec![project_view.query_editor.focus_handle(cx)];
1864 if project_view.replace_enabled {
1865 views.push(project_view.replacement_editor.focus_handle(cx));
1866 }
1867 if project_view.filters_enabled {
1868 views.extend([
1869 project_view.included_files_editor.focus_handle(cx),
1870 project_view.excluded_files_editor.focus_handle(cx),
1871 ]);
1872 }
1873 let current_index = match views.iter().position(|focus| focus.is_focused(window)) {
1874 Some(index) => index,
1875 None => return,
1876 };
1877
1878 let new_index = match direction {
1879 Direction::Next => (current_index + 1) % views.len(),
1880 Direction::Prev if current_index == 0 => views.len() - 1,
1881 Direction::Prev => (current_index - 1) % views.len(),
1882 };
1883 let next_focus_handle = &views[new_index];
1884 window.focus(next_focus_handle, cx);
1885 cx.stop_propagation();
1886 });
1887 }
1888
1889 pub(crate) fn toggle_search_option(
1890 &mut self,
1891 option: SearchOptions,
1892 window: &mut Window,
1893 cx: &mut Context<Self>,
1894 ) -> bool {
1895 if self.active_project_search.is_none() {
1896 return false;
1897 }
1898
1899 cx.spawn_in(window, async move |this, cx| {
1900 let task = this.update_in(cx, |this, window, cx| {
1901 let search_view = this.active_project_search.as_ref()?;
1902 search_view.update(cx, |search_view, cx| {
1903 search_view.toggle_search_option(option, cx);
1904 search_view
1905 .entity
1906 .read(cx)
1907 .active_query
1908 .is_some()
1909 .then(|| search_view.prompt_to_save_if_dirty_then_search(window, cx))
1910 })
1911 })?;
1912 if let Some(task) = task {
1913 task.await?;
1914 }
1915 this.update(cx, |_, cx| {
1916 cx.notify();
1917 })?;
1918 anyhow::Ok(())
1919 })
1920 .detach();
1921 true
1922 }
1923
1924 fn toggle_replace(&mut self, _: &ToggleReplace, window: &mut Window, cx: &mut Context<Self>) {
1925 if let Some(search) = &self.active_project_search {
1926 search.update(cx, |this, cx| {
1927 this.replace_enabled = !this.replace_enabled;
1928 let editor_to_focus = if this.replace_enabled {
1929 this.replacement_editor.focus_handle(cx)
1930 } else {
1931 this.query_editor.focus_handle(cx)
1932 };
1933 window.focus(&editor_to_focus, cx);
1934 cx.notify();
1935 });
1936 }
1937 }
1938
1939 fn toggle_filters(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1940 if let Some(search_view) = self.active_project_search.as_ref() {
1941 search_view.update(cx, |search_view, cx| {
1942 search_view.toggle_filters(cx);
1943 search_view
1944 .included_files_editor
1945 .update(cx, |_, cx| cx.notify());
1946 search_view
1947 .excluded_files_editor
1948 .update(cx, |_, cx| cx.notify());
1949 window.refresh();
1950 cx.notify();
1951 });
1952 cx.notify();
1953 true
1954 } else {
1955 false
1956 }
1957 }
1958
1959 fn toggle_opened_only(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1960 if self.active_project_search.is_none() {
1961 return false;
1962 }
1963
1964 cx.spawn_in(window, async move |this, cx| {
1965 let task = this.update_in(cx, |this, window, cx| {
1966 let search_view = this.active_project_search.as_ref()?;
1967 search_view.update(cx, |search_view, cx| {
1968 search_view.toggle_opened_only(window, cx);
1969 search_view
1970 .entity
1971 .read(cx)
1972 .active_query
1973 .is_some()
1974 .then(|| search_view.prompt_to_save_if_dirty_then_search(window, cx))
1975 })
1976 })?;
1977 if let Some(task) = task {
1978 task.await?;
1979 }
1980 this.update(cx, |_, cx| {
1981 cx.notify();
1982 })?;
1983 anyhow::Ok(())
1984 })
1985 .detach();
1986 true
1987 }
1988
1989 fn is_opened_only_enabled(&self, cx: &App) -> bool {
1990 if let Some(search_view) = self.active_project_search.as_ref() {
1991 search_view.read(cx).included_opened_only
1992 } else {
1993 false
1994 }
1995 }
1996
1997 fn move_focus_to_results(&self, window: &mut Window, cx: &mut Context<Self>) {
1998 if let Some(search_view) = self.active_project_search.as_ref() {
1999 search_view.update(cx, |search_view, cx| {
2000 search_view.move_focus_to_results(window, cx);
2001 });
2002 cx.notify();
2003 }
2004 }
2005
2006 fn next_history_query(
2007 &mut self,
2008 _: &NextHistoryQuery,
2009 window: &mut Window,
2010 cx: &mut Context<Self>,
2011 ) {
2012 if let Some(search_view) = self.active_project_search.as_ref() {
2013 search_view.update(cx, |search_view, cx| {
2014 for (editor, kind) in [
2015 (search_view.query_editor.clone(), SearchInputKind::Query),
2016 (
2017 search_view.included_files_editor.clone(),
2018 SearchInputKind::Include,
2019 ),
2020 (
2021 search_view.excluded_files_editor.clone(),
2022 SearchInputKind::Exclude,
2023 ),
2024 ] {
2025 if editor.focus_handle(cx).is_focused(window) {
2026 if !should_navigate_history(&editor, HistoryNavigationDirection::Next, cx) {
2027 cx.propagate();
2028 return;
2029 }
2030
2031 let new_query = search_view.entity.update(cx, |model, cx| {
2032 let project = model.project.clone();
2033
2034 if let Some(new_query) = project.update(cx, |project, _| {
2035 project
2036 .search_history_mut(kind)
2037 .next(model.cursor_mut(kind))
2038 .map(str::to_string)
2039 }) {
2040 Some(new_query)
2041 } else {
2042 model.cursor_mut(kind).take_draft()
2043 }
2044 });
2045 if let Some(new_query) = new_query {
2046 search_view.set_search_editor(kind, &new_query, window, cx);
2047 }
2048 }
2049 }
2050 });
2051 }
2052 }
2053
2054 fn previous_history_query(
2055 &mut self,
2056 _: &PreviousHistoryQuery,
2057 window: &mut Window,
2058 cx: &mut Context<Self>,
2059 ) {
2060 if let Some(search_view) = self.active_project_search.as_ref() {
2061 search_view.update(cx, |search_view, cx| {
2062 for (editor, kind) in [
2063 (search_view.query_editor.clone(), SearchInputKind::Query),
2064 (
2065 search_view.included_files_editor.clone(),
2066 SearchInputKind::Include,
2067 ),
2068 (
2069 search_view.excluded_files_editor.clone(),
2070 SearchInputKind::Exclude,
2071 ),
2072 ] {
2073 if editor.focus_handle(cx).is_focused(window) {
2074 if !should_navigate_history(
2075 &editor,
2076 HistoryNavigationDirection::Previous,
2077 cx,
2078 ) {
2079 cx.propagate();
2080 return;
2081 }
2082
2083 if editor.read(cx).text(cx).is_empty()
2084 && let Some(new_query) = search_view
2085 .entity
2086 .read(cx)
2087 .project
2088 .read(cx)
2089 .search_history(kind)
2090 .current(search_view.entity.read(cx).cursor(kind))
2091 .map(str::to_string)
2092 {
2093 search_view.set_search_editor(kind, &new_query, window, cx);
2094 return;
2095 }
2096
2097 let current_query = editor.read(cx).text(cx);
2098 if let Some(new_query) = search_view.entity.update(cx, |model, cx| {
2099 let project = model.project.clone();
2100 project.update(cx, |project, _| {
2101 project
2102 .search_history_mut(kind)
2103 .previous(model.cursor_mut(kind), ¤t_query)
2104 .map(str::to_string)
2105 })
2106 }) {
2107 search_view.set_search_editor(kind, &new_query, window, cx);
2108 }
2109 }
2110 }
2111 });
2112 }
2113 }
2114
2115 fn select_next_match(
2116 &mut self,
2117 _: &SelectNextMatch,
2118 window: &mut Window,
2119 cx: &mut Context<Self>,
2120 ) {
2121 if let Some(search) = self.active_project_search.as_ref() {
2122 search.update(cx, |this, cx| {
2123 this.select_match(Direction::Next, window, cx);
2124 })
2125 }
2126 }
2127
2128 fn select_prev_match(
2129 &mut self,
2130 _: &SelectPreviousMatch,
2131 window: &mut Window,
2132 cx: &mut Context<Self>,
2133 ) {
2134 if let Some(search) = self.active_project_search.as_ref() {
2135 search.update(cx, |this, cx| {
2136 this.select_match(Direction::Prev, window, cx);
2137 })
2138 }
2139 }
2140}
2141
2142impl Render for ProjectSearchBar {
2143 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2144 let Some(search) = self.active_project_search.clone() else {
2145 return div().into_any_element();
2146 };
2147 let search = search.read(cx);
2148 let focus_handle = search.focus_handle(cx);
2149
2150 let container_width = window.viewport_size().width;
2151 let input_width = SearchInputWidth::calc_width(container_width);
2152
2153 let input_base_styles = |panel: InputPanel| {
2154 input_base_styles(search.border_color_for(panel, cx), |div| match panel {
2155 InputPanel::Query | InputPanel::Replacement => div.w(input_width),
2156 InputPanel::Include | InputPanel::Exclude => div.flex_grow(),
2157 })
2158 };
2159 let theme_colors = cx.theme().colors();
2160 let project_search = search.entity.read(cx);
2161 let limit_reached = project_search.limit_reached;
2162 let is_search_underway = project_search.pending_search.is_some();
2163
2164 let color_override = match (
2165 &project_search.pending_search,
2166 project_search.no_results,
2167 &project_search.active_query,
2168 &project_search.last_search_query_text,
2169 ) {
2170 (None, Some(true), Some(q), Some(p)) if q.as_str() == p => Some(Color::Error),
2171 _ => None,
2172 };
2173
2174 let match_text = search
2175 .active_match_index
2176 .and_then(|index| {
2177 let index = index + 1;
2178 let match_quantity = project_search.match_ranges.len();
2179 if match_quantity > 0 {
2180 debug_assert!(match_quantity >= index);
2181 if limit_reached {
2182 Some(format!("{index}/{match_quantity}+"))
2183 } else {
2184 Some(format!("{index}/{match_quantity}"))
2185 }
2186 } else {
2187 None
2188 }
2189 })
2190 .unwrap_or_else(|| "0/0".to_string());
2191
2192 let query_focus = search.query_editor.focus_handle(cx);
2193
2194 let query_column = input_base_styles(InputPanel::Query)
2195 .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
2196 .on_action(cx.listener(|this, action, window, cx| {
2197 this.previous_history_query(action, window, cx)
2198 }))
2199 .on_action(
2200 cx.listener(|this, action, window, cx| this.next_history_query(action, window, cx)),
2201 )
2202 .child(div().flex_1().py_1().child(render_text_input(
2203 &search.query_editor,
2204 color_override,
2205 cx,
2206 )))
2207 .child(
2208 h_flex()
2209 .gap_1()
2210 .child(SearchOption::CaseSensitive.as_button(
2211 search.search_options,
2212 SearchSource::Project(cx),
2213 focus_handle.clone(),
2214 ))
2215 .child(SearchOption::WholeWord.as_button(
2216 search.search_options,
2217 SearchSource::Project(cx),
2218 focus_handle.clone(),
2219 ))
2220 .child(SearchOption::Regex.as_button(
2221 search.search_options,
2222 SearchSource::Project(cx),
2223 focus_handle.clone(),
2224 )),
2225 );
2226
2227 let matches_column = h_flex()
2228 .ml_1()
2229 .pl_1p5()
2230 .border_l_1()
2231 .border_color(theme_colors.border_variant)
2232 .child(render_action_button(
2233 "project-search-nav-button",
2234 IconName::ChevronLeft,
2235 search
2236 .active_match_index
2237 .is_none()
2238 .then_some(ActionButtonState::Disabled),
2239 "Select Previous Match",
2240 &SelectPreviousMatch,
2241 query_focus.clone(),
2242 ))
2243 .child(render_action_button(
2244 "project-search-nav-button",
2245 IconName::ChevronRight,
2246 search
2247 .active_match_index
2248 .is_none()
2249 .then_some(ActionButtonState::Disabled),
2250 "Select Next Match",
2251 &SelectNextMatch,
2252 query_focus.clone(),
2253 ))
2254 .child(
2255 div()
2256 .id("matches")
2257 .ml_2()
2258 .min_w(rems_from_px(40.))
2259 .child(
2260 h_flex()
2261 .gap_1p5()
2262 .child(
2263 Label::new(match_text)
2264 .size(LabelSize::Small)
2265 .when(search.active_match_index.is_some(), |this| {
2266 this.color(Color::Disabled)
2267 }),
2268 )
2269 .when(is_search_underway, |this| {
2270 this.child(
2271 Icon::new(IconName::ArrowCircle)
2272 .color(Color::Accent)
2273 .size(IconSize::Small)
2274 .with_rotate_animation(2)
2275 .into_any_element(),
2276 )
2277 }),
2278 )
2279 .when(limit_reached, |this| {
2280 this.tooltip(Tooltip::text(
2281 "Search Limits Reached\nTry narrowing your search",
2282 ))
2283 }),
2284 );
2285
2286 let mode_column = h_flex()
2287 .gap_1()
2288 .min_w_64()
2289 .child(
2290 IconButton::new("project-search-filter-button", IconName::Filter)
2291 .shape(IconButtonShape::Square)
2292 .tooltip(|_window, cx| {
2293 Tooltip::for_action("Toggle Filters", &ToggleFilters, cx)
2294 })
2295 .on_click(cx.listener(|this, _, window, cx| {
2296 this.toggle_filters(window, cx);
2297 }))
2298 .toggle_state(
2299 self.active_project_search
2300 .as_ref()
2301 .map(|search| search.read(cx).filters_enabled)
2302 .unwrap_or_default(),
2303 )
2304 .tooltip({
2305 let focus_handle = focus_handle.clone();
2306 move |_window, cx| {
2307 Tooltip::for_action_in(
2308 "Toggle Filters",
2309 &ToggleFilters,
2310 &focus_handle,
2311 cx,
2312 )
2313 }
2314 }),
2315 )
2316 .child(render_action_button(
2317 "project-search",
2318 IconName::Replace,
2319 self.active_project_search
2320 .as_ref()
2321 .map(|search| search.read(cx).replace_enabled)
2322 .and_then(|enabled| enabled.then_some(ActionButtonState::Toggled)),
2323 "Toggle Replace",
2324 &ToggleReplace,
2325 focus_handle.clone(),
2326 ))
2327 .child(matches_column);
2328
2329 let is_collapsed = search.results_editor.read(cx).has_any_buffer_folded(cx);
2330
2331 let (icon, tooltip_label) = if is_collapsed {
2332 (IconName::ChevronUpDown, "Expand All Search Results")
2333 } else {
2334 (IconName::ChevronDownUp, "Collapse All Search Results")
2335 };
2336
2337 let expand_button = IconButton::new("project-search-collapse-expand", icon)
2338 .shape(IconButtonShape::Square)
2339 .tooltip(move |_, cx| {
2340 Tooltip::for_action_in(
2341 tooltip_label,
2342 &ToggleAllSearchResults,
2343 &query_focus.clone(),
2344 cx,
2345 )
2346 })
2347 .on_click(cx.listener(|this, _, window, cx| {
2348 if let Some(active_view) = &this.active_project_search {
2349 active_view.update(cx, |active_view, cx| {
2350 active_view.toggle_all_search_results(&ToggleAllSearchResults, window, cx);
2351 })
2352 }
2353 }));
2354
2355 let search_line = h_flex()
2356 .pl_0p5()
2357 .w_full()
2358 .gap_2()
2359 .child(expand_button)
2360 .child(query_column)
2361 .child(mode_column);
2362
2363 let replace_line = search.replace_enabled.then(|| {
2364 let replace_column = input_base_styles(InputPanel::Replacement).child(
2365 div().flex_1().py_1().child(render_text_input(
2366 &search.replacement_editor,
2367 None,
2368 cx,
2369 )),
2370 );
2371
2372 let focus_handle = search.replacement_editor.read(cx).focus_handle(cx);
2373 let replace_actions = h_flex()
2374 .min_w_64()
2375 .gap_1()
2376 .child(render_action_button(
2377 "project-search-replace-button",
2378 IconName::ReplaceNext,
2379 is_search_underway.then_some(ActionButtonState::Disabled),
2380 "Replace Next Match",
2381 &ReplaceNext,
2382 focus_handle.clone(),
2383 ))
2384 .child(render_action_button(
2385 "project-search-replace-button",
2386 IconName::ReplaceAll,
2387 Default::default(),
2388 "Replace All Matches",
2389 &ReplaceAll,
2390 focus_handle,
2391 ));
2392
2393 h_flex()
2394 .w_full()
2395 .gap_2()
2396 .child(alignment_element())
2397 .child(replace_column)
2398 .child(replace_actions)
2399 });
2400
2401 let filter_line = search.filters_enabled.then(|| {
2402 let include = input_base_styles(InputPanel::Include)
2403 .on_action(cx.listener(|this, action, window, cx| {
2404 this.previous_history_query(action, window, cx)
2405 }))
2406 .on_action(cx.listener(|this, action, window, cx| {
2407 this.next_history_query(action, window, cx)
2408 }))
2409 .child(render_text_input(&search.included_files_editor, None, cx));
2410 let exclude = input_base_styles(InputPanel::Exclude)
2411 .on_action(cx.listener(|this, action, window, cx| {
2412 this.previous_history_query(action, window, cx)
2413 }))
2414 .on_action(cx.listener(|this, action, window, cx| {
2415 this.next_history_query(action, window, cx)
2416 }))
2417 .child(render_text_input(&search.excluded_files_editor, None, cx));
2418 let mode_column = h_flex()
2419 .gap_1()
2420 .min_w_64()
2421 .child(
2422 IconButton::new("project-search-opened-only", IconName::FolderSearch)
2423 .shape(IconButtonShape::Square)
2424 .toggle_state(self.is_opened_only_enabled(cx))
2425 .tooltip(Tooltip::text("Only Search Open Files"))
2426 .on_click(cx.listener(|this, _, window, cx| {
2427 this.toggle_opened_only(window, cx);
2428 })),
2429 )
2430 .child(SearchOption::IncludeIgnored.as_button(
2431 search.search_options,
2432 SearchSource::Project(cx),
2433 focus_handle,
2434 ));
2435
2436 h_flex()
2437 .w_full()
2438 .gap_2()
2439 .child(alignment_element())
2440 .child(
2441 h_flex()
2442 .w(input_width)
2443 .gap_2()
2444 .child(include)
2445 .child(exclude),
2446 )
2447 .child(mode_column)
2448 });
2449
2450 let mut key_context = KeyContext::default();
2451 key_context.add("ProjectSearchBar");
2452 if search
2453 .replacement_editor
2454 .focus_handle(cx)
2455 .is_focused(window)
2456 {
2457 key_context.add("in_replace");
2458 }
2459
2460 let query_error_line = search
2461 .panels_with_errors
2462 .get(&InputPanel::Query)
2463 .map(|error| {
2464 Label::new(error)
2465 .size(LabelSize::Small)
2466 .color(Color::Error)
2467 .mt_neg_1()
2468 .ml_2()
2469 });
2470
2471 let filter_error_line = search
2472 .panels_with_errors
2473 .get(&InputPanel::Include)
2474 .or_else(|| search.panels_with_errors.get(&InputPanel::Exclude))
2475 .map(|error| {
2476 Label::new(error)
2477 .size(LabelSize::Small)
2478 .color(Color::Error)
2479 .mt_neg_1()
2480 .ml_2()
2481 });
2482
2483 v_flex()
2484 .gap_2()
2485 .w_full()
2486 .key_context(key_context)
2487 .on_action(cx.listener(|this, _: &ToggleFocus, window, cx| {
2488 this.move_focus_to_results(window, cx)
2489 }))
2490 .on_action(cx.listener(|this, _: &ToggleFilters, window, cx| {
2491 this.toggle_filters(window, cx);
2492 }))
2493 .capture_action(cx.listener(Self::tab))
2494 .capture_action(cx.listener(Self::backtab))
2495 .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
2496 .on_action(cx.listener(|this, action, window, cx| {
2497 this.toggle_replace(action, window, cx);
2498 }))
2499 .on_action(cx.listener(|this, _: &ToggleWholeWord, window, cx| {
2500 this.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
2501 }))
2502 .on_action(cx.listener(|this, _: &ToggleCaseSensitive, window, cx| {
2503 this.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
2504 }))
2505 .on_action(cx.listener(|this, action, window, cx| {
2506 if let Some(search) = this.active_project_search.as_ref() {
2507 search.update(cx, |this, cx| {
2508 this.replace_next(action, window, cx);
2509 })
2510 }
2511 }))
2512 .on_action(cx.listener(|this, action, window, cx| {
2513 if let Some(search) = this.active_project_search.as_ref() {
2514 search.update(cx, |this, cx| {
2515 this.replace_all(action, window, cx);
2516 })
2517 }
2518 }))
2519 .when(search.filters_enabled, |this| {
2520 this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, window, cx| {
2521 this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, window, cx);
2522 }))
2523 })
2524 .on_action(cx.listener(Self::select_next_match))
2525 .on_action(cx.listener(Self::select_prev_match))
2526 .child(search_line)
2527 .children(query_error_line)
2528 .children(replace_line)
2529 .children(filter_line)
2530 .children(filter_error_line)
2531 .into_any_element()
2532 }
2533}
2534
2535impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
2536
2537impl ToolbarItemView for ProjectSearchBar {
2538 fn set_active_pane_item(
2539 &mut self,
2540 active_pane_item: Option<&dyn ItemHandle>,
2541 _: &mut Window,
2542 cx: &mut Context<Self>,
2543 ) -> ToolbarItemLocation {
2544 cx.notify();
2545 self.subscription = None;
2546 self.active_project_search = None;
2547 if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
2548 self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
2549 self.active_project_search = Some(search);
2550 ToolbarItemLocation::PrimaryLeft {}
2551 } else {
2552 ToolbarItemLocation::Hidden
2553 }
2554 }
2555}
2556
2557fn register_workspace_action<A: Action>(
2558 workspace: &mut Workspace,
2559 callback: fn(&mut ProjectSearchBar, &A, &mut Window, &mut Context<ProjectSearchBar>),
2560) {
2561 workspace.register_action(move |workspace, action: &A, window, cx| {
2562 if workspace.has_active_modal(window, cx) && !workspace.hide_modal(window, cx) {
2563 cx.propagate();
2564 return;
2565 }
2566
2567 workspace.active_pane().update(cx, |pane, cx| {
2568 pane.toolbar().update(cx, move |workspace, cx| {
2569 if let Some(search_bar) = workspace.item_of_type::<ProjectSearchBar>() {
2570 search_bar.update(cx, move |search_bar, cx| {
2571 if search_bar.active_project_search.is_some() {
2572 callback(search_bar, action, window, cx);
2573 cx.notify();
2574 } else {
2575 cx.propagate();
2576 }
2577 });
2578 }
2579 });
2580 })
2581 });
2582}
2583
2584fn register_workspace_action_for_present_search<A: Action>(
2585 workspace: &mut Workspace,
2586 callback: fn(&mut Workspace, &A, &mut Window, &mut Context<Workspace>),
2587) {
2588 workspace.register_action(move |workspace, action: &A, window, cx| {
2589 if workspace.has_active_modal(window, cx) && !workspace.hide_modal(window, cx) {
2590 cx.propagate();
2591 return;
2592 }
2593
2594 let should_notify = workspace
2595 .active_pane()
2596 .read(cx)
2597 .toolbar()
2598 .read(cx)
2599 .item_of_type::<ProjectSearchBar>()
2600 .map(|search_bar| search_bar.read(cx).active_project_search.is_some())
2601 .unwrap_or(false);
2602 if should_notify {
2603 callback(workspace, action, window, cx);
2604 cx.notify();
2605 } else {
2606 cx.propagate();
2607 }
2608 });
2609}
2610
2611#[cfg(any(test, feature = "test-support"))]
2612pub fn perform_project_search(
2613 search_view: &Entity<ProjectSearchView>,
2614 text: impl Into<std::sync::Arc<str>>,
2615 cx: &mut gpui::VisualTestContext,
2616) {
2617 cx.run_until_parked();
2618 search_view.update_in(cx, |search_view, window, cx| {
2619 search_view.query_editor.update(cx, |query_editor, cx| {
2620 query_editor.set_text(text, window, cx)
2621 });
2622 search_view.search(cx);
2623 });
2624 cx.run_until_parked();
2625}
2626
2627#[cfg(test)]
2628pub mod tests {
2629 use std::{
2630 path::PathBuf,
2631 sync::{
2632 Arc,
2633 atomic::{self, AtomicUsize},
2634 },
2635 time::Duration,
2636 };
2637
2638 use super::*;
2639 use editor::{DisplayPoint, display_map::DisplayRow};
2640 use gpui::{Action, TestAppContext, VisualTestContext, WindowHandle};
2641 use language::{FakeLspAdapter, rust_lang};
2642 use pretty_assertions::assert_eq;
2643 use project::{FakeFs, Fs};
2644 use serde_json::json;
2645 use settings::{
2646 InlayHintSettingsContent, SettingsStore, ThemeColorsContent, ThemeStyleContent,
2647 };
2648 use util::{path, paths::PathStyle, rel_path::rel_path};
2649 use util_macros::perf;
2650 use workspace::{DeploySearch, MultiWorkspace};
2651
2652 #[test]
2653 fn test_split_glob_patterns() {
2654 assert_eq!(split_glob_patterns("a,b,c"), vec!["a", "b", "c"]);
2655 assert_eq!(split_glob_patterns("a, b, c"), vec!["a", " b", " c"]);
2656 assert_eq!(
2657 split_glob_patterns("src/{a,b}/**/*.rs"),
2658 vec!["src/{a,b}/**/*.rs"]
2659 );
2660 assert_eq!(
2661 split_glob_patterns("src/{a,b}/*.rs, tests/**/*.rs"),
2662 vec!["src/{a,b}/*.rs", " tests/**/*.rs"]
2663 );
2664 assert_eq!(split_glob_patterns("{a,b},{c,d}"), vec!["{a,b}", "{c,d}"]);
2665 assert_eq!(split_glob_patterns("{{a,b},{c,d}}"), vec!["{{a,b},{c,d}}"]);
2666 assert_eq!(split_glob_patterns(""), vec![""]);
2667 assert_eq!(split_glob_patterns("a"), vec!["a"]);
2668 // Escaped characters should not be treated as special
2669 assert_eq!(split_glob_patterns(r"a\,b,c"), vec![r"a\,b", "c"]);
2670 assert_eq!(split_glob_patterns(r"\{a,b\}"), vec![r"\{a", r"b\}"]);
2671 assert_eq!(split_glob_patterns(r"a\\,b"), vec![r"a\\", "b"]);
2672 assert_eq!(split_glob_patterns(r"a\\\,b"), vec![r"a\\\,b"]);
2673 }
2674
2675 #[perf]
2676 #[gpui::test]
2677 async fn test_project_search(cx: &mut TestAppContext) {
2678 fn dp(row: u32, col: u32) -> DisplayPoint {
2679 DisplayPoint::new(DisplayRow(row), col)
2680 }
2681
2682 fn assert_active_match_index(
2683 search_view: &WindowHandle<ProjectSearchView>,
2684 cx: &mut TestAppContext,
2685 expected_index: usize,
2686 ) {
2687 search_view
2688 .update(cx, |search_view, _window, _cx| {
2689 assert_eq!(search_view.active_match_index, Some(expected_index));
2690 })
2691 .unwrap();
2692 }
2693
2694 fn assert_selection_range(
2695 search_view: &WindowHandle<ProjectSearchView>,
2696 cx: &mut TestAppContext,
2697 expected_range: Range<DisplayPoint>,
2698 ) {
2699 search_view
2700 .update(cx, |search_view, _window, cx| {
2701 assert_eq!(
2702 search_view.results_editor.update(cx, |editor, cx| editor
2703 .selections
2704 .display_ranges(&editor.display_snapshot(cx))),
2705 [expected_range]
2706 );
2707 })
2708 .unwrap();
2709 }
2710
2711 fn assert_highlights(
2712 search_view: &WindowHandle<ProjectSearchView>,
2713 cx: &mut TestAppContext,
2714 expected_highlights: Vec<(Range<DisplayPoint>, &str)>,
2715 ) {
2716 search_view
2717 .update(cx, |search_view, window, cx| {
2718 let match_bg = cx.theme().colors().search_match_background;
2719 let active_match_bg = cx.theme().colors().search_active_match_background;
2720 let selection_bg = cx
2721 .theme()
2722 .colors()
2723 .editor_document_highlight_bracket_background;
2724
2725 let highlights: Vec<_> = expected_highlights
2726 .into_iter()
2727 .map(|(range, color_type)| {
2728 let color = match color_type {
2729 "active" => active_match_bg,
2730 "match" => match_bg,
2731 "selection" => selection_bg,
2732 _ => panic!("Unknown color type"),
2733 };
2734 (range, color)
2735 })
2736 .collect();
2737
2738 assert_eq!(
2739 search_view.results_editor.update(cx, |editor, cx| editor
2740 .all_text_background_highlights(window, cx)),
2741 highlights.as_slice()
2742 );
2743 })
2744 .unwrap();
2745 }
2746
2747 fn select_match(
2748 search_view: &WindowHandle<ProjectSearchView>,
2749 cx: &mut TestAppContext,
2750 direction: Direction,
2751 ) {
2752 search_view
2753 .update(cx, |search_view, window, cx| {
2754 search_view.select_match(direction, window, cx);
2755 })
2756 .unwrap();
2757 }
2758
2759 init_test(cx);
2760
2761 // Override active search match color since the fallback theme uses the same color
2762 // for normal search match and active one, which can make this test less robust.
2763 cx.update(|cx| {
2764 SettingsStore::update_global(cx, |settings, cx| {
2765 settings.update_user_settings(cx, |settings| {
2766 settings.theme.experimental_theme_overrides = Some(ThemeStyleContent {
2767 colors: ThemeColorsContent {
2768 search_active_match_background: Some("#ff0000ff".to_string()),
2769 ..Default::default()
2770 },
2771 ..Default::default()
2772 });
2773 });
2774 });
2775 });
2776
2777 let fs = FakeFs::new(cx.background_executor.clone());
2778 fs.insert_tree(
2779 path!("/dir"),
2780 json!({
2781 "one.rs": "const ONE: usize = 1;",
2782 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2783 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2784 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2785 }),
2786 )
2787 .await;
2788 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2789 let window =
2790 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2791 let workspace = window
2792 .read_with(cx, |mw, _| mw.workspace().clone())
2793 .unwrap();
2794 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
2795 let search_view = cx.add_window(|window, cx| {
2796 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
2797 });
2798
2799 perform_search(search_view, "TWO", cx);
2800 cx.run_until_parked();
2801
2802 search_view
2803 .update(cx, |search_view, _window, cx| {
2804 assert_eq!(
2805 search_view
2806 .results_editor
2807 .update(cx, |editor, cx| editor.display_text(cx)),
2808 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
2809 );
2810 })
2811 .unwrap();
2812
2813 assert_active_match_index(&search_view, cx, 0);
2814 assert_selection_range(&search_view, cx, dp(2, 32)..dp(2, 35));
2815 assert_highlights(
2816 &search_view,
2817 cx,
2818 vec![
2819 (dp(2, 32)..dp(2, 35), "active"),
2820 (dp(2, 37)..dp(2, 40), "selection"),
2821 (dp(2, 37)..dp(2, 40), "match"),
2822 (dp(5, 6)..dp(5, 9), "selection"),
2823 (dp(5, 6)..dp(5, 9), "match"),
2824 ],
2825 );
2826 select_match(&search_view, cx, Direction::Next);
2827 cx.run_until_parked();
2828
2829 assert_active_match_index(&search_view, cx, 1);
2830 assert_selection_range(&search_view, cx, dp(2, 37)..dp(2, 40));
2831 assert_highlights(
2832 &search_view,
2833 cx,
2834 vec![
2835 (dp(2, 32)..dp(2, 35), "selection"),
2836 (dp(2, 32)..dp(2, 35), "match"),
2837 (dp(2, 37)..dp(2, 40), "active"),
2838 (dp(5, 6)..dp(5, 9), "selection"),
2839 (dp(5, 6)..dp(5, 9), "match"),
2840 ],
2841 );
2842 select_match(&search_view, cx, Direction::Next);
2843 cx.run_until_parked();
2844
2845 assert_active_match_index(&search_view, cx, 2);
2846 assert_selection_range(&search_view, cx, dp(5, 6)..dp(5, 9));
2847 assert_highlights(
2848 &search_view,
2849 cx,
2850 vec![
2851 (dp(2, 32)..dp(2, 35), "selection"),
2852 (dp(2, 32)..dp(2, 35), "match"),
2853 (dp(2, 37)..dp(2, 40), "selection"),
2854 (dp(2, 37)..dp(2, 40), "match"),
2855 (dp(5, 6)..dp(5, 9), "active"),
2856 ],
2857 );
2858 select_match(&search_view, cx, Direction::Next);
2859 cx.run_until_parked();
2860
2861 assert_active_match_index(&search_view, cx, 0);
2862 assert_selection_range(&search_view, cx, dp(2, 32)..dp(2, 35));
2863 assert_highlights(
2864 &search_view,
2865 cx,
2866 vec![
2867 (dp(2, 32)..dp(2, 35), "active"),
2868 (dp(2, 37)..dp(2, 40), "selection"),
2869 (dp(2, 37)..dp(2, 40), "match"),
2870 (dp(5, 6)..dp(5, 9), "selection"),
2871 (dp(5, 6)..dp(5, 9), "match"),
2872 ],
2873 );
2874 select_match(&search_view, cx, Direction::Prev);
2875 cx.run_until_parked();
2876
2877 assert_active_match_index(&search_view, cx, 2);
2878 assert_selection_range(&search_view, cx, dp(5, 6)..dp(5, 9));
2879 assert_highlights(
2880 &search_view,
2881 cx,
2882 vec![
2883 (dp(2, 32)..dp(2, 35), "selection"),
2884 (dp(2, 32)..dp(2, 35), "match"),
2885 (dp(2, 37)..dp(2, 40), "selection"),
2886 (dp(2, 37)..dp(2, 40), "match"),
2887 (dp(5, 6)..dp(5, 9), "active"),
2888 ],
2889 );
2890 select_match(&search_view, cx, Direction::Prev);
2891 cx.run_until_parked();
2892
2893 assert_active_match_index(&search_view, cx, 1);
2894 assert_selection_range(&search_view, cx, dp(2, 37)..dp(2, 40));
2895 assert_highlights(
2896 &search_view,
2897 cx,
2898 vec![
2899 (dp(2, 32)..dp(2, 35), "selection"),
2900 (dp(2, 32)..dp(2, 35), "match"),
2901 (dp(2, 37)..dp(2, 40), "active"),
2902 (dp(5, 6)..dp(5, 9), "selection"),
2903 (dp(5, 6)..dp(5, 9), "match"),
2904 ],
2905 );
2906 search_view
2907 .update(cx, |search_view, window, cx| {
2908 search_view.results_editor.update(cx, |editor, cx| {
2909 editor.fold_all(&FoldAll, window, cx);
2910 })
2911 })
2912 .expect("Should fold fine");
2913 cx.run_until_parked();
2914
2915 let results_collapsed = search_view
2916 .read_with(cx, |search_view, cx| {
2917 search_view
2918 .results_editor
2919 .read(cx)
2920 .has_any_buffer_folded(cx)
2921 })
2922 .expect("got results_collapsed");
2923
2924 assert!(results_collapsed);
2925 search_view
2926 .update(cx, |search_view, window, cx| {
2927 search_view.results_editor.update(cx, |editor, cx| {
2928 editor.unfold_all(&UnfoldAll, window, cx);
2929 })
2930 })
2931 .expect("Should unfold fine");
2932 cx.run_until_parked();
2933
2934 let results_collapsed = search_view
2935 .read_with(cx, |search_view, cx| {
2936 search_view
2937 .results_editor
2938 .read(cx)
2939 .has_any_buffer_folded(cx)
2940 })
2941 .expect("got results_collapsed");
2942
2943 assert!(!results_collapsed);
2944 }
2945
2946 #[perf]
2947 #[gpui::test]
2948 async fn test_collapse_state_syncs_after_manual_buffer_fold(cx: &mut TestAppContext) {
2949 init_test(cx);
2950
2951 let fs = FakeFs::new(cx.background_executor.clone());
2952 fs.insert_tree(
2953 path!("/dir"),
2954 json!({
2955 "one.rs": "const ONE: usize = 1;",
2956 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2957 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2958 }),
2959 )
2960 .await;
2961 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2962 let window =
2963 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2964 let workspace = window
2965 .read_with(cx, |mw, _| mw.workspace().clone())
2966 .unwrap();
2967 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
2968 let search_view = cx.add_window(|window, cx| {
2969 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
2970 });
2971
2972 // Search for "ONE" which appears in all 3 files
2973 perform_search(search_view, "ONE", cx);
2974
2975 // Verify initial state: no folds
2976 let has_any_folded = search_view
2977 .read_with(cx, |search_view, cx| {
2978 search_view
2979 .results_editor
2980 .read(cx)
2981 .has_any_buffer_folded(cx)
2982 })
2983 .expect("should read state");
2984 assert!(!has_any_folded, "No buffers should be folded initially");
2985
2986 // Fold all via fold_all
2987 search_view
2988 .update(cx, |search_view, window, cx| {
2989 search_view.results_editor.update(cx, |editor, cx| {
2990 editor.fold_all(&FoldAll, window, cx);
2991 })
2992 })
2993 .expect("Should fold fine");
2994 cx.run_until_parked();
2995
2996 let has_any_folded = search_view
2997 .read_with(cx, |search_view, cx| {
2998 search_view
2999 .results_editor
3000 .read(cx)
3001 .has_any_buffer_folded(cx)
3002 })
3003 .expect("should read state");
3004 assert!(
3005 has_any_folded,
3006 "All buffers should be folded after fold_all"
3007 );
3008
3009 // Manually unfold one buffer (simulating a chevron click)
3010 let first_buffer_id = search_view
3011 .read_with(cx, |search_view, cx| {
3012 search_view
3013 .results_editor
3014 .read(cx)
3015 .buffer()
3016 .read(cx)
3017 .snapshot(cx)
3018 .excerpts()
3019 .next()
3020 .unwrap()
3021 .context
3022 .start
3023 .buffer_id
3024 })
3025 .expect("should read buffer ids");
3026
3027 search_view
3028 .update(cx, |search_view, _window, cx| {
3029 search_view.results_editor.update(cx, |editor, cx| {
3030 editor.unfold_buffer(first_buffer_id, cx);
3031 })
3032 })
3033 .expect("Should unfold one buffer");
3034
3035 let has_any_folded = search_view
3036 .read_with(cx, |search_view, cx| {
3037 search_view
3038 .results_editor
3039 .read(cx)
3040 .has_any_buffer_folded(cx)
3041 })
3042 .expect("should read state");
3043 assert!(
3044 has_any_folded,
3045 "Should still report folds when only one buffer is unfolded"
3046 );
3047
3048 // Unfold all via unfold_all
3049 search_view
3050 .update(cx, |search_view, window, cx| {
3051 search_view.results_editor.update(cx, |editor, cx| {
3052 editor.unfold_all(&UnfoldAll, window, cx);
3053 })
3054 })
3055 .expect("Should unfold fine");
3056 cx.run_until_parked();
3057
3058 let has_any_folded = search_view
3059 .read_with(cx, |search_view, cx| {
3060 search_view
3061 .results_editor
3062 .read(cx)
3063 .has_any_buffer_folded(cx)
3064 })
3065 .expect("should read state");
3066 assert!(!has_any_folded, "No folds should remain after unfold_all");
3067
3068 // Manually fold one buffer back (simulating a chevron click)
3069 search_view
3070 .update(cx, |search_view, _window, cx| {
3071 search_view.results_editor.update(cx, |editor, cx| {
3072 editor.fold_buffer(first_buffer_id, cx);
3073 })
3074 })
3075 .expect("Should fold one buffer");
3076
3077 let has_any_folded = search_view
3078 .read_with(cx, |search_view, cx| {
3079 search_view
3080 .results_editor
3081 .read(cx)
3082 .has_any_buffer_folded(cx)
3083 })
3084 .expect("should read state");
3085 assert!(
3086 has_any_folded,
3087 "Should report folds after manually folding one buffer"
3088 );
3089 }
3090
3091 #[perf]
3092 #[gpui::test]
3093 async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
3094 init_test(cx);
3095
3096 let fs = FakeFs::new(cx.background_executor.clone());
3097 fs.insert_tree(
3098 "/dir",
3099 json!({
3100 "one.rs": "const ONE: usize = 1;",
3101 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3102 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3103 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3104 }),
3105 )
3106 .await;
3107 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3108 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3109 let workspace = window
3110 .read_with(cx, |mw, _| mw.workspace().clone())
3111 .unwrap();
3112 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3113 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3114
3115 let active_item = cx.read(|cx| {
3116 workspace
3117 .read(cx)
3118 .active_pane()
3119 .read(cx)
3120 .active_item()
3121 .and_then(|item| item.downcast::<ProjectSearchView>())
3122 });
3123 assert!(
3124 active_item.is_none(),
3125 "Expected no search panel to be active"
3126 );
3127
3128 workspace.update_in(cx, move |workspace, window, cx| {
3129 assert_eq!(workspace.panes().len(), 1);
3130 workspace.panes()[0].update(cx, |pane, cx| {
3131 pane.toolbar()
3132 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3133 });
3134
3135 ProjectSearchView::deploy_search(
3136 workspace,
3137 &workspace::DeploySearch::default(),
3138 window,
3139 cx,
3140 )
3141 });
3142
3143 let Some(search_view) = cx.read(|cx| {
3144 workspace
3145 .read(cx)
3146 .active_pane()
3147 .read(cx)
3148 .active_item()
3149 .and_then(|item| item.downcast::<ProjectSearchView>())
3150 }) else {
3151 panic!("Search view expected to appear after new search event trigger")
3152 };
3153
3154 cx.spawn(|mut cx| async move {
3155 window
3156 .update(&mut cx, |_, window, cx| {
3157 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3158 })
3159 .unwrap();
3160 })
3161 .detach();
3162 cx.background_executor.run_until_parked();
3163 window
3164 .update(cx, |_, window, cx| {
3165 search_view.update(cx, |search_view, cx| {
3166 assert!(
3167 search_view.query_editor.focus_handle(cx).is_focused(window),
3168 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
3169 );
3170 });
3171 }).unwrap();
3172
3173 window
3174 .update(cx, |_, window, cx| {
3175 search_view.update(cx, |search_view, cx| {
3176 let query_editor = &search_view.query_editor;
3177 assert!(
3178 query_editor.focus_handle(cx).is_focused(window),
3179 "Search view should be focused after the new search view is activated",
3180 );
3181 let query_text = query_editor.read(cx).text(cx);
3182 assert!(
3183 query_text.is_empty(),
3184 "New search query should be empty but got '{query_text}'",
3185 );
3186 let results_text = search_view
3187 .results_editor
3188 .update(cx, |editor, cx| editor.display_text(cx));
3189 assert!(
3190 results_text.is_empty(),
3191 "Empty search view should have no results but got '{results_text}'"
3192 );
3193 });
3194 })
3195 .unwrap();
3196
3197 window
3198 .update(cx, |_, window, cx| {
3199 search_view.update(cx, |search_view, cx| {
3200 search_view.query_editor.update(cx, |query_editor, cx| {
3201 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
3202 });
3203 search_view.search(cx);
3204 });
3205 })
3206 .unwrap();
3207 cx.background_executor.run_until_parked();
3208 window
3209 .update(cx, |_, window, cx| {
3210 search_view.update(cx, |search_view, cx| {
3211 let results_text = search_view
3212 .results_editor
3213 .update(cx, |editor, cx| editor.display_text(cx));
3214 assert!(
3215 results_text.is_empty(),
3216 "Search view for mismatching query should have no results but got '{results_text}'"
3217 );
3218 assert!(
3219 search_view.query_editor.focus_handle(cx).is_focused(window),
3220 "Search view should be focused after mismatching query had been used in search",
3221 );
3222 });
3223 }).unwrap();
3224
3225 cx.spawn(|mut cx| async move {
3226 window.update(&mut cx, |_, window, cx| {
3227 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3228 })
3229 })
3230 .detach();
3231 cx.background_executor.run_until_parked();
3232 window.update(cx, |_, window, cx| {
3233 search_view.update(cx, |search_view, cx| {
3234 assert!(
3235 search_view.query_editor.focus_handle(cx).is_focused(window),
3236 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
3237 );
3238 });
3239 }).unwrap();
3240
3241 window
3242 .update(cx, |_, window, cx| {
3243 search_view.update(cx, |search_view, cx| {
3244 search_view.query_editor.update(cx, |query_editor, cx| {
3245 query_editor.set_text("TWO", window, cx)
3246 });
3247 search_view.search(cx);
3248 });
3249 })
3250 .unwrap();
3251 cx.background_executor.run_until_parked();
3252 window.update(cx, |_, window, cx| {
3253 search_view.update(cx, |search_view, cx| {
3254 assert_eq!(
3255 search_view
3256 .results_editor
3257 .update(cx, |editor, cx| editor.display_text(cx)),
3258 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3259 "Search view results should match the query"
3260 );
3261 assert!(
3262 search_view.results_editor.focus_handle(cx).is_focused(window),
3263 "Search view with mismatching query should be focused after search results are available",
3264 );
3265 });
3266 }).unwrap();
3267 cx.spawn(|mut cx| async move {
3268 window
3269 .update(&mut cx, |_, window, cx| {
3270 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3271 })
3272 .unwrap();
3273 })
3274 .detach();
3275 cx.background_executor.run_until_parked();
3276 window.update(cx, |_, window, cx| {
3277 search_view.update(cx, |search_view, cx| {
3278 assert!(
3279 search_view.results_editor.focus_handle(cx).is_focused(window),
3280 "Search view with matching query should still have its results editor focused after the toggle focus event",
3281 );
3282 });
3283 }).unwrap();
3284
3285 workspace.update_in(cx, |workspace, window, cx| {
3286 ProjectSearchView::deploy_search(
3287 workspace,
3288 &workspace::DeploySearch::default(),
3289 window,
3290 cx,
3291 )
3292 });
3293 window.update(cx, |_, window, cx| {
3294 search_view.update(cx, |search_view, cx| {
3295 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");
3296 assert_eq!(
3297 search_view
3298 .results_editor
3299 .update(cx, |editor, cx| editor.display_text(cx)),
3300 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3301 "Results should be unchanged after search view 2nd open in a row"
3302 );
3303 assert!(
3304 search_view.query_editor.focus_handle(cx).is_focused(window),
3305 "Focus should be moved into query editor again after search view 2nd open in a row"
3306 );
3307 });
3308 }).unwrap();
3309
3310 cx.spawn(|mut cx| async move {
3311 window
3312 .update(&mut cx, |_, window, cx| {
3313 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3314 })
3315 .unwrap();
3316 })
3317 .detach();
3318 cx.background_executor.run_until_parked();
3319 window.update(cx, |_, window, cx| {
3320 search_view.update(cx, |search_view, cx| {
3321 assert!(
3322 search_view.results_editor.focus_handle(cx).is_focused(window),
3323 "Search view with matching query should switch focus to the results editor after the toggle focus event",
3324 );
3325 });
3326 }).unwrap();
3327 }
3328
3329 #[perf]
3330 #[gpui::test]
3331 async fn test_filters_consider_toggle_state(cx: &mut TestAppContext) {
3332 init_test(cx);
3333
3334 let fs = FakeFs::new(cx.background_executor.clone());
3335 fs.insert_tree(
3336 "/dir",
3337 json!({
3338 "one.rs": "const ONE: usize = 1;",
3339 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3340 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3341 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3342 }),
3343 )
3344 .await;
3345 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3346 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3347 let workspace = window
3348 .read_with(cx, |mw, _| mw.workspace().clone())
3349 .unwrap();
3350 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3351 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3352
3353 workspace.update_in(cx, move |workspace, window, cx| {
3354 workspace.panes()[0].update(cx, |pane, cx| {
3355 pane.toolbar()
3356 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3357 });
3358
3359 ProjectSearchView::deploy_search(
3360 workspace,
3361 &workspace::DeploySearch::default(),
3362 window,
3363 cx,
3364 )
3365 });
3366
3367 let Some(search_view) = cx.read(|cx| {
3368 workspace
3369 .read(cx)
3370 .active_pane()
3371 .read(cx)
3372 .active_item()
3373 .and_then(|item| item.downcast::<ProjectSearchView>())
3374 }) else {
3375 panic!("Search view expected to appear after new search event trigger")
3376 };
3377
3378 cx.spawn(|mut cx| async move {
3379 window
3380 .update(&mut cx, |_, window, cx| {
3381 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3382 })
3383 .unwrap();
3384 })
3385 .detach();
3386 cx.background_executor.run_until_parked();
3387
3388 window
3389 .update(cx, |_, window, cx| {
3390 search_view.update(cx, |search_view, cx| {
3391 search_view.query_editor.update(cx, |query_editor, cx| {
3392 query_editor.set_text("const FOUR", window, cx)
3393 });
3394 search_view.toggle_filters(cx);
3395 search_view
3396 .excluded_files_editor
3397 .update(cx, |exclude_editor, cx| {
3398 exclude_editor.set_text("four.rs", window, cx)
3399 });
3400 search_view.search(cx);
3401 });
3402 })
3403 .unwrap();
3404 cx.background_executor.run_until_parked();
3405 window
3406 .update(cx, |_, _, cx| {
3407 search_view.update(cx, |search_view, cx| {
3408 let results_text = search_view
3409 .results_editor
3410 .update(cx, |editor, cx| editor.display_text(cx));
3411 assert!(
3412 results_text.is_empty(),
3413 "Search view for query with the only match in an excluded file should have no results but got '{results_text}'"
3414 );
3415 });
3416 }).unwrap();
3417
3418 cx.spawn(|mut cx| async move {
3419 window.update(&mut cx, |_, window, cx| {
3420 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3421 })
3422 })
3423 .detach();
3424 cx.background_executor.run_until_parked();
3425
3426 window
3427 .update(cx, |_, _, cx| {
3428 search_view.update(cx, |search_view, cx| {
3429 search_view.toggle_filters(cx);
3430 search_view.search(cx);
3431 });
3432 })
3433 .unwrap();
3434 cx.background_executor.run_until_parked();
3435 window
3436 .update(cx, |_, _, cx| {
3437 search_view.update(cx, |search_view, cx| {
3438 assert_eq!(
3439 search_view
3440 .results_editor
3441 .update(cx, |editor, cx| editor.display_text(cx)),
3442 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
3443 "Search view results should contain the queried result in the previously excluded file with filters toggled off"
3444 );
3445 });
3446 })
3447 .unwrap();
3448 }
3449
3450 #[perf]
3451 #[gpui::test]
3452 async fn test_new_project_search_focus(cx: &mut TestAppContext) {
3453 init_test(cx);
3454
3455 let fs = FakeFs::new(cx.background_executor.clone());
3456 fs.insert_tree(
3457 path!("/dir"),
3458 json!({
3459 "one.rs": "const ONE: usize = 1;",
3460 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3461 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3462 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3463 }),
3464 )
3465 .await;
3466 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3467 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3468 let workspace = window
3469 .read_with(cx, |mw, _| mw.workspace().clone())
3470 .unwrap();
3471 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3472 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3473
3474 let active_item = cx.read(|cx| {
3475 workspace
3476 .read(cx)
3477 .active_pane()
3478 .read(cx)
3479 .active_item()
3480 .and_then(|item| item.downcast::<ProjectSearchView>())
3481 });
3482 assert!(
3483 active_item.is_none(),
3484 "Expected no search panel to be active"
3485 );
3486
3487 workspace.update_in(cx, move |workspace, window, cx| {
3488 assert_eq!(workspace.panes().len(), 1);
3489 workspace.panes()[0].update(cx, |pane, cx| {
3490 pane.toolbar()
3491 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3492 });
3493
3494 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3495 });
3496
3497 let Some(search_view) = cx.read(|cx| {
3498 workspace
3499 .read(cx)
3500 .active_pane()
3501 .read(cx)
3502 .active_item()
3503 .and_then(|item| item.downcast::<ProjectSearchView>())
3504 }) else {
3505 panic!("Search view expected to appear after new search event trigger")
3506 };
3507
3508 cx.spawn(|mut cx| async move {
3509 window
3510 .update(&mut cx, |_, window, cx| {
3511 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3512 })
3513 .unwrap();
3514 })
3515 .detach();
3516 cx.background_executor.run_until_parked();
3517
3518 window.update(cx, |_, window, cx| {
3519 search_view.update(cx, |search_view, cx| {
3520 assert!(
3521 search_view.query_editor.focus_handle(cx).is_focused(window),
3522 "Empty search view should be focused after the toggle focus event: no results panel to focus on",
3523 );
3524 });
3525 }).unwrap();
3526
3527 window
3528 .update(cx, |_, window, cx| {
3529 search_view.update(cx, |search_view, cx| {
3530 let query_editor = &search_view.query_editor;
3531 assert!(
3532 query_editor.focus_handle(cx).is_focused(window),
3533 "Search view should be focused after the new search view is activated",
3534 );
3535 let query_text = query_editor.read(cx).text(cx);
3536 assert!(
3537 query_text.is_empty(),
3538 "New search query should be empty but got '{query_text}'",
3539 );
3540 let results_text = search_view
3541 .results_editor
3542 .update(cx, |editor, cx| editor.display_text(cx));
3543 assert!(
3544 results_text.is_empty(),
3545 "Empty search view should have no results but got '{results_text}'"
3546 );
3547 });
3548 })
3549 .unwrap();
3550
3551 window
3552 .update(cx, |_, window, cx| {
3553 search_view.update(cx, |search_view, cx| {
3554 search_view.query_editor.update(cx, |query_editor, cx| {
3555 query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
3556 });
3557 search_view.search(cx);
3558 });
3559 })
3560 .unwrap();
3561
3562 cx.background_executor.run_until_parked();
3563 window
3564 .update(cx, |_, window, cx| {
3565 search_view.update(cx, |search_view, cx| {
3566 let results_text = search_view
3567 .results_editor
3568 .update(cx, |editor, cx| editor.display_text(cx));
3569 assert!(
3570 results_text.is_empty(),
3571 "Search view for mismatching query should have no results but got '{results_text}'"
3572 );
3573 assert!(
3574 search_view.query_editor.focus_handle(cx).is_focused(window),
3575 "Search view should be focused after mismatching query had been used in search",
3576 );
3577 });
3578 })
3579 .unwrap();
3580 cx.spawn(|mut cx| async move {
3581 window.update(&mut cx, |_, window, cx| {
3582 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3583 })
3584 })
3585 .detach();
3586 cx.background_executor.run_until_parked();
3587 window.update(cx, |_, window, cx| {
3588 search_view.update(cx, |search_view, cx| {
3589 assert!(
3590 search_view.query_editor.focus_handle(cx).is_focused(window),
3591 "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
3592 );
3593 });
3594 }).unwrap();
3595
3596 window
3597 .update(cx, |_, window, cx| {
3598 search_view.update(cx, |search_view, cx| {
3599 search_view.query_editor.update(cx, |query_editor, cx| {
3600 query_editor.set_text("TWO", window, cx)
3601 });
3602 search_view.search(cx);
3603 })
3604 })
3605 .unwrap();
3606 cx.background_executor.run_until_parked();
3607 window.update(cx, |_, window, cx|
3608 search_view.update(cx, |search_view, cx| {
3609 assert_eq!(
3610 search_view
3611 .results_editor
3612 .update(cx, |editor, cx| editor.display_text(cx)),
3613 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3614 "Search view results should match the query"
3615 );
3616 assert!(
3617 search_view.results_editor.focus_handle(cx).is_focused(window),
3618 "Search view with mismatching query should be focused after search results are available",
3619 );
3620 })).unwrap();
3621 cx.spawn(|mut cx| async move {
3622 window
3623 .update(&mut cx, |_, window, cx| {
3624 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3625 })
3626 .unwrap();
3627 })
3628 .detach();
3629 cx.background_executor.run_until_parked();
3630 window.update(cx, |_, window, cx| {
3631 search_view.update(cx, |search_view, cx| {
3632 assert!(
3633 search_view.results_editor.focus_handle(cx).is_focused(window),
3634 "Search view with matching query should still have its results editor focused after the toggle focus event",
3635 );
3636 });
3637 }).unwrap();
3638
3639 workspace.update_in(cx, |workspace, window, cx| {
3640 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3641 });
3642 cx.background_executor.run_until_parked();
3643 let Some(search_view_2) = cx.read(|cx| {
3644 workspace
3645 .read(cx)
3646 .active_pane()
3647 .read(cx)
3648 .active_item()
3649 .and_then(|item| item.downcast::<ProjectSearchView>())
3650 }) else {
3651 panic!("Search view expected to appear after new search event trigger")
3652 };
3653 assert!(
3654 search_view_2 != search_view,
3655 "New search view should be open after `workspace::NewSearch` event"
3656 );
3657
3658 window.update(cx, |_, window, cx| {
3659 search_view.update(cx, |search_view, cx| {
3660 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
3661 assert_eq!(
3662 search_view
3663 .results_editor
3664 .update(cx, |editor, cx| editor.display_text(cx)),
3665 "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3666 "Results of the first search view should not update too"
3667 );
3668 assert!(
3669 !search_view.query_editor.focus_handle(cx).is_focused(window),
3670 "Focus should be moved away from the first search view"
3671 );
3672 });
3673 }).unwrap();
3674
3675 window.update(cx, |_, window, cx| {
3676 search_view_2.update(cx, |search_view_2, cx| {
3677 assert_eq!(
3678 search_view_2.query_editor.read(cx).text(cx),
3679 "two",
3680 "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
3681 );
3682 assert_eq!(
3683 search_view_2
3684 .results_editor
3685 .update(cx, |editor, cx| editor.display_text(cx)),
3686 "",
3687 "No search results should be in the 2nd view yet, as we did not spawn a search for it"
3688 );
3689 assert!(
3690 search_view_2.query_editor.focus_handle(cx).is_focused(window),
3691 "Focus should be moved into query editor of the new window"
3692 );
3693 });
3694 }).unwrap();
3695
3696 window
3697 .update(cx, |_, window, cx| {
3698 search_view_2.update(cx, |search_view_2, cx| {
3699 search_view_2.query_editor.update(cx, |query_editor, cx| {
3700 query_editor.set_text("FOUR", window, cx)
3701 });
3702 search_view_2.search(cx);
3703 });
3704 })
3705 .unwrap();
3706
3707 cx.background_executor.run_until_parked();
3708 window.update(cx, |_, window, cx| {
3709 search_view_2.update(cx, |search_view_2, cx| {
3710 assert_eq!(
3711 search_view_2
3712 .results_editor
3713 .update(cx, |editor, cx| editor.display_text(cx)),
3714 "\n\nconst FOUR: usize = one::ONE + three::THREE;",
3715 "New search view with the updated query should have new search results"
3716 );
3717 assert!(
3718 search_view_2.results_editor.focus_handle(cx).is_focused(window),
3719 "Search view with mismatching query should be focused after search results are available",
3720 );
3721 });
3722 }).unwrap();
3723
3724 cx.spawn(|mut cx| async move {
3725 window
3726 .update(&mut cx, |_, window, cx| {
3727 window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3728 })
3729 .unwrap();
3730 })
3731 .detach();
3732 cx.background_executor.run_until_parked();
3733 window.update(cx, |_, window, cx| {
3734 search_view_2.update(cx, |search_view_2, cx| {
3735 assert!(
3736 search_view_2.results_editor.focus_handle(cx).is_focused(window),
3737 "Search view with matching query should switch focus to the results editor after the toggle focus event",
3738 );
3739 });}).unwrap();
3740 }
3741
3742 #[perf]
3743 #[gpui::test]
3744 async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
3745 init_test(cx);
3746
3747 let fs = FakeFs::new(cx.background_executor.clone());
3748 fs.insert_tree(
3749 path!("/dir"),
3750 json!({
3751 "a": {
3752 "one.rs": "const ONE: usize = 1;",
3753 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3754 },
3755 "b": {
3756 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3757 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3758 },
3759 }),
3760 )
3761 .await;
3762 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3763 let worktree_id = project.read_with(cx, |project, cx| {
3764 project.worktrees(cx).next().unwrap().read(cx).id()
3765 });
3766 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3767 let workspace = window
3768 .read_with(cx, |mw, _| mw.workspace().clone())
3769 .unwrap();
3770 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3771 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3772
3773 let active_item = cx.read(|cx| {
3774 workspace
3775 .read(cx)
3776 .active_pane()
3777 .read(cx)
3778 .active_item()
3779 .and_then(|item| item.downcast::<ProjectSearchView>())
3780 });
3781 assert!(
3782 active_item.is_none(),
3783 "Expected no search panel to be active"
3784 );
3785
3786 workspace.update_in(cx, move |workspace, window, cx| {
3787 assert_eq!(workspace.panes().len(), 1);
3788 workspace.panes()[0].update(cx, move |pane, cx| {
3789 pane.toolbar()
3790 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3791 });
3792 });
3793
3794 let a_dir_entry = cx.update(|_, cx| {
3795 workspace
3796 .read(cx)
3797 .project()
3798 .read(cx)
3799 .entry_for_path(&(worktree_id, rel_path("a")).into(), cx)
3800 .expect("no entry for /a/ directory")
3801 .clone()
3802 });
3803 assert!(a_dir_entry.is_dir());
3804 workspace.update_in(cx, |workspace, window, cx| {
3805 ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, window, cx)
3806 });
3807
3808 let Some(search_view) = cx.read(|cx| {
3809 workspace
3810 .read(cx)
3811 .active_pane()
3812 .read(cx)
3813 .active_item()
3814 .and_then(|item| item.downcast::<ProjectSearchView>())
3815 }) else {
3816 panic!("Search view expected to appear after new search in directory event trigger")
3817 };
3818 cx.background_executor.run_until_parked();
3819 window
3820 .update(cx, |_, window, cx| {
3821 search_view.update(cx, |search_view, cx| {
3822 assert!(
3823 search_view.query_editor.focus_handle(cx).is_focused(window),
3824 "On new search in directory, focus should be moved into query editor"
3825 );
3826 search_view.excluded_files_editor.update(cx, |editor, cx| {
3827 assert!(
3828 editor.display_text(cx).is_empty(),
3829 "New search in directory should not have any excluded files"
3830 );
3831 });
3832 search_view.included_files_editor.update(cx, |editor, cx| {
3833 assert_eq!(
3834 editor.display_text(cx),
3835 a_dir_entry.path.display(PathStyle::local()),
3836 "New search in directory should have included dir entry path"
3837 );
3838 });
3839 });
3840 })
3841 .unwrap();
3842 window
3843 .update(cx, |_, window, cx| {
3844 search_view.update(cx, |search_view, cx| {
3845 search_view.query_editor.update(cx, |query_editor, cx| {
3846 query_editor.set_text("const", window, cx)
3847 });
3848 search_view.search(cx);
3849 });
3850 })
3851 .unwrap();
3852 cx.background_executor.run_until_parked();
3853 window
3854 .update(cx, |_, _, cx| {
3855 search_view.update(cx, |search_view, cx| {
3856 assert_eq!(
3857 search_view
3858 .results_editor
3859 .update(cx, |editor, cx| editor.display_text(cx)),
3860 "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3861 "New search in directory should have a filter that matches a certain directory"
3862 );
3863 })
3864 })
3865 .unwrap();
3866 }
3867
3868 #[perf]
3869 #[gpui::test]
3870 async fn test_search_query_history(cx: &mut TestAppContext) {
3871 init_test(cx);
3872
3873 let fs = FakeFs::new(cx.background_executor.clone());
3874 fs.insert_tree(
3875 path!("/dir"),
3876 json!({
3877 "one.rs": "const ONE: usize = 1;",
3878 "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3879 "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3880 "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3881 }),
3882 )
3883 .await;
3884 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3885 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3886 let workspace = window
3887 .read_with(cx, |mw, _| mw.workspace().clone())
3888 .unwrap();
3889 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3890 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3891
3892 workspace.update_in(cx, {
3893 let search_bar = search_bar.clone();
3894 |workspace, window, cx| {
3895 assert_eq!(workspace.panes().len(), 1);
3896 workspace.panes()[0].update(cx, |pane, cx| {
3897 pane.toolbar()
3898 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3899 });
3900
3901 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3902 }
3903 });
3904
3905 let search_view = cx.read(|cx| {
3906 workspace
3907 .read(cx)
3908 .active_pane()
3909 .read(cx)
3910 .active_item()
3911 .and_then(|item| item.downcast::<ProjectSearchView>())
3912 .expect("Search view expected to appear after new search event trigger")
3913 });
3914
3915 // Add 3 search items into the history + another unsubmitted one.
3916 window
3917 .update(cx, |_, window, cx| {
3918 search_view.update(cx, |search_view, cx| {
3919 search_view.search_options = SearchOptions::CASE_SENSITIVE;
3920 search_view.query_editor.update(cx, |query_editor, cx| {
3921 query_editor.set_text("ONE", window, cx)
3922 });
3923 search_view.search(cx);
3924 });
3925 })
3926 .unwrap();
3927
3928 cx.background_executor.run_until_parked();
3929 window
3930 .update(cx, |_, window, cx| {
3931 search_view.update(cx, |search_view, cx| {
3932 search_view.query_editor.update(cx, |query_editor, cx| {
3933 query_editor.set_text("TWO", window, cx)
3934 });
3935 search_view.search(cx);
3936 });
3937 })
3938 .unwrap();
3939 cx.background_executor.run_until_parked();
3940 window
3941 .update(cx, |_, window, cx| {
3942 search_view.update(cx, |search_view, cx| {
3943 search_view.query_editor.update(cx, |query_editor, cx| {
3944 query_editor.set_text("THREE", window, cx)
3945 });
3946 search_view.search(cx);
3947 })
3948 })
3949 .unwrap();
3950 cx.background_executor.run_until_parked();
3951 window
3952 .update(cx, |_, window, cx| {
3953 search_view.update(cx, |search_view, cx| {
3954 search_view.query_editor.update(cx, |query_editor, cx| {
3955 query_editor.set_text("JUST_TEXT_INPUT", window, cx)
3956 });
3957 })
3958 })
3959 .unwrap();
3960 cx.background_executor.run_until_parked();
3961
3962 // Ensure that the latest input with search settings is active.
3963 window
3964 .update(cx, |_, _, cx| {
3965 search_view.update(cx, |search_view, cx| {
3966 assert_eq!(
3967 search_view.query_editor.read(cx).text(cx),
3968 "JUST_TEXT_INPUT"
3969 );
3970 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3971 });
3972 })
3973 .unwrap();
3974
3975 // Next history query after the latest should preserve the current query.
3976 window
3977 .update(cx, |_, window, cx| {
3978 search_bar.update(cx, |search_bar, cx| {
3979 search_bar.focus_search(window, cx);
3980 search_bar.next_history_query(&NextHistoryQuery, window, cx);
3981 })
3982 })
3983 .unwrap();
3984 window
3985 .update(cx, |_, _, cx| {
3986 search_view.update(cx, |search_view, cx| {
3987 assert_eq!(
3988 search_view.query_editor.read(cx).text(cx),
3989 "JUST_TEXT_INPUT"
3990 );
3991 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3992 });
3993 })
3994 .unwrap();
3995 window
3996 .update(cx, |_, window, cx| {
3997 search_bar.update(cx, |search_bar, cx| {
3998 search_bar.focus_search(window, cx);
3999 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4000 })
4001 })
4002 .unwrap();
4003 window
4004 .update(cx, |_, _, cx| {
4005 search_view.update(cx, |search_view, cx| {
4006 assert_eq!(
4007 search_view.query_editor.read(cx).text(cx),
4008 "JUST_TEXT_INPUT"
4009 );
4010 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4011 });
4012 })
4013 .unwrap();
4014
4015 // Previous query should navigate backwards through history.
4016 window
4017 .update(cx, |_, window, cx| {
4018 search_bar.update(cx, |search_bar, cx| {
4019 search_bar.focus_search(window, cx);
4020 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4021 });
4022 })
4023 .unwrap();
4024 window
4025 .update(cx, |_, _, cx| {
4026 search_view.update(cx, |search_view, cx| {
4027 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
4028 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4029 });
4030 })
4031 .unwrap();
4032
4033 // Further previous items should go over the history in reverse order.
4034 window
4035 .update(cx, |_, window, cx| {
4036 search_bar.update(cx, |search_bar, cx| {
4037 search_bar.focus_search(window, cx);
4038 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4039 });
4040 })
4041 .unwrap();
4042 window
4043 .update(cx, |_, _, cx| {
4044 search_view.update(cx, |search_view, cx| {
4045 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
4046 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4047 });
4048 })
4049 .unwrap();
4050
4051 // Previous items should never go behind the first history item.
4052 window
4053 .update(cx, |_, window, cx| {
4054 search_bar.update(cx, |search_bar, cx| {
4055 search_bar.focus_search(window, cx);
4056 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4057 });
4058 })
4059 .unwrap();
4060 window
4061 .update(cx, |_, _, cx| {
4062 search_view.update(cx, |search_view, cx| {
4063 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
4064 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4065 });
4066 })
4067 .unwrap();
4068 window
4069 .update(cx, |_, window, cx| {
4070 search_bar.update(cx, |search_bar, cx| {
4071 search_bar.focus_search(window, cx);
4072 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4073 });
4074 })
4075 .unwrap();
4076 window
4077 .update(cx, |_, _, cx| {
4078 search_view.update(cx, |search_view, cx| {
4079 assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
4080 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4081 });
4082 })
4083 .unwrap();
4084
4085 // Next items should go over the history in the original order.
4086 window
4087 .update(cx, |_, window, cx| {
4088 search_bar.update(cx, |search_bar, cx| {
4089 search_bar.focus_search(window, cx);
4090 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4091 });
4092 })
4093 .unwrap();
4094 window
4095 .update(cx, |_, _, cx| {
4096 search_view.update(cx, |search_view, cx| {
4097 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
4098 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4099 });
4100 })
4101 .unwrap();
4102
4103 window
4104 .update(cx, |_, window, cx| {
4105 search_view.update(cx, |search_view, cx| {
4106 search_view.query_editor.update(cx, |query_editor, cx| {
4107 query_editor.set_text("TWO_NEW", window, cx)
4108 });
4109 search_view.search(cx);
4110 });
4111 })
4112 .unwrap();
4113 cx.background_executor.run_until_parked();
4114 window
4115 .update(cx, |_, _, cx| {
4116 search_view.update(cx, |search_view, cx| {
4117 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
4118 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4119 });
4120 })
4121 .unwrap();
4122
4123 // New search input should add another entry to history and move the selection to the end of the history.
4124 window
4125 .update(cx, |_, window, cx| {
4126 search_bar.update(cx, |search_bar, cx| {
4127 search_bar.focus_search(window, cx);
4128 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4129 });
4130 })
4131 .unwrap();
4132 window
4133 .update(cx, |_, _, cx| {
4134 search_view.update(cx, |search_view, cx| {
4135 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
4136 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4137 });
4138 })
4139 .unwrap();
4140 window
4141 .update(cx, |_, window, cx| {
4142 search_bar.update(cx, |search_bar, cx| {
4143 search_bar.focus_search(window, cx);
4144 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4145 });
4146 })
4147 .unwrap();
4148 window
4149 .update(cx, |_, _, cx| {
4150 search_view.update(cx, |search_view, cx| {
4151 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
4152 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4153 });
4154 })
4155 .unwrap();
4156 window
4157 .update(cx, |_, window, cx| {
4158 search_bar.update(cx, |search_bar, cx| {
4159 search_bar.focus_search(window, cx);
4160 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4161 });
4162 })
4163 .unwrap();
4164 window
4165 .update(cx, |_, _, cx| {
4166 search_view.update(cx, |search_view, cx| {
4167 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
4168 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4169 });
4170 })
4171 .unwrap();
4172 window
4173 .update(cx, |_, window, cx| {
4174 search_bar.update(cx, |search_bar, cx| {
4175 search_bar.focus_search(window, cx);
4176 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4177 });
4178 })
4179 .unwrap();
4180 window
4181 .update(cx, |_, _, cx| {
4182 search_view.update(cx, |search_view, cx| {
4183 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
4184 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4185 });
4186 })
4187 .unwrap();
4188 window
4189 .update(cx, |_, window, cx| {
4190 search_bar.update(cx, |search_bar, cx| {
4191 search_bar.focus_search(window, cx);
4192 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4193 });
4194 })
4195 .unwrap();
4196 window
4197 .update(cx, |_, _, cx| {
4198 search_view.update(cx, |search_view, cx| {
4199 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
4200 assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4201 });
4202 })
4203 .unwrap();
4204
4205 // Typing text without running a search, then navigating history, should allow
4206 // restoring the draft when pressing next past the end.
4207 window
4208 .update(cx, |_, window, cx| {
4209 search_view.update(cx, |search_view, cx| {
4210 search_view.query_editor.update(cx, |query_editor, cx| {
4211 query_editor.set_text("unsaved draft", window, cx)
4212 });
4213 })
4214 })
4215 .unwrap();
4216 cx.background_executor.run_until_parked();
4217
4218 // Navigate up into history — the draft should be stashed.
4219 window
4220 .update(cx, |_, window, cx| {
4221 search_bar.update(cx, |search_bar, cx| {
4222 search_bar.focus_search(window, cx);
4223 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4224 });
4225 })
4226 .unwrap();
4227 window
4228 .update(cx, |_, _, cx| {
4229 search_view.update(cx, |search_view, cx| {
4230 assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
4231 });
4232 })
4233 .unwrap();
4234
4235 // Navigate forward through history.
4236 window
4237 .update(cx, |_, window, cx| {
4238 search_bar.update(cx, |search_bar, cx| {
4239 search_bar.focus_search(window, cx);
4240 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4241 });
4242 })
4243 .unwrap();
4244 window
4245 .update(cx, |_, _, cx| {
4246 search_view.update(cx, |search_view, cx| {
4247 assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
4248 });
4249 })
4250 .unwrap();
4251
4252 // Navigate past the end — the draft should be restored.
4253 window
4254 .update(cx, |_, window, cx| {
4255 search_bar.update(cx, |search_bar, cx| {
4256 search_bar.focus_search(window, cx);
4257 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4258 });
4259 })
4260 .unwrap();
4261 window
4262 .update(cx, |_, _, cx| {
4263 search_view.update(cx, |search_view, cx| {
4264 assert_eq!(search_view.query_editor.read(cx).text(cx), "unsaved draft");
4265 });
4266 })
4267 .unwrap();
4268 }
4269
4270 #[perf]
4271 #[gpui::test]
4272 async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) {
4273 init_test(cx);
4274
4275 let fs = FakeFs::new(cx.background_executor.clone());
4276 fs.insert_tree(
4277 path!("/dir"),
4278 json!({
4279 "one.rs": "const ONE: usize = 1;",
4280 }),
4281 )
4282 .await;
4283 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4284 let worktree_id = project.update(cx, |this, cx| {
4285 this.worktrees(cx).next().unwrap().read(cx).id()
4286 });
4287
4288 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
4289 let workspace = window
4290 .read_with(cx, |mw, _| mw.workspace().clone())
4291 .unwrap();
4292 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4293
4294 let panes: Vec<_> = workspace.update_in(cx, |this, _, _| this.panes().to_owned());
4295
4296 let search_bar_1 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
4297 let search_bar_2 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
4298
4299 assert_eq!(panes.len(), 1);
4300 let first_pane = panes.first().cloned().unwrap();
4301 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 0);
4302 workspace
4303 .update_in(cx, |workspace, window, cx| {
4304 workspace.open_path(
4305 (worktree_id, rel_path("one.rs")),
4306 Some(first_pane.downgrade()),
4307 true,
4308 window,
4309 cx,
4310 )
4311 })
4312 .await
4313 .unwrap();
4314 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 1);
4315
4316 // Add a project search item to the first pane
4317 workspace.update_in(cx, {
4318 let search_bar = search_bar_1.clone();
4319 |workspace, window, cx| {
4320 first_pane.update(cx, |pane, cx| {
4321 pane.toolbar()
4322 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4323 });
4324
4325 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4326 }
4327 });
4328 let search_view_1 = cx.read(|cx| {
4329 workspace
4330 .read(cx)
4331 .active_item(cx)
4332 .and_then(|item| item.downcast::<ProjectSearchView>())
4333 .expect("Search view expected to appear after new search event trigger")
4334 });
4335
4336 let second_pane = workspace
4337 .update_in(cx, |workspace, window, cx| {
4338 workspace.split_and_clone(
4339 first_pane.clone(),
4340 workspace::SplitDirection::Right,
4341 window,
4342 cx,
4343 )
4344 })
4345 .await
4346 .unwrap();
4347 assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 1);
4348
4349 assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 1);
4350 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 2);
4351
4352 // Add a project search item to the second pane
4353 workspace.update_in(cx, {
4354 let search_bar = search_bar_2.clone();
4355 let pane = second_pane.clone();
4356 move |workspace, window, cx| {
4357 assert_eq!(workspace.panes().len(), 2);
4358 pane.update(cx, |pane, cx| {
4359 pane.toolbar()
4360 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4361 });
4362
4363 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4364 }
4365 });
4366
4367 let search_view_2 = cx.read(|cx| {
4368 workspace
4369 .read(cx)
4370 .active_item(cx)
4371 .and_then(|item| item.downcast::<ProjectSearchView>())
4372 .expect("Search view expected to appear after new search event trigger")
4373 });
4374
4375 cx.run_until_parked();
4376 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 2);
4377 assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 2);
4378
4379 let update_search_view =
4380 |search_view: &Entity<ProjectSearchView>, query: &str, cx: &mut TestAppContext| {
4381 window
4382 .update(cx, |_, window, cx| {
4383 search_view.update(cx, |search_view, cx| {
4384 search_view.query_editor.update(cx, |query_editor, cx| {
4385 query_editor.set_text(query, window, cx)
4386 });
4387 search_view.search(cx);
4388 });
4389 })
4390 .unwrap();
4391 };
4392
4393 let active_query =
4394 |search_view: &Entity<ProjectSearchView>, cx: &mut TestAppContext| -> String {
4395 window
4396 .update(cx, |_, _, cx| {
4397 search_view.update(cx, |search_view, cx| {
4398 search_view.query_editor.read(cx).text(cx)
4399 })
4400 })
4401 .unwrap()
4402 };
4403
4404 let select_prev_history_item =
4405 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
4406 window
4407 .update(cx, |_, window, cx| {
4408 search_bar.update(cx, |search_bar, cx| {
4409 search_bar.focus_search(window, cx);
4410 search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4411 })
4412 })
4413 .unwrap();
4414 };
4415
4416 let select_next_history_item =
4417 |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
4418 window
4419 .update(cx, |_, window, cx| {
4420 search_bar.update(cx, |search_bar, cx| {
4421 search_bar.focus_search(window, cx);
4422 search_bar.next_history_query(&NextHistoryQuery, window, cx);
4423 })
4424 })
4425 .unwrap();
4426 };
4427
4428 update_search_view(&search_view_1, "ONE", cx);
4429 cx.background_executor.run_until_parked();
4430
4431 update_search_view(&search_view_2, "TWO", cx);
4432 cx.background_executor.run_until_parked();
4433
4434 assert_eq!(active_query(&search_view_1, cx), "ONE");
4435 assert_eq!(active_query(&search_view_2, cx), "TWO");
4436
4437 // Selecting previous history item should select the query from search view 1.
4438 select_prev_history_item(&search_bar_2, cx);
4439 assert_eq!(active_query(&search_view_2, cx), "ONE");
4440
4441 // Selecting the previous history item should not change the query as it is already the first item.
4442 select_prev_history_item(&search_bar_2, cx);
4443 assert_eq!(active_query(&search_view_2, cx), "ONE");
4444
4445 // Changing the query in search view 2 should not affect the history of search view 1.
4446 assert_eq!(active_query(&search_view_1, cx), "ONE");
4447
4448 // Deploying a new search in search view 2
4449 update_search_view(&search_view_2, "THREE", cx);
4450 cx.background_executor.run_until_parked();
4451
4452 select_next_history_item(&search_bar_2, cx);
4453 assert_eq!(active_query(&search_view_2, cx), "THREE");
4454
4455 select_prev_history_item(&search_bar_2, cx);
4456 assert_eq!(active_query(&search_view_2, cx), "TWO");
4457
4458 select_prev_history_item(&search_bar_2, cx);
4459 assert_eq!(active_query(&search_view_2, cx), "ONE");
4460
4461 select_prev_history_item(&search_bar_2, cx);
4462 assert_eq!(active_query(&search_view_2, cx), "ONE");
4463
4464 select_prev_history_item(&search_bar_2, cx);
4465 assert_eq!(active_query(&search_view_2, cx), "ONE");
4466
4467 // Search view 1 should now see the query from search view 2.
4468 assert_eq!(active_query(&search_view_1, cx), "ONE");
4469
4470 select_next_history_item(&search_bar_2, cx);
4471 assert_eq!(active_query(&search_view_2, cx), "TWO");
4472
4473 // Here is the new query from search view 2
4474 select_next_history_item(&search_bar_2, cx);
4475 assert_eq!(active_query(&search_view_2, cx), "THREE");
4476
4477 select_next_history_item(&search_bar_2, cx);
4478 assert_eq!(active_query(&search_view_2, cx), "THREE");
4479
4480 select_next_history_item(&search_bar_1, cx);
4481 assert_eq!(active_query(&search_view_1, cx), "TWO");
4482
4483 select_next_history_item(&search_bar_1, cx);
4484 assert_eq!(active_query(&search_view_1, cx), "THREE");
4485
4486 select_next_history_item(&search_bar_1, cx);
4487 assert_eq!(active_query(&search_view_1, cx), "THREE");
4488 }
4489
4490 #[perf]
4491 #[gpui::test]
4492 async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
4493 init_test(cx);
4494
4495 // Setup 2 panes, both with a file open and one with a project search.
4496 let fs = FakeFs::new(cx.background_executor.clone());
4497 fs.insert_tree(
4498 path!("/dir"),
4499 json!({
4500 "one.rs": "const ONE: usize = 1;",
4501 }),
4502 )
4503 .await;
4504 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4505 let worktree_id = project.update(cx, |this, cx| {
4506 this.worktrees(cx).next().unwrap().read(cx).id()
4507 });
4508 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
4509 let workspace = window
4510 .read_with(cx, |mw, _| mw.workspace().clone())
4511 .unwrap();
4512 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4513 let panes: Vec<_> = workspace.update_in(cx, |this, _, _| this.panes().to_owned());
4514 assert_eq!(panes.len(), 1);
4515 let first_pane = panes.first().cloned().unwrap();
4516 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 0);
4517 workspace
4518 .update_in(cx, |workspace, window, cx| {
4519 workspace.open_path(
4520 (worktree_id, rel_path("one.rs")),
4521 Some(first_pane.downgrade()),
4522 true,
4523 window,
4524 cx,
4525 )
4526 })
4527 .await
4528 .unwrap();
4529 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 1);
4530 let second_pane = workspace
4531 .update_in(cx, |workspace, window, cx| {
4532 workspace.split_and_clone(
4533 first_pane.clone(),
4534 workspace::SplitDirection::Right,
4535 window,
4536 cx,
4537 )
4538 })
4539 .await
4540 .unwrap();
4541 assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 1);
4542 assert!(
4543 window
4544 .update(cx, |_, window, cx| second_pane
4545 .focus_handle(cx)
4546 .contains_focused(window, cx))
4547 .unwrap()
4548 );
4549 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
4550 workspace.update_in(cx, {
4551 let search_bar = search_bar.clone();
4552 let pane = first_pane.clone();
4553 move |workspace, window, cx| {
4554 assert_eq!(workspace.panes().len(), 2);
4555 pane.update(cx, move |pane, cx| {
4556 pane.toolbar()
4557 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4558 });
4559 }
4560 });
4561
4562 // Add a project search item to the second pane
4563 workspace.update_in(cx, {
4564 |workspace, window, cx| {
4565 assert_eq!(workspace.panes().len(), 2);
4566 second_pane.update(cx, |pane, cx| {
4567 pane.toolbar()
4568 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4569 });
4570
4571 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4572 }
4573 });
4574
4575 cx.run_until_parked();
4576 assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 2);
4577 assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 1);
4578
4579 // Focus the first pane
4580 workspace.update_in(cx, |workspace, window, cx| {
4581 assert_eq!(workspace.active_pane(), &second_pane);
4582 second_pane.update(cx, |this, cx| {
4583 assert_eq!(this.active_item_index(), 1);
4584 this.activate_previous_item(&Default::default(), window, cx);
4585 assert_eq!(this.active_item_index(), 0);
4586 });
4587 workspace.activate_pane_in_direction(workspace::SplitDirection::Left, window, cx);
4588 });
4589 workspace.update_in(cx, |workspace, _, cx| {
4590 assert_eq!(workspace.active_pane(), &first_pane);
4591 assert_eq!(first_pane.read(cx).items_len(), 1);
4592 assert_eq!(second_pane.read(cx).items_len(), 2);
4593 });
4594
4595 // Deploy a new search
4596 cx.dispatch_action(DeploySearch::default());
4597
4598 // Both panes should now have a project search in them
4599 workspace.update_in(cx, |workspace, window, cx| {
4600 assert_eq!(workspace.active_pane(), &first_pane);
4601 first_pane.read_with(cx, |this, _| {
4602 assert_eq!(this.active_item_index(), 1);
4603 assert_eq!(this.items_len(), 2);
4604 });
4605 second_pane.update(cx, |this, cx| {
4606 assert!(!cx.focus_handle().contains_focused(window, cx));
4607 assert_eq!(this.items_len(), 2);
4608 });
4609 });
4610
4611 // Focus the second pane's non-search item
4612 window
4613 .update(cx, |_workspace, window, cx| {
4614 second_pane.update(cx, |pane, cx| {
4615 pane.activate_next_item(&Default::default(), window, cx)
4616 });
4617 })
4618 .unwrap();
4619
4620 // Deploy a new search
4621 cx.dispatch_action(DeploySearch::default());
4622
4623 // The project search view should now be focused in the second pane
4624 // And the number of items should be unchanged.
4625 window
4626 .update(cx, |_workspace, _, cx| {
4627 second_pane.update(cx, |pane, _cx| {
4628 assert!(
4629 pane.active_item()
4630 .unwrap()
4631 .downcast::<ProjectSearchView>()
4632 .is_some()
4633 );
4634
4635 assert_eq!(pane.items_len(), 2);
4636 });
4637 })
4638 .unwrap();
4639 }
4640
4641 #[perf]
4642 #[gpui::test]
4643 async fn test_scroll_search_results_to_top(cx: &mut TestAppContext) {
4644 init_test(cx);
4645
4646 // We need many lines in the search results to be able to scroll the window
4647 let fs = FakeFs::new(cx.background_executor.clone());
4648 fs.insert_tree(
4649 path!("/dir"),
4650 json!({
4651 "1.txt": "\n\n\n\n\n A \n\n\n\n\n",
4652 "2.txt": "\n\n\n\n\n A \n\n\n\n\n",
4653 "3.rs": "\n\n\n\n\n A \n\n\n\n\n",
4654 "4.rs": "\n\n\n\n\n A \n\n\n\n\n",
4655 "5.rs": "\n\n\n\n\n A \n\n\n\n\n",
4656 "6.rs": "\n\n\n\n\n A \n\n\n\n\n",
4657 "7.rs": "\n\n\n\n\n A \n\n\n\n\n",
4658 "8.rs": "\n\n\n\n\n A \n\n\n\n\n",
4659 "9.rs": "\n\n\n\n\n A \n\n\n\n\n",
4660 "a.rs": "\n\n\n\n\n A \n\n\n\n\n",
4661 "b.rs": "\n\n\n\n\n B \n\n\n\n\n",
4662 "c.rs": "\n\n\n\n\n B \n\n\n\n\n",
4663 "d.rs": "\n\n\n\n\n B \n\n\n\n\n",
4664 "e.rs": "\n\n\n\n\n B \n\n\n\n\n",
4665 "f.rs": "\n\n\n\n\n B \n\n\n\n\n",
4666 "g.rs": "\n\n\n\n\n B \n\n\n\n\n",
4667 "h.rs": "\n\n\n\n\n B \n\n\n\n\n",
4668 "i.rs": "\n\n\n\n\n B \n\n\n\n\n",
4669 "j.rs": "\n\n\n\n\n B \n\n\n\n\n",
4670 "k.rs": "\n\n\n\n\n B \n\n\n\n\n",
4671 }),
4672 )
4673 .await;
4674 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4675 let window =
4676 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4677 let workspace = window
4678 .read_with(cx, |mw, _| mw.workspace().clone())
4679 .unwrap();
4680 let search = cx.new(|cx| ProjectSearch::new(project, cx));
4681 let search_view = cx.add_window(|window, cx| {
4682 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
4683 });
4684
4685 // First search
4686 perform_search(search_view, "A", cx);
4687 search_view
4688 .update(cx, |search_view, window, cx| {
4689 search_view.results_editor.update(cx, |results_editor, cx| {
4690 // Results are correct and scrolled to the top
4691 assert_eq!(
4692 results_editor.display_text(cx).match_indices(" A ").count(),
4693 10
4694 );
4695 assert_eq!(results_editor.scroll_position(cx), Point::default());
4696
4697 // Scroll results all the way down
4698 results_editor.scroll(
4699 Point::new(0., f64::MAX),
4700 Some(Axis::Vertical),
4701 window,
4702 cx,
4703 );
4704 });
4705 })
4706 .expect("unable to update search view");
4707
4708 // Second search
4709 perform_search(search_view, "B", cx);
4710 search_view
4711 .update(cx, |search_view, _, cx| {
4712 search_view.results_editor.update(cx, |results_editor, cx| {
4713 // Results are correct...
4714 assert_eq!(
4715 results_editor.display_text(cx).match_indices(" B ").count(),
4716 10
4717 );
4718 // ...and scrolled back to the top
4719 assert_eq!(results_editor.scroll_position(cx), Point::default());
4720 });
4721 })
4722 .expect("unable to update search view");
4723 }
4724
4725 #[perf]
4726 #[gpui::test]
4727 async fn test_buffer_search_query_reused(cx: &mut TestAppContext) {
4728 init_test(cx);
4729
4730 let fs = FakeFs::new(cx.background_executor.clone());
4731 fs.insert_tree(
4732 path!("/dir"),
4733 json!({
4734 "one.rs": "const ONE: usize = 1;",
4735 }),
4736 )
4737 .await;
4738 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4739 let worktree_id = project.update(cx, |this, cx| {
4740 this.worktrees(cx).next().unwrap().read(cx).id()
4741 });
4742 let window =
4743 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4744 let workspace = window
4745 .read_with(cx, |mw, _| mw.workspace().clone())
4746 .unwrap();
4747 let mut cx = VisualTestContext::from_window(window.into(), cx);
4748
4749 let editor = workspace
4750 .update_in(&mut cx, |workspace, window, cx| {
4751 workspace.open_path((worktree_id, rel_path("one.rs")), None, true, window, cx)
4752 })
4753 .await
4754 .unwrap()
4755 .downcast::<Editor>()
4756 .unwrap();
4757
4758 // Wait for the unstaged changes to be loaded
4759 cx.run_until_parked();
4760
4761 let buffer_search_bar = cx.new_window_entity(|window, cx| {
4762 let mut search_bar =
4763 BufferSearchBar::new(Some(project.read(cx).languages().clone()), window, cx);
4764 search_bar.set_active_pane_item(Some(&editor), window, cx);
4765 search_bar.show(window, cx);
4766 search_bar
4767 });
4768
4769 let panes: Vec<_> = workspace.update_in(&mut cx, |this, _, _| this.panes().to_owned());
4770 assert_eq!(panes.len(), 1);
4771 let pane = panes.first().cloned().unwrap();
4772 pane.update_in(&mut cx, |pane, window, cx| {
4773 pane.toolbar().update(cx, |toolbar, cx| {
4774 toolbar.add_item(buffer_search_bar.clone(), window, cx);
4775 })
4776 });
4777
4778 let buffer_search_query = "search bar query";
4779 buffer_search_bar
4780 .update_in(&mut cx, |buffer_search_bar, window, cx| {
4781 buffer_search_bar.focus_handle(cx).focus(window, cx);
4782 buffer_search_bar.search(buffer_search_query, None, true, window, cx)
4783 })
4784 .await
4785 .unwrap();
4786
4787 workspace.update_in(&mut cx, |workspace, window, cx| {
4788 ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4789 });
4790 cx.run_until_parked();
4791 let project_search_view = pane
4792 .read_with(&cx, |pane, _| {
4793 pane.active_item()
4794 .and_then(|item| item.downcast::<ProjectSearchView>())
4795 })
4796 .expect("should open a project search view after spawning a new search");
4797 project_search_view.update(&mut cx, |search_view, cx| {
4798 assert_eq!(
4799 search_view.search_query_text(cx),
4800 buffer_search_query,
4801 "Project search should take the query from the buffer search bar since it got focused and had a query inside"
4802 );
4803 });
4804 }
4805
4806 #[gpui::test]
4807 async fn test_search_dismisses_modal(cx: &mut TestAppContext) {
4808 init_test(cx);
4809
4810 let fs = FakeFs::new(cx.background_executor.clone());
4811 fs.insert_tree(
4812 path!("/dir"),
4813 json!({
4814 "one.rs": "const ONE: usize = 1;",
4815 }),
4816 )
4817 .await;
4818 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4819 let window =
4820 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4821 let workspace = window
4822 .read_with(cx, |mw, _| mw.workspace().clone())
4823 .unwrap();
4824 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4825
4826 struct EmptyModalView {
4827 focus_handle: gpui::FocusHandle,
4828 }
4829 impl EventEmitter<gpui::DismissEvent> for EmptyModalView {}
4830 impl Render for EmptyModalView {
4831 fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement {
4832 div()
4833 }
4834 }
4835 impl Focusable for EmptyModalView {
4836 fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
4837 self.focus_handle.clone()
4838 }
4839 }
4840 impl workspace::ModalView for EmptyModalView {}
4841
4842 workspace.update_in(cx, |workspace, window, cx| {
4843 workspace.toggle_modal(window, cx, |_, cx| EmptyModalView {
4844 focus_handle: cx.focus_handle(),
4845 });
4846 assert!(workspace.has_active_modal(window, cx));
4847 });
4848
4849 cx.dispatch_action(Deploy::find());
4850
4851 workspace.update_in(cx, |workspace, window, cx| {
4852 assert!(!workspace.has_active_modal(window, cx));
4853 workspace.toggle_modal(window, cx, |_, cx| EmptyModalView {
4854 focus_handle: cx.focus_handle(),
4855 });
4856 assert!(workspace.has_active_modal(window, cx));
4857 });
4858
4859 cx.dispatch_action(DeploySearch::default());
4860
4861 workspace.update_in(cx, |workspace, window, cx| {
4862 assert!(!workspace.has_active_modal(window, cx));
4863 });
4864 }
4865
4866 #[perf]
4867 #[gpui::test]
4868 async fn test_search_with_inlays(cx: &mut TestAppContext) {
4869 init_test(cx);
4870 cx.update(|cx| {
4871 SettingsStore::update_global(cx, |store, cx| {
4872 store.update_user_settings(cx, |settings| {
4873 settings.project.all_languages.defaults.inlay_hints =
4874 Some(InlayHintSettingsContent {
4875 enabled: Some(true),
4876 ..InlayHintSettingsContent::default()
4877 })
4878 });
4879 });
4880 });
4881
4882 let fs = FakeFs::new(cx.background_executor.clone());
4883 fs.insert_tree(
4884 path!("/dir"),
4885 // `\n` , a trailing line on the end, is important for the test case
4886 json!({
4887 "main.rs": "fn main() { let a = 2; }\n",
4888 }),
4889 )
4890 .await;
4891
4892 let requests_count = Arc::new(AtomicUsize::new(0));
4893 let closure_requests_count = requests_count.clone();
4894 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4895 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
4896 let language = rust_lang();
4897 language_registry.add(language);
4898 let mut fake_servers = language_registry.register_fake_lsp(
4899 "Rust",
4900 FakeLspAdapter {
4901 capabilities: lsp::ServerCapabilities {
4902 inlay_hint_provider: Some(lsp::OneOf::Left(true)),
4903 ..lsp::ServerCapabilities::default()
4904 },
4905 initializer: Some(Box::new(move |fake_server| {
4906 let requests_count = closure_requests_count.clone();
4907 fake_server.set_request_handler::<lsp::request::InlayHintRequest, _, _>({
4908 move |_, _| {
4909 let requests_count = requests_count.clone();
4910 async move {
4911 requests_count.fetch_add(1, atomic::Ordering::Release);
4912 Ok(Some(vec![lsp::InlayHint {
4913 position: lsp::Position::new(0, 17),
4914 label: lsp::InlayHintLabel::String(": i32".to_owned()),
4915 kind: Some(lsp::InlayHintKind::TYPE),
4916 text_edits: None,
4917 tooltip: None,
4918 padding_left: None,
4919 padding_right: None,
4920 data: None,
4921 }]))
4922 }
4923 }
4924 });
4925 })),
4926 ..FakeLspAdapter::default()
4927 },
4928 );
4929
4930 let window =
4931 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4932 let workspace = window
4933 .read_with(cx, |mw, _| mw.workspace().clone())
4934 .unwrap();
4935 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4936 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
4937 let search_view = cx.add_window(|window, cx| {
4938 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
4939 });
4940
4941 perform_search(search_view, "let ", cx);
4942 let fake_server = fake_servers.next().await.unwrap();
4943 cx.executor().advance_clock(Duration::from_secs(1));
4944 cx.executor().run_until_parked();
4945 search_view
4946 .update(cx, |search_view, _, cx| {
4947 assert_eq!(
4948 search_view
4949 .results_editor
4950 .update(cx, |editor, cx| editor.display_text(cx)),
4951 "\n\nfn main() { let a: i32 = 2; }\n"
4952 );
4953 })
4954 .unwrap();
4955 assert_eq!(
4956 requests_count.load(atomic::Ordering::Acquire),
4957 1,
4958 "New hints should have been queried",
4959 );
4960
4961 // Can do the 2nd search without any panics
4962 perform_search(search_view, "let ", cx);
4963 cx.executor().advance_clock(Duration::from_secs(1));
4964 cx.executor().run_until_parked();
4965 search_view
4966 .update(cx, |search_view, _, cx| {
4967 assert_eq!(
4968 search_view
4969 .results_editor
4970 .update(cx, |editor, cx| editor.display_text(cx)),
4971 "\n\nfn main() { let a: i32 = 2; }\n"
4972 );
4973 })
4974 .unwrap();
4975 assert_eq!(
4976 requests_count.load(atomic::Ordering::Acquire),
4977 2,
4978 "We did drop the previous buffer when cleared the old project search results, hence another query was made",
4979 );
4980
4981 let singleton_editor = workspace
4982 .update_in(cx, |workspace, window, cx| {
4983 workspace.open_abs_path(
4984 PathBuf::from(path!("/dir/main.rs")),
4985 workspace::OpenOptions::default(),
4986 window,
4987 cx,
4988 )
4989 })
4990 .await
4991 .unwrap()
4992 .downcast::<Editor>()
4993 .unwrap();
4994 cx.executor().advance_clock(Duration::from_millis(100));
4995 cx.executor().run_until_parked();
4996 singleton_editor.update(cx, |editor, cx| {
4997 assert_eq!(
4998 editor.display_text(cx),
4999 "fn main() { let a: i32 = 2; }\n",
5000 "Newly opened editor should have the correct text with hints",
5001 );
5002 });
5003 assert_eq!(
5004 requests_count.load(atomic::Ordering::Acquire),
5005 2,
5006 "Opening the same buffer again should reuse the cached hints",
5007 );
5008
5009 window
5010 .update(cx, |_, window, cx| {
5011 singleton_editor.update(cx, |editor, cx| {
5012 editor.handle_input("test", window, cx);
5013 });
5014 })
5015 .unwrap();
5016
5017 cx.executor().advance_clock(Duration::from_secs(1));
5018 cx.executor().run_until_parked();
5019 singleton_editor.update(cx, |editor, cx| {
5020 assert_eq!(
5021 editor.display_text(cx),
5022 "testfn main() { l: i32et a = 2; }\n",
5023 "Newly opened editor should have the correct text with hints",
5024 );
5025 });
5026 assert_eq!(
5027 requests_count.load(atomic::Ordering::Acquire),
5028 3,
5029 "We have edited the buffer and should send a new request",
5030 );
5031
5032 window
5033 .update(cx, |_, window, cx| {
5034 singleton_editor.update(cx, |editor, cx| {
5035 editor.undo(&editor::actions::Undo, window, cx);
5036 });
5037 })
5038 .unwrap();
5039 cx.executor().advance_clock(Duration::from_secs(1));
5040 cx.executor().run_until_parked();
5041 assert_eq!(
5042 requests_count.load(atomic::Ordering::Acquire),
5043 4,
5044 "We have edited the buffer again and should send a new request again",
5045 );
5046 singleton_editor.update(cx, |editor, cx| {
5047 assert_eq!(
5048 editor.display_text(cx),
5049 "fn main() { let a: i32 = 2; }\n",
5050 "Newly opened editor should have the correct text with hints",
5051 );
5052 });
5053 project.update(cx, |_, cx| {
5054 cx.emit(project::Event::RefreshInlayHints {
5055 server_id: fake_server.server.server_id(),
5056 request_id: Some(1),
5057 });
5058 });
5059 cx.executor().advance_clock(Duration::from_secs(1));
5060 cx.executor().run_until_parked();
5061 assert_eq!(
5062 requests_count.load(atomic::Ordering::Acquire),
5063 5,
5064 "After a simulated server refresh request, we should have sent another request",
5065 );
5066
5067 perform_search(search_view, "let ", cx);
5068 cx.executor().advance_clock(Duration::from_secs(1));
5069 cx.executor().run_until_parked();
5070 assert_eq!(
5071 requests_count.load(atomic::Ordering::Acquire),
5072 5,
5073 "New project search should reuse the cached hints",
5074 );
5075 search_view
5076 .update(cx, |search_view, _, cx| {
5077 assert_eq!(
5078 search_view
5079 .results_editor
5080 .update(cx, |editor, cx| editor.display_text(cx)),
5081 "\n\nfn main() { let a: i32 = 2; }\n"
5082 );
5083 })
5084 .unwrap();
5085 }
5086
5087 #[gpui::test]
5088 async fn test_deleted_file_removed_from_search_results(cx: &mut TestAppContext) {
5089 init_test(cx);
5090
5091 let fs = FakeFs::new(cx.background_executor.clone());
5092 fs.insert_tree(
5093 path!("/dir"),
5094 json!({
5095 "file_a.txt": "hello world",
5096 "file_b.txt": "hello universe",
5097 }),
5098 )
5099 .await;
5100
5101 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5102 let window =
5103 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5104 let workspace = window
5105 .read_with(cx, |mw, _| mw.workspace().clone())
5106 .unwrap();
5107 let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
5108 let search_view = cx.add_window(|window, cx| {
5109 ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
5110 });
5111
5112 perform_search(search_view, "hello", cx);
5113
5114 search_view
5115 .update(cx, |search_view, _window, cx| {
5116 let match_count = search_view.entity.read(cx).match_ranges.len();
5117 assert_eq!(match_count, 2, "Should have matches from both files");
5118 })
5119 .unwrap();
5120
5121 // Delete file_b.txt
5122 fs.remove_file(
5123 path!("/dir/file_b.txt").as_ref(),
5124 fs::RemoveOptions::default(),
5125 )
5126 .await
5127 .unwrap();
5128 cx.run_until_parked();
5129
5130 // Verify deleted file's results are removed proactively
5131 search_view
5132 .update(cx, |search_view, _window, cx| {
5133 let results_text = search_view
5134 .results_editor
5135 .update(cx, |editor, cx| editor.display_text(cx));
5136 assert!(
5137 !results_text.contains("universe"),
5138 "Deleted file's content should be removed from results, got: {results_text}"
5139 );
5140 assert!(
5141 results_text.contains("world"),
5142 "Remaining file's content should still be present, got: {results_text}"
5143 );
5144 })
5145 .unwrap();
5146
5147 // Re-run the search and verify deleted file stays gone
5148 perform_search(search_view, "hello", cx);
5149
5150 search_view
5151 .update(cx, |search_view, _window, cx| {
5152 let results_text = search_view
5153 .results_editor
5154 .update(cx, |editor, cx| editor.display_text(cx));
5155 assert!(
5156 !results_text.contains("universe"),
5157 "Deleted file should not reappear after re-search, got: {results_text}"
5158 );
5159 assert!(
5160 results_text.contains("world"),
5161 "Remaining file should still be found, got: {results_text}"
5162 );
5163 assert_eq!(
5164 search_view.entity.read(cx).match_ranges.len(),
5165 1,
5166 "Should only have match from the remaining file"
5167 );
5168 })
5169 .unwrap();
5170 }
5171
5172 #[gpui::test]
5173 async fn test_deploy_search_applies_and_resets_options(cx: &mut TestAppContext) {
5174 init_test(cx);
5175
5176 let fs = FakeFs::new(cx.background_executor.clone());
5177 fs.insert_tree(
5178 path!("/dir"),
5179 json!({
5180 "one.rs": "const ONE: usize = 1;",
5181 }),
5182 )
5183 .await;
5184 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5185 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
5186 let workspace = window
5187 .read_with(cx, |mw, _| mw.workspace().clone())
5188 .unwrap();
5189 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5190 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
5191
5192 workspace.update_in(cx, |workspace, window, cx| {
5193 workspace.panes()[0].update(cx, |pane, cx| {
5194 pane.toolbar()
5195 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
5196 });
5197
5198 ProjectSearchView::deploy_search(
5199 workspace,
5200 &workspace::DeploySearch {
5201 regex: Some(true),
5202 case_sensitive: Some(true),
5203 whole_word: Some(true),
5204 include_ignored: Some(true),
5205 query: Some("Test_Query".into()),
5206 ..Default::default()
5207 },
5208 window,
5209 cx,
5210 )
5211 });
5212
5213 let search_view = cx
5214 .read(|cx| {
5215 workspace
5216 .read(cx)
5217 .active_pane()
5218 .read(cx)
5219 .active_item()
5220 .and_then(|item| item.downcast::<ProjectSearchView>())
5221 })
5222 .expect("Search view should be active after deploy");
5223
5224 search_view.update_in(cx, |search_view, _window, cx| {
5225 assert!(
5226 search_view.search_options.contains(SearchOptions::REGEX),
5227 "Regex option should be enabled"
5228 );
5229 assert!(
5230 search_view
5231 .search_options
5232 .contains(SearchOptions::CASE_SENSITIVE),
5233 "Case sensitive option should be enabled"
5234 );
5235 assert!(
5236 search_view
5237 .search_options
5238 .contains(SearchOptions::WHOLE_WORD),
5239 "Whole word option should be enabled"
5240 );
5241 assert!(
5242 search_view
5243 .search_options
5244 .contains(SearchOptions::INCLUDE_IGNORED),
5245 "Include ignored option should be enabled"
5246 );
5247 let query_text = search_view.query_editor.read(cx).text(cx);
5248 assert_eq!(
5249 query_text, "Test_Query",
5250 "Query should be set from the action"
5251 );
5252 });
5253
5254 // Redeploy with only regex - unspecified options should be preserved.
5255 cx.dispatch_action(menu::Cancel);
5256 workspace.update_in(cx, |workspace, window, cx| {
5257 ProjectSearchView::deploy_search(
5258 workspace,
5259 &workspace::DeploySearch {
5260 regex: Some(true),
5261 ..Default::default()
5262 },
5263 window,
5264 cx,
5265 )
5266 });
5267
5268 search_view.update_in(cx, |search_view, _window, _cx| {
5269 assert!(
5270 search_view.search_options.contains(SearchOptions::REGEX),
5271 "Regex should still be enabled"
5272 );
5273 assert!(
5274 search_view
5275 .search_options
5276 .contains(SearchOptions::CASE_SENSITIVE),
5277 "Case sensitive should be preserved from previous deploy"
5278 );
5279 assert!(
5280 search_view
5281 .search_options
5282 .contains(SearchOptions::WHOLE_WORD),
5283 "Whole word should be preserved from previous deploy"
5284 );
5285 assert!(
5286 search_view
5287 .search_options
5288 .contains(SearchOptions::INCLUDE_IGNORED),
5289 "Include ignored should be preserved from previous deploy"
5290 );
5291 });
5292
5293 // Redeploy explicitly turning off options.
5294 cx.dispatch_action(menu::Cancel);
5295 workspace.update_in(cx, |workspace, window, cx| {
5296 ProjectSearchView::deploy_search(
5297 workspace,
5298 &workspace::DeploySearch {
5299 regex: Some(true),
5300 case_sensitive: Some(false),
5301 whole_word: Some(false),
5302 include_ignored: Some(false),
5303 ..Default::default()
5304 },
5305 window,
5306 cx,
5307 )
5308 });
5309
5310 search_view.update_in(cx, |search_view, _window, _cx| {
5311 assert_eq!(
5312 search_view.search_options,
5313 SearchOptions::REGEX,
5314 "Explicit Some(false) should turn off options"
5315 );
5316 });
5317
5318 // Redeploy with an empty query - should not overwrite the existing query.
5319 cx.dispatch_action(menu::Cancel);
5320 workspace.update_in(cx, |workspace, window, cx| {
5321 ProjectSearchView::deploy_search(
5322 workspace,
5323 &workspace::DeploySearch {
5324 query: Some("".into()),
5325 ..Default::default()
5326 },
5327 window,
5328 cx,
5329 )
5330 });
5331
5332 search_view.update_in(cx, |search_view, _window, cx| {
5333 let query_text = search_view.query_editor.read(cx).text(cx);
5334 assert_eq!(
5335 query_text, "Test_Query",
5336 "Empty query string should not overwrite the existing query"
5337 );
5338 });
5339 }
5340
5341 #[gpui::test]
5342 async fn test_smartcase_overrides_explicit_case_sensitive(cx: &mut TestAppContext) {
5343 init_test(cx);
5344
5345 cx.update(|cx| {
5346 cx.update_global::<SettingsStore, _>(|store, cx| {
5347 store.update_default_settings(cx, |settings| {
5348 settings.editor.use_smartcase_search = Some(true);
5349 });
5350 });
5351 });
5352
5353 let fs = FakeFs::new(cx.background_executor.clone());
5354 fs.insert_tree(
5355 path!("/dir"),
5356 json!({
5357 "one.rs": "const ONE: usize = 1;",
5358 }),
5359 )
5360 .await;
5361 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5362 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
5363 let workspace = window
5364 .read_with(cx, |mw, _| mw.workspace().clone())
5365 .unwrap();
5366 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5367 let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
5368
5369 workspace.update_in(cx, |workspace, window, cx| {
5370 workspace.panes()[0].update(cx, |pane, cx| {
5371 pane.toolbar()
5372 .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
5373 });
5374
5375 ProjectSearchView::deploy_search(
5376 workspace,
5377 &workspace::DeploySearch {
5378 case_sensitive: Some(true),
5379 query: Some("lowercase_query".into()),
5380 ..Default::default()
5381 },
5382 window,
5383 cx,
5384 )
5385 });
5386
5387 let search_view = cx
5388 .read(|cx| {
5389 workspace
5390 .read(cx)
5391 .active_pane()
5392 .read(cx)
5393 .active_item()
5394 .and_then(|item| item.downcast::<ProjectSearchView>())
5395 })
5396 .expect("Search view should be active after deploy");
5397
5398 // Smartcase should override the explicit case_sensitive flag
5399 // because the query is all lowercase.
5400 search_view.update_in(cx, |search_view, _window, cx| {
5401 assert!(
5402 !search_view
5403 .search_options
5404 .contains(SearchOptions::CASE_SENSITIVE),
5405 "Smartcase should disable case sensitivity for a lowercase query, \
5406 even when case_sensitive was explicitly set in the action"
5407 );
5408 let query_text = search_view.query_editor.read(cx).text(cx);
5409 assert_eq!(query_text, "lowercase_query");
5410 });
5411
5412 // Now deploy with an uppercase query - smartcase should enable case sensitivity.
5413 workspace.update_in(cx, |workspace, window, cx| {
5414 ProjectSearchView::deploy_search(
5415 workspace,
5416 &workspace::DeploySearch {
5417 query: Some("Uppercase_Query".into()),
5418 ..Default::default()
5419 },
5420 window,
5421 cx,
5422 )
5423 });
5424
5425 search_view.update_in(cx, |search_view, _window, cx| {
5426 assert!(
5427 search_view
5428 .search_options
5429 .contains(SearchOptions::CASE_SENSITIVE),
5430 "Smartcase should enable case sensitivity for a query containing uppercase"
5431 );
5432 let query_text = search_view.query_editor.read(cx).text(cx);
5433 assert_eq!(query_text, "Uppercase_Query");
5434 });
5435 }
5436
5437 fn init_test(cx: &mut TestAppContext) {
5438 cx.update(|cx| {
5439 let settings = SettingsStore::test(cx);
5440 cx.set_global(settings);
5441
5442 theme_settings::init(theme::LoadThemes::JustBase, cx);
5443
5444 editor::init(cx);
5445 crate::init(cx);
5446 });
5447 }
5448
5449 fn perform_search(
5450 search_view: WindowHandle<ProjectSearchView>,
5451 text: impl Into<Arc<str>>,
5452 cx: &mut TestAppContext,
5453 ) {
5454 search_view
5455 .update(cx, |search_view, window, cx| {
5456 search_view.query_editor.update(cx, |query_editor, cx| {
5457 query_editor.set_text(text, window, cx)
5458 });
5459 search_view.search(cx);
5460 })
5461 .unwrap();
5462 // Ensure editor highlights appear after the search is done
5463 cx.executor().advance_clock(
5464 editor::SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(100),
5465 );
5466 cx.background_executor.run_until_parked();
5467 }
5468}