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