project_search.rs

   1use crate::{
   2    SearchOptions, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex,
   3    ToggleWholeWord,
   4};
   5use anyhow::Result;
   6use collections::HashMap;
   7use editor::{
   8    items::active_match_index, scroll::autoscroll::Autoscroll, Anchor, Editor, MultiBuffer,
   9    SelectAll, MAX_TAB_TITLE_LEN,
  10};
  11use futures::StreamExt;
  12use globset::{Glob, GlobMatcher};
  13use gpui::{
  14    actions,
  15    elements::*,
  16    platform::{CursorStyle, MouseButton},
  17    Action, AnyElement, AnyViewHandle, AppContext, Entity, ModelContext, ModelHandle, Subscription,
  18    Task, View, ViewContext, ViewHandle, WeakModelHandle, WeakViewHandle,
  19};
  20use menu::Confirm;
  21use postage::stream::Stream;
  22use project::{search::SearchQuery, Entry, Project};
  23use semantic_index::SemanticIndex;
  24use smallvec::SmallVec;
  25use std::{
  26    any::{Any, TypeId},
  27    borrow::Cow,
  28    collections::HashSet,
  29    mem,
  30    ops::{Not, Range},
  31    path::PathBuf,
  32    sync::Arc,
  33};
  34use util::ResultExt as _;
  35use workspace::{
  36    item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
  37    searchable::{Direction, SearchableItem, SearchableItemHandle},
  38    ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId,
  39};
  40
  41actions!(
  42    project_search,
  43    [
  44        SearchInNew,
  45        ToggleFocus,
  46        NextField,
  47        ToggleSemanticSearch,
  48        CycleMode
  49    ]
  50);
  51
  52#[derive(Default)]
  53struct ActiveSearches(HashMap<WeakModelHandle<Project>, WeakViewHandle<ProjectSearchView>>);
  54
  55pub fn init(cx: &mut AppContext) {
  56    cx.set_global(ActiveSearches::default());
  57    cx.add_action(ProjectSearchView::deploy);
  58    cx.add_action(ProjectSearchView::move_focus_to_results);
  59    cx.add_action(ProjectSearchBar::search);
  60    cx.add_action(ProjectSearchBar::search_in_new);
  61    cx.add_action(ProjectSearchBar::select_next_match);
  62    cx.add_action(ProjectSearchBar::select_prev_match);
  63    cx.add_action(ProjectSearchBar::cycle_mode);
  64    cx.capture_action(ProjectSearchBar::tab);
  65    cx.capture_action(ProjectSearchBar::tab_previous);
  66    add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
  67    add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
  68    add_toggle_option_action::<ToggleRegex>(SearchOptions::REGEX, cx);
  69}
  70
  71fn add_toggle_option_action<A: Action>(option: SearchOptions, cx: &mut AppContext) {
  72    cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext<Pane>| {
  73        if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<ProjectSearchBar>() {
  74            if search_bar.update(cx, |search_bar, cx| {
  75                search_bar.toggle_search_option(option, cx)
  76            }) {
  77                return;
  78            }
  79        }
  80        cx.propagate_action();
  81    });
  82}
  83
  84struct ProjectSearch {
  85    project: ModelHandle<Project>,
  86    excerpts: ModelHandle<MultiBuffer>,
  87    pending_search: Option<Task<Option<()>>>,
  88    match_ranges: Vec<Range<Anchor>>,
  89    active_query: Option<SearchQuery>,
  90    search_id: usize,
  91}
  92
  93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
  94enum InputPanel {
  95    Query,
  96    Exclude,
  97    Include,
  98}
  99
 100pub struct ProjectSearchView {
 101    model: ModelHandle<ProjectSearch>,
 102    query_editor: ViewHandle<Editor>,
 103    results_editor: ViewHandle<Editor>,
 104    semantic: Option<SemanticSearchState>,
 105    search_options: SearchOptions,
 106    panels_with_errors: HashSet<InputPanel>,
 107    active_match_index: Option<usize>,
 108    search_id: usize,
 109    query_editor_was_focused: bool,
 110    included_files_editor: ViewHandle<Editor>,
 111    excluded_files_editor: ViewHandle<Editor>,
 112    filters_enabled: bool,
 113    current_mode: SearchMode,
 114}
 115
 116struct SemanticSearchState {
 117    file_count: usize,
 118    outstanding_file_count: usize,
 119    _progress_task: Task<()>,
 120}
 121
 122// TODO: Update the default search mode to get from config
 123#[derive(Clone, Default, PartialEq)]
 124enum SearchMode {
 125    #[default]
 126    Text,
 127    Semantic,
 128    Regex,
 129}
 130
 131pub struct ProjectSearchBar {
 132    active_project_search: Option<ViewHandle<ProjectSearchView>>,
 133    subscription: Option<Subscription>,
 134}
 135
 136impl Entity for ProjectSearch {
 137    type Event = ();
 138}
 139
 140impl ProjectSearch {
 141    fn new(project: ModelHandle<Project>, cx: &mut ModelContext<Self>) -> Self {
 142        let replica_id = project.read(cx).replica_id();
 143        Self {
 144            project,
 145            excerpts: cx.add_model(|_| MultiBuffer::new(replica_id)),
 146            pending_search: Default::default(),
 147            match_ranges: Default::default(),
 148            active_query: None,
 149            search_id: 0,
 150        }
 151    }
 152
 153    fn clone(&self, cx: &mut ModelContext<Self>) -> ModelHandle<Self> {
 154        cx.add_model(|cx| Self {
 155            project: self.project.clone(),
 156            excerpts: self
 157                .excerpts
 158                .update(cx, |excerpts, cx| cx.add_model(|cx| excerpts.clone(cx))),
 159            pending_search: Default::default(),
 160            match_ranges: self.match_ranges.clone(),
 161            active_query: self.active_query.clone(),
 162            search_id: self.search_id,
 163        })
 164    }
 165
 166    fn search(&mut self, query: SearchQuery, cx: &mut ModelContext<Self>) {
 167        let search = self
 168            .project
 169            .update(cx, |project, cx| project.search(query.clone(), cx));
 170        self.search_id += 1;
 171        self.active_query = Some(query);
 172        self.match_ranges.clear();
 173        self.pending_search = Some(cx.spawn_weak(|this, mut cx| async move {
 174            let matches = search.await.log_err()?;
 175            let this = this.upgrade(&cx)?;
 176            let mut matches = matches.into_iter().collect::<Vec<_>>();
 177            let (_task, mut match_ranges) = this.update(&mut cx, |this, cx| {
 178                this.match_ranges.clear();
 179                matches.sort_by_key(|(buffer, _)| buffer.read(cx).file().map(|file| file.path()));
 180                this.excerpts.update(cx, |excerpts, cx| {
 181                    excerpts.clear(cx);
 182                    excerpts.stream_excerpts_with_context_lines(matches, 1, cx)
 183                })
 184            });
 185
 186            while let Some(match_range) = match_ranges.next().await {
 187                this.update(&mut cx, |this, cx| {
 188                    this.match_ranges.push(match_range);
 189                    while let Ok(Some(match_range)) = match_ranges.try_next() {
 190                        this.match_ranges.push(match_range);
 191                    }
 192                    cx.notify();
 193                });
 194            }
 195
 196            this.update(&mut cx, |this, cx| {
 197                this.pending_search.take();
 198                cx.notify();
 199            });
 200
 201            None
 202        }));
 203        cx.notify();
 204    }
 205
 206    fn semantic_search(
 207        &mut self,
 208        query: String,
 209        include_files: Vec<GlobMatcher>,
 210        exclude_files: Vec<GlobMatcher>,
 211        cx: &mut ModelContext<Self>,
 212    ) {
 213        let search = SemanticIndex::global(cx).map(|index| {
 214            index.update(cx, |semantic_index, cx| {
 215                semantic_index.search_project(
 216                    self.project.clone(),
 217                    query.clone(),
 218                    10,
 219                    include_files,
 220                    exclude_files,
 221                    cx,
 222                )
 223            })
 224        });
 225        self.search_id += 1;
 226        self.match_ranges.clear();
 227        self.pending_search = Some(cx.spawn(|this, mut cx| async move {
 228            let results = search?.await.log_err()?;
 229
 230            let (_task, mut match_ranges) = this.update(&mut cx, |this, cx| {
 231                this.excerpts.update(cx, |excerpts, cx| {
 232                    excerpts.clear(cx);
 233
 234                    let matches = results
 235                        .into_iter()
 236                        .map(|result| (result.buffer, vec![result.range.start..result.range.start]))
 237                        .collect();
 238
 239                    excerpts.stream_excerpts_with_context_lines(matches, 3, cx)
 240                })
 241            });
 242
 243            while let Some(match_range) = match_ranges.next().await {
 244                this.update(&mut cx, |this, cx| {
 245                    this.match_ranges.push(match_range);
 246                    while let Ok(Some(match_range)) = match_ranges.try_next() {
 247                        this.match_ranges.push(match_range);
 248                    }
 249                    cx.notify();
 250                });
 251            }
 252
 253            this.update(&mut cx, |this, cx| {
 254                this.pending_search.take();
 255                cx.notify();
 256            });
 257
 258            None
 259        }));
 260        cx.notify();
 261    }
 262}
 263
 264pub enum ViewEvent {
 265    UpdateTab,
 266    Activate,
 267    EditorEvent(editor::Event),
 268}
 269
 270impl Entity for ProjectSearchView {
 271    type Event = ViewEvent;
 272}
 273
 274impl View for ProjectSearchView {
 275    fn ui_name() -> &'static str {
 276        "ProjectSearchView"
 277    }
 278
 279    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
 280        let model = &self.model.read(cx);
 281        if model.match_ranges.is_empty() {
 282            enum Status {}
 283
 284            let theme = theme::current(cx).clone();
 285            let text = if model.pending_search.is_some() {
 286                Cow::Borrowed("Searching...")
 287            } else if let Some(semantic) = &self.semantic {
 288                if semantic.outstanding_file_count > 0 {
 289                    Cow::Owned(format!(
 290                        "Indexing. {} of {}...",
 291                        semantic.file_count - semantic.outstanding_file_count,
 292                        semantic.file_count
 293                    ))
 294                } else {
 295                    Cow::Borrowed("Indexing complete")
 296                }
 297            } else if self.query_editor.read(cx).text(cx).is_empty() {
 298                Cow::Borrowed("Text search all files and folders")
 299            } else {
 300                Cow::Borrowed("No results")
 301            };
 302
 303            MouseEventHandler::<Status, _>::new(0, cx, |_, _| {
 304                Flex::column()
 305                    .with_child(Flex::column().contained().flex(1., true))
 306                    .with_child(
 307                        Label::new(text, theme.search.results_status.clone())
 308                            .aligned()
 309                            .top()
 310                            .contained()
 311                            .flex(7., true),
 312                    )
 313                    .contained()
 314                    .with_background_color(theme.editor.background)
 315            })
 316            .on_down(MouseButton::Left, |_, _, cx| {
 317                cx.focus_parent();
 318            })
 319            .into_any_named("project search view")
 320        } else {
 321            ChildView::new(&self.results_editor, cx)
 322                .flex(1., true)
 323                .into_any_named("project search view")
 324        }
 325    }
 326
 327    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
 328        let handle = cx.weak_handle();
 329        cx.update_global(|state: &mut ActiveSearches, cx| {
 330            state
 331                .0
 332                .insert(self.model.read(cx).project.downgrade(), handle)
 333        });
 334
 335        if cx.is_self_focused() {
 336            if self.query_editor_was_focused {
 337                cx.focus(&self.query_editor);
 338            } else {
 339                cx.focus(&self.results_editor);
 340            }
 341        }
 342    }
 343}
 344
 345impl Item for ProjectSearchView {
 346    fn tab_tooltip_text(&self, cx: &AppContext) -> Option<Cow<str>> {
 347        let query_text = self.query_editor.read(cx).text(cx);
 348
 349        query_text
 350            .is_empty()
 351            .not()
 352            .then(|| query_text.into())
 353            .or_else(|| Some("Project Search".into()))
 354    }
 355
 356    fn act_as_type<'a>(
 357        &'a self,
 358        type_id: TypeId,
 359        self_handle: &'a ViewHandle<Self>,
 360        _: &'a AppContext,
 361    ) -> Option<&'a AnyViewHandle> {
 362        if type_id == TypeId::of::<Self>() {
 363            Some(self_handle)
 364        } else if type_id == TypeId::of::<Editor>() {
 365            Some(&self.results_editor)
 366        } else {
 367            None
 368        }
 369    }
 370
 371    fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
 372        self.results_editor
 373            .update(cx, |editor, cx| editor.deactivated(cx));
 374    }
 375
 376    fn tab_content<T: View>(
 377        &self,
 378        _detail: Option<usize>,
 379        tab_theme: &theme::Tab,
 380        cx: &AppContext,
 381    ) -> AnyElement<T> {
 382        Flex::row()
 383            .with_child(
 384                Svg::new("icons/magnifying_glass_12.svg")
 385                    .with_color(tab_theme.label.text.color)
 386                    .constrained()
 387                    .with_width(tab_theme.type_icon_width)
 388                    .aligned()
 389                    .contained()
 390                    .with_margin_right(tab_theme.spacing),
 391            )
 392            .with_child({
 393                let tab_name: Option<Cow<_>> =
 394                    self.model.read(cx).active_query.as_ref().map(|query| {
 395                        let query_text =
 396                            util::truncate_and_trailoff(query.as_str(), MAX_TAB_TITLE_LEN);
 397                        query_text.into()
 398                    });
 399                Label::new(
 400                    tab_name
 401                        .filter(|name| !name.is_empty())
 402                        .unwrap_or("Project search".into()),
 403                    tab_theme.label.clone(),
 404                )
 405                .aligned()
 406            })
 407            .into_any()
 408    }
 409
 410    fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
 411        self.results_editor.for_each_project_item(cx, f)
 412    }
 413
 414    fn is_singleton(&self, _: &AppContext) -> bool {
 415        false
 416    }
 417
 418    fn can_save(&self, _: &AppContext) -> bool {
 419        true
 420    }
 421
 422    fn is_dirty(&self, cx: &AppContext) -> bool {
 423        self.results_editor.read(cx).is_dirty(cx)
 424    }
 425
 426    fn has_conflict(&self, cx: &AppContext) -> bool {
 427        self.results_editor.read(cx).has_conflict(cx)
 428    }
 429
 430    fn save(
 431        &mut self,
 432        project: ModelHandle<Project>,
 433        cx: &mut ViewContext<Self>,
 434    ) -> Task<anyhow::Result<()>> {
 435        self.results_editor
 436            .update(cx, |editor, cx| editor.save(project, cx))
 437    }
 438
 439    fn save_as(
 440        &mut self,
 441        _: ModelHandle<Project>,
 442        _: PathBuf,
 443        _: &mut ViewContext<Self>,
 444    ) -> Task<anyhow::Result<()>> {
 445        unreachable!("save_as should not have been called")
 446    }
 447
 448    fn reload(
 449        &mut self,
 450        project: ModelHandle<Project>,
 451        cx: &mut ViewContext<Self>,
 452    ) -> Task<anyhow::Result<()>> {
 453        self.results_editor
 454            .update(cx, |editor, cx| editor.reload(project, cx))
 455    }
 456
 457    fn clone_on_split(&self, _workspace_id: WorkspaceId, cx: &mut ViewContext<Self>) -> Option<Self>
 458    where
 459        Self: Sized,
 460    {
 461        let model = self.model.update(cx, |model, cx| model.clone(cx));
 462        Some(Self::new(model, cx))
 463    }
 464
 465    fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
 466        self.results_editor
 467            .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx));
 468    }
 469
 470    fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
 471        self.results_editor.update(cx, |editor, _| {
 472            editor.set_nav_history(Some(nav_history));
 473        });
 474    }
 475
 476    fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
 477        self.results_editor
 478            .update(cx, |editor, cx| editor.navigate(data, cx))
 479    }
 480
 481    fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> {
 482        match event {
 483            ViewEvent::UpdateTab => {
 484                smallvec::smallvec![ItemEvent::UpdateBreadcrumbs, ItemEvent::UpdateTab]
 485            }
 486            ViewEvent::EditorEvent(editor_event) => Editor::to_item_events(editor_event),
 487            _ => SmallVec::new(),
 488        }
 489    }
 490
 491    fn breadcrumb_location(&self) -> ToolbarItemLocation {
 492        if self.has_matches() {
 493            ToolbarItemLocation::Secondary
 494        } else {
 495            ToolbarItemLocation::Hidden
 496        }
 497    }
 498
 499    fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
 500        self.results_editor.breadcrumbs(theme, cx)
 501    }
 502
 503    fn serialized_item_kind() -> Option<&'static str> {
 504        None
 505    }
 506
 507    fn deserialize(
 508        _project: ModelHandle<Project>,
 509        _workspace: WeakViewHandle<Workspace>,
 510        _workspace_id: workspace::WorkspaceId,
 511        _item_id: workspace::ItemId,
 512        _cx: &mut ViewContext<Pane>,
 513    ) -> Task<anyhow::Result<ViewHandle<Self>>> {
 514        unimplemented!()
 515    }
 516}
 517
 518impl ProjectSearchView {
 519    fn new(model: ModelHandle<ProjectSearch>, cx: &mut ViewContext<Self>) -> Self {
 520        let project;
 521        let excerpts;
 522        let mut query_text = String::new();
 523        let mut options = SearchOptions::NONE;
 524
 525        {
 526            let model = model.read(cx);
 527            project = model.project.clone();
 528            excerpts = model.excerpts.clone();
 529            if let Some(active_query) = model.active_query.as_ref() {
 530                query_text = active_query.as_str().to_string();
 531                options = SearchOptions::from_query(active_query);
 532            }
 533        }
 534        cx.observe(&model, |this, _, cx| this.model_changed(cx))
 535            .detach();
 536
 537        let query_editor = cx.add_view(|cx| {
 538            let mut editor = Editor::single_line(
 539                Some(Arc::new(|theme| theme.search.editor.input.clone())),
 540                cx,
 541            );
 542            editor.set_placeholder_text("Text search all files", cx);
 543            editor.set_text(query_text, cx);
 544            editor
 545        });
 546        // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
 547        cx.subscribe(&query_editor, |_, _, event, cx| {
 548            cx.emit(ViewEvent::EditorEvent(event.clone()))
 549        })
 550        .detach();
 551
 552        let results_editor = cx.add_view(|cx| {
 553            let mut editor = Editor::for_multibuffer(excerpts, Some(project), cx);
 554            editor.set_searchable(false);
 555            editor
 556        });
 557        cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab))
 558            .detach();
 559
 560        cx.subscribe(&results_editor, |this, _, event, cx| {
 561            if matches!(event, editor::Event::SelectionsChanged { .. }) {
 562                this.update_match_index(cx);
 563            }
 564            // Reraise editor events for workspace item activation purposes
 565            cx.emit(ViewEvent::EditorEvent(event.clone()));
 566        })
 567        .detach();
 568
 569        let included_files_editor = cx.add_view(|cx| {
 570            let mut editor = Editor::single_line(
 571                Some(Arc::new(|theme| {
 572                    theme.search.include_exclude_editor.input.clone()
 573                })),
 574                cx,
 575            );
 576            editor.set_placeholder_text("Include: crates/**/*.toml", cx);
 577
 578            editor
 579        });
 580        // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes
 581        cx.subscribe(&included_files_editor, |_, _, event, cx| {
 582            cx.emit(ViewEvent::EditorEvent(event.clone()))
 583        })
 584        .detach();
 585
 586        let excluded_files_editor = cx.add_view(|cx| {
 587            let mut editor = Editor::single_line(
 588                Some(Arc::new(|theme| {
 589                    theme.search.include_exclude_editor.input.clone()
 590                })),
 591                cx,
 592            );
 593            editor.set_placeholder_text("Exclude: vendor/*, *.lock", cx);
 594
 595            editor
 596        });
 597        // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes
 598        cx.subscribe(&excluded_files_editor, |_, _, event, cx| {
 599            cx.emit(ViewEvent::EditorEvent(event.clone()))
 600        })
 601        .detach();
 602        let filters_enabled = false;
 603        let mut this = ProjectSearchView {
 604            search_id: model.read(cx).search_id,
 605            model,
 606            query_editor,
 607            results_editor,
 608            semantic: None,
 609            search_options: options,
 610            panels_with_errors: HashSet::new(),
 611            active_match_index: None,
 612            query_editor_was_focused: false,
 613            included_files_editor,
 614            excluded_files_editor,
 615            filters_enabled,
 616            current_mode: Default::default(),
 617        };
 618        this.model_changed(cx);
 619        this
 620    }
 621
 622    pub fn new_search_in_directory(
 623        workspace: &mut Workspace,
 624        dir_entry: &Entry,
 625        cx: &mut ViewContext<Workspace>,
 626    ) {
 627        if !dir_entry.is_dir() {
 628            return;
 629        }
 630        let filter_path = dir_entry.path.join("**");
 631        let Some(filter_str) = filter_path.to_str() else { return; };
 632
 633        let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
 634        let search = cx.add_view(|cx| ProjectSearchView::new(model, cx));
 635        workspace.add_item(Box::new(search.clone()), cx);
 636        search.update(cx, |search, cx| {
 637            search
 638                .included_files_editor
 639                .update(cx, |editor, cx| editor.set_text(filter_str, cx));
 640            search.focus_query_editor(cx)
 641        });
 642    }
 643
 644    // Re-activate the most recently activated search or the most recent if it has been closed.
 645    // If no search exists in the workspace, create a new one.
 646    fn deploy(
 647        workspace: &mut Workspace,
 648        _: &workspace::NewSearch,
 649        cx: &mut ViewContext<Workspace>,
 650    ) {
 651        // Clean up entries for dropped projects
 652        cx.update_global(|state: &mut ActiveSearches, cx| {
 653            state.0.retain(|project, _| project.is_upgradable(cx))
 654        });
 655
 656        let active_search = cx
 657            .global::<ActiveSearches>()
 658            .0
 659            .get(&workspace.project().downgrade());
 660
 661        let existing = active_search
 662            .and_then(|active_search| {
 663                workspace
 664                    .items_of_type::<ProjectSearchView>(cx)
 665                    .find(|search| search == active_search)
 666            })
 667            .or_else(|| workspace.item_of_type::<ProjectSearchView>(cx));
 668
 669        let query = workspace.active_item(cx).and_then(|item| {
 670            let editor = item.act_as::<Editor>(cx)?;
 671            let query = editor.query_suggestion(cx);
 672            if query.is_empty() {
 673                None
 674            } else {
 675                Some(query)
 676            }
 677        });
 678
 679        let search = if let Some(existing) = existing {
 680            workspace.activate_item(&existing, cx);
 681            existing
 682        } else {
 683            let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
 684            let view = cx.add_view(|cx| ProjectSearchView::new(model, cx));
 685            workspace.add_item(Box::new(view.clone()), cx);
 686            view
 687        };
 688
 689        search.update(cx, |search, cx| {
 690            if let Some(query) = query {
 691                search.set_query(&query, cx);
 692            }
 693            search.focus_query_editor(cx)
 694        });
 695    }
 696
 697    fn search(&mut self, cx: &mut ViewContext<Self>) {
 698        if let Some(semantic) = &mut self.semantic {
 699            if semantic.outstanding_file_count > 0 {
 700                return;
 701            }
 702
 703            let query = self.query_editor.read(cx).text(cx);
 704            if let Some((included_files, exclude_files)) =
 705                self.get_included_and_excluded_globsets(cx)
 706            {
 707                self.model.update(cx, |model, cx| {
 708                    model.semantic_search(query, included_files, exclude_files, cx)
 709                });
 710            }
 711            return;
 712        }
 713
 714        if let Some(query) = self.build_search_query(cx) {
 715            self.model.update(cx, |model, cx| model.search(query, cx));
 716        }
 717    }
 718
 719    fn get_included_and_excluded_globsets(
 720        &mut self,
 721        cx: &mut ViewContext<Self>,
 722    ) -> Option<(Vec<GlobMatcher>, Vec<GlobMatcher>)> {
 723        let included_files =
 724            match Self::load_glob_set(&self.included_files_editor.read(cx).text(cx)) {
 725                Ok(included_files) => {
 726                    self.panels_with_errors.remove(&InputPanel::Include);
 727                    included_files
 728                }
 729                Err(_e) => {
 730                    self.panels_with_errors.insert(InputPanel::Include);
 731                    cx.notify();
 732                    return None;
 733                }
 734            };
 735        let excluded_files =
 736            match Self::load_glob_set(&self.excluded_files_editor.read(cx).text(cx)) {
 737                Ok(excluded_files) => {
 738                    self.panels_with_errors.remove(&InputPanel::Exclude);
 739                    excluded_files
 740                }
 741                Err(_e) => {
 742                    self.panels_with_errors.insert(InputPanel::Exclude);
 743                    cx.notify();
 744                    return None;
 745                }
 746            };
 747
 748        Some((included_files, excluded_files))
 749    }
 750
 751    fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
 752        let text = self.query_editor.read(cx).text(cx);
 753        let included_files =
 754            match Self::load_glob_set(&self.included_files_editor.read(cx).text(cx)) {
 755                Ok(included_files) => {
 756                    self.panels_with_errors.remove(&InputPanel::Include);
 757                    included_files
 758                }
 759                Err(_e) => {
 760                    self.panels_with_errors.insert(InputPanel::Include);
 761                    cx.notify();
 762                    return None;
 763                }
 764            };
 765        let excluded_files =
 766            match Self::load_glob_set(&self.excluded_files_editor.read(cx).text(cx)) {
 767                Ok(excluded_files) => {
 768                    self.panels_with_errors.remove(&InputPanel::Exclude);
 769                    excluded_files
 770                }
 771                Err(_e) => {
 772                    self.panels_with_errors.insert(InputPanel::Exclude);
 773                    cx.notify();
 774                    return None;
 775                }
 776            };
 777        if self.search_options.contains(SearchOptions::REGEX) {
 778            match SearchQuery::regex(
 779                text,
 780                self.search_options.contains(SearchOptions::WHOLE_WORD),
 781                self.search_options.contains(SearchOptions::CASE_SENSITIVE),
 782                included_files,
 783                excluded_files,
 784            ) {
 785                Ok(query) => {
 786                    self.panels_with_errors.remove(&InputPanel::Query);
 787                    Some(query)
 788                }
 789                Err(_e) => {
 790                    self.panels_with_errors.insert(InputPanel::Query);
 791                    cx.notify();
 792                    None
 793                }
 794            }
 795        } else {
 796            Some(SearchQuery::text(
 797                text,
 798                self.search_options.contains(SearchOptions::WHOLE_WORD),
 799                self.search_options.contains(SearchOptions::CASE_SENSITIVE),
 800                included_files,
 801                excluded_files,
 802            ))
 803        }
 804    }
 805
 806    fn load_glob_set(text: &str) -> Result<Vec<GlobMatcher>> {
 807        text.split(',')
 808            .map(str::trim)
 809            .filter(|glob_str| !glob_str.is_empty())
 810            .map(|glob_str| anyhow::Ok(Glob::new(glob_str)?.compile_matcher()))
 811            .collect()
 812    }
 813
 814    fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
 815        if let Some(index) = self.active_match_index {
 816            let match_ranges = self.model.read(cx).match_ranges.clone();
 817            let new_index = self.results_editor.update(cx, |editor, cx| {
 818                editor.match_index_for_direction(&match_ranges, index, direction, 1, cx)
 819            });
 820
 821            let range_to_select = match_ranges[new_index].clone();
 822            self.results_editor.update(cx, |editor, cx| {
 823                editor.unfold_ranges([range_to_select.clone()], false, true, cx);
 824                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 825                    s.select_ranges([range_to_select])
 826                });
 827            });
 828        }
 829    }
 830
 831    fn focus_query_editor(&mut self, cx: &mut ViewContext<Self>) {
 832        self.query_editor.update(cx, |query_editor, cx| {
 833            query_editor.select_all(&SelectAll, cx);
 834        });
 835        self.query_editor_was_focused = true;
 836        cx.focus(&self.query_editor);
 837    }
 838
 839    fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
 840        self.query_editor
 841            .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
 842    }
 843
 844    fn focus_results_editor(&mut self, cx: &mut ViewContext<Self>) {
 845        self.query_editor.update(cx, |query_editor, cx| {
 846            let cursor = query_editor.selections.newest_anchor().head();
 847            query_editor.change_selections(None, cx, |s| s.select_ranges([cursor.clone()..cursor]));
 848        });
 849        self.query_editor_was_focused = false;
 850        cx.focus(&self.results_editor);
 851    }
 852
 853    fn model_changed(&mut self, cx: &mut ViewContext<Self>) {
 854        let match_ranges = self.model.read(cx).match_ranges.clone();
 855        if match_ranges.is_empty() {
 856            self.active_match_index = None;
 857        } else {
 858            self.active_match_index = Some(0);
 859            self.update_match_index(cx);
 860            let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id);
 861            let is_new_search = self.search_id != prev_search_id;
 862            self.results_editor.update(cx, |editor, cx| {
 863                if is_new_search {
 864                    editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 865                        s.select_ranges(match_ranges.first().cloned())
 866                    });
 867                }
 868                editor.highlight_background::<Self>(
 869                    match_ranges,
 870                    |theme| theme.search.match_background,
 871                    cx,
 872                );
 873            });
 874            if is_new_search && self.query_editor.is_focused(cx) {
 875                self.focus_results_editor(cx);
 876            }
 877        }
 878
 879        cx.emit(ViewEvent::UpdateTab);
 880        cx.notify();
 881    }
 882
 883    fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
 884        let results_editor = self.results_editor.read(cx);
 885        let new_index = active_match_index(
 886            &self.model.read(cx).match_ranges,
 887            &results_editor.selections.newest_anchor().head(),
 888            &results_editor.buffer().read(cx).snapshot(cx),
 889        );
 890        if self.active_match_index != new_index {
 891            self.active_match_index = new_index;
 892            cx.notify();
 893        }
 894    }
 895
 896    pub fn has_matches(&self) -> bool {
 897        self.active_match_index.is_some()
 898    }
 899
 900    fn move_focus_to_results(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext<Pane>) {
 901        if let Some(search_view) = pane
 902            .active_item()
 903            .and_then(|item| item.downcast::<ProjectSearchView>())
 904        {
 905            search_view.update(cx, |search_view, cx| {
 906                if !search_view.results_editor.is_focused(cx)
 907                    && !search_view.model.read(cx).match_ranges.is_empty()
 908                {
 909                    return search_view.focus_results_editor(cx);
 910                }
 911            });
 912        }
 913
 914        cx.propagate_action();
 915    }
 916}
 917
 918impl Default for ProjectSearchBar {
 919    fn default() -> Self {
 920        Self::new()
 921    }
 922}
 923
 924impl ProjectSearchBar {
 925    pub fn new() -> Self {
 926        Self {
 927            active_project_search: Default::default(),
 928            subscription: Default::default(),
 929        }
 930    }
 931    fn cycle_mode(workspace: &mut Workspace, _: &CycleMode, cx: &mut ViewContext<Workspace>) {
 932        if let Some(search_view) = workspace
 933            .active_item(cx)
 934            .and_then(|item| item.downcast::<ProjectSearchView>())
 935        {
 936            search_view.update(cx, |this, cx| {
 937                let mode = &this.current_mode;
 938                let next_text_state = if SemanticIndex::enabled(cx) {
 939                    SearchMode::Semantic
 940                } else {
 941                    SearchMode::Regex
 942                };
 943
 944                this.current_mode = match mode {
 945                    &SearchMode::Text => next_text_state,
 946                    &SearchMode::Semantic => SearchMode::Regex,
 947                    SearchMode::Regex => SearchMode::Text,
 948                };
 949                cx.notify();
 950            })
 951        }
 952    }
 953    fn search(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
 954        if let Some(search_view) = self.active_project_search.as_ref() {
 955            search_view.update(cx, |search_view, cx| search_view.search(cx));
 956        }
 957    }
 958
 959    fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
 960        if let Some(search_view) = workspace
 961            .active_item(cx)
 962            .and_then(|item| item.downcast::<ProjectSearchView>())
 963        {
 964            let new_query = search_view.update(cx, |search_view, cx| {
 965                let new_query = search_view.build_search_query(cx);
 966                if new_query.is_some() {
 967                    if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
 968                        search_view.query_editor.update(cx, |editor, cx| {
 969                            editor.set_text(old_query.as_str(), cx);
 970                        });
 971                        search_view.search_options = SearchOptions::from_query(&old_query);
 972                    }
 973                }
 974                new_query
 975            });
 976            if let Some(new_query) = new_query {
 977                let model = cx.add_model(|cx| {
 978                    let mut model = ProjectSearch::new(workspace.project().clone(), cx);
 979                    model.search(new_query, cx);
 980                    model
 981                });
 982                workspace.add_item(
 983                    Box::new(cx.add_view(|cx| ProjectSearchView::new(model, cx))),
 984                    cx,
 985                );
 986            }
 987        }
 988    }
 989
 990    fn select_next_match(pane: &mut Pane, _: &SelectNextMatch, cx: &mut ViewContext<Pane>) {
 991        if let Some(search_view) = pane
 992            .active_item()
 993            .and_then(|item| item.downcast::<ProjectSearchView>())
 994        {
 995            search_view.update(cx, |view, cx| view.select_match(Direction::Next, cx));
 996        } else {
 997            cx.propagate_action();
 998        }
 999    }
