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