project_search.rs

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