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
 524impl PickerDelegate for FileFinderDelegate {
 525    type ListItem = ListItem;
 526
 527    fn placeholder_text(&self) -> Arc<str> {
 528        "Search project files...".into()
 529    }
 530
 531    fn match_count(&self) -> usize {
 532        self.matches.len()
 533    }
 534
 535    fn selected_index(&self) -> usize {
 536        self.selected_index.unwrap_or(0)
 537    }
 538
 539    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
 540        self.selected_index = Some(ix);
 541        cx.notify();
 542    }
 543
 544    fn separators_after_indices(&self) -> Vec<usize> {
 545        let history_items = self.matches.history.len();
 546        if history_items == 0 || self.matches.search.is_empty() {
 547            Vec::new()
 548        } else {
 549            vec![history_items - 1]
 550        }
 551    }
 552
 553    fn update_matches(
 554        &mut self,
 555        raw_query: String,
 556        cx: &mut ViewContext<Picker<Self>>,
 557    ) -> Task<()> {
 558        let raw_query = raw_query.trim();
 559        if raw_query.is_empty() {
 560            let project = self.project.read(cx);
 561            self.latest_search_id = post_inc(&mut self.search_count);
 562            self.matches = Matches {
 563                history: self
 564                    .history_items
 565                    .iter()
 566                    .filter(|history_item| {
 567                        project
 568                            .worktree_for_id(history_item.project.worktree_id, cx)
 569                            .is_some()
 570                            || (project.is_local() && history_item.absolute.is_some())
 571                    })
 572                    .cloned()
 573                    .map(|p| (p, None))
 574                    .collect(),
 575                search: Vec::new(),
 576            };
 577            cx.notify();
 578            Task::ready(())
 579        } else {
 580            let query = PathLikeWithPosition::parse_str(raw_query, |path_like_str| {
 581                Ok::<_, std::convert::Infallible>(FileSearchQuery {
 582                    raw_query: raw_query.to_owned(),
 583                    file_query_end: if path_like_str == raw_query {
 584                        None
 585                    } else {
 586                        Some(path_like_str.len())
 587                    },
 588                })
 589            })
 590            .expect("infallible");
 591            self.spawn_search(query, cx)
 592        }
 593    }
 594
 595    fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
 596        if let Some(m) = self.matches.get(self.selected_index()) {
 597            if let Some(workspace) = self.workspace.upgrade() {
 598                let open_task = workspace.update(cx, move |workspace, cx| {
 599                    let split_or_open = |workspace: &mut Workspace, project_path, cx| {
 600                        if secondary {
 601                            workspace.split_path(project_path, cx)
 602                        } else {
 603                            workspace.open_path(project_path, None, true, cx)
 604                        }
 605                    };
 606                    match m {
 607                        Match::History(history_match, _) => {
 608                            let worktree_id = history_match.project.worktree_id;
 609                            if workspace
 610                                .project()
 611                                .read(cx)
 612                                .worktree_for_id(worktree_id, cx)
 613                                .is_some()
 614                            {
 615                                split_or_open(
 616                                    workspace,
 617                                    ProjectPath {
 618                                        worktree_id,
 619                                        path: Arc::clone(&history_match.project.path),
 620                                    },
 621                                    cx,
 622                                )
 623                            } else {
 624                                match history_match.absolute.as_ref() {
 625                                    Some(abs_path) => {
 626                                        if secondary {
 627                                            workspace.split_abs_path(
 628                                                abs_path.to_path_buf(),
 629                                                false,
 630                                                cx,
 631                                            )
 632                                        } else {
 633                                            workspace.open_abs_path(
 634                                                abs_path.to_path_buf(),
 635                                                false,
 636                                                cx,
 637                                            )
 638                                        }
 639                                    }
 640                                    None => split_or_open(
 641                                        workspace,
 642                                        ProjectPath {
 643                                            worktree_id,
 644                                            path: Arc::clone(&history_match.project.path),
 645                                        },
 646                                        cx,
 647                                    ),
 648                                }
 649                            }
 650                        }
 651                        Match::Search(m) => split_or_open(
 652                            workspace,
 653                            ProjectPath {
 654                                worktree_id: WorktreeId::from_usize(m.worktree_id),
 655                                path: m.path.clone(),
 656                            },
 657                            cx,
 658                        ),
 659                    }
 660                });
 661
 662                let row = self
 663                    .latest_search_query
 664                    .as_ref()
 665                    .and_then(|query| query.row)
 666                    .map(|row| row.saturating_sub(1));
 667                let col = self
 668                    .latest_search_query
 669                    .as_ref()
 670                    .and_then(|query| query.column)
 671                    .unwrap_or(0)
 672                    .saturating_sub(1);
 673                let finder = self.file_finder.clone();
 674
 675                cx.spawn(|_, mut cx| async move {
 676                    let item = open_task.await.log_err()?;
 677                    if let Some(row) = row {
 678                        if let Some(active_editor) = item.downcast::<Editor>() {
 679                            active_editor
 680                                .downgrade()
 681                                .update(&mut cx, |editor, cx| {
 682                                    let snapshot = editor.snapshot(cx).display_snapshot;
 683                                    let point = snapshot
 684                                        .buffer_snapshot
 685                                        .clip_point(Point::new(row, col), Bias::Left);
 686                                    editor.change_selections(Some(Autoscroll::center()), cx, |s| {
 687                                        s.select_ranges([point..point])
 688                                    });
 689                                })
 690                                .log_err();
 691                        }
 692                    }
 693                    finder.update(&mut cx, |_, cx| cx.emit(DismissEvent)).ok()?;
 694
 695                    Some(())
 696                })
 697                .detach();
 698            }
 699        }
 700    }
 701
 702    fn dismissed(&mut self, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
 703        self.file_finder
 704            .update(cx, |_, cx| cx.emit(DismissEvent))
 705            .log_err();
 706    }
 707
 708    fn render_match(
 709        &self,
 710        ix: usize,
 711        selected: bool,
 712        cx: &mut ViewContext<Picker<Self>>,
 713    ) -> Option<Self::ListItem> {
 714        let path_match = self
 715            .matches
 716            .get(ix)
 717            .expect("Invalid matches state: no element for index {ix}");
 718
 719        let (file_name, file_name_positions, full_path, full_path_positions) =
 720            self.labels_for_match(path_match, cx, ix);
 721
 722        Some(
 723            ListItem::new(ix)
 724                .spacing(ListItemSpacing::Sparse)
 725                .inset(true)
 726                .selected(selected)
 727                .child(
 728                    v_stack()
 729                        .child(HighlightedLabel::new(file_name, file_name_positions))
 730                        .child(HighlightedLabel::new(full_path, full_path_positions)),
 731                ),
 732        )
 733    }
 734}
 735
 736#[cfg(test)]
 737mod tests {
 738    use std::{assert_eq, path::Path, time::Duration};
 739
 740    use super::*;
 741    use editor::Editor;
 742    use gpui::{Entity, TestAppContext, VisualTestContext};
 743    use menu::{Confirm, SelectNext};
 744    use serde_json::json;
 745    use workspace::{AppState, Workspace};
 746
 747    #[ctor::ctor]
 748    fn init_logger() {
 749        if std::env::var("RUST_LOG").is_ok() {
 750            env_logger::init();
 751        }
 752    }
 753
 754    #[gpui::test]
 755    async fn test_matching_paths(cx: &mut TestAppContext) {
 756        let app_state = init_test(cx);
 757        app_state
 758            .fs
 759            .as_fake()
 760            .insert_tree(
 761                "/root",
 762                json!({
 763                    "a": {
 764                        "banana": "",
 765                        "bandana": "",
 766                    }
 767                }),
 768            )
 769            .await;
 770
 771        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
 772
 773        let (picker, workspace, cx) = build_find_picker(project, cx);
 774
 775        cx.simulate_input("bna");
 776        picker.update(cx, |picker, _| {
 777            assert_eq!(picker.delegate.matches.len(), 2);
 778        });
 779        cx.dispatch_action(SelectNext);
 780        cx.dispatch_action(Confirm);
 781        cx.read(|cx| {
 782            let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
 783            assert_eq!(active_editor.read(cx).title(cx), "bandana");
 784        });
 785
 786        for bandana_query in [
 787            "bandana",
 788            " bandana",
 789            "bandana ",
 790            " bandana ",
 791            " ndan ",
 792            " band ",
 793        ] {
 794            picker
 795                .update(cx, |picker, cx| {
 796                    picker
 797                        .delegate
 798                        .update_matches(bandana_query.to_string(), cx)
 799                })
 800                .await;
 801            picker.update(cx, |picker, _| {
 802                assert_eq!(
 803                    picker.delegate.matches.len(),
 804                    1,
 805                    "Wrong number of matches for bandana query '{bandana_query}'"
 806                );
 807            });
 808            cx.dispatch_action(SelectNext);
 809            cx.dispatch_action(Confirm);
 810            cx.read(|cx| {
 811                let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
 812                assert_eq!(
 813                    active_editor.read(cx).title(cx),
 814                    "bandana",
 815                    "Wrong match for bandana query '{bandana_query}'"
 816                );
 817            });
 818        }
 819    }
 820
 821    #[gpui::test]
 822    async fn test_complex_path(cx: &mut TestAppContext) {
 823        let app_state = init_test(cx);
 824        app_state
 825            .fs
 826            .as_fake()
 827            .insert_tree(
 828                "/root",
 829                json!({
 830                    "其他": {
 831                        "S数据表格": {
 832                            "task.xlsx": "some content",
 833                        },
 834                    }
 835                }),
 836            )
 837            .await;
 838
 839        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
 840
 841        let (picker, workspace, cx) = build_find_picker(project, cx);
 842
 843        cx.simulate_input("t");
 844        picker.update(cx, |picker, _| {
 845            assert_eq!(picker.delegate.matches.len(), 1);
 846            assert_eq!(
 847                collect_search_results(picker),
 848                vec![PathBuf::from("其他/S数据表格/task.xlsx")],
 849            )
 850        });
 851        cx.dispatch_action(SelectNext);
 852        cx.dispatch_action(Confirm);
 853        cx.read(|cx| {
 854            let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
 855            assert_eq!(active_editor.read(cx).title(cx), "task.xlsx");
 856        });
 857    }
 858
 859    #[gpui::test]
 860    async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
 861        let app_state = init_test(cx);
 862
 863        let first_file_name = "first.rs";
 864        let first_file_contents = "// First Rust file";
 865        app_state
 866            .fs
 867            .as_fake()
 868            .insert_tree(
 869                "/src",
 870                json!({
 871                    "test": {
 872                        first_file_name: first_file_contents,
 873                        "second.rs": "// Second Rust file",
 874                    }
 875                }),
 876            )
 877            .await;
 878
 879        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
 880
 881        let (picker, workspace, cx) = build_find_picker(project, cx);
 882
 883        let file_query = &first_file_name[..3];
 884        let file_row = 1;
 885        let file_column = 3;
 886        assert!(file_column <= first_file_contents.len());
 887        let query_inside_file = format!("{file_query}:{file_row}:{file_column}");
 888        picker
 889            .update(cx, |finder, cx| {
 890                finder
 891                    .delegate
 892                    .update_matches(query_inside_file.to_string(), cx)
 893            })
 894            .await;
 895        picker.update(cx, |finder, _| {
 896            let finder = &finder.delegate;
 897            assert_eq!(finder.matches.len(), 1);
 898            let latest_search_query = finder
 899                .latest_search_query
 900                .as_ref()
 901                .expect("Finder should have a query after the update_matches call");
 902            assert_eq!(latest_search_query.path_like.raw_query, query_inside_file);
 903            assert_eq!(
 904                latest_search_query.path_like.file_query_end,
 905                Some(file_query.len())
 906            );
 907            assert_eq!(latest_search_query.row, Some(file_row));
 908            assert_eq!(latest_search_query.column, Some(file_column as u32));
 909        });
 910
 911        cx.dispatch_action(SelectNext);
 912        cx.dispatch_action(Confirm);
 913
 914        let editor = cx.update(|cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
 915        cx.executor().advance_clock(Duration::from_secs(2));
 916
 917        editor.update(cx, |editor, cx| {
 918                let all_selections = editor.selections.all_adjusted(cx);
 919                assert_eq!(
 920                    all_selections.len(),
 921                    1,
 922                    "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
 923                );
 924                let caret_selection = all_selections.into_iter().next().unwrap();
 925                assert_eq!(caret_selection.start, caret_selection.end,
 926                    "Caret selection should have its start and end at the same position");
 927                assert_eq!(file_row, caret_selection.start.row + 1,
 928                    "Query inside file should get caret with the same focus row");
 929                assert_eq!(file_column, caret_selection.start.column as usize + 1,
 930                    "Query inside file should get caret with the same focus column");
 931            });
 932    }
 933
 934    #[gpui::test]
 935    async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
 936        let app_state = init_test(cx);
 937
 938        let first_file_name = "first.rs";
 939        let first_file_contents = "// First Rust file";
 940        app_state
 941            .fs
 942            .as_fake()
 943            .insert_tree(
 944                "/src",
 945                json!({
 946                    "test": {
 947                        first_file_name: first_file_contents,
 948                        "second.rs": "// Second Rust file",
 949                    }
 950                }),
 951            )
 952            .await;
 953
 954        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
 955
 956        let (picker, workspace, cx) = build_find_picker(project, cx);
 957
 958        let file_query = &first_file_name[..3];
 959        let file_row = 200;
 960        let file_column = 300;
 961        assert!(file_column > first_file_contents.len());
 962        let query_outside_file = format!("{file_query}:{file_row}:{file_column}");
 963        picker
 964            .update(cx, |picker, cx| {
 965                picker
 966                    .delegate
 967                    .update_matches(query_outside_file.to_string(), cx)
 968            })
 969            .await;
 970        picker.update(cx, |finder, _| {
 971            let delegate = &finder.delegate;
 972            assert_eq!(delegate.matches.len(), 1);
 973            let latest_search_query = delegate
 974                .latest_search_query
 975                .as_ref()
 976                .expect("Finder should have a query after the update_matches call");
 977            assert_eq!(latest_search_query.path_like.raw_query, query_outside_file);
 978            assert_eq!(
 979                latest_search_query.path_like.file_query_end,
 980                Some(file_query.len())
 981            );
 982            assert_eq!(latest_search_query.row, Some(file_row));
 983            assert_eq!(latest_search_query.column, Some(file_column as u32));
 984        });
 985
 986        cx.dispatch_action(SelectNext);
 987        cx.dispatch_action(Confirm);
 988
 989        let editor = cx.update(|cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
 990        cx.executor().advance_clock(Duration::from_secs(2));
 991
 992        editor.update(cx, |editor, cx| {
 993                let all_selections = editor.selections.all_adjusted(cx);
 994                assert_eq!(
 995                    all_selections.len(),
 996                    1,
 997                    "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
 998                );
 999                let caret_selection = all_selections.into_iter().next().unwrap();
1000                assert_eq!(caret_selection.start, caret_selection.end,
1001                    "Caret selection should have its start and end at the same position");
1002                assert_eq!(0, caret_selection.start.row,
1003                    "Excessive rows (as in query outside file borders) should get trimmed to last file row");
1004                assert_eq!(first_file_contents.len(), caret_selection.start.column as usize,
1005                    "Excessive columns (as in query outside file borders) should get trimmed to selected row's last column");
1006            });
1007    }
1008
1009    #[gpui::test]
1010    async fn test_matching_cancellation(cx: &mut TestAppContext) {
1011        let app_state = init_test(cx);
1012        app_state
1013            .fs
1014            .as_fake()
1015            .insert_tree(
1016                "/dir",
1017                json!({
1018                    "hello": "",
1019                    "goodbye": "",
1020                    "halogen-light": "",
1021                    "happiness": "",
1022                    "height": "",
1023                    "hi": "",
1024                    "hiccup": "",
1025                }),
1026            )
1027            .await;
1028
1029        let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
1030
1031        let (picker, _, cx) = build_find_picker(project, cx);
1032
1033        let query = test_path_like("hi");
1034        picker
1035            .update(cx, |picker, cx| {
1036                picker.delegate.spawn_search(query.clone(), cx)
1037            })
1038            .await;
1039
1040        picker.update(cx, |picker, _cx| {
1041            assert_eq!(picker.delegate.matches.len(), 5)
1042        });
1043
1044        picker.update(cx, |picker, cx| {
1045            let delegate = &mut picker.delegate;
1046            assert!(
1047                delegate.matches.history.is_empty(),
1048                "Search matches expected"
1049            );
1050            let matches = delegate.matches.search.clone();
1051
1052            // Simulate a search being cancelled after the time limit,
1053            // returning only a subset of the matches that would have been found.
1054            drop(delegate.spawn_search(query.clone(), cx));
1055            delegate.set_search_matches(
1056                delegate.latest_search_id,
1057                true, // did-cancel
1058                query.clone(),
1059                vec![matches[1].clone(), matches[3].clone()],
1060                cx,
1061            );
1062
1063            // Simulate another cancellation.
1064            drop(delegate.spawn_search(query.clone(), cx));
1065            delegate.set_search_matches(
1066                delegate.latest_search_id,
1067                true, // did-cancel
1068                query.clone(),
1069                vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
1070                cx,
1071            );
1072
1073            assert!(
1074                delegate.matches.history.is_empty(),
1075                "Search matches expected"
1076            );
1077            assert_eq!(delegate.matches.search.as_slice(), &matches[0..4]);
1078        });
1079    }
1080
1081    #[gpui::test]
1082    async fn test_ignored_root(cx: &mut TestAppContext) {
1083        let app_state = init_test(cx);
1084        app_state
1085            .fs
1086            .as_fake()
1087            .insert_tree(
1088                "/ancestor",
1089                json!({
1090                    ".gitignore": "ignored-root",
1091                    "ignored-root": {
1092                        "happiness": "",
1093                        "height": "",
1094                        "hi": "",
1095                        "hiccup": "",
1096                    },
1097                    "tracked-root": {
1098                        ".gitignore": "height",
1099                        "happiness": "",
1100                        "height": "",
1101                        "hi": "",
1102                        "hiccup": "",
1103                    },
1104                }),
1105            )
1106            .await;
1107
1108        let project = Project::test(
1109            app_state.fs.clone(),
1110            [
1111                "/ancestor/tracked-root".as_ref(),
1112                "/ancestor/ignored-root".as_ref(),
1113            ],
1114            cx,
1115        )
1116        .await;
1117
1118        let (picker, _, cx) = build_find_picker(project, cx);
1119
1120        picker
1121            .update(cx, |picker, cx| {
1122                picker.delegate.spawn_search(test_path_like("hi"), cx)
1123            })
1124            .await;
1125        picker.update(cx, |picker, _| {
1126            assert_eq!(
1127                collect_search_results(picker),
1128                vec![
1129                    PathBuf::from("ignored-root/happiness"),
1130                    PathBuf::from("ignored-root/height"),
1131                    PathBuf::from("ignored-root/hi"),
1132                    PathBuf::from("ignored-root/hiccup"),
1133                    PathBuf::from("tracked-root/happiness"),
1134                    PathBuf::from("tracked-root/height"),
1135                    PathBuf::from("tracked-root/hi"),
1136                    PathBuf::from("tracked-root/hiccup"),
1137                ],
1138                "All files in all roots (including gitignored) should be searched"
1139            )
1140        });
1141    }
1142
1143    #[gpui::test]
1144    async fn test_ignored_files(cx: &mut TestAppContext) {
1145        let app_state = init_test(cx);
1146        app_state
1147            .fs
1148            .as_fake()
1149            .insert_tree(
1150                "/root",
1151                json!({
1152                    ".git": {},
1153                    ".gitignore": "ignored_a\n.env\n",
1154                    "a": {
1155                        "banana_env": "11",
1156                        "bandana_env": "12",
1157                    },
1158                    "ignored_a": {
1159                        "ignored_banana_env": "21",
1160                        "ignored_bandana_env": "22",
1161                        "ignored_nested": {
1162                            "ignored_nested_banana_env": "31",
1163                            "ignored_nested_bandana_env": "32",
1164                        },
1165                    },
1166                    ".env": "something",
1167                }),
1168            )
1169            .await;
1170
1171        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1172
1173        let (picker, workspace, cx) = build_find_picker(project, cx);
1174
1175        cx.simulate_input("env");
1176        picker.update(cx, |picker, _| {
1177            assert_eq!(
1178                collect_search_results(picker),
1179                vec![
1180                    PathBuf::from(".env"),
1181                    PathBuf::from("a/banana_env"),
1182                    PathBuf::from("a/bandana_env"),
1183                ],
1184                "Root gitignored files and all non-gitignored files should be searched"
1185            )
1186        });
1187
1188        let _ = workspace
1189            .update(cx, |workspace, cx| {
1190                workspace.open_abs_path(
1191                    PathBuf::from("/root/ignored_a/ignored_banana_env"),
1192                    true,
1193                    cx,
1194                )
1195            })
1196            .await
1197            .unwrap();
1198        cx.run_until_parked();
1199        cx.simulate_input("env");
1200        picker.update(cx, |picker, _| {
1201            assert_eq!(
1202                collect_search_results(picker),
1203                vec![
1204                    PathBuf::from(".env"),
1205                    PathBuf::from("a/banana_env"),
1206                    PathBuf::from("a/bandana_env"),
1207                    PathBuf::from("ignored_a/ignored_banana_env"),
1208                    PathBuf::from("ignored_a/ignored_bandana_env"),
1209                ],
1210                "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"
1211            )
1212        });
1213    }
1214
1215    #[gpui::test]
1216    async fn test_single_file_worktrees(cx: &mut TestAppContext) {
1217        let app_state = init_test(cx);
1218        app_state
1219            .fs
1220            .as_fake()
1221            .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
1222            .await;
1223
1224        let project = Project::test(
1225            app_state.fs.clone(),
1226            ["/root/the-parent-dir/the-file".as_ref()],
1227            cx,
1228        )
1229        .await;
1230
1231        let (picker, _, cx) = build_find_picker(project, cx);
1232
1233        // Even though there is only one worktree, that worktree's filename
1234        // is included in the matching, because the worktree is a single file.
1235        picker
1236            .update(cx, |picker, cx| {
1237                picker.delegate.spawn_search(test_path_like("thf"), cx)
1238            })
1239            .await;
1240        cx.read(|cx| {
1241            let picker = picker.read(cx);
1242            let delegate = &picker.delegate;
1243            assert!(
1244                delegate.matches.history.is_empty(),
1245                "Search matches expected"
1246            );
1247            let matches = delegate.matches.search.clone();
1248            assert_eq!(matches.len(), 1);
1249
1250            let (file_name, file_name_positions, full_path, full_path_positions) =
1251                delegate.labels_for_path_match(&matches[0]);
1252            assert_eq!(file_name, "the-file");
1253            assert_eq!(file_name_positions, &[0, 1, 4]);
1254            assert_eq!(full_path, "the-file");
1255            assert_eq!(full_path_positions, &[0, 1, 4]);
1256        });
1257
1258        // Since the worktree root is a file, searching for its name followed by a slash does
1259        // not match anything.
1260        picker
1261            .update(cx, |f, cx| {
1262                f.delegate.spawn_search(test_path_like("thf/"), cx)
1263            })
1264            .await;
1265        picker.update(cx, |f, _| assert_eq!(f.delegate.matches.len(), 0));
1266    }
1267
1268    #[gpui::test]
1269    async fn test_path_distance_ordering(cx: &mut TestAppContext) {
1270        let app_state = init_test(cx);
1271        app_state
1272            .fs
1273            .as_fake()
1274            .insert_tree(
1275                "/root",
1276                json!({
1277                    "dir1": { "a.txt": "" },
1278                    "dir2": {
1279                        "a.txt": "",
1280                        "b.txt": ""
1281                    }
1282                }),
1283            )
1284            .await;
1285
1286        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1287        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1288
1289        let worktree_id = cx.read(|cx| {
1290            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1291            assert_eq!(worktrees.len(), 1);
1292            WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1293        });
1294
1295        // When workspace has an active item, sort items which are closer to that item
1296        // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
1297        // so that one should be sorted earlier
1298        let b_path = ProjectPath {
1299            worktree_id,
1300            path: Arc::from(Path::new("/root/dir2/b.txt")),
1301        };
1302        workspace
1303            .update(cx, |workspace, cx| {
1304                workspace.open_path(b_path, None, true, cx)
1305            })
1306            .await
1307            .unwrap();
1308        let finder = open_file_picker(&workspace, cx);
1309        finder
1310            .update(cx, |f, cx| {
1311                f.delegate.spawn_search(test_path_like("a.txt"), cx)
1312            })
1313            .await;
1314
1315        finder.update(cx, |f, _| {
1316            let delegate = &f.delegate;
1317            assert!(
1318                delegate.matches.history.is_empty(),
1319                "Search matches expected"
1320            );
1321            let matches = delegate.matches.search.clone();
1322            assert_eq!(matches[0].path.as_ref(), Path::new("dir2/a.txt"));
1323            assert_eq!(matches[1].path.as_ref(), Path::new("dir1/a.txt"));
1324        });
1325    }
1326
1327    #[gpui::test]
1328    async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
1329        let app_state = init_test(cx);
1330        app_state
1331            .fs
1332            .as_fake()
1333            .insert_tree(
1334                "/root",
1335                json!({
1336                    "dir1": {},
1337                    "dir2": {
1338                        "dir3": {}
1339                    }
1340                }),
1341            )
1342            .await;
1343
1344        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1345        let (picker, _workspace, cx) = build_find_picker(project, cx);
1346
1347        picker
1348            .update(cx, |f, cx| {
1349                f.delegate.spawn_search(test_path_like("dir"), cx)
1350            })
1351            .await;
1352        cx.read(|cx| {
1353            let finder = picker.read(cx);
1354            assert_eq!(finder.delegate.matches.len(), 0);
1355        });
1356    }
1357
1358    #[gpui::test]
1359    async fn test_query_history(cx: &mut gpui::TestAppContext) {
1360        let app_state = init_test(cx);
1361
1362        app_state
1363            .fs
1364            .as_fake()
1365            .insert_tree(
1366                "/src",
1367                json!({
1368                    "test": {
1369                        "first.rs": "// First Rust file",
1370                        "second.rs": "// Second Rust file",
1371                        "third.rs": "// Third Rust file",
1372                    }
1373                }),
1374            )
1375            .await;
1376
1377        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1378        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1379        let worktree_id = cx.read(|cx| {
1380            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1381            assert_eq!(worktrees.len(), 1);
1382            WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1383        });
1384
1385        // Open and close panels, getting their history items afterwards.
1386        // Ensure history items get populated with opened items, and items are kept in a certain order.
1387        // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen.
1388        //
1389        // TODO: without closing, the opened items do not propagate their history changes for some reason
1390        // it does work in real app though, only tests do not propagate.
1391        workspace.update(cx, |_, cx| cx.focused());
1392
1393        let initial_history = open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1394        assert!(
1395            initial_history.is_empty(),
1396            "Should have no history before opening any files"
1397        );
1398
1399        let history_after_first =
1400            open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1401        assert_eq!(
1402            history_after_first,
1403            vec![FoundPath::new(
1404                ProjectPath {
1405                    worktree_id,
1406                    path: Arc::from(Path::new("test/first.rs")),
1407                },
1408                Some(PathBuf::from("/src/test/first.rs"))
1409            )],
1410            "Should show 1st opened item in the history when opening the 2nd item"
1411        );
1412
1413        let history_after_second =
1414            open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1415        assert_eq!(
1416            history_after_second,
1417            vec![
1418                FoundPath::new(
1419                    ProjectPath {
1420                        worktree_id,
1421                        path: Arc::from(Path::new("test/second.rs")),
1422                    },
1423                    Some(PathBuf::from("/src/test/second.rs"))
1424                ),
1425                FoundPath::new(
1426                    ProjectPath {
1427                        worktree_id,
1428                        path: Arc::from(Path::new("test/first.rs")),
1429                    },
1430                    Some(PathBuf::from("/src/test/first.rs"))
1431                ),
1432            ],
1433            "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
1434    2nd item should be the first in the history, as the last opened."
1435        );
1436
1437        let history_after_third =
1438            open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1439        assert_eq!(
1440                history_after_third,
1441                vec![
1442                    FoundPath::new(
1443                        ProjectPath {
1444                            worktree_id,
1445                            path: Arc::from(Path::new("test/third.rs")),
1446                        },
1447                        Some(PathBuf::from("/src/test/third.rs"))
1448                    ),
1449                    FoundPath::new(
1450                        ProjectPath {
1451                            worktree_id,
1452                            path: Arc::from(Path::new("test/second.rs")),
1453                        },
1454                        Some(PathBuf::from("/src/test/second.rs"))
1455                    ),
1456                    FoundPath::new(
1457                        ProjectPath {
1458                            worktree_id,
1459                            path: Arc::from(Path::new("test/first.rs")),
1460                        },
1461                        Some(PathBuf::from("/src/test/first.rs"))
1462                    ),
1463                ],
1464                "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
1465    3rd item should be the first in the history, as the last opened."
1466            );
1467
1468        let history_after_second_again =
1469            open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1470        assert_eq!(
1471                history_after_second_again,
1472                vec![
1473                    FoundPath::new(
1474                        ProjectPath {
1475                            worktree_id,
1476                            path: Arc::from(Path::new("test/second.rs")),
1477                        },
1478                        Some(PathBuf::from("/src/test/second.rs"))
1479                    ),
1480                    FoundPath::new(
1481                        ProjectPath {
1482                            worktree_id,
1483                            path: Arc::from(Path::new("test/third.rs")),
1484                        },
1485                        Some(PathBuf::from("/src/test/third.rs"))
1486                    ),
1487                    FoundPath::new(
1488                        ProjectPath {
1489                            worktree_id,
1490                            path: Arc::from(Path::new("test/first.rs")),
1491                        },
1492                        Some(PathBuf::from("/src/test/first.rs"))
1493                    ),
1494                ],
1495                "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
1496    2nd item, as the last opened, 3rd item should go next as it was opened right before."
1497            );
1498    }
1499
1500    #[gpui::test]
1501    async fn test_external_files_history(cx: &mut gpui::TestAppContext) {
1502        let app_state = init_test(cx);
1503
1504        app_state
1505            .fs
1506            .as_fake()
1507            .insert_tree(
1508                "/src",
1509                json!({
1510                    "test": {
1511                        "first.rs": "// First Rust file",
1512                        "second.rs": "// Second Rust file",
1513                    }
1514                }),
1515            )
1516            .await;
1517
1518        app_state
1519            .fs
1520            .as_fake()
1521            .insert_tree(
1522                "/external-src",
1523                json!({
1524                    "test": {
1525                        "third.rs": "// Third Rust file",
1526                        "fourth.rs": "// Fourth Rust file",
1527                    }
1528                }),
1529            )
1530            .await;
1531
1532        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1533        cx.update(|cx| {
1534            project.update(cx, |project, cx| {
1535                project.find_or_create_local_worktree("/external-src", false, cx)
1536            })
1537        })
1538        .detach();
1539        cx.background_executor.run_until_parked();
1540
1541        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1542        let worktree_id = cx.read(|cx| {
1543            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1544            assert_eq!(worktrees.len(), 1,);
1545
1546            WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1547        });
1548        workspace
1549            .update(cx, |workspace, cx| {
1550                workspace.open_abs_path(PathBuf::from("/external-src/test/third.rs"), false, cx)
1551            })
1552            .detach();
1553        cx.background_executor.run_until_parked();
1554        let external_worktree_id = cx.read(|cx| {
1555            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1556            assert_eq!(
1557                worktrees.len(),
1558                2,
1559                "External file should get opened in a new worktree"
1560            );
1561
1562            WorktreeId::from_usize(
1563                worktrees
1564                    .into_iter()
1565                    .find(|worktree| {
1566                        worktree.entity_id().as_u64() as usize != worktree_id.to_usize()
1567                    })
1568                    .expect("New worktree should have a different id")
1569                    .entity_id()
1570                    .as_u64() as usize,
1571            )
1572        });
1573        cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
1574
1575        let initial_history_items =
1576            open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1577        assert_eq!(
1578            initial_history_items,
1579            vec![FoundPath::new(
1580                ProjectPath {
1581                    worktree_id: external_worktree_id,
1582                    path: Arc::from(Path::new("")),
1583                },
1584                Some(PathBuf::from("/external-src/test/third.rs"))
1585            )],
1586            "Should show external file with its full path in the history after it was open"
1587        );
1588
1589        let updated_history_items =
1590            open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1591        assert_eq!(
1592            updated_history_items,
1593            vec![
1594                FoundPath::new(
1595                    ProjectPath {
1596                        worktree_id,
1597                        path: Arc::from(Path::new("test/second.rs")),
1598                    },
1599                    Some(PathBuf::from("/src/test/second.rs"))
1600                ),
1601                FoundPath::new(
1602                    ProjectPath {
1603                        worktree_id: external_worktree_id,
1604                        path: Arc::from(Path::new("")),
1605                    },
1606                    Some(PathBuf::from("/external-src/test/third.rs"))
1607                ),
1608            ],
1609            "Should keep external file with history updates",
1610        );
1611    }
1612
1613    #[gpui::test]
1614    async fn test_toggle_panel_new_selections(cx: &mut gpui::TestAppContext) {
1615        let app_state = init_test(cx);
1616
1617        app_state
1618            .fs
1619            .as_fake()
1620            .insert_tree(
1621                "/src",
1622                json!({
1623                    "test": {
1624                        "first.rs": "// First Rust file",
1625                        "second.rs": "// Second Rust file",
1626                        "third.rs": "// Third Rust file",
1627                    }
1628                }),
1629            )
1630            .await;
1631
1632        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1633        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1634
1635        // generate some history to select from
1636        open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1637        cx.executor().run_until_parked();
1638        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1639        open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1640        let current_history =
1641            open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1642
1643        for expected_selected_index in 0..current_history.len() {
1644            cx.dispatch_action(Toggle);
1645            let picker = active_file_picker(&workspace, cx);
1646            let selected_index = picker.update(cx, |picker, _| picker.delegate.selected_index());
1647            assert_eq!(
1648                selected_index, expected_selected_index,
1649                "Should select the next item in the history"
1650            );
1651        }
1652
1653        cx.dispatch_action(Toggle);
1654        let selected_index = workspace.update(cx, |workspace, cx| {
1655            workspace
1656                .active_modal::<FileFinder>(cx)
1657                .unwrap()
1658                .read(cx)
1659                .picker
1660                .read(cx)
1661                .delegate
1662                .selected_index()
1663        });
1664        assert_eq!(
1665            selected_index, 0,
1666            "Should wrap around the history and start all over"
1667        );
1668    }
1669
1670    #[gpui::test]
1671    async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) {
1672        let app_state = init_test(cx);
1673
1674        app_state
1675            .fs
1676            .as_fake()
1677            .insert_tree(
1678                "/src",
1679                json!({
1680                    "test": {
1681                        "first.rs": "// First Rust file",
1682                        "second.rs": "// Second Rust file",
1683                        "third.rs": "// Third Rust file",
1684                        "fourth.rs": "// Fourth Rust file",
1685                    }
1686                }),
1687            )
1688            .await;
1689
1690        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1691        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1692        let worktree_id = cx.read(|cx| {
1693            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1694            assert_eq!(worktrees.len(), 1,);
1695
1696            WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1697        });
1698
1699        // generate some history to select from
1700        open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1701        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1702        open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1703        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1704
1705        let finder = open_file_picker(&workspace, cx);
1706        let first_query = "f";
1707        finder
1708            .update(cx, |finder, cx| {
1709                finder.delegate.update_matches(first_query.to_string(), cx)
1710            })
1711            .await;
1712        finder.update(cx, |finder, _| {
1713            let delegate = &finder.delegate;
1714            assert_eq!(delegate.matches.history.len(), 1, "Only one history item contains {first_query}, it should be present and others should be filtered out");
1715            let history_match = delegate.matches.history.first().unwrap();
1716            assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
1717            assert_eq!(history_match.0, FoundPath::new(
1718                ProjectPath {
1719                    worktree_id,
1720                    path: Arc::from(Path::new("test/first.rs")),
1721                },
1722                Some(PathBuf::from("/src/test/first.rs"))
1723            ));
1724            assert_eq!(delegate.matches.search.len(), 1, "Only one non-history item contains {first_query}, it should be present");
1725            assert_eq!(delegate.matches.search.first().unwrap().path.as_ref(), Path::new("test/fourth.rs"));
1726        });
1727
1728        let second_query = "fsdasdsa";
1729        let finder = active_file_picker(&workspace, cx);
1730        finder
1731            .update(cx, |finder, cx| {
1732                finder.delegate.update_matches(second_query.to_string(), cx)
1733            })
1734            .await;
1735        finder.update(cx, |finder, _| {
1736            let delegate = &finder.delegate;
1737            assert!(
1738                delegate.matches.history.is_empty(),
1739                "No history entries should match {second_query}"
1740            );
1741            assert!(
1742                delegate.matches.search.is_empty(),
1743                "No search entries should match {second_query}"
1744            );
1745        });
1746
1747        let first_query_again = first_query;
1748
1749        let finder = active_file_picker(&workspace, cx);
1750        finder
1751            .update(cx, |finder, cx| {
1752                finder
1753                    .delegate
1754                    .update_matches(first_query_again.to_string(), cx)
1755            })
1756            .await;
1757        finder.update(cx, |finder, _| {
1758            let delegate = &finder.delegate;
1759            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");
1760            let history_match = delegate.matches.history.first().unwrap();
1761            assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
1762            assert_eq!(history_match.0, FoundPath::new(
1763                ProjectPath {
1764                    worktree_id,
1765                    path: Arc::from(Path::new("test/first.rs")),
1766                },
1767                Some(PathBuf::from("/src/test/first.rs"))
1768            ));
1769            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");
1770            assert_eq!(delegate.matches.search.first().unwrap().path.as_ref(), Path::new("test/fourth.rs"));
1771        });
1772    }
1773
1774    #[gpui::test]
1775    async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) {
1776        let app_state = init_test(cx);
1777
1778        app_state
1779            .fs
1780            .as_fake()
1781            .insert_tree(
1782                "/src",
1783                json!({
1784                    "collab_ui": {
1785                        "first.rs": "// First Rust file",
1786                        "second.rs": "// Second Rust file",
1787                        "third.rs": "// Third Rust file",
1788                        "collab_ui.rs": "// Fourth Rust file",
1789                    }
1790                }),
1791            )
1792            .await;
1793
1794        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1795        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1796        // generate some history to select from
1797        open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1798        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1799        open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1800        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1801
1802        let finder = open_file_picker(&workspace, cx);
1803        let query = "collab_ui";
1804        cx.simulate_input(query);
1805        finder.update(cx, |finder, _| {
1806            let delegate = &finder.delegate;
1807            assert!(
1808                delegate.matches.history.is_empty(),
1809                "History items should not math query {query}, they should be matched by name only"
1810            );
1811
1812            let search_entries = delegate
1813                .matches
1814                .search
1815                .iter()
1816                .map(|path_match| path_match.path.to_path_buf())
1817                .collect::<Vec<_>>();
1818            assert_eq!(
1819                search_entries,
1820                vec![
1821                    PathBuf::from("collab_ui/collab_ui.rs"),
1822                    PathBuf::from("collab_ui/third.rs"),
1823                    PathBuf::from("collab_ui/first.rs"),
1824                    PathBuf::from("collab_ui/second.rs"),
1825                ],
1826                "Despite all search results having the same directory name, the most matching one should be on top"
1827            );
1828        });
1829    }
1830
1831    #[gpui::test]
1832    async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) {
1833        let app_state = init_test(cx);
1834
1835        app_state
1836            .fs
1837            .as_fake()
1838            .insert_tree(
1839                "/src",
1840                json!({
1841                    "test": {
1842                        "first.rs": "// First Rust file",
1843                        "nonexistent.rs": "// Second Rust file",
1844                        "third.rs": "// Third Rust file",
1845                    }
1846                }),
1847            )
1848            .await;
1849
1850        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1851        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); // generate some history to select from
1852        open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1853        open_close_queried_buffer("non", 1, "nonexistent.rs", &workspace, cx).await;
1854        open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1855        open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1856
1857        let picker = open_file_picker(&workspace, cx);
1858        cx.simulate_input("rs");
1859
1860        picker.update(cx, |finder, _| {
1861            let history_entries = finder.delegate
1862                .matches
1863                .history
1864                .iter()
1865                .map(|(_, path_match)| path_match.as_ref().expect("should have a path match").path.to_path_buf())
1866                .collect::<Vec<_>>();
1867            assert_eq!(
1868                history_entries,
1869                vec![
1870                    PathBuf::from("test/first.rs"),
1871                    PathBuf::from("test/third.rs"),
1872                ],
1873                "Should have all opened files in the history, except the ones that do not exist on disk"
1874            );
1875        });
1876    }
1877
1878    async fn open_close_queried_buffer(
1879        input: &str,
1880        expected_matches: usize,
1881        expected_editor_title: &str,
1882        workspace: &View<Workspace>,
1883        cx: &mut gpui::VisualTestContext,
1884    ) -> Vec<FoundPath> {
1885        let picker = open_file_picker(&workspace, cx);
1886        cx.simulate_input(input);
1887
1888        let history_items = picker.update(cx, |finder, _| {
1889            assert_eq!(
1890                finder.delegate.matches.len(),
1891                expected_matches,
1892                "Unexpected number of matches found for query {input}"
1893            );
1894            finder.delegate.history_items.clone()
1895        });
1896
1897        cx.dispatch_action(SelectNext);
1898        cx.dispatch_action(Confirm);
1899
1900        cx.read(|cx| {
1901            let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
1902            let active_editor_title = active_editor.read(cx).title(cx);
1903            assert_eq!(
1904                expected_editor_title, active_editor_title,
1905                "Unexpected editor title for query {input}"
1906            );
1907        });
1908
1909        cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
1910
1911        history_items
1912    }
1913
1914    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1915        cx.update(|cx| {
1916            let state = AppState::test(cx);
1917            theme::init(theme::LoadThemes::JustBase, cx);
1918            language::init(cx);
1919            super::init(cx);
1920            editor::init(cx);
1921            workspace::init_settings(cx);
1922            Project::init_settings(cx);
1923            state
1924        })
1925    }
1926
1927    fn test_path_like(test_str: &str) -> PathLikeWithPosition<FileSearchQuery> {
1928        PathLikeWithPosition::parse_str(test_str, |path_like_str| {
1929            Ok::<_, std::convert::Infallible>(FileSearchQuery {
1930                raw_query: test_str.to_owned(),
1931                file_query_end: if path_like_str == test_str {
1932                    None
1933                } else {
1934                    Some(path_like_str.len())
1935                },
1936            })
1937        })
1938        .unwrap()
1939    }
1940
1941    fn build_find_picker(
1942        project: Model<Project>,
1943        cx: &mut TestAppContext,
1944    ) -> (
1945        View<Picker<FileFinderDelegate>>,
1946        View<Workspace>,
1947        &mut VisualTestContext,
1948    ) {
1949        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1950        let picker = open_file_picker(&workspace, cx);
1951        (picker, workspace, cx)
1952    }
1953
1954    #[track_caller]
1955    fn open_file_picker(
1956        workspace: &View<Workspace>,
1957        cx: &mut VisualTestContext,
1958    ) -> View<Picker<FileFinderDelegate>> {
1959        cx.dispatch_action(Toggle);
1960        active_file_picker(workspace, cx)
1961    }
1962
1963    #[track_caller]
1964    fn active_file_picker(
1965        workspace: &View<Workspace>,
1966        cx: &mut VisualTestContext,
1967    ) -> View<Picker<FileFinderDelegate>> {
1968        workspace.update(cx, |workspace, cx| {
1969            workspace
1970                .active_modal::<FileFinder>(cx)
1971                .unwrap()
1972                .read(cx)
1973                .picker
1974                .clone()
1975        })
1976    }
1977
1978    fn collect_search_results(picker: &Picker<FileFinderDelegate>) -> Vec<PathBuf> {
1979        let matches = &picker.delegate.matches;
1980        assert!(
1981            matches.history.is_empty(),
1982            "Should have no history matches, but got: {:?}",
1983            matches.history
1984        );
1985        let mut results = matches
1986            .search
1987            .iter()
1988            .map(|path_match| Path::new(path_match.path_prefix.as_ref()).join(&path_match.path))
1989            .collect::<Vec<_>>();
1990        results.sort();
1991        results
1992    }
1993}