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