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