project_search.rs

   1use crate::{
   2    FocusSearch, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOptions,
   3    SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleIncludeIgnored, ToggleRegex,
   4    ToggleReplace, ToggleWholeWord,
   5};
   6use collections::{HashMap, HashSet};
   7use editor::{
   8    actions::SelectAll,
   9    items::active_match_index,
  10    scroll::{Autoscroll, Axis},
  11    Anchor, Editor, EditorElement, EditorEvent, EditorSettings, EditorStyle, MultiBuffer,
  12    MAX_TAB_TITLE_LEN,
  13};
  14use futures::StreamExt;
  15use gpui::{
  16    actions, div, Action, AnyElement, AnyView, AppContext, Context as _, EntityId, EventEmitter,
  17    FocusHandle, FocusableView, Global, Hsla, InteractiveElement, IntoElement, KeyContext, Model,
  18    ModelContext, ParentElement, Point, Render, SharedString, Styled, Subscription, Task,
  19    TextStyle, UpdateGlobal, View, ViewContext, VisualContext, WeakModel, WeakView, WindowContext,
  20};
  21use language::Buffer;
  22use menu::Confirm;
  23use project::{
  24    search::{SearchInputKind, SearchQuery},
  25    search_history::SearchHistoryCursor,
  26    Project, ProjectPath,
  27};
  28use settings::Settings;
  29use std::{
  30    any::{Any, TypeId},
  31    mem,
  32    ops::{Not, Range},
  33    path::Path,
  34};
  35use theme::ThemeSettings;
  36use ui::{
  37    h_flex, prelude::*, v_flex, Icon, IconButton, IconName, KeyBinding, Label, LabelCommon,
  38    LabelSize, Selectable, Tooltip,
  39};
  40use util::paths::PathMatcher;
  41use workspace::{
  42    item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
  43    searchable::{Direction, SearchableItem, SearchableItemHandle},
  44    DeploySearch, ItemNavHistory, NewSearch, ToolbarItemEvent, ToolbarItemLocation,
  45    ToolbarItemView, Workspace, WorkspaceId,
  46};
  47
  48const MIN_INPUT_WIDTH_REMS: f32 = 15.;
  49const MAX_INPUT_WIDTH_REMS: f32 = 30.;
  50
  51actions!(
  52    project_search,
  53    [SearchInNew, ToggleFocus, NextField, ToggleFilters]
  54);
  55
  56#[derive(Default)]
  57struct ActiveSettings(HashMap<WeakModel<Project>, ProjectSearchSettings>);
  58
  59impl Global for ActiveSettings {}
  60
  61pub fn init(cx: &mut AppContext) {
  62    cx.set_global(ActiveSettings::default());
  63    cx.observe_new_views(|workspace: &mut Workspace, _cx| {
  64        register_workspace_action(workspace, move |search_bar, _: &FocusSearch, cx| {
  65            search_bar.focus_search(cx);
  66        });
  67        register_workspace_action(workspace, move |search_bar, _: &ToggleFilters, cx| {
  68            search_bar.toggle_filters(cx);
  69        });
  70        register_workspace_action(workspace, move |search_bar, _: &ToggleCaseSensitive, cx| {
  71            search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
  72        });
  73        register_workspace_action(workspace, move |search_bar, _: &ToggleWholeWord, cx| {
  74            search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
  75        });
  76        register_workspace_action(workspace, move |search_bar, _: &ToggleRegex, cx| {
  77            search_bar.toggle_search_option(SearchOptions::REGEX, cx);
  78        });
  79        register_workspace_action(workspace, move |search_bar, action: &ToggleReplace, cx| {
  80            search_bar.toggle_replace(action, cx)
  81        });
  82        register_workspace_action(
  83            workspace,
  84            move |search_bar, action: &SelectPrevMatch, cx| {
  85                search_bar.select_prev_match(action, cx)
  86            },
  87        );
  88        register_workspace_action(
  89            workspace,
  90            move |search_bar, action: &SelectNextMatch, cx| {
  91                search_bar.select_next_match(action, cx)
  92            },
  93        );
  94
  95        // Only handle search_in_new if there is a search present
  96        register_workspace_action_for_present_search(workspace, |workspace, action, cx| {
  97            ProjectSearchView::search_in_new(workspace, action, cx)
  98        });
  99
 100        // Both on present and dismissed search, we need to unconditionally handle those actions to focus from the editor.
 101        workspace.register_action(move |workspace, action: &DeploySearch, cx| {
 102            if workspace.has_active_modal(cx) {
 103                cx.propagate();
 104                return;
 105            }
 106            ProjectSearchView::deploy_search(workspace, action, cx);
 107            cx.notify();
 108        });
 109        workspace.register_action(move |workspace, action: &NewSearch, cx| {
 110            if workspace.has_active_modal(cx) {
 111                cx.propagate();
 112                return;
 113            }
 114            ProjectSearchView::new_search(workspace, action, cx);
 115            cx.notify();
 116        });
 117    })
 118    .detach();
 119}
 120
 121fn is_contains_uppercase(str: &str) -> bool {
 122    str.chars().any(|c| c.is_uppercase())
 123}
 124
 125pub struct ProjectSearch {
 126    project: Model<Project>,
 127    excerpts: Model<MultiBuffer>,
 128    pending_search: Option<Task<Option<()>>>,
 129    match_ranges: Vec<Range<Anchor>>,
 130    active_query: Option<SearchQuery>,
 131    last_search_query_text: Option<String>,
 132    search_id: usize,
 133    no_results: Option<bool>,
 134    limit_reached: bool,
 135    search_history_cursor: SearchHistoryCursor,
 136    search_included_history_cursor: SearchHistoryCursor,
 137    search_excluded_history_cursor: SearchHistoryCursor,
 138}
 139
 140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 141enum InputPanel {
 142    Query,
 143    Exclude,
 144    Include,
 145}
 146
 147pub struct ProjectSearchView {
 148    workspace: WeakView<Workspace>,
 149    focus_handle: FocusHandle,
 150    model: Model<ProjectSearch>,
 151    query_editor: View<Editor>,
 152    replacement_editor: View<Editor>,
 153    results_editor: View<Editor>,
 154    search_options: SearchOptions,
 155    panels_with_errors: HashSet<InputPanel>,
 156    active_match_index: Option<usize>,
 157    search_id: usize,
 158    included_files_editor: View<Editor>,
 159    excluded_files_editor: View<Editor>,
 160    filters_enabled: bool,
 161    replace_enabled: bool,
 162    included_opened_only: bool,
 163    _subscriptions: Vec<Subscription>,
 164}
 165
 166#[derive(Debug, Clone)]
 167pub struct ProjectSearchSettings {
 168    search_options: SearchOptions,
 169    filters_enabled: bool,
 170}
 171
 172pub struct ProjectSearchBar {
 173    active_project_search: Option<View<ProjectSearchView>>,
 174    subscription: Option<Subscription>,
 175}
 176
 177impl ProjectSearch {
 178    pub fn new(project: Model<Project>, cx: &mut ModelContext<Self>) -> Self {
 179        let capability = project.read(cx).capability();
 180
 181        Self {
 182            project,
 183            excerpts: cx.new_model(|_| MultiBuffer::new(capability)),
 184            pending_search: Default::default(),
 185            match_ranges: Default::default(),
 186            active_query: None,
 187            last_search_query_text: None,
 188            search_id: 0,
 189            no_results: None,
 190            limit_reached: false,
 191            search_history_cursor: Default::default(),
 192            search_included_history_cursor: Default::default(),
 193            search_excluded_history_cursor: Default::default(),
 194        }
 195    }
 196
 197    fn clone(&self, cx: &mut ModelContext<Self>) -> Model<Self> {
 198        cx.new_model(|cx| Self {
 199            project: self.project.clone(),
 200            excerpts: self
 201                .excerpts
 202                .update(cx, |excerpts, cx| cx.new_model(|cx| excerpts.clone(cx))),
 203            pending_search: Default::default(),
 204            match_ranges: self.match_ranges.clone(),
 205            active_query: self.active_query.clone(),
 206            last_search_query_text: self.last_search_query_text.clone(),
 207            search_id: self.search_id,
 208            no_results: self.no_results,
 209            limit_reached: self.limit_reached,
 210            search_history_cursor: self.search_history_cursor.clone(),
 211            search_included_history_cursor: self.search_included_history_cursor.clone(),
 212            search_excluded_history_cursor: self.search_excluded_history_cursor.clone(),
 213        })
 214    }
 215    fn cursor(&self, kind: SearchInputKind) -> &SearchHistoryCursor {
 216        match kind {
 217            SearchInputKind::Query => &self.search_history_cursor,
 218            SearchInputKind::Include => &self.search_included_history_cursor,
 219            SearchInputKind::Exclude => &self.search_excluded_history_cursor,
 220        }
 221    }
 222    fn cursor_mut(&mut self, kind: SearchInputKind) -> &mut SearchHistoryCursor {
 223        match kind {
 224            SearchInputKind::Query => &mut self.search_history_cursor,
 225            SearchInputKind::Include => &mut self.search_included_history_cursor,
 226            SearchInputKind::Exclude => &mut self.search_excluded_history_cursor,
 227        }
 228    }
 229
 230    fn search(&mut self, query: SearchQuery, cx: &mut ModelContext<Self>) {
 231        let search = self.project.update(cx, |project, cx| {
 232            project
 233                .search_history_mut(SearchInputKind::Query)
 234                .add(&mut self.search_history_cursor, query.as_str().to_string());
 235            let included = query.as_inner().files_to_include().sources().join(",");
 236            if !included.is_empty() {
 237                project
 238                    .search_history_mut(SearchInputKind::Include)
 239                    .add(&mut self.search_included_history_cursor, included);
 240            }
 241            let excluded = query.as_inner().files_to_exclude().sources().join(",");
 242            if !excluded.is_empty() {
 243                project
 244                    .search_history_mut(SearchInputKind::Exclude)
 245                    .add(&mut self.search_excluded_history_cursor, excluded);
 246            }
 247            project.search(query.clone(), cx)
 248        });
 249        self.last_search_query_text = Some(query.as_str().to_string());
 250        self.search_id += 1;
 251        self.active_query = Some(query);
 252        self.match_ranges.clear();
 253        self.pending_search = Some(cx.spawn(|this, mut cx| async move {
 254            let mut matches = search.ready_chunks(1024);
 255            let this = this.upgrade()?;
 256            this.update(&mut cx, |this, cx| {
 257                this.match_ranges.clear();
 258                this.excerpts.update(cx, |this, cx| this.clear(cx));
 259                this.no_results = Some(true);
 260                this.limit_reached = false;
 261            })
 262            .ok()?;
 263
 264            let mut limit_reached = false;
 265            while let Some(results) = matches.next().await {
 266                let tasks = results
 267                    .into_iter()
 268                    .map(|result| {
 269                        let this = this.clone();
 270
 271                        cx.spawn(|mut cx| async move {
 272                            match result {
 273                                project::search::SearchResult::Buffer { buffer, ranges } => {
 274                                    let mut match_ranges_rx =
 275                                        this.update(&mut cx, |this, cx| {
 276                                            this.excerpts.update(cx, |excerpts, cx| {
 277                                                excerpts.stream_excerpts_with_context_lines(
 278                                                    buffer,
 279                                                    ranges,
 280                                                    editor::DEFAULT_MULTIBUFFER_CONTEXT,
 281                                                    cx,
 282                                                )
 283                                            })
 284                                        })?;
 285
 286                                    let mut match_ranges = vec![];
 287                                    while let Some(range) = match_ranges_rx.next().await {
 288                                        match_ranges.push(range);
 289                                    }
 290                                    anyhow::Ok((match_ranges, false))
 291                                }
 292                                project::search::SearchResult::LimitReached => {
 293                                    anyhow::Ok((vec![], true))
 294                                }
 295                            }
 296                        })
 297                    })
 298                    .collect::<Vec<_>>();
 299
 300                let result_ranges = futures::future::join_all(tasks).await;
 301                let mut combined_ranges = vec![];
 302                for (ranges, result_limit_reached) in result_ranges.into_iter().flatten() {
 303                    combined_ranges.extend(ranges);
 304                    if result_limit_reached {
 305                        limit_reached = result_limit_reached;
 306                    }
 307                }
 308                this.update(&mut cx, |this, cx| {
 309                    if !combined_ranges.is_empty() {
 310                        this.no_results = Some(false);
 311                        this.match_ranges.extend(combined_ranges);
 312                        cx.notify();
 313                    }
 314                })
 315                .ok()?;
 316            }
 317
 318            this.update(&mut cx, |this, cx| {
 319                this.limit_reached = limit_reached;
 320                this.pending_search.take();
 321                cx.notify();
 322            })
 323            .ok()?;
 324
 325            None
 326        }));
 327        cx.notify();
 328    }
 329}
 330
 331#[derive(Clone, Debug, PartialEq, Eq)]
 332pub enum ViewEvent {
 333    UpdateTab,
 334    Activate,
 335    EditorEvent(editor::EditorEvent),
 336    Dismiss,
 337}
 338
 339impl EventEmitter<ViewEvent> for ProjectSearchView {}
 340
 341impl Render for ProjectSearchView {
 342    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 343        if self.has_matches() {
 344            div()
 345                .flex_1()
 346                .size_full()
 347                .track_focus(&self.focus_handle)
 348                .child(self.results_editor.clone())
 349        } else {
 350            let model = self.model.read(cx);
 351            let has_no_results = model.no_results.unwrap_or(false);
 352            let is_search_underway = model.pending_search.is_some();
 353            let major_text = if is_search_underway {
 354                "Searching..."
 355            } else if has_no_results {
 356                "No results"
 357            } else {
 358                "Search all files"
 359            };
 360
 361            let major_text = div()
 362                .justify_center()
 363                .max_w_96()
 364                .child(Label::new(major_text).size(LabelSize::Large));
 365
 366            let minor_text: Option<AnyElement> = if let Some(no_results) = model.no_results {
 367                if model.pending_search.is_none() && no_results {
 368                    Some(
 369                        Label::new("No results found in this project for the provided query")
 370                            .size(LabelSize::Small)
 371                            .into_any_element(),
 372                    )
 373                } else {
 374                    None
 375                }
 376            } else {
 377                Some(self.landing_text_minor(cx).into_any_element())
 378            };
 379            let minor_text = minor_text.map(|text| div().items_center().max_w_96().child(text));
 380            v_flex()
 381                .flex_1()
 382                .size_full()
 383                .justify_center()
 384                .bg(cx.theme().colors().editor_background)
 385                .track_focus(&self.focus_handle)
 386                .child(
 387                    h_flex()
 388                        .size_full()
 389                        .justify_center()
 390                        .child(h_flex().flex_1())
 391                        .child(v_flex().gap_1().child(major_text).children(minor_text))
 392                        .child(h_flex().flex_1()),
 393                )
 394        }
 395    }
 396}
 397
 398impl FocusableView for ProjectSearchView {
 399    fn focus_handle(&self, _: &AppContext) -> gpui::FocusHandle {
 400        self.focus_handle.clone()
 401    }
 402}
 403
 404impl Item for ProjectSearchView {
 405    type Event = ViewEvent;
 406    fn tab_tooltip_text(&self, cx: &AppContext) -> Option<SharedString> {
 407        let query_text = self.query_editor.read(cx).text(cx);
 408
 409        query_text
 410            .is_empty()
 411            .not()
 412            .then(|| query_text.into())
 413            .or_else(|| Some("Project Search".into()))
 414    }
 415
 416    fn act_as_type<'a>(
 417        &'a self,
 418        type_id: TypeId,
 419        self_handle: &'a View<Self>,
 420        _: &'a AppContext,
 421    ) -> Option<AnyView> {
 422        if type_id == TypeId::of::<Self>() {
 423            Some(self_handle.clone().into())
 424        } else if type_id == TypeId::of::<Editor>() {
 425            Some(self.results_editor.clone().into())
 426        } else {
 427            None
 428        }
 429    }
 430
 431    fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
 432        self.results_editor
 433            .update(cx, |editor, cx| editor.deactivated(cx));
 434    }
 435
 436    fn tab_icon(&self, _cx: &WindowContext) -> Option<Icon> {
 437        Some(Icon::new(IconName::MagnifyingGlass))
 438    }
 439
 440    fn tab_content_text(&self, cx: &WindowContext) -> Option<SharedString> {
 441        let last_query: Option<SharedString> = self
 442            .model
 443            .read(cx)
 444            .last_search_query_text
 445            .as_ref()
 446            .map(|query| {
 447                let query = query.replace('\n', "");
 448                let query_text = util::truncate_and_trailoff(&query, MAX_TAB_TITLE_LEN);
 449                query_text.into()
 450            });
 451        Some(
 452            last_query
 453                .filter(|query| !query.is_empty())
 454                .unwrap_or_else(|| "Project Search".into()),
 455        )
 456    }
 457
 458    fn telemetry_event_text(&self) -> Option<&'static str> {
 459        Some("project search")
 460    }
 461
 462    fn for_each_project_item(
 463        &self,
 464        cx: &AppContext,
 465        f: &mut dyn FnMut(EntityId, &dyn project::Item),
 466    ) {
 467        self.results_editor.for_each_project_item(cx, f)
 468    }
 469
 470    fn is_singleton(&self, _: &AppContext) -> bool {
 471        false
 472    }
 473
 474    fn can_save(&self, _: &AppContext) -> bool {
 475        true
 476    }
 477
 478    fn is_dirty(&self, cx: &AppContext) -> bool {
 479        self.results_editor.read(cx).is_dirty(cx)
 480    }
 481
 482    fn has_conflict(&self, cx: &AppContext) -> bool {
 483        self.results_editor.read(cx).has_conflict(cx)
 484    }
 485
 486    fn save(
 487        &mut self,
 488        format: bool,
 489        project: Model<Project>,
 490        cx: &mut ViewContext<Self>,
 491    ) -> Task<anyhow::Result<()>> {
 492        self.results_editor
 493            .update(cx, |editor, cx| editor.save(format, project, cx))
 494    }
 495
 496    fn save_as(
 497        &mut self,
 498        _: Model<Project>,
 499        _: ProjectPath,
 500        _: &mut ViewContext<Self>,
 501    ) -> Task<anyhow::Result<()>> {
 502        unreachable!("save_as should not have been called")
 503    }
 504
 505    fn reload(
 506        &mut self,
 507        project: Model<Project>,
 508        cx: &mut ViewContext<Self>,
 509    ) -> Task<anyhow::Result<()>> {
 510        self.results_editor
 511            .update(cx, |editor, cx| editor.reload(project, cx))
 512    }
 513
 514    fn clone_on_split(
 515        &self,
 516        _workspace_id: Option<WorkspaceId>,
 517        cx: &mut ViewContext<Self>,
 518    ) -> Option<View<Self>>
 519    where
 520        Self: Sized,
 521    {
 522        let model = self.model.update(cx, |model, cx| model.clone(cx));
 523        Some(cx.new_view(|cx| Self::new(self.workspace.clone(), model, cx, None)))
 524    }
 525
 526    fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
 527        self.results_editor
 528            .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx));
 529    }
 530
 531    fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
 532        self.results_editor.update(cx, |editor, _| {
 533            editor.set_nav_history(Some(nav_history));
 534        });
 535    }
 536
 537    fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
 538        self.results_editor
 539            .update(cx, |editor, cx| editor.navigate(data, cx))
 540    }
 541
 542    fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
 543        match event {
 544            ViewEvent::UpdateTab => {
 545                f(ItemEvent::UpdateBreadcrumbs);
 546                f(ItemEvent::UpdateTab);
 547            }
 548            ViewEvent::EditorEvent(editor_event) => {
 549                Editor::to_item_events(editor_event, f);
 550            }
 551            ViewEvent::Dismiss => f(ItemEvent::CloseItem),
 552            _ => {}
 553        }
 554    }
 555
 556    fn breadcrumb_location(&self) -> ToolbarItemLocation {
 557        if self.has_matches() {
 558            ToolbarItemLocation::Secondary
 559        } else {
 560            ToolbarItemLocation::Hidden
 561        }
 562    }
 563
 564    fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
 565        self.results_editor.breadcrumbs(theme, cx)
 566    }
 567}
 568
 569impl ProjectSearchView {
 570    pub fn get_matches(&self, cx: &AppContext) -> Vec<Range<Anchor>> {
 571        self.model.read(cx).match_ranges.clone()
 572    }
 573
 574    fn toggle_filters(&mut self, cx: &mut ViewContext<Self>) {
 575        self.filters_enabled = !self.filters_enabled;
 576        ActiveSettings::update_global(cx, |settings, cx| {
 577            settings.0.insert(
 578                self.model.read(cx).project.downgrade(),
 579                self.current_settings(),
 580            );
 581        });
 582    }
 583
 584    fn current_settings(&self) -> ProjectSearchSettings {
 585        ProjectSearchSettings {
 586            search_options: self.search_options,
 587            filters_enabled: self.filters_enabled,
 588        }
 589    }
 590
 591    fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) {
 592        self.search_options.toggle(option);
 593        ActiveSettings::update_global(cx, |settings, cx| {
 594            settings.0.insert(
 595                self.model.read(cx).project.downgrade(),
 596                self.current_settings(),
 597            );
 598        });
 599    }
 600
 601    fn toggle_opened_only(&mut self, _cx: &mut ViewContext<Self>) {
 602        self.included_opened_only = !self.included_opened_only;
 603    }
 604
 605    fn replace_next(&mut self, _: &ReplaceNext, cx: &mut ViewContext<Self>) {
 606        if self.model.read(cx).match_ranges.is_empty() {
 607            return;
 608        }
 609        let Some(active_index) = self.active_match_index else {
 610            return;
 611        };
 612
 613        let query = self.model.read(cx).active_query.clone();
 614        if let Some(query) = query {
 615            let query = query.with_replacement(self.replacement(cx));
 616
 617            // TODO: Do we need the clone here?
 618            let mat = self.model.read(cx).match_ranges[active_index].clone();
 619            self.results_editor.update(cx, |editor, cx| {
 620                editor.replace(&mat, &query, cx);
 621            });
 622            self.select_match(Direction::Next, cx)
 623        }
 624    }
 625    pub fn replacement(&self, cx: &AppContext) -> String {
 626        self.replacement_editor.read(cx).text(cx)
 627    }
 628    fn replace_all(&mut self, _: &ReplaceAll, cx: &mut ViewContext<Self>) {
 629        if self.active_match_index.is_none() {
 630            return;
 631        }
 632
 633        let Some(query) = self.model.read(cx).active_query.as_ref() else {
 634            return;
 635        };
 636        let query = query.clone().with_replacement(self.replacement(cx));
 637
 638        let match_ranges = self
 639            .model
 640            .update(cx, |model, _| mem::take(&mut model.match_ranges));
 641        if match_ranges.is_empty() {
 642            return;
 643        }
 644
 645        self.results_editor.update(cx, |editor, cx| {
 646            editor.replace_all(&mut match_ranges.iter(), &query, cx);
 647        });
 648
 649        self.model.update(cx, |model, _cx| {
 650            model.match_ranges = match_ranges;
 651        });
 652    }
 653
 654    pub fn new(
 655        workspace: WeakView<Workspace>,
 656        model: Model<ProjectSearch>,
 657        cx: &mut ViewContext<Self>,
 658        settings: Option<ProjectSearchSettings>,
 659    ) -> Self {
 660        let project;
 661        let excerpts;
 662        let mut replacement_text = None;
 663        let mut query_text = String::new();
 664        let mut subscriptions = Vec::new();
 665
 666        // Read in settings if available
 667        let (mut options, filters_enabled) = if let Some(settings) = settings {
 668            (settings.search_options, settings.filters_enabled)
 669        } else {
 670            let search_options =
 671                SearchOptions::from_settings(&EditorSettings::get_global(cx).search);
 672            (search_options, false)
 673        };
 674
 675        {
 676            let model = model.read(cx);
 677            project = model.project.clone();
 678            excerpts = model.excerpts.clone();
 679            if let Some(active_query) = model.active_query.as_ref() {
 680                query_text = active_query.as_str().to_string();
 681                replacement_text = active_query.replacement().map(ToOwned::to_owned);
 682                options = SearchOptions::from_query(active_query);
 683            }
 684        }
 685        subscriptions.push(cx.observe(&model, |this, _, cx| this.model_changed(cx)));
 686
 687        let query_editor = cx.new_view(|cx| {
 688            let mut editor = Editor::single_line(cx);
 689            editor.set_placeholder_text("Search all files..", cx);
 690            editor.set_text(query_text, cx);
 691            editor
 692        });
 693        // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
 694        subscriptions.push(
 695            cx.subscribe(&query_editor, |this, _, event: &EditorEvent, cx| {
 696                if let EditorEvent::Edited { .. } = event {
 697                    if EditorSettings::get_global(cx).use_smartcase_search {
 698                        let query = this.search_query_text(cx);
 699                        if !query.is_empty()
 700                            && this.search_options.contains(SearchOptions::CASE_SENSITIVE)
 701                                != is_contains_uppercase(&query)
 702                        {
 703                            this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
 704                        }
 705                    }
 706                }
 707                cx.emit(ViewEvent::EditorEvent(event.clone()))
 708            }),
 709        );
 710        let replacement_editor = cx.new_view(|cx| {
 711            let mut editor = Editor::single_line(cx);
 712            editor.set_placeholder_text("Replace in project..", cx);
 713            if let Some(text) = replacement_text {
 714                editor.set_text(text, cx);
 715            }
 716            editor
 717        });
 718        let results_editor = cx.new_view(|cx| {
 719            let mut editor = Editor::for_multibuffer(excerpts, Some(project.clone()), true, cx);
 720            editor.set_searchable(false);
 721            editor
 722        });
 723        subscriptions.push(cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab)));
 724
 725        subscriptions.push(
 726            cx.subscribe(&results_editor, |this, _, event: &EditorEvent, cx| {
 727                if matches!(event, editor::EditorEvent::SelectionsChanged { .. }) {
 728                    this.update_match_index(cx);
 729                }
 730                // Reraise editor events for workspace item activation purposes
 731                cx.emit(ViewEvent::EditorEvent(event.clone()));
 732            }),
 733        );
 734
 735        let included_files_editor = cx.new_view(|cx| {
 736            let mut editor = Editor::single_line(cx);
 737            editor.set_placeholder_text("Include: crates/**/*.toml", cx);
 738
 739            editor
 740        });
 741        // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes
 742        subscriptions.push(
 743            cx.subscribe(&included_files_editor, |_, _, event: &EditorEvent, cx| {
 744                cx.emit(ViewEvent::EditorEvent(event.clone()))
 745            }),
 746        );
 747
 748        let excluded_files_editor = cx.new_view(|cx| {
 749            let mut editor = Editor::single_line(cx);
 750            editor.set_placeholder_text("Exclude: vendor/*, *.lock", cx);
 751
 752            editor
 753        });
 754        // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes
 755        subscriptions.push(
 756            cx.subscribe(&excluded_files_editor, |_, _, event: &EditorEvent, cx| {
 757                cx.emit(ViewEvent::EditorEvent(event.clone()))
 758            }),
 759        );
 760
 761        let focus_handle = cx.focus_handle();
 762        subscriptions.push(cx.on_focus_in(&focus_handle, |this, cx| {
 763            if this.focus_handle.is_focused(cx) {
 764                if this.has_matches() {
 765                    this.results_editor.focus_handle(cx).focus(cx);
 766                } else {
 767                    this.query_editor.focus_handle(cx).focus(cx);
 768                }
 769            }
 770        }));
 771
 772        // Check if Worktrees have all been previously indexed
 773        let mut this = ProjectSearchView {
 774            workspace,
 775            focus_handle,
 776            replacement_editor,
 777            search_id: model.read(cx).search_id,
 778            model,
 779            query_editor,
 780            results_editor,
 781            search_options: options,
 782            panels_with_errors: HashSet::default(),
 783            active_match_index: None,
 784            included_files_editor,
 785            excluded_files_editor,
 786            filters_enabled,
 787            replace_enabled: false,
 788            included_opened_only: false,
 789            _subscriptions: subscriptions,
 790        };
 791        this.model_changed(cx);
 792        this
 793    }
 794
 795    pub fn new_search_in_directory(
 796        workspace: &mut Workspace,
 797        dir_path: &Path,
 798        cx: &mut ViewContext<Workspace>,
 799    ) {
 800        let Some(filter_str) = dir_path.to_str() else {
 801            return;
 802        };
 803
 804        let weak_workspace = cx.view().downgrade();
 805
 806        let model = cx.new_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
 807        let search = cx.new_view(|cx| ProjectSearchView::new(weak_workspace, model, cx, None));
 808        workspace.add_item_to_active_pane(Box::new(search.clone()), None, true, cx);
 809        search.update(cx, |search, cx| {
 810            search
 811                .included_files_editor
 812                .update(cx, |editor, cx| editor.set_text(filter_str, cx));
 813            search.filters_enabled = true;
 814            search.focus_query_editor(cx)
 815        });
 816    }
 817
 818    /// Re-activate the most recently activated search in this pane or the most recent if it has been closed.
 819    /// If no search exists in the workspace, create a new one.
 820    pub fn deploy_search(
 821        workspace: &mut Workspace,
 822        action: &workspace::DeploySearch,
 823        cx: &mut ViewContext<Workspace>,
 824    ) {
 825        let existing = workspace
 826            .active_pane()
 827            .read(cx)
 828            .items()
 829            .find_map(|item| item.downcast::<ProjectSearchView>());
 830
 831        Self::existing_or_new_search(workspace, existing, action, cx);
 832    }
 833
 834    fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
 835        if let Some(search_view) = workspace
 836            .active_item(cx)
 837            .and_then(|item| item.downcast::<ProjectSearchView>())
 838        {
 839            let new_query = search_view.update(cx, |search_view, cx| {
 840                let new_query = search_view.build_search_query(cx);
 841                if new_query.is_some() {
 842                    if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
 843                        search_view.query_editor.update(cx, |editor, cx| {
 844                            editor.set_text(old_query.as_str(), cx);
 845                        });
 846                        search_view.search_options = SearchOptions::from_query(&old_query);
 847                    }
 848                }
 849                new_query
 850            });
 851            if let Some(new_query) = new_query {
 852                let model = cx.new_model(|cx| {
 853                    let mut model = ProjectSearch::new(workspace.project().clone(), cx);
 854                    model.search(new_query, cx);
 855                    model
 856                });
 857                let weak_workspace = cx.view().downgrade();
 858                workspace.add_item_to_active_pane(
 859                    Box::new(
 860                        cx.new_view(|cx| ProjectSearchView::new(weak_workspace, model, cx, None)),
 861                    ),
 862                    None,
 863                    true,
 864                    cx,
 865                );
 866            }
 867        }
 868    }
 869
 870    // Add another search tab to the workspace.
 871    fn new_search(
 872        workspace: &mut Workspace,
 873        _: &workspace::NewSearch,
 874        cx: &mut ViewContext<Workspace>,
 875    ) {
 876        Self::existing_or_new_search(workspace, None, &DeploySearch::find(), cx)
 877    }
 878
 879    fn existing_or_new_search(
 880        workspace: &mut Workspace,
 881        existing: Option<View<ProjectSearchView>>,
 882        action: &workspace::DeploySearch,
 883        cx: &mut ViewContext<Workspace>,
 884    ) {
 885        let query = workspace.active_item(cx).and_then(|item| {
 886            let editor = item.act_as::<Editor>(cx)?;
 887            let query = editor.query_suggestion(cx);
 888            if query.is_empty() {
 889                None
 890            } else {
 891                Some(query)
 892            }
 893        });
 894
 895        let search = if let Some(existing) = existing {
 896            workspace.activate_item(&existing, true, true, cx);
 897            existing
 898        } else {
 899            let settings = cx
 900                .global::<ActiveSettings>()
 901                .0
 902                .get(&workspace.project().downgrade());
 903
 904            let settings = settings.cloned();
 905
 906            let weak_workspace = cx.view().downgrade();
 907
 908            let model = cx.new_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
 909            let view =
 910                cx.new_view(|cx| ProjectSearchView::new(weak_workspace, model, cx, settings));
 911
 912            workspace.add_item_to_active_pane(Box::new(view.clone()), None, true, cx);
 913            view
 914        };
 915
 916        search.update(cx, |search, cx| {
 917            search.replace_enabled = action.replace_enabled;
 918            if let Some(query) = query {
 919                search.set_query(&query, cx);
 920            }
 921            search.focus_query_editor(cx)
 922        });
 923    }
 924
 925    fn search(&mut self, cx: &mut ViewContext<Self>) {
 926        if let Some(query) = self.build_search_query(cx) {
 927            self.model.update(cx, |model, cx| model.search(query, cx));
 928        }
 929    }
 930
 931    pub fn search_query_text(&self, cx: &WindowContext) -> String {
 932        self.query_editor.read(cx).text(cx)
 933    }
 934
 935    fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
 936        // Do not bail early in this function, as we want to fill out `self.panels_with_errors`.
 937        let text = self.query_editor.read(cx).text(cx);
 938        let open_buffers = if self.included_opened_only {
 939            Some(self.open_buffers(cx))
 940        } else {
 941            None
 942        };
 943        let included_files =
 944            match Self::parse_path_matches(&self.included_files_editor.read(cx).text(cx)) {
 945                Ok(included_files) => {
 946                    let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Include);
 947                    if should_unmark_error {
 948                        cx.notify();
 949                    }
 950                    included_files
 951                }
 952                Err(_e) => {
 953                    let should_mark_error = self.panels_with_errors.insert(InputPanel::Include);
 954                    if should_mark_error {
 955                        cx.notify();
 956                    }
 957                    PathMatcher::default()
 958                }
 959            };
 960        let excluded_files =
 961            match Self::parse_path_matches(&self.excluded_files_editor.read(cx).text(cx)) {
 962                Ok(excluded_files) => {
 963                    let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Exclude);
 964                    if should_unmark_error {
 965                        cx.notify();
 966                    }
 967
 968                    excluded_files
 969                }
 970                Err(_e) => {
 971                    let should_mark_error = self.panels_with_errors.insert(InputPanel::Exclude);
 972                    if should_mark_error {
 973                        cx.notify();
 974                    }
 975                    PathMatcher::default()
 976                }
 977            };
 978
 979        let query = if self.search_options.contains(SearchOptions::REGEX) {
 980            match SearchQuery::regex(
 981                text,
 982                self.search_options.contains(SearchOptions::WHOLE_WORD),
 983                self.search_options.contains(SearchOptions::CASE_SENSITIVE),
 984                self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
 985                included_files,
 986                excluded_files,
 987                open_buffers,
 988            ) {
 989                Ok(query) => {
 990                    let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
 991                    if should_unmark_error {
 992                        cx.notify();
 993                    }
 994
 995                    Some(query)
 996                }
 997                Err(_e) => {
 998                    let should_mark_error = self.panels_with_errors.insert(InputPanel::Query);
 999                    if should_mark_error {
1000                        cx.notify();
1001                    }
1002
1003                    None
1004                }
1005            }
1006        } else {
1007            match SearchQuery::text(
1008                text,
1009                self.search_options.contains(SearchOptions::WHOLE_WORD),
1010                self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1011                self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1012                included_files,
1013                excluded_files,
1014                open_buffers,
1015            ) {
1016                Ok(query) => {
1017                    let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
1018                    if should_unmark_error {
1019                        cx.notify();
1020                    }
1021
1022                    Some(query)
1023                }
1024                Err(_e) => {
1025                    let should_mark_error = self.panels_with_errors.insert(InputPanel::Query);
1026                    if should_mark_error {
1027                        cx.notify();
1028                    }
1029
1030                    None
1031                }
1032            }
1033        };
1034        if !self.panels_with_errors.is_empty() {
1035            return None;
1036        }
1037        if query.as_ref().is_some_and(|query| query.is_empty()) {
1038            return None;
1039        }
1040        query
1041    }
1042
1043    fn open_buffers(&self, cx: &mut ViewContext<Self>) -> Vec<Model<Buffer>> {
1044        let mut buffers = Vec::new();
1045        self.workspace
1046            .update(cx, |workspace, cx| {
1047                for editor in workspace.items_of_type::<Editor>(cx) {
1048                    if let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() {
1049                        buffers.push(buffer);
1050                    }
1051                }
1052            })
1053            .ok();
1054        buffers
1055    }
1056
1057    fn parse_path_matches(text: &str) -> anyhow::Result<PathMatcher> {
1058        let queries = text
1059            .split(',')
1060            .map(str::trim)
1061            .filter(|maybe_glob_str| !maybe_glob_str.is_empty())
1062            .map(str::to_owned)
1063            .collect::<Vec<_>>();
1064        Ok(PathMatcher::new(&queries)?)
1065    }
1066
1067    fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
1068        if let Some(index) = self.active_match_index {
1069            let match_ranges = self.model.read(cx).match_ranges.clone();
1070
1071            if !EditorSettings::get_global(cx).search_wrap
1072                && ((direction == Direction::Next && index + 1 >= match_ranges.len())
1073                    || (direction == Direction::Prev && index == 0))
1074            {
1075                crate::show_no_more_matches(cx);
1076                return;
1077            }
1078
1079            let new_index = self.results_editor.update(cx, |editor, cx| {
1080                editor.match_index_for_direction(&match_ranges, index, direction, 1, cx)
1081            });
1082
1083            let range_to_select = match_ranges[new_index].clone();
1084            self.results_editor.update(cx, |editor, cx| {
1085                let range_to_select = editor.range_for_match(&range_to_select);
1086                editor.unfold_ranges([range_to_select.clone()], false, true, cx);
1087                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
1088                    s.select_ranges([range_to_select])
1089                });
1090            });
1091        }
1092    }
1093
1094    fn focus_query_editor(&mut self, cx: &mut ViewContext<Self>) {
1095        self.query_editor.update(cx, |query_editor, cx| {
1096            query_editor.select_all(&SelectAll, cx);
1097        });
1098        let editor_handle = self.query_editor.focus_handle(cx);
1099        cx.focus(&editor_handle);
1100    }
1101
1102    fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
1103        self.set_search_editor(SearchInputKind::Query, query, cx);
1104        if EditorSettings::get_global(cx).use_smartcase_search
1105            && !query.is_empty()
1106            && self.search_options.contains(SearchOptions::CASE_SENSITIVE)
1107                != is_contains_uppercase(query)
1108        {
1109            self.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx)
1110        }
1111    }
1112
1113    fn set_search_editor(&mut self, kind: SearchInputKind, text: &str, cx: &mut ViewContext<Self>) {
1114        let editor = match kind {
1115            SearchInputKind::Query => &self.query_editor,
1116            SearchInputKind::Include => &self.included_files_editor,
1117
1118            SearchInputKind::Exclude => &self.excluded_files_editor,
1119        };
1120        editor.update(cx, |included_editor, cx| included_editor.set_text(text, cx));
1121    }
1122
1123    fn focus_results_editor(&mut self, cx: &mut ViewContext<Self>) {
1124        self.query_editor.update(cx, |query_editor, cx| {
1125            let cursor = query_editor.selections.newest_anchor().head();
1126            query_editor.change_selections(None, cx, |s| s.select_ranges([cursor..cursor]));
1127        });
1128        let results_handle = self.results_editor.focus_handle(cx);
1129        cx.focus(&results_handle);
1130    }
1131
1132    fn model_changed(&mut self, cx: &mut ViewContext<Self>) {
1133        let match_ranges = self.model.read(cx).match_ranges.clone();
1134        if match_ranges.is_empty() {
1135            self.active_match_index = None;
1136        } else {
1137            self.active_match_index = Some(0);
1138            self.update_match_index(cx);
1139            let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id);
1140            let is_new_search = self.search_id != prev_search_id;
1141            self.results_editor.update(cx, |editor, cx| {
1142                if is_new_search {
1143                    let range_to_select = match_ranges
1144                        .first()
1145                        .map(|range| editor.range_for_match(range));
1146                    editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
1147                        s.select_ranges(range_to_select)
1148                    });
1149                    editor.scroll(Point::default(), Some(Axis::Vertical), cx);
1150                }
1151                editor.highlight_background::<Self>(
1152                    &match_ranges,
1153                    |theme| theme.search_match_background,
1154                    cx,
1155                );
1156            });
1157            if is_new_search && self.query_editor.focus_handle(cx).is_focused(cx) {
1158                self.focus_results_editor(cx);
1159            }
1160        }
1161
1162        cx.emit(ViewEvent::UpdateTab);
1163        cx.notify();
1164    }
1165
1166    fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
1167        let results_editor = self.results_editor.read(cx);
1168        let new_index = active_match_index(
1169            &self.model.read(cx).match_ranges,
1170            &results_editor.selections.newest_anchor().head(),
1171            &results_editor.buffer().read(cx).snapshot(cx),
1172        );
1173        if self.active_match_index != new_index {
1174            self.active_match_index = new_index;
1175            cx.notify();
1176        }
1177    }
1178
1179    pub fn has_matches(&self) -> bool {
1180        self.active_match_index.is_some()
1181    }
1182
1183    fn landing_text_minor(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1184        let focus_handle = self.focus_handle.clone();
1185        v_flex()
1186            .gap_1()
1187            .child(Label::new("Hit enter to search. For more options:"))
1188            .child(
1189                Button::new("filter-paths", "Include/exclude specific paths")
1190                    .icon(IconName::Filter)
1191                    .icon_position(IconPosition::Start)
1192                    .icon_size(IconSize::Small)
1193                    .key_binding(KeyBinding::for_action_in(&ToggleFilters, &focus_handle, cx))
1194                    .on_click(|_event, cx| cx.dispatch_action(ToggleFilters.boxed_clone())),
1195            )
1196            .child(
1197                Button::new("find-replace", "Find and replace")
1198                    .icon(IconName::Replace)
1199                    .icon_position(IconPosition::Start)
1200                    .icon_size(IconSize::Small)
1201                    .key_binding(KeyBinding::for_action_in(&ToggleReplace, &focus_handle, cx))
1202                    .on_click(|_event, cx| cx.dispatch_action(ToggleReplace.boxed_clone())),
1203            )
1204            .child(
1205                Button::new("regex", "Match with regex")
1206                    .icon(IconName::Regex)
1207                    .icon_position(IconPosition::Start)
1208                    .icon_size(IconSize::Small)
1209                    .key_binding(KeyBinding::for_action_in(&ToggleRegex, &focus_handle, cx))
1210                    .on_click(|_event, cx| cx.dispatch_action(ToggleRegex.boxed_clone())),
1211            )
1212            .child(
1213                Button::new("match-case", "Match case")
1214                    .icon(IconName::CaseSensitive)
1215                    .icon_position(IconPosition::Start)
1216                    .icon_size(IconSize::Small)
1217                    .key_binding(KeyBinding::for_action_in(
1218                        &ToggleCaseSensitive,
1219                        &focus_handle,
1220                        cx,
1221                    ))
1222                    .on_click(|_event, cx| cx.dispatch_action(ToggleCaseSensitive.boxed_clone())),
1223            )
1224            .child(
1225                Button::new("match-whole-words", "Match whole words")
1226                    .icon(IconName::WholeWord)
1227                    .icon_position(IconPosition::Start)
1228                    .icon_size(IconSize::Small)
1229                    .key_binding(KeyBinding::for_action_in(
1230                        &ToggleWholeWord,
1231                        &focus_handle,
1232                        cx,
1233                    ))
1234                    .on_click(|_event, cx| cx.dispatch_action(ToggleWholeWord.boxed_clone())),
1235            )
1236    }
1237
1238    fn border_color_for(&self, panel: InputPanel, cx: &WindowContext) -> Hsla {
1239        if self.panels_with_errors.contains(&panel) {
1240            Color::Error.color(cx)
1241        } else {
1242            cx.theme().colors().border
1243        }
1244    }
1245
1246    fn move_focus_to_results(&mut self, cx: &mut ViewContext<Self>) {
1247        if !self.results_editor.focus_handle(cx).is_focused(cx)
1248            && !self.model.read(cx).match_ranges.is_empty()
1249        {
1250            cx.stop_propagation();
1251            self.focus_results_editor(cx)
1252        }
1253    }
1254
1255    #[cfg(any(test, feature = "test-support"))]
1256    pub fn results_editor(&self) -> &View<Editor> {
1257        &self.results_editor
1258    }
1259}
1260
1261impl Default for ProjectSearchBar {
1262    fn default() -> Self {
1263        Self::new()
1264    }
1265}
1266
1267impl ProjectSearchBar {
1268    pub fn new() -> Self {
1269        Self {
1270            active_project_search: None,
1271            subscription: None,
1272        }
1273    }
1274
1275    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
1276        if let Some(search_view) = self.active_project_search.as_ref() {
1277            search_view.update(cx, |search_view, cx| {
1278                if !search_view
1279                    .replacement_editor
1280                    .focus_handle(cx)
1281                    .is_focused(cx)
1282                {
1283                    cx.stop_propagation();
1284                    search_view.search(cx);
1285                }
1286            });
1287        }
1288    }
1289
1290    fn tab(&mut self, _: &editor::actions::Tab, cx: &mut ViewContext<Self>) {
1291        self.cycle_field(Direction::Next, cx);
1292    }
1293
1294    fn tab_previous(&mut self, _: &editor::actions::TabPrev, cx: &mut ViewContext<Self>) {
1295        self.cycle_field(Direction::Prev, cx);
1296    }
1297
1298    fn focus_search(&mut self, cx: &mut ViewContext<Self>) {
1299        if let Some(search_view) = self.active_project_search.as_ref() {
1300            search_view.update(cx, |search_view, cx| {
1301                search_view.query_editor.focus_handle(cx).focus(cx);
1302            });
1303        }
1304    }
1305
1306    fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
1307        let active_project_search = match &self.active_project_search {
1308            Some(active_project_search) => active_project_search,
1309
1310            None => {
1311                return;
1312            }
1313        };
1314
1315        active_project_search.update(cx, |project_view, cx| {
1316            let mut views = vec![&project_view.query_editor];
1317            if project_view.replace_enabled {
1318                views.push(&project_view.replacement_editor);
1319            }
1320            if project_view.filters_enabled {
1321                views.extend([
1322                    &project_view.included_files_editor,
1323                    &project_view.excluded_files_editor,
1324                ]);
1325            }
1326            let current_index = match views
1327                .iter()
1328                .enumerate()
1329                .find(|(_, view)| view.focus_handle(cx).is_focused(cx))
1330            {
1331                Some((index, _)) => index,
1332                None => return,
1333            };
1334
1335            let new_index = match direction {
1336                Direction::Next => (current_index + 1) % views.len(),
1337                Direction::Prev if current_index == 0 => views.len() - 1,
1338                Direction::Prev => (current_index - 1) % views.len(),
1339            };
1340            let next_focus_handle = views[new_index].focus_handle(cx);
1341            cx.focus(&next_focus_handle);
1342            cx.stop_propagation();
1343        });
1344    }
1345
1346    fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) -> bool {
1347        if let Some(search_view) = self.active_project_search.as_ref() {
1348            search_view.update(cx, |search_view, cx| {
1349                search_view.toggle_search_option(option, cx);
1350                if search_view.model.read(cx).active_query.is_some() {
1351                    search_view.search(cx);
1352                }
1353            });
1354
1355            cx.notify();
1356            true
1357        } else {
1358            false
1359        }
1360    }
1361
1362    fn toggle_replace(&mut self, _: &ToggleReplace, cx: &mut ViewContext<Self>) {
1363        if let Some(search) = &self.active_project_search {
1364            search.update(cx, |this, cx| {
1365                this.replace_enabled = !this.replace_enabled;
1366                let editor_to_focus = if this.replace_enabled {
1367                    this.replacement_editor.focus_handle(cx)
1368                } else {
1369                    this.query_editor.focus_handle(cx)
1370                };
1371                cx.focus(&editor_to_focus);
1372                cx.notify();
1373            });
1374        }
1375    }
1376
1377    fn toggle_filters(&mut self, cx: &mut ViewContext<Self>) -> bool {
1378        if let Some(search_view) = self.active_project_search.as_ref() {
1379            search_view.update(cx, |search_view, cx| {
1380                search_view.toggle_filters(cx);
1381                search_view
1382                    .included_files_editor
1383                    .update(cx, |_, cx| cx.notify());
1384                search_view
1385                    .excluded_files_editor
1386                    .update(cx, |_, cx| cx.notify());
1387                cx.refresh();
1388                cx.notify();
1389            });
1390            cx.notify();
1391            true
1392        } else {
1393            false
1394        }
1395    }
1396
1397    fn toggle_opened_only(&mut self, cx: &mut ViewContext<Self>) -> bool {
1398        if let Some(search_view) = self.active_project_search.as_ref() {
1399            search_view.update(cx, |search_view, cx| {
1400                search_view.toggle_opened_only(cx);
1401                if search_view.model.read(cx).active_query.is_some() {
1402                    search_view.search(cx);
1403                }
1404            });
1405
1406            cx.notify();
1407            true
1408        } else {
1409            false
1410        }
1411    }
1412
1413    fn is_opened_only_enabled(&self, cx: &AppContext) -> bool {
1414        if let Some(search_view) = self.active_project_search.as_ref() {
1415            search_view.read(cx).included_opened_only
1416        } else {
1417            false
1418        }
1419    }
1420
1421    fn move_focus_to_results(&self, cx: &mut ViewContext<Self>) {
1422        if let Some(search_view) = self.active_project_search.as_ref() {
1423            search_view.update(cx, |search_view, cx| {
1424                search_view.move_focus_to_results(cx);
1425            });
1426            cx.notify();
1427        }
1428    }
1429
1430    fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool {
1431        if let Some(search) = self.active_project_search.as_ref() {
1432            search.read(cx).search_options.contains(option)
1433        } else {
1434            false
1435        }
1436    }
1437
1438    fn next_history_query(&mut self, _: &NextHistoryQuery, cx: &mut ViewContext<Self>) {
1439        if let Some(search_view) = self.active_project_search.as_ref() {
1440            search_view.update(cx, |search_view, cx| {
1441                for (editor, kind) in [
1442                    (search_view.query_editor.clone(), SearchInputKind::Query),
1443                    (
1444                        search_view.included_files_editor.clone(),
1445                        SearchInputKind::Include,
1446                    ),
1447                    (
1448                        search_view.excluded_files_editor.clone(),
1449                        SearchInputKind::Exclude,
1450                    ),
1451                ] {
1452                    if editor.focus_handle(cx).is_focused(cx) {
1453                        let new_query = search_view.model.update(cx, |model, cx| {
1454                            let project = model.project.clone();
1455
1456                            if let Some(new_query) = project.update(cx, |project, _| {
1457                                project
1458                                    .search_history_mut(kind)
1459                                    .next(model.cursor_mut(kind))
1460                                    .map(str::to_string)
1461                            }) {
1462                                new_query
1463                            } else {
1464                                model.cursor_mut(kind).reset();
1465                                String::new()
1466                            }
1467                        });
1468                        search_view.set_search_editor(kind, &new_query, cx);
1469                    }
1470                }
1471            });
1472        }
1473    }
1474
1475    fn previous_history_query(&mut self, _: &PreviousHistoryQuery, cx: &mut ViewContext<Self>) {
1476        if let Some(search_view) = self.active_project_search.as_ref() {
1477            search_view.update(cx, |search_view, cx| {
1478                for (editor, kind) in [
1479                    (search_view.query_editor.clone(), SearchInputKind::Query),
1480                    (
1481                        search_view.included_files_editor.clone(),
1482                        SearchInputKind::Include,
1483                    ),
1484                    (
1485                        search_view.excluded_files_editor.clone(),
1486                        SearchInputKind::Exclude,
1487                    ),
1488                ] {
1489                    if editor.focus_handle(cx).is_focused(cx) {
1490                        if editor.read(cx).text(cx).is_empty() {
1491                            if let Some(new_query) = search_view
1492                                .model
1493                                .read(cx)
1494                                .project
1495                                .read(cx)
1496                                .search_history(kind)
1497                                .current(search_view.model.read(cx).cursor(kind))
1498                                .map(str::to_string)
1499                            {
1500                                search_view.set_search_editor(kind, &new_query, cx);
1501                                return;
1502                            }
1503                        }
1504
1505                        if let Some(new_query) = search_view.model.update(cx, |model, cx| {
1506                            let project = model.project.clone();
1507                            project.update(cx, |project, _| {
1508                                project
1509                                    .search_history_mut(kind)
1510                                    .previous(model.cursor_mut(kind))
1511                                    .map(str::to_string)
1512                            })
1513                        }) {
1514                            search_view.set_search_editor(kind, &new_query, cx);
1515                        }
1516                    }
1517                }
1518            });
1519        }
1520    }
1521
1522    fn select_next_match(&mut self, _: &SelectNextMatch, cx: &mut ViewContext<Self>) {
1523        if let Some(search) = self.active_project_search.as_ref() {
1524            search.update(cx, |this, cx| {
1525                this.select_match(Direction::Next, cx);
1526            })
1527        }
1528    }
1529
1530    fn select_prev_match(&mut self, _: &SelectPrevMatch, cx: &mut ViewContext<Self>) {
1531        if let Some(search) = self.active_project_search.as_ref() {
1532            search.update(cx, |this, cx| {
1533                this.select_match(Direction::Prev, cx);
1534            })
1535        }
1536    }
1537
1538    fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
1539        let settings = ThemeSettings::get_global(cx);
1540        let text_style = TextStyle {
1541            color: if editor.read(cx).read_only(cx) {
1542                cx.theme().colors().text_disabled
1543            } else {
1544                cx.theme().colors().text
1545            },
1546            font_family: settings.buffer_font.family.clone(),
1547            font_features: settings.buffer_font.features.clone(),
1548            font_fallbacks: settings.buffer_font.fallbacks.clone(),
1549            font_size: rems(0.875).into(),
1550            font_weight: settings.buffer_font.weight,
1551            line_height: relative(1.3),
1552            ..Default::default()
1553        };
1554
1555        EditorElement::new(
1556            editor,
1557            EditorStyle {
1558                background: cx.theme().colors().editor_background,
1559                local_player: cx.theme().players().local(),
1560                text: text_style,
1561                ..Default::default()
1562            },
1563        )
1564    }
1565}
1566
1567impl Render for ProjectSearchBar {
1568    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1569        let Some(search) = self.active_project_search.clone() else {
1570            return div();
1571        };
1572        let search = search.read(cx);
1573
1574        let query_column = h_flex()
1575            .flex_1()
1576            .h_8()
1577            .mr_2()
1578            .px_2()
1579            .py_1()
1580            .border_1()
1581            .border_color(search.border_color_for(InputPanel::Query, cx))
1582            .rounded_lg()
1583            .min_w(rems(MIN_INPUT_WIDTH_REMS))
1584            .max_w(rems(MAX_INPUT_WIDTH_REMS))
1585            .on_action(cx.listener(|this, action, cx| this.confirm(action, cx)))
1586            .on_action(cx.listener(|this, action, cx| this.previous_history_query(action, cx)))
1587            .on_action(cx.listener(|this, action, cx| this.next_history_query(action, cx)))
1588            .child(self.render_text_input(&search.query_editor, cx))
1589            .child(
1590                h_flex()
1591                    .child(SearchOptions::CASE_SENSITIVE.as_button(
1592                        self.is_option_enabled(SearchOptions::CASE_SENSITIVE, cx),
1593                        cx.listener(|this, _, cx| {
1594                            this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1595                        }),
1596                    ))
1597                    .child(SearchOptions::WHOLE_WORD.as_button(
1598                        self.is_option_enabled(SearchOptions::WHOLE_WORD, cx),
1599                        cx.listener(|this, _, cx| {
1600                            this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1601                        }),
1602                    ))
1603                    .child(SearchOptions::REGEX.as_button(
1604                        self.is_option_enabled(SearchOptions::REGEX, cx),
1605                        cx.listener(|this, _, cx| {
1606                            this.toggle_search_option(SearchOptions::REGEX, cx);
1607                        }),
1608                    )),
1609            );
1610
1611        let mode_column = v_flex().items_start().justify_start().child(
1612            h_flex()
1613                .child(
1614                    IconButton::new("project-search-filter-button", IconName::Filter)
1615                        .tooltip(|cx| Tooltip::for_action("Toggle filters", &ToggleFilters, cx))
1616                        .on_click(cx.listener(|this, _, cx| {
1617                            this.toggle_filters(cx);
1618                        }))
1619                        .selected(
1620                            self.active_project_search
1621                                .as_ref()
1622                                .map(|search| search.read(cx).filters_enabled)
1623                                .unwrap_or_default(),
1624                        )
1625                        .tooltip(|cx| Tooltip::for_action("Toggle filters", &ToggleFilters, cx)),
1626                )
1627                .child(
1628                    IconButton::new("project-search-toggle-replace", IconName::Replace)
1629                        .on_click(cx.listener(|this, _, cx| {
1630                            this.toggle_replace(&ToggleReplace, cx);
1631                        }))
1632                        .selected(
1633                            self.active_project_search
1634                                .as_ref()
1635                                .map(|search| search.read(cx).replace_enabled)
1636                                .unwrap_or_default(),
1637                        )
1638                        .tooltip(|cx| Tooltip::for_action("Toggle replace", &ToggleReplace, cx)),
1639                ),
1640        );
1641
1642        let limit_reached = search.model.read(cx).limit_reached;
1643        let match_text = search
1644            .active_match_index
1645            .and_then(|index| {
1646                let index = index + 1;
1647                let match_quantity = search.model.read(cx).match_ranges.len();
1648                if match_quantity > 0 {
1649                    debug_assert!(match_quantity >= index);
1650                    if limit_reached {
1651                        Some(format!("{index}/{match_quantity}+").to_string())
1652                    } else {
1653                        Some(format!("{index}/{match_quantity}").to_string())
1654                    }
1655                } else {
1656                    None
1657                }
1658            })
1659            .unwrap_or_else(|| "0/0".to_string());
1660
1661        let matches_column = h_flex()
1662            .child(
1663                IconButton::new("project-search-prev-match", IconName::ChevronLeft)
1664                    .disabled(search.active_match_index.is_none())
1665                    .on_click(cx.listener(|this, _, cx| {
1666                        if let Some(search) = this.active_project_search.as_ref() {
1667                            search.update(cx, |this, cx| {
1668                                this.select_match(Direction::Prev, cx);
1669                            })
1670                        }
1671                    }))
1672                    .tooltip(|cx| {
1673                        Tooltip::for_action("Go to previous match", &SelectPrevMatch, cx)
1674                    }),
1675            )
1676            .child(
1677                IconButton::new("project-search-next-match", IconName::ChevronRight)
1678                    .disabled(search.active_match_index.is_none())
1679                    .on_click(cx.listener(|this, _, cx| {
1680                        if let Some(search) = this.active_project_search.as_ref() {
1681                            search.update(cx, |this, cx| {
1682                                this.select_match(Direction::Next, cx);
1683                            })
1684                        }
1685                    }))
1686                    .tooltip(|cx| Tooltip::for_action("Go to next match", &SelectNextMatch, cx)),
1687            )
1688            .child(
1689                h_flex()
1690                    .id("matches")
1691                    .min_w(rems_from_px(40.))
1692                    .child(
1693                        Label::new(match_text).color(if search.active_match_index.is_some() {
1694                            Color::Default
1695                        } else {
1696                            Color::Disabled
1697                        }),
1698                    )
1699                    .when(limit_reached, |el| {
1700                        el.tooltip(|cx| {
1701                            Tooltip::text("Search limits reached.\nTry narrowing your search.", cx)
1702                        })
1703                    }),
1704            );
1705
1706        let search_line = h_flex()
1707            .flex_1()
1708            .child(query_column)
1709            .child(mode_column)
1710            .child(matches_column);
1711
1712        let replace_line = search.replace_enabled.then(|| {
1713            let replace_column = h_flex()
1714                .flex_1()
1715                .min_w(rems(MIN_INPUT_WIDTH_REMS))
1716                .max_w(rems(MAX_INPUT_WIDTH_REMS))
1717                .h_8()
1718                .px_2()
1719                .py_1()
1720                .border_1()
1721                .border_color(cx.theme().colors().border)
1722                .rounded_lg()
1723                .child(self.render_text_input(&search.replacement_editor, cx));
1724            let replace_actions = h_flex().when(search.replace_enabled, |this| {
1725                this.child(
1726                    IconButton::new("project-search-replace-next", IconName::ReplaceNext)
1727                        .on_click(cx.listener(|this, _, cx| {
1728                            if let Some(search) = this.active_project_search.as_ref() {
1729                                search.update(cx, |this, cx| {
1730                                    this.replace_next(&ReplaceNext, cx);
1731                                })
1732                            }
1733                        }))
1734                        .tooltip(|cx| Tooltip::for_action("Replace next match", &ReplaceNext, cx)),
1735                )
1736                .child(
1737                    IconButton::new("project-search-replace-all", IconName::ReplaceAll)
1738                        .on_click(cx.listener(|this, _, cx| {
1739                            if let Some(search) = this.active_project_search.as_ref() {
1740                                search.update(cx, |this, cx| {
1741                                    this.replace_all(&ReplaceAll, cx);
1742                                })
1743                            }
1744                        }))
1745                        .tooltip(|cx| Tooltip::for_action("Replace all matches", &ReplaceAll, cx)),
1746                )
1747            });
1748            h_flex()
1749                .pr(rems(5.5))
1750                .gap_2()
1751                .child(replace_column)
1752                .child(replace_actions)
1753        });
1754
1755        let filter_line = search.filters_enabled.then(|| {
1756            h_flex()
1757                .w_full()
1758                .gap_2()
1759                .child(
1760                    h_flex()
1761                        .flex_1()
1762                        // chosen so the total width of the search bar line
1763                        // is about the same as the include/exclude line
1764                        .min_w(rems(10.25))
1765                        .max_w(rems(20.))
1766                        .h_8()
1767                        .px_2()
1768                        .py_1()
1769                        .border_1()
1770                        .border_color(search.border_color_for(InputPanel::Include, cx))
1771                        .rounded_lg()
1772                        .on_action(
1773                            cx.listener(|this, action, cx| this.previous_history_query(action, cx)),
1774                        )
1775                        .on_action(
1776                            cx.listener(|this, action, cx| this.next_history_query(action, cx)),
1777                        )
1778                        .child(self.render_text_input(&search.included_files_editor, cx)),
1779                )
1780                .child(
1781                    h_flex()
1782                        .flex_1()
1783                        .min_w(rems(10.25))
1784                        .max_w(rems(20.))
1785                        .h_8()
1786                        .px_2()
1787                        .py_1()
1788                        .border_1()
1789                        .border_color(search.border_color_for(InputPanel::Exclude, cx))
1790                        .rounded_lg()
1791                        .on_action(
1792                            cx.listener(|this, action, cx| this.previous_history_query(action, cx)),
1793                        )
1794                        .on_action(
1795                            cx.listener(|this, action, cx| this.next_history_query(action, cx)),
1796                        )
1797                        .child(self.render_text_input(&search.excluded_files_editor, cx)),
1798                )
1799                .child(
1800                    IconButton::new("project-search-opened-only", IconName::FileDoc)
1801                        .selected(self.is_opened_only_enabled(cx))
1802                        .tooltip(|cx| Tooltip::text("Only search open files", cx))
1803                        .on_click(cx.listener(|this, _, cx| {
1804                            this.toggle_opened_only(cx);
1805                        })),
1806                )
1807                .child(
1808                    SearchOptions::INCLUDE_IGNORED.as_button(
1809                        search
1810                            .search_options
1811                            .contains(SearchOptions::INCLUDE_IGNORED),
1812                        cx.listener(|this, _, cx| {
1813                            this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
1814                        }),
1815                    ),
1816                )
1817        });
1818        let mut key_context = KeyContext::default();
1819        key_context.add("ProjectSearchBar");
1820        if search.replacement_editor.focus_handle(cx).is_focused(cx) {
1821            key_context.add("in_replace");
1822        }
1823
1824        v_flex()
1825            .key_context(key_context)
1826            .on_action(cx.listener(|this, _: &ToggleFocus, cx| this.move_focus_to_results(cx)))
1827            .on_action(cx.listener(|this, _: &ToggleFilters, cx| {
1828                this.toggle_filters(cx);
1829            }))
1830            .capture_action(cx.listener(|this, action, cx| {
1831                this.tab(action, cx);
1832                cx.stop_propagation();
1833            }))
1834            .capture_action(cx.listener(|this, action, cx| {
1835                this.tab_previous(action, cx);
1836                cx.stop_propagation();
1837            }))
1838            .on_action(cx.listener(|this, action, cx| this.confirm(action, cx)))
1839            .on_action(cx.listener(|this, action, cx| {
1840                this.toggle_replace(action, cx);
1841            }))
1842            .on_action(cx.listener(|this, _: &ToggleWholeWord, cx| {
1843                this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1844            }))
1845            .on_action(cx.listener(|this, _: &ToggleCaseSensitive, cx| {
1846                this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1847            }))
1848            .on_action(cx.listener(|this, action, cx| {
1849                if let Some(search) = this.active_project_search.as_ref() {
1850                    search.update(cx, |this, cx| {
1851                        this.replace_next(action, cx);
1852                    })
1853                }
1854            }))
1855            .on_action(cx.listener(|this, action, cx| {
1856                if let Some(search) = this.active_project_search.as_ref() {
1857                    search.update(cx, |this, cx| {
1858                        this.replace_all(action, cx);
1859                    })
1860                }
1861            }))
1862            .when(search.filters_enabled, |this| {
1863                this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, cx| {
1864                    this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
1865                }))
1866            })
1867            .on_action(cx.listener(Self::select_next_match))
1868            .on_action(cx.listener(Self::select_prev_match))
1869            .gap_2()
1870            .w_full()
1871            .child(search_line)
1872            .children(replace_line)
1873            .children(filter_line)
1874    }
1875}
1876
1877impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
1878
1879impl ToolbarItemView for ProjectSearchBar {
1880    fn set_active_pane_item(
1881        &mut self,
1882        active_pane_item: Option<&dyn ItemHandle>,
1883        cx: &mut ViewContext<Self>,
1884    ) -> ToolbarItemLocation {
1885        cx.notify();
1886        self.subscription = None;
1887        self.active_project_search = None;
1888        if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
1889            self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
1890            self.active_project_search = Some(search);
1891            ToolbarItemLocation::PrimaryLeft {}
1892        } else {
1893            ToolbarItemLocation::Hidden
1894        }
1895    }
1896}
1897
1898fn register_workspace_action<A: Action>(
1899    workspace: &mut Workspace,
1900    callback: fn(&mut ProjectSearchBar, &A, &mut ViewContext<ProjectSearchBar>),
1901) {
1902    workspace.register_action(move |workspace, action: &A, cx| {
1903        if workspace.has_active_modal(cx) {
1904            cx.propagate();
1905            return;
1906        }
1907
1908        workspace.active_pane().update(cx, |pane, cx| {
1909            pane.toolbar().update(cx, move |workspace, cx| {
1910                if let Some(search_bar) = workspace.item_of_type::<ProjectSearchBar>() {
1911                    search_bar.update(cx, move |search_bar, cx| {
1912                        if search_bar.active_project_search.is_some() {
1913                            callback(search_bar, action, cx);
1914                            cx.notify();
1915                        } else {
1916                            cx.propagate();
1917                        }
1918                    });
1919                }
1920            });
1921        })
1922    });
1923}
1924
1925fn register_workspace_action_for_present_search<A: Action>(
1926    workspace: &mut Workspace,
1927    callback: fn(&mut Workspace, &A, &mut ViewContext<Workspace>),
1928) {
1929    workspace.register_action(move |workspace, action: &A, cx| {
1930        if workspace.has_active_modal(cx) {
1931            cx.propagate();
1932            return;
1933        }
1934
1935        let should_notify = workspace
1936            .active_pane()
1937            .read(cx)
1938            .toolbar()
1939            .read(cx)
1940            .item_of_type::<ProjectSearchBar>()
1941            .map(|search_bar| search_bar.read(cx).active_project_search.is_some())
1942            .unwrap_or(false);
1943        if should_notify {
1944            callback(workspace, action, cx);
1945            cx.notify();
1946        } else {
1947            cx.propagate();
1948        }
1949    });
1950}
1951
1952#[cfg(any(test, feature = "test-support"))]
1953pub fn perform_project_search(
1954    search_view: &View<ProjectSearchView>,
1955    text: impl Into<std::sync::Arc<str>>,
1956    cx: &mut gpui::VisualTestContext,
1957) {
1958    search_view.update(cx, |search_view, cx| {
1959        search_view
1960            .query_editor
1961            .update(cx, |query_editor, cx| query_editor.set_text(text, cx));
1962        search_view.search(cx);
1963    });
1964    cx.run_until_parked();
1965}
1966
1967#[cfg(test)]
1968pub mod tests {
1969    use std::sync::Arc;
1970
1971    use super::*;
1972    use editor::{display_map::DisplayRow, DisplayPoint};
1973    use gpui::{Action, TestAppContext, WindowHandle};
1974    use project::FakeFs;
1975    use serde_json::json;
1976    use settings::SettingsStore;
1977    use workspace::DeploySearch;
1978
1979    #[gpui::test]
1980    async fn test_project_search(cx: &mut TestAppContext) {
1981        init_test(cx);
1982
1983        let fs = FakeFs::new(cx.background_executor.clone());
1984        fs.insert_tree(
1985            "/dir",
1986            json!({
1987                "one.rs": "const ONE: usize = 1;",
1988                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1989                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1990                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1991            }),
1992        )
1993        .await;
1994        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1995        let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1996        let workspace = window.root(cx).unwrap();
1997        let search = cx.new_model(|cx| ProjectSearch::new(project.clone(), cx));
1998        let search_view = cx.add_window(|cx| {
1999            ProjectSearchView::new(workspace.downgrade(), search.clone(), cx, None)
2000        });
2001
2002        perform_search(search_view, "TWO", cx);
2003        search_view.update(cx, |search_view, cx| {
2004            assert_eq!(
2005                search_view
2006                    .results_editor
2007                    .update(cx, |editor, cx| editor.display_text(cx)),
2008                "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n"
2009            );
2010            let match_background_color = cx.theme().colors().search_match_background;
2011            assert_eq!(
2012                search_view
2013                    .results_editor
2014                    .update(cx, |editor, cx| editor.all_text_background_highlights(cx)),
2015                &[
2016                    (
2017                        DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35),
2018                        match_background_color
2019                    ),
2020                    (
2021                        DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40),
2022                        match_background_color
2023                    ),
2024                    (
2025                        DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9),
2026                        match_background_color
2027                    )
2028                ]
2029            );
2030            assert_eq!(search_view.active_match_index, Some(0));
2031            assert_eq!(
2032                search_view
2033                    .results_editor
2034                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2035                [DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35)]
2036            );
2037
2038            search_view.select_match(Direction::Next, cx);
2039        }).unwrap();
2040
2041        search_view
2042            .update(cx, |search_view, cx| {
2043                assert_eq!(search_view.active_match_index, Some(1));
2044                assert_eq!(
2045                    search_view
2046                        .results_editor
2047                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2048                    [DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40)]
2049                );
2050                search_view.select_match(Direction::Next, cx);
2051            })
2052            .unwrap();
2053
2054        search_view
2055            .update(cx, |search_view, cx| {
2056                assert_eq!(search_view.active_match_index, Some(2));
2057                assert_eq!(
2058                    search_view
2059                        .results_editor
2060                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2061                    [DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9)]
2062                );
2063                search_view.select_match(Direction::Next, cx);
2064            })
2065            .unwrap();
2066
2067        search_view
2068            .update(cx, |search_view, cx| {
2069                assert_eq!(search_view.active_match_index, Some(0));
2070                assert_eq!(
2071                    search_view
2072                        .results_editor
2073                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2074                    [DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35)]
2075                );
2076                search_view.select_match(Direction::Prev, cx);
2077            })
2078            .unwrap();
2079
2080        search_view
2081            .update(cx, |search_view, cx| {
2082                assert_eq!(search_view.active_match_index, Some(2));
2083                assert_eq!(
2084                    search_view
2085                        .results_editor
2086                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2087                    [DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9)]
2088                );
2089                search_view.select_match(Direction::Prev, cx);
2090            })
2091            .unwrap();
2092
2093        search_view
2094            .update(cx, |search_view, cx| {
2095                assert_eq!(search_view.active_match_index, Some(1));
2096                assert_eq!(
2097                    search_view
2098                        .results_editor
2099                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
2100                    [DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40)]
2101                );
2102            })
2103            .unwrap();
2104    }
2105
2106    #[gpui::test]
2107    async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
2108        init_test(cx);
2109
2110        let fs = FakeFs::new(cx.background_executor.clone());
2111        fs.insert_tree(
2112            "/dir",
2113            json!({
2114                "one.rs": "const ONE: usize = 1;",
2115                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2116                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2117                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2118            }),
2119        )
2120        .await;
2121        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2122        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2123        let workspace = window;
2124        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2125
2126        let active_item = cx.read(|cx| {
2127            workspace
2128                .read(cx)
2129                .unwrap()
2130                .active_pane()
2131                .read(cx)
2132                .active_item()
2133                .and_then(|item| item.downcast::<ProjectSearchView>())
2134        });
2135        assert!(
2136            active_item.is_none(),
2137            "Expected no search panel to be active"
2138        );
2139
2140        window
2141            .update(cx, move |workspace, cx| {
2142                assert_eq!(workspace.panes().len(), 1);
2143                workspace.panes()[0].update(cx, move |pane, cx| {
2144                    pane.toolbar()
2145                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2146                });
2147
2148                ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::find(), cx)
2149            })
2150            .unwrap();
2151
2152        let Some(search_view) = cx.read(|cx| {
2153            workspace
2154                .read(cx)
2155                .unwrap()
2156                .active_pane()
2157                .read(cx)
2158                .active_item()
2159                .and_then(|item| item.downcast::<ProjectSearchView>())
2160        }) else {
2161            panic!("Search view expected to appear after new search event trigger")
2162        };
2163
2164        cx.spawn(|mut cx| async move {
2165            window
2166                .update(&mut cx, |_, cx| {
2167                    cx.dispatch_action(ToggleFocus.boxed_clone())
2168                })
2169                .unwrap();
2170        })
2171        .detach();
2172        cx.background_executor.run_until_parked();
2173        window
2174            .update(cx, |_, cx| {
2175                search_view.update(cx, |search_view, cx| {
2176                assert!(
2177                    search_view.query_editor.focus_handle(cx).is_focused(cx),
2178                    "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2179                );
2180           });
2181        }).unwrap();
2182
2183        window
2184            .update(cx, |_, cx| {
2185                search_view.update(cx, |search_view, cx| {
2186                    let query_editor = &search_view.query_editor;
2187                    assert!(
2188                        query_editor.focus_handle(cx).is_focused(cx),
2189                        "Search view should be focused after the new search view is activated",
2190                    );
2191                    let query_text = query_editor.read(cx).text(cx);
2192                    assert!(
2193                        query_text.is_empty(),
2194                        "New search query should be empty but got '{query_text}'",
2195                    );
2196                    let results_text = search_view
2197                        .results_editor
2198                        .update(cx, |editor, cx| editor.display_text(cx));
2199                    assert!(
2200                        results_text.is_empty(),
2201                        "Empty search view should have no results but got '{results_text}'"
2202                    );
2203                });
2204            })
2205            .unwrap();
2206
2207        window
2208            .update(cx, |_, cx| {
2209                search_view.update(cx, |search_view, cx| {
2210                    search_view.query_editor.update(cx, |query_editor, cx| {
2211                        query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
2212                    });
2213                    search_view.search(cx);
2214                });
2215            })
2216            .unwrap();
2217        cx.background_executor.run_until_parked();
2218        window
2219            .update(cx, |_, cx| {
2220            search_view.update(cx, |search_view, cx| {
2221                let results_text = search_view
2222                    .results_editor
2223                    .update(cx, |editor, cx| editor.display_text(cx));
2224                assert!(
2225                    results_text.is_empty(),
2226                    "Search view for mismatching query should have no results but got '{results_text}'"
2227                );
2228                assert!(
2229                    search_view.query_editor.focus_handle(cx).is_focused(cx),
2230                    "Search view should be focused after mismatching query had been used in search",
2231                );
2232            });
2233        }).unwrap();
2234
2235        cx.spawn(|mut cx| async move {
2236            window.update(&mut cx, |_, cx| {
2237                cx.dispatch_action(ToggleFocus.boxed_clone())
2238            })
2239        })
2240        .detach();
2241        cx.background_executor.run_until_parked();
2242        window.update(cx, |_, cx| {
2243            search_view.update(cx, |search_view, cx| {
2244                assert!(
2245                    search_view.query_editor.focus_handle(cx).is_focused(cx),
2246                    "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2247                );
2248            });
2249        }).unwrap();
2250
2251        window
2252            .update(cx, |_, cx| {
2253                search_view.update(cx, |search_view, cx| {
2254                    search_view
2255                        .query_editor
2256                        .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2257                    search_view.search(cx);
2258                });
2259            })
2260            .unwrap();
2261        cx.background_executor.run_until_parked();
2262        window.update(cx, |_, cx| {
2263            search_view.update(cx, |search_view, cx| {
2264                assert_eq!(
2265                    search_view
2266                        .results_editor
2267                        .update(cx, |editor, cx| editor.display_text(cx)),
2268                    "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2269                    "Search view results should match the query"
2270                );
2271                assert!(
2272                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2273                    "Search view with mismatching query should be focused after search results are available",
2274                );
2275            });
2276        }).unwrap();
2277        cx.spawn(|mut cx| async move {
2278            window
2279                .update(&mut cx, |_, cx| {
2280                    cx.dispatch_action(ToggleFocus.boxed_clone())
2281                })
2282                .unwrap();
2283        })
2284        .detach();
2285        cx.background_executor.run_until_parked();
2286        window.update(cx, |_, cx| {
2287            search_view.update(cx, |search_view, cx| {
2288                assert!(
2289                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2290                    "Search view with matching query should still have its results editor focused after the toggle focus event",
2291                );
2292            });
2293        }).unwrap();
2294
2295        workspace
2296            .update(cx, |workspace, cx| {
2297                ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::find(), cx)
2298            })
2299            .unwrap();
2300        window.update(cx, |_, cx| {
2301            search_view.update(cx, |search_view, cx| {
2302                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");
2303                assert_eq!(
2304                    search_view
2305                        .results_editor
2306                        .update(cx, |editor, cx| editor.display_text(cx)),
2307                    "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2308                    "Results should be unchanged after search view 2nd open in a row"
2309                );
2310                assert!(
2311                    search_view.query_editor.focus_handle(cx).is_focused(cx),
2312                    "Focus should be moved into query editor again after search view 2nd open in a row"
2313                );
2314            });
2315        }).unwrap();
2316
2317        cx.spawn(|mut cx| async move {
2318            window
2319                .update(&mut cx, |_, cx| {
2320                    cx.dispatch_action(ToggleFocus.boxed_clone())
2321                })
2322                .unwrap();
2323        })
2324        .detach();
2325        cx.background_executor.run_until_parked();
2326        window.update(cx, |_, cx| {
2327            search_view.update(cx, |search_view, cx| {
2328                assert!(
2329                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2330                    "Search view with matching query should switch focus to the results editor after the toggle focus event",
2331                );
2332            });
2333        }).unwrap();
2334    }
2335
2336    #[gpui::test]
2337    async fn test_new_project_search_focus(cx: &mut TestAppContext) {
2338        init_test(cx);
2339
2340        let fs = FakeFs::new(cx.background_executor.clone());
2341        fs.insert_tree(
2342            "/dir",
2343            json!({
2344                "one.rs": "const ONE: usize = 1;",
2345                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2346                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2347                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2348            }),
2349        )
2350        .await;
2351        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2352        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2353        let workspace = window;
2354        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2355
2356        let active_item = cx.read(|cx| {
2357            workspace
2358                .read(cx)
2359                .unwrap()
2360                .active_pane()
2361                .read(cx)
2362                .active_item()
2363                .and_then(|item| item.downcast::<ProjectSearchView>())
2364        });
2365        assert!(
2366            active_item.is_none(),
2367            "Expected no search panel to be active"
2368        );
2369
2370        window
2371            .update(cx, move |workspace, cx| {
2372                assert_eq!(workspace.panes().len(), 1);
2373                workspace.panes()[0].update(cx, move |pane, cx| {
2374                    pane.toolbar()
2375                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2376                });
2377
2378                ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2379            })
2380            .unwrap();
2381
2382        let Some(search_view) = cx.read(|cx| {
2383            workspace
2384                .read(cx)
2385                .unwrap()
2386                .active_pane()
2387                .read(cx)
2388                .active_item()
2389                .and_then(|item| item.downcast::<ProjectSearchView>())
2390        }) else {
2391            panic!("Search view expected to appear after new search event trigger")
2392        };
2393
2394        cx.spawn(|mut cx| async move {
2395            window
2396                .update(&mut cx, |_, cx| {
2397                    cx.dispatch_action(ToggleFocus.boxed_clone())
2398                })
2399                .unwrap();
2400        })
2401        .detach();
2402        cx.background_executor.run_until_parked();
2403
2404        window.update(cx, |_, cx| {
2405            search_view.update(cx, |search_view, cx| {
2406                    assert!(
2407                        search_view.query_editor.focus_handle(cx).is_focused(cx),
2408                        "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2409                    );
2410                });
2411        }).unwrap();
2412
2413        window
2414            .update(cx, |_, cx| {
2415                search_view.update(cx, |search_view, cx| {
2416                    let query_editor = &search_view.query_editor;
2417                    assert!(
2418                        query_editor.focus_handle(cx).is_focused(cx),
2419                        "Search view should be focused after the new search view is activated",
2420                    );
2421                    let query_text = query_editor.read(cx).text(cx);
2422                    assert!(
2423                        query_text.is_empty(),
2424                        "New search query should be empty but got '{query_text}'",
2425                    );
2426                    let results_text = search_view
2427                        .results_editor
2428                        .update(cx, |editor, cx| editor.display_text(cx));
2429                    assert!(
2430                        results_text.is_empty(),
2431                        "Empty search view should have no results but got '{results_text}'"
2432                    );
2433                });
2434            })
2435            .unwrap();
2436
2437        window
2438            .update(cx, |_, cx| {
2439                search_view.update(cx, |search_view, cx| {
2440                    search_view.query_editor.update(cx, |query_editor, cx| {
2441                        query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
2442                    });
2443                    search_view.search(cx);
2444                });
2445            })
2446            .unwrap();
2447
2448        cx.background_executor.run_until_parked();
2449        window
2450            .update(cx, |_, cx| {
2451                search_view.update(cx, |search_view, cx| {
2452                    let results_text = search_view
2453                        .results_editor
2454                        .update(cx, |editor, cx| editor.display_text(cx));
2455                    assert!(
2456                results_text.is_empty(),
2457                "Search view for mismatching query should have no results but got '{results_text}'"
2458            );
2459                    assert!(
2460                search_view.query_editor.focus_handle(cx).is_focused(cx),
2461                "Search view should be focused after mismatching query had been used in search",
2462            );
2463                });
2464            })
2465            .unwrap();
2466        cx.spawn(|mut cx| async move {
2467            window.update(&mut cx, |_, cx| {
2468                cx.dispatch_action(ToggleFocus.boxed_clone())
2469            })
2470        })
2471        .detach();
2472        cx.background_executor.run_until_parked();
2473        window.update(cx, |_, cx| {
2474            search_view.update(cx, |search_view, cx| {
2475                    assert!(
2476                        search_view.query_editor.focus_handle(cx).is_focused(cx),
2477                        "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2478                    );
2479                });
2480        }).unwrap();
2481
2482        window
2483            .update(cx, |_, cx| {
2484                search_view.update(cx, |search_view, cx| {
2485                    search_view
2486                        .query_editor
2487                        .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2488                    search_view.search(cx);
2489                })
2490            })
2491            .unwrap();
2492        cx.background_executor.run_until_parked();
2493        window.update(cx, |_, cx|
2494        search_view.update(cx, |search_view, cx| {
2495                assert_eq!(
2496                    search_view
2497                        .results_editor
2498                        .update(cx, |editor, cx| editor.display_text(cx)),
2499                    "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2500                    "Search view results should match the query"
2501                );
2502                assert!(
2503                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2504                    "Search view with mismatching query should be focused after search results are available",
2505                );
2506            })).unwrap();
2507        cx.spawn(|mut cx| async move {
2508            window
2509                .update(&mut cx, |_, cx| {
2510                    cx.dispatch_action(ToggleFocus.boxed_clone())
2511                })
2512                .unwrap();
2513        })
2514        .detach();
2515        cx.background_executor.run_until_parked();
2516        window.update(cx, |_, cx| {
2517            search_view.update(cx, |search_view, cx| {
2518                    assert!(
2519                        search_view.results_editor.focus_handle(cx).is_focused(cx),
2520                        "Search view with matching query should still have its results editor focused after the toggle focus event",
2521                    );
2522                });
2523        }).unwrap();
2524
2525        workspace
2526            .update(cx, |workspace, cx| {
2527                ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2528            })
2529            .unwrap();
2530        cx.background_executor.run_until_parked();
2531        let Some(search_view_2) = cx.read(|cx| {
2532            workspace
2533                .read(cx)
2534                .unwrap()
2535                .active_pane()
2536                .read(cx)
2537                .active_item()
2538                .and_then(|item| item.downcast::<ProjectSearchView>())
2539        }) else {
2540            panic!("Search view expected to appear after new search event trigger")
2541        };
2542        assert!(
2543            search_view_2 != search_view,
2544            "New search view should be open after `workspace::NewSearch` event"
2545        );
2546
2547        window.update(cx, |_, cx| {
2548            search_view.update(cx, |search_view, cx| {
2549                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
2550                    assert_eq!(
2551                        search_view
2552                            .results_editor
2553                            .update(cx, |editor, cx| editor.display_text(cx)),
2554                        "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2555                        "Results of the first search view should not update too"
2556                    );
2557                    assert!(
2558                        !search_view.query_editor.focus_handle(cx).is_focused(cx),
2559                        "Focus should be moved away from the first search view"
2560                    );
2561                });
2562        }).unwrap();
2563
2564        window.update(cx, |_, cx| {
2565            search_view_2.update(cx, |search_view_2, cx| {
2566                    assert_eq!(
2567                        search_view_2.query_editor.read(cx).text(cx),
2568                        "two",
2569                        "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
2570                    );
2571                    assert_eq!(
2572                        search_view_2
2573                            .results_editor
2574                            .update(cx, |editor, cx| editor.display_text(cx)),
2575                        "",
2576                        "No search results should be in the 2nd view yet, as we did not spawn a search for it"
2577                    );
2578                    assert!(
2579                        search_view_2.query_editor.focus_handle(cx).is_focused(cx),
2580                        "Focus should be moved into query editor of the new window"
2581                    );
2582                });
2583        }).unwrap();
2584
2585        window
2586            .update(cx, |_, cx| {
2587                search_view_2.update(cx, |search_view_2, cx| {
2588                    search_view_2
2589                        .query_editor
2590                        .update(cx, |query_editor, cx| query_editor.set_text("FOUR", cx));
2591                    search_view_2.search(cx);
2592                });
2593            })
2594            .unwrap();
2595
2596        cx.background_executor.run_until_parked();
2597        window.update(cx, |_, cx| {
2598            search_view_2.update(cx, |search_view_2, cx| {
2599                    assert_eq!(
2600                        search_view_2
2601                            .results_editor
2602                            .update(cx, |editor, cx| editor.display_text(cx)),
2603                        "\n\n\nconst FOUR: usize = one::ONE + three::THREE;\n",
2604                        "New search view with the updated query should have new search results"
2605                    );
2606                    assert!(
2607                        search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2608                        "Search view with mismatching query should be focused after search results are available",
2609                    );
2610                });
2611        }).unwrap();
2612
2613        cx.spawn(|mut cx| async move {
2614            window
2615                .update(&mut cx, |_, cx| {
2616                    cx.dispatch_action(ToggleFocus.boxed_clone())
2617                })
2618                .unwrap();
2619        })
2620        .detach();
2621        cx.background_executor.run_until_parked();
2622        window.update(cx, |_, cx| {
2623            search_view_2.update(cx, |search_view_2, cx| {
2624                    assert!(
2625                        search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2626                        "Search view with matching query should switch focus to the results editor after the toggle focus event",
2627                    );
2628                });}).unwrap();
2629    }
2630
2631    #[gpui::test]
2632    async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
2633        init_test(cx);
2634
2635        let fs = FakeFs::new(cx.background_executor.clone());
2636        fs.insert_tree(
2637            "/dir",
2638            json!({
2639                "a": {
2640                    "one.rs": "const ONE: usize = 1;",
2641                    "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2642                },
2643                "b": {
2644                    "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2645                    "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2646                },
2647            }),
2648        )
2649        .await;
2650        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2651        let worktree_id = project.read_with(cx, |project, cx| {
2652            project.worktrees(cx).next().unwrap().read(cx).id()
2653        });
2654        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2655        let workspace = window.root(cx).unwrap();
2656        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2657
2658        let active_item = cx.read(|cx| {
2659            workspace
2660                .read(cx)
2661                .active_pane()
2662                .read(cx)
2663                .active_item()
2664                .and_then(|item| item.downcast::<ProjectSearchView>())
2665        });
2666        assert!(
2667            active_item.is_none(),
2668            "Expected no search panel to be active"
2669        );
2670
2671        window
2672            .update(cx, move |workspace, cx| {
2673                assert_eq!(workspace.panes().len(), 1);
2674                workspace.panes()[0].update(cx, move |pane, cx| {
2675                    pane.toolbar()
2676                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2677                });
2678            })
2679            .unwrap();
2680
2681        let a_dir_entry = cx.update(|cx| {
2682            workspace
2683                .read(cx)
2684                .project()
2685                .read(cx)
2686                .entry_for_path(&(worktree_id, "a").into(), cx)
2687                .expect("no entry for /a/ directory")
2688        });
2689        assert!(a_dir_entry.is_dir());
2690        window
2691            .update(cx, |workspace, cx| {
2692                ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, cx)
2693            })
2694            .unwrap();
2695
2696        let Some(search_view) = cx.read(|cx| {
2697            workspace
2698                .read(cx)
2699                .active_pane()
2700                .read(cx)
2701                .active_item()
2702                .and_then(|item| item.downcast::<ProjectSearchView>())
2703        }) else {
2704            panic!("Search view expected to appear after new search in directory event trigger")
2705        };
2706        cx.background_executor.run_until_parked();
2707        window
2708            .update(cx, |_, cx| {
2709                search_view.update(cx, |search_view, cx| {
2710                    assert!(
2711                        search_view.query_editor.focus_handle(cx).is_focused(cx),
2712                        "On new search in directory, focus should be moved into query editor"
2713                    );
2714                    search_view.excluded_files_editor.update(cx, |editor, cx| {
2715                        assert!(
2716                            editor.display_text(cx).is_empty(),
2717                            "New search in directory should not have any excluded files"
2718                        );
2719                    });
2720                    search_view.included_files_editor.update(cx, |editor, cx| {
2721                        assert_eq!(
2722                            editor.display_text(cx),
2723                            a_dir_entry.path.to_str().unwrap(),
2724                            "New search in directory should have included dir entry path"
2725                        );
2726                    });
2727                });
2728            })
2729            .unwrap();
2730        window
2731            .update(cx, |_, cx| {
2732                search_view.update(cx, |search_view, cx| {
2733                    search_view
2734                        .query_editor
2735                        .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
2736                    search_view.search(cx);
2737                });
2738            })
2739            .unwrap();
2740        cx.background_executor.run_until_parked();
2741        window
2742            .update(cx, |_, cx| {
2743                search_view.update(cx, |search_view, cx| {
2744                    assert_eq!(
2745                search_view
2746                    .results_editor
2747                    .update(cx, |editor, cx| editor.display_text(cx)),
2748                "\n\n\nconst TWO: usize = one::ONE + one::ONE;\n\n\n\n\nconst ONE: usize = 1;\n",
2749                "New search in directory should have a filter that matches a certain directory"
2750            );
2751                })
2752            })
2753            .unwrap();
2754    }
2755
2756    #[gpui::test]
2757    async fn test_search_query_history(cx: &mut TestAppContext) {
2758        init_test(cx);
2759
2760        let fs = FakeFs::new(cx.background_executor.clone());
2761        fs.insert_tree(
2762            "/dir",
2763            json!({
2764                "one.rs": "const ONE: usize = 1;",
2765                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2766                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2767                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2768            }),
2769        )
2770        .await;
2771        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2772        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2773        let workspace = window.root(cx).unwrap();
2774        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2775
2776        window
2777            .update(cx, {
2778                let search_bar = search_bar.clone();
2779                move |workspace, cx| {
2780                    assert_eq!(workspace.panes().len(), 1);
2781                    workspace.panes()[0].update(cx, move |pane, cx| {
2782                        pane.toolbar()
2783                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2784                    });
2785
2786                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2787                }
2788            })
2789            .unwrap();
2790
2791        let search_view = cx.read(|cx| {
2792            workspace
2793                .read(cx)
2794                .active_pane()
2795                .read(cx)
2796                .active_item()
2797                .and_then(|item| item.downcast::<ProjectSearchView>())
2798                .expect("Search view expected to appear after new search event trigger")
2799        });
2800
2801        // Add 3 search items into the history + another unsubmitted one.
2802        window
2803            .update(cx, |_, cx| {
2804                search_view.update(cx, |search_view, cx| {
2805                    search_view.search_options = SearchOptions::CASE_SENSITIVE;
2806                    search_view
2807                        .query_editor
2808                        .update(cx, |query_editor, cx| query_editor.set_text("ONE", cx));
2809                    search_view.search(cx);
2810                });
2811            })
2812            .unwrap();
2813
2814        cx.background_executor.run_until_parked();
2815        window
2816            .update(cx, |_, cx| {
2817                search_view.update(cx, |search_view, cx| {
2818                    search_view
2819                        .query_editor
2820                        .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2821                    search_view.search(cx);
2822                });
2823            })
2824            .unwrap();
2825        cx.background_executor.run_until_parked();
2826        window
2827            .update(cx, |_, cx| {
2828                search_view.update(cx, |search_view, cx| {
2829                    search_view
2830                        .query_editor
2831                        .update(cx, |query_editor, cx| query_editor.set_text("THREE", cx));
2832                    search_view.search(cx);
2833                })
2834            })
2835            .unwrap();
2836        cx.background_executor.run_until_parked();
2837        window
2838            .update(cx, |_, cx| {
2839                search_view.update(cx, |search_view, cx| {
2840                    search_view.query_editor.update(cx, |query_editor, cx| {
2841                        query_editor.set_text("JUST_TEXT_INPUT", cx)
2842                    });
2843                })
2844            })
2845            .unwrap();
2846        cx.background_executor.run_until_parked();
2847
2848        // Ensure that the latest input with search settings is active.
2849        window
2850            .update(cx, |_, cx| {
2851                search_view.update(cx, |search_view, cx| {
2852                    assert_eq!(
2853                        search_view.query_editor.read(cx).text(cx),
2854                        "JUST_TEXT_INPUT"
2855                    );
2856                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2857                });
2858            })
2859            .unwrap();
2860
2861        // Next history query after the latest should set the query to the empty string.
2862        window
2863            .update(cx, |_, cx| {
2864                search_bar.update(cx, |search_bar, cx| {
2865                    search_bar.focus_search(cx);
2866                    search_bar.next_history_query(&NextHistoryQuery, cx);
2867                })
2868            })
2869            .unwrap();
2870        window
2871            .update(cx, |_, cx| {
2872                search_view.update(cx, |search_view, cx| {
2873                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2874                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2875                });
2876            })
2877            .unwrap();
2878        window
2879            .update(cx, |_, cx| {
2880                search_bar.update(cx, |search_bar, cx| {
2881                    search_bar.focus_search(cx);
2882                    search_bar.next_history_query(&NextHistoryQuery, cx);
2883                })
2884            })
2885            .unwrap();
2886        window
2887            .update(cx, |_, cx| {
2888                search_view.update(cx, |search_view, cx| {
2889                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2890                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2891                });
2892            })
2893            .unwrap();
2894
2895        // First previous query for empty current query should set the query to the latest submitted one.
2896        window
2897            .update(cx, |_, cx| {
2898                search_bar.update(cx, |search_bar, cx| {
2899                    search_bar.focus_search(cx);
2900                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2901                });
2902            })
2903            .unwrap();
2904        window
2905            .update(cx, |_, cx| {
2906                search_view.update(cx, |search_view, cx| {
2907                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2908                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2909                });
2910            })
2911            .unwrap();
2912
2913        // Further previous items should go over the history in reverse order.
2914        window
2915            .update(cx, |_, cx| {
2916                search_bar.update(cx, |search_bar, cx| {
2917                    search_bar.focus_search(cx);
2918                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2919                });
2920            })
2921            .unwrap();
2922        window
2923            .update(cx, |_, cx| {
2924                search_view.update(cx, |search_view, cx| {
2925                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2926                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2927                });
2928            })
2929            .unwrap();
2930
2931        // Previous items should never go behind the first history item.
2932        window
2933            .update(cx, |_, cx| {
2934                search_bar.update(cx, |search_bar, cx| {
2935                    search_bar.focus_search(cx);
2936                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2937                });
2938            })
2939            .unwrap();
2940        window
2941            .update(cx, |_, cx| {
2942                search_view.update(cx, |search_view, cx| {
2943                    assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2944                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2945                });
2946            })
2947            .unwrap();
2948        window
2949            .update(cx, |_, cx| {
2950                search_bar.update(cx, |search_bar, cx| {
2951                    search_bar.focus_search(cx);
2952                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2953                });
2954            })
2955            .unwrap();
2956        window
2957            .update(cx, |_, cx| {
2958                search_view.update(cx, |search_view, cx| {
2959                    assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2960                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2961                });
2962            })
2963            .unwrap();
2964
2965        // Next items should go over the history in the original order.
2966        window
2967            .update(cx, |_, cx| {
2968                search_bar.update(cx, |search_bar, cx| {
2969                    search_bar.focus_search(cx);
2970                    search_bar.next_history_query(&NextHistoryQuery, cx);
2971                });
2972            })
2973            .unwrap();
2974        window
2975            .update(cx, |_, cx| {
2976                search_view.update(cx, |search_view, cx| {
2977                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2978                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2979                });
2980            })
2981            .unwrap();
2982
2983        window
2984            .update(cx, |_, cx| {
2985                search_view.update(cx, |search_view, cx| {
2986                    search_view
2987                        .query_editor
2988                        .update(cx, |query_editor, cx| query_editor.set_text("TWO_NEW", cx));
2989                    search_view.search(cx);
2990                });
2991            })
2992            .unwrap();
2993        cx.background_executor.run_until_parked();
2994        window
2995            .update(cx, |_, cx| {
2996                search_view.update(cx, |search_view, cx| {
2997                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2998                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2999                });
3000            })
3001            .unwrap();
3002
3003        // New search input should add another entry to history and move the selection to the end of the history.
3004        window
3005            .update(cx, |_, cx| {
3006                search_bar.update(cx, |search_bar, cx| {
3007                    search_bar.focus_search(cx);
3008                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3009                });
3010            })
3011            .unwrap();
3012        window
3013            .update(cx, |_, cx| {
3014                search_view.update(cx, |search_view, cx| {
3015                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3016                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3017                });
3018            })
3019            .unwrap();
3020        window
3021            .update(cx, |_, cx| {
3022                search_bar.update(cx, |search_bar, cx| {
3023                    search_bar.focus_search(cx);
3024                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3025                });
3026            })
3027            .unwrap();
3028        window
3029            .update(cx, |_, cx| {
3030                search_view.update(cx, |search_view, cx| {
3031                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
3032                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3033                });
3034            })
3035            .unwrap();
3036        window
3037            .update(cx, |_, cx| {
3038                search_bar.update(cx, |search_bar, cx| {
3039                    search_bar.focus_search(cx);
3040                    search_bar.next_history_query(&NextHistoryQuery, cx);
3041                });
3042            })
3043            .unwrap();
3044        window
3045            .update(cx, |_, cx| {
3046                search_view.update(cx, |search_view, cx| {
3047                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
3048                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3049                });
3050            })
3051            .unwrap();
3052        window
3053            .update(cx, |_, cx| {
3054                search_bar.update(cx, |search_bar, cx| {
3055                    search_bar.focus_search(cx);
3056                    search_bar.next_history_query(&NextHistoryQuery, cx);
3057                });
3058            })
3059            .unwrap();
3060        window
3061            .update(cx, |_, cx| {
3062                search_view.update(cx, |search_view, cx| {
3063                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
3064                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3065                });
3066            })
3067            .unwrap();
3068        window
3069            .update(cx, |_, cx| {
3070                search_bar.update(cx, |search_bar, cx| {
3071                    search_bar.focus_search(cx);
3072                    search_bar.next_history_query(&NextHistoryQuery, cx);
3073                });
3074            })
3075            .unwrap();
3076        window
3077            .update(cx, |_, cx| {
3078                search_view.update(cx, |search_view, cx| {
3079                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
3080                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
3081                });
3082            })
3083            .unwrap();
3084    }
3085
3086    #[gpui::test]
3087    async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) {
3088        init_test(cx);
3089
3090        let fs = FakeFs::new(cx.background_executor.clone());
3091        fs.insert_tree(
3092            "/dir",
3093            json!({
3094                "one.rs": "const ONE: usize = 1;",
3095            }),
3096        )
3097        .await;
3098        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3099        let worktree_id = project.update(cx, |this, cx| {
3100            this.worktrees(cx).next().unwrap().read(cx).id()
3101        });
3102
3103        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
3104        let workspace = window.root(cx).unwrap();
3105
3106        let panes: Vec<_> = window
3107            .update(cx, |this, _| this.panes().to_owned())
3108            .unwrap();
3109
3110        let search_bar_1 = window.build_view(cx, |_| ProjectSearchBar::new());
3111        let search_bar_2 = window.build_view(cx, |_| ProjectSearchBar::new());
3112
3113        assert_eq!(panes.len(), 1);
3114        let first_pane = panes.first().cloned().unwrap();
3115        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3116        window
3117            .update(cx, |workspace, cx| {
3118                workspace.open_path(
3119                    (worktree_id, "one.rs"),
3120                    Some(first_pane.downgrade()),
3121                    true,
3122                    cx,
3123                )
3124            })
3125            .unwrap()
3126            .await
3127            .unwrap();
3128        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3129
3130        // Add a project search item to the first pane
3131        window
3132            .update(cx, {
3133                let search_bar = search_bar_1.clone();
3134                let pane = first_pane.clone();
3135                move |workspace, cx| {
3136                    pane.update(cx, move |pane, cx| {
3137                        pane.toolbar()
3138                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3139                    });
3140
3141                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
3142                }
3143            })
3144            .unwrap();
3145        let search_view_1 = cx.read(|cx| {
3146            workspace
3147                .read(cx)
3148                .active_item(cx)
3149                .and_then(|item| item.downcast::<ProjectSearchView>())
3150                .expect("Search view expected to appear after new search event trigger")
3151        });
3152
3153        let second_pane = window
3154            .update(cx, |workspace, cx| {
3155                workspace.split_and_clone(first_pane.clone(), workspace::SplitDirection::Right, cx)
3156            })
3157            .unwrap()
3158            .unwrap();
3159        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3160
3161        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3162        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3163
3164        // Add a project search item to the second pane
3165        window
3166            .update(cx, {
3167                let search_bar = search_bar_2.clone();
3168                let pane = second_pane.clone();
3169                move |workspace, cx| {
3170                    assert_eq!(workspace.panes().len(), 2);
3171                    pane.update(cx, move |pane, cx| {
3172                        pane.toolbar()
3173                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3174                    });
3175
3176                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
3177                }
3178            })
3179            .unwrap();
3180
3181        let search_view_2 = cx.read(|cx| {
3182            workspace
3183                .read(cx)
3184                .active_item(cx)
3185                .and_then(|item| item.downcast::<ProjectSearchView>())
3186                .expect("Search view expected to appear after new search event trigger")
3187        });
3188
3189        cx.run_until_parked();
3190        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3191        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3192
3193        let update_search_view =
3194            |search_view: &View<ProjectSearchView>, query: &str, cx: &mut TestAppContext| {
3195                window
3196                    .update(cx, |_, cx| {
3197                        search_view.update(cx, |search_view, cx| {
3198                            search_view
3199                                .query_editor
3200                                .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
3201                            search_view.search(cx);
3202                        });
3203                    })
3204                    .unwrap();
3205            };
3206
3207        let active_query =
3208            |search_view: &View<ProjectSearchView>, cx: &mut TestAppContext| -> String {
3209                window
3210                    .update(cx, |_, cx| {
3211                        search_view.update(cx, |search_view, cx| {
3212                            search_view.query_editor.read(cx).text(cx).to_string()
3213                        })
3214                    })
3215                    .unwrap()
3216            };
3217
3218        let select_prev_history_item =
3219            |search_bar: &View<ProjectSearchBar>, cx: &mut TestAppContext| {
3220                window
3221                    .update(cx, |_, cx| {
3222                        search_bar.update(cx, |search_bar, cx| {
3223                            search_bar.focus_search(cx);
3224                            search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3225                        })
3226                    })
3227                    .unwrap();
3228            };
3229
3230        let select_next_history_item =
3231            |search_bar: &View<ProjectSearchBar>, cx: &mut TestAppContext| {
3232                window
3233                    .update(cx, |_, cx| {
3234                        search_bar.update(cx, |search_bar, cx| {
3235                            search_bar.focus_search(cx);
3236                            search_bar.next_history_query(&NextHistoryQuery, cx);
3237                        })
3238                    })
3239                    .unwrap();
3240            };
3241
3242        update_search_view(&search_view_1, "ONE", cx);
3243        cx.background_executor.run_until_parked();
3244
3245        update_search_view(&search_view_2, "TWO", cx);
3246        cx.background_executor.run_until_parked();
3247
3248        assert_eq!(active_query(&search_view_1, cx), "ONE");
3249        assert_eq!(active_query(&search_view_2, cx), "TWO");
3250
3251        // Selecting previous history item should select the query from search view 1.
3252        select_prev_history_item(&search_bar_2, cx);
3253        assert_eq!(active_query(&search_view_2, cx), "ONE");
3254
3255        // Selecting the previous history item should not change the query as it is already the first item.
3256        select_prev_history_item(&search_bar_2, cx);
3257        assert_eq!(active_query(&search_view_2, cx), "ONE");
3258
3259        // Changing the query in search view 2 should not affect the history of search view 1.
3260        assert_eq!(active_query(&search_view_1, cx), "ONE");
3261
3262        // Deploying a new search in search view 2
3263        update_search_view(&search_view_2, "THREE", cx);
3264        cx.background_executor.run_until_parked();
3265
3266        select_next_history_item(&search_bar_2, cx);
3267        assert_eq!(active_query(&search_view_2, cx), "");
3268
3269        select_prev_history_item(&search_bar_2, cx);
3270        assert_eq!(active_query(&search_view_2, cx), "THREE");
3271
3272        select_prev_history_item(&search_bar_2, cx);
3273        assert_eq!(active_query(&search_view_2, cx), "TWO");
3274
3275        select_prev_history_item(&search_bar_2, cx);
3276        assert_eq!(active_query(&search_view_2, cx), "ONE");
3277
3278        select_prev_history_item(&search_bar_2, cx);
3279        assert_eq!(active_query(&search_view_2, cx), "ONE");
3280
3281        // Search view 1 should now see the query from search view 2.
3282        assert_eq!(active_query(&search_view_1, cx), "ONE");
3283
3284        select_next_history_item(&search_bar_2, cx);
3285        assert_eq!(active_query(&search_view_2, cx), "TWO");
3286
3287        // Here is the new query from search view 2
3288        select_next_history_item(&search_bar_2, cx);
3289        assert_eq!(active_query(&search_view_2, cx), "THREE");
3290
3291        select_next_history_item(&search_bar_2, cx);
3292        assert_eq!(active_query(&search_view_2, cx), "");
3293
3294        select_next_history_item(&search_bar_1, cx);
3295        assert_eq!(active_query(&search_view_1, cx), "TWO");
3296
3297        select_next_history_item(&search_bar_1, cx);
3298        assert_eq!(active_query(&search_view_1, cx), "THREE");
3299
3300        select_next_history_item(&search_bar_1, cx);
3301        assert_eq!(active_query(&search_view_1, cx), "");
3302    }
3303
3304    #[gpui::test]
3305    async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
3306        init_test(cx);
3307
3308        // Setup 2 panes, both with a file open and one with a project search.
3309        let fs = FakeFs::new(cx.background_executor.clone());
3310        fs.insert_tree(
3311            "/dir",
3312            json!({
3313                "one.rs": "const ONE: usize = 1;",
3314            }),
3315        )
3316        .await;
3317        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3318        let worktree_id = project.update(cx, |this, cx| {
3319            this.worktrees(cx).next().unwrap().read(cx).id()
3320        });
3321        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
3322        let panes: Vec<_> = window
3323            .update(cx, |this, _| this.panes().to_owned())
3324            .unwrap();
3325        assert_eq!(panes.len(), 1);
3326        let first_pane = panes.first().cloned().unwrap();
3327        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3328        window
3329            .update(cx, |workspace, cx| {
3330                workspace.open_path(
3331                    (worktree_id, "one.rs"),
3332                    Some(first_pane.downgrade()),
3333                    true,
3334                    cx,
3335                )
3336            })
3337            .unwrap()
3338            .await
3339            .unwrap();
3340        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3341        let second_pane = window
3342            .update(cx, |workspace, cx| {
3343                workspace.split_and_clone(first_pane.clone(), workspace::SplitDirection::Right, cx)
3344            })
3345            .unwrap()
3346            .unwrap();
3347        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3348        assert!(window
3349            .update(cx, |_, cx| second_pane
3350                .focus_handle(cx)
3351                .contains_focused(cx))
3352            .unwrap());
3353        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
3354        window
3355            .update(cx, {
3356                let search_bar = search_bar.clone();
3357                let pane = first_pane.clone();
3358                move |workspace, cx| {
3359                    assert_eq!(workspace.panes().len(), 2);
3360                    pane.update(cx, move |pane, cx| {
3361                        pane.toolbar()
3362                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3363                    });
3364                }
3365            })
3366            .unwrap();
3367
3368        // Add a project search item to the second pane
3369        window
3370            .update(cx, {
3371                let search_bar = search_bar.clone();
3372                let pane = second_pane.clone();
3373                move |workspace, cx| {
3374                    assert_eq!(workspace.panes().len(), 2);
3375                    pane.update(cx, move |pane, cx| {
3376                        pane.toolbar()
3377                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3378                    });
3379
3380                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
3381                }
3382            })
3383            .unwrap();
3384
3385        cx.run_until_parked();
3386        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3387        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3388
3389        // Focus the first pane
3390        window
3391            .update(cx, |workspace, cx| {
3392                assert_eq!(workspace.active_pane(), &second_pane);
3393                second_pane.update(cx, |this, cx| {
3394                    assert_eq!(this.active_item_index(), 1);
3395                    this.activate_prev_item(false, cx);
3396                    assert_eq!(this.active_item_index(), 0);
3397                });
3398                workspace.activate_pane_in_direction(workspace::SplitDirection::Left, cx);
3399            })
3400            .unwrap();
3401        window
3402            .update(cx, |workspace, cx| {
3403                assert_eq!(workspace.active_pane(), &first_pane);
3404                assert_eq!(first_pane.read(cx).items_len(), 1);
3405                assert_eq!(second_pane.read(cx).items_len(), 2);
3406            })
3407            .unwrap();
3408
3409        // Deploy a new search
3410        cx.dispatch_action(window.into(), DeploySearch::find());
3411
3412        // Both panes should now have a project search in them
3413        window
3414            .update(cx, |workspace, cx| {
3415                assert_eq!(workspace.active_pane(), &first_pane);
3416                first_pane.update(cx, |this, _| {
3417                    assert_eq!(this.active_item_index(), 1);
3418                    assert_eq!(this.items_len(), 2);
3419                });
3420                second_pane.update(cx, |this, cx| {
3421                    assert!(!cx.focus_handle().contains_focused(cx));
3422                    assert_eq!(this.items_len(), 2);
3423                });
3424            })
3425            .unwrap();
3426
3427        // Focus the second pane's non-search item
3428        window
3429            .update(cx, |_workspace, cx| {
3430                second_pane.update(cx, |pane, cx| pane.activate_next_item(true, cx));
3431            })
3432            .unwrap();
3433
3434        // Deploy a new search
3435        cx.dispatch_action(window.into(), DeploySearch::find());
3436
3437        // The project search view should now be focused in the second pane
3438        // And the number of items should be unchanged.
3439        window
3440            .update(cx, |_workspace, cx| {
3441                second_pane.update(cx, |pane, _cx| {
3442                    assert!(pane
3443                        .active_item()
3444                        .unwrap()
3445                        .downcast::<ProjectSearchView>()
3446                        .is_some());
3447
3448                    assert_eq!(pane.items_len(), 2);
3449                });
3450            })
3451            .unwrap();
3452    }
3453
3454    #[gpui::test]
3455    async fn test_scroll_search_results_to_top(cx: &mut TestAppContext) {
3456        init_test(cx);
3457
3458        // We need many lines in the search results to be able to scroll the window
3459        let fs = FakeFs::new(cx.background_executor.clone());
3460        fs.insert_tree(
3461            "/dir",
3462            json!({
3463                "1.txt": "\n\n\n\n\n A \n\n\n\n\n",
3464                "2.txt": "\n\n\n\n\n A \n\n\n\n\n",
3465                "3.rs": "\n\n\n\n\n A \n\n\n\n\n",
3466                "4.rs": "\n\n\n\n\n A \n\n\n\n\n",
3467                "5.rs": "\n\n\n\n\n A \n\n\n\n\n",
3468                "6.rs": "\n\n\n\n\n A \n\n\n\n\n",
3469                "7.rs": "\n\n\n\n\n A \n\n\n\n\n",
3470                "8.rs": "\n\n\n\n\n A \n\n\n\n\n",
3471                "9.rs": "\n\n\n\n\n A \n\n\n\n\n",
3472                "a.rs": "\n\n\n\n\n A \n\n\n\n\n",
3473                "b.rs": "\n\n\n\n\n B \n\n\n\n\n",
3474                "c.rs": "\n\n\n\n\n B \n\n\n\n\n",
3475                "d.rs": "\n\n\n\n\n B \n\n\n\n\n",
3476                "e.rs": "\n\n\n\n\n B \n\n\n\n\n",
3477                "f.rs": "\n\n\n\n\n B \n\n\n\n\n",
3478                "g.rs": "\n\n\n\n\n B \n\n\n\n\n",
3479                "h.rs": "\n\n\n\n\n B \n\n\n\n\n",
3480                "i.rs": "\n\n\n\n\n B \n\n\n\n\n",
3481                "j.rs": "\n\n\n\n\n B \n\n\n\n\n",
3482                "k.rs": "\n\n\n\n\n B \n\n\n\n\n",
3483            }),
3484        )
3485        .await;
3486        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3487        let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3488        let workspace = window.root(cx).unwrap();
3489        let search = cx.new_model(|cx| ProjectSearch::new(project, cx));
3490        let search_view = cx.add_window(|cx| {
3491            ProjectSearchView::new(workspace.downgrade(), search.clone(), cx, None)
3492        });
3493
3494        // First search
3495        perform_search(search_view, "A", cx);
3496        search_view
3497            .update(cx, |search_view, cx| {
3498                search_view.results_editor.update(cx, |results_editor, cx| {
3499                    // Results are correct and scrolled to the top
3500                    assert_eq!(
3501                        results_editor.display_text(cx).match_indices(" A ").count(),
3502                        10
3503                    );
3504                    assert_eq!(results_editor.scroll_position(cx), Point::default());
3505
3506                    // Scroll results all the way down
3507                    results_editor.scroll(Point::new(0., f32::MAX), Some(Axis::Vertical), cx);
3508                });
3509            })
3510            .expect("unable to update search view");
3511
3512        // Second search
3513        perform_search(search_view, "B", cx);
3514        search_view
3515            .update(cx, |search_view, cx| {
3516                search_view.results_editor.update(cx, |results_editor, cx| {
3517                    // Results are correct...
3518                    assert_eq!(
3519                        results_editor.display_text(cx).match_indices(" B ").count(),
3520                        10
3521                    );
3522                    // ...and scrolled back to the top
3523                    assert_eq!(results_editor.scroll_position(cx), Point::default());
3524                });
3525            })
3526            .expect("unable to update search view");
3527    }
3528
3529    fn init_test(cx: &mut TestAppContext) {
3530        cx.update(|cx| {
3531            let settings = SettingsStore::test(cx);
3532            cx.set_global(settings);
3533
3534            theme::init(theme::LoadThemes::JustBase, cx);
3535
3536            language::init(cx);
3537            client::init_settings(cx);
3538            editor::init(cx);
3539            workspace::init_settings(cx);
3540            Project::init_settings(cx);
3541            crate::init(cx);
3542        });
3543    }
3544
3545    fn perform_search(
3546        search_view: WindowHandle<ProjectSearchView>,
3547        text: impl Into<Arc<str>>,
3548        cx: &mut TestAppContext,
3549    ) {
3550        search_view
3551            .update(cx, |search_view, cx| {
3552                search_view
3553                    .query_editor
3554                    .update(cx, |query_editor, cx| query_editor.set_text(text, cx));
3555                search_view.search(cx);
3556            })
3557            .unwrap();
3558        cx.background_executor.run_until_parked();
3559    }
3560}