project_search.rs

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