project_search.rs

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