project_search.rs

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