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, Global, Hsla, InteractiveElement,
  17    IntoElement, Model, ModelContext, ParentElement, Point, Render, SharedString, Styled,
  18    Subscription, Task, TextStyle, UpdateGlobal, View, ViewContext, VisualContext, WeakModel,
  19    WeakView, WhiteSpace, WindowContext,
  20};
  21use menu::Confirm;
  22use project::{search::SearchQuery, search_history::SearchHistoryCursor, Project, ProjectPath};
  23use settings::Settings;
  24use smol::stream::StreamExt;
  25use std::{
  26    any::{Any, TypeId},
  27    mem,
  28    ops::{Not, Range},
  29    path::Path,
  30};
  31use theme::ThemeSettings;
  32use ui::{
  33    h_flex, prelude::*, v_flex, Icon, IconButton, IconName, Label, LabelCommon, LabelSize,
  34    Selectable, Tooltip,
  35};
  36use util::paths::PathMatcher;
  37use workspace::{
  38    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
  39    searchable::{Direction, SearchableItem, SearchableItemHandle},
  40    DeploySearch, ItemNavHistory, NewSearch, Pane, ToolbarItemEvent, ToolbarItemLocation,
  41    ToolbarItemView, Workspace, WorkspaceId,
  42};
  43
  44const MIN_INPUT_WIDTH_REMS: f32 = 15.;
  45const MAX_INPUT_WIDTH_REMS: f32 = 30.;
  46
  47actions!(
  48    project_search,
  49    [SearchInNew, ToggleFocus, NextField, ToggleFilters]
  50);
  51
  52#[derive(Default)]
  53struct ActiveSettings(HashMap<WeakModel<Project>, ProjectSearchSettings>);
  54
  55impl Global for ActiveSettings {}
  56
  57pub fn init(cx: &mut AppContext) {
  58    cx.set_global(ActiveSettings::default());
  59    cx.observe_new_views(|workspace: &mut Workspace, _cx| {
  60        register_workspace_action(workspace, move |search_bar, _: &FocusSearch, cx| {
  61            search_bar.focus_search(cx);
  62        });
  63        register_workspace_action(workspace, move |search_bar, _: &ToggleFilters, cx| {
  64            search_bar.toggle_filters(cx);
  65        });
  66        register_workspace_action(workspace, move |search_bar, _: &ToggleCaseSensitive, cx| {
  67            search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
  68        });
  69        register_workspace_action(workspace, move |search_bar, _: &ToggleWholeWord, cx| {
  70            search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
  71        });
  72        register_workspace_action(workspace, move |search_bar, _: &ToggleRegex, cx| {
  73            search_bar.toggle_search_option(SearchOptions::REGEX, cx);
  74        });
  75        register_workspace_action(workspace, move |search_bar, action: &ToggleReplace, cx| {
  76            search_bar.toggle_replace(action, cx)
  77        });
  78        register_workspace_action(
  79            workspace,
  80            move |search_bar, action: &SelectPrevMatch, cx| {
  81                search_bar.select_prev_match(action, cx)
  82            },
  83        );
  84        register_workspace_action(
  85            workspace,
  86            move |search_bar, action: &SelectNextMatch, cx| {
  87                search_bar.select_next_match(action, cx)
  88            },
  89        );
  90
  91        // Only handle search_in_new if there is a search present
  92        register_workspace_action_for_present_search(workspace, |workspace, action, cx| {
  93            ProjectSearchView::search_in_new(workspace, action, cx)
  94        });
  95
  96        // Both on present and dismissed search, we need to unconditionally handle those actions to focus from the editor.
  97        workspace.register_action(move |workspace, action: &DeploySearch, cx| {
  98            if workspace.has_active_modal(cx) {
  99                cx.propagate();
 100                return;
 101            }
 102            ProjectSearchView::deploy_search(workspace, action, cx);
 103            cx.notify();
 104        });
 105        workspace.register_action(move |workspace, action: &NewSearch, cx| {
 106            if workspace.has_active_modal(cx) {
 107                cx.propagate();
 108                return;
 109            }
 110            ProjectSearchView::new_search(workspace, action, cx);
 111            cx.notify();
 112        });
 113    })
 114    .detach();
 115}
 116
 117struct ProjectSearch {
 118    project: Model<Project>,
 119    excerpts: Model<MultiBuffer>,
 120    pending_search: Option<Task<Option<()>>>,
 121    match_ranges: Vec<Range<Anchor>>,
 122    active_query: Option<SearchQuery>,
 123    last_search_query_text: Option<String>,
 124    search_id: usize,
 125    no_results: Option<bool>,
 126    limit_reached: bool,
 127    search_history_cursor: SearchHistoryCursor,
 128}
 129
 130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 131enum InputPanel {
 132    Query,
 133    Exclude,
 134    Include,
 135}
 136
 137pub struct ProjectSearchView {
 138    focus_handle: FocusHandle,
 139    model: Model<ProjectSearch>,
 140    query_editor: View<Editor>,
 141    replacement_editor: View<Editor>,
 142    results_editor: View<Editor>,
 143    search_options: SearchOptions,
 144    panels_with_errors: HashSet<InputPanel>,
 145    active_match_index: Option<usize>,
 146    search_id: usize,
 147    query_editor_was_focused: bool,
 148    included_files_editor: View<Editor>,
 149    excluded_files_editor: View<Editor>,
 150    filters_enabled: bool,
 151    replace_enabled: bool,
 152    _subscriptions: Vec<Subscription>,
 153}
 154
 155#[derive(Debug, Clone)]
 156struct ProjectSearchSettings {
 157    search_options: SearchOptions,
 158    filters_enabled: bool,
 159}
 160
 161pub struct ProjectSearchBar {
 162    active_project_search: Option<View<ProjectSearchView>>,
 163    subscription: Option<Subscription>,
 164}
 165
 166impl ProjectSearch {
 167    fn new(project: Model<Project>, cx: &mut ModelContext<Self>) -> Self {
 168        let replica_id = project.read(cx).replica_id();
 169        let capability = project.read(cx).capability();
 170
 171        Self {
 172            project,
 173            excerpts: cx.new_model(|_| MultiBuffer::new(replica_id, capability)),
 174            pending_search: Default::default(),
 175            match_ranges: Default::default(),
 176            active_query: None,
 177            last_search_query_text: None,
 178            search_id: 0,
 179            no_results: None,
 180            limit_reached: false,
 181            search_history_cursor: Default::default(),
 182        }
 183    }
 184
 185    fn clone(&self, cx: &mut ModelContext<Self>) -> Model<Self> {
 186        cx.new_model(|cx| Self {
 187            project: self.project.clone(),
 188            excerpts: self
 189                .excerpts
 190                .update(cx, |excerpts, cx| cx.new_model(|cx| excerpts.clone(cx))),
 191            pending_search: Default::default(),
 192            match_ranges: self.match_ranges.clone(),
 193            active_query: self.active_query.clone(),
 194            last_search_query_text: self.last_search_query_text.clone(),
 195            search_id: self.search_id,
 196            no_results: self.no_results,
 197            limit_reached: self.limit_reached,
 198            search_history_cursor: self.search_history_cursor.clone(),
 199        })
 200    }
 201
 202    fn search(&mut self, query: SearchQuery, cx: &mut ModelContext<Self>) {
 203        let search = self.project.update(cx, |project, cx| {
 204            project
 205                .search_history_mut()
 206                .add(&mut self.search_history_cursor, query.as_str().to_string());
 207            project.search(query.clone(), cx)
 208        });
 209        self.last_search_query_text = Some(query.as_str().to_string());
 210        self.search_id += 1;
 211        self.active_query = Some(query);
 212        self.match_ranges.clear();
 213        self.pending_search = Some(cx.spawn(|this, mut cx| async move {
 214            let mut matches = search;
 215            let this = this.upgrade()?;
 216            this.update(&mut cx, |this, cx| {
 217                this.match_ranges.clear();
 218                this.excerpts.update(cx, |this, cx| this.clear(cx));
 219                this.no_results = Some(true);
 220                this.limit_reached = false;
 221            })
 222            .ok()?;
 223
 224            let mut limit_reached = false;
 225            while let Some(result) = matches.next().await {
 226                match result {
 227                    project::SearchResult::Buffer { buffer, ranges } => {
 228                        let mut match_ranges = this
 229                            .update(&mut cx, |this, cx| {
 230                                this.no_results = Some(false);
 231                                this.excerpts.update(cx, |excerpts, cx| {
 232                                    excerpts.stream_excerpts_with_context_lines(
 233                                        buffer,
 234                                        ranges,
 235                                        editor::DEFAULT_MULTIBUFFER_CONTEXT,
 236                                        cx,
 237                                    )
 238                                })
 239                            })
 240                            .ok()?;
 241
 242                        while let Some(range) = match_ranges.next().await {
 243                            this.update(&mut cx, |this, _| this.match_ranges.push(range))
 244                                .ok()?;
 245                        }
 246                        this.update(&mut cx, |_, cx| cx.notify()).ok()?;
 247                    }
 248                    project::SearchResult::LimitReached => {
 249                        limit_reached = true;
 250                    }
 251                }
 252            }
 253
 254            this.update(&mut cx, |this, cx| {
 255                this.limit_reached = limit_reached;
 256                this.pending_search.take();
 257                cx.notify();
 258            })
 259            .ok()?;
 260
 261            None
 262        }));
 263        cx.notify();
 264    }
 265}
 266
 267#[derive(Clone, Debug, PartialEq, Eq)]
 268pub enum ViewEvent {
 269    UpdateTab,
 270    Activate,
 271    EditorEvent(editor::EditorEvent),
 272    Dismiss,
 273}
 274
 275impl EventEmitter<ViewEvent> for ProjectSearchView {}
 276
 277impl Render for ProjectSearchView {
 278    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 279        if self.has_matches() {
 280            div()
 281                .flex_1()
 282                .size_full()
 283                .track_focus(&self.focus_handle)
 284                .child(self.results_editor.clone())
 285        } else {
 286            let model = self.model.read(cx);
 287            let has_no_results = model.no_results.unwrap_or(false);
 288            let is_search_underway = model.pending_search.is_some();
 289            let major_text = if is_search_underway {
 290                Label::new("Searching...")
 291            } else if has_no_results {
 292                Label::new("No results")
 293            } else {
 294                Label::new("Search all files")
 295            };
 296
 297            let major_text = div().justify_center().max_w_96().child(major_text);
 298
 299            let minor_text: Option<SharedString> = if let Some(no_results) = model.no_results {
 300                if model.pending_search.is_none() && no_results {
 301                    Some("No results found in this project for the provided query".into())
 302                } else {
 303                    None
 304                }
 305            } else {
 306                Some(self.landing_text_minor())
 307            };
 308            let minor_text = minor_text.map(|text| {
 309                div()
 310                    .items_center()
 311                    .max_w_96()
 312                    .child(Label::new(text).size(LabelSize::Small))
 313            });
 314            v_flex()
 315                .flex_1()
 316                .size_full()
 317                .justify_center()
 318                .bg(cx.theme().colors().editor_background)
 319                .track_focus(&self.focus_handle)
 320                .child(
 321                    h_flex()
 322                        .size_full()
 323                        .justify_center()
 324                        .child(h_flex().flex_1())
 325                        .child(v_flex().child(major_text).children(minor_text))
 326                        .child(h_flex().flex_1()),
 327                )
 328        }
 329    }
 330}
 331
 332impl FocusableView for ProjectSearchView {
 333    fn focus_handle(&self, _: &AppContext) -> gpui::FocusHandle {
 334        self.focus_handle.clone()
 335    }
 336}
 337
 338impl Item for ProjectSearchView {
 339    type Event = ViewEvent;
 340    fn tab_tooltip_text(&self, cx: &AppContext) -> Option<SharedString> {
 341        let query_text = self.query_editor.read(cx).text(cx);
 342
 343        query_text
 344            .is_empty()
 345            .not()
 346            .then(|| query_text.into())
 347            .or_else(|| Some("Project Search".into()))
 348    }
 349
 350    fn act_as_type<'a>(
 351        &'a self,
 352        type_id: TypeId,
 353        self_handle: &'a View<Self>,
 354        _: &'a AppContext,
 355    ) -> Option<AnyView> {
 356        if type_id == TypeId::of::<Self>() {
 357            Some(self_handle.clone().into())
 358        } else if type_id == TypeId::of::<Editor>() {
 359            Some(self.results_editor.clone().into())
 360        } else {
 361            None
 362        }
 363    }
 364
 365    fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
 366        self.results_editor
 367            .update(cx, |editor, cx| editor.deactivated(cx));
 368    }
 369
 370    fn tab_content(&self, params: TabContentParams, cx: &WindowContext<'_>) -> AnyElement {
 371        let last_query: Option<SharedString> = self
 372            .model
 373            .read(cx)
 374            .last_search_query_text
 375            .as_ref()
 376            .map(|query| {
 377                let query = query.replace('\n', "");
 378                let query_text = util::truncate_and_trailoff(&query, MAX_TAB_TITLE_LEN);
 379                query_text.into()
 380            });
 381        let tab_name = last_query
 382            .filter(|query| !query.is_empty())
 383            .unwrap_or_else(|| "Project Search".into());
 384        h_flex()
 385            .gap_2()
 386            .child(
 387                Icon::new(IconName::MagnifyingGlass).color(if params.selected {
 388                    Color::Default
 389                } else {
 390                    Color::Muted
 391                }),
 392            )
 393            .child(Label::new(tab_name).color(if params.selected {
 394                Color::Default
 395            } else {
 396                Color::Muted
 397            }))
 398            .into_any()
 399    }
 400
 401    fn telemetry_event_text(&self) -> Option<&'static str> {
 402        Some("project search")
 403    }
 404
 405    fn for_each_project_item(
 406        &self,
 407        cx: &AppContext,
 408        f: &mut dyn FnMut(EntityId, &dyn project::Item),
 409    ) {
 410        self.results_editor.for_each_project_item(cx, f)
 411    }
 412
 413    fn is_singleton(&self, _: &AppContext) -> bool {
 414        false
 415    }
 416
 417    fn can_save(&self, _: &AppContext) -> bool {
 418        true
 419    }
 420
 421    fn is_dirty(&self, cx: &AppContext) -> bool {
 422        self.results_editor.read(cx).is_dirty(cx)
 423    }
 424
 425    fn has_conflict(&self, cx: &AppContext) -> bool {
 426        self.results_editor.read(cx).has_conflict(cx)
 427    }
 428
 429    fn save(
 430        &mut self,
 431        format: bool,
 432        project: Model<Project>,
 433        cx: &mut ViewContext<Self>,
 434    ) -> Task<anyhow::Result<()>> {
 435        self.results_editor
 436            .update(cx, |editor, cx| editor.save(format, project, cx))
 437    }
 438
 439    fn save_as(
 440        &mut self,
 441        _: Model<Project>,
 442        _: ProjectPath,
 443        _: &mut ViewContext<Self>,
 444    ) -> Task<anyhow::Result<()>> {
 445        unreachable!("save_as should not have been called")
 446    }
 447
 448    fn reload(
 449        &mut self,
 450        project: Model<Project>,
 451        cx: &mut ViewContext<Self>,
 452    ) -> Task<anyhow::Result<()>> {
 453        self.results_editor
 454            .update(cx, |editor, cx| editor.reload(project, cx))
 455    }
 456
 457    fn clone_on_split(
 458        &self,
 459        _workspace_id: WorkspaceId,
 460        cx: &mut ViewContext<Self>,
 461    ) -> Option<View<Self>>
 462    where
 463        Self: Sized,
 464    {
 465        let model = self.model.update(cx, |model, cx| model.clone(cx));
 466        Some(cx.new_view(|cx| Self::new(model, cx, None)))
 467    }
 468
 469    fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
 470        self.results_editor
 471            .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx));
 472    }
 473
 474    fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
 475        self.results_editor.update(cx, |editor, _| {
 476            editor.set_nav_history(Some(nav_history));
 477        });
 478    }
 479
 480    fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
 481        self.results_editor
 482            .update(cx, |editor, cx| editor.navigate(data, cx))
 483    }
 484
 485    fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
 486        match event {
 487            ViewEvent::UpdateTab => {
 488                f(ItemEvent::UpdateBreadcrumbs);
 489                f(ItemEvent::UpdateTab);
 490            }
 491            ViewEvent::EditorEvent(editor_event) => {
 492                Editor::to_item_events(editor_event, f);
 493            }
 494            ViewEvent::Dismiss => f(ItemEvent::CloseItem),
 495            _ => {}
 496        }
 497    }
 498
 499    fn breadcrumb_location(&self) -> ToolbarItemLocation {
 500        if self.has_matches() {
 501            ToolbarItemLocation::Secondary
 502        } else {
 503            ToolbarItemLocation::Hidden
 504        }
 505    }
 506
 507    fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
 508        self.results_editor.breadcrumbs(theme, cx)
 509    }
 510
 511    fn serialized_item_kind() -> Option<&'static str> {
 512        None
 513    }
 514
 515    fn deserialize(
 516        _project: Model<Project>,
 517        _workspace: WeakView<Workspace>,
 518        _workspace_id: workspace::WorkspaceId,
 519        _item_id: workspace::ItemId,
 520        _cx: &mut ViewContext<Pane>,
 521    ) -> Task<anyhow::Result<View<Self>>> {
 522        unimplemented!()
 523    }
 524}
 525
 526impl ProjectSearchView {
 527    fn toggle_filters(&mut self, cx: &mut ViewContext<Self>) {
 528        self.filters_enabled = !self.filters_enabled;
 529        ActiveSettings::update_global(cx, |settings, cx| {
 530            settings.0.insert(
 531                self.model.read(cx).project.downgrade(),
 532                self.current_settings(),
 533            );
 534        });
 535    }
 536
 537    fn current_settings(&self) -> ProjectSearchSettings {
 538        ProjectSearchSettings {
 539            search_options: self.search_options,
 540            filters_enabled: self.filters_enabled,
 541        }
 542    }
 543
 544    fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) {
 545        self.search_options.toggle(option);
 546        ActiveSettings::update_global(cx, |settings, cx| {
 547            settings.0.insert(
 548                self.model.read(cx).project.downgrade(),
 549                self.current_settings(),
 550            );
 551        });
 552    }
 553
 554    fn replace_next(&mut self, _: &ReplaceNext, cx: &mut ViewContext<Self>) {
 555        if self.model.read(cx).match_ranges.is_empty() {
 556            return;
 557        }
 558        let Some(active_index) = self.active_match_index else {
 559            return;
 560        };
 561
 562        let query = self.model.read(cx).active_query.clone();
 563        if let Some(query) = query {
 564            let query = query.with_replacement(self.replacement(cx));
 565
 566            // TODO: Do we need the clone here?
 567            let mat = self.model.read(cx).match_ranges[active_index].clone();
 568            self.results_editor.update(cx, |editor, cx| {
 569                editor.replace(&mat, &query, cx);
 570            });
 571            self.select_match(Direction::Next, cx)
 572        }
 573    }
 574    pub fn replacement(&self, cx: &AppContext) -> String {
 575        self.replacement_editor.read(cx).text(cx)
 576    }
 577    fn replace_all(&mut self, _: &ReplaceAll, cx: &mut ViewContext<Self>) {
 578        if self.active_match_index.is_none() {
 579            return;
 580        }
 581
 582        let Some(query) = self.model.read(cx).active_query.as_ref() else {
 583            return;
 584        };
 585        let query = query.clone().with_replacement(self.replacement(cx));
 586
 587        let match_ranges = self
 588            .model
 589            .update(cx, |model, _| mem::take(&mut model.match_ranges));
 590        if match_ranges.is_empty() {
 591            return;
 592        }
 593
 594        self.results_editor.update(cx, |editor, cx| {
 595            for item in &match_ranges {
 596                editor.replace(item, &query, cx);
 597            }
 598        });
 599
 600        self.model.update(cx, |model, _cx| {
 601            model.match_ranges = match_ranges;
 602        });
 603    }
 604
 605    fn new(
 606        model: Model<ProjectSearch>,
 607        cx: &mut ViewContext<Self>,
 608        settings: Option<ProjectSearchSettings>,
 609    ) -> Self {
 610        let project;
 611        let excerpts;
 612        let mut replacement_text = None;
 613        let mut query_text = String::new();
 614        let mut subscriptions = Vec::new();
 615
 616        // Read in settings if available
 617        let (mut options, filters_enabled) = if let Some(settings) = settings {
 618            (settings.search_options, settings.filters_enabled)
 619        } else {
 620            (SearchOptions::NONE, false)
 621        };
 622
 623        {
 624            let model = model.read(cx);
 625            project = model.project.clone();
 626            excerpts = model.excerpts.clone();
 627            if let Some(active_query) = model.active_query.as_ref() {
 628                query_text = active_query.as_str().to_string();
 629                replacement_text = active_query.replacement().map(ToOwned::to_owned);
 630                options = SearchOptions::from_query(active_query);
 631            }
 632        }
 633        subscriptions.push(cx.observe(&model, |this, _, cx| this.model_changed(cx)));
 634
 635        let query_editor = cx.new_view(|cx| {
 636            let mut editor = Editor::single_line(cx);
 637            editor.set_placeholder_text("Search all files..", cx);
 638            editor.set_text(query_text, cx);
 639            editor
 640        });
 641        // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
 642        subscriptions.push(
 643            cx.subscribe(&query_editor, |_, _, event: &EditorEvent, cx| {
 644                cx.emit(ViewEvent::EditorEvent(event.clone()))
 645            }),
 646        );
 647        let replacement_editor = cx.new_view(|cx| {
 648            let mut editor = Editor::single_line(cx);
 649            editor.set_placeholder_text("Replace in project..", cx);
 650            if let Some(text) = replacement_text {
 651                editor.set_text(text, cx);
 652            }
 653            editor
 654        });
 655        let results_editor = cx.new_view(|cx| {
 656            let mut editor = Editor::for_multibuffer(excerpts, Some(project.clone()), true, cx);
 657            editor.set_searchable(false);
 658            editor
 659        });
 660        subscriptions.push(cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab)));
 661
 662        subscriptions.push(
 663            cx.subscribe(&results_editor, |this, _, event: &EditorEvent, cx| {
 664                if matches!(event, editor::EditorEvent::SelectionsChanged { .. }) {
 665                    this.update_match_index(cx);
 666                }
 667                // Reraise editor events for workspace item activation purposes
 668                cx.emit(ViewEvent::EditorEvent(event.clone()));
 669            }),
 670        );
 671
 672        let included_files_editor = cx.new_view(|cx| {
 673            let mut editor = Editor::single_line(cx);
 674            editor.set_placeholder_text("Include: crates/**/*.toml", cx);
 675
 676            editor
 677        });
 678        // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes
 679        subscriptions.push(
 680            cx.subscribe(&included_files_editor, |_, _, event: &EditorEvent, cx| {
 681                cx.emit(ViewEvent::EditorEvent(event.clone()))
 682            }),
 683        );
 684
 685        let excluded_files_editor = cx.new_view(|cx| {
 686            let mut editor = Editor::single_line(cx);
 687            editor.set_placeholder_text("Exclude: vendor/*, *.lock", cx);
 688
 689            editor
 690        });
 691        // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes
 692        subscriptions.push(
 693            cx.subscribe(&excluded_files_editor, |_, _, event: &EditorEvent, cx| {
 694                cx.emit(ViewEvent::EditorEvent(event.clone()))
 695            }),
 696        );
 697
 698        let focus_handle = cx.focus_handle();
 699        subscriptions.push(cx.on_focus_in(&focus_handle, |this, cx| {
 700            if this.focus_handle.is_focused(cx) {
 701                if this.has_matches() {
 702                    this.results_editor.focus_handle(cx).focus(cx);
 703                } else {
 704                    this.query_editor.focus_handle(cx).focus(cx);
 705                }
 706            }
 707        }));
 708
 709        // Check if Worktrees have all been previously indexed
 710        let mut this = ProjectSearchView {
 711            focus_handle,
 712            replacement_editor,
 713            search_id: model.read(cx).search_id,
 714            model,
 715            query_editor,
 716            results_editor,
 717            search_options: options,
 718            panels_with_errors: HashSet::default(),
 719            active_match_index: None,
 720            query_editor_was_focused: false,
 721            included_files_editor,
 722            excluded_files_editor,
 723            filters_enabled,
 724            replace_enabled: false,
 725            _subscriptions: subscriptions,
 726        };
 727        this.model_changed(cx);
 728        this
 729    }
 730
 731    pub fn new_search_in_directory(
 732        workspace: &mut Workspace,
 733        dir_path: &Path,
 734        cx: &mut ViewContext<Workspace>,
 735    ) {
 736        let Some(filter_str) = dir_path.to_str() else {
 737            return;
 738        };
 739
 740        let model = cx.new_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
 741        let search = cx.new_view(|cx| ProjectSearchView::new(model, cx, None));
 742        workspace.add_item_to_active_pane(Box::new(search.clone()), None, cx);
 743        search.update(cx, |search, cx| {
 744            search
 745                .included_files_editor
 746                .update(cx, |editor, cx| editor.set_text(filter_str, cx));
 747            search.filters_enabled = true;
 748            search.focus_query_editor(cx)
 749        });
 750    }
 751
 752    // Re-activate the most recently activated search in this pane or the most recent if it has been closed.
 753    // If no search exists in the workspace, create a new one.
 754    fn deploy_search(
 755        workspace: &mut Workspace,
 756        action: &workspace::DeploySearch,
 757        cx: &mut ViewContext<Workspace>,
 758    ) {
 759        let existing = workspace
 760            .active_pane()
 761            .read(cx)
 762            .items()
 763            .find_map(|item| item.downcast::<ProjectSearchView>());
 764
 765        Self::existing_or_new_search(workspace, existing, action, cx);
 766    }
 767
 768    fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
 769        if let Some(search_view) = workspace
 770            .active_item(cx)
 771            .and_then(|item| item.downcast::<ProjectSearchView>())
 772        {
 773            let new_query = search_view.update(cx, |search_view, cx| {
 774                let new_query = search_view.build_search_query(cx);
 775                if new_query.is_some() {
 776                    if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
 777                        search_view.query_editor.update(cx, |editor, cx| {
 778                            editor.set_text(old_query.as_str(), cx);
 779                        });
 780                        search_view.search_options = SearchOptions::from_query(&old_query);
 781                    }
 782                }
 783                new_query
 784            });
 785            if let Some(new_query) = new_query {
 786                let model = cx.new_model(|cx| {
 787                    let mut model = ProjectSearch::new(workspace.project().clone(), cx);
 788                    model.search(new_query, cx);
 789                    model
 790                });
 791                workspace.add_item_to_active_pane(
 792                    Box::new(cx.new_view(|cx| ProjectSearchView::new(model, cx, None))),
 793                    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()), None, 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.clone(),
1313            font_size: rems(0.875).into(),
1314            font_weight: settings.buffer_font.weight,
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
1635fn register_workspace_action<A: Action>(
1636    workspace: &mut Workspace,
1637    callback: fn(&mut ProjectSearchBar, &A, &mut ViewContext<ProjectSearchBar>),
1638) {
1639    workspace.register_action(move |workspace, action: &A, cx| {
1640        if workspace.has_active_modal(cx) {
1641            cx.propagate();
1642            return;
1643        }
1644
1645        workspace.active_pane().update(cx, |pane, cx| {
1646            pane.toolbar().update(cx, move |workspace, cx| {
1647                if let Some(search_bar) = workspace.item_of_type::<ProjectSearchBar>() {
1648                    search_bar.update(cx, move |search_bar, cx| {
1649                        if search_bar.active_project_search.is_some() {
1650                            callback(search_bar, action, cx);
1651                            cx.notify();
1652                        } else {
1653                            cx.propagate();
1654                        }
1655                    });
1656                }
1657            });
1658        })
1659    });
1660}
1661
1662fn register_workspace_action_for_present_search<A: Action>(
1663    workspace: &mut Workspace,
1664    callback: fn(&mut Workspace, &A, &mut ViewContext<Workspace>),
1665) {
1666    workspace.register_action(move |workspace, action: &A, cx| {
1667        if workspace.has_active_modal(cx) {
1668            cx.propagate();
1669            return;
1670        }
1671
1672        let should_notify = workspace
1673            .active_pane()
1674            .read(cx)
1675            .toolbar()
1676            .read(cx)
1677            .item_of_type::<ProjectSearchBar>()
1678            .map(|search_bar| search_bar.read(cx).active_project_search.is_some())
1679            .unwrap_or(false);
1680        if should_notify {
1681            callback(workspace, action, cx);
1682            cx.notify();
1683        } else {
1684            cx.propagate();
1685        }
1686    });
1687}
1688
1689#[cfg(test)]
1690pub mod tests {
1691    use super::*;
1692    use editor::{display_map::DisplayRow, DisplayPoint};
1693    use gpui::{Action, TestAppContext, WindowHandle};
1694    use project::FakeFs;
1695    use serde_json::json;
1696    use settings::SettingsStore;
1697    use std::sync::Arc;
1698    use workspace::DeploySearch;
1699
1700    #[gpui::test]
1701    async fn test_project_search(cx: &mut TestAppContext) {
1702        init_test(cx);
1703
1704        let fs = FakeFs::new(cx.background_executor.clone());
1705        fs.insert_tree(
1706            "/dir",
1707            json!({
1708                "one.rs": "const ONE: usize = 1;",
1709                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1710                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1711                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1712            }),
1713        )
1714        .await;
1715        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1716        let search = cx.new_model(|cx| ProjectSearch::new(project, cx));
1717        let search_view = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx, None));
1718
1719        perform_search(search_view, "TWO", cx);
1720        search_view.update(cx, |search_view, cx| {
1721            assert_eq!(
1722                search_view
1723                    .results_editor
1724                    .update(cx, |editor, cx| editor.display_text(cx)),
1725                "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n"
1726            );
1727            let match_background_color = cx.theme().colors().search_match_background;
1728            assert_eq!(
1729                search_view
1730                    .results_editor
1731                    .update(cx, |editor, cx| editor.all_text_background_highlights(cx)),
1732                &[
1733                    (
1734                        DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35),
1735                        match_background_color
1736                    ),
1737                    (
1738                        DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40),
1739                        match_background_color
1740                    ),
1741                    (
1742                        DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9),
1743                        match_background_color
1744                    )
1745                ]
1746            );
1747            assert_eq!(search_view.active_match_index, Some(0));
1748            assert_eq!(
1749                search_view
1750                    .results_editor
1751                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1752                [DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35)]
1753            );
1754
1755            search_view.select_match(Direction::Next, cx);
1756        }).unwrap();
1757
1758        search_view
1759            .update(cx, |search_view, cx| {
1760                assert_eq!(search_view.active_match_index, Some(1));
1761                assert_eq!(
1762                    search_view
1763                        .results_editor
1764                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1765                    [DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40)]
1766                );
1767                search_view.select_match(Direction::Next, cx);
1768            })
1769            .unwrap();
1770
1771        search_view
1772            .update(cx, |search_view, cx| {
1773                assert_eq!(search_view.active_match_index, Some(2));
1774                assert_eq!(
1775                    search_view
1776                        .results_editor
1777                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1778                    [DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9)]
1779                );
1780                search_view.select_match(Direction::Next, cx);
1781            })
1782            .unwrap();
1783
1784        search_view
1785            .update(cx, |search_view, cx| {
1786                assert_eq!(search_view.active_match_index, Some(0));
1787                assert_eq!(
1788                    search_view
1789                        .results_editor
1790                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1791                    [DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35)]
1792                );
1793                search_view.select_match(Direction::Prev, cx);
1794            })
1795            .unwrap();
1796
1797        search_view
1798            .update(cx, |search_view, cx| {
1799                assert_eq!(search_view.active_match_index, Some(2));
1800                assert_eq!(
1801                    search_view
1802                        .results_editor
1803                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1804                    [DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9)]
1805                );
1806                search_view.select_match(Direction::Prev, cx);
1807            })
1808            .unwrap();
1809
1810        search_view
1811            .update(cx, |search_view, cx| {
1812                assert_eq!(search_view.active_match_index, Some(1));
1813                assert_eq!(
1814                    search_view
1815                        .results_editor
1816                        .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1817                    [DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40)]
1818                );
1819            })
1820            .unwrap();
1821    }
1822
1823    #[gpui::test]
1824    async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
1825        init_test(cx);
1826
1827        let fs = FakeFs::new(cx.background_executor.clone());
1828        fs.insert_tree(
1829            "/dir",
1830            json!({
1831                "one.rs": "const ONE: usize = 1;",
1832                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1833                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1834                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1835            }),
1836        )
1837        .await;
1838        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1839        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
1840        let workspace = window;
1841        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
1842
1843        let active_item = cx.read(|cx| {
1844            workspace
1845                .read(cx)
1846                .unwrap()
1847                .active_pane()
1848                .read(cx)
1849                .active_item()
1850                .and_then(|item| item.downcast::<ProjectSearchView>())
1851        });
1852        assert!(
1853            active_item.is_none(),
1854            "Expected no search panel to be active"
1855        );
1856
1857        window
1858            .update(cx, move |workspace, cx| {
1859                assert_eq!(workspace.panes().len(), 1);
1860                workspace.panes()[0].update(cx, move |pane, cx| {
1861                    pane.toolbar()
1862                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
1863                });
1864
1865                ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::find(), cx)
1866            })
1867            .unwrap();
1868
1869        let Some(search_view) = cx.read(|cx| {
1870            workspace
1871                .read(cx)
1872                .unwrap()
1873                .active_pane()
1874                .read(cx)
1875                .active_item()
1876                .and_then(|item| item.downcast::<ProjectSearchView>())
1877        }) else {
1878            panic!("Search view expected to appear after new search event trigger")
1879        };
1880
1881        cx.spawn(|mut cx| async move {
1882            window
1883                .update(&mut cx, |_, cx| {
1884                    cx.dispatch_action(ToggleFocus.boxed_clone())
1885                })
1886                .unwrap();
1887        })
1888        .detach();
1889        cx.background_executor.run_until_parked();
1890        window
1891            .update(cx, |_, cx| {
1892                search_view.update(cx, |search_view, cx| {
1893                assert!(
1894                    search_view.query_editor.focus_handle(cx).is_focused(cx),
1895                    "Empty search view should be focused after the toggle focus event: no results panel to focus on",
1896                );
1897           });
1898        }).unwrap();
1899
1900        window
1901            .update(cx, |_, cx| {
1902                search_view.update(cx, |search_view, cx| {
1903                    let query_editor = &search_view.query_editor;
1904                    assert!(
1905                        query_editor.focus_handle(cx).is_focused(cx),
1906                        "Search view should be focused after the new search view is activated",
1907                    );
1908                    let query_text = query_editor.read(cx).text(cx);
1909                    assert!(
1910                        query_text.is_empty(),
1911                        "New search query should be empty but got '{query_text}'",
1912                    );
1913                    let results_text = search_view
1914                        .results_editor
1915                        .update(cx, |editor, cx| editor.display_text(cx));
1916                    assert!(
1917                        results_text.is_empty(),
1918                        "Empty search view should have no results but got '{results_text}'"
1919                    );
1920                });
1921            })
1922            .unwrap();
1923
1924        window
1925            .update(cx, |_, cx| {
1926                search_view.update(cx, |search_view, cx| {
1927                    search_view.query_editor.update(cx, |query_editor, cx| {
1928                        query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
1929                    });
1930                    search_view.search(cx);
1931                });
1932            })
1933            .unwrap();
1934        cx.background_executor.run_until_parked();
1935        window
1936            .update(cx, |_, cx| {
1937            search_view.update(cx, |search_view, cx| {
1938                let results_text = search_view
1939                    .results_editor
1940                    .update(cx, |editor, cx| editor.display_text(cx));
1941                assert!(
1942                    results_text.is_empty(),
1943                    "Search view for mismatching query should have no results but got '{results_text}'"
1944                );
1945                assert!(
1946                    search_view.query_editor.focus_handle(cx).is_focused(cx),
1947                    "Search view should be focused after mismatching query had been used in search",
1948                );
1949            });
1950        }).unwrap();
1951
1952        cx.spawn(|mut cx| async move {
1953            window.update(&mut cx, |_, cx| {
1954                cx.dispatch_action(ToggleFocus.boxed_clone())
1955            })
1956        })
1957        .detach();
1958        cx.background_executor.run_until_parked();
1959        window.update(cx, |_, cx| {
1960            search_view.update(cx, |search_view, cx| {
1961                assert!(
1962                    search_view.query_editor.focus_handle(cx).is_focused(cx),
1963                    "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
1964                );
1965            });
1966        }).unwrap();
1967
1968        window
1969            .update(cx, |_, cx| {
1970                search_view.update(cx, |search_view, cx| {
1971                    search_view
1972                        .query_editor
1973                        .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1974                    search_view.search(cx);
1975                });
1976            })
1977            .unwrap();
1978        cx.background_executor.run_until_parked();
1979        window.update(cx, |_, cx| {
1980            search_view.update(cx, |search_view, cx| {
1981                assert_eq!(
1982                    search_view
1983                        .results_editor
1984                        .update(cx, |editor, cx| editor.display_text(cx)),
1985                    "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
1986                    "Search view results should match the query"
1987                );
1988                assert!(
1989                    search_view.results_editor.focus_handle(cx).is_focused(cx),
1990                    "Search view with mismatching query should be focused after search results are available",
1991                );
1992            });
1993        }).unwrap();
1994        cx.spawn(|mut cx| async move {
1995            window
1996                .update(&mut cx, |_, cx| {
1997                    cx.dispatch_action(ToggleFocus.boxed_clone())
1998                })
1999                .unwrap();
2000        })
2001        .detach();
2002        cx.background_executor.run_until_parked();
2003        window.update(cx, |_, cx| {
2004            search_view.update(cx, |search_view, cx| {
2005                assert!(
2006                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2007                    "Search view with matching query should still have its results editor focused after the toggle focus event",
2008                );
2009            });
2010        }).unwrap();
2011
2012        workspace
2013            .update(cx, |workspace, cx| {
2014                ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::find(), cx)
2015            })
2016            .unwrap();
2017        window.update(cx, |_, cx| {
2018            search_view.update(cx, |search_view, cx| {
2019                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");
2020                assert_eq!(
2021                    search_view
2022                        .results_editor
2023                        .update(cx, |editor, cx| editor.display_text(cx)),
2024                    "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2025                    "Results should be unchanged after search view 2nd open in a row"
2026                );
2027                assert!(
2028                    search_view.query_editor.focus_handle(cx).is_focused(cx),
2029                    "Focus should be moved into query editor again after search view 2nd open in a row"
2030                );
2031            });
2032        }).unwrap();
2033
2034        cx.spawn(|mut cx| async move {
2035            window
2036                .update(&mut cx, |_, cx| {
2037                    cx.dispatch_action(ToggleFocus.boxed_clone())
2038                })
2039                .unwrap();
2040        })
2041        .detach();
2042        cx.background_executor.run_until_parked();
2043        window.update(cx, |_, cx| {
2044            search_view.update(cx, |search_view, cx| {
2045                assert!(
2046                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2047                    "Search view with matching query should switch focus to the results editor after the toggle focus event",
2048                );
2049            });
2050        }).unwrap();
2051    }
2052
2053    #[gpui::test]
2054    async fn test_new_project_search_focus(cx: &mut TestAppContext) {
2055        init_test(cx);
2056
2057        let fs = FakeFs::new(cx.background_executor.clone());
2058        fs.insert_tree(
2059            "/dir",
2060            json!({
2061                "one.rs": "const ONE: usize = 1;",
2062                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2063                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2064                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2065            }),
2066        )
2067        .await;
2068        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2069        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2070        let workspace = window;
2071        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2072
2073        let active_item = cx.read(|cx| {
2074            workspace
2075                .read(cx)
2076                .unwrap()
2077                .active_pane()
2078                .read(cx)
2079                .active_item()
2080                .and_then(|item| item.downcast::<ProjectSearchView>())
2081        });
2082        assert!(
2083            active_item.is_none(),
2084            "Expected no search panel to be active"
2085        );
2086
2087        window
2088            .update(cx, move |workspace, cx| {
2089                assert_eq!(workspace.panes().len(), 1);
2090                workspace.panes()[0].update(cx, move |pane, cx| {
2091                    pane.toolbar()
2092                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2093                });
2094
2095                ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2096            })
2097            .unwrap();
2098
2099        let Some(search_view) = cx.read(|cx| {
2100            workspace
2101                .read(cx)
2102                .unwrap()
2103                .active_pane()
2104                .read(cx)
2105                .active_item()
2106                .and_then(|item| item.downcast::<ProjectSearchView>())
2107        }) else {
2108            panic!("Search view expected to appear after new search event trigger")
2109        };
2110
2111        cx.spawn(|mut cx| async move {
2112            window
2113                .update(&mut cx, |_, cx| {
2114                    cx.dispatch_action(ToggleFocus.boxed_clone())
2115                })
2116                .unwrap();
2117        })
2118        .detach();
2119        cx.background_executor.run_until_parked();
2120
2121        window.update(cx, |_, cx| {
2122            search_view.update(cx, |search_view, cx| {
2123                    assert!(
2124                        search_view.query_editor.focus_handle(cx).is_focused(cx),
2125                        "Empty search view should be focused after the toggle focus event: no results panel to focus on",
2126                    );
2127                });
2128        }).unwrap();
2129
2130        window
2131            .update(cx, |_, cx| {
2132                search_view.update(cx, |search_view, cx| {
2133                    let query_editor = &search_view.query_editor;
2134                    assert!(
2135                        query_editor.focus_handle(cx).is_focused(cx),
2136                        "Search view should be focused after the new search view is activated",
2137                    );
2138                    let query_text = query_editor.read(cx).text(cx);
2139                    assert!(
2140                        query_text.is_empty(),
2141                        "New search query should be empty but got '{query_text}'",
2142                    );
2143                    let results_text = search_view
2144                        .results_editor
2145                        .update(cx, |editor, cx| editor.display_text(cx));
2146                    assert!(
2147                        results_text.is_empty(),
2148                        "Empty search view should have no results but got '{results_text}'"
2149                    );
2150                });
2151            })
2152            .unwrap();
2153
2154        window
2155            .update(cx, |_, cx| {
2156                search_view.update(cx, |search_view, cx| {
2157                    search_view.query_editor.update(cx, |query_editor, cx| {
2158                        query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
2159                    });
2160                    search_view.search(cx);
2161                });
2162            })
2163            .unwrap();
2164
2165        cx.background_executor.run_until_parked();
2166        window
2167            .update(cx, |_, cx| {
2168                search_view.update(cx, |search_view, cx| {
2169                    let results_text = search_view
2170                        .results_editor
2171                        .update(cx, |editor, cx| editor.display_text(cx));
2172                    assert!(
2173                results_text.is_empty(),
2174                "Search view for mismatching query should have no results but got '{results_text}'"
2175            );
2176                    assert!(
2177                search_view.query_editor.focus_handle(cx).is_focused(cx),
2178                "Search view should be focused after mismatching query had been used in search",
2179            );
2180                });
2181            })
2182            .unwrap();
2183        cx.spawn(|mut cx| async move {
2184            window.update(&mut cx, |_, cx| {
2185                cx.dispatch_action(ToggleFocus.boxed_clone())
2186            })
2187        })
2188        .detach();
2189        cx.background_executor.run_until_parked();
2190        window.update(cx, |_, cx| {
2191            search_view.update(cx, |search_view, cx| {
2192                    assert!(
2193                        search_view.query_editor.focus_handle(cx).is_focused(cx),
2194                        "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
2195                    );
2196                });
2197        }).unwrap();
2198
2199        window
2200            .update(cx, |_, cx| {
2201                search_view.update(cx, |search_view, cx| {
2202                    search_view
2203                        .query_editor
2204                        .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2205                    search_view.search(cx);
2206                })
2207            })
2208            .unwrap();
2209        cx.background_executor.run_until_parked();
2210        window.update(cx, |_, cx|
2211        search_view.update(cx, |search_view, cx| {
2212                assert_eq!(
2213                    search_view
2214                        .results_editor
2215                        .update(cx, |editor, cx| editor.display_text(cx)),
2216                    "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2217                    "Search view results should match the query"
2218                );
2219                assert!(
2220                    search_view.results_editor.focus_handle(cx).is_focused(cx),
2221                    "Search view with mismatching query should be focused after search results are available",
2222                );
2223            })).unwrap();
2224        cx.spawn(|mut cx| async move {
2225            window
2226                .update(&mut cx, |_, cx| {
2227                    cx.dispatch_action(ToggleFocus.boxed_clone())
2228                })
2229                .unwrap();
2230        })
2231        .detach();
2232        cx.background_executor.run_until_parked();
2233        window.update(cx, |_, cx| {
2234            search_view.update(cx, |search_view, cx| {
2235                    assert!(
2236                        search_view.results_editor.focus_handle(cx).is_focused(cx),
2237                        "Search view with matching query should still have its results editor focused after the toggle focus event",
2238                    );
2239                });
2240        }).unwrap();
2241
2242        workspace
2243            .update(cx, |workspace, cx| {
2244                ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2245            })
2246            .unwrap();
2247        cx.background_executor.run_until_parked();
2248        let Some(search_view_2) = cx.read(|cx| {
2249            workspace
2250                .read(cx)
2251                .unwrap()
2252                .active_pane()
2253                .read(cx)
2254                .active_item()
2255                .and_then(|item| item.downcast::<ProjectSearchView>())
2256        }) else {
2257            panic!("Search view expected to appear after new search event trigger")
2258        };
2259        assert!(
2260            search_view_2 != search_view,
2261            "New search view should be open after `workspace::NewSearch` event"
2262        );
2263
2264        window.update(cx, |_, cx| {
2265            search_view.update(cx, |search_view, cx| {
2266                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
2267                    assert_eq!(
2268                        search_view
2269                            .results_editor
2270                            .update(cx, |editor, cx| editor.display_text(cx)),
2271                        "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2272                        "Results of the first search view should not update too"
2273                    );
2274                    assert!(
2275                        !search_view.query_editor.focus_handle(cx).is_focused(cx),
2276                        "Focus should be moved away from the first search view"
2277                    );
2278                });
2279        }).unwrap();
2280
2281        window.update(cx, |_, cx| {
2282            search_view_2.update(cx, |search_view_2, cx| {
2283                    assert_eq!(
2284                        search_view_2.query_editor.read(cx).text(cx),
2285                        "two",
2286                        "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
2287                    );
2288                    assert_eq!(
2289                        search_view_2
2290                            .results_editor
2291                            .update(cx, |editor, cx| editor.display_text(cx)),
2292                        "",
2293                        "No search results should be in the 2nd view yet, as we did not spawn a search for it"
2294                    );
2295                    assert!(
2296                        search_view_2.query_editor.focus_handle(cx).is_focused(cx),
2297                        "Focus should be moved into query editor of the new window"
2298                    );
2299                });
2300        }).unwrap();
2301
2302        window
2303            .update(cx, |_, cx| {
2304                search_view_2.update(cx, |search_view_2, cx| {
2305                    search_view_2
2306                        .query_editor
2307                        .update(cx, |query_editor, cx| query_editor.set_text("FOUR", cx));
2308                    search_view_2.search(cx);
2309                });
2310            })
2311            .unwrap();
2312
2313        cx.background_executor.run_until_parked();
2314        window.update(cx, |_, cx| {
2315            search_view_2.update(cx, |search_view_2, cx| {
2316                    assert_eq!(
2317                        search_view_2
2318                            .results_editor
2319                            .update(cx, |editor, cx| editor.display_text(cx)),
2320                        "\n\n\nconst FOUR: usize = one::ONE + three::THREE;\n",
2321                        "New search view with the updated query should have new search results"
2322                    );
2323                    assert!(
2324                        search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2325                        "Search view with mismatching query should be focused after search results are available",
2326                    );
2327                });
2328        }).unwrap();
2329
2330        cx.spawn(|mut cx| async move {
2331            window
2332                .update(&mut cx, |_, cx| {
2333                    cx.dispatch_action(ToggleFocus.boxed_clone())
2334                })
2335                .unwrap();
2336        })
2337        .detach();
2338        cx.background_executor.run_until_parked();
2339        window.update(cx, |_, cx| {
2340            search_view_2.update(cx, |search_view_2, cx| {
2341                    assert!(
2342                        search_view_2.results_editor.focus_handle(cx).is_focused(cx),
2343                        "Search view with matching query should switch focus to the results editor after the toggle focus event",
2344                    );
2345                });}).unwrap();
2346    }
2347
2348    #[gpui::test]
2349    async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
2350        init_test(cx);
2351
2352        let fs = FakeFs::new(cx.background_executor.clone());
2353        fs.insert_tree(
2354            "/dir",
2355            json!({
2356                "a": {
2357                    "one.rs": "const ONE: usize = 1;",
2358                    "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2359                },
2360                "b": {
2361                    "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2362                    "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2363                },
2364            }),
2365        )
2366        .await;
2367        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2368        let worktree_id = project.read_with(cx, |project, cx| {
2369            project.worktrees().next().unwrap().read(cx).id()
2370        });
2371        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2372        let workspace = window.root(cx).unwrap();
2373        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2374
2375        let active_item = cx.read(|cx| {
2376            workspace
2377                .read(cx)
2378                .active_pane()
2379                .read(cx)
2380                .active_item()
2381                .and_then(|item| item.downcast::<ProjectSearchView>())
2382        });
2383        assert!(
2384            active_item.is_none(),
2385            "Expected no search panel to be active"
2386        );
2387
2388        window
2389            .update(cx, move |workspace, cx| {
2390                assert_eq!(workspace.panes().len(), 1);
2391                workspace.panes()[0].update(cx, move |pane, cx| {
2392                    pane.toolbar()
2393                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2394                });
2395            })
2396            .unwrap();
2397
2398        let a_dir_entry = cx.update(|cx| {
2399            workspace
2400                .read(cx)
2401                .project()
2402                .read(cx)
2403                .entry_for_path(&(worktree_id, "a").into(), cx)
2404                .expect("no entry for /a/ directory")
2405        });
2406        assert!(a_dir_entry.is_dir());
2407        window
2408            .update(cx, |workspace, cx| {
2409                ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, cx)
2410            })
2411            .unwrap();
2412
2413        let Some(search_view) = cx.read(|cx| {
2414            workspace
2415                .read(cx)
2416                .active_pane()
2417                .read(cx)
2418                .active_item()
2419                .and_then(|item| item.downcast::<ProjectSearchView>())
2420        }) else {
2421            panic!("Search view expected to appear after new search in directory event trigger")
2422        };
2423        cx.background_executor.run_until_parked();
2424        window
2425            .update(cx, |_, cx| {
2426                search_view.update(cx, |search_view, cx| {
2427                    assert!(
2428                        search_view.query_editor.focus_handle(cx).is_focused(cx),
2429                        "On new search in directory, focus should be moved into query editor"
2430                    );
2431                    search_view.excluded_files_editor.update(cx, |editor, cx| {
2432                        assert!(
2433                            editor.display_text(cx).is_empty(),
2434                            "New search in directory should not have any excluded files"
2435                        );
2436                    });
2437                    search_view.included_files_editor.update(cx, |editor, cx| {
2438                        assert_eq!(
2439                            editor.display_text(cx),
2440                            a_dir_entry.path.to_str().unwrap(),
2441                            "New search in directory should have included dir entry path"
2442                        );
2443                    });
2444                });
2445            })
2446            .unwrap();
2447        window
2448            .update(cx, |_, cx| {
2449                search_view.update(cx, |search_view, cx| {
2450                    search_view
2451                        .query_editor
2452                        .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
2453                    search_view.search(cx);
2454                });
2455            })
2456            .unwrap();
2457        cx.background_executor.run_until_parked();
2458        window
2459            .update(cx, |_, cx| {
2460                search_view.update(cx, |search_view, cx| {
2461                    assert_eq!(
2462                search_view
2463                    .results_editor
2464                    .update(cx, |editor, cx| editor.display_text(cx)),
2465                "\n\n\nconst ONE: usize = 1;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
2466                "New search in directory should have a filter that matches a certain directory"
2467            );
2468                })
2469            })
2470            .unwrap();
2471    }
2472
2473    #[gpui::test]
2474    async fn test_search_query_history(cx: &mut TestAppContext) {
2475        init_test(cx);
2476
2477        let fs = FakeFs::new(cx.background_executor.clone());
2478        fs.insert_tree(
2479            "/dir",
2480            json!({
2481                "one.rs": "const ONE: usize = 1;",
2482                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
2483                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
2484                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
2485            }),
2486        )
2487        .await;
2488        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2489        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2490        let workspace = window.root(cx).unwrap();
2491        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
2492
2493        window
2494            .update(cx, {
2495                let search_bar = search_bar.clone();
2496                move |workspace, cx| {
2497                    assert_eq!(workspace.panes().len(), 1);
2498                    workspace.panes()[0].update(cx, move |pane, cx| {
2499                        pane.toolbar()
2500                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2501                    });
2502
2503                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2504                }
2505            })
2506            .unwrap();
2507
2508        let search_view = cx.read(|cx| {
2509            workspace
2510                .read(cx)
2511                .active_pane()
2512                .read(cx)
2513                .active_item()
2514                .and_then(|item| item.downcast::<ProjectSearchView>())
2515                .expect("Search view expected to appear after new search event trigger")
2516        });
2517
2518        // Add 3 search items into the history + another unsubmitted one.
2519        window
2520            .update(cx, |_, cx| {
2521                search_view.update(cx, |search_view, cx| {
2522                    search_view.search_options = SearchOptions::CASE_SENSITIVE;
2523                    search_view
2524                        .query_editor
2525                        .update(cx, |query_editor, cx| query_editor.set_text("ONE", cx));
2526                    search_view.search(cx);
2527                });
2528            })
2529            .unwrap();
2530
2531        cx.background_executor.run_until_parked();
2532        window
2533            .update(cx, |_, cx| {
2534                search_view.update(cx, |search_view, cx| {
2535                    search_view
2536                        .query_editor
2537                        .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
2538                    search_view.search(cx);
2539                });
2540            })
2541            .unwrap();
2542        cx.background_executor.run_until_parked();
2543        window
2544            .update(cx, |_, cx| {
2545                search_view.update(cx, |search_view, cx| {
2546                    search_view
2547                        .query_editor
2548                        .update(cx, |query_editor, cx| query_editor.set_text("THREE", cx));
2549                    search_view.search(cx);
2550                })
2551            })
2552            .unwrap();
2553        cx.background_executor.run_until_parked();
2554        window
2555            .update(cx, |_, cx| {
2556                search_view.update(cx, |search_view, cx| {
2557                    search_view.query_editor.update(cx, |query_editor, cx| {
2558                        query_editor.set_text("JUST_TEXT_INPUT", cx)
2559                    });
2560                })
2561            })
2562            .unwrap();
2563        cx.background_executor.run_until_parked();
2564
2565        // Ensure that the latest input with search settings is active.
2566        window
2567            .update(cx, |_, cx| {
2568                search_view.update(cx, |search_view, cx| {
2569                    assert_eq!(
2570                        search_view.query_editor.read(cx).text(cx),
2571                        "JUST_TEXT_INPUT"
2572                    );
2573                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2574                });
2575            })
2576            .unwrap();
2577
2578        // Next history query after the latest should set the query to the empty string.
2579        window
2580            .update(cx, |_, cx| {
2581                search_bar.update(cx, |search_bar, cx| {
2582                    search_bar.next_history_query(&NextHistoryQuery, cx);
2583                })
2584            })
2585            .unwrap();
2586        window
2587            .update(cx, |_, cx| {
2588                search_view.update(cx, |search_view, cx| {
2589                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2590                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2591                });
2592            })
2593            .unwrap();
2594        window
2595            .update(cx, |_, cx| {
2596                search_bar.update(cx, |search_bar, cx| {
2597                    search_bar.next_history_query(&NextHistoryQuery, cx);
2598                })
2599            })
2600            .unwrap();
2601        window
2602            .update(cx, |_, cx| {
2603                search_view.update(cx, |search_view, cx| {
2604                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2605                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2606                });
2607            })
2608            .unwrap();
2609
2610        // First previous query for empty current query should set the query to the latest submitted one.
2611        window
2612            .update(cx, |_, cx| {
2613                search_bar.update(cx, |search_bar, cx| {
2614                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2615                });
2616            })
2617            .unwrap();
2618        window
2619            .update(cx, |_, cx| {
2620                search_view.update(cx, |search_view, cx| {
2621                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2622                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2623                });
2624            })
2625            .unwrap();
2626
2627        // Further previous items should go over the history in reverse order.
2628        window
2629            .update(cx, |_, cx| {
2630                search_bar.update(cx, |search_bar, cx| {
2631                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2632                });
2633            })
2634            .unwrap();
2635        window
2636            .update(cx, |_, cx| {
2637                search_view.update(cx, |search_view, cx| {
2638                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2639                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2640                });
2641            })
2642            .unwrap();
2643
2644        // Previous items should never go behind the first history item.
2645        window
2646            .update(cx, |_, cx| {
2647                search_bar.update(cx, |search_bar, cx| {
2648                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2649                });
2650            })
2651            .unwrap();
2652        window
2653            .update(cx, |_, cx| {
2654                search_view.update(cx, |search_view, cx| {
2655                    assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2656                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2657                });
2658            })
2659            .unwrap();
2660        window
2661            .update(cx, |_, cx| {
2662                search_bar.update(cx, |search_bar, cx| {
2663                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2664                });
2665            })
2666            .unwrap();
2667        window
2668            .update(cx, |_, cx| {
2669                search_view.update(cx, |search_view, cx| {
2670                    assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
2671                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2672                });
2673            })
2674            .unwrap();
2675
2676        // Next items should go over the history in the original order.
2677        window
2678            .update(cx, |_, cx| {
2679                search_bar.update(cx, |search_bar, cx| {
2680                    search_bar.next_history_query(&NextHistoryQuery, cx);
2681                });
2682            })
2683            .unwrap();
2684        window
2685            .update(cx, |_, cx| {
2686                search_view.update(cx, |search_view, cx| {
2687                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2688                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2689                });
2690            })
2691            .unwrap();
2692
2693        window
2694            .update(cx, |_, cx| {
2695                search_view.update(cx, |search_view, cx| {
2696                    search_view
2697                        .query_editor
2698                        .update(cx, |query_editor, cx| query_editor.set_text("TWO_NEW", cx));
2699                    search_view.search(cx);
2700                });
2701            })
2702            .unwrap();
2703        cx.background_executor.run_until_parked();
2704        window
2705            .update(cx, |_, cx| {
2706                search_view.update(cx, |search_view, cx| {
2707                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2708                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2709                });
2710            })
2711            .unwrap();
2712
2713        // New search input should add another entry to history and move the selection to the end of the history.
2714        window
2715            .update(cx, |_, cx| {
2716                search_bar.update(cx, |search_bar, cx| {
2717                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2718                });
2719            })
2720            .unwrap();
2721        window
2722            .update(cx, |_, cx| {
2723                search_view.update(cx, |search_view, cx| {
2724                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2725                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2726                });
2727            })
2728            .unwrap();
2729        window
2730            .update(cx, |_, cx| {
2731                search_bar.update(cx, |search_bar, cx| {
2732                    search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2733                });
2734            })
2735            .unwrap();
2736        window
2737            .update(cx, |_, cx| {
2738                search_view.update(cx, |search_view, cx| {
2739                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
2740                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2741                });
2742            })
2743            .unwrap();
2744        window
2745            .update(cx, |_, cx| {
2746                search_bar.update(cx, |search_bar, cx| {
2747                    search_bar.next_history_query(&NextHistoryQuery, cx);
2748                });
2749            })
2750            .unwrap();
2751        window
2752            .update(cx, |_, cx| {
2753                search_view.update(cx, |search_view, cx| {
2754                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
2755                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2756                });
2757            })
2758            .unwrap();
2759        window
2760            .update(cx, |_, cx| {
2761                search_bar.update(cx, |search_bar, cx| {
2762                    search_bar.next_history_query(&NextHistoryQuery, cx);
2763                });
2764            })
2765            .unwrap();
2766        window
2767            .update(cx, |_, cx| {
2768                search_view.update(cx, |search_view, cx| {
2769                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
2770                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2771                });
2772            })
2773            .unwrap();
2774        window
2775            .update(cx, |_, cx| {
2776                search_bar.update(cx, |search_bar, cx| {
2777                    search_bar.next_history_query(&NextHistoryQuery, cx);
2778                });
2779            })
2780            .unwrap();
2781        window
2782            .update(cx, |_, cx| {
2783                search_view.update(cx, |search_view, cx| {
2784                    assert_eq!(search_view.query_editor.read(cx).text(cx), "");
2785                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
2786                });
2787            })
2788            .unwrap();
2789    }
2790
2791    #[gpui::test]
2792    async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) {
2793        init_test(cx);
2794
2795        let fs = FakeFs::new(cx.background_executor.clone());
2796        fs.insert_tree(
2797            "/dir",
2798            json!({
2799                "one.rs": "const ONE: usize = 1;",
2800            }),
2801        )
2802        .await;
2803        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
2804        let worktree_id = project.update(cx, |this, cx| {
2805            this.worktrees().next().unwrap().read(cx).id()
2806        });
2807
2808        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
2809        let workspace = window.root(cx).unwrap();
2810
2811        let panes: Vec<_> = window
2812            .update(cx, |this, _| this.panes().to_owned())
2813            .unwrap();
2814
2815        let search_bar_1 = window.build_view(cx, |_| ProjectSearchBar::new());
2816        let search_bar_2 = window.build_view(cx, |_| ProjectSearchBar::new());
2817
2818        assert_eq!(panes.len(), 1);
2819        let first_pane = panes.get(0).cloned().unwrap();
2820        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
2821        window
2822            .update(cx, |workspace, cx| {
2823                workspace.open_path(
2824                    (worktree_id, "one.rs"),
2825                    Some(first_pane.downgrade()),
2826                    true,
2827                    cx,
2828                )
2829            })
2830            .unwrap()
2831            .await
2832            .unwrap();
2833        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
2834
2835        // Add a project search item to the first pane
2836        window
2837            .update(cx, {
2838                let search_bar = search_bar_1.clone();
2839                let pane = first_pane.clone();
2840                move |workspace, cx| {
2841                    pane.update(cx, move |pane, cx| {
2842                        pane.toolbar()
2843                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2844                    });
2845
2846                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2847                }
2848            })
2849            .unwrap();
2850        let search_view_1 = cx.read(|cx| {
2851            workspace
2852                .read(cx)
2853                .active_item(cx)
2854                .and_then(|item| item.downcast::<ProjectSearchView>())
2855                .expect("Search view expected to appear after new search event trigger")
2856        });
2857
2858        let second_pane = window
2859            .update(cx, |workspace, cx| {
2860                workspace.split_and_clone(first_pane.clone(), workspace::SplitDirection::Right, cx)
2861            })
2862            .unwrap()
2863            .unwrap();
2864        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
2865
2866        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
2867        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
2868
2869        // Add a project search item to the second pane
2870        window
2871            .update(cx, {
2872                let search_bar = search_bar_2.clone();
2873                let pane = second_pane.clone();
2874                move |workspace, cx| {
2875                    assert_eq!(workspace.panes().len(), 2);
2876                    pane.update(cx, move |pane, cx| {
2877                        pane.toolbar()
2878                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
2879                    });
2880
2881                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
2882                }
2883            })
2884            .unwrap();
2885
2886        let search_view_2 = cx.read(|cx| {
2887            workspace
2888                .read(cx)
2889                .active_item(cx)
2890                .and_then(|item| item.downcast::<ProjectSearchView>())
2891                .expect("Search view expected to appear after new search event trigger")
2892        });
2893
2894        cx.run_until_parked();
2895        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2);
2896        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
2897
2898        let update_search_view =
2899            |search_view: &View<ProjectSearchView>, query: &str, cx: &mut TestAppContext| {
2900                window
2901                    .update(cx, |_, cx| {
2902                        search_view.update(cx, |search_view, cx| {
2903                            search_view
2904                                .query_editor
2905                                .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
2906                            search_view.search(cx);
2907                        });
2908                    })
2909                    .unwrap();
2910            };
2911
2912        let active_query =
2913            |search_view: &View<ProjectSearchView>, cx: &mut TestAppContext| -> String {
2914                window
2915                    .update(cx, |_, cx| {
2916                        search_view.update(cx, |search_view, cx| {
2917                            search_view.query_editor.read(cx).text(cx).to_string()
2918                        })
2919                    })
2920                    .unwrap()
2921            };
2922
2923        let select_prev_history_item =
2924            |search_bar: &View<ProjectSearchBar>, cx: &mut TestAppContext| {
2925                window
2926                    .update(cx, |_, cx| {
2927                        search_bar.update(cx, |search_bar, cx| {
2928                            search_bar.previous_history_query(&PreviousHistoryQuery, cx);
2929                        })
2930                    })
2931                    .unwrap();
2932            };
2933
2934        let select_next_history_item =
2935            |search_bar: &View<ProjectSearchBar>, cx: &mut TestAppContext| {
2936                window
2937                    .update(cx, |_, cx| {
2938                        search_bar.update(cx, |search_bar, cx| {
2939                            search_bar.next_history_query(&NextHistoryQuery, cx);
2940                        })
2941                    })
2942                    .unwrap();
2943            };
2944
2945        update_search_view(&search_view_1, "ONE", cx);
2946        cx.background_executor.run_until_parked();
2947
2948        update_search_view(&search_view_2, "TWO", cx);
2949        cx.background_executor.run_until_parked();
2950
2951        assert_eq!(active_query(&search_view_1, cx), "ONE");
2952        assert_eq!(active_query(&search_view_2, cx), "TWO");
2953
2954        // Selecting previous history item should select the query from search view 1.
2955        select_prev_history_item(&search_bar_2, cx);
2956        assert_eq!(active_query(&search_view_2, cx), "ONE");
2957
2958        // Selecting the previous history item should not change the query as it is already the first item.
2959        select_prev_history_item(&search_bar_2, cx);
2960        assert_eq!(active_query(&search_view_2, cx), "ONE");
2961
2962        // Changing the query in search view 2 should not affect the history of search view 1.
2963        assert_eq!(active_query(&search_view_1, cx), "ONE");
2964
2965        // Deploying a new search in search view 2
2966        update_search_view(&search_view_2, "THREE", cx);
2967        cx.background_executor.run_until_parked();
2968
2969        select_next_history_item(&search_bar_2, cx);
2970        assert_eq!(active_query(&search_view_2, cx), "");
2971
2972        select_prev_history_item(&search_bar_2, cx);
2973        assert_eq!(active_query(&search_view_2, cx), "THREE");
2974
2975        select_prev_history_item(&search_bar_2, cx);
2976        assert_eq!(active_query(&search_view_2, cx), "TWO");
2977
2978        select_prev_history_item(&search_bar_2, cx);
2979        assert_eq!(active_query(&search_view_2, cx), "ONE");
2980
2981        select_prev_history_item(&search_bar_2, cx);
2982        assert_eq!(active_query(&search_view_2, cx), "ONE");
2983
2984        // Search view 1 should now see the query from search view 2.
2985        assert_eq!(active_query(&search_view_1, cx), "ONE");
2986
2987        select_next_history_item(&search_bar_2, cx);
2988        assert_eq!(active_query(&search_view_2, cx), "TWO");
2989
2990        // Here is the new query from search view 2
2991        select_next_history_item(&search_bar_2, cx);
2992        assert_eq!(active_query(&search_view_2, cx), "THREE");
2993
2994        select_next_history_item(&search_bar_2, cx);
2995        assert_eq!(active_query(&search_view_2, cx), "");
2996
2997        select_next_history_item(&search_bar_1, cx);
2998        assert_eq!(active_query(&search_view_1, cx), "TWO");
2999
3000        select_next_history_item(&search_bar_1, cx);
3001        assert_eq!(active_query(&search_view_1, cx), "THREE");
3002
3003        select_next_history_item(&search_bar_1, cx);
3004        assert_eq!(active_query(&search_view_1, cx), "");
3005    }
3006
3007    #[gpui::test]
3008    async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
3009        init_test(cx);
3010
3011        // Setup 2 panes, both with a file open and one with a project search.
3012        let fs = FakeFs::new(cx.background_executor.clone());
3013        fs.insert_tree(
3014            "/dir",
3015            json!({
3016                "one.rs": "const ONE: usize = 1;",
3017            }),
3018        )
3019        .await;
3020        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3021        let worktree_id = project.update(cx, |this, cx| {
3022            this.worktrees().next().unwrap().read(cx).id()
3023        });
3024        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
3025        let panes: Vec<_> = window
3026            .update(cx, |this, _| this.panes().to_owned())
3027            .unwrap();
3028        assert_eq!(panes.len(), 1);
3029        let first_pane = panes.get(0).cloned().unwrap();
3030        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0);
3031        window
3032            .update(cx, |workspace, cx| {
3033                workspace.open_path(
3034                    (worktree_id, "one.rs"),
3035                    Some(first_pane.downgrade()),
3036                    true,
3037                    cx,
3038                )
3039            })
3040            .unwrap()
3041            .await
3042            .unwrap();
3043        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3044        let second_pane = window
3045            .update(cx, |workspace, cx| {
3046                workspace.split_and_clone(first_pane.clone(), workspace::SplitDirection::Right, cx)
3047            })
3048            .unwrap()
3049            .unwrap();
3050        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1);
3051        assert!(window
3052            .update(cx, |_, cx| second_pane
3053                .focus_handle(cx)
3054                .contains_focused(cx))
3055            .unwrap());
3056        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
3057        window
3058            .update(cx, {
3059                let search_bar = search_bar.clone();
3060                let pane = first_pane.clone();
3061                move |workspace, cx| {
3062                    assert_eq!(workspace.panes().len(), 2);
3063                    pane.update(cx, move |pane, cx| {
3064                        pane.toolbar()
3065                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3066                    });
3067                }
3068            })
3069            .unwrap();
3070
3071        // Add a project search item to the second pane
3072        window
3073            .update(cx, {
3074                let search_bar = search_bar.clone();
3075                let pane = second_pane.clone();
3076                move |workspace, cx| {
3077                    assert_eq!(workspace.panes().len(), 2);
3078                    pane.update(cx, move |pane, cx| {
3079                        pane.toolbar()
3080                            .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
3081                    });
3082
3083                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
3084                }
3085            })
3086            .unwrap();
3087
3088        cx.run_until_parked();
3089        assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2);
3090        assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1);
3091
3092        // Focus the first pane
3093        window
3094            .update(cx, |workspace, cx| {
3095                assert_eq!(workspace.active_pane(), &second_pane);
3096                second_pane.update(cx, |this, cx| {
3097                    assert_eq!(this.active_item_index(), 1);
3098                    this.activate_prev_item(false, cx);
3099                    assert_eq!(this.active_item_index(), 0);
3100                });
3101                workspace.activate_pane_in_direction(workspace::SplitDirection::Left, cx);
3102            })
3103            .unwrap();
3104        window
3105            .update(cx, |workspace, cx| {
3106                assert_eq!(workspace.active_pane(), &first_pane);
3107                assert_eq!(first_pane.read(cx).items_len(), 1);
3108                assert_eq!(second_pane.read(cx).items_len(), 2);
3109            })
3110            .unwrap();
3111
3112        // Deploy a new search
3113        cx.dispatch_action(window.into(), DeploySearch::find());
3114
3115        // Both panes should now have a project search in them
3116        window
3117            .update(cx, |workspace, cx| {
3118                assert_eq!(workspace.active_pane(), &first_pane);
3119                first_pane.update(cx, |this, _| {
3120                    assert_eq!(this.active_item_index(), 1);
3121                    assert_eq!(this.items_len(), 2);
3122                });
3123                second_pane.update(cx, |this, cx| {
3124                    assert!(!cx.focus_handle().contains_focused(cx));
3125                    assert_eq!(this.items_len(), 2);
3126                });
3127            })
3128            .unwrap();
3129
3130        // Focus the second pane's non-search item
3131        window
3132            .update(cx, |_workspace, cx| {
3133                second_pane.update(cx, |pane, cx| pane.activate_next_item(true, cx));
3134            })
3135            .unwrap();
3136
3137        // Deploy a new search
3138        cx.dispatch_action(window.into(), DeploySearch::find());
3139
3140        // The project search view should now be focused in the second pane
3141        // And the number of items should be unchanged.
3142        window
3143            .update(cx, |_workspace, cx| {
3144                second_pane.update(cx, |pane, _cx| {
3145                    assert!(pane
3146                        .active_item()
3147                        .unwrap()
3148                        .downcast::<ProjectSearchView>()
3149                        .is_some());
3150
3151                    assert_eq!(pane.items_len(), 2);
3152                });
3153            })
3154            .unwrap();
3155    }
3156
3157    #[gpui::test]
3158    async fn test_scroll_search_results_to_top(cx: &mut TestAppContext) {
3159        init_test(cx);
3160
3161        // We need many lines in the search results to be able to scroll the window
3162        let fs = FakeFs::new(cx.background_executor.clone());
3163        fs.insert_tree(
3164            "/dir",
3165            json!({
3166                "1.txt": "\n\n\n\n\n A \n\n\n\n\n",
3167                "2.txt": "\n\n\n\n\n A \n\n\n\n\n",
3168                "3.rs": "\n\n\n\n\n A \n\n\n\n\n",
3169                "4.rs": "\n\n\n\n\n A \n\n\n\n\n",
3170                "5.rs": "\n\n\n\n\n A \n\n\n\n\n",
3171                "6.rs": "\n\n\n\n\n A \n\n\n\n\n",
3172                "7.rs": "\n\n\n\n\n A \n\n\n\n\n",
3173                "8.rs": "\n\n\n\n\n A \n\n\n\n\n",
3174                "9.rs": "\n\n\n\n\n A \n\n\n\n\n",
3175                "a.rs": "\n\n\n\n\n A \n\n\n\n\n",
3176                "b.rs": "\n\n\n\n\n B \n\n\n\n\n",
3177                "c.rs": "\n\n\n\n\n B \n\n\n\n\n",
3178                "d.rs": "\n\n\n\n\n B \n\n\n\n\n",
3179                "e.rs": "\n\n\n\n\n B \n\n\n\n\n",
3180                "f.rs": "\n\n\n\n\n B \n\n\n\n\n",
3181                "g.rs": "\n\n\n\n\n B \n\n\n\n\n",
3182                "h.rs": "\n\n\n\n\n B \n\n\n\n\n",
3183                "i.rs": "\n\n\n\n\n B \n\n\n\n\n",
3184                "j.rs": "\n\n\n\n\n B \n\n\n\n\n",
3185                "k.rs": "\n\n\n\n\n B \n\n\n\n\n",
3186            }),
3187        )
3188        .await;
3189        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3190        let search = cx.new_model(|cx| ProjectSearch::new(project, cx));
3191        let search_view = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx, None));
3192
3193        // First search
3194        perform_search(search_view, "A", cx);
3195        search_view
3196            .update(cx, |search_view, cx| {
3197                search_view.results_editor.update(cx, |results_editor, cx| {
3198                    // Results are correct and scrolled to the top
3199                    assert_eq!(
3200                        results_editor.display_text(cx).match_indices(" A ").count(),
3201                        10
3202                    );
3203                    assert_eq!(results_editor.scroll_position(cx), Point::default());
3204
3205                    // Scroll results all the way down
3206                    results_editor.scroll(Point::new(0., f32::MAX), Some(Axis::Vertical), cx);
3207                });
3208            })
3209            .expect("unable to update search view");
3210
3211        // Second search
3212        perform_search(search_view, "B", cx);
3213        search_view
3214            .update(cx, |search_view, cx| {
3215                search_view.results_editor.update(cx, |results_editor, cx| {
3216                    // Results are correct...
3217                    assert_eq!(
3218                        results_editor.display_text(cx).match_indices(" B ").count(),
3219                        10
3220                    );
3221                    // ...and scrolled back to the top
3222                    assert_eq!(results_editor.scroll_position(cx), Point::default());
3223                });
3224            })
3225            .expect("unable to update search view");
3226    }
3227
3228    fn init_test(cx: &mut TestAppContext) {
3229        cx.update(|cx| {
3230            let settings = SettingsStore::test(cx);
3231            cx.set_global(settings);
3232
3233            theme::init(theme::LoadThemes::JustBase, cx);
3234
3235            language::init(cx);
3236            client::init_settings(cx);
3237            editor::init(cx);
3238            workspace::init_settings(cx);
3239            Project::init_settings(cx);
3240            super::init(cx);
3241        });
3242    }
3243
3244    fn perform_search(
3245        search_view: WindowHandle<ProjectSearchView>,
3246        text: impl Into<Arc<str>>,
3247        cx: &mut TestAppContext,
3248    ) {
3249        search_view
3250            .update(cx, |search_view, cx| {
3251                search_view
3252                    .query_editor
3253                    .update(cx, |query_editor, cx| query_editor.set_text(text, cx));
3254                search_view.search(cx);
3255            })
3256            .unwrap();
3257        cx.background_executor.run_until_parked();
3258    }
3259}