file_finder.rs

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