project_search.rs

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