project_search.rs

   1use crate::{
   2    SearchOption, 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 project::{search::SearchQuery, Project};
  22use settings::Settings;
  23use smallvec::SmallVec;
  24use std::{
  25    any::{Any, TypeId},
  26    borrow::Cow,
  27    collections::HashSet,
  28    mem,
  29    ops::Range,
  30    path::PathBuf,
  31    sync::Arc,
  32};
  33use util::ResultExt as _;
  34use workspace::{
  35    item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
  36    searchable::{Direction, SearchableItem, SearchableItemHandle},
  37    ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId,
  38};
  39
  40actions!(project_search, [SearchInNew, ToggleFocus, NextField]);
  41
  42#[derive(Default)]
  43struct ActiveSearches(HashMap<WeakModelHandle<Project>, WeakViewHandle<ProjectSearchView>>);
  44
  45pub fn init(cx: &mut AppContext) {
  46    cx.set_global(ActiveSearches::default());
  47    cx.add_action(ProjectSearchView::deploy);
  48    cx.add_action(ProjectSearchBar::search);
  49    cx.add_action(ProjectSearchBar::search_in_new);
  50    cx.add_action(ProjectSearchBar::select_next_match);
  51    cx.add_action(ProjectSearchBar::select_prev_match);
  52    cx.add_action(ProjectSearchBar::move_focus_to_results);
  53    cx.capture_action(ProjectSearchBar::tab);
  54    cx.capture_action(ProjectSearchBar::tab_previous);
  55    add_toggle_option_action::<ToggleCaseSensitive>(SearchOption::CaseSensitive, cx);
  56    add_toggle_option_action::<ToggleWholeWord>(SearchOption::WholeWord, cx);
  57    add_toggle_option_action::<ToggleRegex>(SearchOption::Regex, cx);
  58}
  59
  60fn add_toggle_option_action<A: Action>(option: SearchOption, cx: &mut AppContext) {
  61    cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext<Pane>| {
  62        if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<ProjectSearchBar>() {
  63            if search_bar.update(cx, |search_bar, cx| {
  64                search_bar.toggle_search_option(option, cx)
  65            }) {
  66                return;
  67            }
  68        }
  69        cx.propagate_action();
  70    });
  71}
  72
  73struct ProjectSearch {
  74    project: ModelHandle<Project>,
  75    excerpts: ModelHandle<MultiBuffer>,
  76    pending_search: Option<Task<Option<()>>>,
  77    match_ranges: Vec<Range<Anchor>>,
  78    active_query: Option<SearchQuery>,
  79    search_id: usize,
  80}
  81
  82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
  83enum InputPanel {
  84    Query,
  85    Exclude,
  86    Include,
  87}
  88
  89pub struct ProjectSearchView {
  90    model: ModelHandle<ProjectSearch>,
  91    query_editor: ViewHandle<Editor>,
  92    results_editor: ViewHandle<Editor>,
  93    case_sensitive: bool,
  94    whole_word: bool,
  95    regex: bool,
  96    panels_with_errors: HashSet<InputPanel>,
  97    active_match_index: Option<usize>,
  98    search_id: usize,
  99    query_editor_was_focused: bool,
 100    included_files_editor: ViewHandle<Editor>,
 101    excluded_files_editor: ViewHandle<Editor>,
 102}
 103
 104pub struct ProjectSearchBar {
 105    active_project_search: Option<ViewHandle<ProjectSearchView>>,
 106    subscription: Option<Subscription>,
 107}
 108
 109impl Entity for ProjectSearch {
 110    type Event = ();
 111}
 112
 113impl ProjectSearch {
 114    fn new(project: ModelHandle<Project>, cx: &mut ModelContext<Self>) -> Self {
 115        let replica_id = project.read(cx).replica_id();
 116        Self {
 117            project,
 118            excerpts: cx.add_model(|_| MultiBuffer::new(replica_id)),
 119            pending_search: Default::default(),
 120            match_ranges: Default::default(),
 121            active_query: None,
 122            search_id: 0,
 123        }
 124    }
 125
 126    fn clone(&self, cx: &mut ModelContext<Self>) -> ModelHandle<Self> {
 127        cx.add_model(|cx| Self {
 128            project: self.project.clone(),
 129            excerpts: self
 130                .excerpts
 131                .update(cx, |excerpts, cx| cx.add_model(|cx| excerpts.clone(cx))),
 132            pending_search: Default::default(),
 133            match_ranges: self.match_ranges.clone(),
 134            active_query: self.active_query.clone(),
 135            search_id: self.search_id,
 136        })
 137    }
 138
 139    fn search(&mut self, query: SearchQuery, cx: &mut ModelContext<Self>) {
 140        let search = self
 141            .project
 142            .update(cx, |project, cx| project.search(query.clone(), cx));
 143        self.search_id += 1;
 144        self.active_query = Some(query);
 145        self.match_ranges.clear();
 146        self.pending_search = Some(cx.spawn_weak(|this, mut cx| async move {
 147            let matches = search.await.log_err()?;
 148            let this = this.upgrade(&cx)?;
 149            let mut matches = matches.into_iter().collect::<Vec<_>>();
 150            let (_task, mut match_ranges) = this.update(&mut cx, |this, cx| {
 151                this.match_ranges.clear();
 152                matches.sort_by_key(|(buffer, _)| buffer.read(cx).file().map(|file| file.path()));
 153                this.excerpts.update(cx, |excerpts, cx| {
 154                    excerpts.clear(cx);
 155                    excerpts.stream_excerpts_with_context_lines(matches, 1, cx)
 156                })
 157            });
 158
 159            while let Some(match_range) = match_ranges.next().await {
 160                this.update(&mut cx, |this, cx| {
 161                    this.match_ranges.push(match_range);
 162                    while let Ok(Some(match_range)) = match_ranges.try_next() {
 163                        this.match_ranges.push(match_range);
 164                    }
 165                    cx.notify();
 166                });
 167            }
 168
 169            this.update(&mut cx, |this, cx| {
 170                this.pending_search.take();
 171                cx.notify();
 172            });
 173
 174            None
 175        }));
 176        cx.notify();
 177    }
 178}
 179
 180pub enum ViewEvent {
 181    UpdateTab,
 182    Activate,
 183    EditorEvent(editor::Event),
 184}
 185
 186impl Entity for ProjectSearchView {
 187    type Event = ViewEvent;
 188}
 189
 190impl View for ProjectSearchView {
 191    fn ui_name() -> &'static str {
 192        "ProjectSearchView"
 193    }
 194
 195    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
 196        let model = &self.model.read(cx);
 197        if model.match_ranges.is_empty() {
 198            enum Status {}
 199
 200            let theme = cx.global::<Settings>().theme.clone();
 201            let text = if self.query_editor.read(cx).text(cx).is_empty() {
 202                ""
 203            } else if model.pending_search.is_some() {
 204                "Searching..."
 205            } else {
 206                "No results"
 207            };
 208            MouseEventHandler::<Status, _>::new(0, cx, |_, _| {
 209                Label::new(text, theme.search.results_status.clone())
 210                    .aligned()
 211                    .contained()
 212                    .with_background_color(theme.editor.background)
 213                    .flex(1., true)
 214            })
 215            .on_down(MouseButton::Left, |_, _, cx| {
 216                cx.focus_parent();
 217            })
 218            .into_any_named("project search view")
 219        } else {
 220            ChildView::new(&self.results_editor, cx)
 221                .flex(1., true)
 222                .into_any_named("project search view")
 223        }
 224    }
 225
 226    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
 227        let handle = cx.weak_handle();
 228        cx.update_global(|state: &mut ActiveSearches, cx| {
 229            state
 230                .0
 231                .insert(self.model.read(cx).project.downgrade(), handle)
 232        });
 233
 234        if cx.is_self_focused() {
 235            if self.query_editor_was_focused {
 236                cx.focus(&self.query_editor);
 237            } else {
 238                cx.focus(&self.results_editor);
 239            }
 240        }
 241    }
 242}
 243
 244impl Item for ProjectSearchView {
 245    fn tab_tooltip_text(&self, cx: &AppContext) -> Option<Cow<str>> {
 246        Some(self.query_editor.read(cx).text(cx).into())
 247    }
 248
 249    fn act_as_type<'a>(
 250        &'a self,
 251        type_id: TypeId,
 252        self_handle: &'a ViewHandle<Self>,
 253        _: &'a AppContext,
 254    ) -> Option<&'a AnyViewHandle> {
 255        if type_id == TypeId::of::<Self>() {
 256            Some(self_handle)
 257        } else if type_id == TypeId::of::<Editor>() {
 258            Some(&self.results_editor)
 259        } else {
 260            None
 261        }
 262    }
 263
 264    fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
 265        self.results_editor
 266            .update(cx, |editor, cx| editor.deactivated(cx));
 267    }
 268
 269    fn tab_content<T: View>(
 270        &self,
 271        _detail: Option<usize>,
 272        tab_theme: &theme::Tab,
 273        cx: &AppContext,
 274    ) -> AnyElement<T> {
 275        Flex::row()
 276            .with_child(
 277                Svg::new("icons/magnifying_glass_12.svg")
 278                    .with_color(tab_theme.label.text.color)
 279                    .constrained()
 280                    .with_width(tab_theme.type_icon_width)
 281                    .aligned()
 282                    .contained()
 283                    .with_margin_right(tab_theme.spacing),
 284            )
 285            .with_children(self.model.read(cx).active_query.as_ref().map(|query| {
 286                let query_text = util::truncate_and_trailoff(query.as_str(), MAX_TAB_TITLE_LEN);
 287
 288                Label::new(query_text, tab_theme.label.clone()).aligned()
 289            }))
 290            .into_any()
 291    }
 292
 293    fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
 294        self.results_editor.for_each_project_item(cx, f)
 295    }
 296
 297    fn is_singleton(&self, _: &AppContext) -> bool {
 298        false
 299    }
 300
 301    fn can_save(&self, _: &AppContext) -> bool {
 302        true
 303    }
 304
 305    fn is_dirty(&self, cx: &AppContext) -> bool {
 306        self.results_editor.read(cx).is_dirty(cx)
 307    }
 308
 309    fn has_conflict(&self, cx: &AppContext) -> bool {
 310        self.results_editor.read(cx).has_conflict(cx)
 311    }
 312
 313    fn save(
 314        &mut self,
 315        project: ModelHandle<Project>,
 316        cx: &mut ViewContext<Self>,
 317    ) -> Task<anyhow::Result<()>> {
 318        self.results_editor
 319            .update(cx, |editor, cx| editor.save(project, cx))
 320    }
 321
 322    fn save_as(
 323        &mut self,
 324        _: ModelHandle<Project>,
 325        _: PathBuf,
 326        _: &mut ViewContext<Self>,
 327    ) -> Task<anyhow::Result<()>> {
 328        unreachable!("save_as should not have been called")
 329    }
 330
 331    fn reload(
 332        &mut self,
 333        project: ModelHandle<Project>,
 334        cx: &mut ViewContext<Self>,
 335    ) -> Task<anyhow::Result<()>> {
 336        self.results_editor
 337            .update(cx, |editor, cx| editor.reload(project, cx))
 338    }
 339
 340    fn clone_on_split(&self, _workspace_id: WorkspaceId, cx: &mut ViewContext<Self>) -> Option<Self>
 341    where
 342        Self: Sized,
 343    {
 344        let model = self.model.update(cx, |model, cx| model.clone(cx));
 345        Some(Self::new(model, cx))
 346    }
 347
 348    fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
 349        self.results_editor
 350            .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx));
 351    }
 352
 353    fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
 354        self.results_editor.update(cx, |editor, _| {
 355            editor.set_nav_history(Some(nav_history));
 356        });
 357    }
 358
 359    fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
 360        self.results_editor
 361            .update(cx, |editor, cx| editor.navigate(data, cx))
 362    }
 363
 364    fn git_diff_recalc(
 365        &mut self,
 366        project: ModelHandle<Project>,
 367        cx: &mut ViewContext<Self>,
 368    ) -> Task<anyhow::Result<()>> {
 369        self.results_editor
 370            .update(cx, |editor, cx| editor.git_diff_recalc(project, cx))
 371    }
 372
 373    fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> {
 374        match event {
 375            ViewEvent::UpdateTab => {
 376                smallvec::smallvec![ItemEvent::UpdateBreadcrumbs, ItemEvent::UpdateTab]
 377            }
 378            ViewEvent::EditorEvent(editor_event) => Editor::to_item_events(editor_event),
 379            _ => SmallVec::new(),
 380        }
 381    }
 382
 383    fn breadcrumb_location(&self) -> ToolbarItemLocation {
 384        if self.has_matches() {
 385            ToolbarItemLocation::Secondary
 386        } else {
 387            ToolbarItemLocation::Hidden
 388        }
 389    }
 390
 391    fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
 392        self.results_editor.breadcrumbs(theme, cx)
 393    }
 394
 395    fn serialized_item_kind() -> Option<&'static str> {
 396        None
 397    }
 398
 399    fn deserialize(
 400        _project: ModelHandle<Project>,
 401        _workspace: WeakViewHandle<Workspace>,
 402        _workspace_id: workspace::WorkspaceId,
 403        _item_id: workspace::ItemId,
 404        _cx: &mut ViewContext<Pane>,
 405    ) -> Task<anyhow::Result<ViewHandle<Self>>> {
 406        unimplemented!()
 407    }
 408}
 409
 410impl ProjectSearchView {
 411    fn new(model: ModelHandle<ProjectSearch>, cx: &mut ViewContext<Self>) -> Self {
 412        let project;
 413        let excerpts;
 414        let mut query_text = String::new();
 415        let mut regex = false;
 416        let mut case_sensitive = false;
 417        let mut whole_word = false;
 418
 419        {
 420            let model = model.read(cx);
 421            project = model.project.clone();
 422            excerpts = model.excerpts.clone();
 423            if let Some(active_query) = model.active_query.as_ref() {
 424                query_text = active_query.as_str().to_string();
 425                regex = active_query.is_regex();
 426                case_sensitive = active_query.case_sensitive();
 427                whole_word = active_query.whole_word();
 428            }
 429        }
 430        cx.observe(&model, |this, _, cx| this.model_changed(cx))
 431            .detach();
 432
 433        let query_editor = cx.add_view(|cx| {
 434            let mut editor = Editor::single_line(
 435                Some(Arc::new(|theme| theme.search.editor.input.clone())),
 436                cx,
 437            );
 438            editor.set_text(query_text, cx);
 439            editor
 440        });
 441        // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
 442        cx.subscribe(&query_editor, |_, _, event, cx| {
 443            cx.emit(ViewEvent::EditorEvent(event.clone()))
 444        })
 445        .detach();
 446
 447        let results_editor = cx.add_view(|cx| {
 448            let mut editor = Editor::for_multibuffer(excerpts, Some(project), cx);
 449            editor.set_searchable(false);
 450            editor
 451        });
 452        cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab))
 453            .detach();
 454
 455        cx.subscribe(&results_editor, |this, _, event, cx| {
 456            if matches!(event, editor::Event::SelectionsChanged { .. }) {
 457                this.update_match_index(cx);
 458            }
 459            // Reraise editor events for workspace item activation purposes
 460            cx.emit(ViewEvent::EditorEvent(event.clone()));
 461        })
 462        .detach();
 463
 464        let included_files_editor = cx.add_view(|cx| {
 465            let mut editor = Editor::single_line(
 466                Some(Arc::new(|theme| {
 467                    theme.search.include_exclude_editor.input.clone()
 468                })),
 469                cx,
 470            );
 471            editor.set_placeholder_text("Include: crates/**/*.toml", cx);
 472
 473            editor
 474        });
 475        // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes
 476        cx.subscribe(&included_files_editor, |_, _, event, cx| {
 477            cx.emit(ViewEvent::EditorEvent(event.clone()))
 478        })
 479        .detach();
 480
 481        let excluded_files_editor = cx.add_view(|cx| {
 482            let mut editor = Editor::single_line(
 483                Some(Arc::new(|theme| {
 484                    theme.search.include_exclude_editor.input.clone()
 485                })),
 486                cx,
 487            );
 488            editor.set_placeholder_text("Exclude: vendor/*, *.lock", cx);
 489
 490            editor
 491        });
 492        // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes
 493        cx.subscribe(&excluded_files_editor, |_, _, event, cx| {
 494            cx.emit(ViewEvent::EditorEvent(event.clone()))
 495        })
 496        .detach();
 497
 498        let mut this = ProjectSearchView {
 499            search_id: model.read(cx).search_id,
 500            model,
 501            query_editor,
 502            results_editor,
 503            case_sensitive,
 504            whole_word,
 505            regex,
 506            panels_with_errors: HashSet::new(),
 507            active_match_index: None,
 508            query_editor_was_focused: false,
 509            included_files_editor,
 510            excluded_files_editor,
 511        };
 512        this.model_changed(cx);
 513        this
 514    }
 515
 516    // Re-activate the most recently activated search or the most recent if it has been closed.
 517    // If no search exists in the workspace, create a new one.
 518    fn deploy(
 519        workspace: &mut Workspace,
 520        _: &workspace::NewSearch,
 521        cx: &mut ViewContext<Workspace>,
 522    ) {
 523        // Clean up entries for dropped projects
 524        cx.update_global(|state: &mut ActiveSearches, cx| {
 525            state.0.retain(|project, _| project.is_upgradable(cx))
 526        });
 527
 528        let active_search = cx
 529            .global::<ActiveSearches>()
 530            .0
 531            .get(&workspace.project().downgrade());
 532
 533        let existing = active_search
 534            .and_then(|active_search| {
 535                workspace
 536                    .items_of_type::<ProjectSearchView>(cx)
 537                    .find(|search| search == active_search)
 538            })
 539            .or_else(|| workspace.item_of_type::<ProjectSearchView>(cx));
 540
 541        let query = workspace.active_item(cx).and_then(|item| {
 542            let editor = item.act_as::<Editor>(cx)?;
 543            let query = editor.query_suggestion(cx);
 544            if query.is_empty() {
 545                None
 546            } else {
 547                Some(query)
 548            }
 549        });
 550
 551        let search = if let Some(existing) = existing {
 552            workspace.activate_item(&existing, cx);
 553            existing
 554        } else {
 555            let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
 556            let view = cx.add_view(|cx| ProjectSearchView::new(model, cx));
 557            workspace.add_item(Box::new(view.clone()), cx);
 558            view
 559        };
 560
 561        search.update(cx, |search, cx| {
 562            if let Some(query) = query {
 563                search.set_query(&query, cx);
 564            }
 565            search.focus_query_editor(cx)
 566        });
 567    }
 568
 569    fn search(&mut self, cx: &mut ViewContext<Self>) {
 570        if let Some(query) = self.build_search_query(cx) {
 571            self.model.update(cx, |model, cx| model.search(query, cx));
 572        }
 573    }
 574
 575    fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
 576        let text = self.query_editor.read(cx).text(cx);
 577        let included_files =
 578            match Self::load_glob_set(&self.included_files_editor.read(cx).text(cx)) {
 579                Ok(included_files) => {
 580                    self.panels_with_errors.remove(&InputPanel::Include);
 581                    included_files
 582                }
 583                Err(_e) => {
 584                    self.panels_with_errors.insert(InputPanel::Include);
 585                    cx.notify();
 586                    return None;
 587                }
 588            };
 589        let excluded_files =
 590            match Self::load_glob_set(&self.excluded_files_editor.read(cx).text(cx)) {
 591                Ok(excluded_files) => {
 592                    self.panels_with_errors.remove(&InputPanel::Exclude);
 593                    excluded_files
 594                }
 595                Err(_e) => {
 596                    self.panels_with_errors.insert(InputPanel::Exclude);
 597                    cx.notify();
 598                    return None;
 599                }
 600            };
 601        if self.regex {
 602            match SearchQuery::regex(
 603                text,
 604                self.whole_word,
 605                self.case_sensitive,
 606                included_files,
 607                excluded_files,
 608            ) {
 609                Ok(query) => {
 610                    self.panels_with_errors.remove(&InputPanel::Query);
 611                    Some(query)
 612                }
 613                Err(_e) => {
 614                    self.panels_with_errors.insert(InputPanel::Query);
 615                    cx.notify();
 616                    None
 617                }
 618            }
 619        } else {
 620            Some(SearchQuery::text(
 621                text,
 622                self.whole_word,
 623                self.case_sensitive,
 624                included_files,
 625                excluded_files,
 626            ))
 627        }
 628    }
 629
 630    fn load_glob_set(text: &str) -> Result<Vec<GlobMatcher>> {
 631        text.split(',')
 632            .map(str::trim)
 633            .filter(|glob_str| !glob_str.is_empty())
 634            .map(|glob_str| anyhow::Ok(Glob::new(glob_str)?.compile_matcher()))
 635            .collect()
 636    }
 637
 638    fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
 639        if let Some(index) = self.active_match_index {
 640            let match_ranges = self.model.read(cx).match_ranges.clone();
 641            let new_index = self.results_editor.update(cx, |editor, cx| {
 642                editor.match_index_for_direction(&match_ranges, index, direction, cx)
 643            });
 644
 645            let range_to_select = match_ranges[new_index].clone();
 646            self.results_editor.update(cx, |editor, cx| {
 647                editor.unfold_ranges([range_to_select.clone()], false, true, cx);
 648                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 649                    s.select_ranges([range_to_select])
 650                });
 651            });
 652        }
 653    }
 654
 655    fn focus_query_editor(&mut self, cx: &mut ViewContext<Self>) {
 656        self.query_editor.update(cx, |query_editor, cx| {
 657            query_editor.select_all(&SelectAll, cx);
 658        });
 659        self.query_editor_was_focused = true;
 660        cx.focus(&self.query_editor);
 661    }
 662
 663    fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
 664        self.query_editor
 665            .update(cx, |query_editor, cx| query_editor.set_text(query, cx));
 666    }
 667
 668    fn focus_results_editor(&mut self, cx: &mut ViewContext<Self>) {
 669        self.query_editor.update(cx, |query_editor, cx| {
 670            let cursor = query_editor.selections.newest_anchor().head();
 671            query_editor.change_selections(None, cx, |s| s.select_ranges([cursor.clone()..cursor]));
 672        });
 673        self.query_editor_was_focused = false;
 674        cx.focus(&self.results_editor);
 675    }
 676
 677    fn model_changed(&mut self, cx: &mut ViewContext<Self>) {
 678        let match_ranges = self.model.read(cx).match_ranges.clone();
 679        if match_ranges.is_empty() {
 680            self.active_match_index = None;
 681        } else {
 682            let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id);
 683            let is_new_search = self.search_id != prev_search_id;
 684            self.results_editor.update(cx, |editor, cx| {
 685                if is_new_search {
 686                    editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 687                        s.select_ranges(match_ranges.first().cloned())
 688                    });
 689                }
 690                editor.highlight_background::<Self>(
 691                    match_ranges,
 692                    |theme| theme.search.match_background,
 693                    cx,
 694                );
 695            });
 696            if is_new_search && self.query_editor.is_focused(cx) {
 697                self.focus_results_editor(cx);
 698            }
 699        }
 700
 701        cx.emit(ViewEvent::UpdateTab);
 702        cx.notify();
 703    }
 704
 705    fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
 706        let results_editor = self.results_editor.read(cx);
 707        let new_index = active_match_index(
 708            &self.model.read(cx).match_ranges,
 709            &results_editor.selections.newest_anchor().head(),
 710            &results_editor.buffer().read(cx).snapshot(cx),
 711        );
 712        if self.active_match_index != new_index {
 713            self.active_match_index = new_index;
 714            cx.notify();
 715        }
 716    }
 717
 718    pub fn has_matches(&self) -> bool {
 719        self.active_match_index.is_some()
 720    }
 721}
 722
 723impl Default for ProjectSearchBar {
 724    fn default() -> Self {
 725        Self::new()
 726    }
 727}
 728
 729impl ProjectSearchBar {
 730    pub fn new() -> Self {
 731        Self {
 732            active_project_search: Default::default(),
 733            subscription: Default::default(),
 734        }
 735    }
 736
 737    fn search(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
 738        if let Some(search_view) = self.active_project_search.as_ref() {
 739            search_view.update(cx, |search_view, cx| search_view.search(cx));
 740        }
 741    }
 742
 743    fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
 744        if let Some(search_view) = workspace
 745            .active_item(cx)
 746            .and_then(|item| item.downcast::<ProjectSearchView>())
 747        {
 748            let new_query = search_view.update(cx, |search_view, cx| {
 749                let new_query = search_view.build_search_query(cx);
 750                if new_query.is_some() {
 751                    if let Some(old_query) = search_view.model.read(cx).active_query.clone() {
 752                        search_view.query_editor.update(cx, |editor, cx| {
 753                            editor.set_text(old_query.as_str(), cx);
 754                        });
 755                        search_view.regex = old_query.is_regex();
 756                        search_view.whole_word = old_query.whole_word();
 757                        search_view.case_sensitive = old_query.case_sensitive();
 758                    }
 759                }
 760                new_query
 761            });
 762            if let Some(new_query) = new_query {
 763                let model = cx.add_model(|cx| {
 764                    let mut model = ProjectSearch::new(workspace.project().clone(), cx);
 765                    model.search(new_query, cx);
 766                    model
 767                });
 768                workspace.add_item(
 769                    Box::new(cx.add_view(|cx| ProjectSearchView::new(model, cx))),
 770                    cx,
 771                );
 772            }
 773        }
 774    }
 775
 776    fn select_next_match(pane: &mut Pane, _: &SelectNextMatch, cx: &mut ViewContext<Pane>) {
 777        if let Some(search_view) = pane
 778            .active_item()
 779            .and_then(|item| item.downcast::<ProjectSearchView>())
 780        {
 781            search_view.update(cx, |view, cx| view.select_match(Direction::Next, cx));
 782        } else {
 783            cx.propagate_action();
 784        }
 785    }
 786
 787    fn select_prev_match(pane: &mut Pane, _: &SelectPrevMatch, cx: &mut ViewContext<Pane>) {
 788        if let Some(search_view) = pane
 789            .active_item()
 790            .and_then(|item| item.downcast::<ProjectSearchView>())
 791        {
 792            search_view.update(cx, |view, cx| view.select_match(Direction::Prev, cx));
 793        } else {
 794            cx.propagate_action();
 795        }
 796    }
 797
 798    fn move_focus_to_results(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext<Pane>) {
 799        if let Some(search_view) = pane
 800            .active_item()
 801            .and_then(|item| item.downcast::<ProjectSearchView>())
 802        {
 803            search_view.update(cx, |search_view, cx| {
 804                if search_view.query_editor.is_focused(cx)
 805                    && !search_view.model.read(cx).match_ranges.is_empty()
 806                {
 807                    search_view.focus_results_editor(cx);
 808                }
 809            });
 810        } else {
 811            cx.propagate_action();
 812        }
 813    }
 814
 815    fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
 816        self.cycle_field(Direction::Next, cx);
 817    }
 818
 819    fn tab_previous(&mut self, _: &editor::TabPrev, cx: &mut ViewContext<Self>) {
 820        self.cycle_field(Direction::Prev, cx);
 821    }
 822
 823    fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
 824        let active_project_search = match &self.active_project_search {
 825            Some(active_project_search) => active_project_search,
 826
 827            None => {
 828                cx.propagate_action();
 829                return;
 830            }
 831        };
 832
 833        active_project_search.update(cx, |project_view, cx| {
 834            let views = &[
 835                &project_view.query_editor,
 836                &project_view.included_files_editor,
 837                &project_view.excluded_files_editor,
 838            ];
 839
 840            let current_index = match views
 841                .iter()
 842                .enumerate()
 843                .find(|(_, view)| view.is_focused(cx))
 844            {
 845                Some((index, _)) => index,
 846
 847                None => {
 848                    cx.propagate_action();
 849                    return;
 850                }
 851            };
 852
 853            let new_index = match direction {
 854                Direction::Next => (current_index + 1) % views.len(),
 855                Direction::Prev if current_index == 0 => views.len() - 1,
 856                Direction::Prev => (current_index - 1) % views.len(),
 857            };
 858            cx.focus(views[new_index]);
 859        });
 860    }
 861
 862    fn toggle_search_option(&mut self, option: SearchOption, cx: &mut ViewContext<Self>) -> bool {
 863        if let Some(search_view) = self.active_project_search.as_ref() {
 864            search_view.update(cx, |search_view, cx| {
 865                let value = match option {
 866                    SearchOption::WholeWord => &mut search_view.whole_word,
 867                    SearchOption::CaseSensitive => &mut search_view.case_sensitive,
 868                    SearchOption::Regex => &mut search_view.regex,
 869                };
 870                *value = !*value;
 871                search_view.search(cx);
 872            });
 873            cx.notify();
 874            true
 875        } else {
 876            false
 877        }
 878    }
 879
 880    fn render_nav_button(
 881        &self,
 882        icon: &'static str,
 883        direction: Direction,
 884        cx: &mut ViewContext<Self>,
 885    ) -> AnyElement<Self> {
 886        let action: Box<dyn Action>;
 887        let tooltip;
 888        match direction {
 889            Direction::Prev => {
 890                action = Box::new(SelectPrevMatch);
 891                tooltip = "Select Previous Match";
 892            }
 893            Direction::Next => {
 894                action = Box::new(SelectNextMatch);
 895                tooltip = "Select Next Match";
 896            }
 897        };
 898        let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
 899
 900        enum NavButton {}
 901        MouseEventHandler::<NavButton, _>::new(direction as usize, cx, |state, cx| {
 902            let style = &cx
 903                .global::<Settings>()
 904                .theme
 905                .search
 906                .option_button
 907                .style_for(state, false);
 908            Label::new(icon, style.text.clone())
 909                .contained()
 910                .with_style(style.container)
 911        })
 912        .on_click(MouseButton::Left, move |_, this, cx| {
 913            if let Some(search) = this.active_project_search.as_ref() {
 914                search.update(cx, |search, cx| search.select_match(direction, cx));
 915            }
 916        })
 917        .with_cursor_style(CursorStyle::PointingHand)
 918        .with_tooltip::<NavButton>(
 919            direction as usize,
 920            tooltip.to_string(),
 921            Some(action),
 922            tooltip_style,
 923            cx,
 924        )
 925        .into_any()
 926    }
 927
 928    fn render_option_button(
 929        &self,
 930        icon: &'static str,
 931        option: SearchOption,
 932        cx: &mut ViewContext<Self>,
 933    ) -> AnyElement<Self> {
 934        let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
 935        let is_active = self.is_option_enabled(option, cx);
 936        MouseEventHandler::<Self, _>::new(option as usize, cx, |state, cx| {
 937            let style = &cx
 938                .global::<Settings>()
 939                .theme
 940                .search
 941                .option_button
 942                .style_for(state, is_active);
 943            Label::new(icon, style.text.clone())
 944                .contained()
 945                .with_style(style.container)
 946        })
 947        .on_click(MouseButton::Left, move |_, this, cx| {
 948            this.toggle_search_option(option, cx);
 949        })
 950        .with_cursor_style(CursorStyle::PointingHand)
 951        .with_tooltip::<Self>(
 952            option as usize,
 953            format!("Toggle {}", option.label()),
 954            Some(option.to_toggle_action()),
 955            tooltip_style,
 956            cx,
 957        )
 958        .into_any()
 959    }
 960
 961    fn is_option_enabled(&self, option: SearchOption, cx: &AppContext) -> bool {
 962        if let Some(search) = self.active_project_search.as_ref() {
 963            let search = search.read(cx);
 964            match option {
 965                SearchOption::WholeWord => search.whole_word,
 966                SearchOption::CaseSensitive => search.case_sensitive,
 967                SearchOption::Regex => search.regex,
 968            }
 969        } else {
 970            false
 971        }
 972    }
 973}
 974
 975impl Entity for ProjectSearchBar {
 976    type Event = ();
 977}
 978
 979impl View for ProjectSearchBar {
 980    fn ui_name() -> &'static str {
 981        "ProjectSearchBar"
 982    }
 983
 984    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
 985        if let Some(search) = self.active_project_search.as_ref() {
 986            let search = search.read(cx);
 987            let theme = cx.global::<Settings>().theme.clone();
 988            let query_container_style = if search.panels_with_errors.contains(&InputPanel::Query) {
 989                theme.search.invalid_editor
 990            } else {
 991                theme.search.editor.input.container
 992            };
 993            let include_container_style =
 994                if search.panels_with_errors.contains(&InputPanel::Include) {
 995                    theme.search.invalid_include_exclude_editor
 996                } else {
 997                    theme.search.include_exclude_editor.input.container
 998                };
 999            let exclude_container_style =
1000                if search.panels_with_errors.contains(&InputPanel::Exclude) {
1001                    theme.search.invalid_include_exclude_editor
1002                } else {
1003                    theme.search.include_exclude_editor.input.container
1004                };
1005
1006            let included_files_view = ChildView::new(&search.included_files_editor, cx)
1007                .aligned()
1008                .left()
1009                .flex(1.0, true);
1010            let excluded_files_view = ChildView::new(&search.excluded_files_editor, cx)
1011                .aligned()
1012                .right()
1013                .flex(1.0, true);
1014
1015            let row_spacing = theme.workspace.toolbar.container.padding.bottom;
1016
1017            Flex::column()
1018                .with_child(
1019                    Flex::row()
1020                        .with_child(
1021                            Flex::row()
1022                                .with_child(
1023                                    ChildView::new(&search.query_editor, cx)
1024                                        .aligned()
1025                                        .left()
1026                                        .flex(1., true),
1027                                )
1028                                .with_children(search.active_match_index.map(|match_ix| {
1029                                    Label::new(
1030                                        format!(
1031                                            "{}/{}",
1032                                            match_ix + 1,
1033                                            search.model.read(cx).match_ranges.len()
1034                                        ),
1035                                        theme.search.match_index.text.clone(),
1036                                    )
1037                                    .contained()
1038                                    .with_style(theme.search.match_index.container)
1039                                    .aligned()
1040                                }))
1041                                .contained()
1042                                .with_style(query_container_style)
1043                                .aligned()
1044                                .constrained()
1045                                .with_min_width(theme.search.editor.min_width)
1046                                .with_max_width(theme.search.editor.max_width)
1047                                .flex(1., false),
1048                        )
1049                        .with_child(
1050                            Flex::row()
1051                                .with_child(self.render_nav_button("<", Direction::Prev, cx))
1052                                .with_child(self.render_nav_button(">", Direction::Next, cx))
1053                                .aligned(),
1054                        )
1055                        .with_child(
1056                            Flex::row()
1057                                .with_child(self.render_option_button(
1058                                    "Case",
1059                                    SearchOption::CaseSensitive,
1060                                    cx,
1061                                ))
1062                                .with_child(self.render_option_button(
1063                                    "Word",
1064                                    SearchOption::WholeWord,
1065                                    cx,
1066                                ))
1067                                .with_child(self.render_option_button(
1068                                    "Regex",
1069                                    SearchOption::Regex,
1070                                    cx,
1071                                ))
1072                                .contained()
1073                                .with_style(theme.search.option_button_group)
1074                                .aligned(),
1075                        )
1076                        .contained()
1077                        .with_margin_bottom(row_spacing),
1078                )
1079                .with_child(
1080                    Flex::row()
1081                        .with_child(
1082                            Flex::row()
1083                                .with_child(included_files_view)
1084                                .contained()
1085                                .with_style(include_container_style)
1086                                .aligned()
1087                                .constrained()
1088                                .with_min_width(theme.search.include_exclude_editor.min_width)
1089                                .with_max_width(theme.search.include_exclude_editor.max_width)
1090                                .flex(1., false),
1091                        )
1092                        .with_child(
1093                            Flex::row()
1094                                .with_child(excluded_files_view)
1095                                .contained()
1096                                .with_style(exclude_container_style)
1097                                .aligned()
1098                                .constrained()
1099                                .with_min_width(theme.search.include_exclude_editor.min_width)
1100                                .with_max_width(theme.search.include_exclude_editor.max_width)
1101                                .flex(1., false),
1102                        ),
1103                )
1104                .contained()
1105                .with_style(theme.search.container)
1106                .aligned()
1107                .left()
1108                .into_any_named("project search")
1109        } else {
1110            Empty::new().into_any()
1111        }
1112    }
1113}
1114
1115impl ToolbarItemView for ProjectSearchBar {
1116    fn set_active_pane_item(
1117        &mut self,
1118        active_pane_item: Option<&dyn ItemHandle>,
1119        cx: &mut ViewContext<Self>,
1120    ) -> ToolbarItemLocation {
1121        cx.notify();
1122        self.subscription = None;
1123        self.active_project_search = None;
1124        if let Some(search) = active_pane_item.and_then(|i| i.downcast::<ProjectSearchView>()) {
1125            self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify()));
1126            self.active_project_search = Some(search);
1127            ToolbarItemLocation::PrimaryLeft {
1128                flex: Some((1., false)),
1129            }
1130        } else {
1131            ToolbarItemLocation::Hidden
1132        }
1133    }
1134
1135    fn row_count(&self) -> usize {
1136        2
1137    }
1138}
1139
1140#[cfg(test)]
1141mod tests {
1142    use super::*;
1143    use editor::DisplayPoint;
1144    use gpui::{color::Color, executor::Deterministic, TestAppContext};
1145    use project::FakeFs;
1146    use serde_json::json;
1147    use std::sync::Arc;
1148
1149    #[gpui::test]
1150    async fn test_project_search(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
1151        let fonts = cx.font_cache();
1152        let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default);
1153        theme.search.match_background = Color::red();
1154        cx.update(|cx| {
1155            let mut settings = Settings::test(cx);
1156            settings.theme = Arc::new(theme);
1157            cx.set_global(settings);
1158            cx.set_global(ActiveSearches::default());
1159        });
1160
1161        let fs = FakeFs::new(cx.background());
1162        fs.insert_tree(
1163            "/dir",
1164            json!({
1165                "one.rs": "const ONE: usize = 1;",
1166                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
1167                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
1168                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
1169            }),
1170        )
1171        .await;
1172        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
1173        let search = cx.add_model(|cx| ProjectSearch::new(project, cx));
1174        let (_, search_view) = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx));
1175
1176        search_view.update(cx, |search_view, cx| {
1177            search_view
1178                .query_editor
1179                .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
1180            search_view.search(cx);
1181        });
1182        deterministic.run_until_parked();
1183        search_view.update(cx, |search_view, cx| {
1184            assert_eq!(
1185                search_view
1186                    .results_editor
1187                    .update(cx, |editor, cx| editor.display_text(cx)),
1188                "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
1189            );
1190            assert_eq!(
1191                search_view
1192                    .results_editor
1193                    .update(cx, |editor, cx| editor.all_background_highlights(cx)),
1194                &[
1195                    (
1196                        DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35),
1197                        Color::red()
1198                    ),
1199                    (
1200                        DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40),
1201                        Color::red()
1202                    ),
1203                    (
1204                        DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9),
1205                        Color::red()
1206                    )
1207                ]
1208            );
1209            assert_eq!(search_view.active_match_index, Some(0));
1210            assert_eq!(
1211                search_view
1212                    .results_editor
1213                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1214                [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1215            );
1216
1217            search_view.select_match(Direction::Next, cx);
1218        });
1219
1220        search_view.update(cx, |search_view, cx| {
1221            assert_eq!(search_view.active_match_index, Some(1));
1222            assert_eq!(
1223                search_view
1224                    .results_editor
1225                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1226                [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1227            );
1228            search_view.select_match(Direction::Next, cx);
1229        });
1230
1231        search_view.update(cx, |search_view, cx| {
1232            assert_eq!(search_view.active_match_index, Some(2));
1233            assert_eq!(
1234                search_view
1235                    .results_editor
1236                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1237                [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1238            );
1239            search_view.select_match(Direction::Next, cx);
1240        });
1241
1242        search_view.update(cx, |search_view, cx| {
1243            assert_eq!(search_view.active_match_index, Some(0));
1244            assert_eq!(
1245                search_view
1246                    .results_editor
1247                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1248                [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)]
1249            );
1250            search_view.select_match(Direction::Prev, cx);
1251        });
1252
1253        search_view.update(cx, |search_view, cx| {
1254            assert_eq!(search_view.active_match_index, Some(2));
1255            assert_eq!(
1256                search_view
1257                    .results_editor
1258                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1259                [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)]
1260            );
1261            search_view.select_match(Direction::Prev, cx);
1262        });
1263
1264        search_view.update(cx, |search_view, cx| {
1265            assert_eq!(search_view.active_match_index, Some(1));
1266            assert_eq!(
1267                search_view
1268                    .results_editor
1269                    .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
1270                [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)]
1271            );
1272        });
1273    }
1274}