project_search.rs

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