project_search.rs

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