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