file_finder.rs

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