project_search.rs

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