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