project_search.rs

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