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