project_search.rs

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