project_search.rs

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