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