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