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