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
 117struct 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)]
 157struct 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    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    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    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
1184impl ProjectSearchBar {
1185    pub fn new() -> Self {
1186        Self {
1187            active_project_search: None,
1188            subscription: None,
1189        }
1190    }
1191
1192    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
1193        if let Some(search_view) = self.active_project_search.as_ref() {
1194            search_view.update(cx, |search_view, cx| {
1195                if !search_view
1196                    .replacement_editor
1197                    .focus_handle(cx)
1198                    .is_focused(cx)
1199                {
1200                    cx.stop_propagation();
1201                    search_view.search(cx);
1202                }
1203            });
1204        }
1205    }
1206
1207    fn tab(&mut self, _: &editor::actions::Tab, cx: &mut ViewContext<Self>) {
1208        self.cycle_field(Direction::Next, cx);
1209    }
1210
1211    fn tab_previous(&mut self, _: &editor::actions::TabPrev, cx: &mut ViewContext<Self>) {
1212        self.cycle_field(Direction::Prev, cx);
1213    }
1214
1215    fn focus_search(&mut self, cx: &mut ViewContext<Self>) {
1216        if let Some(search_view) = self.active_project_search.as_ref() {
1217            search_view.update(cx, |search_view, cx| {
1218                search_view.query_editor.focus_handle(cx).focus(cx);
1219            });
1220        }
1221    }
1222
1223    fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
1224        let active_project_search = match &self.active_project_search {
1225            Some(active_project_search) => active_project_search,
1226
1227            None => {
1228                return;
1229            }
1230        };
1231
1232        active_project_search.update(cx, |project_view, cx| {
1233            let mut views = vec![&project_view.query_editor];
1234            if project_view.replace_enabled {
1235                views.push(&project_view.replacement_editor);
1236            }
1237            if project_view.filters_enabled {
1238                views.extend([
1239                    &project_view.included_files_editor,
1240                    &project_view.excluded_files_editor,
1241                ]);
1242            }
1243            let current_index = match views
1244                .iter()
1245                .enumerate()
1246                .find(|(_, view)| view.focus_handle(cx).is_focused(cx))
1247            {
1248                Some((index, _)) => index,
1249                None => return,
1250            };
1251
1252            let new_index = match direction {
1253                Direction::Next => (current_index + 1) % views.len(),
1254                Direction::Prev if current_index == 0 => views.len() - 1,
1255                Direction::Prev => (current_index - 1) % views.len(),
1256            };
1257            let next_focus_handle = views[new_index].focus_handle(cx);
1258            cx.focus(&next_focus_handle);
1259            cx.stop_propagation();
1260        });
1261    }
1262
1263    fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) -> bool {
1264        if let Some(search_view) = self.active_project_search.as_ref() {
1265            search_view.update(cx, |search_view, cx| {
1266                search_view.toggle_search_option(option, cx);
1267                if search_view.model.read(cx).active_query.is_some() {
1268                    search_view.search(cx);
1269                }
1270            });
1271
1272            cx.notify();
1273            true
1274        } else {
1275            false
1276        }
1277    }
1278
1279    fn toggle_replace(&mut self, _: &ToggleReplace, cx: &mut ViewContext<Self>) {
1280        if let Some(search) = &self.active_project_search {
1281            search.update(cx, |this, cx| {
1282                this.replace_enabled = !this.replace_enabled;
1283                let editor_to_focus = if this.replace_enabled {
1284                    this.replacement_editor.focus_handle(cx)
1285                } else {
1286                    this.query_editor.focus_handle(cx)
1287                };
1288                cx.focus(&editor_to_focus);
1289                cx.notify();
1290            });
1291        }
1292    }
1293
1294    fn toggle_filters(&mut self, cx: &mut ViewContext<Self>) -> bool {
1295        if let Some(search_view) = self.active_project_search.as_ref() {
1296            search_view.update(cx, |search_view, cx| {
1297                search_view.toggle_filters(cx);
1298                search_view
1299                    .included_files_editor
1300                    .update(cx, |_, cx| cx.notify());
1301                search_view
1302                    .excluded_files_editor
1303                    .update(cx, |_, cx| cx.notify());
1304                cx.refresh();
1305                cx.notify();
1306            });
1307            cx.notify();
1308            true
1309        } else {
1310            false
1311        }
1312    }
1313
1314    fn toggle_opened_only(&mut self, cx: &mut ViewContext<Self>) -> bool {
1315        if let Some(search_view) = self.active_project_search.as_ref() {
1316            search_view.update(cx, |search_view, cx| {
1317                search_view.toggle_opened_only(cx);
1318                if search_view.model.read(cx).active_query.is_some() {
1319                    search_view.search(cx);
1320                }
1321            });
1322
1323            cx.notify();
1324            true
1325        } else {
1326            false
1327        }
1328    }
1329
1330    fn is_opened_only_enabled(&self, cx: &AppContext) -> bool {
1331        if let Some(search_view) = self.active_project_search.as_ref() {
1332            search_view.read(cx).included_opened_only
1333        } else {
1334            false
1335        }
1336    }
1337
1338    fn move_focus_to_results(&self, cx: &mut ViewContext<Self>) {
1339        if let Some(search_view) = self.active_project_search.as_ref() {
1340            search_view.update(cx, |search_view, cx| {
1341                search_view.move_focus_to_results(cx);
1342            });
1343            cx.notify();
1344        }
1345    }
1346
1347    fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool {
1348        if let Some(search) = self.active_project_search.as_ref() {
1349            search.read(cx).search_options.contains(option)
1350        } else {
1351            false
1352        }
1353    }
1354
1355    fn next_history_query(&mut self, _: &NextHistoryQuery, cx: &mut ViewContext<Self>) {
1356        if let Some(search_view) = self.active_project_search.as_ref() {
1357            search_view.update(cx, |search_view, cx| {
1358                let new_query = search_view.model.update(cx, |model, cx| {
1359                    if let Some(new_query) = model.project.update(cx, |project, _| {
1360                        project
1361                            .search_history_mut()
1362                            .next(&mut model.search_history_cursor)
1363                            .map(str::to_string)
1364                    }) {
1365                        new_query
1366                    } else {
1367                        model.search_history_cursor.reset();
1368                        String::new()
1369                    }
1370                });
1371                search_view.set_query(&new_query, cx);
1372            });
1373        }
1374    }
1375
1376    fn previous_history_query(&mut self, _: &PreviousHistoryQuery, cx: &mut ViewContext<Self>) {
1377        if let Some(search_view) = self.active_project_search.as_ref() {
1378            search_view.update(cx, |search_view, cx| {
1379                if search_view.query_editor.read(cx).text(cx).is_empty() {
1380                    if let Some(new_query) = search_view
1381                        .model
1382                        .read(cx)
1383                        .project
1384                        .read(cx)
1385                        .search_history()
1386                        .current(&search_view.model.read(cx).search_history_cursor)
1387                        .map(str::to_string)
1388                    {
1389                        search_view.set_query(&new_query, cx);
1390                        return;
1391                    }
1392                }
1393
1394                if let Some(new_query) = search_view.model.update(cx, |model, cx| {
1395                    model.project.update(cx, |project, _| {
1396                        project
1397                            .search_history_mut()
1398                            .previous(&mut model.search_history_cursor)
1399                            .map(str::to_string)
1400                    })
1401                }) {
1402                    search_view.set_query(&new_query, cx);
1403                }
1404            });
1405        }
1406    }
1407
1408    fn select_next_match(&mut self, _: &SelectNextMatch, cx: &mut ViewContext<Self>) {
1409        if let Some(search) = self.active_project_search.as_ref() {
1410            search.update(cx, |this, cx| {
1411                this.select_match(Direction::Next, cx);
1412            })
1413        }
1414    }
1415
1416    fn select_prev_match(&mut self, _: &SelectPrevMatch, cx: &mut ViewContext<Self>) {
1417        if let Some(search) = self.active_project_search.as_ref() {
1418            search.update(cx, |this, cx| {
1419                this.select_match(Direction::Prev, cx);
1420            })
1421        }
1422    }
1423
1424    fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
1425        let settings = ThemeSettings::get_global(cx);
1426        let text_style = TextStyle {
1427            color: if editor.read(cx).read_only(cx) {
1428                cx.theme().colors().text_disabled
1429            } else {
1430                cx.theme().colors().text
1431            },
1432            font_family: settings.buffer_font.family.clone(),
1433            font_features: settings.buffer_font.features.clone(),
1434            font_fallbacks: settings.buffer_font.fallbacks.clone(),
1435            font_size: rems(0.875).into(),
1436            font_weight: settings.buffer_font.weight,
1437            line_height: relative(1.3),
1438            ..Default::default()
1439        };
1440
1441        EditorElement::new(
1442            &editor,
1443            EditorStyle {
1444                background: cx.theme().colors().editor_background,
1445                local_player: cx.theme().players().local(),
1446                text: text_style,
1447                ..Default::default()
1448            },
1449        )
1450    }
1451}
1452
1453impl Render for ProjectSearchBar {
1454    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1455        let Some(search) = self.active_project_search.clone() else {
1456            return div();
1457        };
1458        let search = search.read(cx);
1459
1460        let query_column = h_flex()
1461            .flex_1()
1462            .h_8()
1463            .mr_2()
1464            .px_2()
1465            .py_1()
1466            .border_1()
1467            .border_color(search.border_color_for(InputPanel::Query, cx))
1468            .rounded_lg()
1469            .min_w(rems(MIN_INPUT_WIDTH_REMS))
1470            .max_w(rems(MAX_INPUT_WIDTH_REMS))
1471            .on_action(cx.listener(|this, action, cx| this.confirm(action, cx)))
1472            .on_action(cx.listener(|this, action, cx| this.previous_history_query(action, cx)))
1473            .on_action(cx.listener(|this, action, cx| this.next_history_query(action, cx)))
1474            .child(self.render_text_input(&search.query_editor, cx))
1475            .child(
1476                h_flex()
1477                    .child(SearchOptions::CASE_SENSITIVE.as_button(
1478                        self.is_option_enabled(SearchOptions::CASE_SENSITIVE, cx),
1479                        cx.listener(|this, _, cx| {
1480                            this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1481                        }),
1482                    ))
1483                    .child(SearchOptions::WHOLE_WORD.as_button(
1484                        self.is_option_enabled(SearchOptions::WHOLE_WORD, cx),
1485                        cx.listener(|this, _, cx| {
1486                            this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1487                        }),
1488                    ))
1489                    .child(SearchOptions::REGEX.as_button(
1490                        self.is_option_enabled(SearchOptions::REGEX, cx),
1491                        cx.listener(|this, _, cx| {
1492                            this.toggle_search_option(SearchOptions::REGEX, cx);
1493                        }),
1494                    )),
1495            );
1496
1497        let mode_column = v_flex().items_start().justify_start().child(
1498            h_flex()
1499                .child(
1500                    IconButton::new("project-search-filter-button", IconName::Filter)
1501                        .tooltip(|cx| Tooltip::for_action("Toggle filters", &ToggleFilters, cx))
1502                        .on_click(cx.listener(|this, _, cx| {
1503                            this.toggle_filters(cx);
1504                        }))
1505                        .selected(
1506                            self.active_project_search
1507                                .as_ref()
1508                                .map(|search| search.read(cx).filters_enabled)
1509                                .unwrap_or_default(),
1510                        )
1511                        .tooltip(|cx| Tooltip::for_action("Toggle filters", &ToggleFilters, cx)),
1512                )
1513                .child(
1514                    IconButton::new("project-search-toggle-replace", IconName::Replace)
1515                        .on_click(cx.listener(|this, _, cx| {
1516                            this.toggle_replace(&ToggleReplace, cx);
1517                        }))
1518                        .selected(
1519                            self.active_project_search
1520                                .as_ref()
1521                                .map(|search| search.read(cx).replace_enabled)
1522                                .unwrap_or_default(),
1523                        )
1524                        .tooltip(|cx| Tooltip::for_action("Toggle replace", &ToggleReplace, cx)),
1525                ),
1526        );
1527
1528        let limit_reached = search.model.read(cx).limit_reached;
1529        let match_text = search
1530            .active_match_index
1531            .and_then(|index| {
1532                let index = index + 1;
1533                let match_quantity = search.model.read(cx).match_ranges.len();
1534                if match_quantity > 0 {
1535                    debug_assert!(match_quantity >= index);
1536                    if limit_reached {
1537                        Some(format!("{index}/{match_quantity}+").to_string())
1538                    } else {
1539                        Some(format!("{index}/{match_quantity}").to_string())
1540                    }
1541                } else {
1542                    None
1543                }
1544            })
1545            .unwrap_or_else(|| "0/0".to_string());
1546
1547        let matches_column = h_flex()
1548            .child(
1549                IconButton::new("project-search-prev-match", IconName::ChevronLeft)
1550                    .disabled(search.active_match_index.is_none())
1551                    .on_click(cx.listener(|this, _, cx| {
1552                        if let Some(search) = this.active_project_search.as_ref() {
1553                            search.update(cx, |this, cx| {
1554                                this.select_match(Direction::Prev, cx);
1555                            })
1556                        }
1557                    }))
1558                    .tooltip(|cx| {
1559                        Tooltip::for_action("Go to previous match", &SelectPrevMatch, cx)
1560                    }),
1561            )
1562            .child(
1563                IconButton::new("project-search-next-match", IconName::ChevronRight)
1564                    .disabled(search.active_match_index.is_none())
1565                    .on_click(cx.listener(|this, _, cx| {
1566                        if let Some(search) = this.active_project_search.as_ref() {
1567                            search.update(cx, |this, cx| {
1568                                this.select_match(Direction::Next, cx);
1569                            })
1570                        }
1571                    }))
1572                    .tooltip(|cx| Tooltip::for_action("Go to next match", &SelectNextMatch, cx)),
1573            )
1574            .child(
1575                h_flex()
1576                    .id("matches")
1577                    .min_w(rems_from_px(40.))
1578                    .child(
1579                        Label::new(match_text).color(if search.active_match_index.is_some() {
1580                            Color::Default
1581                        } else {
1582                            Color::Disabled
1583                        }),
1584                    )
1585                    .when(limit_reached, |el| {
1586                        el.tooltip(|cx| {
1587                            Tooltip::text("Search limits reached.\nTry narrowing your search.", cx)
1588                        })
1589                    }),
1590            );
1591
1592        let search_line = h_flex()
1593            .flex_1()
1594            .child(query_column)
1595            .child(mode_column)
1596            .child(matches_column);
1597
1598        let replace_line = search.replace_enabled.then(|| {
1599            let replace_column = h_flex()
1600                .flex_1()
1601                .min_w(rems(MIN_INPUT_WIDTH_REMS))
1602                .max_w(rems(MAX_INPUT_WIDTH_REMS))
1603                .h_8()
1604                .px_2()
1605                .py_1()
1606                .border_1()
1607                .border_color(cx.theme().colors().border)
1608                .rounded_lg()
1609                .child(self.render_text_input(&search.replacement_editor, cx));
1610            let replace_actions = h_flex().when(search.replace_enabled, |this| {
1611                this.child(
1612                    IconButton::new("project-search-replace-next", IconName::ReplaceNext)
1613                        .on_click(cx.listener(|this, _, cx| {
1614                            if let Some(search) = this.active_project_search.as_ref() {
1615                                search.update(cx, |this, cx| {
1616                                    this.replace_next(&ReplaceNext, cx);
1617                                })
1618                            }
1619                        }))
1620                        .tooltip(|cx| Tooltip::for_action("Replace next match", &ReplaceNext, cx)),
1621                )
1622                .child(
1623                    IconButton::new("project-search-replace-all", IconName::ReplaceAll)
1624                        .on_click(cx.listener(|this, _, cx| {
1625                            if let Some(search) = this.active_project_search.as_ref() {
1626                                search.update(cx, |this, cx| {
1627                                    this.replace_all(&ReplaceAll, cx);
1628                                })
1629                            }
1630                        }))
1631                        .tooltip(|cx| Tooltip::for_action("Replace all matches", &ReplaceAll, cx)),
1632                )
1633            });
1634            h_flex()
1635                .pr(rems(5.5))
1636                .gap_2()
1637                .child(replace_column)
1638                .child(replace_actions)
1639        });
1640
1641        let filter_line = search.filters_enabled.then(|| {
1642            h_flex()
1643                .w_full()
1644                .gap_2()
1645                .child(
1646                    h_flex()
1647                        .flex_1()
1648                        // chosen so the total width of the search bar line
1649                        // is about the same as the include/exclude line
1650                        .min_w(rems(10.25))
1651                        .max_w(rems(20.))
1652                        .h_8()
1653                        .px_2()
1654                        .py_1()
1655                        .border_1()
1656                        .border_color(search.border_color_for(InputPanel::Include, cx))
1657                        .rounded_lg()
1658                        .child(self.render_text_input(&search.included_files_editor, cx)),
1659                )
1660                .child(
1661                    h_flex()
1662                        .flex_1()
1663                        .min_w(rems(10.25))
1664                        .max_w(rems(20.))
1665                        .h_8()
1666                        .px_2()
1667                        .py_1()
1668                        .border_1()
1669                        .border_color(search.border_color_for(InputPanel::Exclude, cx))
1670                        .rounded_lg()
1671                        .child(self.render_text_input(&search.excluded_files_editor, cx)),
1672                )
1673                .child(
1674                    IconButton::new("project-search-opened-only", IconName::FileDoc)
1675                        .selected(self.is_opened_only_enabled(cx))
1676                        .tooltip(|cx| Tooltip::text("Only search open files", cx))
1677                        .on_click(cx.listener(|this, _, cx| {
1678                            this.toggle_opened_only(cx);
1679                        })),
1680                )
1681                .child(
1682                    SearchOptions::INCLUDE_IGNORED.as_button(
1683                        search
1684                            .search_options
1685                            .contains(SearchOptions::INCLUDE_IGNORED),
1686                        cx.listener(|this, _, cx| {
1687                            this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
1688                        }),
1689                    ),
1690                )
1691        });
1692        let mut key_context = KeyContext::default();
1693        key_context.add("ProjectSearchBar");
1694        if search.replacement_editor.focus_handle(cx).is_focused(cx) {
1695            key_context.add("in_replace");
1696        }
1697
1698        v_flex()
1699            .key_context(key_context)
1700            .on_action(cx.listener(|this, _: &ToggleFocus, cx| this.move_focus_to_results(cx)))
1701            .on_action(cx.listener(|this, _: &ToggleFilters, cx| {
1702                this.toggle_filters(cx);
1703            }))
1704            .capture_action(cx.listener(|this, action, cx| {
1705                this.tab(action, cx);
1706                cx.stop_propagation();
1707            }))
1708            .capture_action(cx.listener(|this, action, cx| {
1709                this.tab_previous(action, cx);
1710                cx.stop_propagation();
1711            }))
1712            .on_action(cx.listener(|this, action, cx| this.confirm(action, cx)))
1713            .on_action(cx.listener(|this, action, cx| {
1714                this.toggle_replace(action, cx);
1715            }))
1716            .on_action(cx.listener(|this, _: &ToggleWholeWord, cx| {
1717                this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
1718            }))
1719            .on_action(cx.listener(|this, _: &ToggleCaseSensitive, cx| {
1720                this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1721            }))
1722            .on_action(cx.listener(|this, action, cx| {
1723                if let Some(search) = this.active_project_search.as_ref() {
1724                    search.update(cx, |this, cx| {
1725                        this.replace_next(action, cx);
1726                    })
1727                }
1728            }))
1729            .on_action(cx.listener(|this, action, cx| {
1730                if let Some(search) = this.active_project_search.as_ref() {
1731                    search.update(cx, |this, cx| {
1732                        this.replace_all(action, cx);
1733                    })
1734                }
1735            }))
1736            .when(search.filters_enabled, |this| {
1737                this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, cx| {
1738                    this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
1739                }))
1740            })
1741            .on_action(cx.listener(Self::select_next_match))
1742            .on_action(cx.listener(Self::select_prev_match))
1743            .gap_2()
1744            .w_full()
1745            .child(search_line)
1746            .children(replace_line)
1747            .children(filter_line)
1748    }
1749}
1750
1751impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
1752
1753impl ToolbarItemView for ProjectSearchBar {
1754    fn set_active_pane_item(
1755        &mut self,
1756        active_pane_item: Option<&dyn ItemHandle>,
1757        cx: &mut ViewContext<Self>,
1758    ) -> ToolbarItemLocation {
1759        cx.notify();
1760        self.subscription = None;
1761        self.active_project_search = None;
1762        if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
1763            self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
1764            self.active_project_search = Some(search);
1765            ToolbarItemLocation::PrimaryLeft {}
1766        } else {
1767            ToolbarItemLocation::Hidden
1768        }
1769    }
1770}
1771
1772fn register_workspace_action<A: Action>(
1773    workspace: &mut Workspace,
1774    callback: fn(&mut ProjectSearchBar, &A, &mut ViewContext<ProjectSearchBar>),
1775) {
1776    workspace.register_action(move |workspace, action: &A, cx| {
1777        if workspace.has_active_modal(cx) {
1778            cx.propagate();
1779            return;
1780        }
1781
1782        workspace.active_pane().update(cx, |pane, cx| {
1783            pane.toolbar().update(cx, move |workspace, cx| {
1784                if let Some(search_bar) = workspace.item_of_type::<ProjectSearchBar>() {
1785                    search_bar.update(cx, move |search_bar, cx| {
1786                        if search_bar.active_project_search.is_some() {
1787                            callback(search_bar, action, cx);
1788                            cx.notify();
1789                        } else {
1790                            cx.propagate();
1791                        }
1792                    });
1793                }
1794            });
1795        })
1796    });
1797}
1798
1799fn register_workspace_action_for_present_search<A: Action>(
1800    workspace: &mut Workspace,
1801    callback: fn(&mut Workspace, &A, &mut ViewContext<Workspace>),
1802) {
1803    workspace.register_action(move |workspace, action: &A, cx| {
1804        if workspace.has_active_modal(cx) {
1805            cx.propagate();
1806            return;
1807        }
1808
1809        let should_notify = workspace
1810            .active_pane()
1811            .read(cx)
1812            .toolbar()
1813            .read(cx)
1814            .item_of_type::<ProjectSearchBar>()
1815            .map(|search_bar| search_bar.read(cx).active_project_search.is_some())
1816            .unwrap_or(false);
1817        if should_notify {
1818            callback(workspace, action, cx);
1819            cx.notify();
1820        } else {
1821            cx.propagate();
1822        }
1823    });
1824}
1825
1826#[cfg(test)]
1827pub mod tests {
1828    use super::*;
1829    use editor::{display_map::DisplayRow, DisplayPoint};
1830    use gpui::{Action, TestAppContext, WindowHandle};
1831    use project::FakeFs;
1832    use serde_json::json;
1833    use settings::SettingsStore;
1834    use std::sync::Arc;
1835    use workspace::DeploySearch;
1836
1837    #[gpui::test]
1838    async fn test_project_search(cx: &mut TestAppContext) {
1839        init_test(cx);
1840
1841        let fs = FakeFs::new(cx.background_executor.clone());
1842        fs.insert_tree(
1843            "/dir",
1844            json!({
1845                "one.rs": "const ONE: usize = 1;",
1846                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1847                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1848                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1849            }),
1850        )
1851        .await;
1852        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1853        let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1854        let workspace = window.root(cx).unwrap();
1855        let search = cx.new_model(|cx| ProjectSearch::new(project.clone(), cx));
1856        let search_view = cx.add_window(|cx| {
1857            ProjectSearchView::new(workspace.downgrade(), search.clone(), cx, None)
1858        });
1859
1860        perform_search(search_view, "TWO", cx);
1861        search_view.update(cx, |search_view, cx| {
1862            assert_eq!(
1863                search_view
1864                    .results_editor
1865                    .update(cx, |editor, cx| editor.display_text(cx)),
1866                "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n"
1867            );
1868            let match_background_color = cx.theme().colors().search_match_background;
1869            assert_eq!(
1870                search_view
1871                    .results_editor
1872                    .update(cx, |editor, cx| editor.all_text_background_highlights(cx)),
1873                &[
1874                    (
1875                        DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35),
1876                        match_background_color
1877                    ),
1878                    (
1879                        DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40),
1880                        match_background_color
1881                    ),
1882                    (
1883                        DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9),
1884                        match_background_color
1885                    )
1886                ]
1887            );
1888            assert_eq!(search_view.active_match_index, Some(0));
1889            assert_eq!(
1890                search_view
1891                    .results_editor
1892                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1893                [DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35)]
1894            );
1895
1896            search_view.select_match(Direction::Next, cx);
1897        }).unwrap();
1898
1899        search_view
1900            .update(cx, |search_view, cx| {
1901                assert_eq!(search_view.active_match_index, Some(1));
1902                assert_eq!(
1903                    search_view
1904                        .results_editor
1905                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1906                    [DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40)]
1907                );
1908                search_view.select_match(Direction::Next, cx);
1909            })
1910            .unwrap();
1911
1912        search_view
1913            .update(cx, |search_view, cx| {
1914                assert_eq!(search_view.active_match_index, Some(2));
1915                assert_eq!(
1916                    search_view
1917                        .results_editor
1918                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1919                    [DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9)]
1920                );
1921                search_view.select_match(Direction::Next, cx);
1922            })
1923            .unwrap();
1924
1925        search_view
1926            .update(cx, |search_view, cx| {
1927                assert_eq!(search_view.active_match_index, Some(0));
1928                assert_eq!(
1929                    search_view
1930                        .results_editor
1931                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1932                    [DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35)]
1933                );
1934                search_view.select_match(Direction::Prev, cx);
1935            })
1936            .unwrap();
1937
1938        search_view
1939            .update(cx, |search_view, cx| {
1940                assert_eq!(search_view.active_match_index, Some(2));
1941                assert_eq!(
1942                    search_view
1943                        .results_editor
1944                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1945                    [DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9)]
1946                );
1947                search_view.select_match(Direction::Prev, cx);
1948            })
1949            .unwrap();
1950
1951        search_view
1952            .update(cx, |search_view, cx| {
1953                assert_eq!(search_view.active_match_index, Some(1));
1954                assert_eq!(
1955                    search_view
1956                        .results_editor
1957                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1958                    [DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40)]
1959                );
1960            })
1961            .unwrap();
1962    }
1963
1964    #[gpui::test]
1965    async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
1966        init_test(cx);
1967
1968        let fs = FakeFs::new(cx.background_executor.clone());
1969        fs.insert_tree(
1970            "/dir",
1971            json!({
1972                "one.rs": "const ONE: usize = 1;",
1973                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1974                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1975                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1976            }),
1977        )
1978        .await;
1979        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1980        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
1981        let workspace = window;
1982        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
1983
1984        let active_item = cx.read(|cx| {
1985            workspace
1986                .read(cx)
1987                .unwrap()
1988                .active_pane()
1989                .read(cx)
1990                .active_item()
1991                .and_then(|item| item.downcast::<ProjectSearchView>())
1992        });
1993        assert!(
1994            active_item.is_none(),
1995            "Expected no search panel to be active"
1996        );
1997
1998        window
1999            .update(cx, move |workspace, cx| {
2000                assert_eq!(workspace.panes().len(), 1);
2001                workspace.panes()[0].update(cx, move |pane, cx| {
2002                    pane.toolbar()
2003                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2004                });
2005
2006                ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::find(), cx)
2007            })
2008            .unwrap();
2009
2010        let Some(search_view) = cx.read(|cx| {
2011            workspace
2012                .read(cx)
2013                .unwrap()
2014                .active_pane()
2015                .read(cx)
2016                .active_item()
2017                .and_then(|item| item.downcast::<ProjectSearchView>())
2018        }) else {
2019            panic!("Search view expected to appear after new search event trigger")
2020        };
2021
2022        cx.spawn(|mut cx| async move {
2023            window
2024                .update(&mut cx, |_, cx| {
2025                    cx.dispatch_action(ToggleFocus.boxed_clone())
2026                })
2027                .unwrap();
2028        })
2029        .detach();
2030        cx.background_executor.run_until_parked();
2031        window
2032            .update(cx, |_, cx| {
2033                search_view.update(cx, |search_view, cx| {
2034                assert!(
2035                    search_view.query_editor.focus_handle(cx).is_focused(cx),
2036                    "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2037                );
2038           });
2039        }).unwrap();
2040
2041        window
2042            .update(cx, |_, cx| {
2043                search_view.update(cx, |search_view, cx| {
2044                    let query_editor = &search_view.query_editor;
2045                    assert!(
2046                        query_editor.focus_handle(cx).is_focused(cx),
2047                        "Search view should be focused after the new search view is activated",
2048                    );
2049                    let query_text = query_editor.read(cx).text(cx);
2050                    assert!(
2051                        query_text.is_empty(),
2052                        "New search query should be empty but got '{query_text}'",
2053                    );
2054                    let results_text = search_view
2055                        .results_editor
2056                        .update(cx, |editor, cx| editor.display_text(cx));
2057                    assert!(
2058                        results_text.is_empty(),
2059                        "Empty search view should have no results but got '{results_text}'"
2060                    );
2061                });
2062            })
2063            .unwrap();
2064
2065        window
2066            .update(cx, |_, cx| {
2067                search_view.update(cx, |search_view, cx| {
2068                    search_view.query_editor.update(cx, |query_editor, cx| {
2069                        query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
2070                    });
2071                    search_view.search(cx);
2072                });
2073            })
2074            .unwrap();
2075        cx.background_executor.run_until_parked();
2076        window
2077            .update(cx, |_, cx| {
2078            search_view.update(cx, |search_view, cx| {
2079                let results_text = search_view
2080                    .results_editor
2081                    .update(cx, |editor, cx| editor.display_text(cx));
2082                assert!(
2083                    results_text.is_empty(),
2084                    "Search view for mismatching query should have no results but got '{results_text}'"
2085                );
2086                assert!(
2087                    search_view.query_editor.focus_handle(cx).is_focused(cx),
2088                    "Search view should be focused after mismatching query had been used in search",
2089                );
2090            });
2091        }).unwrap();
2092
2093        cx.spawn(|mut cx| async move {
2094            window.update(&mut cx, |_, cx| {
2095                cx.dispatch_action(ToggleFocus.boxed_clone())
2096            })
2097        })
2098        .detach();
2099        cx.background_executor.run_until_parked();
2100        window.update(cx, |_, cx| {
2101            search_view.update(cx, |search_view, cx| {
2102                assert!(
2103                    search_view.query_editor.focus_handle(cx).is_focused(cx),
2104                    "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2105                );
2106            });
2107        }).unwrap();
2108
2109        window
2110            .update(cx, |_, cx| {
2111                search_view.update(cx, |search_view, cx| {
2112                    search_view
2113                        .query_editor
2114                        .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2115                    search_view.search(cx);
2116                });
2117            })
2118            .unwrap();
2119        cx.background_executor.run_until_parked();
2120        window.update(cx, |_, cx| {
2121            search_view.update(cx, |search_view, cx| {
2122                assert_eq!(
2123                    search_view
2124                        .results_editor
2125                        .update(cx, |editor, cx| editor.display_text(cx)),
2126                    "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2127                    "Search view results should match the query"
2128                );
2129                assert!(
2130                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2131                    "Search view with mismatching query should be focused after search results are available",
2132                );
2133            });
2134        }).unwrap();
2135        cx.spawn(|mut cx| async move {
2136            window
2137                .update(&mut cx, |_, cx| {
2138                    cx.dispatch_action(ToggleFocus.boxed_clone())
2139                })
2140                .unwrap();
2141        })
2142        .detach();
2143        cx.background_executor.run_until_parked();
2144        window.update(cx, |_, cx| {
2145            search_view.update(cx, |search_view, cx| {
2146                assert!(
2147                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2148                    "Search view with matching query should still have its results editor focused after the toggle focus event",
2149                );
2150            });
2151        }).unwrap();
2152
2153        workspace
2154            .update(cx, |workspace, cx| {
2155                ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::find(), cx)
2156            })
2157            .unwrap();
2158        window.update(cx, |_, cx| {
2159            search_view.update(cx, |search_view, cx| {
2160                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");
2161                assert_eq!(
2162                    search_view
2163                        .results_editor
2164                        .update(cx, |editor, cx| editor.display_text(cx)),
2165                    "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2166                    "Results should be unchanged after search view 2nd open in a row"
2167                );
2168                assert!(
2169                    search_view.query_editor.focus_handle(cx).is_focused(cx),
2170                    "Focus should be moved into query editor again after search view 2nd open in a row"
2171                );
2172            });
2173        }).unwrap();
2174
2175        cx.spawn(|mut cx| async move {
2176            window
2177                .update(&mut cx, |_, cx| {
2178                    cx.dispatch_action(ToggleFocus.boxed_clone())
2179                })
2180                .unwrap();
2181        })
2182        .detach();
2183        cx.background_executor.run_until_parked();
2184        window.update(cx, |_, cx| {
2185            search_view.update(cx, |search_view, cx| {
2186                assert!(
2187                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2188                    "Search view with matching query should switch focus to the results editor after the toggle focus event",
2189                );
2190            });
2191        }).unwrap();
2192    }
2193
2194    #[gpui::test]
2195    async fn test_new_project_search_focus(cx: &mut TestAppContext) {
2196        init_test(cx);
2197
2198        let fs = FakeFs::new(cx.background_executor.clone());
2199        fs.insert_tree(
2200            "/dir",
2201            json!({
2202                "one.rs": "const ONE: usize = 1;",
2203                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2204                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2205                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2206            }),
2207        )
2208        .await;
2209        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2210        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2211        let workspace = window;
2212        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2213
2214        let active_item = cx.read(|cx| {
2215            workspace
2216                .read(cx)
2217                .unwrap()
2218                .active_pane()
2219                .read(cx)
2220                .active_item()
2221                .and_then(|item| item.downcast::<ProjectSearchView>())
2222        });
2223        assert!(
2224            active_item.is_none(),
2225            "Expected no search panel to be active"
2226        );
2227
2228        window
2229            .update(cx, move |workspace, cx| {
2230                assert_eq!(workspace.panes().len(), 1);
2231                workspace.panes()[0].update(cx, move |pane, cx| {
2232                    pane.toolbar()
2233                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2234                });
2235
2236                ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2237            })
2238            .unwrap();
2239
2240        let Some(search_view) = cx.read(|cx| {
2241            workspace
2242                .read(cx)
2243                .unwrap()
2244                .active_pane()
2245                .read(cx)
2246                .active_item()
2247                .and_then(|item| item.downcast::<ProjectSearchView>())
2248        }) else {
2249            panic!("Search view expected to appear after new search event trigger")
2250        };
2251
2252        cx.spawn(|mut cx| async move {
2253            window
2254                .update(&mut cx, |_, cx| {
2255                    cx.dispatch_action(ToggleFocus.boxed_clone())
2256                })
2257                .unwrap();
2258        })
2259        .detach();
2260        cx.background_executor.run_until_parked();
2261
2262        window.update(cx, |_, cx| {
2263            search_view.update(cx, |search_view, cx| {
2264                    assert!(
2265                        search_view.query_editor.focus_handle(cx).is_focused(cx),
2266                        "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2267                    );
2268                });
2269        }).unwrap();
2270
2271        window
2272            .update(cx, |_, cx| {
2273                search_view.update(cx, |search_view, cx| {
2274                    let query_editor = &search_view.query_editor;
2275                    assert!(
2276                        query_editor.focus_handle(cx).is_focused(cx),
2277                        "Search view should be focused after the new search view is activated",
2278                    );
2279                    let query_text = query_editor.read(cx).text(cx);
2280                    assert!(
2281                        query_text.is_empty(),
2282                        "New search query should be empty but got '{query_text}'",
2283                    );
2284                    let results_text = search_view
2285                        .results_editor
2286                        .update(cx, |editor, cx| editor.display_text(cx));
2287                    assert!(
2288                        results_text.is_empty(),
2289                        "Empty search view should have no results but got '{results_text}'"
2290                    );
2291                });
2292            })
2293            .unwrap();
2294
2295        window
2296            .update(cx, |_, cx| {
2297                search_view.update(cx, |search_view, cx| {
2298                    search_view.query_editor.update(cx, |query_editor, cx| {
2299                        query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
2300                    });
2301                    search_view.search(cx);
2302                });
2303            })
2304            .unwrap();
2305
2306        cx.background_executor.run_until_parked();
2307        window
2308            .update(cx, |_, cx| {
2309                search_view.update(cx, |search_view, cx| {
2310                    let results_text = search_view
2311                        .results_editor
2312                        .update(cx, |editor, cx| editor.display_text(cx));
2313                    assert!(
2314                results_text.is_empty(),
2315                "Search view for mismatching query should have no results but got '{results_text}'"
2316            );
2317                    assert!(
2318                search_view.query_editor.focus_handle(cx).is_focused(cx),
2319                "Search view should be focused after mismatching query had been used in search",
2320            );
2321                });
2322            })
2323            .unwrap();
2324        cx.spawn(|mut cx| async move {
2325            window.update(&mut cx, |_, cx| {
2326                cx.dispatch_action(ToggleFocus.boxed_clone())
2327            })
2328        })
2329        .detach();
2330        cx.background_executor.run_until_parked();
2331        window.update(cx, |_, cx| {
2332            search_view.update(cx, |search_view, cx| {
2333                    assert!(
2334                        search_view.query_editor.focus_handle(cx).is_focused(cx),
2335                        "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2336                    );
2337                });
2338        }).unwrap();
2339
2340        window
2341            .update(cx, |_, cx| {
2342                search_view.update(cx, |search_view, cx| {
2343                    search_view
2344                        .query_editor
2345                        .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2346                    search_view.search(cx);
2347                })
2348            })
2349            .unwrap();
2350        cx.background_executor.run_until_parked();
2351        window.update(cx, |_, cx|
2352        search_view.update(cx, |search_view, cx| {
2353                assert_eq!(
2354                    search_view
2355                        .results_editor
2356                        .update(cx, |editor, cx| editor.display_text(cx)),
2357                    "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2358                    "Search view results should match the query"
2359                );
2360                assert!(
2361                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2362                    "Search view with mismatching query should be focused after search results are available",
2363                );
2364            })).unwrap();
2365        cx.spawn(|mut cx| async move {
2366            window
2367                .update(&mut cx, |_, cx| {
2368                    cx.dispatch_action(ToggleFocus.boxed_clone())
2369                })
2370                .unwrap();
2371        })
2372        .detach();
2373        cx.background_executor.run_until_parked();
2374        window.update(cx, |_, cx| {
2375            search_view.update(cx, |search_view, cx| {
2376                    assert!(
2377                        search_view.results_editor.focus_handle(cx).is_focused(cx),
2378                        "Search view with matching query should still have its results editor focused after the toggle focus event",
2379                    );
2380                });
2381        }).unwrap();
2382
2383        workspace
2384            .update(cx, |workspace, cx| {
2385                ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2386            })
2387            .unwrap();
2388        cx.background_executor.run_until_parked();
2389        let Some(search_view_2) = cx.read(|cx| {
2390            workspace
2391                .read(cx)
2392                .unwrap()
2393                .active_pane()
2394                .read(cx)
2395                .active_item()
2396                .and_then(|item| item.downcast::<ProjectSearchView>())
2397        }) else {
2398            panic!("Search view expected to appear after new search event trigger")
2399        };
2400        assert!(
2401            search_view_2 != search_view,
2402            "New search view should be open after `workspace::NewSearch` event"
2403        );
2404
2405        window.update(cx, |_, cx| {
2406            search_view.update(cx, |search_view, cx| {
2407                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
2408                    assert_eq!(
2409                        search_view
2410                            .results_editor
2411                            .update(cx, |editor, cx| editor.display_text(cx)),
2412                        "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2413                        "Results of the first search view should not update too"
2414                    );
2415                    assert!(
2416                        !search_view.query_editor.focus_handle(cx).is_focused(cx),
2417                        "Focus should be moved away from the first search view"
2418                    );
2419                });
2420        }).unwrap();
2421
2422        window.update(cx, |_, cx| {
2423            search_view_2.update(cx, |search_view_2, cx| {
2424                    assert_eq!(
2425                        search_view_2.query_editor.read(cx).text(cx),
2426                        "two",
2427                        "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
2428                    );
2429                    assert_eq!(
2430                        search_view_2
2431                            .results_editor
2432                            .update(cx, |editor, cx| editor.display_text(cx)),
2433                        "",
2434                        "No search results should be in the 2nd view yet, as we did not spawn a search for it"
2435                    );
2436                    assert!(
2437                        search_view_2.query_editor.focus_handle(cx).is_focused(cx),
2438                        "Focus should be moved into query editor of the new window"
2439                    );
2440                });
2441        }).unwrap();
2442
2443        window
2444            .update(cx, |_, cx| {
2445                search_view_2.update(cx, |search_view_2, cx| {
2446                    search_view_2
2447                        .query_editor
2448                        .update(cx, |query_editor, cx| query_editor.set_text("FOUR", cx));
2449                    search_view_2.search(cx);
2450                });
2451            })
2452            .unwrap();
2453
2454        cx.background_executor.run_until_parked();
2455        window.update(cx, |_, cx| {
2456            search_view_2.update(cx, |search_view_2, cx| {
2457                    assert_eq!(
2458                        search_view_2
2459                            .results_editor
2460                            .update(cx, |editor, cx| editor.display_text(cx)),
2461                        "\n\n\nconst FOUR: usize = one::ONE + three::THREE;\n",
2462                        "New search view with the updated query should have new search results"
2463                    );
2464                    assert!(
2465                        search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2466                        "Search view with mismatching query should be focused after search results are available",
2467                    );
2468                });
2469        }).unwrap();
2470
2471        cx.spawn(|mut cx| async move {
2472            window
2473                .update(&mut cx, |_, cx| {
2474                    cx.dispatch_action(ToggleFocus.boxed_clone())
2475                })
2476                .unwrap();
2477        })
2478        .detach();
2479        cx.background_executor.run_until_parked();
2480        window.update(cx, |_, cx| {
2481            search_view_2.update(cx, |search_view_2, cx| {
2482                    assert!(
2483                        search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2484                        "Search view with matching query should switch focus to the results editor after the toggle focus event",
2485                    );
2486                });}).unwrap();
2487    }
2488
2489    #[gpui::test]
2490    async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
2491        init_test(cx);
2492
2493        let fs = FakeFs::new(cx.background_executor.clone());
2494        fs.insert_tree(
2495            "/dir",
2496            json!({
2497                "a": {
2498                    "one.rs": "const ONE: usize = 1;",
2499                    "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2500                },
2501                "b": {
2502                    "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2503                    "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2504                },
2505            }),
2506        )
2507        .await;
2508        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2509        let worktree_id = project.read_with(cx, |project, cx| {
2510            project.worktrees(cx).next().unwrap().read(cx).id()
2511        });
2512        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2513        let workspace = window.root(cx).unwrap();
2514        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2515
2516        let active_item = cx.read(|cx| {
2517            workspace
2518                .read(cx)
2519                .active_pane()
2520                .read(cx)
2521                .active_item()
2522                .and_then(|item| item.downcast::<ProjectSearchView>())
2523        });
2524        assert!(
2525            active_item.is_none(),
2526            "Expected no search panel to be active"
2527        );
2528
2529        window
2530            .update(cx, move |workspace, cx| {
2531                assert_eq!(workspace.panes().len(), 1);
2532                workspace.panes()[0].update(cx, move |pane, cx| {
2533                    pane.toolbar()
2534                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2535                });
2536            })
2537            .unwrap();
2538
2539        let a_dir_entry = cx.update(|cx| {
2540            workspace
2541                .read(cx)
2542                .project()
2543                .read(cx)
2544                .entry_for_path(&(worktree_id, "a").into(), cx)
2545                .expect("no entry for /a/ directory")
2546        });
2547        assert!(a_dir_entry.is_dir());
2548        window
2549            .update(cx, |workspace, cx| {
2550                ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, cx)
2551            })
2552            .unwrap();
2553
2554        let Some(search_view) = cx.read(|cx| {
2555            workspace
2556                .read(cx)
2557                .active_pane()
2558                .read(cx)
2559                .active_item()
2560                .and_then(|item| item.downcast::<ProjectSearchView>())
2561        }) else {
2562            panic!("Search view expected to appear after new search in directory event trigger")
2563        };
2564        cx.background_executor.run_until_parked();
2565        window
2566            .update(cx, |_, cx| {
2567                search_view.update(cx, |search_view, cx| {
2568                    assert!(
2569                        search_view.query_editor.focus_handle(cx).is_focused(cx),
2570                        "On new search in directory, focus should be moved into query editor"
2571                    );
2572                    search_view.excluded_files_editor.update(cx, |editor, cx| {
2573                        assert!(
2574                            editor.display_text(cx).is_empty(),
2575                            "New search in directory should not have any excluded files"
2576                        );
2577                    });
2578                    search_view.included_files_editor.update(cx, |editor, cx| {
2579                        assert_eq!(
2580                            editor.display_text(cx),
2581                            a_dir_entry.path.to_str().unwrap(),
2582                            "New search in directory should have included dir entry path"
2583                        );
2584                    });
2585                });
2586            })
2587            .unwrap();
2588        window
2589            .update(cx, |_, cx| {
2590                search_view.update(cx, |search_view, cx| {
2591                    search_view
2592                        .query_editor
2593                        .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
2594                    search_view.search(cx);
2595                });
2596            })
2597            .unwrap();
2598        cx.background_executor.run_until_parked();
2599        window
2600            .update(cx, |_, cx| {
2601                search_view.update(cx, |search_view, cx| {
2602                    assert_eq!(
2603                search_view
2604                    .results_editor
2605                    .update(cx, |editor, cx| editor.display_text(cx)),
2606                "\n\n\nconst ONE: usize = 1;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2607                "New search in directory should have a filter that matches a certain directory"
2608            );
2609                })
2610            })
2611            .unwrap();
2612    }
2613
2614    #[gpui::test]
2615    async fn test_search_query_history(cx: &mut TestAppContext) {
2616        init_test(cx);
2617
2618        let fs = FakeFs::new(cx.background_executor.clone());
2619        fs.insert_tree(
2620            "/dir",
2621            json!({
2622                "one.rs": "const ONE: usize = 1;",
2623                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2624                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2625                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2626            }),
2627        )
2628        .await;
2629        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2630        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2631        let workspace = window.root(cx).unwrap();
2632        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2633
2634        window
2635            .update(cx, {
2636                let search_bar = search_bar.clone();
2637                move |workspace, cx| {
2638                    assert_eq!(workspace.panes().len(), 1);
2639                    workspace.panes()[0].update(cx, move |pane, cx| {
2640                        pane.toolbar()
2641                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2642                    });
2643
2644                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2645                }
2646            })
2647            .unwrap();
2648
2649        let search_view = cx.read(|cx| {
2650            workspace
2651                .read(cx)
2652                .active_pane()
2653                .read(cx)
2654                .active_item()
2655                .and_then(|item| item.downcast::<ProjectSearchView>())
2656                .expect("Search view expected to appear after new search event trigger")
2657        });
2658
2659        // Add 3 search items into the history + another unsubmitted one.
2660        window
2661            .update(cx, |_, cx| {
2662                search_view.update(cx, |search_view, cx| {
2663                    search_view.search_options = SearchOptions::CASE_SENSITIVE;
2664                    search_view
2665                        .query_editor
2666                        .update(cx, |query_editor, cx| query_editor.set_text("ONE", cx));
2667                    search_view.search(cx);
2668                });
2669            })
2670            .unwrap();
2671
2672        cx.background_executor.run_until_parked();
2673        window
2674            .update(cx, |_, cx| {
2675                search_view.update(cx, |search_view, cx| {
2676                    search_view
2677                        .query_editor
2678                        .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2679                    search_view.search(cx);
2680                });
2681            })
2682            .unwrap();
2683        cx.background_executor.run_until_parked();
2684        window
2685            .update(cx, |_, cx| {
2686                search_view.update(cx, |search_view, cx| {
2687                    search_view
2688                        .query_editor
2689                        .update(cx, |query_editor, cx| query_editor.set_text("THREE", cx));
2690                    search_view.search(cx);
2691                })
2692            })
2693            .unwrap();
2694        cx.background_executor.run_until_parked();
2695        window
2696            .update(cx, |_, cx| {
2697                search_view.update(cx, |search_view, cx| {
2698                    search_view.query_editor.update(cx, |query_editor, cx| {
2699                        query_editor.set_text("JUST_TEXT_INPUT", cx)
2700                    });
2701                })
2702            })
2703            .unwrap();
2704        cx.background_executor.run_until_parked();
2705
2706        // Ensure that the latest input with search settings is active.
2707        window
2708            .update(cx, |_, cx| {
2709                search_view.update(cx, |search_view, cx| {
2710                    assert_eq!(
2711                        search_view.query_editor.read(cx).text(cx),
2712                        "JUST_TEXT_INPUT"
2713                    );
2714                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2715                });
2716            })
2717            .unwrap();
2718
2719        // Next history query after the latest should set the query to the empty string.
2720        window
2721            .update(cx, |_, cx| {
2722                search_bar.update(cx, |search_bar, cx| {
2723                    search_bar.next_history_query(&NextHistoryQuery, cx);
2724                })
2725            })
2726            .unwrap();
2727        window
2728            .update(cx, |_, cx| {
2729                search_view.update(cx, |search_view, cx| {
2730                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2731                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2732                });
2733            })
2734            .unwrap();
2735        window
2736            .update(cx, |_, cx| {
2737                search_bar.update(cx, |search_bar, cx| {
2738                    search_bar.next_history_query(&NextHistoryQuery, cx);
2739                })
2740            })
2741            .unwrap();
2742        window
2743            .update(cx, |_, cx| {
2744                search_view.update(cx, |search_view, cx| {
2745                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2746                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2747                });
2748            })
2749            .unwrap();
2750
2751        // First previous query for empty current query should set the query to the latest submitted one.
2752        window
2753            .update(cx, |_, cx| {
2754                search_bar.update(cx, |search_bar, cx| {
2755                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2756                });
2757            })
2758            .unwrap();
2759        window
2760            .update(cx, |_, cx| {
2761                search_view.update(cx, |search_view, cx| {
2762                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2763                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2764                });
2765            })
2766            .unwrap();
2767
2768        // Further previous items should go over the history in reverse order.
2769        window
2770            .update(cx, |_, cx| {
2771                search_bar.update(cx, |search_bar, cx| {
2772                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2773                });
2774            })
2775            .unwrap();
2776        window
2777            .update(cx, |_, cx| {
2778                search_view.update(cx, |search_view, cx| {
2779                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2780                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2781                });
2782            })
2783            .unwrap();
2784
2785        // Previous items should never go behind the first history item.
2786        window
2787            .update(cx, |_, cx| {
2788                search_bar.update(cx, |search_bar, cx| {
2789                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2790                });
2791            })
2792            .unwrap();
2793        window
2794            .update(cx, |_, cx| {
2795                search_view.update(cx, |search_view, cx| {
2796                    assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2797                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2798                });
2799            })
2800            .unwrap();
2801        window
2802            .update(cx, |_, cx| {
2803                search_bar.update(cx, |search_bar, cx| {
2804                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2805                });
2806            })
2807            .unwrap();
2808        window
2809            .update(cx, |_, cx| {
2810                search_view.update(cx, |search_view, cx| {
2811                    assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2812                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2813                });
2814            })
2815            .unwrap();
2816
2817        // Next items should go over the history in the original order.
2818        window
2819            .update(cx, |_, cx| {
2820                search_bar.update(cx, |search_bar, cx| {
2821                    search_bar.next_history_query(&NextHistoryQuery, cx);
2822                });
2823            })
2824            .unwrap();
2825        window
2826            .update(cx, |_, cx| {
2827                search_view.update(cx, |search_view, cx| {
2828                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2829                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2830                });
2831            })
2832            .unwrap();
2833
2834        window
2835            .update(cx, |_, cx| {
2836                search_view.update(cx, |search_view, cx| {
2837                    search_view
2838                        .query_editor
2839                        .update(cx, |query_editor, cx| query_editor.set_text("TWO_NEW", cx));
2840                    search_view.search(cx);
2841                });
2842            })
2843            .unwrap();
2844        cx.background_executor.run_until_parked();
2845        window
2846            .update(cx, |_, cx| {
2847                search_view.update(cx, |search_view, cx| {
2848                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2849                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2850                });
2851            })
2852            .unwrap();
2853
2854        // New search input should add another entry to history and move the selection to the end of the history.
2855        window
2856            .update(cx, |_, cx| {
2857                search_bar.update(cx, |search_bar, cx| {
2858                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2859                });
2860            })
2861            .unwrap();
2862        window
2863            .update(cx, |_, cx| {
2864                search_view.update(cx, |search_view, cx| {
2865                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2866                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2867                });
2868            })
2869            .unwrap();
2870        window
2871            .update(cx, |_, cx| {
2872                search_bar.update(cx, |search_bar, cx| {
2873                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2874                });
2875            })
2876            .unwrap();
2877        window
2878            .update(cx, |_, cx| {
2879                search_view.update(cx, |search_view, cx| {
2880                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2881                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2882                });
2883            })
2884            .unwrap();
2885        window
2886            .update(cx, |_, cx| {
2887                search_bar.update(cx, |search_bar, cx| {
2888                    search_bar.next_history_query(&NextHistoryQuery, cx);
2889                });
2890            })
2891            .unwrap();
2892        window
2893            .update(cx, |_, cx| {
2894                search_view.update(cx, |search_view, cx| {
2895                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2896                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2897                });
2898            })
2899            .unwrap();
2900        window
2901            .update(cx, |_, cx| {
2902                search_bar.update(cx, |search_bar, cx| {
2903                    search_bar.next_history_query(&NextHistoryQuery, cx);
2904                });
2905            })
2906            .unwrap();
2907        window
2908            .update(cx, |_, cx| {
2909                search_view.update(cx, |search_view, cx| {
2910                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2911                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2912                });
2913            })
2914            .unwrap();
2915        window
2916            .update(cx, |_, cx| {
2917                search_bar.update(cx, |search_bar, cx| {
2918                    search_bar.next_history_query(&NextHistoryQuery, cx);
2919                });
2920            })
2921            .unwrap();
2922        window
2923            .update(cx, |_, cx| {
2924                search_view.update(cx, |search_view, cx| {
2925                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2926                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2927                });
2928            })
2929            .unwrap();
2930    }
2931
2932    #[gpui::test]
2933    async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) {
2934        init_test(cx);
2935
2936        let fs = FakeFs::new(cx.background_executor.clone());
2937        fs.insert_tree(
2938            "/dir",
2939            json!({
2940                "one.rs": "const ONE: usize = 1;",
2941            }),
2942        )
2943        .await;
2944        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2945        let worktree_id = project.update(cx, |this, cx| {
2946            this.worktrees(cx).next().unwrap().read(cx).id()
2947        });
2948
2949        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2950        let workspace = window.root(cx).unwrap();
2951
2952        let panes: Vec<_> = window
2953            .update(cx, |this, _| this.panes().to_owned())
2954            .unwrap();
2955
2956        let search_bar_1 = window.build_view(cx, |_| ProjectSearchBar::new());
2957        let search_bar_2 = window.build_view(cx, |_| ProjectSearchBar::new());
2958
2959        assert_eq!(panes.len(), 1);
2960        let first_pane = panes.get(0).cloned().unwrap();
2961        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
2962        window
2963            .update(cx, |workspace, cx| {
2964                workspace.open_path(
2965                    (worktree_id, "one.rs"),
2966                    Some(first_pane.downgrade()),
2967                    true,
2968                    cx,
2969                )
2970            })
2971            .unwrap()
2972            .await
2973            .unwrap();
2974        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
2975
2976        // Add a project search item to the first pane
2977        window
2978            .update(cx, {
2979                let search_bar = search_bar_1.clone();
2980                let pane = first_pane.clone();
2981                move |workspace, cx| {
2982                    pane.update(cx, move |pane, cx| {
2983                        pane.toolbar()
2984                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2985                    });
2986
2987                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2988                }
2989            })
2990            .unwrap();
2991        let search_view_1 = cx.read(|cx| {
2992            workspace
2993                .read(cx)
2994                .active_item(cx)
2995                .and_then(|item| item.downcast::<ProjectSearchView>())
2996                .expect("Search view expected to appear after new search event trigger")
2997        });
2998
2999        let second_pane = window
3000            .update(cx, |workspace, cx| {
3001                workspace.split_and_clone(first_pane.clone(), workspace::SplitDirection::Right, cx)
3002            })
3003            .unwrap()
3004            .unwrap();
3005        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3006
3007        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3008        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3009
3010        // Add a project search item to the second pane
3011        window
3012            .update(cx, {
3013                let search_bar = search_bar_2.clone();
3014                let pane = second_pane.clone();
3015                move |workspace, cx| {
3016                    assert_eq!(workspace.panes().len(), 2);
3017                    pane.update(cx, move |pane, cx| {
3018                        pane.toolbar()
3019                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3020                    });
3021
3022                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
3023                }
3024            })
3025            .unwrap();
3026
3027        let search_view_2 = cx.read(|cx| {
3028            workspace
3029                .read(cx)
3030                .active_item(cx)
3031                .and_then(|item| item.downcast::<ProjectSearchView>())
3032                .expect("Search view expected to appear after new search event trigger")
3033        });
3034
3035        cx.run_until_parked();
3036        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
3037        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3038
3039        let update_search_view =
3040            |search_view: &View<ProjectSearchView>, query: &str, cx: &mut TestAppContext| {
3041                window
3042                    .update(cx, |_, cx| {
3043                        search_view.update(cx, |search_view, cx| {
3044                            search_view
3045                                .query_editor
3046                                .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
3047                            search_view.search(cx);
3048                        });
3049                    })
3050                    .unwrap();
3051            };
3052
3053        let active_query =
3054            |search_view: &View<ProjectSearchView>, cx: &mut TestAppContext| -> String {
3055                window
3056                    .update(cx, |_, cx| {
3057                        search_view.update(cx, |search_view, cx| {
3058                            search_view.query_editor.read(cx).text(cx).to_string()
3059                        })
3060                    })
3061                    .unwrap()
3062            };
3063
3064        let select_prev_history_item =
3065            |search_bar: &View<ProjectSearchBar>, cx: &mut TestAppContext| {
3066                window
3067                    .update(cx, |_, cx| {
3068                        search_bar.update(cx, |search_bar, cx| {
3069                            search_bar.previous_history_query(&PreviousHistoryQuery, cx);
3070                        })
3071                    })
3072                    .unwrap();
3073            };
3074
3075        let select_next_history_item =
3076            |search_bar: &View<ProjectSearchBar>, cx: &mut TestAppContext| {
3077                window
3078                    .update(cx, |_, cx| {
3079                        search_bar.update(cx, |search_bar, cx| {
3080                            search_bar.next_history_query(&NextHistoryQuery, cx);
3081                        })
3082                    })
3083                    .unwrap();
3084            };
3085
3086        update_search_view(&search_view_1, "ONE", cx);
3087        cx.background_executor.run_until_parked();
3088
3089        update_search_view(&search_view_2, "TWO", cx);
3090        cx.background_executor.run_until_parked();
3091
3092        assert_eq!(active_query(&search_view_1, cx), "ONE");
3093        assert_eq!(active_query(&search_view_2, cx), "TWO");
3094
3095        // Selecting previous history item should select the query from search view 1.
3096        select_prev_history_item(&search_bar_2, cx);
3097        assert_eq!(active_query(&search_view_2, cx), "ONE");
3098
3099        // Selecting the previous history item should not change the query as it is already the first item.
3100        select_prev_history_item(&search_bar_2, cx);
3101        assert_eq!(active_query(&search_view_2, cx), "ONE");
3102
3103        // Changing the query in search view 2 should not affect the history of search view 1.
3104        assert_eq!(active_query(&search_view_1, cx), "ONE");
3105
3106        // Deploying a new search in search view 2
3107        update_search_view(&search_view_2, "THREE", cx);
3108        cx.background_executor.run_until_parked();
3109
3110        select_next_history_item(&search_bar_2, cx);
3111        assert_eq!(active_query(&search_view_2, cx), "");
3112
3113        select_prev_history_item(&search_bar_2, cx);
3114        assert_eq!(active_query(&search_view_2, cx), "THREE");
3115
3116        select_prev_history_item(&search_bar_2, cx);
3117        assert_eq!(active_query(&search_view_2, cx), "TWO");
3118
3119        select_prev_history_item(&search_bar_2, cx);
3120        assert_eq!(active_query(&search_view_2, cx), "ONE");
3121
3122        select_prev_history_item(&search_bar_2, cx);
3123        assert_eq!(active_query(&search_view_2, cx), "ONE");
3124
3125        // Search view 1 should now see the query from search view 2.
3126        assert_eq!(active_query(&search_view_1, cx), "ONE");
3127
3128        select_next_history_item(&search_bar_2, cx);
3129        assert_eq!(active_query(&search_view_2, cx), "TWO");
3130
3131        // Here is the new query from search view 2
3132        select_next_history_item(&search_bar_2, cx);
3133        assert_eq!(active_query(&search_view_2, cx), "THREE");
3134
3135        select_next_history_item(&search_bar_2, cx);
3136        assert_eq!(active_query(&search_view_2, cx), "");
3137
3138        select_next_history_item(&search_bar_1, cx);
3139        assert_eq!(active_query(&search_view_1, cx), "TWO");
3140
3141        select_next_history_item(&search_bar_1, cx);
3142        assert_eq!(active_query(&search_view_1, cx), "THREE");
3143
3144        select_next_history_item(&search_bar_1, cx);
3145        assert_eq!(active_query(&search_view_1, cx), "");
3146    }
3147
3148    #[gpui::test]
3149    async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
3150        init_test(cx);
3151
3152        // Setup 2 panes, both with a file open and one with a project search.
3153        let fs = FakeFs::new(cx.background_executor.clone());
3154        fs.insert_tree(
3155            "/dir",
3156            json!({
3157                "one.rs": "const ONE: usize = 1;",
3158            }),
3159        )
3160        .await;
3161        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3162        let worktree_id = project.update(cx, |this, cx| {
3163            this.worktrees(cx).next().unwrap().read(cx).id()
3164        });
3165        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
3166        let panes: Vec<_> = window
3167            .update(cx, |this, _| this.panes().to_owned())
3168            .unwrap();
3169        assert_eq!(panes.len(), 1);
3170        let first_pane = panes.get(0).cloned().unwrap();
3171        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3172        window
3173            .update(cx, |workspace, cx| {
3174                workspace.open_path(
3175                    (worktree_id, "one.rs"),
3176                    Some(first_pane.downgrade()),
3177                    true,
3178                    cx,
3179                )
3180            })
3181            .unwrap()
3182            .await
3183            .unwrap();
3184        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3185        let second_pane = window
3186            .update(cx, |workspace, cx| {
3187                workspace.split_and_clone(first_pane.clone(), workspace::SplitDirection::Right, cx)
3188            })
3189            .unwrap()
3190            .unwrap();
3191        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3192        assert!(window
3193            .update(cx, |_, cx| second_pane
3194                .focus_handle(cx)
3195                .contains_focused(cx))
3196            .unwrap());
3197        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
3198        window
3199            .update(cx, {
3200                let search_bar = search_bar.clone();
3201                let pane = first_pane.clone();
3202                move |workspace, cx| {
3203                    assert_eq!(workspace.panes().len(), 2);
3204                    pane.update(cx, move |pane, cx| {
3205                        pane.toolbar()
3206                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3207                    });
3208                }
3209            })
3210            .unwrap();
3211
3212        // Add a project search item to the second pane
3213        window
3214            .update(cx, {
3215                let search_bar = search_bar.clone();
3216                let pane = second_pane.clone();
3217                move |workspace, cx| {
3218                    assert_eq!(workspace.panes().len(), 2);
3219                    pane.update(cx, move |pane, cx| {
3220                        pane.toolbar()
3221                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3222                    });
3223
3224                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
3225                }
3226            })
3227            .unwrap();
3228
3229        cx.run_until_parked();
3230        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3231        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3232
3233        // Focus the first pane
3234        window
3235            .update(cx, |workspace, cx| {
3236                assert_eq!(workspace.active_pane(), &second_pane);
3237                second_pane.update(cx, |this, cx| {
3238                    assert_eq!(this.active_item_index(), 1);
3239                    this.activate_prev_item(false, cx);
3240                    assert_eq!(this.active_item_index(), 0);
3241                });
3242                workspace.activate_pane_in_direction(workspace::SplitDirection::Left, cx);
3243            })
3244            .unwrap();
3245        window
3246            .update(cx, |workspace, cx| {
3247                assert_eq!(workspace.active_pane(), &first_pane);
3248                assert_eq!(first_pane.read(cx).items_len(), 1);
3249                assert_eq!(second_pane.read(cx).items_len(), 2);
3250            })
3251            .unwrap();
3252
3253        // Deploy a new search
3254        cx.dispatch_action(window.into(), DeploySearch::find());
3255
3256        // Both panes should now have a project search in them
3257        window
3258            .update(cx, |workspace, cx| {
3259                assert_eq!(workspace.active_pane(), &first_pane);
3260                first_pane.update(cx, |this, _| {
3261                    assert_eq!(this.active_item_index(), 1);
3262                    assert_eq!(this.items_len(), 2);
3263                });
3264                second_pane.update(cx, |this, cx| {
3265                    assert!(!cx.focus_handle().contains_focused(cx));
3266                    assert_eq!(this.items_len(), 2);
3267                });
3268            })
3269            .unwrap();
3270
3271        // Focus the second pane's non-search item
3272        window
3273            .update(cx, |_workspace, cx| {
3274                second_pane.update(cx, |pane, cx| pane.activate_next_item(true, cx));
3275            })
3276            .unwrap();
3277
3278        // Deploy a new search
3279        cx.dispatch_action(window.into(), DeploySearch::find());
3280
3281        // The project search view should now be focused in the second pane
3282        // And the number of items should be unchanged.
3283        window
3284            .update(cx, |_workspace, cx| {
3285                second_pane.update(cx, |pane, _cx| {
3286                    assert!(pane
3287                        .active_item()
3288                        .unwrap()
3289                        .downcast::<ProjectSearchView>()
3290                        .is_some());
3291
3292                    assert_eq!(pane.items_len(), 2);
3293                });
3294            })
3295            .unwrap();
3296    }
3297
3298    #[gpui::test]
3299    async fn test_scroll_search_results_to_top(cx: &mut TestAppContext) {
3300        init_test(cx);
3301
3302        // We need many lines in the search results to be able to scroll the window
3303        let fs = FakeFs::new(cx.background_executor.clone());
3304        fs.insert_tree(
3305            "/dir",
3306            json!({
3307                "1.txt": "\n\n\n\n\n A \n\n\n\n\n",
3308                "2.txt": "\n\n\n\n\n A \n\n\n\n\n",
3309                "3.rs": "\n\n\n\n\n A \n\n\n\n\n",
3310                "4.rs": "\n\n\n\n\n A \n\n\n\n\n",
3311                "5.rs": "\n\n\n\n\n A \n\n\n\n\n",
3312                "6.rs": "\n\n\n\n\n A \n\n\n\n\n",
3313                "7.rs": "\n\n\n\n\n A \n\n\n\n\n",
3314                "8.rs": "\n\n\n\n\n A \n\n\n\n\n",
3315                "9.rs": "\n\n\n\n\n A \n\n\n\n\n",
3316                "a.rs": "\n\n\n\n\n A \n\n\n\n\n",
3317                "b.rs": "\n\n\n\n\n B \n\n\n\n\n",
3318                "c.rs": "\n\n\n\n\n B \n\n\n\n\n",
3319                "d.rs": "\n\n\n\n\n B \n\n\n\n\n",
3320                "e.rs": "\n\n\n\n\n B \n\n\n\n\n",
3321                "f.rs": "\n\n\n\n\n B \n\n\n\n\n",
3322                "g.rs": "\n\n\n\n\n B \n\n\n\n\n",
3323                "h.rs": "\n\n\n\n\n B \n\n\n\n\n",
3324                "i.rs": "\n\n\n\n\n B \n\n\n\n\n",
3325                "j.rs": "\n\n\n\n\n B \n\n\n\n\n",
3326                "k.rs": "\n\n\n\n\n B \n\n\n\n\n",
3327            }),
3328        )
3329        .await;
3330        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3331        let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3332        let workspace = window.root(cx).unwrap();
3333        let search = cx.new_model(|cx| ProjectSearch::new(project, cx));
3334        let search_view = cx.add_window(|cx| {
3335            ProjectSearchView::new(workspace.downgrade(), search.clone(), cx, None)
3336        });
3337
3338        // First search
3339        perform_search(search_view, "A", cx);
3340        search_view
3341            .update(cx, |search_view, cx| {
3342                search_view.results_editor.update(cx, |results_editor, cx| {
3343                    // Results are correct and scrolled to the top
3344                    assert_eq!(
3345                        results_editor.display_text(cx).match_indices(" A ").count(),
3346                        10
3347                    );
3348                    assert_eq!(results_editor.scroll_position(cx), Point::default());
3349
3350                    // Scroll results all the way down
3351                    results_editor.scroll(Point::new(0., f32::MAX), Some(Axis::Vertical), cx);
3352                });
3353            })
3354            .expect("unable to update search view");
3355
3356        // Second search
3357        perform_search(search_view, "B", cx);
3358        search_view
3359            .update(cx, |search_view, cx| {
3360                search_view.results_editor.update(cx, |results_editor, cx| {
3361                    // Results are correct...
3362                    assert_eq!(
3363                        results_editor.display_text(cx).match_indices(" B ").count(),
3364                        10
3365                    );
3366                    // ...and scrolled back to the top
3367                    assert_eq!(results_editor.scroll_position(cx), Point::default());
3368                });
3369            })
3370            .expect("unable to update search view");
3371    }
3372
3373    fn init_test(cx: &mut TestAppContext) {
3374        cx.update(|cx| {
3375            let settings = SettingsStore::test(cx);
3376            cx.set_global(settings);
3377
3378            theme::init(theme::LoadThemes::JustBase, cx);
3379
3380            language::init(cx);
3381            client::init_settings(cx);
3382            editor::init(cx);
3383            workspace::init_settings(cx);
3384            Project::init_settings(cx);
3385            super::init(cx);
3386        });
3387    }
3388
3389    fn perform_search(
3390        search_view: WindowHandle<ProjectSearchView>,
3391        text: impl Into<Arc<str>>,
3392        cx: &mut TestAppContext,
3393    ) {
3394        search_view
3395            .update(cx, |search_view, cx| {
3396                search_view
3397                    .query_editor
3398                    .update(cx, |query_editor, cx| query_editor.set_text(text, cx));
3399                search_view.search(cx);
3400            })
3401            .unwrap();
3402        cx.background_executor.run_until_parked();
3403    }
3404}