1000
1001    fn select_prev_match(pane: &mut Pane, _: &SelectPrevMatch, cx: &mut ViewContext<Pane>) {
1002        if let Some(search_view) = pane
1003            .active_item()
1004            .and_then(|item| item.downcast::<ProjectSearchView>())
1005        {
1006            search_view.update(cx, |view, cx| view.select_match(Direction::Prev, cx));
1007        } else {
1008            cx.propagate_action();
1009        }
1010    }
1011
1012    fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
1013        self.cycle_field(Direction::Next, cx);
1014    }
1015
1016    fn tab_previous(&mut self, _: &editor::TabPrev, cx: &mut ViewContext<Self>) {
1017        self.cycle_field(Direction::Prev, cx);
1018    }
1019
1020    fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
1021        let active_project_search = match &self.active_project_search {
1022            Some(active_project_search) => active_project_search,
1023
1024            None => {
1025                cx.propagate_action();
1026                return;
1027            }
1028        };
1029
1030        active_project_search.update(cx, |project_view, cx| {
1031            let views = &[
1032                &project_view.query_editor,
1033                &project_view.included_files_editor,
1034                &project_view.excluded_files_editor,
1035            ];
1036
1037            let current_index = match views
1038                .iter()
1039                .enumerate()
1040                .find(|(_, view)| view.is_focused(cx))
1041            {
1042                Some((index, _)) => index,
1043
1044                None => {
1045                    cx.propagate_action();
1046                    return;
1047                }
1048            };
1049
1050            let new_index = match direction {
1051                Direction::Next => (current_index + 1) % views.len(),
1052                Direction::Prev if current_index == 0 => views.len() - 1,
1053                Direction::Prev => (current_index - 1) % views.len(),
1054            };
1055            cx.focus(views[new_index]);
1056        });
1057    }
1058
1059    fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) -> bool {
1060        if let Some(search_view) = self.active_project_search.as_ref() {
1061            search_view.update(cx, |search_view, cx| {
1062                search_view.search_options.toggle(option);
1063                search_view.semantic = None;
1064                search_view.search(cx);
1065            });
1066            cx.notify();
1067            true
1068        } else {
1069            false
1070        }
1071    }
1072    fn toggle_filters(&mut self, cx: &mut ViewContext<Self>) -> bool {
1073        if let Some(search_view) = self.active_project_search.as_ref() {
1074            search_view.update(cx, |search_view, cx| {
1075                search_view.filters_enabled = !search_view.filters_enabled;
1076                search_view
1077                    .included_files_editor
1078                    .update(cx, |_, cx| cx.notify());
1079                search_view
1080                    .excluded_files_editor
1081                    .update(cx, |_, cx| cx.notify());
1082                search_view.semantic = None;
1083                search_view.search(cx);
1084                cx.notify();
1085            });
1086            cx.notify();
1087            true
1088        } else {
1089            false
1090        }
1091    }
1092
1093    fn toggle_semantic_search(&mut self, cx: &mut ViewContext<Self>) -> bool {
1094        if let Some(search_view) = self.active_project_search.as_ref() {
1095            search_view.update(cx, |search_view, cx| {
1096                if search_view.semantic.is_some() {
1097                    search_view.semantic = None;
1098                } else if let Some(semantic_index) = SemanticIndex::global(cx) {
1099                    // TODO: confirm that it's ok to send this project
1100                    search_view.search_options = SearchOptions::none();
1101
1102                    let project = search_view.model.read(cx).project.clone();
1103                    let index_task = semantic_index.update(cx, |semantic_index, cx| {
1104                        semantic_index.index_project(project, cx)
1105                    });
1106
1107                    cx.spawn(|search_view, mut cx| async move {
1108                        let (files_to_index, mut files_remaining_rx) = index_task.await?;
1109
1110                        search_view.update(&mut cx, |search_view, cx| {
1111                            cx.notify();
1112                            search_view.semantic = Some(SemanticSearchState {
1113                                file_count: files_to_index,
1114                                outstanding_file_count: files_to_index,
1115                                _progress_task: cx.spawn(|search_view, mut cx| async move {
1116                                    while let Some(count) = files_remaining_rx.recv().await {
1117                                        search_view
1118                                            .update(&mut cx, |search_view, cx| {
1119                                                if let Some(semantic_search_state) =
1120                                                    &mut search_view.semantic
1121                                                {
1122                                                    semantic_search_state.outstanding_file_count =
1123                                                        count;
1124                                                    cx.notify();
1125                                                    if count == 0 {
1126                                                        return;
1127                                                    }
1128                                                }
1129                                            })
1130                                            .ok();
1131                                    }
1132                                }),
1133                            });
1134                        })?;
1135                        anyhow::Ok(())
1136                    })
1137                    .detach_and_log_err(cx);
1138                }
1139                cx.notify();
1140            });
1141            cx.notify();
1142            true
1143        } else {
1144            false
1145        }
1146    }
1147
1148    fn render_nav_button(
1149        &self,
1150        icon: &'static str,
1151        direction: Direction,
1152        cx: &mut ViewContext<Self>,
1153    ) -> AnyElement<Self> {
1154        let action: Box<dyn Action>;
1155        let tooltip;
1156        match direction {
1157            Direction::Prev => {
1158                action = Box::new(SelectPrevMatch);
1159                tooltip = "Select Previous Match";
1160            }
1161            Direction::Next => {
1162                action = Box::new(SelectNextMatch);
1163                tooltip = "Select Next Match";
1164            }
1165        };
1166        let tooltip_style = theme::current(cx).tooltip.clone();
1167
1168        enum NavButton {}
1169        MouseEventHandler::<NavButton, _>::new(direction as usize, cx, |state, cx| {
1170            let theme = theme::current(cx);
1171            let style = theme.search.option_button.inactive_state().style_for(state);
1172            Label::new(icon, style.text.clone())
1173                .contained()
1174                .with_style(style.container)
1175        })
1176        .on_click(MouseButton::Left, move |_, this, cx| {
1177            if let Some(search) = this.active_project_search.as_ref() {
1178                search.update(cx, |search, cx| search.select_match(direction, cx));
1179            }
1180        })
1181        .with_cursor_style(CursorStyle::PointingHand)
1182        .with_tooltip::<NavButton>(
1183            direction as usize,
1184            tooltip.to_string(),
1185            Some(action),
1186            tooltip_style,
1187            cx,
1188        )
1189        .into_any()
1190    }
1191    fn render_option_button_icon(
1192        &self,
1193        icon: &'static str,
1194        option: SearchOptions,
1195        cx: &mut ViewContext<Self>,
1196    ) -> AnyElement<Self> {
1197        let tooltip_style = theme::current(cx).tooltip.clone();
1198        let is_active = self.is_option_enabled(option, cx);
1199        MouseEventHandler::<Self, _>::new(option.bits as usize, cx, |state, cx| {
1200            let theme = theme::current(cx);
1201            let style = theme
1202                .search
1203                .option_button
1204                .in_state(is_active)
1205                .style_for(state);
1206            Svg::new(icon)
1207                .with_color(style.text.color.clone())
1208                .contained()
1209                .with_style(style.container)
1210                .constrained()
1211        })
1212        .on_click(MouseButton::Left, move |_, this, cx| {
1213            this.toggle_search_option(option, cx);
1214        })
1215        .with_cursor_style(CursorStyle::PointingHand)
1216        .with_tooltip::<Self>(
1217            option.bits as usize,
1218            format!("Toggle {}", option.label()),
1219            Some(option.to_toggle_action()),
1220            tooltip_style,
1221            cx,
1222        )
1223        .into_any()
1224    }
1225
1226    fn render_regex_button(
1227        &self,
1228        icon: &'static str,
1229        current_mode: SearchMode,
1230        cx: &mut ViewContext<Self>,
1231    ) -> AnyElement<Self> {
1232        let tooltip_style = theme::current(cx).tooltip.clone();
1233        let is_active = current_mode == SearchMode::Regex;
1234        let option = SearchOptions::REGEX;
1235        MouseEventHandler::<Self, _>::new(option.bits as usize, cx, |state, cx| {
1236            let theme = theme::current(cx);
1237            let style = theme
1238                .search
1239                .option_button
1240                .in_state(is_active)
1241                .style_for(state);
1242            Label::new(icon, style.text.clone())
1243                .contained()
1244                .with_style(style.container)
1245        })
1246        .on_click(MouseButton::Left, move |_, this, cx| {
1247            this.toggle_search_option(option, cx);
1248        })
1249        .with_cursor_style(CursorStyle::PointingHand)
1250        .with_tooltip::<Self>(
1251            option.bits as usize,
1252            format!("Toggle {}", option.label()),
1253            Some(option.to_toggle_action()),
1254            tooltip_style,
1255            cx,
1256        )
1257        .into_any()
1258    }
1259
1260    fn render_semantic_search_button(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
1261        let tooltip_style = theme::current(cx).tooltip.clone();
1262        let is_active = if let Some(search) = self.active_project_search.as_ref() {
1263            let search = search.read(cx);
1264            search.current_mode == SearchMode::Semantic
1265        } else {
1266            false
1267        };
1268
1269        let region_id = 3;
1270
1271        MouseEventHandler::<Self, _>::new(region_id, cx, |state, cx| {
1272            let theme = theme::current(cx);
1273            let style = theme
1274                .search
1275                .option_button
1276                .in_state(is_active)
1277                .style_for(state);
1278            Label::new("Semantic", style.text.clone())
1279                .contained()
1280                .with_style(style.container)
1281        })
1282        .on_click(MouseButton::Left, move |_, this, cx| {
1283            this.toggle_semantic_search(cx);
1284        })
1285        .with_cursor_style(CursorStyle::PointingHand)
1286        .with_tooltip::<Self>(
1287            region_id,
1288            format!("Toggle Semantic Search"),
1289            Some(Box::new(ToggleSemanticSearch)),
1290            tooltip_style,
1291            cx,
1292        )
1293        .into_any()
1294    }
1295    fn render_text_search_button(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
1296        let tooltip_style = theme::current(cx).tooltip.clone();
1297        let is_active = if let Some(search) = self.active_project_search.as_ref() {
1298            let search = search.read(cx);
1299            search.current_mode == SearchMode::Text
1300        } else {
1301            false
1302        };
1303
1304        let region_id = 4;
1305        enum NormalSearchTag {}
1306        MouseEventHandler::<NormalSearchTag, _>::new(region_id, cx, |state, cx| {
1307            let theme = theme::current(cx);
1308            let style = theme
1309                .search
1310                .option_button
1311                .in_state(is_active)
1312                .style_for(state);
1313            Label::new("Text", style.text.clone())
1314                .contained()
1315                .with_style(style.container)
1316        })
1317        .on_click(MouseButton::Left, move |_, this, cx| {
1318            this.toggle_semantic_search(cx);
1319        })
1320        .with_cursor_style(CursorStyle::PointingHand)
1321        .with_tooltip::<NormalSearchTag>(
1322            region_id,
1323            format!("Toggle Normal Search"),
1324            None,
1325            tooltip_style,
1326            cx,
1327        )
1328        .into_any()
1329    }
1330    fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool {
1331        if let Some(search) = self.active_project_search.as_ref() {
1332            search.read(cx).search_options.contains(option)
1333        } else {
1334            false
1335        }
1336    }
1337}
1338
1339impl Entity for ProjectSearchBar {
1340    type Event = ();
1341}
1342
1343impl View for ProjectSearchBar {
1344    fn ui_name() -> &'static str {
1345        "ProjectSearchBar"
1346    }
1347
1348    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
1349        if let Some(_search) = self.active_project_search.as_ref() {
1350            let search = _search.read(cx);
1351            let theme = theme::current(cx).clone();
1352            let query_container_style = if search.panels_with_errors.contains(&InputPanel::Query) {
1353                theme.search.invalid_editor
1354            } else {
1355                theme.search.editor.input.container
1356            };
1357            let include_container_style =
1358                if search.panels_with_errors.contains(&InputPanel::Include) {
1359                    theme.search.invalid_include_exclude_editor
1360                } else {
1361                    theme.search.include_exclude_editor.input.container
1362                };
1363            let exclude_container_style =
1364                if search.panels_with_errors.contains(&InputPanel::Exclude) {
1365                    theme.search.invalid_include_exclude_editor
1366                } else {
1367                    theme.search.include_exclude_editor.input.container
1368                };
1369
1370            let included_files_view = ChildView::new(&search.included_files_editor, cx)
1371                .aligned()
1372                .left()
1373                .flex(1.0, true);
1374            let excluded_files_view = ChildView::new(&search.excluded_files_editor, cx)
1375                .aligned()
1376                .right()
1377                .flex(1.0, true);
1378            let regex_button = self.render_regex_button("Regex", search.current_mode.clone(), cx);
1379            let row_spacing = theme.workspace.toolbar.container.padding.bottom;
1380            let search = _search.read(cx);
1381            let filter_button = {
1382                let tooltip_style = theme::current(cx).tooltip.clone();
1383                let is_active = search.filters_enabled;
1384                MouseEventHandler::<Self, _>::new(0, cx, |state, cx| {
1385                    let theme = theme::current(cx);
1386                    let style = theme
1387                        .search
1388                        .option_button
1389                        .in_state(is_active)
1390                        .style_for(state);
1391                    Svg::new("icons/filter_12.svg")
1392                        .with_color(style.text.color.clone())
1393                        .contained()
1394                        .with_style(style.container)
1395                })
1396                .on_click(MouseButton::Left, move |_, this, cx| {
1397                    this.toggle_filters(cx);
1398                })
1399                .with_cursor_style(CursorStyle::PointingHand)
1400                .with_tooltip::<Self>(0, "Toggle filters".into(), None, tooltip_style, cx)
1401                .into_any()
1402            };
1403            let search = _search.read(cx);
1404            let is_semantic_disabled = search.semantic.is_none();
1405
1406            let case_sensitive = if is_semantic_disabled {
1407                Some(self.render_option_button_icon(
1408                    "icons/case_insensitive_14.svg",
1409                    SearchOptions::CASE_SENSITIVE,
1410                    cx,
1411                ))
1412            } else {
1413                None
1414            };
1415
1416            let whole_word = if is_semantic_disabled {
1417                Some(self.render_option_button_icon(
1418                    "icons/word_search_12.svg",
1419                    SearchOptions::WHOLE_WORD,
1420                    cx,
1421                ))
1422            } else {
1423                None
1424            };
1425
1426            let search = _search.read(cx);
1427            let query = Flex::row()
1428                .with_child(
1429                    Svg::new("icons/magnifying_glass_12.svg")
1430                        .with_color(gpui::color::Color::white())
1431                        //.with_color(tab_theme.label.text.color)
1432                        .contained()
1433                        .constrained(), //.with_margin_right(tab_theme.spacing),
1434                )
1435                .with_child(
1436                    ChildView::new(&search.query_editor, cx)
1437                        .constrained()
1438                        .flex(1., true)
1439                        .into_any(),
1440                )
1441                .with_child(
1442                    Flex::row()
1443                        .with_child(filter_button)
1444                        .with_children(whole_word)
1445                        .with_children(case_sensitive)
1446                        .flex(1., true)
1447                        .contained(),
1448                )
1449                .align_children_center()
1450                .aligned()
1451                .left()
1452                .flex(1., true);
1453            let search = _search.read(cx);
1454            let matches = search.active_match_index.map(|match_ix| {
1455                Label::new(
1456                    format!(
1457                        "{}/{}",
1458                        match_ix + 1,
1459                        search.model.read(cx).match_ranges.len()
1460                    ),
1461                    theme.search.match_index.text.clone(),
1462                )
1463                .contained()
1464                .with_style(theme.search.match_index.container)
1465                .aligned()
1466                .left()
1467            });
1468
1469            let filters = search.filters_enabled.then(|| {
1470                Flex::row()
1471                    .with_child(
1472                        Flex::row()
1473                            .with_child(included_files_view)
1474                            .contained()
1475                            .with_style(include_container_style)
1476                            .aligned()
1477                            .constrained()
1478                            .with_min_width(theme.search.include_exclude_editor.min_width)
1479                            .with_max_width(theme.search.include_exclude_editor.max_width)
1480                            .flex(1., false),
1481                    )
1482                    .with_child(
1483                        Flex::row()
1484                            .with_child(excluded_files_view)
1485                            .contained()
1486                            .with_style(exclude_container_style)
1487                            .aligned()
1488                            .constrained()
1489                            .with_min_width(theme.search.include_exclude_editor.min_width)
1490                            .with_max_width(theme.search.include_exclude_editor.max_width)
1491                            .flex(1., false),
1492                    )
1493            });
1494
1495            let semantic_index =
1496                SemanticIndex::enabled(cx).then(|| self.render_semantic_search_button(cx));
1497            let normal_search = self.render_text_search_button(cx);
1498            Flex::row()
1499                .with_child(
1500                    Flex::column()
1501                        .with_child(Flex::row().with_children(matches).aligned().left())
1502                        .flex(1., true),
1503                )
1504                .with_child(
1505                    Flex::column()
1506                        .with_child(
1507                            Flex::row()
1508                                .with_child(
1509                                    Flex::row()
1510                                        .with_child(query)
1511                                        .contained()
1512                                        .with_style(query_container_style)
1513                                        .aligned()
1514                                        .constrained()
1515                                        .with_min_width(theme.search.editor.min_width)
1516                                        .with_max_width(theme.search.editor.max_width)
1517                                        .flex(1., false),
1518                                )
1519                                .with_child(
1520                                    Flex::row()
1521                                        .with_child(self.render_nav_button(
1522                                            "<",
1523                                            Direction::Prev,
1524                                            cx,
1525                                        ))
1526                                        .with_child(self.render_nav_button(
1527                                            ">",
1528                                            Direction::Next,
1529                                            cx,
1530                                        ))
1531                                        .aligned(),
1532                                )
1533                                .contained()
1534                                .with_margin_bottom(row_spacing),
1535                        )
1536                        .with_children(filters)
1537                        .contained()
1538                        .with_style(theme.search.container)
1539                        .flex(2., true),
1540                )
1541                .with_child(
1542                    Flex::column().with_child(
1543                        Flex::row()
1544                            .with_child(normal_search)
1545                            .with_children(semantic_index)
1546                            .with_child(regex_button)
1547                            .flex(1., true)
1548                            .aligned()
1549                            .right(),
1550                    ),
1551                )
1552                .contained()
1553                .flex_float()
1554                .into_any_named("project search")
1555        } else {
1556            Empty::new().into_any()
1557        }
1558    }
1559}
1560
1561impl ToolbarItemView for ProjectSearchBar {
1562    fn set_active_pane_item(
1563        &mut self,
1564        active_pane_item: Option<&dyn ItemHandle>,
1565        cx: &mut ViewContext<Self>,
1566    ) -> ToolbarItemLocation {
1567        cx.notify();
1568        self.subscription = None;
1569        self.active_project_search = None;
1570        if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
1571            self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
1572            self.active_project_search = Some(search);
1573            ToolbarItemLocation::PrimaryLeft {
1574                flex: Some((1., false)),
1575            }
1576        } else {
1577            ToolbarItemLocation::Hidden
1578        }
1579    }
1580
1581    fn row_count(&self, cx: &ViewContext<Self>) -> usize {
1582        self.active_project_search
1583            .as_ref()
1584            .map(|search| {
1585                let offset = search.read(cx).filters_enabled as usize;
1586                1 + offset
1587            })
1588            .unwrap_or_else(|| 1)
1589    }
1590}
1591
1592#[cfg(test)]
1593pub mod tests {
1594    use super::*;
1595    use editor::DisplayPoint;
1596    use gpui::{color::Color, executor::Deterministic, TestAppContext};
1597    use project::FakeFs;
1598    use serde_json::json;
1599    use settings::SettingsStore;
1600    use std::sync::Arc;
1601    use theme::ThemeSettings;
1602
1603    #[gpui::test]
1604    async fn test_project_search(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
1605        init_test(cx);
1606
1607        let fs = FakeFs::new(cx.background());
1608        fs.insert_tree(
1609            "/dir",
1610            json!({
1611                "one.rs": "const ONE: usize = 1;",
1612                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1613                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1614                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1615            }),
1616        )
1617        .await;
1618        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1619        let search = cx.add_model(|cx| ProjectSearch::new(project, cx));
1620        let (_, search_view) = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx));
1621
1622        search_view.update(cx, |search_view, cx| {
1623            search_view
1624                .query_editor
1625                .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1626            search_view.search(cx);
1627        });
1628        deterministic.run_until_parked();
1629        search_view.update(cx, |search_view, cx| {
1630            assert_eq!(
1631                search_view
1632                    .results_editor
1633                    .update(cx, |editor, cx| editor.display_text(cx)),
1634                "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
1635            );
1636            assert_eq!(
1637                search_view
1638                    .results_editor
1639                    .update(cx, |editor, cx| editor.all_background_highlights(cx)),
1640                &[
1641                    (
1642                        DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
1643                        Color::red()
1644                    ),
1645                    (
1646                        DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
1647                        Color::red()
1648                    ),
1649                    (
1650                        DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
1651                        Color::red()
1652                    )
1653                ]
1654            );
1655            assert_eq!(search_view.active_match_index, Some(0));
1656            assert_eq!(
1657                search_view
1658                    .results_editor
1659                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1660                [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1661            );
1662
1663            search_view.select_match(Direction::Next, cx);
1664        });
1665
1666        search_view.update(cx, |search_view, cx| {
1667            assert_eq!(search_view.active_match_index, Some(1));
1668            assert_eq!(
1669                search_view
1670                    .results_editor
1671                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1672                [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1673            );
1674            search_view.select_match(Direction::Next, cx);
1675        });
1676
1677        search_view.update(cx, |search_view, cx| {
1678            assert_eq!(search_view.active_match_index, Some(2));
1679            assert_eq!(
1680                search_view
1681                    .results_editor
1682                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1683                [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1684            );
1685            search_view.select_match(Direction::Next, cx);
1686        });
1687
1688        search_view.update(cx, |search_view, cx| {
1689            assert_eq!(search_view.active_match_index, Some(0));
1690            assert_eq!(
1691                search_view
1692                    .results_editor
1693                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1694                [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1695            );
1696            search_view.select_match(Direction::Prev, cx);
1697        });
1698
1699        search_view.update(cx, |search_view, cx| {
1700            assert_eq!(search_view.active_match_index, Some(2));
1701            assert_eq!(
1702                search_view
1703                    .results_editor
1704                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1705                [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1706            );
1707            search_view.select_match(Direction::Prev, cx);
1708        });
1709
1710        search_view.update(cx, |search_view, cx| {
1711            assert_eq!(search_view.active_match_index, Some(1));
1712            assert_eq!(
1713                search_view
1714                    .results_editor
1715                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1716                [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1717            );
1718        });
1719    }
1720
1721    #[gpui::test]
1722    async fn test_project_search_focus(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
1723        init_test(cx);
1724
1725        let fs = FakeFs::new(cx.background());
1726        fs.insert_tree(
1727            "/dir",
1728            json!({
1729                "one.rs": "const ONE: usize = 1;",
1730                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1731                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1732                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1733            }),
1734        )
1735        .await;
1736        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1737        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
1738
1739        let active_item = cx.read(|cx| {
1740            workspace
1741                .read(cx)
1742                .active_pane()
1743                .read(cx)
1744                .active_item()
1745                .and_then(|item| item.downcast::<ProjectSearchView>())
1746        });
1747        assert!(
1748            active_item.is_none(),
1749            "Expected no search panel to be active, but got: {active_item:?}"
1750        );
1751
1752        workspace.update(cx, |workspace, cx| {
1753            ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
1754        });
1755
1756        let Some(search_view) = cx.read(|cx| {
1757            workspace
1758                .read(cx)
1759                .active_pane()
1760                .read(cx)
1761                .active_item()
1762                .and_then(|item| item.downcast::<ProjectSearchView>())
1763        }) else {
1764            panic!("Search view expected to appear after new search event trigger")
1765        };
1766        let search_view_id = search_view.id();
1767
1768        cx.spawn(
1769            |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1770        )
1771        .detach();
1772        deterministic.run_until_parked();
1773        search_view.update(cx, |search_view, cx| {
1774            assert!(
1775                search_view.query_editor.is_focused(cx),
1776                "Empty search view should be focused after the toggle focus event: no results panel to focus on",
1777            );
1778        });
1779
1780        search_view.update(cx, |search_view, cx| {
1781            let query_editor = &search_view.query_editor;
1782            assert!(
1783                query_editor.is_focused(cx),
1784                "Search view should be focused after the new search view is activated",
1785            );
1786            let query_text = query_editor.read(cx).text(cx);
1787            assert!(
1788                query_text.is_empty(),
1789                "New search query should be empty but got '{query_text}'",
1790            );
1791            let results_text = search_view
1792                .results_editor
1793                .update(cx, |editor, cx| editor.display_text(cx));
1794            assert!(
1795                results_text.is_empty(),
1796                "Empty search view should have no results but got '{results_text}'"
1797            );
1798        });
1799
1800        search_view.update(cx, |search_view, cx| {
1801            search_view.query_editor.update(cx, |query_editor, cx| {
1802                query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
1803            });
1804            search_view.search(cx);
1805        });
1806        deterministic.run_until_parked();
1807        search_view.update(cx, |search_view, cx| {
1808            let results_text = search_view
1809                .results_editor
1810                .update(cx, |editor, cx| editor.display_text(cx));
1811            assert!(
1812                results_text.is_empty(),
1813                "Search view for mismatching query should have no results but got '{results_text}'"
1814            );
1815            assert!(
1816                search_view.query_editor.is_focused(cx),
1817                "Search view should be focused after mismatching query had been used in search",
1818            );
1819        });
1820        cx.spawn(
1821            |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1822        )
1823        .detach();
1824        deterministic.run_until_parked();
1825        search_view.update(cx, |search_view, cx| {
1826            assert!(
1827                search_view.query_editor.is_focused(cx),
1828                "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
1829            );
1830        });
1831
1832        search_view.update(cx, |search_view, cx| {
1833            search_view
1834                .query_editor
1835                .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1836            search_view.search(cx);
1837        });
1838        deterministic.run_until_parked();
1839        search_view.update(cx, |search_view, cx| {
1840            assert_eq!(
1841                search_view
1842                    .results_editor
1843                    .update(cx, |editor, cx| editor.display_text(cx)),
1844                "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1845                "Search view results should match the query"
1846            );
1847            assert!(
1848                search_view.results_editor.is_focused(cx),
1849                "Search view with mismatching query should be focused after search results are available",
1850            );
1851        });
1852        cx.spawn(
1853            |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1854        )
1855        .detach();
1856        deterministic.run_until_parked();
1857        search_view.update(cx, |search_view, cx| {
1858            assert!(
1859                search_view.results_editor.is_focused(cx),
1860                "Search view with matching query should still have its results editor focused after the toggle focus event",
1861            );
1862        });
1863
1864        workspace.update(cx, |workspace, cx| {
1865            ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
1866        });
1867        search_view.update(cx, |search_view, cx| {
1868            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");
1869            assert_eq!(
1870                search_view
1871                    .results_editor
1872                    .update(cx, |editor, cx| editor.display_text(cx)),
1873                "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
1874                "Results should be unchanged after search view 2nd open in a row"
1875            );
1876            assert!(
1877                search_view.query_editor.is_focused(cx),
1878                "Focus should be moved into query editor again after search view 2nd open in a row"
1879            );
1880        });
1881
1882        cx.spawn(
1883            |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) },
1884        )
1885        .detach();
1886        deterministic.run_until_parked();
1887        search_view.update(cx, |search_view, cx| {
1888            assert!(
1889                search_view.results_editor.is_focused(cx),
1890                "Search view with matching query should switch focus to the results editor after the toggle focus event",
1891            );
1892        });
1893    }
1894
1895    #[gpui::test]
1896    async fn test_new_project_search_in_directory(
1897        deterministic: Arc<Deterministic>,
1898        cx: &mut TestAppContext,
1899    ) {
1900        init_test(cx);
1901
1902        let fs = FakeFs::new(cx.background());
1903        fs.insert_tree(
1904            "/dir",
1905            json!({
1906                "a": {
1907                    "one.rs": "const ONE: usize = 1;",
1908                    "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1909                },
1910                "b": {
1911                    "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1912                    "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1913                },
1914            }),
1915        )
1916        .await;
1917        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1918        let worktree_id = project.read_with(cx, |project, cx| {
1919            project.worktrees(cx).next().unwrap().read(cx).id()
1920        });
1921        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
1922
1923        let active_item = cx.read(|cx| {
1924            workspace
1925                .read(cx)
1926                .active_pane()
1927                .read(cx)
1928                .active_item()
1929                .and_then(|item| item.downcast::<ProjectSearchView>())
1930        });
1931        assert!(
1932            active_item.is_none(),
1933            "Expected no search panel to be active, but got: {active_item:?}"
1934        );
1935
1936        let one_file_entry = cx.update(|cx| {
1937            workspace
1938                .read(cx)
1939                .project()
1940                .read(cx)
1941                .entry_for_path(&(worktree_id, "a/one.rs").into(), cx)
1942                .expect("no entry for /a/one.rs file")
1943        });
1944        assert!(one_file_entry.is_file());
1945        workspace.update(cx, |workspace, cx| {
1946            ProjectSearchView::new_search_in_directory(workspace, &one_file_entry, cx)
1947        });
1948        let active_search_entry = cx.read(|cx| {
1949            workspace
1950                .read(cx)
1951                .active_pane()
1952                .read(cx)
1953                .active_item()
1954                .and_then(|item| item.downcast::<ProjectSearchView>())
1955        });
1956        assert!(
1957            active_search_entry.is_none(),
1958            "Expected no search panel to be active for file entry"
1959        );
1960
1961        let a_dir_entry = cx.update(|cx| {
1962            workspace
1963                .read(cx)
1964                .project()
1965                .read(cx)
1966                .entry_for_path(&(worktree_id, "a").into(), cx)
1967                .expect("no entry for /a/ directory")
1968        });
1969        assert!(a_dir_entry.is_dir());
1970        workspace.update(cx, |workspace, cx| {
1971            ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry, cx)
1972        });
1973
1974        let Some(search_view) = cx.read(|cx| {
1975            workspace
1976                .read(cx)
1977                .active_pane()
1978                .read(cx)
1979                .active_item()
1980                .and_then(|item| item.downcast::<ProjectSearchView>())
1981        }) else {
1982            panic!("Search view expected to appear after new search in directory event trigger")
1983        };
1984        deterministic.run_until_parked();
1985        search_view.update(cx, |search_view, cx| {
1986            assert!(
1987                search_view.query_editor.is_focused(cx),
1988                "On new search in directory, focus should be moved into query editor"
1989            );
1990            search_view.excluded_files_editor.update(cx, |editor, cx| {
1991                assert!(
1992                    editor.display_text(cx).is_empty(),
1993                    "New search in directory should not have any excluded files"
1994                );
1995            });
1996            search_view.included_files_editor.update(cx, |editor, cx| {
1997                assert_eq!(
1998                    editor.display_text(cx),
1999                    a_dir_entry.path.join("**").display().to_string(),
2000                    "New search in directory should have included dir entry path"
2001                );
2002            });
2003        });
2004
2005        search_view.update(cx, |search_view, cx| {
2006            search_view
2007                .query_editor
2008                .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
2009            search_view.search(cx);
2010        });
2011        deterministic.run_until_parked();
2012        search_view.update(cx, |search_view, cx| {
2013            assert_eq!(
2014                search_view
2015                    .results_editor
2016                    .update(cx, |editor, cx| editor.display_text(cx)),
2017                "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
2018                "New search in directory should have a filter that matches a certain directory"
2019            );
2020        });
2021    }
2022
2023    pub fn init_test(cx: &mut TestAppContext) {
2024        cx.foreground().forbid_parking();
2025        let fonts = cx.font_cache();
2026        let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default);
2027        theme.search.match_background = Color::red();
2028
2029        cx.update(|cx| {
2030            cx.set_global(SettingsStore::test(cx));
2031            cx.set_global(ActiveSearches::default());
2032
2033            theme::init((), cx);
2034            cx.update_global::<SettingsStore, _, _>(|store, _| {
2035                let mut settings = store.get::<ThemeSettings>(None).clone();
2036                settings.theme = Arc::new(theme);
2037                store.override_global(settings)
2038            });
2039
2040            language::init(cx);
2041            client::init_settings(cx);
2042            editor::init(cx);
2043            workspace::init_settings(cx);
2044            Project::init_settings(cx);
2045            super::init(cx);
2046        });
2047    }
2048}