project_search.rs

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