project_search.rs

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