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