project_search.rs

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