file_finder.rs

   1use collections::HashMap;
   2use editor::{scroll::autoscroll::Autoscroll, Bias, Editor};
   3use fuzzy::{CharBag, PathMatch, PathMatchCandidate};
   4use gpui::{
   5    actions, AppContext, DismissEvent, Div, 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::{v_stack, HighlightedLabel, ListItem};
  19use util::{paths::PathLikeWithPosition, post_inc, ResultExt};
  20use workspace::Workspace;
  21
  22actions!(Toggle);
  23
  24pub struct FileFinder {
  25    picker: View<Picker<FileFinderDelegate>>,
  26}
  27
  28pub fn init(cx: &mut AppContext) {
  29    cx.observe_new_views(FileFinder::register).detach();
  30}
  31
  32impl FileFinder {
  33    fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
  34        workspace.register_action(|workspace, _: &Toggle, cx| {
  35            let Some(file_finder) = workspace.active_modal::<Self>(cx) else {
  36                Self::open(workspace, cx);
  37                return;
  38            };
  39
  40            file_finder.update(cx, |file_finder, cx| {
  41                file_finder
  42                    .picker
  43                    .update(cx, |picker, cx| picker.cycle_selection(cx))
  44            });
  45        });
  46    }
  47
  48    fn open(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
  49        let project = workspace.project().read(cx);
  50
  51        let currently_opened_path = workspace
  52            .active_item(cx)
  53            .and_then(|item| item.project_path(cx))
  54            .map(|project_path| {
  55                let abs_path = project
  56                    .worktree_for_id(project_path.worktree_id, cx)
  57                    .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path));
  58                FoundPath::new(project_path, abs_path)
  59            });
  60
  61        // if exists, bubble the currently opened path to the top
  62        let history_items = currently_opened_path
  63            .clone()
  64            .into_iter()
  65            .chain(
  66                workspace
  67                    .recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx)
  68                    .into_iter()
  69                    .filter(|(history_path, _)| {
  70                        Some(history_path)
  71                            != currently_opened_path
  72                                .as_ref()
  73                                .map(|found_path| &found_path.project)
  74                    })
  75                    .filter(|(_, history_abs_path)| {
  76                        history_abs_path.as_ref()
  77                            != currently_opened_path
  78                                .as_ref()
  79                                .and_then(|found_path| found_path.absolute.as_ref())
  80                    })
  81                    .filter(|(_, history_abs_path)| match history_abs_path {
  82                        Some(abs_path) => history_file_exists(abs_path),
  83                        None => true,
  84                    })
  85                    .map(|(history_path, abs_path)| FoundPath::new(history_path, abs_path)),
  86            )
  87            .collect();
  88
  89        let project = workspace.project().clone();
  90        let weak_workspace = cx.view().downgrade();
  91        workspace.toggle_modal(cx, |cx| {
  92            let delegate = FileFinderDelegate::new(
  93                cx.view().downgrade(),
  94                weak_workspace,
  95                project,
  96                currently_opened_path,
  97                history_items,
  98                cx,
  99            );
 100
 101            FileFinder::new(delegate, cx)
 102        });
 103    }
 104
 105    fn new(delegate: FileFinderDelegate, cx: &mut ViewContext<Self>) -> Self {
 106        Self {
 107            picker: cx.build_view(|cx| Picker::new(delegate, cx)),
 108        }
 109    }
 110}
 111
 112impl EventEmitter<DismissEvent> for FileFinder {}
 113impl FocusableView for FileFinder {
 114    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
 115        self.picker.focus_handle(cx)
 116    }
 117}
 118impl Render for FileFinder {
 119    type Element = Div;
 120
 121    fn render(&mut self, _cx: &mut ViewContext<Self>) -> Self::Element {
 122        v_stack().w_96().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| {
 356                let worktree = worktree.read(cx);
 357                PathMatchCandidateSet {
 358                    snapshot: worktree.snapshot(),
 359                    include_ignored: worktree
 360                        .root_entry()
 361                        .map_or(false, |entry| entry.is_ignored),
 362                    include_root_name,
 363                }
 364            })
 365            .collect::<Vec<_>>();
 366
 367        let search_id = util::post_inc(&mut self.search_count);
 368        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
 369        self.cancel_flag = Arc::new(AtomicBool::new(false));
 370        let cancel_flag = self.cancel_flag.clone();
 371        cx.spawn(|picker, mut cx| async move {
 372            let matches = fuzzy::match_path_sets(
 373                candidate_sets.as_slice(),
 374                query.path_like.path_query(),
 375                relative_to,
 376                false,
 377                100,
 378                &cancel_flag,
 379                cx.background_executor().clone(),
 380            )
 381            .await;
 382            let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
 383            picker
 384                .update(&mut cx, |picker, cx| {
 385                    picker
 386                        .delegate
 387                        .set_search_matches(search_id, did_cancel, query, matches, cx)
 388                })
 389                .log_err();
 390        })
 391    }
 392
 393    fn set_search_matches(
 394        &mut self,
 395        search_id: usize,
 396        did_cancel: bool,
 397        query: PathLikeWithPosition<FileSearchQuery>,
 398        matches: Vec<PathMatch>,
 399        cx: &mut ViewContext<Picker<Self>>,
 400    ) {
 401        if search_id >= self.latest_search_id {
 402            self.latest_search_id = search_id;
 403            let extend_old_matches = self.latest_search_did_cancel
 404                && Some(query.path_like.path_query())
 405                    == self
 406                        .latest_search_query
 407                        .as_ref()
 408                        .map(|query| query.path_like.path_query());
 409            self.matches
 410                .push_new_matches(&self.history_items, &query, matches, extend_old_matches);
 411            self.latest_search_query = Some(query);
 412            self.latest_search_did_cancel = did_cancel;
 413            cx.notify();
 414        }
 415    }
 416
 417    fn labels_for_match(
 418        &self,
 419        path_match: Match,
 420        cx: &AppContext,
 421        ix: usize,
 422    ) -> (String, Vec<usize>, String, Vec<usize>) {
 423        let (file_name, file_name_positions, full_path, full_path_positions) = match path_match {
 424            Match::History(found_path, found_path_match) => {
 425                let worktree_id = found_path.project.worktree_id;
 426                let project_relative_path = &found_path.project.path;
 427                let has_worktree = self
 428                    .project
 429                    .read(cx)
 430                    .worktree_for_id(worktree_id, cx)
 431                    .is_some();
 432
 433                if !has_worktree {
 434                    if let Some(absolute_path) = &found_path.absolute {
 435                        return (
 436                            absolute_path
 437                                .file_name()
 438                                .map_or_else(
 439                                    || project_relative_path.to_string_lossy(),
 440                                    |file_name| file_name.to_string_lossy(),
 441                                )
 442                                .to_string(),
 443                            Vec::new(),
 444                            absolute_path.to_string_lossy().to_string(),
 445                            Vec::new(),
 446                        );
 447                    }
 448                }
 449
 450                let mut path = Arc::clone(project_relative_path);
 451                if project_relative_path.as_ref() == Path::new("") {
 452                    if let Some(absolute_path) = &found_path.absolute {
 453                        path = Arc::from(absolute_path.as_path());
 454                    }
 455                }
 456
 457                let mut path_match = PathMatch {
 458                    score: ix as f64,
 459                    positions: Vec::new(),
 460                    worktree_id: worktree_id.to_usize(),
 461                    path,
 462                    path_prefix: "".into(),
 463                    distance_to_relative_ancestor: usize::MAX,
 464                };
 465                if let Some(found_path_match) = found_path_match {
 466                    path_match
 467                        .positions
 468                        .extend(found_path_match.positions.iter())
 469                }
 470
 471                self.labels_for_path_match(&path_match)
 472            }
 473            Match::Search(path_match) => self.labels_for_path_match(path_match),
 474        };
 475
 476        if file_name_positions.is_empty() {
 477            if let Some(user_home_path) = std::env::var("HOME").ok() {
 478                let user_home_path = user_home_path.trim();
 479                if !user_home_path.is_empty() {
 480                    if (&full_path).starts_with(user_home_path) {
 481                        return (
 482                            file_name,
 483                            file_name_positions,
 484                            full_path.replace(user_home_path, "~"),
 485                            full_path_positions,
 486                        );
 487                    }
 488                }
 489            }
 490        }
 491
 492        (
 493            file_name,
 494            file_name_positions,
 495            full_path,
 496            full_path_positions,
 497        )
 498    }
 499
 500    fn labels_for_path_match(
 501        &self,
 502        path_match: &PathMatch,
 503    ) -> (String, Vec<usize>, String, Vec<usize>) {
 504        let path = &path_match.path;
 505        let path_string = path.to_string_lossy();
 506        let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
 507        let path_positions = path_match.positions.clone();
 508
 509        let file_name = path.file_name().map_or_else(
 510            || path_match.path_prefix.to_string(),
 511            |file_name| file_name.to_string_lossy().to_string(),
 512        );
 513        let file_name_start = path_match.path_prefix.chars().count() + path_string.chars().count()
 514            - file_name.chars().count();
 515        let file_name_positions = path_positions
 516            .iter()
 517            .filter_map(|pos| {
 518                if pos >= &file_name_start {
 519                    Some(pos - file_name_start)
 520                } else {
 521                    None
 522                }
 523            })
 524            .collect();
 525
 526        (file_name, file_name_positions, full_path, path_positions)
 527    }
 528}
 529
 530impl PickerDelegate for FileFinderDelegate {
 531    type ListItem = ListItem;
 532
 533    fn placeholder_text(&self) -> Arc<str> {
 534        "Search project files...".into()
 535    }
 536
 537    fn match_count(&self) -> usize {
 538        self.matches.len()
 539    }
 540
 541    fn selected_index(&self) -> usize {
 542        self.selected_index.unwrap_or(0)
 543    }
 544
 545    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
 546        self.selected_index = Some(ix);
 547        cx.notify();
 548    }
 549
 550    fn update_matches(
 551        &mut self,
 552        raw_query: String,
 553        cx: &mut ViewContext<Picker<Self>>,
 554    ) -> Task<()> {
 555        let raw_query = raw_query.trim();
 556        if raw_query.is_empty() {
 557            let project = self.project.read(cx);
 558            self.latest_search_id = post_inc(&mut self.search_count);
 559            self.matches = Matches {
 560                history: self
 561                    .history_items
 562                    .iter()
 563                    .filter(|history_item| {
 564                        project
 565                            .worktree_for_id(history_item.project.worktree_id, cx)
 566                            .is_some()
 567                            || (project.is_local() && history_item.absolute.is_some())
 568                    })
 569                    .cloned()
 570                    .map(|p| (p, None))
 571                    .collect(),
 572                search: Vec::new(),
 573            };
 574            cx.notify();
 575            Task::ready(())
 576        } else {
 577            let query = PathLikeWithPosition::parse_str(raw_query, |path_like_str| {
 578                Ok::<_, std::convert::Infallible>(FileSearchQuery {
 579                    raw_query: raw_query.to_owned(),
 580                    file_query_end: if path_like_str == raw_query {
 581                        None
 582                    } else {
 583                        Some(path_like_str.len())
 584                    },
 585                })
 586            })
 587            .expect("infallible");
 588            self.spawn_search(query, cx)
 589        }
 590    }
 591
 592    fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
 593        if let Some(m) = self.matches.get(self.selected_index()) {
 594            if let Some(workspace) = self.workspace.upgrade() {
 595                let open_task = workspace.update(cx, move |workspace, cx| {
 596                    let split_or_open = |workspace: &mut Workspace, project_path, cx| {
 597                        if secondary {
 598                            workspace.split_path(project_path, cx)
 599                        } else {
 600                            workspace.open_path(project_path, None, true, cx)
 601                        }
 602                    };
 603                    match m {
 604                        Match::History(history_match, _) => {
 605                            let worktree_id = history_match.project.worktree_id;
 606                            if workspace
 607                                .project()
 608                                .read(cx)
 609                                .worktree_for_id(worktree_id, cx)
 610                                .is_some()
 611                            {
 612                                split_or_open(
 613                                    workspace,
 614                                    ProjectPath {
 615                                        worktree_id,
 616                                        path: Arc::clone(&history_match.project.path),
 617                                    },
 618                                    cx,
 619                                )
 620                            } else {
 621                                match history_match.absolute.as_ref() {
 622                                    Some(abs_path) => {
 623                                        if secondary {
 624                                            workspace.split_abs_path(
 625                                                abs_path.to_path_buf(),
 626                                                false,
 627                                                cx,
 628                                            )
 629                                        } else {
 630                                            workspace.open_abs_path(
 631                                                abs_path.to_path_buf(),
 632                                                false,
 633                                                cx,
 634                                            )
 635                                        }
 636                                    }
 637                                    None => split_or_open(
 638                                        workspace,
 639                                        ProjectPath {
 640                                            worktree_id,
 641                                            path: Arc::clone(&history_match.project.path),
 642                                        },
 643                                        cx,
 644                                    ),
 645                                }
 646                            }
 647                        }
 648                        Match::Search(m) => split_or_open(
 649                            workspace,
 650                            ProjectPath {
 651                                worktree_id: WorktreeId::from_usize(m.worktree_id),
 652                                path: m.path.clone(),
 653                            },
 654                            cx,
 655                        ),
 656                    }
 657                });
 658
 659                let row = self
 660                    .latest_search_query
 661                    .as_ref()
 662                    .and_then(|query| query.row)
 663                    .map(|row| row.saturating_sub(1));
 664                let col = self
 665                    .latest_search_query
 666                    .as_ref()
 667                    .and_then(|query| query.column)
 668                    .unwrap_or(0)
 669                    .saturating_sub(1);
 670                let finder = self.file_finder.clone();
 671
 672                cx.spawn(|_, mut cx| async move {
 673                    let item = open_task.await.log_err()?;
 674                    if let Some(row) = row {
 675                        if let Some(active_editor) = item.downcast::<Editor>() {
 676                            active_editor
 677                                .downgrade()
 678                                .update(&mut cx, |editor, cx| {
 679                                    let snapshot = editor.snapshot(cx).display_snapshot;
 680                                    let point = snapshot
 681                                        .buffer_snapshot
 682                                        .clip_point(Point::new(row, col), Bias::Left);
 683                                    editor.change_selections(Some(Autoscroll::center()), cx, |s| {
 684                                        s.select_ranges([point..point])
 685                                    });
 686                                })
 687                                .log_err();
 688                        }
 689                    }
 690                    finder
 691                        .update(&mut cx, |_, cx| cx.emit(DismissEvent::Dismiss))
 692                        .ok()?;
 693
 694                    Some(())
 695                })
 696                .detach();
 697            }
 698        }
 699    }
 700
 701    fn dismissed(&mut self, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
 702        self.file_finder
 703            .update(cx, |_, cx| cx.emit(DismissEvent::Dismiss))
 704            .log_err();
 705    }
 706
 707    fn render_match(
 708        &self,
 709        ix: usize,
 710        selected: bool,
 711        cx: &mut ViewContext<Picker<Self>>,
 712    ) -> Option<Self::ListItem> {
 713        let path_match = self
 714            .matches
 715            .get(ix)
 716            .expect("Invalid matches state: no element for index {ix}");
 717
 718        let (file_name, file_name_positions, full_path, full_path_positions) =
 719            self.labels_for_match(path_match, cx, ix);
 720
 721        Some(
 722            ListItem::new(ix).inset(true).selected(selected).child(
 723                v_stack()
 724                    .child(HighlightedLabel::new(file_name, file_name_positions))
 725                    .child(HighlightedLabel::new(full_path, full_path_positions)),
 726            ),
 727        )
 728    }
 729}
 730
 731#[cfg(test)]
 732mod tests {
 733    use std::{assert_eq, path::Path, time::Duration};
 734
 735    use super::*;
 736    use editor::Editor;
 737    use gpui::{Entity, TestAppContext, VisualTestContext};
 738    use menu::{Confirm, SelectNext};
 739    use serde_json::json;
 740    use workspace::{AppState, Workspace};
 741
 742    #[ctor::ctor]
 743    fn init_logger() {
 744        if std::env::var("RUST_LOG").is_ok() {
 745            env_logger::init();
 746        }
 747    }
 748
 749    #[gpui::test]
 750    async fn test_matching_paths(cx: &mut TestAppContext) {
 751        let app_state = init_test(cx);
 752        app_state
 753            .fs
 754            .as_fake()
 755            .insert_tree(
 756                "/root",
 757                json!({
 758                    "a": {
 759                        "banana": "",
 760                        "bandana": "",
 761                    }
 762                }),
 763            )
 764            .await;
 765
 766        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
 767
 768        let (picker, workspace, cx) = build_find_picker(project, cx);
 769
 770        cx.simulate_input("bna");
 771        picker.update(cx, |picker, _| {
 772            assert_eq!(picker.delegate.matches.len(), 2);
 773        });
 774        cx.dispatch_action(SelectNext);
 775        cx.dispatch_action(Confirm);
 776        cx.read(|cx| {
 777            let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
 778            assert_eq!(active_editor.read(cx).title(cx), "bandana");
 779        });
 780
 781        for bandana_query in [
 782            "bandana",
 783            " bandana",
 784            "bandana ",
 785            " bandana ",
 786            " ndan ",
 787            " band ",
 788        ] {
 789            picker
 790                .update(cx, |picker, cx| {
 791                    picker
 792                        .delegate
 793                        .update_matches(bandana_query.to_string(), cx)
 794                })
 795                .await;
 796            picker.update(cx, |picker, _| {
 797                assert_eq!(
 798                    picker.delegate.matches.len(),
 799                    1,
 800                    "Wrong number of matches for bandana query '{bandana_query}'"
 801                );
 802            });
 803            cx.dispatch_action(SelectNext);
 804            cx.dispatch_action(Confirm);
 805            cx.read(|cx| {
 806                let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
 807                assert_eq!(
 808                    active_editor.read(cx).title(cx),
 809                    "bandana",
 810                    "Wrong match for bandana query '{bandana_query}'"
 811                );
 812            });
 813        }
 814    }
 815
 816    #[gpui::test]
 817    async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
 818        let app_state = init_test(cx);
 819
 820        let first_file_name = "first.rs";
 821        let first_file_contents = "// First Rust file";
 822        app_state
 823            .fs
 824            .as_fake()
 825            .insert_tree(
 826                "/src",
 827                json!({
 828                    "test": {
 829                        first_file_name: first_file_contents,
 830                        "second.rs": "// Second Rust file",
 831                    }
 832                }),
 833            )
 834            .await;
 835
 836        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
 837
 838        let (picker, workspace, cx) = build_find_picker(project, cx);
 839
 840        let file_query = &first_file_name[..3];
 841        let file_row = 1;
 842        let file_column = 3;
 843        assert!(file_column <= first_file_contents.len());
 844        let query_inside_file = format!("{file_query}:{file_row}:{file_column}");
 845        picker
 846            .update(cx, |finder, cx| {
 847                finder
 848                    .delegate
 849                    .update_matches(query_inside_file.to_string(), cx)
 850            })
 851            .await;
 852        picker.update(cx, |finder, _| {
 853            let finder = &finder.delegate;
 854            assert_eq!(finder.matches.len(), 1);
 855            let latest_search_query = finder
 856                .latest_search_query
 857                .as_ref()
 858                .expect("Finder should have a query after the update_matches call");
 859            assert_eq!(latest_search_query.path_like.raw_query, query_inside_file);
 860            assert_eq!(
 861                latest_search_query.path_like.file_query_end,
 862                Some(file_query.len())
 863            );
 864            assert_eq!(latest_search_query.row, Some(file_row));
 865            assert_eq!(latest_search_query.column, Some(file_column as u32));
 866        });
 867
 868        cx.dispatch_action(SelectNext);
 869        cx.dispatch_action(Confirm);
 870
 871        let editor = cx.update(|cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
 872        cx.executor().advance_clock(Duration::from_secs(2));
 873
 874        editor.update(cx, |editor, cx| {
 875                let all_selections = editor.selections.all_adjusted(cx);
 876                assert_eq!(
 877                    all_selections.len(),
 878                    1,
 879                    "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
 880                );
 881                let caret_selection = all_selections.into_iter().next().unwrap();
 882                assert_eq!(caret_selection.start, caret_selection.end,
 883                    "Caret selection should have its start and end at the same position");
 884                assert_eq!(file_row, caret_selection.start.row + 1,
 885                    "Query inside file should get caret with the same focus row");
 886                assert_eq!(file_column, caret_selection.start.column as usize + 1,
 887                    "Query inside file should get caret with the same focus column");
 888            });
 889    }
 890
 891    #[gpui::test]
 892    async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
 893        let app_state = init_test(cx);
 894
 895        let first_file_name = "first.rs";
 896        let first_file_contents = "// First Rust file";
 897        app_state
 898            .fs
 899            .as_fake()
 900            .insert_tree(
 901                "/src",
 902                json!({
 903                    "test": {
 904                        first_file_name: first_file_contents,
 905                        "second.rs": "// Second Rust file",
 906                    }
 907                }),
 908            )
 909            .await;
 910
 911        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
 912
 913        let (picker, workspace, cx) = build_find_picker(project, cx);
 914
 915        let file_query = &first_file_name[..3];
 916        let file_row = 200;
 917        let file_column = 300;
 918        assert!(file_column > first_file_contents.len());
 919        let query_outside_file = format!("{file_query}:{file_row}:{file_column}");
 920        picker
 921            .update(cx, |picker, cx| {
 922                picker
 923                    .delegate
 924                    .update_matches(query_outside_file.to_string(), cx)
 925            })
 926            .await;
 927        picker.update(cx, |finder, _| {
 928            let delegate = &finder.delegate;
 929            assert_eq!(delegate.matches.len(), 1);
 930            let latest_search_query = delegate
 931                .latest_search_query
 932                .as_ref()
 933                .expect("Finder should have a query after the update_matches call");
 934            assert_eq!(latest_search_query.path_like.raw_query, query_outside_file);
 935            assert_eq!(
 936                latest_search_query.path_like.file_query_end,
 937                Some(file_query.len())
 938            );
 939            assert_eq!(latest_search_query.row, Some(file_row));
 940            assert_eq!(latest_search_query.column, Some(file_column as u32));
 941        });
 942
 943        cx.dispatch_action(SelectNext);
 944        cx.dispatch_action(Confirm);
 945
 946        let editor = cx.update(|cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
 947        cx.executor().advance_clock(Duration::from_secs(2));
 948
 949        editor.update(cx, |editor, cx| {
 950                let all_selections = editor.selections.all_adjusted(cx);
 951                assert_eq!(
 952                    all_selections.len(),
 953                    1,
 954                    "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
 955                );
 956                let caret_selection = all_selections.into_iter().next().unwrap();
 957                assert_eq!(caret_selection.start, caret_selection.end,
 958                    "Caret selection should have its start and end at the same position");
 959                assert_eq!(0, caret_selection.start.row,
 960                    "Excessive rows (as in query outside file borders) should get trimmed to last file row");
 961                assert_eq!(first_file_contents.len(), caret_selection.start.column as usize,
 962                    "Excessive columns (as in query outside file borders) should get trimmed to selected row's last column");
 963            });
 964    }
 965
 966    #[gpui::test]
 967    async fn test_matching_cancellation(cx: &mut TestAppContext) {
 968        let app_state = init_test(cx);
 969        app_state
 970            .fs
 971            .as_fake()
 972            .insert_tree(
 973                "/dir",
 974                json!({
 975                    "hello": "",
 976                    "goodbye": "",
 977                    "halogen-light": "",
 978                    "happiness": "",
 979                    "height": "",
 980                    "hi": "",
 981                    "hiccup": "",
 982                }),
 983            )
 984            .await;
 985
 986        let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
 987
 988        let (picker, _, cx) = build_find_picker(project, cx);
 989
 990        let query = test_path_like("hi");
 991        picker
 992            .update(cx, |picker, cx| {
 993                picker.delegate.spawn_search(query.clone(), cx)
 994            })
 995            .await;
 996
 997        picker.update(cx, |picker, _cx| {
 998            assert_eq!(picker.delegate.matches.len(), 5)
 999        });
1000
1001        picker.update(cx, |picker, cx| {
1002            let delegate = &mut picker.delegate;
1003            assert!(
1004                delegate.matches.history.is_empty(),
1005                "Search matches expected"
1006            );
1007            let matches = delegate.matches.search.clone();
1008
1009            // Simulate a search being cancelled after the time limit,
1010            // returning only a subset of the matches that would have been found.
1011            drop(delegate.spawn_search(query.clone(), cx));
1012            delegate.set_search_matches(
1013                delegate.latest_search_id,
1014                true, // did-cancel
1015                query.clone(),
1016                vec![matches[1].clone(), matches[3].clone()],
1017                cx,
1018            );
1019
1020            // Simulate another cancellation.
1021            drop(delegate.spawn_search(query.clone(), cx));
1022            delegate.set_search_matches(
1023                delegate.latest_search_id,
1024                true, // did-cancel
1025                query.clone(),
1026                vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
1027                cx,
1028            );
1029
1030            assert!(
1031                delegate.matches.history.is_empty(),
1032                "Search matches expected"
1033            );
1034            assert_eq!(delegate.matches.search.as_slice(), &matches[0..4]);
1035        });
1036    }
1037
1038    #[gpui::test]
1039    async fn test_ignored_files(cx: &mut TestAppContext) {
1040        let app_state = init_test(cx);
1041        app_state
1042            .fs
1043            .as_fake()
1044            .insert_tree(
1045                "/ancestor",
1046                json!({
1047                    ".gitignore": "ignored-root",
1048                    "ignored-root": {
1049                        "happiness": "",
1050                        "height": "",
1051                        "hi": "",
1052                        "hiccup": "",
1053                    },
1054                    "tracked-root": {
1055                        ".gitignore": "height",
1056                        "happiness": "",
1057                        "height": "",
1058                        "hi": "",
1059                        "hiccup": "",
1060                    },
1061                }),
1062            )
1063            .await;
1064
1065        let project = Project::test(
1066            app_state.fs.clone(),
1067            [
1068                "/ancestor/tracked-root".as_ref(),
1069                "/ancestor/ignored-root".as_ref(),
1070            ],
1071            cx,
1072        )
1073        .await;
1074
1075        let (picker, _, cx) = build_find_picker(project, cx);
1076
1077        picker
1078            .update(cx, |picker, cx| {
1079                picker.delegate.spawn_search(test_path_like("hi"), cx)
1080            })
1081            .await;
1082        picker.update(cx, |picker, _| assert_eq!(picker.delegate.matches.len(), 7));
1083    }
1084
1085    #[gpui::test]
1086    async fn test_single_file_worktrees(cx: &mut TestAppContext) {
1087        let app_state = init_test(cx);
1088        app_state
1089            .fs
1090            .as_fake()
1091            .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
1092            .await;
1093
1094        let project = Project::test(
1095            app_state.fs.clone(),
1096            ["/root/the-parent-dir/the-file".as_ref()],
1097            cx,
1098        )
1099        .await;
1100
1101        let (picker, _, cx) = build_find_picker(project, cx);
1102
1103        // Even though there is only one worktree, that worktree's filename
1104        // is included in the matching, because the worktree is a single file.
1105        picker
1106            .update(cx, |picker, cx| {
1107                picker.delegate.spawn_search(test_path_like("thf"), cx)
1108            })
1109            .await;
1110        cx.read(|cx| {
1111            let picker = picker.read(cx);
1112            let delegate = &picker.delegate;
1113            assert!(
1114                delegate.matches.history.is_empty(),
1115                "Search matches expected"
1116            );
1117            let matches = delegate.matches.search.clone();
1118            assert_eq!(matches.len(), 1);
1119
1120            let (file_name, file_name_positions, full_path, full_path_positions) =
1121                delegate.labels_for_path_match(&matches[0]);
1122            assert_eq!(file_name, "the-file");
1123            assert_eq!(file_name_positions, &[0, 1, 4]);
1124            assert_eq!(full_path, "the-file");
1125            assert_eq!(full_path_positions, &[0, 1, 4]);
1126        });
1127
1128        // Since the worktree root is a file, searching for its name followed by a slash does
1129        // not match anything.
1130        picker
1131            .update(cx, |f, cx| {
1132                f.delegate.spawn_search(test_path_like("thf/"), cx)
1133            })
1134            .await;
1135        picker.update(cx, |f, _| assert_eq!(f.delegate.matches.len(), 0));
1136    }
1137
1138    #[gpui::test]
1139    async fn test_path_distance_ordering(cx: &mut TestAppContext) {
1140        let app_state = init_test(cx);
1141        app_state
1142            .fs
1143            .as_fake()
1144            .insert_tree(
1145                "/root",
1146                json!({
1147                    "dir1": { "a.txt": "" },
1148                    "dir2": {
1149                        "a.txt": "",
1150                        "b.txt": ""
1151                    }
1152                }),
1153            )
1154            .await;
1155
1156        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1157        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1158
1159        let worktree_id = cx.read(|cx| {
1160            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1161            assert_eq!(worktrees.len(), 1);
1162            WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1163        });
1164
1165        // When workspace has an active item, sort items which are closer to that item
1166        // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
1167        // so that one should be sorted earlier
1168        let b_path = ProjectPath {
1169            worktree_id,
1170            path: Arc::from(Path::new("/root/dir2/b.txt")),
1171        };
1172        workspace
1173            .update(cx, |workspace, cx| {
1174                workspace.open_path(b_path, None, true, cx)
1175            })
1176            .await
1177            .unwrap();
1178        let finder = open_file_picker(&workspace, cx);
1179        finder
1180            .update(cx, |f, cx| {
1181                f.delegate.spawn_search(test_path_like("a.txt"), cx)
1182            })
1183            .await;
1184
1185        finder.update(cx, |f, _| {
1186            let delegate = &f.delegate;
1187            assert!(
1188                delegate.matches.history.is_empty(),
1189                "Search matches expected"
1190            );
1191            let matches = delegate.matches.search.clone();
1192            assert_eq!(matches[0].path.as_ref(), Path::new("dir2/a.txt"));
1193            assert_eq!(matches[1].path.as_ref(), Path::new("dir1/a.txt"));
1194        });
1195    }
1196
1197    #[gpui::test]
1198    async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
1199        let app_state = init_test(cx);
1200        app_state
1201            .fs
1202            .as_fake()
1203            .insert_tree(
1204                "/root",
1205                json!({
1206                    "dir1": {},
1207                    "dir2": {
1208                        "dir3": {}
1209                    }
1210                }),
1211            )
1212            .await;
1213
1214        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1215        let (picker, _workspace, cx) = build_find_picker(project, cx);
1216
1217        picker
1218            .update(cx, |f, cx| {
1219                f.delegate.spawn_search(test_path_like("dir"), cx)
1220            })
1221            .await;
1222        cx.read(|cx| {
1223            let finder = picker.read(cx);
1224            assert_eq!(finder.delegate.matches.len(), 0);
1225        });
1226    }
1227
1228    #[gpui::test]
1229    async fn test_query_history(cx: &mut gpui::TestAppContext) {
1230        let app_state = init_test(cx);
1231
1232        app_state
1233            .fs
1234            .as_fake()
1235            .insert_tree(
1236                "/src",
1237                json!({
1238                    "test": {
1239                        "first.rs": "// First Rust file",
1240                        "second.rs": "// Second Rust file",
1241                        "third.rs": "// Third Rust file",
1242                    }
1243                }),
1244            )
1245            .await;
1246
1247        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1248        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1249        let worktree_id = cx.read(|cx| {
1250            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1251            assert_eq!(worktrees.len(), 1);
1252            WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1253        });
1254
1255        // Open and close panels, getting their history items afterwards.
1256        // Ensure history items get populated with opened items, and items are kept in a certain order.
1257        // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen.
1258        //
1259        // TODO: without closing, the opened items do not propagate their history changes for some reason
1260        // it does work in real app though, only tests do not propagate.
1261        workspace.update(cx, |_, cx| dbg!(cx.focused()));
1262
1263        let initial_history = open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1264        assert!(
1265            initial_history.is_empty(),
1266            "Should have no history before opening any files"
1267        );
1268
1269        let history_after_first =
1270            open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1271        assert_eq!(
1272            history_after_first,
1273            vec![FoundPath::new(
1274                ProjectPath {
1275                    worktree_id,
1276                    path: Arc::from(Path::new("test/first.rs")),
1277                },
1278                Some(PathBuf::from("/src/test/first.rs"))
1279            )],
1280            "Should show 1st opened item in the history when opening the 2nd item"
1281        );
1282
1283        let history_after_second =
1284            open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1285        assert_eq!(
1286            history_after_second,
1287            vec![
1288                FoundPath::new(
1289                    ProjectPath {
1290                        worktree_id,
1291                        path: Arc::from(Path::new("test/second.rs")),
1292                    },
1293                    Some(PathBuf::from("/src/test/second.rs"))
1294                ),
1295                FoundPath::new(
1296                    ProjectPath {
1297                        worktree_id,
1298                        path: Arc::from(Path::new("test/first.rs")),
1299                    },
1300                    Some(PathBuf::from("/src/test/first.rs"))
1301                ),
1302            ],
1303            "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
1304    2nd item should be the first in the history, as the last opened."
1305        );
1306
1307        let history_after_third =
1308            open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1309        assert_eq!(
1310                history_after_third,
1311                vec![
1312                    FoundPath::new(
1313                        ProjectPath {
1314                            worktree_id,
1315                            path: Arc::from(Path::new("test/third.rs")),
1316                        },
1317                        Some(PathBuf::from("/src/test/third.rs"))
1318                    ),
1319                    FoundPath::new(
1320                        ProjectPath {
1321                            worktree_id,
1322                            path: Arc::from(Path::new("test/second.rs")),
1323                        },
1324                        Some(PathBuf::from("/src/test/second.rs"))
1325                    ),
1326                    FoundPath::new(
1327                        ProjectPath {
1328                            worktree_id,
1329                            path: Arc::from(Path::new("test/first.rs")),
1330                        },
1331                        Some(PathBuf::from("/src/test/first.rs"))
1332                    ),
1333                ],
1334                "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
1335    3rd item should be the first in the history, as the last opened."
1336            );
1337
1338        let history_after_second_again =
1339            open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1340        assert_eq!(
1341                history_after_second_again,
1342                vec![
1343                    FoundPath::new(
1344                        ProjectPath {
1345                            worktree_id,
1346                            path: Arc::from(Path::new("test/second.rs")),
1347                        },
1348                        Some(PathBuf::from("/src/test/second.rs"))
1349                    ),
1350                    FoundPath::new(
1351                        ProjectPath {
1352                            worktree_id,
1353                            path: Arc::from(Path::new("test/third.rs")),
1354                        },
1355                        Some(PathBuf::from("/src/test/third.rs"))
1356                    ),
1357                    FoundPath::new(
1358                        ProjectPath {
1359                            worktree_id,
1360                            path: Arc::from(Path::new("test/first.rs")),
1361                        },
1362                        Some(PathBuf::from("/src/test/first.rs"))
1363                    ),
1364                ],
1365                "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
1366    2nd item, as the last opened, 3rd item should go next as it was opened right before."
1367            );
1368    }
1369
1370    #[gpui::test]
1371    async fn test_external_files_history(cx: &mut gpui::TestAppContext) {
1372        let app_state = init_test(cx);
1373
1374        app_state
1375            .fs
1376            .as_fake()
1377            .insert_tree(
1378                "/src",
1379                json!({
1380                    "test": {
1381                        "first.rs": "// First Rust file",
1382                        "second.rs": "// Second Rust file",
1383                    }
1384                }),
1385            )
1386            .await;
1387
1388        app_state
1389            .fs
1390            .as_fake()
1391            .insert_tree(
1392                "/external-src",
1393                json!({
1394                    "test": {
1395                        "third.rs": "// Third Rust file",
1396                        "fourth.rs": "// Fourth Rust file",
1397                    }
1398                }),
1399            )
1400            .await;
1401
1402        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1403        cx.update(|cx| {
1404            project.update(cx, |project, cx| {
1405                project.find_or_create_local_worktree("/external-src", false, cx)
1406            })
1407        })
1408        .detach();
1409        cx.background_executor.run_until_parked();
1410
1411        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1412        let worktree_id = cx.read(|cx| {
1413            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1414            assert_eq!(worktrees.len(), 1,);
1415
1416            WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1417        });
1418        workspace
1419            .update(cx, |workspace, cx| {
1420                workspace.open_abs_path(PathBuf::from("/external-src/test/third.rs"), false, cx)
1421            })
1422            .detach();
1423        cx.background_executor.run_until_parked();
1424        let external_worktree_id = cx.read(|cx| {
1425            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1426            assert_eq!(
1427                worktrees.len(),
1428                2,
1429                "External file should get opened in a new worktree"
1430            );
1431
1432            WorktreeId::from_usize(
1433                worktrees
1434                    .into_iter()
1435                    .find(|worktree| {
1436                        worktree.entity_id().as_u64() as usize != worktree_id.to_usize()
1437                    })
1438                    .expect("New worktree should have a different id")
1439                    .entity_id()
1440                    .as_u64() as usize,
1441            )
1442        });
1443        cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
1444
1445        let initial_history_items =
1446            open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1447        assert_eq!(
1448            initial_history_items,
1449            vec![FoundPath::new(
1450                ProjectPath {
1451                    worktree_id: external_worktree_id,
1452                    path: Arc::from(Path::new("")),
1453                },
1454                Some(PathBuf::from("/external-src/test/third.rs"))
1455            )],
1456            "Should show external file with its full path in the history after it was open"
1457        );
1458
1459        let updated_history_items =
1460            open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1461        assert_eq!(
1462            updated_history_items,
1463            vec![
1464                FoundPath::new(
1465                    ProjectPath {
1466                        worktree_id,
1467                        path: Arc::from(Path::new("test/second.rs")),
1468                    },
1469                    Some(PathBuf::from("/src/test/second.rs"))
1470                ),
1471                FoundPath::new(
1472                    ProjectPath {
1473                        worktree_id: external_worktree_id,
1474                        path: Arc::from(Path::new("")),
1475                    },
1476                    Some(PathBuf::from("/external-src/test/third.rs"))
1477                ),
1478            ],
1479            "Should keep external file with history updates",
1480        );
1481    }
1482
1483    #[gpui::test]
1484    async fn test_toggle_panel_new_selections(cx: &mut gpui::TestAppContext) {
1485        let app_state = init_test(cx);
1486
1487        app_state
1488            .fs
1489            .as_fake()
1490            .insert_tree(
1491                "/src",
1492                json!({
1493                    "test": {
1494                        "first.rs": "// First Rust file",
1495                        "second.rs": "// Second Rust file",
1496                        "third.rs": "// Third Rust file",
1497                    }
1498                }),
1499            )
1500            .await;
1501
1502        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1503        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1504
1505        // generate some history to select from
1506        open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1507        cx.executor().run_until_parked();
1508        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1509        open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1510        let current_history =
1511            open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1512
1513        for expected_selected_index in 0..current_history.len() {
1514            cx.dispatch_action(Toggle);
1515            let picker = active_file_picker(&workspace, cx);
1516            let selected_index = picker.update(cx, |picker, _| picker.delegate.selected_index());
1517            assert_eq!(
1518                selected_index, expected_selected_index,
1519                "Should select the next item in the history"
1520            );
1521        }
1522
1523        cx.dispatch_action(Toggle);
1524        let selected_index = workspace.update(cx, |workspace, cx| {
1525            workspace
1526                .active_modal::<FileFinder>(cx)
1527                .unwrap()
1528                .read(cx)
1529                .picker
1530                .read(cx)
1531                .delegate
1532                .selected_index()
1533        });
1534        assert_eq!(
1535            selected_index, 0,
1536            "Should wrap around the history and start all over"
1537        );
1538    }
1539
1540    #[gpui::test]
1541    async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) {
1542        let app_state = init_test(cx);
1543
1544        app_state
1545            .fs
1546            .as_fake()
1547            .insert_tree(
1548                "/src",
1549                json!({
1550                    "test": {
1551                        "first.rs": "// First Rust file",
1552                        "second.rs": "// Second Rust file",
1553                        "third.rs": "// Third Rust file",
1554                        "fourth.rs": "// Fourth Rust file",
1555                    }
1556                }),
1557            )
1558            .await;
1559
1560        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1561        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1562        let worktree_id = cx.read(|cx| {
1563            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1564            assert_eq!(worktrees.len(), 1,);
1565
1566            WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1567        });
1568
1569        // generate some history to select from
1570        open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1571        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1572        open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1573        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1574
1575        let finder = open_file_picker(&workspace, cx);
1576        let first_query = "f";
1577        finder
1578            .update(cx, |finder, cx| {
1579                finder.delegate.update_matches(first_query.to_string(), cx)
1580            })
1581            .await;
1582        finder.update(cx, |finder, _| {
1583            let delegate = &finder.delegate;
1584            assert_eq!(delegate.matches.history.len(), 1, "Only one history item contains {first_query}, it should be present and others should be filtered out");
1585            let history_match = delegate.matches.history.first().unwrap();
1586            assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
1587            assert_eq!(history_match.0, FoundPath::new(
1588                ProjectPath {
1589                    worktree_id,
1590                    path: Arc::from(Path::new("test/first.rs")),
1591                },
1592                Some(PathBuf::from("/src/test/first.rs"))
1593            ));
1594            assert_eq!(delegate.matches.search.len(), 1, "Only one non-history item contains {first_query}, it should be present");
1595            assert_eq!(delegate.matches.search.first().unwrap().path.as_ref(), Path::new("test/fourth.rs"));
1596        });
1597
1598        let second_query = "fsdasdsa";
1599        let finder = active_file_picker(&workspace, cx);
1600        finder
1601            .update(cx, |finder, cx| {
1602                finder.delegate.update_matches(second_query.to_string(), cx)
1603            })
1604            .await;
1605        finder.update(cx, |finder, _| {
1606            let delegate = &finder.delegate;
1607            assert!(
1608                delegate.matches.history.is_empty(),
1609                "No history entries should match {second_query}"
1610            );
1611            assert!(
1612                delegate.matches.search.is_empty(),
1613                "No search entries should match {second_query}"
1614            );
1615        });
1616
1617        let first_query_again = first_query;
1618
1619        let finder = active_file_picker(&workspace, cx);
1620        finder
1621            .update(cx, |finder, cx| {
1622                finder
1623                    .delegate
1624                    .update_matches(first_query_again.to_string(), cx)
1625            })
1626            .await;
1627        finder.update(cx, |finder, _| {
1628            let delegate = &finder.delegate;
1629            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");
1630            let history_match = delegate.matches.history.first().unwrap();
1631            assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
1632            assert_eq!(history_match.0, FoundPath::new(
1633                ProjectPath {
1634                    worktree_id,
1635                    path: Arc::from(Path::new("test/first.rs")),
1636                },
1637                Some(PathBuf::from("/src/test/first.rs"))
1638            ));
1639            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");
1640            assert_eq!(delegate.matches.search.first().unwrap().path.as_ref(), Path::new("test/fourth.rs"));
1641        });
1642    }
1643
1644    #[gpui::test]
1645    async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) {
1646        let app_state = init_test(cx);
1647
1648        app_state
1649            .fs
1650            .as_fake()
1651            .insert_tree(
1652                "/src",
1653                json!({
1654                    "collab_ui": {
1655                        "first.rs": "// First Rust file",
1656                        "second.rs": "// Second Rust file",
1657                        "third.rs": "// Third Rust file",
1658                        "collab_ui.rs": "// Fourth Rust file",
1659                    }
1660                }),
1661            )
1662            .await;
1663
1664        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1665        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1666        // generate some history to select from
1667        open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1668        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1669        open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1670        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1671
1672        let finder = open_file_picker(&workspace, cx);
1673        let query = "collab_ui";
1674        cx.simulate_input(query);
1675        finder.update(cx, |finder, _| {
1676            let delegate = &finder.delegate;
1677            assert!(
1678                delegate.matches.history.is_empty(),
1679                "History items should not math query {query}, they should be matched by name only"
1680            );
1681
1682            let search_entries = delegate
1683                .matches
1684                .search
1685                .iter()
1686                .map(|path_match| path_match.path.to_path_buf())
1687                .collect::<Vec<_>>();
1688            assert_eq!(
1689                search_entries,
1690                vec![
1691                    PathBuf::from("collab_ui/collab_ui.rs"),
1692                    PathBuf::from("collab_ui/third.rs"),
1693                    PathBuf::from("collab_ui/first.rs"),
1694                    PathBuf::from("collab_ui/second.rs"),
1695                ],
1696                "Despite all search results having the same directory name, the most matching one should be on top"
1697            );
1698        });
1699    }
1700
1701    #[gpui::test]
1702    async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) {
1703        let app_state = init_test(cx);
1704
1705        app_state
1706            .fs
1707            .as_fake()
1708            .insert_tree(
1709                "/src",
1710                json!({
1711                    "test": {
1712                        "first.rs": "// First Rust file",
1713                        "nonexistent.rs": "// Second Rust file",
1714                        "third.rs": "// Third Rust file",
1715                    }
1716                }),
1717            )
1718            .await;
1719
1720        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1721        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); // generate some history to select from
1722        open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1723        open_close_queried_buffer("non", 1, "nonexistent.rs", &workspace, cx).await;
1724        open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1725        open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1726
1727        let picker = open_file_picker(&workspace, cx);
1728        cx.simulate_input("rs");
1729
1730        picker.update(cx, |finder, _| {
1731            let history_entries = finder.delegate
1732                .matches
1733                .history
1734                .iter()
1735                .map(|(_, path_match)| path_match.as_ref().expect("should have a path match").path.to_path_buf())
1736                .collect::<Vec<_>>();
1737            assert_eq!(
1738                history_entries,
1739                vec![
1740                    PathBuf::from("test/first.rs"),
1741                    PathBuf::from("test/third.rs"),
1742                ],
1743                "Should have all opened files in the history, except the ones that do not exist on disk"
1744            );
1745        });
1746    }
1747
1748    async fn open_close_queried_buffer(
1749        input: &str,
1750        expected_matches: usize,
1751        expected_editor_title: &str,
1752        workspace: &View<Workspace>,
1753        cx: &mut gpui::VisualTestContext<'_>,
1754    ) -> Vec<FoundPath> {
1755        let picker = open_file_picker(&workspace, cx);
1756        cx.simulate_input(input);
1757
1758        let history_items = picker.update(cx, |finder, _| {
1759            assert_eq!(
1760                finder.delegate.matches.len(),
1761                expected_matches,
1762                "Unexpected number of matches found for query {input}"
1763            );
1764            finder.delegate.history_items.clone()
1765        });
1766
1767        cx.dispatch_action(SelectNext);
1768        cx.dispatch_action(Confirm);
1769
1770        cx.read(|cx| {
1771            let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
1772            let active_editor_title = active_editor.read(cx).title(cx);
1773            assert_eq!(
1774                expected_editor_title, active_editor_title,
1775                "Unexpected editor title for query {input}"
1776            );
1777        });
1778
1779        cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
1780
1781        history_items
1782    }
1783
1784    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1785        cx.update(|cx| {
1786            let state = AppState::test(cx);
1787            theme::init(theme::LoadThemes::JustBase, cx);
1788            language::init(cx);
1789            super::init(cx);
1790            editor::init(cx);
1791            workspace::init_settings(cx);
1792            Project::init_settings(cx);
1793            state
1794        })
1795    }
1796
1797    fn test_path_like(test_str: &str) -> PathLikeWithPosition<FileSearchQuery> {
1798        PathLikeWithPosition::parse_str(test_str, |path_like_str| {
1799            Ok::<_, std::convert::Infallible>(FileSearchQuery {
1800                raw_query: test_str.to_owned(),
1801                file_query_end: if path_like_str == test_str {
1802                    None
1803                } else {
1804                    Some(path_like_str.len())
1805                },
1806            })
1807        })
1808        .unwrap()
1809    }
1810
1811    fn build_find_picker(
1812        project: Model<Project>,
1813        cx: &mut TestAppContext,
1814    ) -> (
1815        View<Picker<FileFinderDelegate>>,
1816        View<Workspace>,
1817        &mut VisualTestContext,
1818    ) {
1819        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1820        let picker = open_file_picker(&workspace, cx);
1821        (picker, workspace, cx)
1822    }
1823
1824    #[track_caller]
1825    fn open_file_picker(
1826        workspace: &View<Workspace>,
1827        cx: &mut VisualTestContext,
1828    ) -> View<Picker<FileFinderDelegate>> {
1829        cx.dispatch_action(Toggle);
1830        active_file_picker(workspace, cx)
1831    }
1832
1833    #[track_caller]
1834    fn active_file_picker(
1835        workspace: &View<Workspace>,
1836        cx: &mut VisualTestContext,
1837    ) -> View<Picker<FileFinderDelegate>> {
1838        workspace.update(cx, |workspace, cx| {
1839            workspace
1840                .active_modal::<FileFinder>(cx)
1841                .unwrap()
1842                .read(cx)
1843                .picker
1844                .clone()
1845        })
1846    }
1847}