project_search.rs

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