project_search.rs

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