file_finder.rs

   1use collections::HashMap;
   2use editor::{scroll::autoscroll::Autoscroll, Bias, Editor};
   3use fuzzy::{CharBag, PathMatch, PathMatchCandidate};
   4use gpui::{
   5    actions, rems, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model,
   6    ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, WeakView,
   7};
   8use picker::{Picker, PickerDelegate};
   9use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
  10use std::{
  11    path::{Path, PathBuf},
  12    sync::{
  13        atomic::{self, AtomicBool},
  14        Arc,
  15    },
  16};
  17use text::Point;
  18use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
  19use util::{paths::PathLikeWithPosition, post_inc, ResultExt};
  20use workspace::{ModalView, Workspace};
  21
  22actions!(file_finder, [Toggle]);
  23
  24impl ModalView for FileFinder {}
  25
  26pub struct FileFinder {
  27    picker: View<Picker<FileFinderDelegate>>,
  28}
  29
  30pub fn init(cx: &mut AppContext) {
  31    cx.observe_new_views(FileFinder::register).detach();
  32}
  33
  34impl FileFinder {
  35    fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
  36        workspace.register_action(|workspace, _: &Toggle, cx| {
  37            let Some(file_finder) = workspace.active_modal::<Self>(cx) else {
  38                Self::open(workspace, cx);
  39                return;
  40            };
  41
  42            file_finder.update(cx, |file_finder, cx| {
  43                file_finder
  44                    .picker
  45                    .update(cx, |picker, cx| picker.cycle_selection(cx))
  46            });
  47        });
  48    }
  49
  50    fn open(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
  51        let project = workspace.project().read(cx);
  52
  53        let currently_opened_path = workspace
  54            .active_item(cx)
  55            .and_then(|item| item.project_path(cx))
  56            .map(|project_path| {
  57                let abs_path = project
  58                    .worktree_for_id(project_path.worktree_id, cx)
  59                    .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path));
  60                FoundPath::new(project_path, abs_path)
  61            });
  62
  63        // if exists, bubble the currently opened path to the top
  64        let history_items = currently_opened_path
  65            .clone()
  66            .into_iter()
  67            .chain(
  68                workspace
  69                    .recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx)
  70                    .into_iter()
  71                    .filter(|(history_path, _)| {
  72                        Some(history_path)
  73                            != currently_opened_path
  74                                .as_ref()
  75                                .map(|found_path| &found_path.project)
  76                    })
  77                    .filter(|(_, history_abs_path)| {
  78                        history_abs_path.as_ref()
  79                            != currently_opened_path
  80                                .as_ref()
  81                                .and_then(|found_path| found_path.absolute.as_ref())
  82                    })
  83                    .filter(|(_, history_abs_path)| match history_abs_path {
  84                        Some(abs_path) => history_file_exists(abs_path),
  85                        None => true,
  86                    })
  87                    .map(|(history_path, abs_path)| FoundPath::new(history_path, abs_path)),
  88            )
  89            .collect();
  90
  91        let project = workspace.project().clone();
  92        let weak_workspace = cx.view().downgrade();
  93        workspace.toggle_modal(cx, |cx| {
  94            let delegate = FileFinderDelegate::new(
  95                cx.view().downgrade(),
  96                weak_workspace,
  97                project,
  98                currently_opened_path,
  99                history_items,
 100                cx,
 101            );
 102
 103            FileFinder::new(delegate, cx)
 104        });
 105    }
 106
 107    fn new(delegate: FileFinderDelegate, cx: &mut ViewContext<Self>) -> Self {
 108        Self {
 109            picker: cx.new_view(|cx| Picker::new(delegate, cx)),
 110        }
 111    }
 112}
 113
 114impl EventEmitter<DismissEvent> for FileFinder {}
 115impl FocusableView for FileFinder {
 116    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
 117        self.picker.focus_handle(cx)
 118    }
 119}
 120impl Render for FileFinder {
 121    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
 122        v_stack().w(rems(34.)).child(self.picker.clone())
 123    }
 124}
 125
 126pub struct FileFinderDelegate {
 127    file_finder: WeakView<FileFinder>,
 128    workspace: WeakView<Workspace>,
 129    project: Model<Project>,
 130    search_count: usize,
 131    latest_search_id: usize,
 132    latest_search_did_cancel: bool,
 133    latest_search_query: Option<PathLikeWithPosition<FileSearchQuery>>,
 134    currently_opened_path: Option<FoundPath>,
 135    matches: Matches,
 136    selected_index: Option<usize>,
 137    cancel_flag: Arc<AtomicBool>,
 138    history_items: Vec<FoundPath>,
 139}
 140
 141#[derive(Debug, Default)]
 142struct Matches {
 143    history: Vec<(FoundPath, Option<PathMatch>)>,
 144    search: Vec<PathMatch>,
 145}
 146
 147#[derive(Debug)]
 148enum Match<'a> {
 149    History(&'a FoundPath, Option<&'a PathMatch>),
 150    Search(&'a PathMatch),
 151}
 152
 153impl Matches {
 154    fn len(&self) -> usize {
 155        self.history.len() + self.search.len()
 156    }
 157
 158    fn get(&self, index: usize) -> Option<Match<'_>> {
 159        if index < self.history.len() {
 160            self.history
 161                .get(index)
 162                .map(|(path, path_match)| Match::History(path, path_match.as_ref()))
 163        } else {
 164            self.search
 165                .get(index - self.history.len())
 166                .map(Match::Search)
 167        }
 168    }
 169
 170    fn push_new_matches(
 171        &mut self,
 172        history_items: &Vec<FoundPath>,
 173        query: &PathLikeWithPosition<FileSearchQuery>,
 174        mut new_search_matches: Vec<PathMatch>,
 175        extend_old_matches: bool,
 176    ) {
 177        let matching_history_paths = matching_history_item_paths(history_items, query);
 178        new_search_matches
 179            .retain(|path_match| !matching_history_paths.contains_key(&path_match.path));
 180        let history_items_to_show = history_items
 181            .iter()
 182            .filter_map(|history_item| {
 183                Some((
 184                    history_item.clone(),
 185                    Some(
 186                        matching_history_paths
 187                            .get(&history_item.project.path)?
 188                            .clone(),
 189                    ),
 190                ))
 191            })
 192            .collect::<Vec<_>>();
 193        self.history = history_items_to_show;
 194        if extend_old_matches {
 195            self.search
 196                .retain(|path_match| !matching_history_paths.contains_key(&path_match.path));
 197            util::extend_sorted(
 198                &mut self.search,
 199                new_search_matches.into_iter(),
 200                100,
 201                |a, b| b.cmp(a),
 202            )
 203        } else {
 204            self.search = new_search_matches;
 205        }
 206    }
 207}
 208
 209fn matching_history_item_paths(
 210    history_items: &Vec<FoundPath>,
 211    query: &PathLikeWithPosition<FileSearchQuery>,
 212) -> HashMap<Arc<Path>, PathMatch> {
 213    let history_items_by_worktrees = history_items
 214        .iter()
 215        .filter_map(|found_path| {
 216            let candidate = PathMatchCandidate {
 217                path: &found_path.project.path,
 218                // Only match history items names, otherwise their paths may match too many queries, producing false positives.
 219                // E.g. `foo` would match both `something/foo/bar.rs` and `something/foo/foo.rs` and if the former is a history item,
 220                // it would be shown first always, despite the latter being a better match.
 221                char_bag: CharBag::from_iter(
 222                    found_path
 223                        .project
 224                        .path
 225                        .file_name()?
 226                        .to_string_lossy()
 227                        .to_lowercase()
 228                        .chars(),
 229                ),
 230            };
 231            Some((found_path.project.worktree_id, candidate))
 232        })
 233        .fold(
 234            HashMap::default(),
 235            |mut candidates, (worktree_id, new_candidate)| {
 236                candidates
 237                    .entry(worktree_id)
 238                    .or_insert_with(Vec::new)
 239                    .push(new_candidate);
 240                candidates
 241            },
 242        );
 243    let mut matching_history_paths = HashMap::default();
 244    for (worktree, candidates) in history_items_by_worktrees {
 245        let max_results = candidates.len() + 1;
 246        matching_history_paths.extend(
 247            fuzzy::match_fixed_path_set(
 248                candidates,
 249                worktree.to_usize(),
 250                query.path_like.path_query(),
 251                false,
 252                max_results,
 253            )
 254            .into_iter()
 255            .map(|path_match| (Arc::clone(&path_match.path), path_match)),
 256        );
 257    }
 258    matching_history_paths
 259}
 260
 261#[derive(Debug, Clone, PartialEq, Eq)]
 262struct FoundPath {
 263    project: ProjectPath,
 264    absolute: Option<PathBuf>,
 265}
 266
 267impl FoundPath {
 268    fn new(project: ProjectPath, absolute: Option<PathBuf>) -> Self {
 269        Self { project, absolute }
 270    }
 271}
 272
 273const MAX_RECENT_SELECTIONS: usize = 20;
 274
 275#[cfg(not(test))]
 276fn history_file_exists(abs_path: &PathBuf) -> bool {
 277    abs_path.exists()
 278}
 279
 280#[cfg(test)]
 281fn history_file_exists(abs_path: &PathBuf) -> bool {
 282    !abs_path.ends_with("nonexistent.rs")
 283}
 284
 285pub enum Event {
 286    Selected(ProjectPath),
 287    Dismissed,
 288}
 289
 290#[derive(Debug, Clone)]
 291struct FileSearchQuery {
 292    raw_query: String,
 293    file_query_end: Option<usize>,
 294}
 295
 296impl FileSearchQuery {
 297    fn path_query(&self) -> &str {
 298        match self.file_query_end {
 299            Some(file_path_end) => &self.raw_query[..file_path_end],
 300            None => &self.raw_query,
 301        }
 302    }
 303}
 304
 305impl FileFinderDelegate {
 306    fn new(
 307        file_finder: WeakView<FileFinder>,
 308        workspace: WeakView<Workspace>,
 309        project: Model<Project>,
 310        currently_opened_path: Option<FoundPath>,
 311        history_items: Vec<FoundPath>,
 312        cx: &mut ViewContext<FileFinder>,
 313    ) -> Self {
 314        cx.observe(&project, |file_finder, _, cx| {
 315            //todo We should probably not re-render on every project anything
 316            file_finder
 317                .picker
 318                .update(cx, |picker, cx| picker.refresh(cx))
 319        })
 320        .detach();
 321
 322        Self {
 323            file_finder,
 324            workspace,
 325            project,
 326            search_count: 0,
 327            latest_search_id: 0,
 328            latest_search_did_cancel: false,
 329            latest_search_query: None,
 330            currently_opened_path,
 331            matches: Matches::default(),
 332            selected_index: None,
 333            cancel_flag: Arc::new(AtomicBool::new(false)),
 334            history_items,
 335        }
 336    }
 337
 338    fn spawn_search(
 339        &mut self,
 340        query: PathLikeWithPosition<FileSearchQuery>,
 341        cx: &mut ViewContext<Picker<Self>>,
 342    ) -> Task<()> {
 343        let relative_to = self
 344            .currently_opened_path
 345            .as_ref()
 346            .map(|found_path| Arc::clone(&found_path.project.path));
 347        let worktrees = self
 348            .project
 349            .read(cx)
 350            .visible_worktrees(cx)
 351            .collect::<Vec<_>>();
 352        let include_root_name = worktrees.len() > 1;
 353        let candidate_sets = worktrees
 354            .into_iter()
 355            .map(|worktree| PathMatchCandidateSet {
 356                snapshot: worktree.read(cx).snapshot(),
 357                include_ignored: true,
 358                include_root_name,
 359            })
 360            .collect::<Vec<_>>();
 361
 362        let search_id = util::post_inc(&mut self.search_count);
 363        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
 364        self.cancel_flag = Arc::new(AtomicBool::new(false));
 365        let cancel_flag = self.cancel_flag.clone();
 366        cx.spawn(|picker, mut cx| async move {
 367            let matches = fuzzy::match_path_sets(
 368                candidate_sets.as_slice(),
 369                query.path_like.path_query(),
 370                relative_to,
 371                false,
 372                100,
 373                &cancel_flag,
 374                cx.background_executor().clone(),
 375            )
 376            .await;
 377            let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
 378            picker
 379                .update(&mut cx, |picker, cx| {
 380                    picker
 381                        .delegate
 382                        .set_search_matches(search_id, did_cancel, query, matches, cx)
 383                })
 384                .log_err();
 385        })
 386    }
 387
 388    fn set_search_matches(
 389        &mut self,
 390        search_id: usize,
 391        did_cancel: bool,
 392        query: PathLikeWithPosition<FileSearchQuery>,
 393        matches: Vec<PathMatch>,
 394        cx: &mut ViewContext<Picker<Self>>,
 395    ) {
 396        if search_id >= self.latest_search_id {
 397            self.latest_search_id = search_id;
 398            let extend_old_matches = self.latest_search_did_cancel
 399                && Some(query.path_like.path_query())
 400                    == self
 401                        .latest_search_query
 402                        .as_ref()
 403                        .map(|query| query.path_like.path_query());
 404            self.matches
 405                .push_new_matches(&self.history_items, &query, matches, extend_old_matches);
 406            self.latest_search_query = Some(query);
 407            self.latest_search_did_cancel = did_cancel;
 408            cx.notify();
 409        }
 410    }
 411
 412    fn labels_for_match(
 413        &self,
 414        path_match: Match,
 415        cx: &AppContext,
 416        ix: usize,
 417    ) -> (String, Vec<usize>, String, Vec<usize>) {
 418        let (file_name, file_name_positions, full_path, full_path_positions) = match path_match {
 419            Match::History(found_path, found_path_match) => {
 420                let worktree_id = found_path.project.worktree_id;
 421                let project_relative_path = &found_path.project.path;
 422                let has_worktree = self
 423                    .project
 424                    .read(cx)
 425                    .worktree_for_id(worktree_id, cx)
 426                    .is_some();
 427
 428                if !has_worktree {
 429                    if let Some(absolute_path) = &found_path.absolute {
 430                        return (
 431                            absolute_path
 432                                .file_name()
 433                                .map_or_else(
 434                                    || project_relative_path.to_string_lossy(),
 435                                    |file_name| file_name.to_string_lossy(),
 436                                )
 437                                .to_string(),
 438                            Vec::new(),
 439                            absolute_path.to_string_lossy().to_string(),
 440                            Vec::new(),
 441                        );
 442                    }
 443                }
 444
 445                let mut path = Arc::clone(project_relative_path);
 446                if project_relative_path.as_ref() == Path::new("") {
 447                    if let Some(absolute_path) = &found_path.absolute {
 448                        path = Arc::from(absolute_path.as_path());
 449                    }
 450                }
 451
 452                let mut path_match = PathMatch {
 453                    score: ix as f64,
 454                    positions: Vec::new(),
 455                    worktree_id: worktree_id.to_usize(),
 456                    path,
 457                    path_prefix: "".into(),
 458                    distance_to_relative_ancestor: usize::MAX,
 459                };
 460                if let Some(found_path_match) = found_path_match {
 461                    path_match
 462                        .positions
 463                        .extend(found_path_match.positions.iter())
 464                }
 465
 466                self.labels_for_path_match(&path_match)
 467            }
 468            Match::Search(path_match) => self.labels_for_path_match(path_match),
 469        };
 470
 471        if file_name_positions.is_empty() {
 472            if let Some(user_home_path) = std::env::var("HOME").ok() {
 473                let user_home_path = user_home_path.trim();
 474                if !user_home_path.is_empty() {
 475                    if (&full_path).starts_with(user_home_path) {
 476                        return (
 477                            file_name,
 478                            file_name_positions,
 479                            full_path.replace(user_home_path, "~"),
 480                            full_path_positions,
 481                        );
 482                    }
 483                }
 484            }
 485        }
 486
 487        (
 488            file_name,
 489            file_name_positions,
 490            full_path,
 491            full_path_positions,
 492        )
 493    }
 494
 495    fn labels_for_path_match(
 496        &self,
 497        path_match: &PathMatch,
 498    ) -> (String, Vec<usize>, String, Vec<usize>) {
 499        let path = &path_match.path;
 500        let path_string = path.to_string_lossy();
 501        let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
 502        let path_positions = path_match.positions.clone();
 503
 504        let file_name = path.file_name().map_or_else(
 505            || path_match.path_prefix.to_string(),
 506            |file_name| file_name.to_string_lossy().to_string(),
 507        );
 508        let file_name_start = path_match.path_prefix.len() + path_string.len() - file_name.len();
 509        let file_name_positions = path_positions
 510            .iter()
 511            .filter_map(|pos| {
 512                if pos >= &file_name_start {
 513                    Some(pos - file_name_start)
 514                } else {
 515                    None
 516                }
 517            })
 518            .collect();
 519
 520        (file_name, file_name_positions, full_path, path_positions)
 521    }
 522
 523    fn lookup_absolute_path(
 524        &self,
 525        query: PathLikeWithPosition<FileSearchQuery>,
 526        cx: &mut ViewContext<'_, Picker<Self>>,
 527    ) -> Task<()> {
 528        cx.spawn(|picker, mut cx| async move {
 529            let Some((project, fs)) = picker
 530                .update(&mut cx, |picker, cx| {
 531                    let fs = Arc::clone(&picker.delegate.project.read(cx).fs());
 532                    (picker.delegate.project.clone(), fs)
 533                })
 534                .log_err()
 535            else {
 536                return;
 537            };
 538
 539            let query_path = Path::new(query.path_like.path_query());
 540            let mut path_matches = Vec::new();
 541            match fs.metadata(query_path).await.log_err() {
 542                Some(Some(_metadata)) => {
 543                    let update_result = project
 544                        .update(&mut cx, |project, cx| {
 545                            if let Some((worktree, relative_path)) =
 546                                project.find_local_worktree(query_path, cx)
 547                            {
 548                                path_matches.push(PathMatch {
 549                                    score: 0.0,
 550                                    positions: Vec::new(),
 551                                    worktree_id: worktree.read(cx).id().to_usize(),
 552                                    path: Arc::from(relative_path),
 553                                    path_prefix: "".into(),
 554                                    distance_to_relative_ancestor: usize::MAX,
 555                                });
 556                            }
 557                        })
 558                        .log_err();
 559                    if update_result.is_none() {
 560                        return;
 561                    }
 562                }
 563                Some(None) => {}
 564                None => return,
 565            }
 566
 567            picker
 568                .update(&mut cx, |picker, cx| {
 569                    let picker_delegate = &mut picker.delegate;
 570                    let search_id = util::post_inc(&mut picker_delegate.search_count);
 571                    picker_delegate.set_search_matches(search_id, false, query, path_matches, cx);
 572
 573                    anyhow::Ok(())
 574                })
 575                .log_err();
 576        })
 577    }
 578}
 579
 580impl PickerDelegate for FileFinderDelegate {
 581    type ListItem = ListItem;
 582
 583    fn placeholder_text(&self) -> Arc<str> {
 584        "Search project files...".into()
 585    }
 586
 587    fn match_count(&self) -> usize {
 588        self.matches.len()
 589    }
 590
 591    fn selected_index(&self) -> usize {
 592        self.selected_index.unwrap_or(0)
 593    }
 594
 595    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
 596        self.selected_index = Some(ix);
 597        cx.notify();
 598    }
 599
 600    fn separators_after_indices(&self) -> Vec<usize> {
 601        let history_items = self.matches.history.len();
 602        if history_items == 0 || self.matches.search.is_empty() {
 603            Vec::new()
 604        } else {
 605            vec![history_items - 1]
 606        }
 607    }
 608
 609    fn update_matches(
 610        &mut self,
 611        raw_query: String,
 612        cx: &mut ViewContext<Picker<Self>>,
 613    ) -> Task<()> {
 614        let raw_query = raw_query.trim();
 615        if raw_query.is_empty() {
 616            let project = self.project.read(cx);
 617            self.latest_search_id = post_inc(&mut self.search_count);
 618            self.matches = Matches {
 619                history: self
 620                    .history_items
 621                    .iter()
 622                    .filter(|history_item| {
 623                        project
 624                            .worktree_for_id(history_item.project.worktree_id, cx)
 625                            .is_some()
 626                            || (project.is_local() && history_item.absolute.is_some())
 627                    })
 628                    .cloned()
 629                    .map(|p| (p, None))
 630                    .collect(),
 631                search: Vec::new(),
 632            };
 633            cx.notify();
 634            Task::ready(())
 635        } else {
 636            let query = PathLikeWithPosition::parse_str(raw_query, |path_like_str| {
 637                Ok::<_, std::convert::Infallible>(FileSearchQuery {
 638                    raw_query: raw_query.to_owned(),
 639                    file_query_end: if path_like_str == raw_query {
 640                        None
 641                    } else {
 642                        Some(path_like_str.len())
 643                    },
 644                })
 645            })
 646            .expect("infallible");
 647
 648            if Path::new(query.path_like.path_query()).is_absolute() {
 649                self.lookup_absolute_path(query, cx)
 650            } else {
 651                self.spawn_search(query, cx)
 652            }
 653        }
 654    }
 655
 656    fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
 657        if let Some(m) = self.matches.get(self.selected_index()) {
 658            if let Some(workspace) = self.workspace.upgrade() {
 659                let open_task = workspace.update(cx, move |workspace, cx| {
 660                    let split_or_open = |workspace: &mut Workspace, project_path, cx| {
 661                        if secondary {
 662                            workspace.split_path(project_path, cx)
 663                        } else {
 664                            workspace.open_path(project_path, None, true, cx)
 665                        }
 666                    };
 667                    match m {
 668                        Match::History(history_match, _) => {
 669                            let worktree_id = history_match.project.worktree_id;
 670                            if workspace
 671                                .project()
 672                                .read(cx)
 673                                .worktree_for_id(worktree_id, cx)
 674                                .is_some()
 675                            {
 676                                split_or_open(
 677                                    workspace,
 678                                    ProjectPath {
 679                                        worktree_id,
 680                                        path: Arc::clone(&history_match.project.path),
 681                                    },
 682                                    cx,
 683                                )
 684                            } else {
 685                                match history_match.absolute.as_ref() {
 686                                    Some(abs_path) => {
 687                                        if secondary {
 688                                            workspace.split_abs_path(
 689                                                abs_path.to_path_buf(),
 690                                                false,
 691                                                cx,
 692                                            )
 693                                        } else {
 694                                            workspace.open_abs_path(
 695                                                abs_path.to_path_buf(),
 696                                                false,
 697                                                cx,
 698                                            )
 699                                        }
 700                                    }
 701                                    None => split_or_open(
 702                                        workspace,
 703                                        ProjectPath {
 704                                            worktree_id,
 705                                            path: Arc::clone(&history_match.project.path),
 706                                        },
 707                                        cx,
 708                                    ),
 709                                }
 710                            }
 711                        }
 712                        Match::Search(m) => split_or_open(
 713                            workspace,
 714                            ProjectPath {
 715                                worktree_id: WorktreeId::from_usize(m.worktree_id),
 716                                path: m.path.clone(),
 717                            },
 718                            cx,
 719                        ),
 720                    }
 721                });
 722
 723                let row = self
 724                    .latest_search_query
 725                    .as_ref()
 726                    .and_then(|query| query.row)
 727                    .map(|row| row.saturating_sub(1));
 728                let col = self
 729                    .latest_search_query
 730                    .as_ref()
 731                    .and_then(|query| query.column)
 732                    .unwrap_or(0)
 733                    .saturating_sub(1);
 734                let finder = self.file_finder.clone();
 735
 736                cx.spawn(|_, mut cx| async move {
 737                    let item = open_task.await.log_err()?;
 738                    if let Some(row) = row {
 739                        if let Some(active_editor) = item.downcast::<Editor>() {
 740                            active_editor
 741                                .downgrade()
 742                                .update(&mut cx, |editor, cx| {
 743                                    let snapshot = editor.snapshot(cx).display_snapshot;
 744                                    let point = snapshot
 745                                        .buffer_snapshot
 746                                        .clip_point(Point::new(row, col), Bias::Left);
 747                                    editor.change_selections(Some(Autoscroll::center()), cx, |s| {
 748                                        s.select_ranges([point..point])
 749                                    });
 750                                })
 751                                .log_err();
 752                        }
 753                    }
 754                    finder.update(&mut cx, |_, cx| cx.emit(DismissEvent)).ok()?;
 755
 756                    Some(())
 757                })
 758                .detach();
 759            }
 760        }
 761    }
 762
 763    fn dismissed(&mut self, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
 764        self.file_finder
 765            .update(cx, |_, cx| cx.emit(DismissEvent))
 766            .log_err();
 767    }
 768
 769    fn render_match(
 770        &self,
 771        ix: usize,
 772        selected: bool,
 773        cx: &mut ViewContext<Picker<Self>>,
 774    ) -> Option<Self::ListItem> {
 775        let path_match = self
 776            .matches
 777            .get(ix)
 778            .expect("Invalid matches state: no element for index {ix}");
 779
 780        let (file_name, file_name_positions, full_path, full_path_positions) =
 781            self.labels_for_match(path_match, cx, ix);
 782
 783        Some(
 784            ListItem::new(ix)
 785                .spacing(ListItemSpacing::Sparse)
 786                .inset(true)
 787                .selected(selected)
 788                .child(
 789                    v_stack()
 790                        .child(HighlightedLabel::new(file_name, file_name_positions))
 791                        .child(HighlightedLabel::new(full_path, full_path_positions)),
 792                ),
 793        )
 794    }
 795}
 796
 797#[cfg(test)]
 798mod tests {
 799    use std::{assert_eq, path::Path, time::Duration};
 800
 801    use super::*;
 802    use editor::Editor;
 803    use gpui::{Entity, TestAppContext, VisualTestContext};
 804    use menu::{Confirm, SelectNext};
 805    use serde_json::json;
 806    use workspace::{AppState, Workspace};
 807
 808    #[ctor::ctor]
 809    fn init_logger() {
 810        if std::env::var("RUST_LOG").is_ok() {
 811            env_logger::init();
 812        }
 813    }
 814
 815    #[gpui::test]
 816    async fn test_matching_paths(cx: &mut TestAppContext) {
 817        let app_state = init_test(cx);
 818        app_state
 819            .fs
 820            .as_fake()
 821            .insert_tree(
 822                "/root",
 823                json!({
 824                    "a": {
 825                        "banana": "",
 826                        "bandana": "",
 827                    }
 828                }),
 829            )
 830            .await;
 831
 832        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
 833
 834        let (picker, workspace, cx) = build_find_picker(project, cx);
 835
 836        cx.simulate_input("bna");
 837        picker.update(cx, |picker, _| {
 838            assert_eq!(picker.delegate.matches.len(), 2);
 839        });
 840        cx.dispatch_action(SelectNext);
 841        cx.dispatch_action(Confirm);
 842        cx.read(|cx| {
 843            let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
 844            assert_eq!(active_editor.read(cx).title(cx), "bandana");
 845        });
 846
 847        for bandana_query in [
 848            "bandana",
 849            " bandana",
 850            "bandana ",
 851            " bandana ",
 852            " ndan ",
 853            " band ",
 854        ] {
 855            picker
 856                .update(cx, |picker, cx| {
 857                    picker
 858                        .delegate
 859                        .update_matches(bandana_query.to_string(), cx)
 860                })
 861                .await;
 862            picker.update(cx, |picker, _| {
 863                assert_eq!(
 864                    picker.delegate.matches.len(),
 865                    1,
 866                    "Wrong number of matches for bandana query '{bandana_query}'"
 867                );
 868            });
 869            cx.dispatch_action(SelectNext);
 870            cx.dispatch_action(Confirm);
 871            cx.read(|cx| {
 872                let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
 873                assert_eq!(
 874                    active_editor.read(cx).title(cx),
 875                    "bandana",
 876                    "Wrong match for bandana query '{bandana_query}'"
 877                );
 878            });
 879        }
 880    }
 881
 882    #[gpui::test]
 883    async fn test_absolute_paths(cx: &mut TestAppContext) {
 884        let app_state = init_test(cx);
 885        app_state
 886            .fs
 887            .as_fake()
 888            .insert_tree(
 889                "/root",
 890                json!({
 891                    "a": {
 892                        "file1.txt": "",
 893                        "b": {
 894                            "file2.txt": "",
 895                        },
 896                    }
 897                }),
 898            )
 899            .await;
 900
 901        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
 902
 903        let (picker, workspace, cx) = build_find_picker(project, cx);
 904
 905        let matching_abs_path = "/root/a/b/file2.txt";
 906        picker
 907            .update(cx, |picker, cx| {
 908                picker
 909                    .delegate
 910                    .update_matches(matching_abs_path.to_string(), cx)
 911            })
 912            .await;
 913        picker.update(cx, |picker, _| {
 914            assert_eq!(
 915                collect_search_results(picker),
 916                vec![PathBuf::from("a/b/file2.txt")],
 917                "Matching abs path should be the only match"
 918            )
 919        });
 920        cx.dispatch_action(SelectNext);
 921        cx.dispatch_action(Confirm);
 922        cx.read(|cx| {
 923            let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
 924            assert_eq!(active_editor.read(cx).title(cx), "file2.txt");
 925        });
 926
 927        let mismatching_abs_path = "/root/a/b/file1.txt";
 928        picker
 929            .update(cx, |picker, cx| {
 930                picker
 931                    .delegate
 932                    .update_matches(mismatching_abs_path.to_string(), cx)
 933            })
 934            .await;
 935        picker.update(cx, |picker, _| {
 936            assert_eq!(
 937                collect_search_results(picker),
 938                Vec::<PathBuf>::new(),
 939                "Mismatching abs path should produce no matches"
 940            )
 941        });
 942    }
 943
 944    #[gpui::test]
 945    async fn test_complex_path(cx: &mut TestAppContext) {
 946        let app_state = init_test(cx);
 947        app_state
 948            .fs
 949            .as_fake()
 950            .insert_tree(
 951                "/root",
 952                json!({
 953                    "其他": {
 954                        "S数据表格": {
 955                            "task.xlsx": "some content",
 956                        },
 957                    }
 958                }),
 959            )
 960            .await;
 961
 962        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
 963
 964        let (picker, workspace, cx) = build_find_picker(project, cx);
 965
 966        cx.simulate_input("t");
 967        picker.update(cx, |picker, _| {
 968            assert_eq!(picker.delegate.matches.len(), 1);
 969            assert_eq!(
 970                collect_search_results(picker),
 971                vec![PathBuf::from("其他/S数据表格/task.xlsx")],
 972            )
 973        });
 974        cx.dispatch_action(SelectNext);
 975        cx.dispatch_action(Confirm);
 976        cx.read(|cx| {
 977            let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
 978            assert_eq!(active_editor.read(cx).title(cx), "task.xlsx");
 979        });
 980    }
 981
 982    #[gpui::test]
 983    async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
 984        let app_state = init_test(cx);
 985
 986        let first_file_name = "first.rs";
 987        let first_file_contents = "// First Rust file";
 988        app_state
 989            .fs
 990            .as_fake()
 991            .insert_tree(
 992                "/src",
 993                json!({
 994                    "test": {
 995                        first_file_name: first_file_contents,
 996                        "second.rs": "// Second Rust file",
 997                    }
 998                }),
 999            )
1000            .await;
1001
1002        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1003
1004        let (picker, workspace, cx) = build_find_picker(project, cx);
1005
1006        let file_query = &first_file_name[..3];
1007        let file_row = 1;
1008        let file_column = 3;
1009        assert!(file_column <= first_file_contents.len());
1010        let query_inside_file = format!("{file_query}:{file_row}:{file_column}");
1011        picker
1012            .update(cx, |finder, cx| {
1013                finder
1014                    .delegate
1015                    .update_matches(query_inside_file.to_string(), cx)
1016            })
1017            .await;
1018        picker.update(cx, |finder, _| {
1019            let finder = &finder.delegate;
1020            assert_eq!(finder.matches.len(), 1);
1021            let latest_search_query = finder
1022                .latest_search_query
1023                .as_ref()
1024                .expect("Finder should have a query after the update_matches call");
1025            assert_eq!(latest_search_query.path_like.raw_query, query_inside_file);
1026            assert_eq!(
1027                latest_search_query.path_like.file_query_end,
1028                Some(file_query.len())
1029            );
1030            assert_eq!(latest_search_query.row, Some(file_row));
1031            assert_eq!(latest_search_query.column, Some(file_column as u32));
1032        });
1033
1034        cx.dispatch_action(SelectNext);
1035        cx.dispatch_action(Confirm);
1036
1037        let editor = cx.update(|cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
1038        cx.executor().advance_clock(Duration::from_secs(2));
1039
1040        editor.update(cx, |editor, cx| {
1041                let all_selections = editor.selections.all_adjusted(cx);
1042                assert_eq!(
1043                    all_selections.len(),
1044                    1,
1045                    "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
1046                );
1047                let caret_selection = all_selections.into_iter().next().unwrap();
1048                assert_eq!(caret_selection.start, caret_selection.end,
1049                    "Caret selection should have its start and end at the same position");
1050                assert_eq!(file_row, caret_selection.start.row + 1,
1051                    "Query inside file should get caret with the same focus row");
1052                assert_eq!(file_column, caret_selection.start.column as usize + 1,
1053                    "Query inside file should get caret with the same focus column");
1054            });
1055    }
1056
1057    #[gpui::test]
1058    async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
1059        let app_state = init_test(cx);
1060
1061        let first_file_name = "first.rs";
1062        let first_file_contents = "// First Rust file";
1063        app_state
1064            .fs
1065            .as_fake()
1066            .insert_tree(
1067                "/src",
1068                json!({
1069                    "test": {
1070                        first_file_name: first_file_contents,
1071                        "second.rs": "// Second Rust file",
1072                    }
1073                }),
1074            )
1075            .await;
1076
1077        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1078
1079        let (picker, workspace, cx) = build_find_picker(project, cx);
1080
1081        let file_query = &first_file_name[..3];
1082        let file_row = 200;
1083        let file_column = 300;
1084        assert!(file_column > first_file_contents.len());
1085        let query_outside_file = format!("{file_query}:{file_row}:{file_column}");
1086        picker
1087            .update(cx, |picker, cx| {
1088                picker
1089                    .delegate
1090                    .update_matches(query_outside_file.to_string(), cx)
1091            })
1092            .await;
1093        picker.update(cx, |finder, _| {
1094            let delegate = &finder.delegate;
1095            assert_eq!(delegate.matches.len(), 1);
1096            let latest_search_query = delegate
1097                .latest_search_query
1098                .as_ref()
1099                .expect("Finder should have a query after the update_matches call");
1100            assert_eq!(latest_search_query.path_like.raw_query, query_outside_file);
1101            assert_eq!(
1102                latest_search_query.path_like.file_query_end,
1103                Some(file_query.len())
1104            );
1105            assert_eq!(latest_search_query.row, Some(file_row));
1106            assert_eq!(latest_search_query.column, Some(file_column as u32));
1107        });
1108
1109        cx.dispatch_action(SelectNext);
1110        cx.dispatch_action(Confirm);
1111
1112        let editor = cx.update(|cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
1113        cx.executor().advance_clock(Duration::from_secs(2));
1114
1115        editor.update(cx, |editor, cx| {
1116                let all_selections = editor.selections.all_adjusted(cx);
1117                assert_eq!(
1118                    all_selections.len(),
1119                    1,
1120                    "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
1121                );
1122                let caret_selection = all_selections.into_iter().next().unwrap();
1123                assert_eq!(caret_selection.start, caret_selection.end,
1124                    "Caret selection should have its start and end at the same position");
1125                assert_eq!(0, caret_selection.start.row,
1126                    "Excessive rows (as in query outside file borders) should get trimmed to last file row");
1127                assert_eq!(first_file_contents.len(), caret_selection.start.column as usize,
1128                    "Excessive columns (as in query outside file borders) should get trimmed to selected row's last column");
1129            });
1130    }
1131
1132    #[gpui::test]
1133    async fn test_matching_cancellation(cx: &mut TestAppContext) {
1134        let app_state = init_test(cx);
1135        app_state
1136            .fs
1137            .as_fake()
1138            .insert_tree(
1139                "/dir",
1140                json!({
1141                    "hello": "",
1142                    "goodbye": "",
1143                    "halogen-light": "",
1144                    "happiness": "",
1145                    "height": "",
1146                    "hi": "",
1147                    "hiccup": "",
1148                }),
1149            )
1150            .await;
1151
1152        let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
1153
1154        let (picker, _, cx) = build_find_picker(project, cx);
1155
1156        let query = test_path_like("hi");
1157        picker
1158            .update(cx, |picker, cx| {
1159                picker.delegate.spawn_search(query.clone(), cx)
1160            })
1161            .await;
1162
1163        picker.update(cx, |picker, _cx| {
1164            assert_eq!(picker.delegate.matches.len(), 5)
1165        });
1166
1167        picker.update(cx, |picker, cx| {
1168            let delegate = &mut picker.delegate;
1169            assert!(
1170                delegate.matches.history.is_empty(),
1171                "Search matches expected"
1172            );
1173            let matches = delegate.matches.search.clone();
1174
1175            // Simulate a search being cancelled after the time limit,
1176            // returning only a subset of the matches that would have been found.
1177            drop(delegate.spawn_search(query.clone(), cx));
1178            delegate.set_search_matches(
1179                delegate.latest_search_id,
1180                true, // did-cancel
1181                query.clone(),
1182                vec![matches[1].clone(), matches[3].clone()],
1183                cx,
1184            );
1185
1186            // Simulate another cancellation.
1187            drop(delegate.spawn_search(query.clone(), cx));
1188            delegate.set_search_matches(
1189                delegate.latest_search_id,
1190                true, // did-cancel
1191                query.clone(),
1192                vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
1193                cx,
1194            );
1195
1196            assert!(
1197                delegate.matches.history.is_empty(),
1198                "Search matches expected"
1199            );
1200            assert_eq!(delegate.matches.search.as_slice(), &matches[0..4]);
1201        });
1202    }
1203
1204    #[gpui::test]
1205    async fn test_ignored_root(cx: &mut TestAppContext) {
1206        let app_state = init_test(cx);
1207        app_state
1208            .fs
1209            .as_fake()
1210            .insert_tree(
1211                "/ancestor",
1212                json!({
1213                    ".gitignore": "ignored-root",
1214                    "ignored-root": {
1215                        "happiness": "",
1216                        "height": "",
1217                        "hi": "",
1218                        "hiccup": "",
1219                    },
1220                    "tracked-root": {
1221                        ".gitignore": "height",
1222                        "happiness": "",
1223                        "height": "",
1224                        "hi": "",
1225                        "hiccup": "",
1226                    },
1227                }),
1228            )
1229            .await;
1230
1231        let project = Project::test(
1232            app_state.fs.clone(),
1233            [
1234                "/ancestor/tracked-root".as_ref(),
1235                "/ancestor/ignored-root".as_ref(),
1236            ],
1237            cx,
1238        )
1239        .await;
1240
1241        let (picker, _, cx) = build_find_picker(project, cx);
1242
1243        picker
1244            .update(cx, |picker, cx| {
1245                picker.delegate.spawn_search(test_path_like("hi"), cx)
1246            })
1247            .await;
1248        picker.update(cx, |picker, _| {
1249            assert_eq!(
1250                collect_search_results(picker),
1251                vec![
1252                    PathBuf::from("ignored-root/happiness"),
1253                    PathBuf::from("ignored-root/height"),
1254                    PathBuf::from("ignored-root/hi"),
1255                    PathBuf::from("ignored-root/hiccup"),
1256                    PathBuf::from("tracked-root/happiness"),
1257                    PathBuf::from("tracked-root/height"),
1258                    PathBuf::from("tracked-root/hi"),
1259                    PathBuf::from("tracked-root/hiccup"),
1260                ],
1261                "All files in all roots (including gitignored) should be searched"
1262            )
1263        });
1264    }
1265
1266    #[gpui::test]
1267    async fn test_ignored_files(cx: &mut TestAppContext) {
1268        let app_state = init_test(cx);
1269        app_state
1270            .fs
1271            .as_fake()
1272            .insert_tree(
1273                "/root",
1274                json!({
1275                    ".git": {},
1276                    ".gitignore": "ignored_a\n.env\n",
1277                    "a": {
1278                        "banana_env": "11",
1279                        "bandana_env": "12",
1280                    },
1281                    "ignored_a": {
1282                        "ignored_banana_env": "21",
1283                        "ignored_bandana_env": "22",
1284                        "ignored_nested": {
1285                            "ignored_nested_banana_env": "31",
1286                            "ignored_nested_bandana_env": "32",
1287                        },
1288                    },
1289                    ".env": "something",
1290                }),
1291            )
1292            .await;
1293
1294        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1295
1296        let (picker, workspace, cx) = build_find_picker(project, cx);
1297
1298        cx.simulate_input("env");
1299        picker.update(cx, |picker, _| {
1300            assert_eq!(
1301                collect_search_results(picker),
1302                vec![
1303                    PathBuf::from(".env"),
1304                    PathBuf::from("a/banana_env"),
1305                    PathBuf::from("a/bandana_env"),
1306                ],
1307                "Root gitignored files and all non-gitignored files should be searched"
1308            )
1309        });
1310
1311        let _ = workspace
1312            .update(cx, |workspace, cx| {
1313                workspace.open_abs_path(
1314                    PathBuf::from("/root/ignored_a/ignored_banana_env"),
1315                    true,
1316                    cx,
1317                )
1318            })
1319            .await
1320            .unwrap();
1321        cx.run_until_parked();
1322        cx.simulate_input("env");
1323        picker.update(cx, |picker, _| {
1324            assert_eq!(
1325                collect_search_results(picker),
1326                vec![
1327                    PathBuf::from(".env"),
1328                    PathBuf::from("a/banana_env"),
1329                    PathBuf::from("a/bandana_env"),
1330                    PathBuf::from("ignored_a/ignored_banana_env"),
1331                    PathBuf::from("ignored_a/ignored_bandana_env"),
1332                ],
1333                "Root gitignored dir got listed and its entries got into worktree, but all gitignored dirs below it were not listed. Old entries + new listed gitignored entries should be searched"
1334            )
1335        });
1336    }
1337
1338    #[gpui::test]
1339    async fn test_single_file_worktrees(cx: &mut TestAppContext) {
1340        let app_state = init_test(cx);
1341        app_state
1342            .fs
1343            .as_fake()
1344            .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
1345            .await;
1346
1347        let project = Project::test(
1348            app_state.fs.clone(),
1349            ["/root/the-parent-dir/the-file".as_ref()],
1350            cx,
1351        )
1352        .await;
1353
1354        let (picker, _, cx) = build_find_picker(project, cx);
1355
1356        // Even though there is only one worktree, that worktree's filename
1357        // is included in the matching, because the worktree is a single file.
1358        picker
1359            .update(cx, |picker, cx| {
1360                picker.delegate.spawn_search(test_path_like("thf"), cx)
1361            })
1362            .await;
1363        cx.read(|cx| {
1364            let picker = picker.read(cx);
1365            let delegate = &picker.delegate;
1366            assert!(
1367                delegate.matches.history.is_empty(),
1368                "Search matches expected"
1369            );
1370            let matches = delegate.matches.search.clone();
1371            assert_eq!(matches.len(), 1);
1372
1373            let (file_name, file_name_positions, full_path, full_path_positions) =
1374                delegate.labels_for_path_match(&matches[0]);
1375            assert_eq!(file_name, "the-file");
1376            assert_eq!(file_name_positions, &[0, 1, 4]);
1377            assert_eq!(full_path, "the-file");
1378            assert_eq!(full_path_positions, &[0, 1, 4]);
1379        });
1380
1381        // Since the worktree root is a file, searching for its name followed by a slash does
1382        // not match anything.
1383        picker
1384            .update(cx, |f, cx| {
1385                f.delegate.spawn_search(test_path_like("thf/"), cx)
1386            })
1387            .await;
1388        picker.update(cx, |f, _| assert_eq!(f.delegate.matches.len(), 0));
1389    }
1390
1391    #[gpui::test]
1392    async fn test_path_distance_ordering(cx: &mut TestAppContext) {
1393        let app_state = init_test(cx);
1394        app_state
1395            .fs
1396            .as_fake()
1397            .insert_tree(
1398                "/root",
1399                json!({
1400                    "dir1": { "a.txt": "" },
1401                    "dir2": {
1402                        "a.txt": "",
1403                        "b.txt": ""
1404                    }
1405                }),
1406            )
1407            .await;
1408
1409        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1410        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1411
1412        let worktree_id = cx.read(|cx| {
1413            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1414            assert_eq!(worktrees.len(), 1);
1415            WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1416        });
1417
1418        // When workspace has an active item, sort items which are closer to that item
1419        // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
1420        // so that one should be sorted earlier
1421        let b_path = ProjectPath {
1422            worktree_id,
1423            path: Arc::from(Path::new("dir2/b.txt")),
1424        };
1425        workspace
1426            .update(cx, |workspace, cx| {
1427                workspace.open_path(b_path, None, true, cx)
1428            })
1429            .await
1430            .unwrap();
1431        let finder = open_file_picker(&workspace, cx);
1432        finder
1433            .update(cx, |f, cx| {
1434                f.delegate.spawn_search(test_path_like("a.txt"), cx)
1435            })
1436            .await;
1437
1438        finder.update(cx, |f, _| {
1439            let delegate = &f.delegate;
1440            assert!(
1441                delegate.matches.history.is_empty(),
1442                "Search matches expected"
1443            );
1444            let matches = delegate.matches.search.clone();
1445            assert_eq!(matches[0].path.as_ref(), Path::new("dir2/a.txt"));
1446            assert_eq!(matches[1].path.as_ref(), Path::new("dir1/a.txt"));
1447        });
1448    }
1449
1450    #[gpui::test]
1451    async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
1452        let app_state = init_test(cx);
1453        app_state
1454            .fs
1455            .as_fake()
1456            .insert_tree(
1457                "/root",
1458                json!({
1459                    "dir1": {},
1460                    "dir2": {
1461                        "dir3": {}
1462                    }
1463                }),
1464            )
1465            .await;
1466
1467        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1468        let (picker, _workspace, cx) = build_find_picker(project, cx);
1469
1470        picker
1471            .update(cx, |f, cx| {
1472                f.delegate.spawn_search(test_path_like("dir"), cx)
1473            })
1474            .await;
1475        cx.read(|cx| {
1476            let finder = picker.read(cx);
1477            assert_eq!(finder.delegate.matches.len(), 0);
1478        });
1479    }
1480
1481    #[gpui::test]
1482    async fn test_query_history(cx: &mut gpui::TestAppContext) {
1483        let app_state = init_test(cx);
1484
1485        app_state
1486            .fs
1487            .as_fake()
1488            .insert_tree(
1489                "/src",
1490                json!({
1491                    "test": {
1492                        "first.rs": "// First Rust file",
1493                        "second.rs": "// Second Rust file",
1494                        "third.rs": "// Third Rust file",
1495                    }
1496                }),
1497            )
1498            .await;
1499
1500        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1501        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1502        let worktree_id = cx.read(|cx| {
1503            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1504            assert_eq!(worktrees.len(), 1);
1505            WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1506        });
1507
1508        // Open and close panels, getting their history items afterwards.
1509        // Ensure history items get populated with opened items, and items are kept in a certain order.
1510        // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen.
1511        //
1512        // TODO: without closing, the opened items do not propagate their history changes for some reason
1513        // it does work in real app though, only tests do not propagate.
1514        workspace.update(cx, |_, cx| cx.focused());
1515
1516        let initial_history = open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1517        assert!(
1518            initial_history.is_empty(),
1519            "Should have no history before opening any files"
1520        );
1521
1522        let history_after_first =
1523            open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1524        assert_eq!(
1525            history_after_first,
1526            vec![FoundPath::new(
1527                ProjectPath {
1528                    worktree_id,
1529                    path: Arc::from(Path::new("test/first.rs")),
1530                },
1531                Some(PathBuf::from("/src/test/first.rs"))
1532            )],
1533            "Should show 1st opened item in the history when opening the 2nd item"
1534        );
1535
1536        let history_after_second =
1537            open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1538        assert_eq!(
1539            history_after_second,
1540            vec![
1541                FoundPath::new(
1542                    ProjectPath {
1543                        worktree_id,
1544                        path: Arc::from(Path::new("test/second.rs")),
1545                    },
1546                    Some(PathBuf::from("/src/test/second.rs"))
1547                ),
1548                FoundPath::new(
1549                    ProjectPath {
1550                        worktree_id,
1551                        path: Arc::from(Path::new("test/first.rs")),
1552                    },
1553                    Some(PathBuf::from("/src/test/first.rs"))
1554                ),
1555            ],
1556            "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
1557    2nd item should be the first in the history, as the last opened."
1558        );
1559
1560        let history_after_third =
1561            open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1562        assert_eq!(
1563                history_after_third,
1564                vec![
1565                    FoundPath::new(
1566                        ProjectPath {
1567                            worktree_id,
1568                            path: Arc::from(Path::new("test/third.rs")),
1569                        },
1570                        Some(PathBuf::from("/src/test/third.rs"))
1571                    ),
1572                    FoundPath::new(
1573                        ProjectPath {
1574                            worktree_id,
1575                            path: Arc::from(Path::new("test/second.rs")),
1576                        },
1577                        Some(PathBuf::from("/src/test/second.rs"))
1578                    ),
1579                    FoundPath::new(
1580                        ProjectPath {
1581                            worktree_id,
1582                            path: Arc::from(Path::new("test/first.rs")),
1583                        },
1584                        Some(PathBuf::from("/src/test/first.rs"))
1585                    ),
1586                ],
1587                "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
1588    3rd item should be the first in the history, as the last opened."
1589            );
1590
1591        let history_after_second_again =
1592            open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1593        assert_eq!(
1594                history_after_second_again,
1595                vec![
1596                    FoundPath::new(
1597                        ProjectPath {
1598                            worktree_id,
1599                            path: Arc::from(Path::new("test/second.rs")),
1600                        },
1601                        Some(PathBuf::from("/src/test/second.rs"))
1602                    ),
1603                    FoundPath::new(
1604                        ProjectPath {
1605                            worktree_id,
1606                            path: Arc::from(Path::new("test/third.rs")),
1607                        },
1608                        Some(PathBuf::from("/src/test/third.rs"))
1609                    ),
1610                    FoundPath::new(
1611                        ProjectPath {
1612                            worktree_id,
1613                            path: Arc::from(Path::new("test/first.rs")),
1614                        },
1615                        Some(PathBuf::from("/src/test/first.rs"))
1616                    ),
1617                ],
1618                "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
1619    2nd item, as the last opened, 3rd item should go next as it was opened right before."
1620            );
1621    }
1622
1623    #[gpui::test]
1624    async fn test_external_files_history(cx: &mut gpui::TestAppContext) {
1625        let app_state = init_test(cx);
1626
1627        app_state
1628            .fs
1629            .as_fake()
1630            .insert_tree(
1631                "/src",
1632                json!({
1633                    "test": {
1634                        "first.rs": "// First Rust file",
1635                        "second.rs": "// Second Rust file",
1636                    }
1637                }),
1638            )
1639            .await;
1640
1641        app_state
1642            .fs
1643            .as_fake()
1644            .insert_tree(
1645                "/external-src",
1646                json!({
1647                    "test": {
1648                        "third.rs": "// Third Rust file",
1649                        "fourth.rs": "// Fourth Rust file",
1650                    }
1651                }),
1652            )
1653            .await;
1654
1655        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1656        cx.update(|cx| {
1657            project.update(cx, |project, cx| {
1658                project.find_or_create_local_worktree("/external-src", false, cx)
1659            })
1660        })
1661        .detach();
1662        cx.background_executor.run_until_parked();
1663
1664        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1665        let worktree_id = cx.read(|cx| {
1666            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1667            assert_eq!(worktrees.len(), 1,);
1668
1669            WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1670        });
1671        workspace
1672            .update(cx, |workspace, cx| {
1673                workspace.open_abs_path(PathBuf::from("/external-src/test/third.rs"), false, cx)
1674            })
1675            .detach();
1676        cx.background_executor.run_until_parked();
1677        let external_worktree_id = cx.read(|cx| {
1678            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1679            assert_eq!(
1680                worktrees.len(),
1681                2,
1682                "External file should get opened in a new worktree"
1683            );
1684
1685            WorktreeId::from_usize(
1686                worktrees
1687                    .into_iter()
1688                    .find(|worktree| {
1689                        worktree.entity_id().as_u64() as usize != worktree_id.to_usize()
1690                    })
1691                    .expect("New worktree should have a different id")
1692                    .entity_id()
1693                    .as_u64() as usize,
1694            )
1695        });
1696        cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
1697
1698        let initial_history_items =
1699            open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1700        assert_eq!(
1701            initial_history_items,
1702            vec![FoundPath::new(
1703                ProjectPath {
1704                    worktree_id: external_worktree_id,
1705                    path: Arc::from(Path::new("")),
1706                },
1707                Some(PathBuf::from("/external-src/test/third.rs"))
1708            )],
1709            "Should show external file with its full path in the history after it was open"
1710        );
1711
1712        let updated_history_items =
1713            open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1714        assert_eq!(
1715            updated_history_items,
1716            vec![
1717                FoundPath::new(
1718                    ProjectPath {
1719                        worktree_id,
1720                        path: Arc::from(Path::new("test/second.rs")),
1721                    },
1722                    Some(PathBuf::from("/src/test/second.rs"))
1723                ),
1724                FoundPath::new(
1725                    ProjectPath {
1726                        worktree_id: external_worktree_id,
1727                        path: Arc::from(Path::new("")),
1728                    },
1729                    Some(PathBuf::from("/external-src/test/third.rs"))
1730                ),
1731            ],
1732            "Should keep external file with history updates",
1733        );
1734    }
1735
1736    #[gpui::test]
1737    async fn test_toggle_panel_new_selections(cx: &mut gpui::TestAppContext) {
1738        let app_state = init_test(cx);
1739
1740        app_state
1741            .fs
1742            .as_fake()
1743            .insert_tree(
1744                "/src",
1745                json!({
1746                    "test": {
1747                        "first.rs": "// First Rust file",
1748                        "second.rs": "// Second Rust file",
1749                        "third.rs": "// Third Rust file",
1750                    }
1751                }),
1752            )
1753            .await;
1754
1755        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1756        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1757
1758        // generate some history to select from
1759        open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1760        cx.executor().run_until_parked();
1761        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1762        open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1763        let current_history =
1764            open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1765
1766        for expected_selected_index in 0..current_history.len() {
1767            cx.dispatch_action(Toggle);
1768            let picker = active_file_picker(&workspace, cx);
1769            let selected_index = picker.update(cx, |picker, _| picker.delegate.selected_index());
1770            assert_eq!(
1771                selected_index, expected_selected_index,
1772                "Should select the next item in the history"
1773            );
1774        }
1775
1776        cx.dispatch_action(Toggle);
1777        let selected_index = workspace.update(cx, |workspace, cx| {
1778            workspace
1779                .active_modal::<FileFinder>(cx)
1780                .unwrap()
1781                .read(cx)
1782                .picker
1783                .read(cx)
1784                .delegate
1785                .selected_index()
1786        });
1787        assert_eq!(
1788            selected_index, 0,
1789            "Should wrap around the history and start all over"
1790        );
1791    }
1792
1793    #[gpui::test]
1794    async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) {
1795        let app_state = init_test(cx);
1796
1797        app_state
1798            .fs
1799            .as_fake()
1800            .insert_tree(
1801                "/src",
1802                json!({
1803                    "test": {
1804                        "first.rs": "// First Rust file",
1805                        "second.rs": "// Second Rust file",
1806                        "third.rs": "// Third Rust file",
1807                        "fourth.rs": "// Fourth Rust file",
1808                    }
1809                }),
1810            )
1811            .await;
1812
1813        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1814        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1815        let worktree_id = cx.read(|cx| {
1816            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1817            assert_eq!(worktrees.len(), 1,);
1818
1819            WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1820        });
1821
1822        // generate some history to select from
1823        open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1824        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1825        open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1826        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1827
1828        let finder = open_file_picker(&workspace, cx);
1829        let first_query = "f";
1830        finder
1831            .update(cx, |finder, cx| {
1832                finder.delegate.update_matches(first_query.to_string(), cx)
1833            })
1834            .await;
1835        finder.update(cx, |finder, _| {
1836            let delegate = &finder.delegate;
1837            assert_eq!(delegate.matches.history.len(), 1, "Only one history item contains {first_query}, it should be present and others should be filtered out");
1838            let history_match = delegate.matches.history.first().unwrap();
1839            assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
1840            assert_eq!(history_match.0, FoundPath::new(
1841                ProjectPath {
1842                    worktree_id,
1843                    path: Arc::from(Path::new("test/first.rs")),
1844                },
1845                Some(PathBuf::from("/src/test/first.rs"))
1846            ));
1847            assert_eq!(delegate.matches.search.len(), 1, "Only one non-history item contains {first_query}, it should be present");
1848            assert_eq!(delegate.matches.search.first().unwrap().path.as_ref(), Path::new("test/fourth.rs"));
1849        });
1850
1851        let second_query = "fsdasdsa";
1852        let finder = active_file_picker(&workspace, cx);
1853        finder
1854            .update(cx, |finder, cx| {
1855                finder.delegate.update_matches(second_query.to_string(), cx)
1856            })
1857            .await;
1858        finder.update(cx, |finder, _| {
1859            let delegate = &finder.delegate;
1860            assert!(
1861                delegate.matches.history.is_empty(),
1862                "No history entries should match {second_query}"
1863            );
1864            assert!(
1865                delegate.matches.search.is_empty(),
1866                "No search entries should match {second_query}"
1867            );
1868        });
1869
1870        let first_query_again = first_query;
1871
1872        let finder = active_file_picker(&workspace, cx);
1873        finder
1874            .update(cx, |finder, cx| {
1875                finder
1876                    .delegate
1877                    .update_matches(first_query_again.to_string(), cx)
1878            })
1879            .await;
1880        finder.update(cx, |finder, _| {
1881            let delegate = &finder.delegate;
1882            assert_eq!(delegate.matches.history.len(), 1, "Only one history item contains {first_query_again}, it should be present and others should be filtered out, even after non-matching query");
1883            let history_match = delegate.matches.history.first().unwrap();
1884            assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
1885            assert_eq!(history_match.0, FoundPath::new(
1886                ProjectPath {
1887                    worktree_id,
1888                    path: Arc::from(Path::new("test/first.rs")),
1889                },
1890                Some(PathBuf::from("/src/test/first.rs"))
1891            ));
1892            assert_eq!(delegate.matches.search.len(), 1, "Only one non-history item contains {first_query_again}, it should be present, even after non-matching query");
1893            assert_eq!(delegate.matches.search.first().unwrap().path.as_ref(), Path::new("test/fourth.rs"));
1894        });
1895    }
1896
1897    #[gpui::test]
1898    async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) {
1899        let app_state = init_test(cx);
1900
1901        app_state
1902            .fs
1903            .as_fake()
1904            .insert_tree(
1905                "/src",
1906                json!({
1907                    "collab_ui": {
1908                        "first.rs": "// First Rust file",
1909                        "second.rs": "// Second Rust file",
1910                        "third.rs": "// Third Rust file",
1911                        "collab_ui.rs": "// Fourth Rust file",
1912                    }
1913                }),
1914            )
1915            .await;
1916
1917        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1918        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1919        // generate some history to select from
1920        open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1921        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1922        open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1923        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1924
1925        let finder = open_file_picker(&workspace, cx);
1926        let query = "collab_ui";
1927        cx.simulate_input(query);
1928        finder.update(cx, |finder, _| {
1929            let delegate = &finder.delegate;
1930            assert!(
1931                delegate.matches.history.is_empty(),
1932                "History items should not math query {query}, they should be matched by name only"
1933            );
1934
1935            let search_entries = delegate
1936                .matches
1937                .search
1938                .iter()
1939                .map(|path_match| path_match.path.to_path_buf())
1940                .collect::<Vec<_>>();
1941            assert_eq!(
1942                search_entries,
1943                vec![
1944                    PathBuf::from("collab_ui/collab_ui.rs"),
1945                    PathBuf::from("collab_ui/third.rs"),
1946                    PathBuf::from("collab_ui/first.rs"),
1947                    PathBuf::from("collab_ui/second.rs"),
1948                ],
1949                "Despite all search results having the same directory name, the most matching one should be on top"
1950            );
1951        });
1952    }
1953
1954    #[gpui::test]
1955    async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) {
1956        let app_state = init_test(cx);
1957
1958        app_state
1959            .fs
1960            .as_fake()
1961            .insert_tree(
1962                "/src",
1963                json!({
1964                    "test": {
1965                        "first.rs": "// First Rust file",
1966                        "nonexistent.rs": "// Second Rust file",
1967                        "third.rs": "// Third Rust file",
1968                    }
1969                }),
1970            )
1971            .await;
1972
1973        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1974        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); // generate some history to select from
1975        open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1976        open_close_queried_buffer("non", 1, "nonexistent.rs", &workspace, cx).await;
1977        open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1978        open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1979
1980        let picker = open_file_picker(&workspace, cx);
1981        cx.simulate_input("rs");
1982
1983        picker.update(cx, |finder, _| {
1984            let history_entries = finder.delegate
1985                .matches
1986                .history
1987                .iter()
1988                .map(|(_, path_match)| path_match.as_ref().expect("should have a path match").path.to_path_buf())
1989                .collect::<Vec<_>>();
1990            assert_eq!(
1991                history_entries,
1992                vec![
1993                    PathBuf::from("test/first.rs"),
1994                    PathBuf::from("test/third.rs"),
1995                ],
1996                "Should have all opened files in the history, except the ones that do not exist on disk"
1997            );
1998        });
1999    }
2000
2001    async fn open_close_queried_buffer(
2002        input: &str,
2003        expected_matches: usize,
2004        expected_editor_title: &str,
2005        workspace: &View<Workspace>,
2006        cx: &mut gpui::VisualTestContext,
2007    ) -> Vec<FoundPath> {
2008        let picker = open_file_picker(&workspace, cx);
2009        cx.simulate_input(input);
2010
2011        let history_items = picker.update(cx, |finder, _| {
2012            assert_eq!(
2013                finder.delegate.matches.len(),
2014                expected_matches,
2015                "Unexpected number of matches found for query {input}"
2016            );
2017            finder.delegate.history_items.clone()
2018        });
2019
2020        cx.dispatch_action(SelectNext);
2021        cx.dispatch_action(Confirm);
2022
2023        cx.read(|cx| {
2024            let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
2025            let active_editor_title = active_editor.read(cx).title(cx);
2026            assert_eq!(
2027                expected_editor_title, active_editor_title,
2028                "Unexpected editor title for query {input}"
2029            );
2030        });
2031
2032        cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
2033
2034        history_items
2035    }
2036
2037    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
2038        cx.update(|cx| {
2039            let state = AppState::test(cx);
2040            theme::init(theme::LoadThemes::JustBase, cx);
2041            language::init(cx);
2042            super::init(cx);
2043            editor::init(cx);
2044            workspace::init_settings(cx);
2045            Project::init_settings(cx);
2046            state
2047        })
2048    }
2049
2050    fn test_path_like(test_str: &str) -> PathLikeWithPosition<FileSearchQuery> {
2051        PathLikeWithPosition::parse_str(test_str, |path_like_str| {
2052            Ok::<_, std::convert::Infallible>(FileSearchQuery {
2053                raw_query: test_str.to_owned(),
2054                file_query_end: if path_like_str == test_str {
2055                    None
2056                } else {
2057                    Some(path_like_str.len())
2058                },
2059            })
2060        })
2061        .unwrap()
2062    }
2063
2064    fn build_find_picker(
2065        project: Model<Project>,
2066        cx: &mut TestAppContext,
2067    ) -> (
2068        View<Picker<FileFinderDelegate>>,
2069        View<Workspace>,
2070        &mut VisualTestContext,
2071    ) {
2072        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
2073        let picker = open_file_picker(&workspace, cx);
2074        (picker, workspace, cx)
2075    }
2076
2077    #[track_caller]
2078    fn open_file_picker(
2079        workspace: &View<Workspace>,
2080        cx: &mut VisualTestContext,
2081    ) -> View<Picker<FileFinderDelegate>> {
2082        cx.dispatch_action(Toggle);
2083        active_file_picker(workspace, cx)
2084    }
2085
2086    #[track_caller]
2087    fn active_file_picker(
2088        workspace: &View<Workspace>,
2089        cx: &mut VisualTestContext,
2090    ) -> View<Picker<FileFinderDelegate>> {
2091        workspace.update(cx, |workspace, cx| {
2092            workspace
2093                .active_modal::<FileFinder>(cx)
2094                .unwrap()
2095                .read(cx)
2096                .picker
2097                .clone()
2098        })
2099    }
2100
2101    fn collect_search_results(picker: &Picker<FileFinderDelegate>) -> Vec<PathBuf> {
2102        let matches = &picker.delegate.matches;
2103        assert!(
2104            matches.history.is_empty(),
2105            "Should have no history matches, but got: {:?}",
2106            matches.history
2107        );
2108        let mut results = matches
2109            .search
2110            .iter()
2111            .map(|path_match| Path::new(path_match.path_prefix.as_ref()).join(&path_match.path))
2112            .collect::<Vec<_>>();
2113        results.sort();
2114        results
2115    }
2116}