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