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