project_search.rs

   1use crate::{
   2    BufferSearchBar, FocusSearch, HighlightKey, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll,
   3    ReplaceNext, SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch,
   4    ToggleCaseSensitive, ToggleIncludeIgnored, ToggleRegex, ToggleReplace, ToggleWholeWord,
   5    buffer_search::Deploy,
   6    search_bar::{
   7        ActionButtonState, HistoryNavigationDirection, alignment_element, input_base_styles,
   8        render_action_button, render_text_input, should_navigate_history,
   9    },
  10};
  11use anyhow::Context as _;
  12use collections::{HashMap, HashSet};
  13use editor::{
  14    Anchor, Editor, EditorEvent, EditorSettings, MAX_TAB_TITLE_LEN, MultiBuffer, PathKey,
  15    SelectionEffects,
  16    actions::{Backtab, FoldAll, SelectAll, Tab, UnfoldAll},
  17    items::active_match_index,
  18    multibuffer_context_lines,
  19    scroll::Autoscroll,
  20};
  21use futures::{StreamExt, stream::FuturesOrdered};
  22use gpui::{
  23    Action, AnyElement, App, Axis, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
  24    Global, Hsla, InteractiveElement, IntoElement, KeyContext, ParentElement, Point, Render,
  25    SharedString, Styled, Subscription, Task, UpdateGlobal, WeakEntity, Window, actions, div,
  26};
  27use itertools::Itertools;
  28use language::{Buffer, Language};
  29use menu::Confirm;
  30use multi_buffer;
  31use project::{
  32    Project, ProjectPath, SearchResults,
  33    search::{SearchInputKind, SearchQuery},
  34    search_history::SearchHistoryCursor,
  35};
  36use settings::Settings;
  37use std::{
  38    any::{Any, TypeId},
  39    mem,
  40    ops::{Not, Range},
  41    pin::pin,
  42    sync::Arc,
  43};
  44use ui::{
  45    CommonAnimationExt, IconButtonShape, KeyBinding, Toggleable, Tooltip, prelude::*,
  46    utils::SearchInputWidth,
  47};
  48use util::{ResultExt as _, paths::PathMatcher, rel_path::RelPath};
  49use workspace::{
  50    DeploySearch, ItemNavHistory, NewSearch, ToolbarItemEvent, ToolbarItemLocation,
  51    ToolbarItemView, Workspace, WorkspaceId,
  52    item::{Item, ItemEvent, ItemHandle, SaveOptions},
  53    searchable::{Direction, SearchEvent, SearchToken, SearchableItem, SearchableItemHandle},
  54};
  55
  56actions!(
  57    project_search,
  58    [
  59        /// Searches in a new project search tab.
  60        SearchInNew,
  61        /// Toggles focus between the search bar and the search results.
  62        ToggleFocus,
  63        /// Moves to the next input field.
  64        NextField,
  65        /// Toggles the search filters panel.
  66        ToggleFilters,
  67        /// Toggles collapse/expand state of all search result excerpts.
  68        ToggleAllSearchResults
  69    ]
  70);
  71
  72fn split_glob_patterns(text: &str) -> Vec<&str> {
  73    let mut patterns = Vec::new();
  74    let mut pattern_start = 0;
  75    let mut brace_depth: usize = 0;
  76    let mut escaped = false;
  77
  78    for (index, character) in text.char_indices() {
  79        if escaped {
  80            escaped = false;
  81            continue;
  82        }
  83        match character {
  84            '\\' => escaped = true,
  85            '{' => brace_depth += 1,
  86            '}' => brace_depth = brace_depth.saturating_sub(1),
  87            ',' if brace_depth == 0 => {
  88                patterns.push(&text[pattern_start..index]);
  89                pattern_start = index + 1;
  90            }
  91            _ => {}
  92        }
  93    }
  94    patterns.push(&text[pattern_start..]);
  95    patterns
  96}
  97
  98#[derive(Default)]
  99struct ActiveSettings(HashMap<WeakEntity<Project>, ProjectSearchSettings>);
 100
 101impl Global for ActiveSettings {}
 102
 103pub fn init(cx: &mut App) {
 104    cx.set_global(ActiveSettings::default());
 105    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
 106        register_workspace_action(workspace, move |search_bar, _: &Deploy, window, cx| {
 107            search_bar.focus_search(window, cx);
 108        });
 109        register_workspace_action(workspace, move |search_bar, _: &FocusSearch, window, cx| {
 110            search_bar.focus_search(window, cx);
 111        });
 112        register_workspace_action(
 113            workspace,
 114            move |search_bar, _: &ToggleFilters, window, cx| {
 115                search_bar.toggle_filters(window, cx);
 116            },
 117        );
 118        register_workspace_action(
 119            workspace,
 120            move |search_bar, _: &ToggleCaseSensitive, window, cx| {
 121                search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
 122            },
 123        );
 124        register_workspace_action(
 125            workspace,
 126            move |search_bar, _: &ToggleWholeWord, window, cx| {
 127                search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
 128            },
 129        );
 130        register_workspace_action(workspace, move |search_bar, _: &ToggleRegex, window, cx| {
 131            search_bar.toggle_search_option(SearchOptions::REGEX, window, cx);
 132        });
 133        register_workspace_action(
 134            workspace,
 135            move |search_bar, action: &ToggleReplace, window, cx| {
 136                search_bar.toggle_replace(action, window, cx)
 137            },
 138        );
 139        register_workspace_action(
 140            workspace,
 141            move |search_bar, action: &SelectPreviousMatch, window, cx| {
 142                search_bar.select_prev_match(action, window, cx)
 143            },
 144        );
 145        register_workspace_action(
 146            workspace,
 147            move |search_bar, action: &SelectNextMatch, window, cx| {
 148                search_bar.select_next_match(action, window, cx)
 149            },
 150        );
 151
 152        // Only handle search_in_new if there is a search present
 153        register_workspace_action_for_present_search(workspace, |workspace, action, window, cx| {
 154            ProjectSearchView::search_in_new(workspace, action, window, cx)
 155        });
 156
 157        register_workspace_action_for_present_search(
 158            workspace,
 159            |workspace, action: &ToggleAllSearchResults, window, cx| {
 160                if let Some(search_view) = workspace
 161                    .active_item(cx)
 162                    .and_then(|item| item.downcast::<ProjectSearchView>())
 163                {
 164                    search_view.update(cx, |search_view, cx| {
 165                        search_view.toggle_all_search_results(action, window, cx);
 166                    });
 167                }
 168            },
 169        );
 170
 171        register_workspace_action_for_present_search(
 172            workspace,
 173            |workspace, _: &menu::Cancel, window, cx| {
 174                if let Some(project_search_bar) = workspace
 175                    .active_pane()
 176                    .read(cx)
 177                    .toolbar()
 178                    .read(cx)
 179                    .item_of_type::<ProjectSearchBar>()
 180                {
 181                    project_search_bar.update(cx, |project_search_bar, cx| {
 182                        let search_is_focused = project_search_bar
 183                            .active_project_search
 184                            .as_ref()
 185                            .is_some_and(|search_view| {
 186                                search_view
 187                                    .read(cx)
 188                                    .query_editor
 189                                    .read(cx)
 190                                    .focus_handle(cx)
 191                                    .is_focused(window)
 192                            });
 193                        if search_is_focused {
 194                            project_search_bar.move_focus_to_results(window, cx);
 195                        } else {
 196                            project_search_bar.focus_search(window, cx)
 197                        }
 198                    });
 199                } else {
 200                    cx.propagate();
 201                }
 202            },
 203        );
 204
 205        // Both on present and dismissed search, we need to unconditionally handle those actions to focus from the editor.
 206        workspace.register_action(move |workspace, action: &DeploySearch, window, cx| {
 207            if workspace.has_active_modal(window, cx) && !workspace.hide_modal(window, cx) {
 208                cx.propagate();
 209                return;
 210            }
 211            ProjectSearchView::deploy_search(workspace, action, window, cx);
 212            cx.notify();
 213        });
 214        workspace.register_action(move |workspace, action: &NewSearch, window, cx| {
 215            if workspace.has_active_modal(window, cx) && !workspace.hide_modal(window, cx) {
 216                cx.propagate();
 217                return;
 218            }
 219            ProjectSearchView::new_search(workspace, action, window, cx);
 220            cx.notify();
 221        });
 222    })
 223    .detach();
 224}
 225
 226fn contains_uppercase(str: &str) -> bool {
 227    str.chars().any(|c| c.is_uppercase())
 228}
 229
 230pub struct ProjectSearch {
 231    project: Entity<Project>,
 232    excerpts: Entity<MultiBuffer>,
 233    pending_search: Option<Task<Option<()>>>,
 234    match_ranges: Vec<Range<Anchor>>,
 235    active_query: Option<SearchQuery>,
 236    last_search_query_text: Option<String>,
 237    search_id: usize,
 238    no_results: Option<bool>,
 239    limit_reached: bool,
 240    search_input_confirmed: bool,
 241    search_history_cursor: SearchHistoryCursor,
 242    search_included_history_cursor: SearchHistoryCursor,
 243    search_excluded_history_cursor: SearchHistoryCursor,
 244    _excerpts_subscription: Subscription,
 245}
 246
 247#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 248enum InputPanel {
 249    Query,
 250    Replacement,
 251    Exclude,
 252    Include,
 253}
 254
 255pub struct ProjectSearchView {
 256    workspace: WeakEntity<Workspace>,
 257    focus_handle: FocusHandle,
 258    entity: Entity<ProjectSearch>,
 259    query_editor: Entity<Editor>,
 260    replacement_editor: Entity<Editor>,
 261    results_editor: Entity<Editor>,
 262    search_options: SearchOptions,
 263    panels_with_errors: HashMap<InputPanel, String>,
 264    active_match_index: Option<usize>,
 265    search_id: usize,
 266    included_files_editor: Entity<Editor>,
 267    excluded_files_editor: Entity<Editor>,
 268    filters_enabled: bool,
 269    replace_enabled: bool,
 270    pending_replace_all: bool,
 271    included_opened_only: bool,
 272    regex_language: Option<Arc<Language>>,
 273    _subscriptions: Vec<Subscription>,
 274}
 275
 276#[derive(Debug, Clone)]
 277pub struct ProjectSearchSettings {
 278    search_options: SearchOptions,
 279    filters_enabled: bool,
 280}
 281
 282pub struct ProjectSearchBar {
 283    active_project_search: Option<Entity<ProjectSearchView>>,
 284    subscription: Option<Subscription>,
 285}
 286
 287impl ProjectSearch {
 288    pub fn new(project: Entity<Project>, cx: &mut Context<Self>) -> Self {
 289        let capability = project.read(cx).capability();
 290        let excerpts = cx.new(|_| MultiBuffer::new(capability));
 291        let subscription = Self::subscribe_to_excerpts(&excerpts, cx);
 292
 293        Self {
 294            project,
 295            excerpts,
 296            pending_search: Default::default(),
 297            match_ranges: Default::default(),
 298            active_query: None,
 299            last_search_query_text: None,
 300            search_id: 0,
 301            no_results: None,
 302            limit_reached: false,
 303            search_input_confirmed: false,
 304            search_history_cursor: Default::default(),
 305            search_included_history_cursor: Default::default(),
 306            search_excluded_history_cursor: Default::default(),
 307            _excerpts_subscription: subscription,
 308        }
 309    }
 310
 311    fn clone(&self, cx: &mut Context<Self>) -> Entity<Self> {
 312        cx.new(|cx| {
 313            let excerpts = self
 314                .excerpts
 315                .update(cx, |excerpts, cx| cx.new(|cx| excerpts.clone(cx)));
 316            let subscription = Self::subscribe_to_excerpts(&excerpts, cx);
 317
 318            Self {
 319                project: self.project.clone(),
 320                excerpts,
 321                pending_search: Default::default(),
 322                match_ranges: self.match_ranges.clone(),
 323                active_query: self.active_query.clone(),
 324                last_search_query_text: self.last_search_query_text.clone(),
 325                search_id: self.search_id,
 326                no_results: self.no_results,
 327                limit_reached: self.limit_reached,
 328                search_input_confirmed: self.search_input_confirmed,
 329                search_history_cursor: self.search_history_cursor.clone(),
 330                search_included_history_cursor: self.search_included_history_cursor.clone(),
 331                search_excluded_history_cursor: self.search_excluded_history_cursor.clone(),
 332                _excerpts_subscription: subscription,
 333            }
 334        })
 335    }
 336
 337    fn subscribe_to_excerpts(
 338        excerpts: &Entity<MultiBuffer>,
 339        cx: &mut Context<Self>,
 340    ) -> Subscription {
 341        cx.subscribe(excerpts, |this, _, event, cx| {
 342            if matches!(event, multi_buffer::Event::FileHandleChanged) {
 343                this.remove_deleted_buffers(cx);
 344            }
 345        })
 346    }
 347
 348    fn remove_deleted_buffers(&mut self, cx: &mut Context<Self>) {
 349        let deleted_buffer_ids = self
 350            .excerpts
 351            .read(cx)
 352            .all_buffers_iter()
 353            .filter(|buffer| {
 354                buffer
 355                    .read(cx)
 356                    .file()
 357                    .is_some_and(|file| file.disk_state().is_deleted())
 358            })
 359            .map(|buffer| buffer.read(cx).remote_id())
 360            .collect::<Vec<_>>();
 361
 362        if deleted_buffer_ids.is_empty() {
 363            return;
 364        }
 365
 366        let snapshot = self.excerpts.update(cx, |excerpts, cx| {
 367            for buffer_id in deleted_buffer_ids {
 368                excerpts.remove_excerpts_for_buffer(buffer_id, cx);
 369            }
 370            excerpts.snapshot(cx)
 371        });
 372
 373        self.match_ranges
 374            .retain(|range| snapshot.anchor_to_buffer_anchor(range.start).is_some());
 375
 376        cx.notify();
 377    }
 378
 379    fn cursor(&self, kind: SearchInputKind) -> &SearchHistoryCursor {
 380        match kind {
 381            SearchInputKind::Query => &self.search_history_cursor,
 382            SearchInputKind::Include => &self.search_included_history_cursor,
 383            SearchInputKind::Exclude => &self.search_excluded_history_cursor,
 384        }
 385    }
 386    fn cursor_mut(&mut self, kind: SearchInputKind) -> &mut SearchHistoryCursor {
 387        match kind {
 388            SearchInputKind::Query => &mut self.search_history_cursor,
 389            SearchInputKind::Include => &mut self.search_included_history_cursor,
 390            SearchInputKind::Exclude => &mut self.search_excluded_history_cursor,
 391        }
 392    }
 393
 394    fn search(&mut self, query: SearchQuery, incremental: bool, cx: &mut Context<Self>) {
 395        let search = self.project.update(cx, |project, cx| {
 396            project
 397                .search_history_mut(SearchInputKind::Query)
 398                .add(&mut self.search_history_cursor, query.as_str().to_string());
 399            let included = query.as_inner().files_to_include().sources().join(",");
 400            if !included.is_empty() {
 401                project
 402                    .search_history_mut(SearchInputKind::Include)
 403                    .add(&mut self.search_included_history_cursor, included);
 404            }
 405            let excluded = query.as_inner().files_to_exclude().sources().join(",");
 406            if !excluded.is_empty() {
 407                project
 408                    .search_history_mut(SearchInputKind::Exclude)
 409                    .add(&mut self.search_excluded_history_cursor, excluded);
 410            }
 411            project.search(query.clone(), cx)
 412        });
 413        self.last_search_query_text = Some(query.as_str().to_string());
 414        self.search_id += 1;
 415        self.active_query = Some(query);
 416        self.match_ranges.clear();
 417        self.search_input_confirmed = !incremental;
 418        self.pending_search = Some(cx.spawn(async move |project_search, cx| {
 419            let SearchResults { rx, _task_handle } = search;
 420
 421            let mut matches = pin!(rx.ready_chunks(1024));
 422
 423            if !incremental {
 424                project_search
 425                    .update(cx, |project_search, cx| {
 426                        project_search.match_ranges.clear();
 427                        project_search
 428                            .excerpts
 429                            .update(cx, |excerpts, cx| excerpts.clear(cx));
 430                        project_search.no_results = Some(true);
 431                        project_search.limit_reached = false;
 432                    })
 433                    .ok()?;
 434            }
 435
 436            let mut limit_reached = false;
 437            let mut seen_paths = HashSet::default();
 438            while let Some(results) = matches.next().await {
 439                let (buffers_with_ranges, has_reached_limit) = cx
 440                    .background_executor()
 441                    .spawn(async move {
 442                        let mut limit_reached = false;
 443                        let mut buffers_with_ranges = Vec::with_capacity(results.len());
 444                        for result in results {
 445                            match result {
 446                                project::search::SearchResult::Buffer { buffer, ranges } => {
 447                                    buffers_with_ranges.push((buffer, ranges));
 448                                }
 449                                project::search::SearchResult::LimitReached => {
 450                                    limit_reached = true;
 451                                }
 452                            }
 453                        }
 454                        (buffers_with_ranges, limit_reached)
 455                    })
 456                    .await;
 457                limit_reached |= has_reached_limit;
 458
 459                if incremental {
 460                    let buffers_with_ranges = buffers_with_ranges
 461                        .into_iter()
 462                        .filter(|(_, ranges)| !ranges.is_empty())
 463                        .collect::<Vec<_>>();
 464                    if buffers_with_ranges.is_empty() {
 465                        continue;
 466                    }
 467                    let (mut chunk_ranges, chunk_paths) = project_search
 468                        .update(cx, |project_search, cx| {
 469                            let mut paths = Vec::new();
 470                            let futures = project_search.excerpts.update(cx, |excerpts, cx| {
 471                                buffers_with_ranges
 472                                    .into_iter()
 473                                    .map(|(buffer, ranges)| {
 474                                        let path_key = PathKey::for_buffer(&buffer, cx);
 475                                        paths.push(path_key.clone());
 476                                        excerpts.set_anchored_excerpts_for_path(
 477                                            path_key,
 478                                            buffer,
 479                                            ranges,
 480                                            multibuffer_context_lines(cx),
 481                                            cx,
 482                                        )
 483                                    })
 484                                    .collect::<FuturesOrdered<_>>()
 485                            });
 486                            (futures, paths)
 487                        })
 488                        .ok()?;
 489                    seen_paths.extend(chunk_paths);
 490                    while let Some(ranges) = chunk_ranges.next().await {
 491                        smol::future::yield_now().await;
 492                        project_search
 493                            .update(cx, |project_search, cx| {
 494                                project_search.match_ranges.extend(ranges);
 495                                cx.notify();
 496                            })
 497                            .ok()?;
 498                    }
 499                    project_search
 500                        .update(cx, |project_search, cx| {
 501                            project_search.excerpts.update(cx, |excerpts, cx| {
 502                                for path in excerpts
 503                                    .existing_excerpts()
 504                                    .into_iter()
 505                                    .filter(|path| !seen_paths.contains(path))
 506                                {
 507                                    excerpts.remove_excerpts(path, cx);
 508                                }
 509                            });
 510                        })
 511                        .ok()?;
 512                    continue;
 513                }
 514
 515                let mut new_ranges = project_search
 516                    .update(cx, |project_search, cx| {
 517                        project_search.excerpts.update(cx, |excerpts, cx| {
 518                            buffers_with_ranges
 519                                .into_iter()
 520                                .map(|(buffer, ranges)| {
 521                                    excerpts.set_anchored_excerpts_for_path(
 522                                        PathKey::for_buffer(&buffer, cx),
 523                                        buffer,
 524                                        ranges,
 525                                        multibuffer_context_lines(cx),
 526                                        cx,
 527                                    )
 528                                })
 529                                .collect::<FuturesOrdered<_>>()
 530                        })
 531                    })
 532                    .ok()?;
 533                while let Some(new_ranges) = new_ranges.next().await {
 534                    // `new_ranges.next().await` likely never gets hit while still pending so `async_task`
 535                    // will not reschedule, starving other front end tasks, insert a yield point for that here
 536                    smol::future::yield_now().await;
 537                    project_search
 538                        .update(cx, |project_search, cx| {
 539                            project_search.match_ranges.extend(new_ranges);
 540                            cx.notify();
 541                        })
 542                        .ok()?;
 543                }
 544            }
 545
 546            project_search
 547                .update(cx, |project_search, cx| {
 548                    project_search.no_results = Some(project_search.match_ranges.is_empty());
 549                    project_search.limit_reached = limit_reached;
 550                    project_search.pending_search.take();
 551                    if incremental && seen_paths.is_empty() {
 552                        project_search
 553                            .excerpts
 554                            .update(cx, |excerpts, cx| excerpts.clear(cx));
 555                    }
 556                    cx.notify();
 557                })
 558                .ok()?;
 559
 560            None
 561        }));
 562        cx.notify();
 563    }
 564}
 565
 566#[derive(Clone, Debug, PartialEq, Eq)]
 567pub enum ViewEvent {
 568    UpdateTab,
 569    Activate,
 570    EditorEvent(editor::EditorEvent),
 571    Dismiss,
 572}
 573
 574impl EventEmitter<ViewEvent> for ProjectSearchView {}
 575
 576impl Render for ProjectSearchView {
 577    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 578        if self.has_matches() {
 579            div()
 580                .flex_1()
 581                .size_full()
 582                .track_focus(&self.focus_handle(cx))
 583                .child(self.results_editor.clone())
 584        } else {
 585            let model = self.entity.read(cx);
 586            let has_no_results = model.no_results.unwrap_or(false);
 587            let is_search_underway = model.pending_search.is_some();
 588
 589            let heading_text = if is_search_underway {
 590                "Searching…"
 591            } else if has_no_results {
 592                "No Results"
 593            } else {
 594                "Search All Files"
 595            };
 596
 597            let heading_text = div()
 598                .justify_center()
 599                .child(Label::new(heading_text).size(LabelSize::Large));
 600
 601            let page_content: Option<AnyElement> = if let Some(no_results) = model.no_results {
 602                if model.pending_search.is_none() && no_results {
 603                    Some(
 604                        Label::new("No results found in this project for the provided query")
 605                            .size(LabelSize::Small)
 606                            .into_any_element(),
 607                    )
 608                } else {
 609                    None
 610                }
 611            } else {
 612                Some(self.landing_text_minor(cx).into_any_element())
 613            };
 614
 615            let page_content = page_content.map(|text| div().child(text));
 616
 617            h_flex()
 618                .size_full()
 619                .items_center()
 620                .justify_center()
 621                .overflow_hidden()
 622                .bg(cx.theme().colors().editor_background)
 623                .track_focus(&self.focus_handle(cx))
 624                .child(
 625                    v_flex()
 626                        .id("project-search-landing-page")
 627                        .overflow_y_scroll()
 628                        .gap_1()
 629                        .child(heading_text)
 630                        .children(page_content),
 631                )
 632        }
 633    }
 634}
 635
 636impl Focusable for ProjectSearchView {
 637    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
 638        self.focus_handle.clone()
 639    }
 640}
 641
 642impl Item for ProjectSearchView {
 643    type Event = ViewEvent;
 644    fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
 645        let query_text = self.query_editor.read(cx).text(cx);
 646
 647        query_text
 648            .is_empty()
 649            .not()
 650            .then(|| query_text.into())
 651            .or_else(|| Some("Project Search".into()))
 652    }
 653
 654    fn act_as_type<'a>(
 655        &'a self,
 656        type_id: TypeId,
 657        self_handle: &'a Entity<Self>,
 658        _: &'a App,
 659    ) -> Option<gpui::AnyEntity> {
 660        if type_id == TypeId::of::<Self>() {
 661            Some(self_handle.clone().into())
 662        } else if type_id == TypeId::of::<Editor>() {
 663            Some(self.results_editor.clone().into())
 664        } else {
 665            None
 666        }
 667    }
 668    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
 669        Some(Box::new(self.results_editor.clone()))
 670    }
 671
 672    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 673        self.results_editor
 674            .update(cx, |editor, cx| editor.deactivated(window, cx));
 675    }
 676
 677    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
 678        Some(Icon::new(IconName::MagnifyingGlass))
 679    }
 680
 681    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
 682        let last_query: Option<SharedString> = self
 683            .entity
 684            .read(cx)
 685            .last_search_query_text
 686            .as_ref()
 687            .map(|query| {
 688                let query = query.replace('\n', "");
 689                let query_text = util::truncate_and_trailoff(&query, MAX_TAB_TITLE_LEN);
 690                query_text.into()
 691            });
 692
 693        last_query
 694            .filter(|query| !query.is_empty())
 695            .unwrap_or_else(|| "Project Search".into())
 696    }
 697
 698    fn telemetry_event_text(&self) -> Option<&'static str> {
 699        Some("Project Search Opened")
 700    }
 701
 702    fn for_each_project_item(
 703        &self,
 704        cx: &App,
 705        f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
 706    ) {
 707        self.results_editor.for_each_project_item(cx, f)
 708    }
 709
 710    fn can_save(&self, _: &App) -> bool {
 711        true
 712    }
 713
 714    fn is_dirty(&self, cx: &App) -> bool {
 715        self.results_editor.read(cx).is_dirty(cx)
 716    }
 717
 718    fn has_conflict(&self, cx: &App) -> bool {
 719        self.results_editor.read(cx).has_conflict(cx)
 720    }
 721
 722    fn save(
 723        &mut self,
 724        options: SaveOptions,
 725        project: Entity<Project>,
 726        window: &mut Window,
 727        cx: &mut Context<Self>,
 728    ) -> Task<anyhow::Result<()>> {
 729        self.results_editor
 730            .update(cx, |editor, cx| editor.save(options, project, window, cx))
 731    }
 732
 733    fn save_as(
 734        &mut self,
 735        _: Entity<Project>,
 736        _: ProjectPath,
 737        _window: &mut Window,
 738        _: &mut Context<Self>,
 739    ) -> Task<anyhow::Result<()>> {
 740        unreachable!("save_as should not have been called")
 741    }
 742
 743    fn reload(
 744        &mut self,
 745        project: Entity<Project>,
 746        window: &mut Window,
 747        cx: &mut Context<Self>,
 748    ) -> Task<anyhow::Result<()>> {
 749        self.results_editor
 750            .update(cx, |editor, cx| editor.reload(project, window, cx))
 751    }
 752
 753    fn can_split(&self) -> bool {
 754        true
 755    }
 756
 757    fn clone_on_split(
 758        &self,
 759        _workspace_id: Option<WorkspaceId>,
 760        window: &mut Window,
 761        cx: &mut Context<Self>,
 762    ) -> Task<Option<Entity<Self>>>
 763    where
 764        Self: Sized,
 765    {
 766        let model = self.entity.update(cx, |model, cx| model.clone(cx));
 767        Task::ready(Some(cx.new(|cx| {
 768            Self::new(self.workspace.clone(), model, window, cx, None)
 769        })))
 770    }
 771
 772    fn added_to_workspace(
 773        &mut self,
 774        workspace: &mut Workspace,
 775        window: &mut Window,
 776        cx: &mut Context<Self>,
 777    ) {
 778        self.results_editor.update(cx, |editor, cx| {
 779            editor.added_to_workspace(workspace, window, cx)
 780        });
 781    }
 782
 783    fn set_nav_history(
 784        &mut self,
 785        nav_history: ItemNavHistory,
 786        _: &mut Window,
 787        cx: &mut Context<Self>,
 788    ) {
 789        self.results_editor.update(cx, |editor, _| {
 790            editor.set_nav_history(Some(nav_history));
 791        });
 792    }
 793
 794    fn navigate(
 795        &mut self,
 796        data: Arc<dyn Any + Send>,
 797        window: &mut Window,
 798        cx: &mut Context<Self>,
 799    ) -> bool {
 800        self.results_editor
 801            .update(cx, |editor, cx| editor.navigate(data, window, cx))
 802    }
 803
 804    fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
 805        match event {
 806            ViewEvent::UpdateTab => {
 807                f(ItemEvent::UpdateBreadcrumbs);
 808                f(ItemEvent::UpdateTab);
 809            }
 810            ViewEvent::EditorEvent(editor_event) => {
 811                Editor::to_item_events(editor_event, f);
 812            }
 813            ViewEvent::Dismiss => f(ItemEvent::CloseItem),
 814            _ => {}
 815        }
 816    }
 817}
 818
 819impl ProjectSearchView {
 820    pub fn get_matches(&self, cx: &App) -> Vec<Range<Anchor>> {
 821        self.entity.read(cx).match_ranges.clone()
 822    }
 823
 824    fn toggle_filters(&mut self, cx: &mut Context<Self>) {
 825        self.filters_enabled = !self.filters_enabled;
 826        ActiveSettings::update_global(cx, |settings, cx| {
 827            settings.0.insert(
 828                self.entity.read(cx).project.downgrade(),
 829                self.current_settings(),
 830            );
 831        });
 832    }
 833
 834    fn current_settings(&self) -> ProjectSearchSettings {
 835        ProjectSearchSettings {
 836            search_options: self.search_options,
 837            filters_enabled: self.filters_enabled,
 838        }
 839    }
 840
 841    fn set_search_option_enabled(
 842        &mut self,
 843        option: SearchOptions,
 844        enabled: bool,
 845        cx: &mut Context<Self>,
 846    ) {
 847        if self.search_options.contains(option) != enabled {
 848            self.toggle_search_option(option, cx);
 849        }
 850    }
 851
 852    fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut Context<Self>) {
 853        self.search_options.toggle(option);
 854        ActiveSettings::update_global(cx, |settings, cx| {
 855            settings.0.insert(
 856                self.entity.read(cx).project.downgrade(),
 857                self.current_settings(),
 858            );
 859        });
 860        self.adjust_query_regex_language(cx);
 861    }
 862
 863    fn toggle_opened_only(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
 864        self.included_opened_only = !self.included_opened_only;
 865    }
 866
 867    pub fn replacement(&self, cx: &App) -> String {
 868        self.replacement_editor.read(cx).text(cx)
 869    }
 870
 871    fn replace_next(&mut self, _: &ReplaceNext, window: &mut Window, cx: &mut Context<Self>) {
 872        if self.entity.read(cx).pending_search.is_some() {
 873            return;
 874        }
 875        if let Some(last_search_query_text) = &self.entity.read(cx).last_search_query_text
 876            && self.query_editor.read(cx).text(cx) != *last_search_query_text
 877        {
 878            // search query has changed, restart search and bail
 879            self.search(false, cx);
 880            return;
 881        }
 882        if self.entity.read(cx).match_ranges.is_empty() {
 883            return;
 884        }
 885        let Some(active_index) = self.active_match_index else {
 886            return;
 887        };
 888
 889        let query = self.entity.read(cx).active_query.clone();
 890        if let Some(query) = query {
 891            let query = query.with_replacement(self.replacement(cx));
 892
 893            let mat = self.entity.read(cx).match_ranges.get(active_index).cloned();
 894            self.results_editor.update(cx, |editor, cx| {
 895                if let Some(mat) = mat.as_ref() {
 896                    editor.replace(mat, &query, SearchToken::default(), window, cx);
 897                }
 898            });
 899            self.select_match(Direction::Next, window, cx)
 900        }
 901    }
 902
 903    fn replace_all(&mut self, _: &ReplaceAll, window: &mut Window, cx: &mut Context<Self>) {
 904        if self.entity.read(cx).pending_search.is_some() {
 905            self.pending_replace_all = true;
 906            return;
 907        }
 908        let query_text = self.query_editor.read(cx).text(cx);
 909        let query_is_stale =
 910            self.entity.read(cx).last_search_query_text.as_deref() != Some(query_text.as_str());
 911        if query_is_stale {
 912            self.pending_replace_all = true;
 913            self.search(false, cx);
 914            if self.entity.read(cx).pending_search.is_none() {
 915                self.pending_replace_all = false;
 916            }
 917            return;
 918        }
 919        self.pending_replace_all = false;
 920        if self.active_match_index.is_none() {
 921            return;
 922        }
 923        let Some(query) = self.entity.read(cx).active_query.as_ref() else {
 924            return;
 925        };
 926        let query = query.clone().with_replacement(self.replacement(cx));
 927
 928        let match_ranges = self
 929            .entity
 930            .update(cx, |model, _| mem::take(&mut model.match_ranges));
 931        if match_ranges.is_empty() {
 932            return;
 933        }
 934
 935        self.results_editor.update(cx, |editor, cx| {
 936            editor.replace_all(
 937                &mut match_ranges.iter(),
 938                &query,
 939                SearchToken::default(),
 940                window,
 941                cx,
 942            );
 943        });
 944
 945        self.entity.update(cx, |model, _cx| {
 946            model.match_ranges = match_ranges;
 947        });
 948    }
 949
 950    fn toggle_all_search_results(
 951        &mut self,
 952        _: &ToggleAllSearchResults,
 953        window: &mut Window,
 954        cx: &mut Context<Self>,
 955    ) {
 956        self.update_results_visibility(window, cx);
 957    }
 958
 959    fn update_results_visibility(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 960        let has_any_folded = self.results_editor.read(cx).has_any_buffer_folded(cx);
 961        self.results_editor.update(cx, |editor, cx| {
 962            if has_any_folded {
 963                editor.unfold_all(&UnfoldAll, window, cx);
 964            } else {
 965                editor.fold_all(&FoldAll, window, cx);
 966            }
 967        });
 968        cx.notify();
 969    }
 970
 971    pub fn new(
 972        workspace: WeakEntity<Workspace>,
 973        entity: Entity<ProjectSearch>,
 974        window: &mut Window,
 975        cx: &mut Context<Self>,
 976        settings: Option<ProjectSearchSettings>,
 977    ) -> Self {
 978        let project;
 979        let excerpts;
 980        let mut replacement_text = None;
 981        let mut query_text = String::new();
 982        let mut subscriptions = Vec::new();
 983
 984        // Read in settings if available
 985        let (mut options, filters_enabled) = if let Some(settings) = settings {
 986            (settings.search_options, settings.filters_enabled)
 987        } else {
 988            let search_options =
 989                SearchOptions::from_settings(&EditorSettings::get_global(cx).search);
 990            (search_options, false)
 991        };
 992
 993        {
 994            let entity = entity.read(cx);
 995            project = entity.project.clone();
 996            excerpts = entity.excerpts.clone();
 997            if let Some(active_query) = entity.active_query.as_ref() {
 998                query_text = active_query.as_str().to_string();
 999                replacement_text = active_query.replacement().map(ToOwned::to_owned);
1000                options = SearchOptions::from_query(active_query);
1001            }
1002        }
1003        subscriptions.push(cx.observe_in(&entity, window, |this, _, window, cx| {
1004            this.entity_changed(window, cx)
1005        }));
1006
1007        let query_editor = cx.new(|cx| {
1008            let mut editor = Editor::auto_height(1, 4, window, cx);
1009            editor.set_placeholder_text("Search all files…", window, cx);
1010            editor.set_use_autoclose(false);
1011            editor.set_use_selection_highlight(false);
1012            editor.set_text(query_text, window, cx);
1013            editor
1014        });
1015        // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
1016        subscriptions.push(
1017            cx.subscribe(&query_editor, |this, _, event: &EditorEvent, cx| {
1018                if let EditorEvent::Edited { .. } = event {
1019                    if EditorSettings::get_global(cx).use_smartcase_search {
1020                        let query = this.search_query_text(cx);
1021                        if !query.is_empty()
1022                            && this.search_options.contains(SearchOptions::CASE_SENSITIVE)
1023                                != contains_uppercase(&query)
1024                        {
1025                            this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
1026                        }
1027                    }
1028
1029                    if EditorSettings::get_global(cx).search.search_on_input {
1030                        if this.query_editor.read(cx).is_empty(cx) {
1031                            this.entity.update(cx, |model, cx| {
1032                                model.pending_search = None;
1033                                model.match_ranges.clear();
1034                                model.excerpts.update(cx, |excerpts, cx| excerpts.clear(cx));
1035                                model.no_results = None;
1036                                model.limit_reached = false;
1037                                model.last_search_query_text = None;
1038                                cx.notify();
1039                            });
1040                        } else {
1041                            this.search(true, cx);
1042                        }
1043                    }
1044                }
1045                cx.emit(ViewEvent::EditorEvent(event.clone()))
1046            }),
1047        );
1048        let replacement_editor = cx.new(|cx| {
1049            let mut editor = Editor::auto_height(1, 4, window, cx);
1050            editor.set_placeholder_text("Replace in project…", window, cx);
1051            if let Some(text) = replacement_text {
1052                editor.set_text(text, window, cx);
1053            }
1054            editor
1055        });
1056        let results_editor = cx.new(|cx| {
1057            let mut editor = Editor::for_multibuffer(excerpts, Some(project.clone()), window, cx);
1058            editor.set_searchable(false);
1059            editor.set_in_project_search(true);
1060            editor
1061        });
1062        subscriptions.push(cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab)));
1063
1064        subscriptions.push(
1065            cx.subscribe(&results_editor, |this, _, event: &EditorEvent, cx| {
1066                if matches!(event, editor::EditorEvent::SelectionsChanged { .. }) {
1067                    this.update_match_index(cx);
1068                }
1069                // Reraise editor events for workspace item activation purposes
1070                cx.emit(ViewEvent::EditorEvent(event.clone()));
1071            }),
1072        );
1073        subscriptions.push(cx.subscribe(
1074            &results_editor,
1075            |_this, _editor, _event: &SearchEvent, cx| cx.notify(),
1076        ));
1077
1078        let included_files_editor = cx.new(|cx| {
1079            let mut editor = Editor::single_line(window, cx);
1080            editor.set_placeholder_text("Include: crates/**/*.toml", window, cx);
1081
1082            editor
1083        });
1084        // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes
1085        subscriptions.push(
1086            cx.subscribe(&included_files_editor, |_, _, event: &EditorEvent, cx| {
1087                cx.emit(ViewEvent::EditorEvent(event.clone()))
1088            }),
1089        );
1090
1091        let excluded_files_editor = cx.new(|cx| {
1092            let mut editor = Editor::single_line(window, cx);
1093            editor.set_placeholder_text("Exclude: vendor/*, *.lock", window, cx);
1094
1095            editor
1096        });
1097        // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes
1098        subscriptions.push(
1099            cx.subscribe(&excluded_files_editor, |_, _, event: &EditorEvent, cx| {
1100                cx.emit(ViewEvent::EditorEvent(event.clone()))
1101            }),
1102        );
1103
1104        let focus_handle = cx.focus_handle();
1105        subscriptions.push(cx.on_focus(&focus_handle, window, |_, window, cx| {
1106            cx.on_next_frame(window, |this, window, cx| {
1107                if this.focus_handle.is_focused(window) {
1108                    if this.has_matches() {
1109                        this.results_editor.focus_handle(cx).focus(window, cx);
1110                    } else {
1111                        this.query_editor.focus_handle(cx).focus(window, cx);
1112                    }
1113                }
1114            });
1115        }));
1116
1117        let languages = project.read(cx).languages().clone();
1118        cx.spawn(async move |project_search_view, cx| {
1119            let regex_language = languages
1120                .language_for_name("regex")
1121                .await
1122                .context("loading regex language")?;
1123            project_search_view
1124                .update(cx, |project_search_view, cx| {
1125                    project_search_view.regex_language = Some(regex_language);
1126                    project_search_view.adjust_query_regex_language(cx);
1127                })
1128                .ok();
1129            anyhow::Ok(())
1130        })
1131        .detach_and_log_err(cx);
1132
1133        // Check if Worktrees have all been previously indexed
1134        let mut this = ProjectSearchView {
1135            workspace,
1136            focus_handle,
1137            replacement_editor,
1138            search_id: entity.read(cx).search_id,
1139            entity,
1140            query_editor,
1141            results_editor,
1142            search_options: options,
1143            panels_with_errors: HashMap::default(),
1144            active_match_index: None,
1145            included_files_editor,
1146            excluded_files_editor,
1147            filters_enabled,
1148            replace_enabled: false,
1149            pending_replace_all: false,
1150            included_opened_only: false,
1151            regex_language: None,
1152            _subscriptions: subscriptions,
1153        };
1154
1155        this.entity_changed(window, cx);
1156        this
1157    }
1158
1159    pub fn new_search_in_directory(
1160        workspace: &mut Workspace,
1161        dir_path: &RelPath,
1162        window: &mut Window,
1163        cx: &mut Context<Workspace>,
1164    ) {
1165        let filter_str = dir_path.display(workspace.path_style(cx));
1166
1167        let weak_workspace = cx.entity().downgrade();
1168
1169        let entity = cx.new(|cx| ProjectSearch::new(workspace.project().clone(), cx));
1170        let search = cx.new(|cx| ProjectSearchView::new(weak_workspace, entity, window, cx, None));
1171        workspace.add_item_to_active_pane(Box::new(search.clone()), None, true, window, cx);
1172        search.update(cx, |search, cx| {
1173            search
1174                .included_files_editor
1175                .update(cx, |editor, cx| editor.set_text(filter_str, window, cx));
1176            search.filters_enabled = true;
1177            search.focus_query_editor(window, cx)
1178        });
1179    }
1180
1181    /// Re-activate the most recently activated search in this pane or the most recent if it has been closed.
1182    /// If no search exists in the workspace, create a new one.
1183    pub fn deploy_search(
1184        workspace: &mut Workspace,
1185        action: &workspace::DeploySearch,
1186        window: &mut Window,
1187        cx: &mut Context<Workspace>,
1188    ) {
1189        let existing = workspace
1190            .active_pane()
1191            .read(cx)
1192            .items()
1193            .find_map(|item| item.downcast::<ProjectSearchView>());
1194
1195        Self::existing_or_new_search(workspace, existing, action, window, cx);
1196    }
1197
1198    fn search_in_new(
1199        workspace: &mut Workspace,
1200        _: &SearchInNew,
1201        window: &mut Window,
1202        cx: &mut Context<Workspace>,
1203    ) {
1204        if let Some(search_view) = workspace
1205            .active_item(cx)
1206            .and_then(|item| item.downcast::<ProjectSearchView>())
1207        {
1208            let new_query = search_view.update(cx, |search_view, cx| {
1209                let open_buffers = if search_view.included_opened_only {
1210                    Some(search_view.open_buffers(cx, workspace))
1211                } else {
1212                    None
1213                };
1214                let new_query = search_view.build_search_query(cx, open_buffers);
1215                if new_query.is_some()
1216                    && let Some(old_query) = search_view.entity.read(cx).active_query.clone()
1217                {
1218                    search_view.query_editor.update(cx, |editor, cx| {
1219                        editor.set_text(old_query.as_str(), window, cx);
1220                    });
1221                    search_view.search_options = SearchOptions::from_query(&old_query);
1222                    search_view.adjust_query_regex_language(cx);
1223                }
1224                new_query
1225            });
1226            if let Some(new_query) = new_query {
1227                let entity = cx.new(|cx| {
1228                    let mut entity = ProjectSearch::new(workspace.project().clone(), cx);
1229                    entity.search(new_query, false, cx);
1230                    entity
1231                });
1232                let weak_workspace = cx.entity().downgrade();
1233                workspace.add_item_to_active_pane(
1234                    Box::new(cx.new(|cx| {
1235                        ProjectSearchView::new(weak_workspace, entity, window, cx, None)
1236                    })),
1237                    None,
1238                    true,
1239                    window,
1240                    cx,
1241                );
1242            }
1243        }
1244    }
1245
1246    // Add another search tab to the workspace.
1247    fn new_search(
1248        workspace: &mut Workspace,
1249        _: &workspace::NewSearch,
1250        window: &mut Window,
1251        cx: &mut Context<Workspace>,
1252    ) {
1253        Self::existing_or_new_search(workspace, None, &DeploySearch::default(), window, cx)
1254    }
1255
1256    fn existing_or_new_search(
1257        workspace: &mut Workspace,
1258        existing: Option<Entity<ProjectSearchView>>,
1259        action: &workspace::DeploySearch,
1260        window: &mut Window,
1261        cx: &mut Context<Workspace>,
1262    ) {
1263        let query = workspace.active_item(cx).and_then(|item| {
1264            if let Some(buffer_search_query) = buffer_search_query(workspace, item.as_ref(), cx) {
1265                return Some(buffer_search_query);
1266            }
1267
1268            let editor = item.act_as::<Editor>(cx)?;
1269            let query = editor.query_suggestion(window, cx);
1270            if query.is_empty() { None } else { Some(query) }
1271        });
1272
1273        let search = if let Some(existing) = existing {
1274            workspace.activate_item(&existing, true, true, window, cx);
1275            existing
1276        } else {
1277            let settings = cx
1278                .global::<ActiveSettings>()
1279                .0
1280                .get(&workspace.project().downgrade());
1281
1282            let settings = settings.cloned();
1283
1284            let weak_workspace = cx.entity().downgrade();
1285
1286            let project_search = cx.new(|cx| ProjectSearch::new(workspace.project().clone(), cx));
1287            let project_search_view = cx.new(|cx| {
1288                ProjectSearchView::new(weak_workspace, project_search, window, cx, settings)
1289            });
1290
1291            workspace.add_item_to_active_pane(
1292                Box::new(project_search_view.clone()),
1293                None,
1294                true,
1295                window,
1296                cx,
1297            );
1298            project_search_view
1299        };
1300
1301        search.update(cx, |search, cx| {
1302            search.replace_enabled |= action.replace_enabled;
1303            if let Some(regex) = action.regex {
1304                search.set_search_option_enabled(SearchOptions::REGEX, regex, cx);
1305            }
1306            if let Some(case_sensitive) = action.case_sensitive {
1307                search.set_search_option_enabled(SearchOptions::CASE_SENSITIVE, case_sensitive, cx);
1308            }
1309            if let Some(whole_word) = action.whole_word {
1310                search.set_search_option_enabled(SearchOptions::WHOLE_WORD, whole_word, cx);
1311            }
1312            if let Some(include_ignored) = action.include_ignored {
1313                search.set_search_option_enabled(
1314                    SearchOptions::INCLUDE_IGNORED,
1315                    include_ignored,
1316                    cx,
1317                );
1318            }
1319            let query = action
1320                .query
1321                .as_deref()
1322                .filter(|q| !q.is_empty())
1323                .or(query.as_deref());
1324            if let Some(query) = query {
1325                search.set_query(query, window, cx);
1326            }
1327            if let Some(included_files) = action.included_files.as_deref() {
1328                search
1329                    .included_files_editor
1330                    .update(cx, |editor, cx| editor.set_text(included_files, window, cx));
1331                search.filters_enabled = true;
1332            }
1333            if let Some(excluded_files) = action.excluded_files.as_deref() {
1334                search
1335                    .excluded_files_editor
1336                    .update(cx, |editor, cx| editor.set_text(excluded_files, window, cx));
1337                search.filters_enabled = true;
1338            }
1339            search.focus_query_editor(window, cx)
1340        });
1341    }
1342
1343    fn prompt_to_save_if_dirty_then_search(
1344        &mut self,
1345        window: &mut Window,
1346        cx: &mut Context<Self>,
1347    ) -> Task<anyhow::Result<()>> {
1348        let project = self.entity.read(cx).project.clone();
1349
1350        let can_autosave = self.results_editor.can_autosave(cx);
1351        let autosave_setting = self.results_editor.workspace_settings(cx).autosave;
1352
1353        let will_autosave = can_autosave && autosave_setting.should_save_on_close();
1354
1355        let is_dirty = self.is_dirty(cx);
1356
1357        cx.spawn_in(window, async move |this, cx| {
1358            let skip_save_on_close = this
1359                .read_with(cx, |this, cx| {
1360                    this.workspace.read_with(cx, |workspace, cx| {
1361                        workspace::Pane::skip_save_on_close(&this.results_editor, workspace, cx)
1362                    })
1363                })?
1364                .unwrap_or(false);
1365
1366            let should_prompt_to_save = !skip_save_on_close && !will_autosave && is_dirty;
1367
1368            let should_search = if should_prompt_to_save {
1369                let options = &["Save", "Don't Save", "Cancel"];
1370                let result_channel = this.update_in(cx, |_, window, cx| {
1371                    window.prompt(
1372                        gpui::PromptLevel::Warning,
1373                        "Project search buffer contains unsaved edits. Do you want to save it?",
1374                        None,
1375                        options,
1376                        cx,
1377                    )
1378                })?;
1379                let result = result_channel.await?;
1380                let should_save = result == 0;
1381                if should_save {
1382                    this.update_in(cx, |this, window, cx| {
1383                        this.save(
1384                            SaveOptions {
1385                                format: true,
1386                                autosave: false,
1387                            },
1388                            project,
1389                            window,
1390                            cx,
1391                        )
1392                    })?
1393                    .await
1394                    .log_err();
1395                }
1396
1397                result != 2
1398            } else {
1399                true
1400            };
1401            if should_search {
1402                this.update(cx, |this, cx| {
1403                    this.search(false, cx);
1404                })?;
1405            }
1406            anyhow::Ok(())
1407        })
1408    }
1409
1410    fn search(&mut self, incremental: bool, cx: &mut Context<Self>) {
1411        let open_buffers = if self.included_opened_only {
1412            self.workspace
1413                .update(cx, |workspace, cx| self.open_buffers(cx, workspace))
1414                .ok()
1415        } else {
1416            None
1417        };
1418        if let Some(query) = self.build_search_query(cx, open_buffers) {
1419            self.entity
1420                .update(cx, |model, cx| model.search(query, incremental, cx));
1421        }
1422    }
1423
1424    pub fn search_query_text(&self, cx: &App) -> String {
1425        self.query_editor.read(cx).text(cx)
1426    }
1427
1428    fn build_search_query(
1429        &mut self,
1430        cx: &mut Context<Self>,
1431        open_buffers: Option<Vec<Entity<Buffer>>>,
1432    ) -> Option<SearchQuery> {
1433        // Do not bail early in this function, as we want to fill out `self.panels_with_errors`.
1434
1435        let text = self.search_query_text(cx);
1436        let included_files = self
1437            .filters_enabled
1438            .then(|| {
1439                match self.parse_path_matches(self.included_files_editor.read(cx).text(cx), cx) {
1440                    Ok(included_files) => {
1441                        let should_unmark_error =
1442                            self.panels_with_errors.remove(&InputPanel::Include);
1443                        if should_unmark_error.is_some() {
1444                            cx.notify();
1445                        }
1446                        included_files
1447                    }
1448                    Err(e) => {
1449                        let should_mark_error = self
1450                            .panels_with_errors
1451                            .insert(InputPanel::Include, e.to_string());
1452                        if should_mark_error.is_none() {
1453                            cx.notify();
1454                        }
1455                        PathMatcher::default()
1456                    }
1457                }
1458            })
1459            .unwrap_or(PathMatcher::default());
1460        let excluded_files = self
1461            .filters_enabled
1462            .then(|| {
1463                match self.parse_path_matches(self.excluded_files_editor.read(cx).text(cx), cx) {
1464                    Ok(excluded_files) => {
1465                        let should_unmark_error =
1466                            self.panels_with_errors.remove(&InputPanel::Exclude);
1467                        if should_unmark_error.is_some() {
1468                            cx.notify();
1469                        }
1470
1471                        excluded_files
1472                    }
1473                    Err(e) => {
1474                        let should_mark_error = self
1475                            .panels_with_errors
1476                            .insert(InputPanel::Exclude, e.to_string());
1477                        if should_mark_error.is_none() {
1478                            cx.notify();
1479                        }
1480                        PathMatcher::default()
1481                    }
1482                }
1483            })
1484            .unwrap_or(PathMatcher::default());
1485
1486        // If the project contains multiple visible worktrees, we match the
1487        // include/exclude patterns against full paths to allow them to be
1488        // disambiguated. For single worktree projects we use worktree relative
1489        // paths for convenience.
1490        let match_full_paths = self
1491            .entity
1492            .read(cx)
1493            .project
1494            .read(cx)
1495            .visible_worktrees(cx)
1496            .count()
1497            > 1;
1498
1499        let query = if self.search_options.contains(SearchOptions::REGEX) {
1500            match SearchQuery::regex(
1501                text,
1502                self.search_options.contains(SearchOptions::WHOLE_WORD),
1503                self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1504                self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1505                self.search_options
1506                    .contains(SearchOptions::ONE_MATCH_PER_LINE),
1507                included_files,
1508                excluded_files,
1509                match_full_paths,
1510                open_buffers,
1511            ) {
1512                Ok(query) => {
1513                    let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
1514                    if should_unmark_error.is_some() {
1515                        cx.notify();
1516                    }
1517
1518                    Some(query)
1519                }
1520                Err(e) => {
1521                    let should_mark_error = self
1522                        .panels_with_errors
1523                        .insert(InputPanel::Query, e.to_string());
1524                    if should_mark_error.is_none() {
1525                        cx.notify();
1526                    }
1527
1528                    None
1529                }
1530            }
1531        } else {
1532            match SearchQuery::text(
1533                text,
1534                self.search_options.contains(SearchOptions::WHOLE_WORD),
1535                self.search_options.contains(SearchOptions::CASE_SENSITIVE),
1536                self.search_options.contains(SearchOptions::INCLUDE_IGNORED),
1537                included_files,
1538                excluded_files,
1539                match_full_paths,
1540                open_buffers,
1541            ) {
1542                Ok(query) => {
1543                    let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
1544                    if should_unmark_error.is_some() {
1545                        cx.notify();
1546                    }
1547
1548                    Some(query)
1549                }
1550                Err(e) => {
1551                    let should_mark_error = self
1552                        .panels_with_errors
1553                        .insert(InputPanel::Query, e.to_string());
1554                    if should_mark_error.is_none() {
1555                        cx.notify();
1556                    }
1557
1558                    None
1559                }
1560            }
1561        };
1562        if !self.panels_with_errors.is_empty() {
1563            return None;
1564        }
1565        if query.as_ref().is_some_and(|query| query.is_empty()) {
1566            return None;
1567        }
1568        query
1569    }
1570
1571    fn open_buffers(&self, cx: &App, workspace: &Workspace) -> Vec<Entity<Buffer>> {
1572        let mut buffers = Vec::new();
1573        for editor in workspace.items_of_type::<Editor>(cx) {
1574            if let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() {
1575                buffers.push(buffer);
1576            }
1577        }
1578        buffers
1579    }
1580
1581    fn parse_path_matches(&self, text: String, cx: &App) -> anyhow::Result<PathMatcher> {
1582        let path_style = self.entity.read(cx).project.read(cx).path_style(cx);
1583        let queries = split_glob_patterns(&text)
1584            .into_iter()
1585            .map(str::trim)
1586            .filter(|maybe_glob_str| !maybe_glob_str.is_empty())
1587            .map(str::to_owned)
1588            .collect::<Vec<_>>();
1589        Ok(PathMatcher::new(&queries, path_style)?)
1590    }
1591
1592    fn select_match(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
1593        if let Some(index) = self.active_match_index {
1594            let match_ranges = self.entity.read(cx).match_ranges.clone();
1595
1596            if !EditorSettings::get_global(cx).search_wrap
1597                && ((direction == Direction::Next && index + 1 >= match_ranges.len())
1598                    || (direction == Direction::Prev && index == 0))
1599            {
1600                crate::show_no_more_matches(window, cx);
1601                return;
1602            }
1603
1604            let new_index = self.results_editor.update(cx, |editor, cx| {
1605                editor.match_index_for_direction(
1606                    &match_ranges,
1607                    index,
1608                    direction,
1609                    1,
1610                    SearchToken::default(),
1611                    window,
1612                    cx,
1613                )
1614            });
1615
1616            self.select_match_range(&match_ranges, new_index, window, cx);
1617        }
1618    }
1619
1620    fn select_first_match(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1621        self.entity.clone().update(cx, |entity, cx| {
1622            if !entity.match_ranges.is_empty() {
1623                self.active_match_index = Some(0);
1624                self.select_match_range(&entity.match_ranges, 0, window, cx);
1625            }
1626        })
1627    }
1628
1629    fn select_match_range(
1630        &mut self,
1631        match_ranges: &[Range<Anchor>],
1632        index: usize,
1633        window: &mut Window,
1634        cx: &mut App,
1635    ) {
1636        let Some(range) = match_ranges.get(index) else {
1637            return;
1638        };
1639        self.results_editor.update(cx, |editor, cx| {
1640            let range_to_select = editor.range_for_match(range);
1641            let autoscroll = if EditorSettings::get_global(cx).search.center_on_match {
1642                Autoscroll::center()
1643            } else {
1644                Autoscroll::fit()
1645            };
1646            editor.unfold_ranges(std::slice::from_ref(&range_to_select), false, true, cx);
1647            editor.change_selections(SelectionEffects::scroll(autoscroll), window, cx, |s| {
1648                s.select_ranges([range_to_select])
1649            });
1650        });
1651        self.highlight_matches(match_ranges, Some(index), cx);
1652    }
1653
1654    fn focus_query_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1655        self.query_editor.update(cx, |query_editor, cx| {
1656            query_editor.select_all(&SelectAll, window, cx);
1657        });
1658        let editor_handle = self.query_editor.focus_handle(cx);
1659        window.focus(&editor_handle, cx);
1660    }
1661
1662    fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
1663        self.set_search_editor(SearchInputKind::Query, query, window, cx);
1664        if EditorSettings::get_global(cx).use_smartcase_search
1665            && !query.is_empty()
1666            && self.search_options.contains(SearchOptions::CASE_SENSITIVE)
1667                != contains_uppercase(query)
1668        {
1669            self.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx)
1670        }
1671    }
1672
1673    fn set_search_editor(
1674        &mut self,
1675        kind: SearchInputKind,
1676        text: &str,
1677        window: &mut Window,
1678        cx: &mut Context<Self>,
1679    ) {
1680        let editor = match kind {
1681            SearchInputKind::Query => &self.query_editor,
1682            SearchInputKind::Include => &self.included_files_editor,
1683            SearchInputKind::Exclude => &self.excluded_files_editor,
1684        };
1685        editor.update(cx, |editor, cx| {
1686            editor.set_text(text, window, cx);
1687            editor.request_autoscroll(Autoscroll::fit(), cx);
1688        });
1689    }
1690
1691    fn focus_results_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1692        self.query_editor.update(cx, |query_editor, cx| {
1693            let cursor = query_editor.selections.newest_anchor().head();
1694            query_editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
1695                s.select_ranges([cursor..cursor])
1696            });
1697        });
1698        let results_handle = self.results_editor.focus_handle(cx);
1699        window.focus(&results_handle, cx);
1700    }
1701
1702    fn entity_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1703        let model = self.entity.read(cx);
1704        let search_input_confirmed = model.search_input_confirmed;
1705        let match_ranges = model.match_ranges.clone();
1706        let is_incremental_pending =
1707            model.pending_search.is_some() && EditorSettings::get_global(cx).search.search_on_input;
1708
1709        if match_ranges.is_empty() {
1710            if !is_incremental_pending {
1711                self.active_match_index = None;
1712            }
1713            self.results_editor.update(cx, |editor, cx| {
1714                editor.clear_background_highlights(HighlightKey::ProjectSearchView, cx);
1715            });
1716        } else {
1717            self.active_match_index = Some(0);
1718            self.update_match_index(cx);
1719            let prev_search_id = mem::replace(&mut self.search_id, self.entity.read(cx).search_id);
1720            let is_new_search = self.search_id != prev_search_id;
1721            self.results_editor.update(cx, |editor, cx| {
1722                if is_new_search {
1723                    let range_to_select = match_ranges
1724                        .first()
1725                        .map(|range| editor.range_for_match(range));
1726                    editor.change_selections(Default::default(), window, cx, |s| {
1727                        s.select_ranges(range_to_select)
1728                    });
1729                    editor.scroll(Point::default(), Some(Axis::Vertical), window, cx);
1730                }
1731            });
1732            let should_auto_focus =
1733                search_input_confirmed || !EditorSettings::get_global(cx).search.search_on_input;
1734            if is_new_search
1735                && self.query_editor.focus_handle(cx).is_focused(window)
1736                && should_auto_focus
1737            {
1738                self.focus_results_editor(window, cx);
1739            }
1740        }
1741
1742        cx.emit(ViewEvent::UpdateTab);
1743        cx.notify();
1744
1745        if self.pending_replace_all && self.entity.read(cx).pending_search.is_none() {
1746            self.replace_all(&ReplaceAll, window, cx);
1747        }
1748    }
1749
1750    fn update_match_index(&mut self, cx: &mut Context<Self>) {
1751        let results_editor = self.results_editor.read(cx);
1752        let newest_anchor = results_editor.selections.newest_anchor().head();
1753        let buffer_snapshot = results_editor.buffer().read(cx).snapshot(cx);
1754        let new_index = self.entity.update(cx, |this, cx| {
1755            let new_index = active_match_index(
1756                Direction::Next,
1757                &this.match_ranges,
1758                &newest_anchor,
1759                &buffer_snapshot,
1760            );
1761
1762            self.highlight_matches(&this.match_ranges, new_index, cx);
1763            new_index
1764        });
1765
1766        if self.active_match_index != new_index {
1767            self.active_match_index = new_index;
1768            cx.notify();
1769        }
1770    }
1771
1772    #[ztracing::instrument(skip_all)]
1773    fn highlight_matches(
1774        &self,
1775        match_ranges: &[Range<Anchor>],
1776        active_index: Option<usize>,
1777        cx: &mut App,
1778    ) {
1779        self.results_editor.update(cx, |editor, cx| {
1780            editor.highlight_background(
1781                HighlightKey::ProjectSearchView,
1782                match_ranges,
1783                move |index, theme| {
1784                    if active_index == Some(*index) {
1785                        theme.colors().search_active_match_background
1786                    } else {
1787                        theme.colors().search_match_background
1788                    }
1789                },
1790                cx,
1791            );
1792        });
1793    }
1794
1795    pub fn has_matches(&self) -> bool {
1796        self.active_match_index.is_some()
1797    }
1798
1799    fn landing_text_minor(&self, cx: &App) -> impl IntoElement {
1800        let focus_handle = self.focus_handle.clone();
1801        v_flex()
1802            .gap_1()
1803            .child(
1804                Label::new(if EditorSettings::get_global(cx).search.search_on_input {
1805                    "Start typing to search. For more options:"
1806                } else {
1807                    "Hit enter to search. For more options:"
1808                })
1809                .color(Color::Muted)
1810                .mb_2(),
1811            )
1812            .child(
1813                Button::new("filter-paths", "Include/exclude specific paths")
1814                    .start_icon(Icon::new(IconName::Filter).size(IconSize::Small))
1815                    .key_binding(KeyBinding::for_action_in(&ToggleFilters, &focus_handle, cx))
1816                    .on_click(|_event, window, cx| {
1817                        window.dispatch_action(ToggleFilters.boxed_clone(), cx)
1818                    }),
1819            )
1820            .child(
1821                Button::new("find-replace", "Find and replace")
1822                    .start_icon(Icon::new(IconName::Replace).size(IconSize::Small))
1823                    .key_binding(KeyBinding::for_action_in(&ToggleReplace, &focus_handle, cx))
1824                    .on_click(|_event, window, cx| {
1825                        window.dispatch_action(ToggleReplace.boxed_clone(), cx)
1826                    }),
1827            )
1828            .child(
1829                Button::new("regex", "Match with regex")
1830                    .start_icon(Icon::new(IconName::Regex).size(IconSize::Small))
1831                    .key_binding(KeyBinding::for_action_in(&ToggleRegex, &focus_handle, cx))
1832                    .on_click(|_event, window, cx| {
1833                        window.dispatch_action(ToggleRegex.boxed_clone(), cx)
1834                    }),
1835            )
1836            .child(
1837                Button::new("match-case", "Match case")
1838                    .start_icon(Icon::new(IconName::CaseSensitive).size(IconSize::Small))
1839                    .key_binding(KeyBinding::for_action_in(
1840                        &ToggleCaseSensitive,
1841                        &focus_handle,
1842                        cx,
1843                    ))
1844                    .on_click(|_event, window, cx| {
1845                        window.dispatch_action(ToggleCaseSensitive.boxed_clone(), cx)
1846                    }),
1847            )
1848            .child(
1849                Button::new("match-whole-words", "Match whole words")
1850                    .start_icon(Icon::new(IconName::WholeWord).size(IconSize::Small))
1851                    .key_binding(KeyBinding::for_action_in(
1852                        &ToggleWholeWord,
1853                        &focus_handle,
1854                        cx,
1855                    ))
1856                    .on_click(|_event, window, cx| {
1857                        window.dispatch_action(ToggleWholeWord.boxed_clone(), cx)
1858                    }),
1859            )
1860    }
1861
1862    fn border_color_for(&self, panel: InputPanel, cx: &App) -> Hsla {
1863        if self.panels_with_errors.contains_key(&panel) {
1864            Color::Error.color(cx)
1865        } else {
1866            cx.theme().colors().border
1867        }
1868    }
1869
1870    fn move_focus_to_results(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1871        if !self.results_editor.focus_handle(cx).is_focused(window)
1872            && !self.entity.read(cx).match_ranges.is_empty()
1873        {
1874            cx.stop_propagation();
1875            self.focus_results_editor(window, cx)
1876        }
1877    }
1878
1879    #[cfg(any(test, feature = "test-support"))]
1880    pub fn results_editor(&self) -> &Entity<Editor> {
1881        &self.results_editor
1882    }
1883
1884    fn adjust_query_regex_language(&self, cx: &mut App) {
1885        let enable = self.search_options.contains(SearchOptions::REGEX);
1886        let query_buffer = self
1887            .query_editor
1888            .read(cx)
1889            .buffer()
1890            .read(cx)
1891            .as_singleton()
1892            .expect("query editor should be backed by a singleton buffer");
1893        if enable {
1894            if let Some(regex_language) = self.regex_language.clone() {
1895                query_buffer.update(cx, |query_buffer, cx| {
1896                    query_buffer.set_language(Some(regex_language), cx);
1897                })
1898            }
1899        } else {
1900            query_buffer.update(cx, |query_buffer, cx| {
1901                query_buffer.set_language(None, cx);
1902            })
1903        }
1904    }
1905}
1906
1907fn buffer_search_query(
1908    workspace: &mut Workspace,
1909    item: &dyn ItemHandle,
1910    cx: &mut Context<Workspace>,
1911) -> Option<String> {
1912    let buffer_search_bar = workspace
1913        .pane_for(item)
1914        .and_then(|pane| {
1915            pane.read(cx)
1916                .toolbar()
1917                .read(cx)
1918                .item_of_type::<BufferSearchBar>()
1919        })?
1920        .read(cx);
1921    if buffer_search_bar.query_editor_focused() {
1922        let buffer_search_query = buffer_search_bar.query(cx);
1923        if !buffer_search_query.is_empty() {
1924            return Some(buffer_search_query);
1925        }
1926    }
1927    None
1928}
1929
1930impl Default for ProjectSearchBar {
1931    fn default() -> Self {
1932        Self::new()
1933    }
1934}
1935
1936impl ProjectSearchBar {
1937    pub fn new() -> Self {
1938        Self {
1939            active_project_search: None,
1940            subscription: None,
1941        }
1942    }
1943
1944    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1945        if let Some(search_view) = self.active_project_search.as_ref() {
1946            search_view.update(cx, |search_view, cx| {
1947                if search_view
1948                    .replacement_editor
1949                    .focus_handle(cx)
1950                    .is_focused(window)
1951                {
1952                    return;
1953                }
1954
1955                cx.stop_propagation();
1956                if EditorSettings::get_global(cx).search.search_on_input {
1957                    // Results are already available from incremental search.
1958                    // Mark confirmed so entity_changed auto-focuses when
1959                    // a pending search completes, and select the first match
1960                    // if results already exist.
1961                    search_view
1962                        .entity
1963                        .update(cx, |model, _| model.search_input_confirmed = true);
1964                    if search_view.has_matches() {
1965                        search_view.select_first_match(window, cx);
1966                        search_view.focus_results_editor(window, cx);
1967                    }
1968                } else {
1969                    search_view
1970                        .prompt_to_save_if_dirty_then_search(window, cx)
1971                        .detach_and_log_err(cx);
1972                }
1973            });
1974        }
1975    }
1976
1977    fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
1978        self.cycle_field(Direction::Next, window, cx);
1979    }
1980
1981    fn backtab(&mut self, _: &Backtab, window: &mut Window, cx: &mut Context<Self>) {
1982        self.cycle_field(Direction::Prev, window, cx);
1983    }
1984
1985    fn focus_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1986        if let Some(search_view) = self.active_project_search.as_ref() {
1987            search_view.update(cx, |search_view, cx| {
1988                search_view.query_editor.focus_handle(cx).focus(window, cx);
1989            });
1990        }
1991    }
1992
1993    fn cycle_field(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
1994        let active_project_search = match &self.active_project_search {
1995            Some(active_project_search) => active_project_search,
1996            None => return,
1997        };
1998
1999        active_project_search.update(cx, |project_view, cx| {
2000            let mut views = vec![project_view.query_editor.focus_handle(cx)];
2001            if project_view.replace_enabled {
2002                views.push(project_view.replacement_editor.focus_handle(cx));
2003            }
2004            if project_view.filters_enabled {
2005                views.extend([
2006                    project_view.included_files_editor.focus_handle(cx),
2007                    project_view.excluded_files_editor.focus_handle(cx),
2008                ]);
2009            }
2010            let current_index = match views.iter().position(|focus| focus.is_focused(window)) {
2011                Some(index) => index,
2012                None => return,
2013            };
2014
2015            let new_index = match direction {
2016                Direction::Next => (current_index + 1) % views.len(),
2017                Direction::Prev if current_index == 0 => views.len() - 1,
2018                Direction::Prev => (current_index - 1) % views.len(),
2019            };
2020            let next_focus_handle = &views[new_index];
2021            window.focus(next_focus_handle, cx);
2022            cx.stop_propagation();
2023        });
2024    }
2025
2026    pub(crate) fn toggle_search_option(
2027        &mut self,
2028        option: SearchOptions,
2029        window: &mut Window,
2030        cx: &mut Context<Self>,
2031    ) -> bool {
2032        if self.active_project_search.is_none() {
2033            return false;
2034        }
2035
2036        cx.spawn_in(window, async move |this, cx| {
2037            let task = this.update_in(cx, |this, window, cx| {
2038                let search_view = this.active_project_search.as_ref()?;
2039                search_view.update(cx, |search_view, cx| {
2040                    search_view.toggle_search_option(option, cx);
2041                    search_view
2042                        .entity
2043                        .read(cx)
2044                        .active_query
2045                        .is_some()
2046                        .then(|| search_view.prompt_to_save_if_dirty_then_search(window, cx))
2047                })
2048            })?;
2049            if let Some(task) = task {
2050                task.await?;
2051            }
2052            this.update(cx, |_, cx| {
2053                cx.notify();
2054            })?;
2055            anyhow::Ok(())
2056        })
2057        .detach();
2058        true
2059    }
2060
2061    fn toggle_replace(&mut self, _: &ToggleReplace, window: &mut Window, cx: &mut Context<Self>) {
2062        if let Some(search) = &self.active_project_search {
2063            search.update(cx, |this, cx| {
2064                this.replace_enabled = !this.replace_enabled;
2065                let editor_to_focus = if this.replace_enabled {
2066                    this.replacement_editor.focus_handle(cx)
2067                } else {
2068                    this.query_editor.focus_handle(cx)
2069                };
2070                window.focus(&editor_to_focus, cx);
2071                cx.notify();
2072            });
2073        }
2074    }
2075
2076    fn toggle_filters(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
2077        if let Some(search_view) = self.active_project_search.as_ref() {
2078            search_view.update(cx, |search_view, cx| {
2079                search_view.toggle_filters(cx);
2080                search_view
2081                    .included_files_editor
2082                    .update(cx, |_, cx| cx.notify());
2083                search_view
2084                    .excluded_files_editor
2085                    .update(cx, |_, cx| cx.notify());
2086                window.refresh();
2087                cx.notify();
2088            });
2089            cx.notify();
2090            true
2091        } else {
2092            false
2093        }
2094    }
2095
2096    fn toggle_opened_only(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
2097        if self.active_project_search.is_none() {
2098            return false;
2099        }
2100
2101        cx.spawn_in(window, async move |this, cx| {
2102            let task = this.update_in(cx, |this, window, cx| {
2103                let search_view = this.active_project_search.as_ref()?;
2104                search_view.update(cx, |search_view, cx| {
2105                    search_view.toggle_opened_only(window, cx);
2106                    search_view
2107                        .entity
2108                        .read(cx)
2109                        .active_query
2110                        .is_some()
2111                        .then(|| search_view.prompt_to_save_if_dirty_then_search(window, cx))
2112                })
2113            })?;
2114            if let Some(task) = task {
2115                task.await?;
2116            }
2117            this.update(cx, |_, cx| {
2118                cx.notify();
2119            })?;
2120            anyhow::Ok(())
2121        })
2122        .detach();
2123        true
2124    }
2125
2126    fn is_opened_only_enabled(&self, cx: &App) -> bool {
2127        if let Some(search_view) = self.active_project_search.as_ref() {
2128            search_view.read(cx).included_opened_only
2129        } else {
2130            false
2131        }
2132    }
2133
2134    fn move_focus_to_results(&self, window: &mut Window, cx: &mut Context<Self>) {
2135        if let Some(search_view) = self.active_project_search.as_ref() {
2136            search_view.update(cx, |search_view, cx| {
2137                search_view.move_focus_to_results(window, cx);
2138            });
2139            cx.notify();
2140        }
2141    }
2142
2143    fn next_history_query(
2144        &mut self,
2145        _: &NextHistoryQuery,
2146        window: &mut Window,
2147        cx: &mut Context<Self>,
2148    ) {
2149        if let Some(search_view) = self.active_project_search.as_ref() {
2150            search_view.update(cx, |search_view, cx| {
2151                for (editor, kind) in [
2152                    (search_view.query_editor.clone(), SearchInputKind::Query),
2153                    (
2154                        search_view.included_files_editor.clone(),
2155                        SearchInputKind::Include,
2156                    ),
2157                    (
2158                        search_view.excluded_files_editor.clone(),
2159                        SearchInputKind::Exclude,
2160                    ),
2161                ] {
2162                    if editor.focus_handle(cx).is_focused(window) {
2163                        if !should_navigate_history(&editor, HistoryNavigationDirection::Next, cx) {
2164                            cx.propagate();
2165                            return;
2166                        }
2167
2168                        let new_query = search_view.entity.update(cx, |model, cx| {
2169                            let project = model.project.clone();
2170
2171                            if let Some(new_query) = project.update(cx, |project, _| {
2172                                project
2173                                    .search_history_mut(kind)
2174                                    .next(model.cursor_mut(kind))
2175                                    .map(str::to_string)
2176                            }) {
2177                                Some(new_query)
2178                            } else {
2179                                model.cursor_mut(kind).take_draft()
2180                            }
2181                        });
2182                        if let Some(new_query) = new_query {
2183                            search_view.set_search_editor(kind, &new_query, window, cx);
2184                        }
2185                    }
2186                }
2187            });
2188        }
2189    }
2190
2191    fn previous_history_query(
2192        &mut self,
2193        _: &PreviousHistoryQuery,
2194        window: &mut Window,
2195        cx: &mut Context<Self>,
2196    ) {
2197        if let Some(search_view) = self.active_project_search.as_ref() {
2198            search_view.update(cx, |search_view, cx| {
2199                for (editor, kind) in [
2200                    (search_view.query_editor.clone(), SearchInputKind::Query),
2201                    (
2202                        search_view.included_files_editor.clone(),
2203                        SearchInputKind::Include,
2204                    ),
2205                    (
2206                        search_view.excluded_files_editor.clone(),
2207                        SearchInputKind::Exclude,
2208                    ),
2209                ] {
2210                    if editor.focus_handle(cx).is_focused(window) {
2211                        if !should_navigate_history(
2212                            &editor,
2213                            HistoryNavigationDirection::Previous,
2214                            cx,
2215                        ) {
2216                            cx.propagate();
2217                            return;
2218                        }
2219
2220                        if editor.read(cx).text(cx).is_empty()
2221                            && let Some(new_query) = search_view
2222                                .entity
2223                                .read(cx)
2224                                .project
2225                                .read(cx)
2226                                .search_history(kind)
2227                                .current(search_view.entity.read(cx).cursor(kind))
2228                                .map(str::to_string)
2229                        {
2230                            search_view.set_search_editor(kind, &new_query, window, cx);
2231                            return;
2232                        }
2233
2234                        let current_query = editor.read(cx).text(cx);
2235                        if let Some(new_query) = search_view.entity.update(cx, |model, cx| {
2236                            let project = model.project.clone();
2237                            project.update(cx, |project, _| {
2238                                project
2239                                    .search_history_mut(kind)
2240                                    .previous(model.cursor_mut(kind), &current_query)
2241                                    .map(str::to_string)
2242                            })
2243                        }) {
2244                            search_view.set_search_editor(kind, &new_query, window, cx);
2245                        }
2246                    }
2247                }
2248            });
2249        }
2250    }
2251
2252    fn select_next_match(
2253        &mut self,
2254        _: &SelectNextMatch,
2255        window: &mut Window,
2256        cx: &mut Context<Self>,
2257    ) {
2258        if let Some(search) = self.active_project_search.as_ref() {
2259            search.update(cx, |this, cx| {
2260                this.select_match(Direction::Next, window, cx);
2261            })
2262        }
2263    }
2264
2265    fn select_prev_match(
2266        &mut self,
2267        _: &SelectPreviousMatch,
2268        window: &mut Window,
2269        cx: &mut Context<Self>,
2270    ) {
2271        if let Some(search) = self.active_project_search.as_ref() {
2272            search.update(cx, |this, cx| {
2273                this.select_match(Direction::Prev, window, cx);
2274            })
2275        }
2276    }
2277}
2278
2279impl Render for ProjectSearchBar {
2280    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2281        let Some(search) = self.active_project_search.clone() else {
2282            return div().into_any_element();
2283        };
2284        let search = search.read(cx);
2285        let focus_handle = search.focus_handle(cx);
2286
2287        let container_width = window.viewport_size().width;
2288        let input_width = SearchInputWidth::calc_width(container_width);
2289
2290        let input_base_styles = |panel: InputPanel| {
2291            input_base_styles(search.border_color_for(panel, cx), |div| match panel {
2292                InputPanel::Query | InputPanel::Replacement => div.w(input_width),
2293                InputPanel::Include | InputPanel::Exclude => div.flex_grow(),
2294            })
2295        };
2296        let theme_colors = cx.theme().colors();
2297        let project_search = search.entity.read(cx);
2298        let limit_reached = project_search.limit_reached;
2299        let is_search_underway = project_search.pending_search.is_some();
2300
2301        let color_override = match (
2302            &project_search.pending_search,
2303            project_search.no_results,
2304            &project_search.active_query,
2305            &project_search.last_search_query_text,
2306        ) {
2307            (None, Some(true), Some(q), Some(p)) if q.as_str() == p => Some(Color::Error),
2308            _ => None,
2309        };
2310
2311        let match_text = search
2312            .active_match_index
2313            .and_then(|index| {
2314                let index = index + 1;
2315                let match_quantity = project_search.match_ranges.len();
2316                if match_quantity > 0 {
2317                    debug_assert!(match_quantity >= index);
2318                    if limit_reached {
2319                        Some(format!("{index}/{match_quantity}+"))
2320                    } else {
2321                        Some(format!("{index}/{match_quantity}"))
2322                    }
2323                } else {
2324                    None
2325                }
2326            })
2327            .unwrap_or_else(|| "0/0".to_string());
2328
2329        let query_focus = search.query_editor.focus_handle(cx);
2330
2331        let query_column = input_base_styles(InputPanel::Query)
2332            .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
2333            .on_action(cx.listener(|this, action, window, cx| {
2334                this.previous_history_query(action, window, cx)
2335            }))
2336            .on_action(
2337                cx.listener(|this, action, window, cx| this.next_history_query(action, window, cx)),
2338            )
2339            .child(div().flex_1().py_1().child(render_text_input(
2340                &search.query_editor,
2341                color_override,
2342                cx,
2343            )))
2344            .child(
2345                h_flex()
2346                    .gap_1()
2347                    .child(SearchOption::CaseSensitive.as_button(
2348                        search.search_options,
2349                        SearchSource::Project(cx),
2350                        focus_handle.clone(),
2351                    ))
2352                    .child(SearchOption::WholeWord.as_button(
2353                        search.search_options,
2354                        SearchSource::Project(cx),
2355                        focus_handle.clone(),
2356                    ))
2357                    .child(SearchOption::Regex.as_button(
2358                        search.search_options,
2359                        SearchSource::Project(cx),
2360                        focus_handle.clone(),
2361                    )),
2362            );
2363
2364        let matches_column = h_flex()
2365            .ml_1()
2366            .pl_1p5()
2367            .border_l_1()
2368            .border_color(theme_colors.border_variant)
2369            .child(render_action_button(
2370                "project-search-nav-button",
2371                IconName::ChevronLeft,
2372                search
2373                    .active_match_index
2374                    .is_none()
2375                    .then_some(ActionButtonState::Disabled),
2376                "Select Previous Match",
2377                &SelectPreviousMatch,
2378                query_focus.clone(),
2379            ))
2380            .child(render_action_button(
2381                "project-search-nav-button",
2382                IconName::ChevronRight,
2383                search
2384                    .active_match_index
2385                    .is_none()
2386                    .then_some(ActionButtonState::Disabled),
2387                "Select Next Match",
2388                &SelectNextMatch,
2389                query_focus.clone(),
2390            ))
2391            .child(
2392                div()
2393                    .id("matches")
2394                    .ml_2()
2395                    .min_w(rems_from_px(40.))
2396                    .child(
2397                        h_flex()
2398                            .gap_1p5()
2399                            .child(
2400                                Label::new(match_text)
2401                                    .size(LabelSize::Small)
2402                                    .when(search.active_match_index.is_some(), |this| {
2403                                        this.color(Color::Disabled)
2404                                    }),
2405                            )
2406                            .when(is_search_underway, |this| {
2407                                this.child(
2408                                    Icon::new(IconName::ArrowCircle)
2409                                        .color(Color::Accent)
2410                                        .size(IconSize::Small)
2411                                        .with_rotate_animation(2)
2412                                        .into_any_element(),
2413                                )
2414                            }),
2415                    )
2416                    .when(limit_reached, |this| {
2417                        this.tooltip(Tooltip::text(
2418                            "Search Limits Reached\nTry narrowing your search",
2419                        ))
2420                    }),
2421            );
2422
2423        let mode_column = h_flex()
2424            .gap_1()
2425            .min_w_64()
2426            .child(
2427                IconButton::new("project-search-filter-button", IconName::Filter)
2428                    .shape(IconButtonShape::Square)
2429                    .tooltip(|_window, cx| {
2430                        Tooltip::for_action("Toggle Filters", &ToggleFilters, cx)
2431                    })
2432                    .on_click(cx.listener(|this, _, window, cx| {
2433                        this.toggle_filters(window, cx);
2434                    }))
2435                    .toggle_state(
2436                        self.active_project_search
2437                            .as_ref()
2438                            .map(|search| search.read(cx).filters_enabled)
2439                            .unwrap_or_default(),
2440                    )
2441                    .tooltip({
2442                        let focus_handle = focus_handle.clone();
2443                        move |_window, cx| {
2444                            Tooltip::for_action_in(
2445                                "Toggle Filters",
2446                                &ToggleFilters,
2447                                &focus_handle,
2448                                cx,
2449                            )
2450                        }
2451                    }),
2452            )
2453            .child(render_action_button(
2454                "project-search",
2455                IconName::Replace,
2456                self.active_project_search
2457                    .as_ref()
2458                    .map(|search| search.read(cx).replace_enabled)
2459                    .and_then(|enabled| enabled.then_some(ActionButtonState::Toggled)),
2460                "Toggle Replace",
2461                &ToggleReplace,
2462                focus_handle.clone(),
2463            ))
2464            .child(matches_column);
2465
2466        let is_collapsed = search.results_editor.read(cx).has_any_buffer_folded(cx);
2467
2468        let (icon, tooltip_label) = if is_collapsed {
2469            (IconName::ChevronUpDown, "Expand All Search Results")
2470        } else {
2471            (IconName::ChevronDownUp, "Collapse All Search Results")
2472        };
2473
2474        let expand_button = IconButton::new("project-search-collapse-expand", icon)
2475            .shape(IconButtonShape::Square)
2476            .tooltip(move |_, cx| {
2477                Tooltip::for_action_in(
2478                    tooltip_label,
2479                    &ToggleAllSearchResults,
2480                    &query_focus.clone(),
2481                    cx,
2482                )
2483            })
2484            .on_click(cx.listener(|this, _, window, cx| {
2485                if let Some(active_view) = &this.active_project_search {
2486                    active_view.update(cx, |active_view, cx| {
2487                        active_view.toggle_all_search_results(&ToggleAllSearchResults, window, cx);
2488                    })
2489                }
2490            }));
2491
2492        let search_line = h_flex()
2493            .pl_0p5()
2494            .w_full()
2495            .gap_2()
2496            .child(expand_button)
2497            .child(query_column)
2498            .child(mode_column);
2499
2500        let replace_line = search.replace_enabled.then(|| {
2501            let replace_column = input_base_styles(InputPanel::Replacement).child(
2502                div().flex_1().py_1().child(render_text_input(
2503                    &search.replacement_editor,
2504                    None,
2505                    cx,
2506                )),
2507            );
2508
2509            let focus_handle = search.replacement_editor.read(cx).focus_handle(cx);
2510            let replace_actions = h_flex()
2511                .min_w_64()
2512                .gap_1()
2513                .child(render_action_button(
2514                    "project-search-replace-button",
2515                    IconName::ReplaceNext,
2516                    is_search_underway.then_some(ActionButtonState::Disabled),
2517                    "Replace Next Match",
2518                    &ReplaceNext,
2519                    focus_handle.clone(),
2520                ))
2521                .child(render_action_button(
2522                    "project-search-replace-button",
2523                    IconName::ReplaceAll,
2524                    Default::default(),
2525                    "Replace All Matches",
2526                    &ReplaceAll,
2527                    focus_handle,
2528                ));
2529
2530            h_flex()
2531                .w_full()
2532                .gap_2()
2533                .child(alignment_element())
2534                .child(replace_column)
2535                .child(replace_actions)
2536        });
2537
2538        let filter_line = search.filters_enabled.then(|| {
2539            let include = input_base_styles(InputPanel::Include)
2540                .on_action(cx.listener(|this, action, window, cx| {
2541                    this.previous_history_query(action, window, cx)
2542                }))
2543                .on_action(cx.listener(|this, action, window, cx| {
2544                    this.next_history_query(action, window, cx)
2545                }))
2546                .child(render_text_input(&search.included_files_editor, None, cx));
2547            let exclude = input_base_styles(InputPanel::Exclude)
2548                .on_action(cx.listener(|this, action, window, cx| {
2549                    this.previous_history_query(action, window, cx)
2550                }))
2551                .on_action(cx.listener(|this, action, window, cx| {
2552                    this.next_history_query(action, window, cx)
2553                }))
2554                .child(render_text_input(&search.excluded_files_editor, None, cx));
2555            let mode_column = h_flex()
2556                .gap_1()
2557                .min_w_64()
2558                .child(
2559                    IconButton::new("project-search-opened-only", IconName::FolderSearch)
2560                        .shape(IconButtonShape::Square)
2561                        .toggle_state(self.is_opened_only_enabled(cx))
2562                        .tooltip(Tooltip::text("Only Search Open Files"))
2563                        .on_click(cx.listener(|this, _, window, cx| {
2564                            this.toggle_opened_only(window, cx);
2565                        })),
2566                )
2567                .child(SearchOption::IncludeIgnored.as_button(
2568                    search.search_options,
2569                    SearchSource::Project(cx),
2570                    focus_handle,
2571                ));
2572
2573            h_flex()
2574                .w_full()
2575                .gap_2()
2576                .child(alignment_element())
2577                .child(
2578                    h_flex()
2579                        .w(input_width)
2580                        .gap_2()
2581                        .child(include)
2582                        .child(exclude),
2583                )
2584                .child(mode_column)
2585        });
2586
2587        let mut key_context = KeyContext::default();
2588        key_context.add("ProjectSearchBar");
2589        if search
2590            .replacement_editor
2591            .focus_handle(cx)
2592            .is_focused(window)
2593        {
2594            key_context.add("in_replace");
2595        }
2596
2597        let query_error_line = search
2598            .panels_with_errors
2599            .get(&InputPanel::Query)
2600            .map(|error| {
2601                Label::new(error)
2602                    .size(LabelSize::Small)
2603                    .color(Color::Error)
2604                    .mt_neg_1()
2605                    .ml_2()
2606            });
2607
2608        let filter_error_line = search
2609            .panels_with_errors
2610            .get(&InputPanel::Include)
2611            .or_else(|| search.panels_with_errors.get(&InputPanel::Exclude))
2612            .map(|error| {
2613                Label::new(error)
2614                    .size(LabelSize::Small)
2615                    .color(Color::Error)
2616                    .mt_neg_1()
2617                    .ml_2()
2618            });
2619
2620        v_flex()
2621            .gap_2()
2622            .w_full()
2623            .key_context(key_context)
2624            .on_action(cx.listener(|this, _: &ToggleFocus, window, cx| {
2625                this.move_focus_to_results(window, cx)
2626            }))
2627            .on_action(cx.listener(|this, _: &ToggleFilters, window, cx| {
2628                this.toggle_filters(window, cx);
2629            }))
2630            .capture_action(cx.listener(Self::tab))
2631            .capture_action(cx.listener(Self::backtab))
2632            .on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
2633            .on_action(cx.listener(|this, action, window, cx| {
2634                this.toggle_replace(action, window, cx);
2635            }))
2636            .on_action(cx.listener(|this, _: &ToggleWholeWord, window, cx| {
2637                this.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
2638            }))
2639            .on_action(cx.listener(|this, _: &ToggleCaseSensitive, window, cx| {
2640                this.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
2641            }))
2642            .on_action(cx.listener(|this, action, window, cx| {
2643                if let Some(search) = this.active_project_search.as_ref() {
2644                    search.update(cx, |this, cx| {
2645                        this.replace_next(action, window, cx);
2646                    })
2647                }
2648            }))
2649            .on_action(cx.listener(|this, action, window, cx| {
2650                if let Some(search) = this.active_project_search.as_ref() {
2651                    search.update(cx, |this, cx| {
2652                        this.replace_all(action, window, cx);
2653                    })
2654                }
2655            }))
2656            .when(search.filters_enabled, |this| {
2657                this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, window, cx| {
2658                    this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, window, cx);
2659                }))
2660            })
2661            .on_action(cx.listener(Self::select_next_match))
2662            .on_action(cx.listener(Self::select_prev_match))
2663            .child(search_line)
2664            .children(query_error_line)
2665            .children(replace_line)
2666            .children(filter_line)
2667            .children(filter_error_line)
2668            .into_any_element()
2669    }
2670}
2671
2672impl EventEmitter<ToolbarItemEvent> for ProjectSearchBar {}
2673
2674impl ToolbarItemView for ProjectSearchBar {
2675    fn set_active_pane_item(
2676        &mut self,
2677        active_pane_item: Option<&dyn ItemHandle>,
2678        _: &mut Window,
2679        cx: &mut Context<Self>,
2680    ) -> ToolbarItemLocation {
2681        cx.notify();
2682        self.subscription = None;
2683        self.active_project_search = None;
2684        if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
2685            self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
2686            self.active_project_search = Some(search);
2687            ToolbarItemLocation::PrimaryLeft {}
2688        } else {
2689            ToolbarItemLocation::Hidden
2690        }
2691    }
2692}
2693
2694fn register_workspace_action<A: Action>(
2695    workspace: &mut Workspace,
2696    callback: fn(&mut ProjectSearchBar, &A, &mut Window, &mut Context<ProjectSearchBar>),
2697) {
2698    workspace.register_action(move |workspace, action: &A, window, cx| {
2699        if workspace.has_active_modal(window, cx) && !workspace.hide_modal(window, cx) {
2700            cx.propagate();
2701            return;
2702        }
2703
2704        workspace.active_pane().update(cx, |pane, cx| {
2705            pane.toolbar().update(cx, move |workspace, cx| {
2706                if let Some(search_bar) = workspace.item_of_type::<ProjectSearchBar>() {
2707                    search_bar.update(cx, move |search_bar, cx| {
2708                        if search_bar.active_project_search.is_some() {
2709                            callback(search_bar, action, window, cx);
2710                            cx.notify();
2711                        } else {
2712                            cx.propagate();
2713                        }
2714                    });
2715                }
2716            });
2717        })
2718    });
2719}
2720
2721fn register_workspace_action_for_present_search<A: Action>(
2722    workspace: &mut Workspace,
2723    callback: fn(&mut Workspace, &A, &mut Window, &mut Context<Workspace>),
2724) {
2725    workspace.register_action(move |workspace, action: &A, window, cx| {
2726        if workspace.has_active_modal(window, cx) && !workspace.hide_modal(window, cx) {
2727            cx.propagate();
2728            return;
2729        }
2730
2731        let should_notify = workspace
2732            .active_pane()
2733            .read(cx)
2734            .toolbar()
2735            .read(cx)
2736            .item_of_type::<ProjectSearchBar>()
2737            .map(|search_bar| search_bar.read(cx).active_project_search.is_some())
2738            .unwrap_or(false);
2739        if should_notify {
2740            callback(workspace, action, window, cx);
2741            cx.notify();
2742        } else {
2743            cx.propagate();
2744        }
2745    });
2746}
2747
2748#[cfg(any(test, feature = "test-support"))]
2749pub fn perform_project_search(
2750    search_view: &Entity<ProjectSearchView>,
2751    text: impl Into<std::sync::Arc<str>>,
2752    cx: &mut gpui::VisualTestContext,
2753) {
2754    cx.run_until_parked();
2755    search_view.update_in(cx, |search_view, window, cx| {
2756        search_view.query_editor.update(cx, |query_editor, cx| {
2757            query_editor.set_text(text, window, cx)
2758        });
2759        search_view.search(false, cx);
2760    });
2761    cx.run_until_parked();
2762}
2763
2764#[cfg(test)]
2765pub mod tests {
2766    use std::{
2767        cell::RefCell,
2768        path::PathBuf,
2769        rc::Rc,
2770        sync::{
2771            Arc,
2772            atomic::{self, AtomicUsize},
2773        },
2774        time::Duration,
2775    };
2776
2777    use super::*;
2778    use editor::{DisplayPoint, display_map::DisplayRow};
2779    use gpui::{Action, TestAppContext, VisualTestContext, WindowHandle};
2780    use language::{FakeLspAdapter, rust_lang};
2781    use multi_buffer::Event as MultiBufferEvent;
2782    use pretty_assertions::assert_eq;
2783    use project::{FakeFs, Fs};
2784    use serde_json::json;
2785    use settings::{
2786        InlayHintSettingsContent, SettingsStore, ThemeColorsContent, ThemeStyleContent,
2787    };
2788    use util::{path, paths::PathStyle, rel_path::rel_path};
2789    use util_macros::perf;
2790    use workspace::{DeploySearch, MultiWorkspace};
2791
2792    fn take_updated_files(events: &RefCell<Vec<MultiBufferEvent>>) -> Vec<String> {
2793        let mut files: Vec<String> = Vec::new();
2794        for event in events.borrow_mut().drain(..) {
2795            if let MultiBufferEvent::BufferRangesUpdated { path_key, .. } = event {
2796                let name = path_key.path.as_unix_str().to_owned();
2797                if !files.contains(&name) {
2798                    files.push(name);
2799                }
2800            }
2801        }
2802        files.sort();
2803        files
2804    }
2805
2806    fn subscribe_to_excerpt_events(
2807        search: &Entity<ProjectSearch>,
2808        cx: &mut TestAppContext,
2809    ) -> (Rc<RefCell<Vec<MultiBufferEvent>>>, Subscription) {
2810        let events: Rc<RefCell<Vec<MultiBufferEvent>>> = Rc::default();
2811        let excerpts = cx.update(|cx| search.read(cx).excerpts.clone());
2812        let subscription = cx.update({
2813            let events = events.clone();
2814            |cx| {
2815                cx.subscribe(&excerpts, move |_, event: &MultiBufferEvent, _| {
2816                    events.borrow_mut().push(event.clone());
2817                })
2818            }
2819        });
2820        (events, subscription)
2821    }
2822
2823    fn init_test(cx: &mut TestAppContext) {
2824        cx.update(|cx| {
2825            let settings = SettingsStore::test(cx);
2826            cx.set_global(settings);
2827
2828            theme::init(theme::LoadThemes::JustBase, cx);
2829            theme_settings::init(theme::LoadThemes::JustBase, cx);
2830
2831            editor::init(cx);
2832            crate::init(cx);
2833
2834            SettingsStore::update_global(cx, |store, cx| {
2835                store.update_user_settings(cx, |settings| {
2836                    settings
2837                        .editor
2838                        .search
2839                        .get_or_insert_default()
2840                        .search_on_input = Some(false);
2841                });
2842            });
2843        });
2844    }
2845
2846    fn perform_search(
2847        search_view: WindowHandle<ProjectSearchView>,
2848        text: impl Into<Arc<str>>,
2849        cx: &mut TestAppContext,
2850    ) {
2851        search_view
2852            .update(cx, |search_view, window, cx| {
2853                search_view.query_editor.update(cx, |query_editor, cx| {
2854                    query_editor.set_text(text, window, cx)
2855                });
2856                search_view.search(false, cx);
2857            })
2858            .unwrap();
2859        // Ensure editor highlights appear after the search is done
2860        cx.executor().advance_clock(
2861            editor::SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(100),
2862        );
2863        cx.background_executor.run_until_parked();
2864    }
2865
2866    fn perform_incremental_search(
2867        search_view: WindowHandle<ProjectSearchView>,
2868        text: impl Into<Arc<str>>,
2869        cx: &mut TestAppContext,
2870    ) {
2871        search_view
2872            .update(cx, |search_view, window, cx| {
2873                search_view.query_editor.update(cx, |query_editor, cx| {
2874                    query_editor.set_text(text, window, cx)
2875                });
2876                search_view.search(true, cx);
2877            })
2878            .unwrap();
2879        cx.executor().advance_clock(
2880            editor::SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(100),
2881        );
2882        cx.background_executor.run_until_parked();
2883    }
2884
2885    fn read_match_count(
2886        search_view: WindowHandle<ProjectSearchView>,
2887        cx: &mut TestAppContext,
2888    ) -> usize {
2889        search_view
2890            .read_with(cx, |search_view, cx| {
2891                search_view.entity.read(cx).match_ranges.len()
2892            })
2893            .unwrap()
2894    }
2895
2896    fn read_match_texts(
2897        search_view: WindowHandle<ProjectSearchView>,
2898        cx: &mut TestAppContext,
2899    ) -> Vec<String> {
2900        search_view
2901            .read_with(cx, |search_view, cx| {
2902                let search = search_view.entity.read(cx);
2903                let snapshot = search.excerpts.read(cx).snapshot(cx);
2904                search
2905                    .match_ranges
2906                    .iter()
2907                    .map(|range| snapshot.text_for_range(range.clone()).collect::<String>())
2908                    .collect()
2909            })
2910            .unwrap()
2911    }
2912
2913    fn assert_all_highlights_match_query(
2914        search_view: WindowHandle<ProjectSearchView>,
2915        query: &str,
2916        cx: &mut TestAppContext,
2917    ) {
2918        let match_texts = read_match_texts(search_view, cx);
2919        assert_eq!(
2920            match_texts.len(),
2921            read_match_count(search_view, cx),
2922            "match texts count should equal match_ranges count for query {query:?}"
2923        );
2924        for text in &match_texts {
2925            assert_eq!(
2926                text.to_uppercase(),
2927                query.to_uppercase(),
2928                "every highlighted range should match the query {query:?}"
2929            );
2930        }
2931    }
2932
2933    #[test]
2934    fn test_split_glob_patterns() {
2935        assert_eq!(split_glob_patterns("a,b,c"), vec!["a", "b", "c"]);
2936        assert_eq!(split_glob_patterns("a, b, c"), vec!["a", " b", " c"]);
2937        assert_eq!(
2938            split_glob_patterns("src/{a,b}/**/*.rs"),
2939            vec!["src/{a,b}/**/*.rs"]
2940        );
2941        assert_eq!(
2942            split_glob_patterns("src/{a,b}/*.rs, tests/**/*.rs"),
2943            vec!["src/{a,b}/*.rs", " tests/**/*.rs"]
2944        );
2945        assert_eq!(split_glob_patterns("{a,b},{c,d}"), vec!["{a,b}", "{c,d}"]);
2946        assert_eq!(split_glob_patterns("{{a,b},{c,d}}"), vec!["{{a,b},{c,d}}"]);
2947        assert_eq!(split_glob_patterns(""), vec![""]);
2948        assert_eq!(split_glob_patterns("a"), vec!["a"]);
2949        // Escaped characters should not be treated as special
2950        assert_eq!(split_glob_patterns(r"a\,b,c"), vec![r"a\,b", "c"]);
2951        assert_eq!(split_glob_patterns(r"\{a,b\}"), vec![r"\{a", r"b\}"]);
2952        assert_eq!(split_glob_patterns(r"a\\,b"), vec![r"a\\", "b"]);
2953        assert_eq!(split_glob_patterns(r"a\\\,b"), vec![r"a\\\,b"]);
2954    }
2955
2956    #[perf]
2957    #[gpui::test]
2958    async fn test_project_search(cx: &mut TestAppContext) {
2959        fn dp(row: u32, col: u32) -> DisplayPoint {
2960            DisplayPoint::new(DisplayRow(row), col)
2961        }
2962
2963        fn assert_active_match_index(
2964            search_view: &WindowHandle<ProjectSearchView>,
2965            cx: &mut TestAppContext,
2966            expected_index: usize,
2967        ) {
2968            search_view
2969                .update(cx, |search_view, _window, _cx| {
2970                    assert_eq!(search_view.active_match_index, Some(expected_index));
2971                })
2972                .unwrap();
2973        }
2974
2975        fn assert_selection_range(
2976            search_view: &WindowHandle<ProjectSearchView>,
2977            cx: &mut TestAppContext,
2978            expected_range: Range<DisplayPoint>,
2979        ) {
2980            search_view
2981                .update(cx, |search_view, _window, cx| {
2982                    assert_eq!(
2983                        search_view.results_editor.update(cx, |editor, cx| editor
2984                            .selections
2985                            .display_ranges(&editor.display_snapshot(cx))),
2986                        [expected_range]
2987                    );
2988                })
2989                .unwrap();
2990        }
2991
2992        fn assert_highlights(
2993            search_view: &WindowHandle<ProjectSearchView>,
2994            cx: &mut TestAppContext,
2995            expected_highlights: Vec<(Range<DisplayPoint>, &str)>,
2996        ) {
2997            search_view
2998                .update(cx, |search_view, window, cx| {
2999                    let match_bg = cx.theme().colors().search_match_background;
3000                    let active_match_bg = cx.theme().colors().search_active_match_background;
3001                    let selection_bg = cx
3002                        .theme()
3003                        .colors()
3004                        .editor_document_highlight_bracket_background;
3005
3006                    let highlights: Vec<_> = expected_highlights
3007                        .into_iter()
3008                        .map(|(range, color_type)| {
3009                            let color = match color_type {
3010                                "active" => active_match_bg,
3011                                "match" => match_bg,
3012                                "selection" => selection_bg,
3013                                _ => panic!("Unknown color type"),
3014                            };
3015                            (range, color)
3016                        })
3017                        .collect();
3018
3019                    assert_eq!(
3020                        search_view.results_editor.update(cx, |editor, cx| editor
3021                            .all_text_background_highlights(window, cx)),
3022                        highlights.as_slice()
3023                    );
3024                })
3025                .unwrap();
3026        }
3027
3028        fn select_match(
3029            search_view: &WindowHandle<ProjectSearchView>,
3030            cx: &mut TestAppContext,
3031            direction: Direction,
3032        ) {
3033            search_view
3034                .update(cx, |search_view, window, cx| {
3035                    search_view.select_match(direction, window, cx);
3036                })
3037                .unwrap();
3038        }
3039
3040        init_test(cx);
3041
3042        // Override active search match color since the fallback theme uses the same color
3043        // for normal search match and active one, which can make this test less robust.
3044        cx.update(|cx| {
3045            SettingsStore::update_global(cx, |settings, cx| {
3046                settings.update_user_settings(cx, |settings| {
3047                    settings.theme.experimental_theme_overrides = Some(ThemeStyleContent {
3048                        colors: ThemeColorsContent {
3049                            search_active_match_background: Some("#ff0000ff".to_string()),
3050                            ..Default::default()
3051                        },
3052                        ..Default::default()
3053                    });
3054                });
3055            });
3056        });
3057
3058        let fs = FakeFs::new(cx.background_executor.clone());
3059        fs.insert_tree(
3060            path!("/dir"),
3061            json!({
3062                "one.rs": "const ONE: usize = 1;",
3063                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3064                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3065                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3066            }),
3067        )
3068        .await;
3069        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3070        let window =
3071            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3072        let workspace = window
3073            .read_with(cx, |mw, _| mw.workspace().clone())
3074            .unwrap();
3075        let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
3076        let search_view = cx.add_window(|window, cx| {
3077            ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
3078        });
3079
3080        perform_search(search_view, "TWO", cx);
3081        cx.run_until_parked();
3082
3083        search_view
3084            .update(cx, |search_view, _window, cx| {
3085                assert_eq!(
3086                    search_view
3087                        .results_editor
3088                        .update(cx, |editor, cx| editor.display_text(cx)),
3089                    "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
3090                );
3091            })
3092            .unwrap();
3093
3094        assert_active_match_index(&search_view, cx, 0);
3095        assert_selection_range(&search_view, cx, dp(2, 32)..dp(2, 35));
3096        assert_highlights(
3097            &search_view,
3098            cx,
3099            vec![
3100                (dp(2, 32)..dp(2, 35), "active"),
3101                (dp(2, 37)..dp(2, 40), "selection"),
3102                (dp(2, 37)..dp(2, 40), "match"),
3103                (dp(5, 6)..dp(5, 9), "selection"),
3104                (dp(5, 6)..dp(5, 9), "match"),
3105            ],
3106        );
3107        select_match(&search_view, cx, Direction::Next);
3108        cx.run_until_parked();
3109
3110        assert_active_match_index(&search_view, cx, 1);
3111        assert_selection_range(&search_view, cx, dp(2, 37)..dp(2, 40));
3112        assert_highlights(
3113            &search_view,
3114            cx,
3115            vec![
3116                (dp(2, 32)..dp(2, 35), "selection"),
3117                (dp(2, 32)..dp(2, 35), "match"),
3118                (dp(2, 37)..dp(2, 40), "active"),
3119                (dp(5, 6)..dp(5, 9), "selection"),
3120                (dp(5, 6)..dp(5, 9), "match"),
3121            ],
3122        );
3123        select_match(&search_view, cx, Direction::Next);
3124        cx.run_until_parked();
3125
3126        assert_active_match_index(&search_view, cx, 2);
3127        assert_selection_range(&search_view, cx, dp(5, 6)..dp(5, 9));
3128        assert_highlights(
3129            &search_view,
3130            cx,
3131            vec![
3132                (dp(2, 32)..dp(2, 35), "selection"),
3133                (dp(2, 32)..dp(2, 35), "match"),
3134                (dp(2, 37)..dp(2, 40), "selection"),
3135                (dp(2, 37)..dp(2, 40), "match"),
3136                (dp(5, 6)..dp(5, 9), "active"),
3137            ],
3138        );
3139        select_match(&search_view, cx, Direction::Next);
3140        cx.run_until_parked();
3141
3142        assert_active_match_index(&search_view, cx, 0);
3143        assert_selection_range(&search_view, cx, dp(2, 32)..dp(2, 35));
3144        assert_highlights(
3145            &search_view,
3146            cx,
3147            vec![
3148                (dp(2, 32)..dp(2, 35), "active"),
3149                (dp(2, 37)..dp(2, 40), "selection"),
3150                (dp(2, 37)..dp(2, 40), "match"),
3151                (dp(5, 6)..dp(5, 9), "selection"),
3152                (dp(5, 6)..dp(5, 9), "match"),
3153            ],
3154        );
3155        select_match(&search_view, cx, Direction::Prev);
3156        cx.run_until_parked();
3157
3158        assert_active_match_index(&search_view, cx, 2);
3159        assert_selection_range(&search_view, cx, dp(5, 6)..dp(5, 9));
3160        assert_highlights(
3161            &search_view,
3162            cx,
3163            vec![
3164                (dp(2, 32)..dp(2, 35), "selection"),
3165                (dp(2, 32)..dp(2, 35), "match"),
3166                (dp(2, 37)..dp(2, 40), "selection"),
3167                (dp(2, 37)..dp(2, 40), "match"),
3168                (dp(5, 6)..dp(5, 9), "active"),
3169            ],
3170        );
3171        select_match(&search_view, cx, Direction::Prev);
3172        cx.run_until_parked();
3173
3174        assert_active_match_index(&search_view, cx, 1);
3175        assert_selection_range(&search_view, cx, dp(2, 37)..dp(2, 40));
3176        assert_highlights(
3177            &search_view,
3178            cx,
3179            vec![
3180                (dp(2, 32)..dp(2, 35), "selection"),
3181                (dp(2, 32)..dp(2, 35), "match"),
3182                (dp(2, 37)..dp(2, 40), "active"),
3183                (dp(5, 6)..dp(5, 9), "selection"),
3184                (dp(5, 6)..dp(5, 9), "match"),
3185            ],
3186        );
3187        search_view
3188            .update(cx, |search_view, window, cx| {
3189                search_view.results_editor.update(cx, |editor, cx| {
3190                    editor.fold_all(&FoldAll, window, cx);
3191                })
3192            })
3193            .expect("Should fold fine");
3194        cx.run_until_parked();
3195
3196        let results_collapsed = search_view
3197            .read_with(cx, |search_view, cx| {
3198                search_view
3199                    .results_editor
3200                    .read(cx)
3201                    .has_any_buffer_folded(cx)
3202            })
3203            .expect("got results_collapsed");
3204
3205        assert!(results_collapsed);
3206        search_view
3207            .update(cx, |search_view, window, cx| {
3208                search_view.results_editor.update(cx, |editor, cx| {
3209                    editor.unfold_all(&UnfoldAll, window, cx);
3210                })
3211            })
3212            .expect("Should unfold fine");
3213        cx.run_until_parked();
3214
3215        let results_collapsed = search_view
3216            .read_with(cx, |search_view, cx| {
3217                search_view
3218                    .results_editor
3219                    .read(cx)
3220                    .has_any_buffer_folded(cx)
3221            })
3222            .expect("got results_collapsed");
3223
3224        assert!(!results_collapsed);
3225    }
3226
3227    #[perf]
3228    #[gpui::test]
3229    async fn test_collapse_state_syncs_after_manual_buffer_fold(cx: &mut TestAppContext) {
3230        init_test(cx);
3231
3232        let fs = FakeFs::new(cx.background_executor.clone());
3233        fs.insert_tree(
3234            path!("/dir"),
3235            json!({
3236                "one.rs": "const ONE: usize = 1;",
3237                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3238                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3239            }),
3240        )
3241        .await;
3242        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3243        let window =
3244            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3245        let workspace = window
3246            .read_with(cx, |mw, _| mw.workspace().clone())
3247            .unwrap();
3248        let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
3249        let search_view = cx.add_window(|window, cx| {
3250            ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
3251        });
3252
3253        // Search for "ONE" which appears in all 3 files
3254        perform_search(search_view, "ONE", cx);
3255
3256        // Verify initial state: no folds
3257        let has_any_folded = search_view
3258            .read_with(cx, |search_view, cx| {
3259                search_view
3260                    .results_editor
3261                    .read(cx)
3262                    .has_any_buffer_folded(cx)
3263            })
3264            .expect("should read state");
3265        assert!(!has_any_folded, "No buffers should be folded initially");
3266
3267        // Fold all via fold_all
3268        search_view
3269            .update(cx, |search_view, window, cx| {
3270                search_view.results_editor.update(cx, |editor, cx| {
3271                    editor.fold_all(&FoldAll, window, cx);
3272                })
3273            })
3274            .expect("Should fold fine");
3275        cx.run_until_parked();
3276
3277        let has_any_folded = search_view
3278            .read_with(cx, |search_view, cx| {
3279                search_view
3280                    .results_editor
3281                    .read(cx)
3282                    .has_any_buffer_folded(cx)
3283            })
3284            .expect("should read state");
3285        assert!(
3286            has_any_folded,
3287            "All buffers should be folded after fold_all"
3288        );
3289
3290        // Manually unfold one buffer (simulating a chevron click)
3291        let first_buffer_id = search_view
3292            .read_with(cx, |search_view, cx| {
3293                search_view
3294                    .results_editor
3295                    .read(cx)
3296                    .buffer()
3297                    .read(cx)
3298                    .snapshot(cx)
3299                    .excerpts()
3300                    .next()
3301                    .unwrap()
3302                    .context
3303                    .start
3304                    .buffer_id
3305            })
3306            .expect("should read buffer ids");
3307
3308        search_view
3309            .update(cx, |search_view, _window, cx| {
3310                search_view.results_editor.update(cx, |editor, cx| {
3311                    editor.unfold_buffer(first_buffer_id, cx);
3312                })
3313            })
3314            .expect("Should unfold one buffer");
3315
3316        let has_any_folded = search_view
3317            .read_with(cx, |search_view, cx| {
3318                search_view
3319                    .results_editor
3320                    .read(cx)
3321                    .has_any_buffer_folded(cx)
3322            })
3323            .expect("should read state");
3324        assert!(
3325            has_any_folded,
3326            "Should still report folds when only one buffer is unfolded"
3327        );
3328
3329        // Unfold all via unfold_all
3330        search_view
3331            .update(cx, |search_view, window, cx| {
3332                search_view.results_editor.update(cx, |editor, cx| {
3333                    editor.unfold_all(&UnfoldAll, window, cx);
3334                })
3335            })
3336            .expect("Should unfold fine");
3337        cx.run_until_parked();
3338
3339        let has_any_folded = search_view
3340            .read_with(cx, |search_view, cx| {
3341                search_view
3342                    .results_editor
3343                    .read(cx)
3344                    .has_any_buffer_folded(cx)
3345            })
3346            .expect("should read state");
3347        assert!(!has_any_folded, "No folds should remain after unfold_all");
3348
3349        // Manually fold one buffer back (simulating a chevron click)
3350        search_view
3351            .update(cx, |search_view, _window, cx| {
3352                search_view.results_editor.update(cx, |editor, cx| {
3353                    editor.fold_buffer(first_buffer_id, cx);
3354                })
3355            })
3356            .expect("Should fold one buffer");
3357
3358        let has_any_folded = search_view
3359            .read_with(cx, |search_view, cx| {
3360                search_view
3361                    .results_editor
3362                    .read(cx)
3363                    .has_any_buffer_folded(cx)
3364            })
3365            .expect("should read state");
3366        assert!(
3367            has_any_folded,
3368            "Should report folds after manually folding one buffer"
3369        );
3370    }
3371
3372    #[perf]
3373    #[gpui::test]
3374    async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
3375        init_test(cx);
3376
3377        let fs = FakeFs::new(cx.background_executor.clone());
3378        fs.insert_tree(
3379            "/dir",
3380            json!({
3381                "one.rs": "const ONE: usize = 1;",
3382                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3383                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3384                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3385            }),
3386        )
3387        .await;
3388        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3389        let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3390        let workspace = window
3391            .read_with(cx, |mw, _| mw.workspace().clone())
3392            .unwrap();
3393        let cx = &mut VisualTestContext::from_window(window.into(), cx);
3394        let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3395
3396        let active_item = cx.read(|cx| {
3397            workspace
3398                .read(cx)
3399                .active_pane()
3400                .read(cx)
3401                .active_item()
3402                .and_then(|item| item.downcast::<ProjectSearchView>())
3403        });
3404        assert!(
3405            active_item.is_none(),
3406            "Expected no search panel to be active"
3407        );
3408
3409        workspace.update_in(cx, move |workspace, window, cx| {
3410            assert_eq!(workspace.panes().len(), 1);
3411            workspace.panes()[0].update(cx, |pane, cx| {
3412                pane.toolbar()
3413                    .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3414            });
3415
3416            ProjectSearchView::deploy_search(
3417                workspace,
3418                &workspace::DeploySearch::default(),
3419                window,
3420                cx,
3421            )
3422        });
3423
3424        let Some(search_view) = cx.read(|cx| {
3425            workspace
3426                .read(cx)
3427                .active_pane()
3428                .read(cx)
3429                .active_item()
3430                .and_then(|item| item.downcast::<ProjectSearchView>())
3431        }) else {
3432            panic!("Search view expected to appear after new search event trigger")
3433        };
3434
3435        cx.spawn(|mut cx| async move {
3436            window
3437                .update(&mut cx, |_, window, cx| {
3438                    window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3439                })
3440                .unwrap();
3441        })
3442        .detach();
3443        cx.background_executor.run_until_parked();
3444        window
3445            .update(cx, |_, window, cx| {
3446                search_view.update(cx, |search_view, cx| {
3447                    assert!(
3448                        search_view.query_editor.focus_handle(cx).is_focused(window),
3449                        "Empty search view should be focused after the toggle focus event: no results panel to focus on",
3450                    );
3451                });
3452        }).unwrap();
3453
3454        window
3455            .update(cx, |_, window, cx| {
3456                search_view.update(cx, |search_view, cx| {
3457                    let query_editor = &search_view.query_editor;
3458                    assert!(
3459                        query_editor.focus_handle(cx).is_focused(window),
3460                        "Search view should be focused after the new search view is activated",
3461                    );
3462                    let query_text = query_editor.read(cx).text(cx);
3463                    assert!(
3464                        query_text.is_empty(),
3465                        "New search query should be empty but got '{query_text}'",
3466                    );
3467                    let results_text = search_view
3468                        .results_editor
3469                        .update(cx, |editor, cx| editor.display_text(cx));
3470                    assert!(
3471                        results_text.is_empty(),
3472                        "Empty search view should have no results but got '{results_text}'"
3473                    );
3474                });
3475            })
3476            .unwrap();
3477
3478        window
3479            .update(cx, |_, window, cx| {
3480                search_view.update(cx, |search_view, cx| {
3481                    search_view.query_editor.update(cx, |query_editor, cx| {
3482                        query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
3483                    });
3484                    search_view.search(false, cx);
3485                });
3486            })
3487            .unwrap();
3488        cx.background_executor.run_until_parked();
3489        window
3490            .update(cx, |_, window, cx| {
3491                search_view.update(cx, |search_view, cx| {
3492                    let results_text = search_view
3493                        .results_editor
3494                        .update(cx, |editor, cx| editor.display_text(cx));
3495                    assert!(
3496                        results_text.is_empty(),
3497                        "Search view for mismatching query should have no results but got '{results_text}'"
3498                    );
3499                    assert!(
3500                        search_view.query_editor.focus_handle(cx).is_focused(window),
3501                        "Search view should be focused after mismatching query had been used in search",
3502                    );
3503                });
3504            }).unwrap();
3505
3506        cx.spawn(|mut cx| async move {
3507            window.update(&mut cx, |_, window, cx| {
3508                window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3509            })
3510        })
3511        .detach();
3512        cx.background_executor.run_until_parked();
3513        window.update(cx, |_, window, cx| {
3514            search_view.update(cx, |search_view, cx| {
3515                assert!(
3516                    search_view.query_editor.focus_handle(cx).is_focused(window),
3517                    "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
3518                );
3519            });
3520        }).unwrap();
3521
3522        window
3523            .update(cx, |_, window, cx| {
3524                search_view.update(cx, |search_view, cx| {
3525                    search_view.query_editor.update(cx, |query_editor, cx| {
3526                        query_editor.set_text("TWO", window, cx)
3527                    });
3528                    search_view.search(false, cx);
3529                });
3530            })
3531            .unwrap();
3532        cx.background_executor.run_until_parked();
3533        window.update(cx, |_, window, cx| {
3534            search_view.update(cx, |search_view, cx| {
3535                assert_eq!(
3536                    search_view
3537                        .results_editor
3538                        .update(cx, |editor, cx| editor.display_text(cx)),
3539                    "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3540                    "Search view results should match the query"
3541                );
3542                assert!(
3543                    search_view.results_editor.focus_handle(cx).is_focused(window),
3544                    "Search view with mismatching query should be focused after search results are available",
3545                );
3546            });
3547        }).unwrap();
3548        cx.spawn(|mut cx| async move {
3549            window
3550                .update(&mut cx, |_, window, cx| {
3551                    window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3552                })
3553                .unwrap();
3554        })
3555        .detach();
3556        cx.background_executor.run_until_parked();
3557        window.update(cx, |_, window, cx| {
3558            search_view.update(cx, |search_view, cx| {
3559                assert!(
3560                    search_view.results_editor.focus_handle(cx).is_focused(window),
3561                    "Search view with matching query should still have its results editor focused after the toggle focus event",
3562                );
3563            });
3564        }).unwrap();
3565
3566        workspace.update_in(cx, |workspace, window, cx| {
3567            ProjectSearchView::deploy_search(
3568                workspace,
3569                &workspace::DeploySearch::default(),
3570                window,
3571                cx,
3572            )
3573        });
3574        window.update(cx, |_, window, cx| {
3575            search_view.update(cx, |search_view, cx| {
3576                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");
3577                assert_eq!(
3578                    search_view
3579                        .results_editor
3580                        .update(cx, |editor, cx| editor.display_text(cx)),
3581                    "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3582                    "Results should be unchanged after search view 2nd open in a row"
3583                );
3584                assert!(
3585                    search_view.query_editor.focus_handle(cx).is_focused(window),
3586                    "Focus should be moved into query editor again after search view 2nd open in a row"
3587                );
3588            });
3589        }).unwrap();
3590
3591        cx.spawn(|mut cx| async move {
3592            window
3593                .update(&mut cx, |_, window, cx| {
3594                    window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3595                })
3596                .unwrap();
3597        })
3598        .detach();
3599        cx.background_executor.run_until_parked();
3600        window.update(cx, |_, window, cx| {
3601            search_view.update(cx, |search_view, cx| {
3602                assert!(
3603                    search_view.results_editor.focus_handle(cx).is_focused(window),
3604                    "Search view with matching query should switch focus to the results editor after the toggle focus event",
3605                );
3606            });
3607        }).unwrap();
3608    }
3609
3610    #[perf]
3611    #[gpui::test]
3612    async fn test_filters_consider_toggle_state(cx: &mut TestAppContext) {
3613        init_test(cx);
3614
3615        let fs = FakeFs::new(cx.background_executor.clone());
3616        fs.insert_tree(
3617            "/dir",
3618            json!({
3619                "one.rs": "const ONE: usize = 1;",
3620                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3621                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3622                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3623            }),
3624        )
3625        .await;
3626        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
3627        let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3628        let workspace = window
3629            .read_with(cx, |mw, _| mw.workspace().clone())
3630            .unwrap();
3631        let cx = &mut VisualTestContext::from_window(window.into(), cx);
3632        let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3633
3634        workspace.update_in(cx, move |workspace, window, cx| {
3635            workspace.panes()[0].update(cx, |pane, cx| {
3636                pane.toolbar()
3637                    .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3638            });
3639
3640            ProjectSearchView::deploy_search(
3641                workspace,
3642                &workspace::DeploySearch::default(),
3643                window,
3644                cx,
3645            )
3646        });
3647
3648        let Some(search_view) = cx.read(|cx| {
3649            workspace
3650                .read(cx)
3651                .active_pane()
3652                .read(cx)
3653                .active_item()
3654                .and_then(|item| item.downcast::<ProjectSearchView>())
3655        }) else {
3656            panic!("Search view expected to appear after new search event trigger")
3657        };
3658
3659        cx.spawn(|mut cx| async move {
3660            window
3661                .update(&mut cx, |_, window, cx| {
3662                    window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3663                })
3664                .unwrap();
3665        })
3666        .detach();
3667        cx.background_executor.run_until_parked();
3668
3669        window
3670            .update(cx, |_, window, cx| {
3671                search_view.update(cx, |search_view, cx| {
3672                    search_view.query_editor.update(cx, |query_editor, cx| {
3673                        query_editor.set_text("const FOUR", window, cx)
3674                    });
3675                    search_view.toggle_filters(cx);
3676                    search_view
3677                        .excluded_files_editor
3678                        .update(cx, |exclude_editor, cx| {
3679                            exclude_editor.set_text("four.rs", window, cx)
3680                        });
3681                    search_view.search(false, cx);
3682                });
3683            })
3684            .unwrap();
3685        cx.background_executor.run_until_parked();
3686        window
3687            .update(cx, |_, _, cx| {
3688                search_view.update(cx, |search_view, cx| {
3689                    let results_text = search_view
3690                        .results_editor
3691                        .update(cx, |editor, cx| editor.display_text(cx));
3692                    assert!(
3693                        results_text.is_empty(),
3694                        "Search view for query with the only match in an excluded file should have no results but got '{results_text}'"
3695                    );
3696                });
3697            }).unwrap();
3698
3699        cx.spawn(|mut cx| async move {
3700            window.update(&mut cx, |_, window, cx| {
3701                window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3702            })
3703        })
3704        .detach();
3705        cx.background_executor.run_until_parked();
3706
3707        window
3708            .update(cx, |_, _, cx| {
3709                search_view.update(cx, |search_view, cx| {
3710                    search_view.toggle_filters(cx);
3711                    search_view.search(false, cx);
3712                });
3713            })
3714            .unwrap();
3715        cx.background_executor.run_until_parked();
3716        window
3717            .update(cx, |_, _, cx| {
3718                search_view.update(cx, |search_view, cx| {
3719                assert_eq!(
3720                    search_view
3721                        .results_editor
3722                        .update(cx, |editor, cx| editor.display_text(cx)),
3723                    "\n\nconst FOUR: usize = one::ONE + three::THREE;",
3724                    "Search view results should contain the queried result in the previously excluded file with filters toggled off"
3725                );
3726            });
3727            })
3728            .unwrap();
3729    }
3730
3731    #[perf]
3732    #[gpui::test]
3733    async fn test_new_project_search_focus(cx: &mut TestAppContext) {
3734        init_test(cx);
3735
3736        let fs = FakeFs::new(cx.background_executor.clone());
3737        fs.insert_tree(
3738            path!("/dir"),
3739            json!({
3740                "one.rs": "const ONE: usize = 1;",
3741                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
3742                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
3743                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
3744            }),
3745        )
3746        .await;
3747        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3748        let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3749        let workspace = window
3750            .read_with(cx, |mw, _| mw.workspace().clone())
3751            .unwrap();
3752        let cx = &mut VisualTestContext::from_window(window.into(), cx);
3753        let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
3754
3755        let active_item = cx.read(|cx| {
3756            workspace
3757                .read(cx)
3758                .active_pane()
3759                .read(cx)
3760                .active_item()
3761                .and_then(|item| item.downcast::<ProjectSearchView>())
3762        });
3763        assert!(
3764            active_item.is_none(),
3765            "Expected no search panel to be active"
3766        );
3767
3768        workspace.update_in(cx, move |workspace, window, cx| {
3769            assert_eq!(workspace.panes().len(), 1);
3770            workspace.panes()[0].update(cx, |pane, cx| {
3771                pane.toolbar()
3772                    .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
3773            });
3774
3775            ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3776        });
3777
3778        let Some(search_view) = cx.read(|cx| {
3779            workspace
3780                .read(cx)
3781                .active_pane()
3782                .read(cx)
3783                .active_item()
3784                .and_then(|item| item.downcast::<ProjectSearchView>())
3785        }) else {
3786            panic!("Search view expected to appear after new search event trigger")
3787        };
3788
3789        cx.spawn(|mut cx| async move {
3790            window
3791                .update(&mut cx, |_, window, cx| {
3792                    window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3793                })
3794                .unwrap();
3795        })
3796        .detach();
3797        cx.background_executor.run_until_parked();
3798
3799        window.update(cx, |_, window, cx| {
3800            search_view.update(cx, |search_view, cx| {
3801                    assert!(
3802                        search_view.query_editor.focus_handle(cx).is_focused(window),
3803                        "Empty search view should be focused after the toggle focus event: no results panel to focus on",
3804                    );
3805                });
3806        }).unwrap();
3807
3808        window
3809            .update(cx, |_, window, cx| {
3810                search_view.update(cx, |search_view, cx| {
3811                    let query_editor = &search_view.query_editor;
3812                    assert!(
3813                        query_editor.focus_handle(cx).is_focused(window),
3814                        "Search view should be focused after the new search view is activated",
3815                    );
3816                    let query_text = query_editor.read(cx).text(cx);
3817                    assert!(
3818                        query_text.is_empty(),
3819                        "New search query should be empty but got '{query_text}'",
3820                    );
3821                    let results_text = search_view
3822                        .results_editor
3823                        .update(cx, |editor, cx| editor.display_text(cx));
3824                    assert!(
3825                        results_text.is_empty(),
3826                        "Empty search view should have no results but got '{results_text}'"
3827                    );
3828                });
3829            })
3830            .unwrap();
3831
3832        window
3833            .update(cx, |_, window, cx| {
3834                search_view.update(cx, |search_view, cx| {
3835                    search_view.query_editor.update(cx, |query_editor, cx| {
3836                        query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", window, cx)
3837                    });
3838                    search_view.search(false, cx);
3839                });
3840            })
3841            .unwrap();
3842
3843        cx.background_executor.run_until_parked();
3844        window
3845            .update(cx, |_, window, cx| {
3846                search_view.update(cx, |search_view, cx| {
3847                    let results_text = search_view
3848                        .results_editor
3849                        .update(cx, |editor, cx| editor.display_text(cx));
3850                    assert!(
3851                results_text.is_empty(),
3852                "Search view for mismatching query should have no results but got '{results_text}'"
3853            );
3854                    assert!(
3855                search_view.query_editor.focus_handle(cx).is_focused(window),
3856                "Search view should be focused after mismatching query had been used in search",
3857            );
3858                });
3859            })
3860            .unwrap();
3861        cx.spawn(|mut cx| async move {
3862            window.update(&mut cx, |_, window, cx| {
3863                window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3864            })
3865        })
3866        .detach();
3867        cx.background_executor.run_until_parked();
3868        window.update(cx, |_, window, cx| {
3869            search_view.update(cx, |search_view, cx| {
3870                    assert!(
3871                        search_view.query_editor.focus_handle(cx).is_focused(window),
3872                        "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
3873                    );
3874                });
3875        }).unwrap();
3876
3877        window
3878            .update(cx, |_, window, cx| {
3879                search_view.update(cx, |search_view, cx| {
3880                    search_view.query_editor.update(cx, |query_editor, cx| {
3881                        query_editor.set_text("TWO", window, cx)
3882                    });
3883                    search_view.search(false, cx);
3884                })
3885            })
3886            .unwrap();
3887        cx.background_executor.run_until_parked();
3888        window.update(cx, |_, window, cx|
3889        search_view.update(cx, |search_view, cx| {
3890                assert_eq!(
3891                    search_view
3892                        .results_editor
3893                        .update(cx, |editor, cx| editor.display_text(cx)),
3894                    "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3895                    "Search view results should match the query"
3896                );
3897                assert!(
3898                    search_view.results_editor.focus_handle(cx).is_focused(window),
3899                    "Search view with mismatching query should be focused after search results are available",
3900                );
3901            })).unwrap();
3902        cx.spawn(|mut cx| async move {
3903            window
3904                .update(&mut cx, |_, window, cx| {
3905                    window.dispatch_action(ToggleFocus.boxed_clone(), cx)
3906                })
3907                .unwrap();
3908        })
3909        .detach();
3910        cx.background_executor.run_until_parked();
3911        window.update(cx, |_, window, cx| {
3912            search_view.update(cx, |search_view, cx| {
3913                    assert!(
3914                        search_view.results_editor.focus_handle(cx).is_focused(window),
3915                        "Search view with matching query should still have its results editor focused after the toggle focus event",
3916                    );
3917                });
3918        }).unwrap();
3919
3920        workspace.update_in(cx, |workspace, window, cx| {
3921            ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
3922        });
3923        cx.background_executor.run_until_parked();
3924        let Some(search_view_2) = cx.read(|cx| {
3925            workspace
3926                .read(cx)
3927                .active_pane()
3928                .read(cx)
3929                .active_item()
3930                .and_then(|item| item.downcast::<ProjectSearchView>())
3931        }) else {
3932            panic!("Search view expected to appear after new search event trigger")
3933        };
3934        assert!(
3935            search_view_2 != search_view,
3936            "New search view should be open after `workspace::NewSearch` event"
3937        );
3938
3939        window.update(cx, |_, window, cx| {
3940            search_view.update(cx, |search_view, cx| {
3941                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO", "First search view should not have an updated query");
3942                    assert_eq!(
3943                        search_view
3944                            .results_editor
3945                            .update(cx, |editor, cx| editor.display_text(cx)),
3946                        "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
3947                        "Results of the first search view should not update too"
3948                    );
3949                    assert!(
3950                        !search_view.query_editor.focus_handle(cx).is_focused(window),
3951                        "Focus should be moved away from the first search view"
3952                    );
3953                });
3954        }).unwrap();
3955
3956        window.update(cx, |_, window, cx| {
3957            search_view_2.update(cx, |search_view_2, cx| {
3958                    assert_eq!(
3959                        search_view_2.query_editor.read(cx).text(cx),
3960                        "two",
3961                        "New search view should get the query from the text cursor was at during the event spawn (first search view's first result)"
3962                    );
3963                    assert_eq!(
3964                        search_view_2
3965                            .results_editor
3966                            .update(cx, |editor, cx| editor.display_text(cx)),
3967                        "",
3968                        "No search results should be in the 2nd view yet, as we did not spawn a search for it"
3969                    );
3970                    assert!(
3971                        search_view_2.query_editor.focus_handle(cx).is_focused(window),
3972                        "Focus should be moved into query editor of the new window"
3973                    );
3974                });
3975        }).unwrap();
3976
3977        window
3978            .update(cx, |_, window, cx| {
3979                search_view_2.update(cx, |search_view_2, cx| {
3980                    search_view_2.query_editor.update(cx, |query_editor, cx| {
3981                        query_editor.set_text("FOUR", window, cx)
3982                    });
3983                    search_view_2.search(false, cx);
3984                });
3985            })
3986            .unwrap();
3987
3988        cx.background_executor.run_until_parked();
3989        window.update(cx, |_, window, cx| {
3990            search_view_2.update(cx, |search_view_2, cx| {
3991                    assert_eq!(
3992                        search_view_2
3993                            .results_editor
3994                            .update(cx, |editor, cx| editor.display_text(cx)),
3995                        "\n\nconst FOUR: usize = one::ONE + three::THREE;",
3996                        "New search view with the updated query should have new search results"
3997                    );
3998                    assert!(
3999                        search_view_2.results_editor.focus_handle(cx).is_focused(window),
4000                        "Search view with mismatching query should be focused after search results are available",
4001                    );
4002                });
4003        }).unwrap();
4004
4005        cx.spawn(|mut cx| async move {
4006            window
4007                .update(&mut cx, |_, window, cx| {
4008                    window.dispatch_action(ToggleFocus.boxed_clone(), cx)
4009                })
4010                .unwrap();
4011        })
4012        .detach();
4013        cx.background_executor.run_until_parked();
4014        window.update(cx, |_, window, cx| {
4015            search_view_2.update(cx, |search_view_2, cx| {
4016                    assert!(
4017                        search_view_2.results_editor.focus_handle(cx).is_focused(window),
4018                        "Search view with matching query should switch focus to the results editor after the toggle focus event",
4019                    );
4020                });}).unwrap();
4021    }
4022
4023    #[perf]
4024    #[gpui::test]
4025    async fn test_new_project_search_in_directory(cx: &mut TestAppContext) {
4026        init_test(cx);
4027
4028        let fs = FakeFs::new(cx.background_executor.clone());
4029        fs.insert_tree(
4030            path!("/dir"),
4031            json!({
4032                "a": {
4033                    "one.rs": "const ONE: usize = 1;",
4034                    "two.rs": "const TWO: usize = one::ONE + one::ONE;",
4035                },
4036                "b": {
4037                    "three.rs": "const THREE: usize = one::ONE + two::TWO;",
4038                    "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
4039                },
4040            }),
4041        )
4042        .await;
4043        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
4044        let worktree_id = project.read_with(cx, |project, cx| {
4045            project.worktrees(cx).next().unwrap().read(cx).id()
4046        });
4047        let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
4048        let workspace = window
4049            .read_with(cx, |mw, _| mw.workspace().clone())
4050            .unwrap();
4051        let cx = &mut VisualTestContext::from_window(window.into(), cx);
4052        let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
4053
4054        let active_item = cx.read(|cx| {
4055            workspace
4056                .read(cx)
4057                .active_pane()
4058                .read(cx)
4059                .active_item()
4060                .and_then(|item| item.downcast::<ProjectSearchView>())
4061        });
4062        assert!(
4063            active_item.is_none(),
4064            "Expected no search panel to be active"
4065        );
4066
4067        workspace.update_in(cx, move |workspace, window, cx| {
4068            assert_eq!(workspace.panes().len(), 1);
4069            workspace.panes()[0].update(cx, move |pane, cx| {
4070                pane.toolbar()
4071                    .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4072            });
4073        });
4074
4075        let a_dir_entry = cx.update(|_, cx| {
4076            workspace
4077                .read(cx)
4078                .project()
4079                .read(cx)
4080                .entry_for_path(&(worktree_id, rel_path("a")).into(), cx)
4081                .expect("no entry for /a/ directory")
4082                .clone()
4083        });
4084        assert!(a_dir_entry.is_dir());
4085        workspace.update_in(cx, |workspace, window, cx| {
4086            ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, window, cx)
4087        });
4088
4089        let Some(search_view) = cx.read(|cx| {
4090            workspace
4091                .read(cx)
4092                .active_pane()
4093                .read(cx)
4094                .active_item()
4095                .and_then(|item| item.downcast::<ProjectSearchView>())
4096        }) else {
4097            panic!("Search view expected to appear after new search in directory event trigger")
4098        };
4099        cx.background_executor.run_until_parked();
4100        window
4101            .update(cx, |_, window, cx| {
4102                search_view.update(cx, |search_view, cx| {
4103                    assert!(
4104                        search_view.query_editor.focus_handle(cx).is_focused(window),
4105                        "On new search in directory, focus should be moved into query editor"
4106                    );
4107                    search_view.excluded_files_editor.update(cx, |editor, cx| {
4108                        assert!(
4109                            editor.display_text(cx).is_empty(),
4110                            "New search in directory should not have any excluded files"
4111                        );
4112                    });
4113                    search_view.included_files_editor.update(cx, |editor, cx| {
4114                        assert_eq!(
4115                            editor.display_text(cx),
4116                            a_dir_entry.path.display(PathStyle::local()),
4117                            "New search in directory should have included dir entry path"
4118                        );
4119                    });
4120                });
4121            })
4122            .unwrap();
4123        window
4124            .update(cx, |_, window, cx| {
4125                search_view.update(cx, |search_view, cx| {
4126                    search_view.query_editor.update(cx, |query_editor, cx| {
4127                        query_editor.set_text("const", window, cx)
4128                    });
4129                    search_view.search(false, cx);
4130                });
4131            })
4132            .unwrap();
4133        cx.background_executor.run_until_parked();
4134        window
4135            .update(cx, |_, _, cx| {
4136                search_view.update(cx, |search_view, cx| {
4137                    assert_eq!(
4138                search_view
4139                    .results_editor
4140                    .update(cx, |editor, cx| editor.display_text(cx)),
4141                "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
4142                "New search in directory should have a filter that matches a certain directory"
4143            );
4144                })
4145            })
4146            .unwrap();
4147    }
4148
4149    #[perf]
4150    #[gpui::test]
4151    async fn test_search_query_history(cx: &mut TestAppContext) {
4152        init_test(cx);
4153
4154        let fs = FakeFs::new(cx.background_executor.clone());
4155        fs.insert_tree(
4156            path!("/dir"),
4157            json!({
4158                "one.rs": "const ONE: usize = 1;",
4159                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
4160                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
4161                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
4162            }),
4163        )
4164        .await;
4165        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4166        let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
4167        let workspace = window
4168            .read_with(cx, |mw, _| mw.workspace().clone())
4169            .unwrap();
4170        let cx = &mut VisualTestContext::from_window(window.into(), cx);
4171        let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
4172
4173        workspace.update_in(cx, {
4174            let search_bar = search_bar.clone();
4175            |workspace, window, cx| {
4176                assert_eq!(workspace.panes().len(), 1);
4177                workspace.panes()[0].update(cx, |pane, cx| {
4178                    pane.toolbar()
4179                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4180                });
4181
4182                ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4183            }
4184        });
4185
4186        let search_view = cx.read(|cx| {
4187            workspace
4188                .read(cx)
4189                .active_pane()
4190                .read(cx)
4191                .active_item()
4192                .and_then(|item| item.downcast::<ProjectSearchView>())
4193                .expect("Search view expected to appear after new search event trigger")
4194        });
4195
4196        // Add 3 search items into the history + another unsubmitted one.
4197        window
4198            .update(cx, |_, window, cx| {
4199                search_view.update(cx, |search_view, cx| {
4200                    search_view.search_options = SearchOptions::CASE_SENSITIVE;
4201                    search_view.query_editor.update(cx, |query_editor, cx| {
4202                        query_editor.set_text("ONE", window, cx)
4203                    });
4204                    search_view.search(false, cx);
4205                });
4206            })
4207            .unwrap();
4208
4209        cx.background_executor.run_until_parked();
4210        window
4211            .update(cx, |_, window, cx| {
4212                search_view.update(cx, |search_view, cx| {
4213                    search_view.query_editor.update(cx, |query_editor, cx| {
4214                        query_editor.set_text("TWO", window, cx)
4215                    });
4216                    search_view.search(false, cx);
4217                });
4218            })
4219            .unwrap();
4220        cx.background_executor.run_until_parked();
4221        window
4222            .update(cx, |_, window, cx| {
4223                search_view.update(cx, |search_view, cx| {
4224                    search_view.query_editor.update(cx, |query_editor, cx| {
4225                        query_editor.set_text("THREE", window, cx)
4226                    });
4227                    search_view.search(false, cx);
4228                })
4229            })
4230            .unwrap();
4231        cx.background_executor.run_until_parked();
4232        window
4233            .update(cx, |_, window, cx| {
4234                search_view.update(cx, |search_view, cx| {
4235                    search_view.query_editor.update(cx, |query_editor, cx| {
4236                        query_editor.set_text("JUST_TEXT_INPUT", window, cx)
4237                    });
4238                })
4239            })
4240            .unwrap();
4241        cx.background_executor.run_until_parked();
4242
4243        // Ensure that the latest input with search settings is active.
4244        window
4245            .update(cx, |_, _, cx| {
4246                search_view.update(cx, |search_view, cx| {
4247                    assert_eq!(
4248                        search_view.query_editor.read(cx).text(cx),
4249                        "JUST_TEXT_INPUT"
4250                    );
4251                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4252                });
4253            })
4254            .unwrap();
4255
4256        // Next history query after the latest should preserve the current query.
4257        window
4258            .update(cx, |_, window, cx| {
4259                search_bar.update(cx, |search_bar, cx| {
4260                    search_bar.focus_search(window, cx);
4261                    search_bar.next_history_query(&NextHistoryQuery, window, cx);
4262                })
4263            })
4264            .unwrap();
4265        window
4266            .update(cx, |_, _, cx| {
4267                search_view.update(cx, |search_view, cx| {
4268                    assert_eq!(
4269                        search_view.query_editor.read(cx).text(cx),
4270                        "JUST_TEXT_INPUT"
4271                    );
4272                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4273                });
4274            })
4275            .unwrap();
4276        window
4277            .update(cx, |_, window, cx| {
4278                search_bar.update(cx, |search_bar, cx| {
4279                    search_bar.focus_search(window, cx);
4280                    search_bar.next_history_query(&NextHistoryQuery, window, cx);
4281                })
4282            })
4283            .unwrap();
4284        window
4285            .update(cx, |_, _, cx| {
4286                search_view.update(cx, |search_view, cx| {
4287                    assert_eq!(
4288                        search_view.query_editor.read(cx).text(cx),
4289                        "JUST_TEXT_INPUT"
4290                    );
4291                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4292                });
4293            })
4294            .unwrap();
4295
4296        // Previous query should navigate backwards through history.
4297        window
4298            .update(cx, |_, window, cx| {
4299                search_bar.update(cx, |search_bar, cx| {
4300                    search_bar.focus_search(window, cx);
4301                    search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4302                });
4303            })
4304            .unwrap();
4305        window
4306            .update(cx, |_, _, cx| {
4307                search_view.update(cx, |search_view, cx| {
4308                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
4309                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4310                });
4311            })
4312            .unwrap();
4313
4314        // Further previous items should go over the history in reverse order.
4315        window
4316            .update(cx, |_, window, cx| {
4317                search_bar.update(cx, |search_bar, cx| {
4318                    search_bar.focus_search(window, cx);
4319                    search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4320                });
4321            })
4322            .unwrap();
4323        window
4324            .update(cx, |_, _, cx| {
4325                search_view.update(cx, |search_view, cx| {
4326                    assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
4327                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4328                });
4329            })
4330            .unwrap();
4331
4332        // Previous items should never go behind the first history item.
4333        window
4334            .update(cx, |_, window, cx| {
4335                search_bar.update(cx, |search_bar, cx| {
4336                    search_bar.focus_search(window, cx);
4337                    search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4338                });
4339            })
4340            .unwrap();
4341        window
4342            .update(cx, |_, _, cx| {
4343                search_view.update(cx, |search_view, cx| {
4344                    assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
4345                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4346                });
4347            })
4348            .unwrap();
4349        window
4350            .update(cx, |_, window, cx| {
4351                search_bar.update(cx, |search_bar, cx| {
4352                    search_bar.focus_search(window, cx);
4353                    search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4354                });
4355            })
4356            .unwrap();
4357        window
4358            .update(cx, |_, _, cx| {
4359                search_view.update(cx, |search_view, cx| {
4360                    assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
4361                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4362                });
4363            })
4364            .unwrap();
4365
4366        // Next items should go over the history in the original order.
4367        window
4368            .update(cx, |_, window, cx| {
4369                search_bar.update(cx, |search_bar, cx| {
4370                    search_bar.focus_search(window, cx);
4371                    search_bar.next_history_query(&NextHistoryQuery, window, cx);
4372                });
4373            })
4374            .unwrap();
4375        window
4376            .update(cx, |_, _, cx| {
4377                search_view.update(cx, |search_view, cx| {
4378                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
4379                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4380                });
4381            })
4382            .unwrap();
4383
4384        window
4385            .update(cx, |_, window, cx| {
4386                search_view.update(cx, |search_view, cx| {
4387                    search_view.query_editor.update(cx, |query_editor, cx| {
4388                        query_editor.set_text("TWO_NEW", window, cx)
4389                    });
4390                    search_view.search(false, cx);
4391                });
4392            })
4393            .unwrap();
4394        cx.background_executor.run_until_parked();
4395        window
4396            .update(cx, |_, _, cx| {
4397                search_view.update(cx, |search_view, cx| {
4398                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
4399                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4400                });
4401            })
4402            .unwrap();
4403
4404        // New search input should add another entry to history and move the selection to the end of the history.
4405        window
4406            .update(cx, |_, window, cx| {
4407                search_bar.update(cx, |search_bar, cx| {
4408                    search_bar.focus_search(window, cx);
4409                    search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4410                });
4411            })
4412            .unwrap();
4413        window
4414            .update(cx, |_, _, cx| {
4415                search_view.update(cx, |search_view, cx| {
4416                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
4417                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4418                });
4419            })
4420            .unwrap();
4421        window
4422            .update(cx, |_, window, cx| {
4423                search_bar.update(cx, |search_bar, cx| {
4424                    search_bar.focus_search(window, cx);
4425                    search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4426                });
4427            })
4428            .unwrap();
4429        window
4430            .update(cx, |_, _, cx| {
4431                search_view.update(cx, |search_view, cx| {
4432                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
4433                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4434                });
4435            })
4436            .unwrap();
4437        window
4438            .update(cx, |_, window, cx| {
4439                search_bar.update(cx, |search_bar, cx| {
4440                    search_bar.focus_search(window, cx);
4441                    search_bar.next_history_query(&NextHistoryQuery, window, cx);
4442                });
4443            })
4444            .unwrap();
4445        window
4446            .update(cx, |_, _, cx| {
4447                search_view.update(cx, |search_view, cx| {
4448                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
4449                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4450                });
4451            })
4452            .unwrap();
4453        window
4454            .update(cx, |_, window, cx| {
4455                search_bar.update(cx, |search_bar, cx| {
4456                    search_bar.focus_search(window, cx);
4457                    search_bar.next_history_query(&NextHistoryQuery, window, cx);
4458                });
4459            })
4460            .unwrap();
4461        window
4462            .update(cx, |_, _, cx| {
4463                search_view.update(cx, |search_view, cx| {
4464                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
4465                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4466                });
4467            })
4468            .unwrap();
4469        window
4470            .update(cx, |_, window, cx| {
4471                search_bar.update(cx, |search_bar, cx| {
4472                    search_bar.focus_search(window, cx);
4473                    search_bar.next_history_query(&NextHistoryQuery, window, cx);
4474                });
4475            })
4476            .unwrap();
4477        window
4478            .update(cx, |_, _, cx| {
4479                search_view.update(cx, |search_view, cx| {
4480                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
4481                    assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
4482                });
4483            })
4484            .unwrap();
4485
4486        // Typing text without running a search, then navigating history, should allow
4487        // restoring the draft when pressing next past the end.
4488        window
4489            .update(cx, |_, window, cx| {
4490                search_view.update(cx, |search_view, cx| {
4491                    search_view.query_editor.update(cx, |query_editor, cx| {
4492                        query_editor.set_text("unsaved draft", window, cx)
4493                    });
4494                })
4495            })
4496            .unwrap();
4497        cx.background_executor.run_until_parked();
4498
4499        // Navigate up into history — the draft should be stashed.
4500        window
4501            .update(cx, |_, window, cx| {
4502                search_bar.update(cx, |search_bar, cx| {
4503                    search_bar.focus_search(window, cx);
4504                    search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4505                });
4506            })
4507            .unwrap();
4508        window
4509            .update(cx, |_, _, cx| {
4510                search_view.update(cx, |search_view, cx| {
4511                    assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
4512                });
4513            })
4514            .unwrap();
4515
4516        // Navigate forward through history.
4517        window
4518            .update(cx, |_, window, cx| {
4519                search_bar.update(cx, |search_bar, cx| {
4520                    search_bar.focus_search(window, cx);
4521                    search_bar.next_history_query(&NextHistoryQuery, window, cx);
4522                });
4523            })
4524            .unwrap();
4525        window
4526            .update(cx, |_, _, cx| {
4527                search_view.update(cx, |search_view, cx| {
4528                    assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
4529                });
4530            })
4531            .unwrap();
4532
4533        // Navigate past the end — the draft should be restored.
4534        window
4535            .update(cx, |_, window, cx| {
4536                search_bar.update(cx, |search_bar, cx| {
4537                    search_bar.focus_search(window, cx);
4538                    search_bar.next_history_query(&NextHistoryQuery, window, cx);
4539                });
4540            })
4541            .unwrap();
4542        window
4543            .update(cx, |_, _, cx| {
4544                search_view.update(cx, |search_view, cx| {
4545                    assert_eq!(search_view.query_editor.read(cx).text(cx), "unsaved draft");
4546                });
4547            })
4548            .unwrap();
4549    }
4550
4551    #[perf]
4552    #[gpui::test]
4553    async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) {
4554        init_test(cx);
4555
4556        let fs = FakeFs::new(cx.background_executor.clone());
4557        fs.insert_tree(
4558            path!("/dir"),
4559            json!({
4560                "one.rs": "const ONE: usize = 1;",
4561            }),
4562        )
4563        .await;
4564        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4565        let worktree_id = project.update(cx, |this, cx| {
4566            this.worktrees(cx).next().unwrap().read(cx).id()
4567        });
4568
4569        let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
4570        let workspace = window
4571            .read_with(cx, |mw, _| mw.workspace().clone())
4572            .unwrap();
4573        let cx = &mut VisualTestContext::from_window(window.into(), cx);
4574
4575        let panes: Vec<_> = workspace.update_in(cx, |this, _, _| this.panes().to_owned());
4576
4577        let search_bar_1 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
4578        let search_bar_2 = window.build_entity(cx, |_, _| ProjectSearchBar::new());
4579
4580        assert_eq!(panes.len(), 1);
4581        let first_pane = panes.first().cloned().unwrap();
4582        assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 0);
4583        workspace
4584            .update_in(cx, |workspace, window, cx| {
4585                workspace.open_path(
4586                    (worktree_id, rel_path("one.rs")),
4587                    Some(first_pane.downgrade()),
4588                    true,
4589                    window,
4590                    cx,
4591                )
4592            })
4593            .await
4594            .unwrap();
4595        assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 1);
4596
4597        // Add a project search item to the first pane
4598        workspace.update_in(cx, {
4599            let search_bar = search_bar_1.clone();
4600            |workspace, window, cx| {
4601                first_pane.update(cx, |pane, cx| {
4602                    pane.toolbar()
4603                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4604                });
4605
4606                ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4607            }
4608        });
4609        let search_view_1 = cx.read(|cx| {
4610            workspace
4611                .read(cx)
4612                .active_item(cx)
4613                .and_then(|item| item.downcast::<ProjectSearchView>())
4614                .expect("Search view expected to appear after new search event trigger")
4615        });
4616
4617        let second_pane = workspace
4618            .update_in(cx, |workspace, window, cx| {
4619                workspace.split_and_clone(
4620                    first_pane.clone(),
4621                    workspace::SplitDirection::Right,
4622                    window,
4623                    cx,
4624                )
4625            })
4626            .await
4627            .unwrap();
4628        assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 1);
4629
4630        assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 1);
4631        assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 2);
4632
4633        // Add a project search item to the second pane
4634        workspace.update_in(cx, {
4635            let search_bar = search_bar_2.clone();
4636            let pane = second_pane.clone();
4637            move |workspace, window, cx| {
4638                assert_eq!(workspace.panes().len(), 2);
4639                pane.update(cx, |pane, cx| {
4640                    pane.toolbar()
4641                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4642                });
4643
4644                ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4645            }
4646        });
4647
4648        let search_view_2 = cx.read(|cx| {
4649            workspace
4650                .read(cx)
4651                .active_item(cx)
4652                .and_then(|item| item.downcast::<ProjectSearchView>())
4653                .expect("Search view expected to appear after new search event trigger")
4654        });
4655
4656        cx.run_until_parked();
4657        assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 2);
4658        assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 2);
4659
4660        let update_search_view =
4661            |search_view: &Entity<ProjectSearchView>, query: &str, cx: &mut TestAppContext| {
4662                window
4663                    .update(cx, |_, window, cx| {
4664                        search_view.update(cx, |search_view, cx| {
4665                            search_view.query_editor.update(cx, |query_editor, cx| {
4666                                query_editor.set_text(query, window, cx)
4667                            });
4668                            search_view.search(false, cx);
4669                        });
4670                    })
4671                    .unwrap();
4672            };
4673
4674        let active_query =
4675            |search_view: &Entity<ProjectSearchView>, cx: &mut TestAppContext| -> String {
4676                window
4677                    .update(cx, |_, _, cx| {
4678                        search_view.update(cx, |search_view, cx| {
4679                            search_view.query_editor.read(cx).text(cx)
4680                        })
4681                    })
4682                    .unwrap()
4683            };
4684
4685        let select_prev_history_item =
4686            |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
4687                window
4688                    .update(cx, |_, window, cx| {
4689                        search_bar.update(cx, |search_bar, cx| {
4690                            search_bar.focus_search(window, cx);
4691                            search_bar.previous_history_query(&PreviousHistoryQuery, window, cx);
4692                        })
4693                    })
4694                    .unwrap();
4695            };
4696
4697        let select_next_history_item =
4698            |search_bar: &Entity<ProjectSearchBar>, cx: &mut TestAppContext| {
4699                window
4700                    .update(cx, |_, window, cx| {
4701                        search_bar.update(cx, |search_bar, cx| {
4702                            search_bar.focus_search(window, cx);
4703                            search_bar.next_history_query(&NextHistoryQuery, window, cx);
4704                        })
4705                    })
4706                    .unwrap();
4707            };
4708
4709        update_search_view(&search_view_1, "ONE", cx);
4710        cx.background_executor.run_until_parked();
4711
4712        update_search_view(&search_view_2, "TWO", cx);
4713        cx.background_executor.run_until_parked();
4714
4715        assert_eq!(active_query(&search_view_1, cx), "ONE");
4716        assert_eq!(active_query(&search_view_2, cx), "TWO");
4717
4718        // Selecting previous history item should select the query from search view 1.
4719        select_prev_history_item(&search_bar_2, cx);
4720        assert_eq!(active_query(&search_view_2, cx), "ONE");
4721
4722        // Selecting the previous history item should not change the query as it is already the first item.
4723        select_prev_history_item(&search_bar_2, cx);
4724        assert_eq!(active_query(&search_view_2, cx), "ONE");
4725
4726        // Changing the query in search view 2 should not affect the history of search view 1.
4727        assert_eq!(active_query(&search_view_1, cx), "ONE");
4728
4729        // Deploying a new search in search view 2
4730        update_search_view(&search_view_2, "THREE", cx);
4731        cx.background_executor.run_until_parked();
4732
4733        select_next_history_item(&search_bar_2, cx);
4734        assert_eq!(active_query(&search_view_2, cx), "THREE");
4735
4736        select_prev_history_item(&search_bar_2, cx);
4737        assert_eq!(active_query(&search_view_2, cx), "TWO");
4738
4739        select_prev_history_item(&search_bar_2, cx);
4740        assert_eq!(active_query(&search_view_2, cx), "ONE");
4741
4742        select_prev_history_item(&search_bar_2, cx);
4743        assert_eq!(active_query(&search_view_2, cx), "ONE");
4744
4745        select_prev_history_item(&search_bar_2, cx);
4746        assert_eq!(active_query(&search_view_2, cx), "ONE");
4747
4748        // Search view 1 should now see the query from search view 2.
4749        assert_eq!(active_query(&search_view_1, cx), "ONE");
4750
4751        select_next_history_item(&search_bar_2, cx);
4752        assert_eq!(active_query(&search_view_2, cx), "TWO");
4753
4754        // Here is the new query from search view 2
4755        select_next_history_item(&search_bar_2, cx);
4756        assert_eq!(active_query(&search_view_2, cx), "THREE");
4757
4758        select_next_history_item(&search_bar_2, cx);
4759        assert_eq!(active_query(&search_view_2, cx), "THREE");
4760
4761        select_next_history_item(&search_bar_1, cx);
4762        assert_eq!(active_query(&search_view_1, cx), "TWO");
4763
4764        select_next_history_item(&search_bar_1, cx);
4765        assert_eq!(active_query(&search_view_1, cx), "THREE");
4766
4767        select_next_history_item(&search_bar_1, cx);
4768        assert_eq!(active_query(&search_view_1, cx), "THREE");
4769    }
4770
4771    #[perf]
4772    #[gpui::test]
4773    async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) {
4774        init_test(cx);
4775
4776        // Setup 2 panes, both with a file open and one with a project search.
4777        let fs = FakeFs::new(cx.background_executor.clone());
4778        fs.insert_tree(
4779            path!("/dir"),
4780            json!({
4781                "one.rs": "const ONE: usize = 1;",
4782            }),
4783        )
4784        .await;
4785        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4786        let worktree_id = project.update(cx, |this, cx| {
4787            this.worktrees(cx).next().unwrap().read(cx).id()
4788        });
4789        let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
4790        let workspace = window
4791            .read_with(cx, |mw, _| mw.workspace().clone())
4792            .unwrap();
4793        let cx = &mut VisualTestContext::from_window(window.into(), cx);
4794        let panes: Vec<_> = workspace.update_in(cx, |this, _, _| this.panes().to_owned());
4795        assert_eq!(panes.len(), 1);
4796        let first_pane = panes.first().cloned().unwrap();
4797        assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 0);
4798        workspace
4799            .update_in(cx, |workspace, window, cx| {
4800                workspace.open_path(
4801                    (worktree_id, rel_path("one.rs")),
4802                    Some(first_pane.downgrade()),
4803                    true,
4804                    window,
4805                    cx,
4806                )
4807            })
4808            .await
4809            .unwrap();
4810        assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 1);
4811        let second_pane = workspace
4812            .update_in(cx, |workspace, window, cx| {
4813                workspace.split_and_clone(
4814                    first_pane.clone(),
4815                    workspace::SplitDirection::Right,
4816                    window,
4817                    cx,
4818                )
4819            })
4820            .await
4821            .unwrap();
4822        assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 1);
4823        assert!(
4824            window
4825                .update(cx, |_, window, cx| second_pane
4826                    .focus_handle(cx)
4827                    .contains_focused(window, cx))
4828                .unwrap()
4829        );
4830        let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
4831        workspace.update_in(cx, {
4832            let search_bar = search_bar.clone();
4833            let pane = first_pane.clone();
4834            move |workspace, window, cx| {
4835                assert_eq!(workspace.panes().len(), 2);
4836                pane.update(cx, move |pane, cx| {
4837                    pane.toolbar()
4838                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4839                });
4840            }
4841        });
4842
4843        // Add a project search item to the second pane
4844        workspace.update_in(cx, {
4845            |workspace, window, cx| {
4846                assert_eq!(workspace.panes().len(), 2);
4847                second_pane.update(cx, |pane, cx| {
4848                    pane.toolbar()
4849                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
4850                });
4851
4852                ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
4853            }
4854        });
4855
4856        cx.run_until_parked();
4857        assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 2);
4858        assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 1);
4859
4860        // Focus the first pane
4861        workspace.update_in(cx, |workspace, window, cx| {
4862            assert_eq!(workspace.active_pane(), &second_pane);
4863            second_pane.update(cx, |this, cx| {
4864                assert_eq!(this.active_item_index(), 1);
4865                this.activate_previous_item(&Default::default(), window, cx);
4866                assert_eq!(this.active_item_index(), 0);
4867            });
4868            workspace.activate_pane_in_direction(workspace::SplitDirection::Left, window, cx);
4869        });
4870        workspace.update_in(cx, |workspace, _, cx| {
4871            assert_eq!(workspace.active_pane(), &first_pane);
4872            assert_eq!(first_pane.read(cx).items_len(), 1);
4873            assert_eq!(second_pane.read(cx).items_len(), 2);
4874        });
4875
4876        // Deploy a new search
4877        cx.dispatch_action(DeploySearch::default());
4878
4879        // Both panes should now have a project search in them
4880        workspace.update_in(cx, |workspace, window, cx| {
4881            assert_eq!(workspace.active_pane(), &first_pane);
4882            first_pane.read_with(cx, |this, _| {
4883                assert_eq!(this.active_item_index(), 1);
4884                assert_eq!(this.items_len(), 2);
4885            });
4886            second_pane.update(cx, |this, cx| {
4887                assert!(!cx.focus_handle().contains_focused(window, cx));
4888                assert_eq!(this.items_len(), 2);
4889            });
4890        });
4891
4892        // Focus the second pane's non-search item
4893        window
4894            .update(cx, |_workspace, window, cx| {
4895                second_pane.update(cx, |pane, cx| {
4896                    pane.activate_next_item(&Default::default(), window, cx)
4897                });
4898            })
4899            .unwrap();
4900
4901        // Deploy a new search
4902        cx.dispatch_action(DeploySearch::default());
4903
4904        // The project search view should now be focused in the second pane
4905        // And the number of items should be unchanged.
4906        window
4907            .update(cx, |_workspace, _, cx| {
4908                second_pane.update(cx, |pane, _cx| {
4909                    assert!(
4910                        pane.active_item()
4911                            .unwrap()
4912                            .downcast::<ProjectSearchView>()
4913                            .is_some()
4914                    );
4915
4916                    assert_eq!(pane.items_len(), 2);
4917                });
4918            })
4919            .unwrap();
4920    }
4921
4922    #[perf]
4923    #[gpui::test]
4924    async fn test_scroll_search_results_to_top(cx: &mut TestAppContext) {
4925        init_test(cx);
4926
4927        // We need many lines in the search results to be able to scroll the window
4928        let fs = FakeFs::new(cx.background_executor.clone());
4929        fs.insert_tree(
4930            path!("/dir"),
4931            json!({
4932                "1.txt": "\n\n\n\n\n A \n\n\n\n\n",
4933                "2.txt": "\n\n\n\n\n A \n\n\n\n\n",
4934                "3.rs": "\n\n\n\n\n A \n\n\n\n\n",
4935                "4.rs": "\n\n\n\n\n A \n\n\n\n\n",
4936                "5.rs": "\n\n\n\n\n A \n\n\n\n\n",
4937                "6.rs": "\n\n\n\n\n A \n\n\n\n\n",
4938                "7.rs": "\n\n\n\n\n A \n\n\n\n\n",
4939                "8.rs": "\n\n\n\n\n A \n\n\n\n\n",
4940                "9.rs": "\n\n\n\n\n A \n\n\n\n\n",
4941                "a.rs": "\n\n\n\n\n A \n\n\n\n\n",
4942                "b.rs": "\n\n\n\n\n B \n\n\n\n\n",
4943                "c.rs": "\n\n\n\n\n B \n\n\n\n\n",
4944                "d.rs": "\n\n\n\n\n B \n\n\n\n\n",
4945                "e.rs": "\n\n\n\n\n B \n\n\n\n\n",
4946                "f.rs": "\n\n\n\n\n B \n\n\n\n\n",
4947                "g.rs": "\n\n\n\n\n B \n\n\n\n\n",
4948                "h.rs": "\n\n\n\n\n B \n\n\n\n\n",
4949                "i.rs": "\n\n\n\n\n B \n\n\n\n\n",
4950                "j.rs": "\n\n\n\n\n B \n\n\n\n\n",
4951                "k.rs": "\n\n\n\n\n B \n\n\n\n\n",
4952            }),
4953        )
4954        .await;
4955        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4956        let window =
4957            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4958        let workspace = window
4959            .read_with(cx, |mw, _| mw.workspace().clone())
4960            .unwrap();
4961        let search = cx.new(|cx| ProjectSearch::new(project, cx));
4962        let search_view = cx.add_window(|window, cx| {
4963            ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
4964        });
4965
4966        // First search
4967        perform_search(search_view, "A", cx);
4968        search_view
4969            .update(cx, |search_view, window, cx| {
4970                search_view.results_editor.update(cx, |results_editor, cx| {
4971                    // Results are correct and scrolled to the top
4972                    assert_eq!(
4973                        results_editor.display_text(cx).match_indices(" A ").count(),
4974                        10
4975                    );
4976                    assert_eq!(results_editor.scroll_position(cx), Point::default());
4977
4978                    // Scroll results all the way down
4979                    results_editor.scroll(
4980                        Point::new(0., f64::MAX),
4981                        Some(Axis::Vertical),
4982                        window,
4983                        cx,
4984                    );
4985                });
4986            })
4987            .expect("unable to update search view");
4988
4989        // Second search
4990        perform_search(search_view, "B", cx);
4991        search_view
4992            .update(cx, |search_view, _, cx| {
4993                search_view.results_editor.update(cx, |results_editor, cx| {
4994                    // Results are correct...
4995                    assert_eq!(
4996                        results_editor.display_text(cx).match_indices(" B ").count(),
4997                        10
4998                    );
4999                    // ...and scrolled back to the top
5000                    assert_eq!(results_editor.scroll_position(cx), Point::default());
5001                });
5002            })
5003            .expect("unable to update search view");
5004    }
5005
5006    #[perf]
5007    #[gpui::test]
5008    async fn test_buffer_search_query_reused(cx: &mut TestAppContext) {
5009        init_test(cx);
5010
5011        let fs = FakeFs::new(cx.background_executor.clone());
5012        fs.insert_tree(
5013            path!("/dir"),
5014            json!({
5015                "one.rs": "const ONE: usize = 1;",
5016            }),
5017        )
5018        .await;
5019        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5020        let worktree_id = project.update(cx, |this, cx| {
5021            this.worktrees(cx).next().unwrap().read(cx).id()
5022        });
5023        let window =
5024            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5025        let workspace = window
5026            .read_with(cx, |mw, _| mw.workspace().clone())
5027            .unwrap();
5028        let mut cx = VisualTestContext::from_window(window.into(), cx);
5029
5030        let editor = workspace
5031            .update_in(&mut cx, |workspace, window, cx| {
5032                workspace.open_path((worktree_id, rel_path("one.rs")), None, true, window, cx)
5033            })
5034            .await
5035            .unwrap()
5036            .downcast::<Editor>()
5037            .unwrap();
5038
5039        // Wait for the unstaged changes to be loaded
5040        cx.run_until_parked();
5041
5042        let buffer_search_bar = cx.new_window_entity(|window, cx| {
5043            let mut search_bar =
5044                BufferSearchBar::new(Some(project.read(cx).languages().clone()), window, cx);
5045            search_bar.set_active_pane_item(Some(&editor), window, cx);
5046            search_bar.show(window, cx);
5047            search_bar
5048        });
5049
5050        let panes: Vec<_> = workspace.update_in(&mut cx, |this, _, _| this.panes().to_owned());
5051        assert_eq!(panes.len(), 1);
5052        let pane = panes.first().cloned().unwrap();
5053        pane.update_in(&mut cx, |pane, window, cx| {
5054            pane.toolbar().update(cx, |toolbar, cx| {
5055                toolbar.add_item(buffer_search_bar.clone(), window, cx);
5056            })
5057        });
5058
5059        let buffer_search_query = "search bar query";
5060        buffer_search_bar
5061            .update_in(&mut cx, |buffer_search_bar, window, cx| {
5062                buffer_search_bar.focus_handle(cx).focus(window, cx);
5063                buffer_search_bar.search(buffer_search_query, None, true, window, cx)
5064            })
5065            .await
5066            .unwrap();
5067
5068        workspace.update_in(&mut cx, |workspace, window, cx| {
5069            ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx)
5070        });
5071        cx.run_until_parked();
5072        let project_search_view = pane
5073            .read_with(&cx, |pane, _| {
5074                pane.active_item()
5075                    .and_then(|item| item.downcast::<ProjectSearchView>())
5076            })
5077            .expect("should open a project search view after spawning a new search");
5078        project_search_view.update(&mut cx, |search_view, cx| {
5079            assert_eq!(
5080                search_view.search_query_text(cx),
5081                buffer_search_query,
5082                "Project search should take the query from the buffer search bar since it got focused and had a query inside"
5083            );
5084        });
5085    }
5086
5087    #[gpui::test]
5088    async fn test_search_dismisses_modal(cx: &mut TestAppContext) {
5089        init_test(cx);
5090
5091        let fs = FakeFs::new(cx.background_executor.clone());
5092        fs.insert_tree(
5093            path!("/dir"),
5094            json!({
5095                "one.rs": "const ONE: usize = 1;",
5096            }),
5097        )
5098        .await;
5099        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5100        let window =
5101            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5102        let workspace = window
5103            .read_with(cx, |mw, _| mw.workspace().clone())
5104            .unwrap();
5105        let cx = &mut VisualTestContext::from_window(window.into(), cx);
5106
5107        struct EmptyModalView {
5108            focus_handle: gpui::FocusHandle,
5109        }
5110        impl EventEmitter<gpui::DismissEvent> for EmptyModalView {}
5111        impl Render for EmptyModalView {
5112            fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement {
5113                div()
5114            }
5115        }
5116        impl Focusable for EmptyModalView {
5117            fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
5118                self.focus_handle.clone()
5119            }
5120        }
5121        impl workspace::ModalView for EmptyModalView {}
5122
5123        workspace.update_in(cx, |workspace, window, cx| {
5124            workspace.toggle_modal(window, cx, |_, cx| EmptyModalView {
5125                focus_handle: cx.focus_handle(),
5126            });
5127            assert!(workspace.has_active_modal(window, cx));
5128        });
5129
5130        cx.dispatch_action(Deploy::find());
5131
5132        workspace.update_in(cx, |workspace, window, cx| {
5133            assert!(!workspace.has_active_modal(window, cx));
5134            workspace.toggle_modal(window, cx, |_, cx| EmptyModalView {
5135                focus_handle: cx.focus_handle(),
5136            });
5137            assert!(workspace.has_active_modal(window, cx));
5138        });
5139
5140        cx.dispatch_action(DeploySearch::default());
5141
5142        workspace.update_in(cx, |workspace, window, cx| {
5143            assert!(!workspace.has_active_modal(window, cx));
5144        });
5145    }
5146
5147    #[perf]
5148    #[gpui::test]
5149    async fn test_search_with_inlays(cx: &mut TestAppContext) {
5150        init_test(cx);
5151        cx.update(|cx| {
5152            SettingsStore::update_global(cx, |store, cx| {
5153                store.update_user_settings(cx, |settings| {
5154                    settings.project.all_languages.defaults.inlay_hints =
5155                        Some(InlayHintSettingsContent {
5156                            enabled: Some(true),
5157                            ..InlayHintSettingsContent::default()
5158                        })
5159                });
5160            });
5161        });
5162
5163        let fs = FakeFs::new(cx.background_executor.clone());
5164        fs.insert_tree(
5165            path!("/dir"),
5166            // `\n` , a trailing line on the end, is important for the test case
5167            json!({
5168                "main.rs": "fn main() { let a = 2; }\n",
5169            }),
5170        )
5171        .await;
5172
5173        let requests_count = Arc::new(AtomicUsize::new(0));
5174        let closure_requests_count = requests_count.clone();
5175        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5176        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
5177        let language = rust_lang();
5178        language_registry.add(language);
5179        let mut fake_servers = language_registry.register_fake_lsp(
5180            "Rust",
5181            FakeLspAdapter {
5182                capabilities: lsp::ServerCapabilities {
5183                    inlay_hint_provider: Some(lsp::OneOf::Left(true)),
5184                    ..lsp::ServerCapabilities::default()
5185                },
5186                initializer: Some(Box::new(move |fake_server| {
5187                    let requests_count = closure_requests_count.clone();
5188                    fake_server.set_request_handler::<lsp::request::InlayHintRequest, _, _>({
5189                        move |_, _| {
5190                            let requests_count = requests_count.clone();
5191                            async move {
5192                                requests_count.fetch_add(1, atomic::Ordering::Release);
5193                                Ok(Some(vec![lsp::InlayHint {
5194                                    position: lsp::Position::new(0, 17),
5195                                    label: lsp::InlayHintLabel::String(": i32".to_owned()),
5196                                    kind: Some(lsp::InlayHintKind::TYPE),
5197                                    text_edits: None,
5198                                    tooltip: None,
5199                                    padding_left: None,
5200                                    padding_right: None,
5201                                    data: None,
5202                                }]))
5203                            }
5204                        }
5205                    });
5206                })),
5207                ..FakeLspAdapter::default()
5208            },
5209        );
5210
5211        let window =
5212            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5213        let workspace = window
5214            .read_with(cx, |mw, _| mw.workspace().clone())
5215            .unwrap();
5216        let cx = &mut VisualTestContext::from_window(window.into(), cx);
5217        let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
5218        let search_view = cx.add_window(|window, cx| {
5219            ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
5220        });
5221
5222        perform_search(search_view, "let ", cx);
5223        let fake_server = fake_servers.next().await.unwrap();
5224        cx.executor().advance_clock(Duration::from_secs(1));
5225        cx.executor().run_until_parked();
5226        search_view
5227            .update(cx, |search_view, _, cx| {
5228                assert_eq!(
5229                    search_view
5230                        .results_editor
5231                        .update(cx, |editor, cx| editor.display_text(cx)),
5232                    "\n\nfn main() { let a: i32 = 2; }\n"
5233                );
5234            })
5235            .unwrap();
5236        assert_eq!(
5237            requests_count.load(atomic::Ordering::Acquire),
5238            1,
5239            "New hints should have been queried",
5240        );
5241
5242        // Can do the 2nd search without any panics
5243        perform_search(search_view, "let ", cx);
5244        cx.executor().advance_clock(Duration::from_secs(1));
5245        cx.executor().run_until_parked();
5246        search_view
5247            .update(cx, |search_view, _, cx| {
5248                assert_eq!(
5249                    search_view
5250                        .results_editor
5251                        .update(cx, |editor, cx| editor.display_text(cx)),
5252                    "\n\nfn main() { let a: i32 = 2; }\n"
5253                );
5254            })
5255            .unwrap();
5256        assert_eq!(
5257            requests_count.load(atomic::Ordering::Acquire),
5258            2,
5259            "We did drop the previous buffer when cleared the old project search results, hence another query was made",
5260        );
5261
5262        let singleton_editor = workspace
5263            .update_in(cx, |workspace, window, cx| {
5264                workspace.open_abs_path(
5265                    PathBuf::from(path!("/dir/main.rs")),
5266                    workspace::OpenOptions::default(),
5267                    window,
5268                    cx,
5269                )
5270            })
5271            .await
5272            .unwrap()
5273            .downcast::<Editor>()
5274            .unwrap();
5275        cx.executor().advance_clock(Duration::from_millis(100));
5276        cx.executor().run_until_parked();
5277        singleton_editor.update(cx, |editor, cx| {
5278            assert_eq!(
5279                editor.display_text(cx),
5280                "fn main() { let a: i32 = 2; }\n",
5281                "Newly opened editor should have the correct text with hints",
5282            );
5283        });
5284        assert_eq!(
5285            requests_count.load(atomic::Ordering::Acquire),
5286            2,
5287            "Opening the same buffer again should reuse the cached hints",
5288        );
5289
5290        window
5291            .update(cx, |_, window, cx| {
5292                singleton_editor.update(cx, |editor, cx| {
5293                    editor.handle_input("test", window, cx);
5294                });
5295            })
5296            .unwrap();
5297
5298        cx.executor().advance_clock(Duration::from_secs(1));
5299        cx.executor().run_until_parked();
5300        singleton_editor.update(cx, |editor, cx| {
5301            assert_eq!(
5302                editor.display_text(cx),
5303                "testfn main() { l: i32et a = 2; }\n",
5304                "Newly opened editor should have the correct text with hints",
5305            );
5306        });
5307        assert_eq!(
5308            requests_count.load(atomic::Ordering::Acquire),
5309            3,
5310            "We have edited the buffer and should send a new request",
5311        );
5312
5313        window
5314            .update(cx, |_, window, cx| {
5315                singleton_editor.update(cx, |editor, cx| {
5316                    editor.undo(&editor::actions::Undo, window, cx);
5317                });
5318            })
5319            .unwrap();
5320        cx.executor().advance_clock(Duration::from_secs(1));
5321        cx.executor().run_until_parked();
5322        assert_eq!(
5323            requests_count.load(atomic::Ordering::Acquire),
5324            4,
5325            "We have edited the buffer again and should send a new request again",
5326        );
5327        singleton_editor.update(cx, |editor, cx| {
5328            assert_eq!(
5329                editor.display_text(cx),
5330                "fn main() { let a: i32 = 2; }\n",
5331                "Newly opened editor should have the correct text with hints",
5332            );
5333        });
5334        project.update(cx, |_, cx| {
5335            cx.emit(project::Event::RefreshInlayHints {
5336                server_id: fake_server.server.server_id(),
5337                request_id: Some(1),
5338            });
5339        });
5340        cx.executor().advance_clock(Duration::from_secs(1));
5341        cx.executor().run_until_parked();
5342        assert_eq!(
5343            requests_count.load(atomic::Ordering::Acquire),
5344            5,
5345            "After a simulated server refresh request, we should have sent another request",
5346        );
5347
5348        perform_search(search_view, "let ", cx);
5349        cx.executor().advance_clock(Duration::from_secs(1));
5350        cx.executor().run_until_parked();
5351        assert_eq!(
5352            requests_count.load(atomic::Ordering::Acquire),
5353            5,
5354            "New project search should reuse the cached hints",
5355        );
5356        search_view
5357            .update(cx, |search_view, _, cx| {
5358                assert_eq!(
5359                    search_view
5360                        .results_editor
5361                        .update(cx, |editor, cx| editor.display_text(cx)),
5362                    "\n\nfn main() { let a: i32 = 2; }\n"
5363                );
5364            })
5365            .unwrap();
5366    }
5367
5368    #[gpui::test]
5369    async fn test_deleted_file_removed_from_search_results(cx: &mut TestAppContext) {
5370        init_test(cx);
5371
5372        let fs = FakeFs::new(cx.background_executor.clone());
5373        fs.insert_tree(
5374            path!("/dir"),
5375            json!({
5376                "file_a.txt": "hello world",
5377                "file_b.txt": "hello universe",
5378            }),
5379        )
5380        .await;
5381
5382        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5383        let window =
5384            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5385        let workspace = window
5386            .read_with(cx, |mw, _| mw.workspace().clone())
5387            .unwrap();
5388        let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
5389        let search_view = cx.add_window(|window, cx| {
5390            ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
5391        });
5392
5393        perform_search(search_view, "hello", cx);
5394
5395        search_view
5396            .update(cx, |search_view, _window, cx| {
5397                let match_count = search_view.entity.read(cx).match_ranges.len();
5398                assert_eq!(match_count, 2, "Should have matches from both files");
5399            })
5400            .unwrap();
5401
5402        // Delete file_b.txt
5403        fs.remove_file(
5404            path!("/dir/file_b.txt").as_ref(),
5405            fs::RemoveOptions::default(),
5406        )
5407        .await
5408        .unwrap();
5409        cx.run_until_parked();
5410
5411        // Verify deleted file's results are removed proactively
5412        search_view
5413            .update(cx, |search_view, _window, cx| {
5414                let results_text = search_view
5415                    .results_editor
5416                    .update(cx, |editor, cx| editor.display_text(cx));
5417                assert!(
5418                    !results_text.contains("universe"),
5419                    "Deleted file's content should be removed from results, got: {results_text}"
5420                );
5421                assert!(
5422                    results_text.contains("world"),
5423                    "Remaining file's content should still be present, got: {results_text}"
5424                );
5425            })
5426            .unwrap();
5427
5428        // Re-run the search and verify deleted file stays gone
5429        perform_search(search_view, "hello", cx);
5430
5431        search_view
5432            .update(cx, |search_view, _window, cx| {
5433                let results_text = search_view
5434                    .results_editor
5435                    .update(cx, |editor, cx| editor.display_text(cx));
5436                assert!(
5437                    !results_text.contains("universe"),
5438                    "Deleted file should not reappear after re-search, got: {results_text}"
5439                );
5440                assert!(
5441                    results_text.contains("world"),
5442                    "Remaining file should still be found, got: {results_text}"
5443                );
5444                assert_eq!(
5445                    search_view.entity.read(cx).match_ranges.len(),
5446                    1,
5447                    "Should only have match from the remaining file"
5448                );
5449            })
5450            .unwrap();
5451    }
5452
5453    #[gpui::test]
5454    async fn test_deploy_search_applies_and_resets_options(cx: &mut TestAppContext) {
5455        init_test(cx);
5456
5457        let fs = FakeFs::new(cx.background_executor.clone());
5458        fs.insert_tree(
5459            path!("/dir"),
5460            json!({
5461                "one.rs": "const ONE: usize = 1;",
5462            }),
5463        )
5464        .await;
5465        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5466        let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
5467        let workspace = window
5468            .read_with(cx, |mw, _| mw.workspace().clone())
5469            .unwrap();
5470        let cx = &mut VisualTestContext::from_window(window.into(), cx);
5471        let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
5472
5473        workspace.update_in(cx, |workspace, window, cx| {
5474            workspace.panes()[0].update(cx, |pane, cx| {
5475                pane.toolbar()
5476                    .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
5477            });
5478
5479            ProjectSearchView::deploy_search(
5480                workspace,
5481                &workspace::DeploySearch {
5482                    regex: Some(true),
5483                    case_sensitive: Some(true),
5484                    whole_word: Some(true),
5485                    include_ignored: Some(true),
5486                    query: Some("Test_Query".into()),
5487                    ..Default::default()
5488                },
5489                window,
5490                cx,
5491            )
5492        });
5493
5494        let search_view = cx
5495            .read(|cx| {
5496                workspace
5497                    .read(cx)
5498                    .active_pane()
5499                    .read(cx)
5500                    .active_item()
5501                    .and_then(|item| item.downcast::<ProjectSearchView>())
5502            })
5503            .expect("Search view should be active after deploy");
5504
5505        search_view.update_in(cx, |search_view, _window, cx| {
5506            assert!(
5507                search_view.search_options.contains(SearchOptions::REGEX),
5508                "Regex option should be enabled"
5509            );
5510            assert!(
5511                search_view
5512                    .search_options
5513                    .contains(SearchOptions::CASE_SENSITIVE),
5514                "Case sensitive option should be enabled"
5515            );
5516            assert!(
5517                search_view
5518                    .search_options
5519                    .contains(SearchOptions::WHOLE_WORD),
5520                "Whole word option should be enabled"
5521            );
5522            assert!(
5523                search_view
5524                    .search_options
5525                    .contains(SearchOptions::INCLUDE_IGNORED),
5526                "Include ignored option should be enabled"
5527            );
5528            let query_text = search_view.query_editor.read(cx).text(cx);
5529            assert_eq!(
5530                query_text, "Test_Query",
5531                "Query should be set from the action"
5532            );
5533        });
5534
5535        // Redeploy with only regex - unspecified options should be preserved.
5536        cx.dispatch_action(menu::Cancel);
5537        workspace.update_in(cx, |workspace, window, cx| {
5538            ProjectSearchView::deploy_search(
5539                workspace,
5540                &workspace::DeploySearch {
5541                    regex: Some(true),
5542                    ..Default::default()
5543                },
5544                window,
5545                cx,
5546            )
5547        });
5548
5549        search_view.update_in(cx, |search_view, _window, _cx| {
5550            assert!(
5551                search_view.search_options.contains(SearchOptions::REGEX),
5552                "Regex should still be enabled"
5553            );
5554            assert!(
5555                search_view
5556                    .search_options
5557                    .contains(SearchOptions::CASE_SENSITIVE),
5558                "Case sensitive should be preserved from previous deploy"
5559            );
5560            assert!(
5561                search_view
5562                    .search_options
5563                    .contains(SearchOptions::WHOLE_WORD),
5564                "Whole word should be preserved from previous deploy"
5565            );
5566            assert!(
5567                search_view
5568                    .search_options
5569                    .contains(SearchOptions::INCLUDE_IGNORED),
5570                "Include ignored should be preserved from previous deploy"
5571            );
5572        });
5573
5574        // Redeploy explicitly turning off options.
5575        cx.dispatch_action(menu::Cancel);
5576        workspace.update_in(cx, |workspace, window, cx| {
5577            ProjectSearchView::deploy_search(
5578                workspace,
5579                &workspace::DeploySearch {
5580                    regex: Some(true),
5581                    case_sensitive: Some(false),
5582                    whole_word: Some(false),
5583                    include_ignored: Some(false),
5584                    ..Default::default()
5585                },
5586                window,
5587                cx,
5588            )
5589        });
5590
5591        search_view.update_in(cx, |search_view, _window, _cx| {
5592            assert_eq!(
5593                search_view.search_options,
5594                SearchOptions::REGEX,
5595                "Explicit Some(false) should turn off options"
5596            );
5597        });
5598
5599        // Redeploy with an empty query - should not overwrite the existing query.
5600        cx.dispatch_action(menu::Cancel);
5601        workspace.update_in(cx, |workspace, window, cx| {
5602            ProjectSearchView::deploy_search(
5603                workspace,
5604                &workspace::DeploySearch {
5605                    query: Some("".into()),
5606                    ..Default::default()
5607                },
5608                window,
5609                cx,
5610            )
5611        });
5612
5613        search_view.update_in(cx, |search_view, _window, cx| {
5614            let query_text = search_view.query_editor.read(cx).text(cx);
5615            assert_eq!(
5616                query_text, "Test_Query",
5617                "Empty query string should not overwrite the existing query"
5618            );
5619        });
5620    }
5621
5622    #[gpui::test]
5623    async fn test_smartcase_overrides_explicit_case_sensitive(cx: &mut TestAppContext) {
5624        init_test(cx);
5625
5626        cx.update(|cx| {
5627            cx.update_global::<SettingsStore, _>(|store, cx| {
5628                store.update_default_settings(cx, |settings| {
5629                    settings.editor.use_smartcase_search = Some(true);
5630                });
5631            });
5632        });
5633
5634        let fs = FakeFs::new(cx.background_executor.clone());
5635        fs.insert_tree(
5636            path!("/dir"),
5637            json!({
5638                "one.rs": "const ONE: usize = 1;",
5639            }),
5640        )
5641        .await;
5642        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5643        let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
5644        let workspace = window
5645            .read_with(cx, |mw, _| mw.workspace().clone())
5646            .unwrap();
5647        let cx = &mut VisualTestContext::from_window(window.into(), cx);
5648        let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
5649
5650        workspace.update_in(cx, |workspace, window, cx| {
5651            workspace.panes()[0].update(cx, |pane, cx| {
5652                pane.toolbar()
5653                    .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
5654            });
5655
5656            ProjectSearchView::deploy_search(
5657                workspace,
5658                &workspace::DeploySearch {
5659                    case_sensitive: Some(true),
5660                    query: Some("lowercase_query".into()),
5661                    ..Default::default()
5662                },
5663                window,
5664                cx,
5665            )
5666        });
5667
5668        let search_view = cx
5669            .read(|cx| {
5670                workspace
5671                    .read(cx)
5672                    .active_pane()
5673                    .read(cx)
5674                    .active_item()
5675                    .and_then(|item| item.downcast::<ProjectSearchView>())
5676            })
5677            .expect("Search view should be active after deploy");
5678
5679        // Smartcase should override the explicit case_sensitive flag
5680        // because the query is all lowercase.
5681        search_view.update_in(cx, |search_view, _window, cx| {
5682            assert!(
5683                !search_view
5684                    .search_options
5685                    .contains(SearchOptions::CASE_SENSITIVE),
5686                "Smartcase should disable case sensitivity for a lowercase query, \
5687                 even when case_sensitive was explicitly set in the action"
5688            );
5689            let query_text = search_view.query_editor.read(cx).text(cx);
5690            assert_eq!(query_text, "lowercase_query");
5691        });
5692
5693        // Now deploy with an uppercase query - smartcase should enable case sensitivity.
5694        workspace.update_in(cx, |workspace, window, cx| {
5695            ProjectSearchView::deploy_search(
5696                workspace,
5697                &workspace::DeploySearch {
5698                    query: Some("Uppercase_Query".into()),
5699                    ..Default::default()
5700                },
5701                window,
5702                cx,
5703            )
5704        });
5705
5706        search_view.update_in(cx, |search_view, _window, cx| {
5707            assert!(
5708                search_view
5709                    .search_options
5710                    .contains(SearchOptions::CASE_SENSITIVE),
5711                "Smartcase should enable case sensitivity for a query containing uppercase"
5712            );
5713            let query_text = search_view.query_editor.read(cx).text(cx);
5714            assert_eq!(query_text, "Uppercase_Query");
5715        });
5716    }
5717
5718    #[gpui::test]
5719    async fn test_incremental_search_narrows_and_widens(cx: &mut TestAppContext) {
5720        init_test(cx);
5721
5722        // Two matches 5 lines apart: with context_line_count=2, contexts
5723        // [3..7] and [8..12] are adjacent and merge into a single excerpt
5724        // [3..12]. Narrowing to "targeted" produces context [3..7] ⊂ [3..12]
5725        // — the expand_new_ranges_to_existing fix ensures zero excerpt events.
5726        let mut lines: Vec<String> = (0..20).map(|i| format!("line {i}: filler")).collect();
5727        lines[5] = "line 5: targeted item".into();
5728        lines[10] = "line 10: target item".into();
5729        let big_file = lines.join("\n");
5730
5731        let fs = FakeFs::new(cx.background_executor.clone());
5732        fs.insert_tree(
5733            path!("/dir"),
5734            json!({
5735                "one.rs": "const ONE: usize = 1;\nconst ONEROUS: usize = 2;",
5736                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
5737                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
5738                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
5739                "only_one.rs": "const ONLY_ONE: usize = 1;",
5740                "big.txt": big_file,
5741            }),
5742        )
5743        .await;
5744        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5745        let window =
5746            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5747        let workspace = window
5748            .read_with(cx, |mw, _| mw.workspace().clone())
5749            .unwrap();
5750        let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
5751        let search_view = cx.add_window(|window, cx| {
5752            ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
5753        });
5754        let (events, _subscription) = subscribe_to_excerpt_events(&search, cx);
5755        let all_rs_files = vec!["four.rs", "one.rs", "only_one.rs", "three.rs", "two.rs"];
5756        let expected_one_matches = vec![
5757            "one", "ONE", "ONE", "ONE", "ONE", "one", "ONE", "one", "ONE", "one", "ONE",
5758        ];
5759
5760        // Initial non-incremental search for "ONE" — inserts one excerpt per file.
5761        perform_search(search_view, "ONE", cx);
5762        assert_eq!(read_match_texts(search_view, cx), expected_one_matches);
5763        assert_all_highlights_match_query(search_view, "ONE", cx);
5764        assert_eq!(take_updated_files(&events), all_rs_files);
5765
5766        // Natural narrowing: "ONE" -> "ONER". Only one.rs has ONEROUS.
5767        perform_incremental_search(search_view, "ONER", cx);
5768        assert_eq!(read_match_texts(search_view, cx), vec!["ONER"]);
5769        assert_all_highlights_match_query(search_view, "ONER", cx);
5770        assert_eq!(take_updated_files(&events), vec!["one.rs"]);
5771
5772        // Continue narrowing: "ONER" -> "ONEROUS". Still one.rs only.
5773        perform_incremental_search(search_view, "ONEROUS", cx);
5774        assert_eq!(read_match_texts(search_view, cx), vec!["ONEROUS"]);
5775        assert_all_highlights_match_query(search_view, "ONEROUS", cx);
5776        assert_eq!(take_updated_files(&events), vec!["one.rs"]);
5777
5778        // Backspace to "ONER" — still one.rs only.
5779        perform_incremental_search(search_view, "ONER", cx);
5780        assert_eq!(read_match_texts(search_view, cx), vec!["ONER"]);
5781        assert_all_highlights_match_query(search_view, "ONER", cx);
5782        assert_eq!(take_updated_files(&events), vec!["one.rs"]);
5783
5784        // Backspace to "ONE" — all 5 files re-appear.
5785        perform_incremental_search(search_view, "ONE", cx);
5786        assert_eq!(read_match_texts(search_view, cx), expected_one_matches);
5787        assert_all_highlights_match_query(search_view, "ONE", cx);
5788        assert_eq!(take_updated_files(&events), all_rs_files);
5789
5790        // Repeat the same "ONE" query — excerpts still get updated.
5791        perform_incremental_search(search_view, "ONE", cx);
5792        assert_eq!(read_match_texts(search_view, cx), expected_one_matches);
5793        assert_all_highlights_match_query(search_view, "ONE", cx);
5794        assert_eq!(take_updated_files(&events), all_rs_files);
5795
5796        // Narrow to "ONLY_ONE" — single match in only_one.rs.
5797        perform_incremental_search(search_view, "ONLY_ONE", cx);
5798        assert_eq!(read_match_texts(search_view, cx), vec!["ONLY_ONE"]);
5799        assert_all_highlights_match_query(search_view, "ONLY_ONE", cx);
5800        assert_eq!(take_updated_files(&events), vec!["only_one.rs"]);
5801
5802        // Widen back to "ONE" — all 5 files re-appear.
5803        perform_incremental_search(search_view, "ONE", cx);
5804        assert_eq!(read_match_texts(search_view, cx), expected_one_matches);
5805        assert_all_highlights_match_query(search_view, "ONE", cx);
5806        assert_eq!(take_updated_files(&events), all_rs_files);
5807
5808        // Narrowing when all files still match: "usize" -> "usize =".
5809        // Same file set, same match count.
5810        perform_search(search_view, "usize", cx);
5811        assert_eq!(read_match_count(search_view, cx), 6);
5812        events.borrow_mut().clear();
5813        perform_incremental_search(search_view, "usize =", cx);
5814        assert_eq!(read_match_count(search_view, cx), 6);
5815        assert_eq!(take_updated_files(&events), all_rs_files);
5816
5817        // Merged-excerpt narrowing: "target" matches lines 5 and 10 in big.txt.
5818        // Narrowing to "targeted" drops to 1 match but big.txt stays.
5819        perform_search(search_view, "target", cx);
5820        assert_all_highlights_match_query(search_view, "target", cx);
5821        assert_eq!(read_match_count(search_view, cx), 2);
5822        events.borrow_mut().clear();
5823
5824        perform_incremental_search(search_view, "targeted", cx);
5825        assert_all_highlights_match_query(search_view, "targeted", cx);
5826        assert_eq!(read_match_count(search_view, cx), 1);
5827        assert_eq!(take_updated_files(&events), vec!["big.txt"]);
5828    }
5829
5830    #[gpui::test]
5831    async fn test_incremental_search_clears_stale_excerpts_on_query_change(
5832        cx: &mut TestAppContext,
5833    ) {
5834        init_test(cx);
5835
5836        let fs = FakeFs::new(cx.background_executor.clone());
5837        fs.insert_tree(
5838            path!("/dir"),
5839            json!({
5840                "alpha.rs": "fn alpha() {}\nfn alpha_bravo() {}",
5841                "bravo.rs": "fn bravo() { alpha() }",
5842                "charlie.rs": "fn charlie() { alpha(); bravo() }",
5843                "delta.rs": "fn delta_unique_word() {}",
5844            }),
5845        )
5846        .await;
5847        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5848        let window =
5849            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5850        let workspace = window
5851            .read_with(cx, |mw, _| mw.workspace().clone())
5852            .unwrap();
5853        let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
5854        let search_view = cx.add_window(|window, cx| {
5855            ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
5856        });
5857        let (events, _subscription) = subscribe_to_excerpt_events(&search, cx);
5858
5859        // Search for "alpha" — matches in alpha.rs, bravo.rs, charlie.rs.
5860        perform_search(search_view, "alpha", cx);
5861        let alpha_files = take_updated_files(&events);
5862        assert_eq!(alpha_files, vec!["alpha.rs", "bravo.rs", "charlie.rs"]);
5863        assert!(read_match_count(search_view, cx) > 0);
5864
5865        // Incremental search for "delta_unique_word" — only delta.rs should match.
5866        // Before the fix, stale excerpts from "alpha" search would linger.
5867        perform_incremental_search(search_view, "delta_unique_word", cx);
5868        let delta_files = take_updated_files(&events);
5869        assert_eq!(delta_files, vec!["delta.rs"]);
5870        assert_eq!(read_match_count(search_view, cx), 1);
5871        assert_eq!(read_match_texts(search_view, cx), vec!["delta_unique_word"]);
5872    }
5873
5874    #[gpui::test]
5875    async fn test_search_on_input_keeps_focus_confirm_shifts_it(cx: &mut TestAppContext) {
5876        init_test(cx);
5877        cx.update(|cx| {
5878            SettingsStore::update_global(cx, |store, cx| {
5879                store.update_user_settings(cx, |settings| {
5880                    settings
5881                        .editor
5882                        .search
5883                        .get_or_insert_default()
5884                        .search_on_input = Some(true);
5885                });
5886            });
5887        });
5888
5889        let fs = FakeFs::new(cx.background_executor.clone());
5890        fs.insert_tree(
5891            path!("/dir"),
5892            json!({
5893                "one.rs": "const ONE: usize = 1;",
5894                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
5895                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
5896            }),
5897        )
5898        .await;
5899        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5900        let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
5901        let workspace = window
5902            .read_with(cx, |mw, _| mw.workspace().clone())
5903            .unwrap();
5904        let cx = &mut VisualTestContext::from_window(window.into(), cx);
5905        let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new());
5906
5907        workspace.update_in(cx, |workspace, window, cx| {
5908            workspace.panes()[0].update(cx, |pane, cx| {
5909                pane.toolbar()
5910                    .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx))
5911            });
5912            ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx);
5913        });
5914
5915        let search_view = cx
5916            .read(|cx| {
5917                workspace
5918                    .read(cx)
5919                    .active_pane()
5920                    .read(cx)
5921                    .active_item()
5922                    .and_then(|item| item.downcast::<ProjectSearchView>())
5923            })
5924            .expect("Search view expected to appear after new search event trigger");
5925
5926        // Type into the query editor — with search_on_input the EditorEvent::Edited
5927        // subscriber triggers an incremental search. The query editor must stay focused.
5928        window
5929            .update(cx, |_, window, cx| {
5930                search_view.update(cx, |search_view, cx| {
5931                    search_view.query_editor.update(cx, |query_editor, cx| {
5932                        query_editor.set_text("ONE", window, cx);
5933                    });
5934                });
5935            })
5936            .unwrap();
5937        cx.background_executor.run_until_parked();
5938
5939        window
5940            .update(cx, |_, window, cx| {
5941                search_view.update(cx, |search_view, cx| {
5942                    assert!(
5943                        !search_view.entity.read(cx).match_ranges.is_empty(),
5944                        "Incremental search should have found matches",
5945                    );
5946                    assert!(
5947                        search_view.query_editor.focus_handle(cx).is_focused(window),
5948                        "Query editor should remain focused after incremental search with search_on_input",
5949                    );
5950                    assert!(
5951                        !search_view.results_editor.focus_handle(cx).is_focused(window),
5952                        "Results editor should not be focused after incremental search with search_on_input",
5953                    );
5954                });
5955            })
5956            .unwrap();
5957
5958        // Move the results editor cursor to the end of the buffer so it's
5959        // far away from the first match, then re-focus the query editor.
5960        window
5961            .update(cx, |_, window, cx| {
5962                search_view.update(cx, |search_view, cx| {
5963                    search_view.results_editor.update(cx, |editor, cx| {
5964                        editor.move_to_end(&editor::actions::MoveToEnd, window, cx);
5965                    });
5966                    let ranges = search_view.results_editor.update(cx, |editor, cx| {
5967                        editor
5968                            .selections
5969                            .display_ranges(&editor.display_snapshot(cx))
5970                    });
5971                    assert_eq!(ranges.len(), 1);
5972                    assert_ne!(
5973                        ranges[0].start,
5974                        DisplayPoint::new(DisplayRow(0), 0),
5975                        "Cursor should be past the start of the buffer after MoveToEnd",
5976                    );
5977                    search_view.query_editor.focus_handle(cx).focus(window, cx);
5978                });
5979            })
5980            .unwrap();
5981
5982        // Press Enter (Confirm) via the search bar — this should shift focus
5983        // to the results editor with the first match selected, regardless of
5984        // where the cursor was before.
5985        cx.dispatch_action(Confirm);
5986        cx.background_executor.run_until_parked();
5987
5988        window
5989            .update(cx, |_, window, cx| {
5990                search_view.update(cx, |search_view, cx| {
5991                    assert!(
5992                        !search_view.entity.read(cx).match_ranges.is_empty(),
5993                        "Confirm search should have found matches",
5994                    );
5995                    assert!(
5996                        search_view.results_editor.focus_handle(cx).is_focused(window),
5997                        "Results editor should be focused after confirming search with search_on_input",
5998                    );
5999                    assert!(
6000                        !search_view.query_editor.focus_handle(cx).is_focused(window),
6001                        "Query editor should not be focused after confirming search with search_on_input",
6002                    );
6003                    assert_eq!(
6004                        search_view.active_match_index,
6005                        Some(0),
6006                        "First match should be selected after confirm",
6007                    );
6008                    let results_editor = search_view.results_editor.read(cx);
6009                    let selection = results_editor.selections.newest_anchor();
6010                    let snapshot = results_editor.buffer().read(cx).snapshot(cx);
6011                    let selected_text = snapshot
6012                        .text_for_range(selection.start..selection.end)
6013                        .collect::<String>();
6014                    assert_eq!(
6015                        selected_text, "ONE",
6016                        "First match text should be selected after confirm",
6017                    );
6018                });
6019            })
6020            .unwrap();
6021    }
6022}