project_search.rs

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