project_search.rs

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