project_search.rs

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